feat: 飞书报告卡片化 + 报告权限系统 + 产出过滤优化
Some checks failed
Build and Deploy Web / build-and-deploy (push) Has been cancelled
Build and Deploy Backend / build-and-deploy (push) Has been cancelled

- 日报/周报/月报改为结构化卡片推送(column_set布局)
- 新增 report:daily/weekly/monthly 权限到角色管理
- 产出统计只算中期制作阶段动画秒数
- 效率之星改为跨项目加权通过率
- AI点评补充风险数据源
- 禁用多余admin账号,股东角色加报告权限

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-02 20:43:35 +08:00
parent ac350e763b
commit 530f02a66a
16 changed files with 892 additions and 252 deletions

Binary file not shown.

View File

@ -167,6 +167,56 @@ def calc_overhead_cost_for_project(project_id: int, db: Session) -> float:
return 0.0
# ──────────────────────────── 管理成本分摊 ────────────────────────────
def calc_management_cost_for_project(project_id: int, db: Session) -> float:
"""
计算某项目分摊的管理成本豁免提交角色的人员日薪
规则
- 只算豁免提交exempt_submission=1角色下有工资的活跃用户
- 按有提交记录的工作日数 × 每人日薪 计算总池
- 总池按各项目产出秒数比例分摊
"""
from models import Role
# 找出豁免角色 ID
exempt_role_ids = set(
r.id for r in db.query(Role).filter(Role.exempt_submission == 1).all()
)
if not exempt_role_ids:
return 0.0
# 豁免角色下有工资的活跃用户
exempt_users = db.query(User).filter(
User.is_active == 1,
User.monthly_salary > 0,
User.role_id.in_(exempt_role_ids),
).all()
if not exempt_users:
return 0.0
# 有提交记录的工作日数(代表公司运营天数)
working_days = db.query(Submission.submit_date).distinct().count()
if working_days == 0:
return 0.0
# 总管理成本池 = 每人日薪 × 工作日数
total_pool = sum(u.daily_cost * working_days for u in exempt_users)
# 按产出秒数比例分摊
all_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.total_seconds > 0
).scalar() or 0
proj_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == project_id,
Submission.total_seconds > 0,
).scalar() or 0
if all_secs > 0:
return round(total_pool * proj_secs / all_secs, 2)
return 0.0
# ──────────────────────────── 工作日计算工具 ────────────────────────────
def _working_days_between(start_date, end_date) -> int:
@ -446,7 +496,8 @@ def calc_project_settlement(project_id: int, db: Session) -> dict:
ai_tool = calc_ai_tool_cost_for_project(project_id, db)
outsource = calc_outsource_cost_for_project(project_id, db)
overhead = calc_overhead_cost_for_project(project_id, db)
total_cost = labor + ai_tool + outsource + overhead
management = calc_management_cost_for_project(project_id, db)
total_cost = labor + ai_tool + outsource + overhead + management
waste = calc_waste_for_project(project_id, db)
efficiency = calc_team_efficiency(project_id, db)
@ -458,6 +509,7 @@ def calc_project_settlement(project_id: int, db: Session) -> dict:
"ai_tool_cost": ai_tool,
"outsource_cost": outsource,
"overhead_cost": overhead,
"management_cost": management,
"total_cost": round(total_cost, 2),
**waste,
"team_efficiency": efficiency,

View File

@ -33,6 +33,11 @@ if _db_url.startswith("sqlite"):
conn.execute(sqlalchemy.text("ALTER TABLE users ADD COLUMN role_id INTEGER"))
conn.commit()
logger.info("[MIGRATE] added column users.role_id")
role_cols = [r[1] for r in conn.execute(sqlalchemy.text("PRAGMA table_info('roles')"))]
if "exempt_submission" not in role_cols:
conn.execute(sqlalchemy.text("ALTER TABLE roles ADD COLUMN exempt_submission INTEGER NOT NULL DEFAULT 0"))
conn.commit()
logger.info("[MIGRATE] added column roles.exempt_submission")
app = FastAPI(title="AirLabs Project", version="1.0.0")
@ -118,6 +123,7 @@ def init_roles_and_admin():
inspector = inspect(engine)
ms_cols = [c['name'] for c in inspector.get_columns('project_milestones')]
sub_cols = [c['name'] for c in inspector.get_columns('submissions')]
role_cols = [c['name'] for c in inspector.get_columns('roles')]
with engine.connect() as conn:
# ProjectMilestone 新字段
@ -139,6 +145,12 @@ def init_roles_and_admin():
conn.commit()
print("[MIGRATE] added episode_number to submissions")
# Role: exempt_submission 字段
if 'exempt_submission' not in role_cols:
conn.execute(text("ALTER TABLE roles ADD COLUMN exempt_submission INT NOT NULL DEFAULT 0"))
conn.commit()
print("[MIGRATE] added exempt_submission to roles")
# MySQL: 扩展 content_type 枚举(使用 Python enum 名称)+ 旧值迁移
from config import DATABASE_URL
if not DATABASE_URL.startswith("sqlite"):

View File

@ -50,6 +50,10 @@ ALL_PERMISSIONS = [
# 结算与效率
("settlement:view", "查看结算报告", "结算与效率"),
("efficiency:view", "查看团队效率", "结算与效率"),
# 报告推送
("report:daily", "触发/查看日报", "报告推送"),
("report:weekly", "触发/查看周报(含成本)", "报告推送"),
("report:monthly", "触发/查看月报(含成本+盈亏)", "报告推送"),
]
PERMISSION_KEYS = [p[0] for p in ALL_PERMISSIONS]
@ -80,6 +84,7 @@ BUILTIN_ROLES = {
"cost_outsource:view", "cost_outsource:create", "cost_outsource:delete",
"user:view",
"efficiency:view",
"report:daily", "report:weekly", "report:monthly",
],
},
"组长": {
@ -89,6 +94,7 @@ BUILTIN_ROLES = {
"submission:view", "submission:create",
"cost_ai:view", "cost_ai:create",
"efficiency:view",
"report:daily",
],
},
"成员": {
@ -194,6 +200,7 @@ class Role(Base):
description = Column(String(200), nullable=True)
permissions = Column(JSON, nullable=False, default=[]) # 权限标识符列表
is_system = Column(Integer, nullable=False, default=0) # 1=内置角色不可删
exempt_submission = Column(Integer, nullable=False, default=0) # 1=豁免提交
created_at = Column(DateTime, server_default=func.now())
users = relationship("User", back_populates="role_ref")

View File

@ -6,14 +6,14 @@ from datetime import date, timedelta
from database import get_db
from models import (
User, Project, Submission, AIToolCost,
ProjectStatus, ProjectType, WorkType
ProjectStatus, ProjectType, WorkType, PhaseGroup
)
from auth import get_current_user, require_permission
from calculations import (
calc_project_settlement, calc_waste_for_project,
calc_labor_cost_for_project, calc_ai_tool_cost_for_project,
calc_outsource_cost_for_project, calc_overhead_cost_for_project,
calc_team_efficiency
calc_management_cost_for_project, calc_team_efficiency
)
router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
@ -56,11 +56,12 @@ def get_dashboard(
AIToolCost.record_date <= today,
).scalar() or 0
# 本月总产出秒数
# 本月总产出秒数(只算中期动画制作)
monthly_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.submit_date >= month_start,
Submission.submit_date <= today,
Submission.total_seconds > 0,
Submission.project_phase == PhaseGroup.PRODUCTION,
).scalar() or 0
# 活跃人数
@ -70,13 +71,18 @@ def get_dashboard(
working_days = max(1, (today - month_start).days + 1)
avg_daily = round(monthly_secs / max(1, active_users) / working_days, 1)
# 各项目摘要
# 各项目摘要(进度只算中期动画制作)
project_summaries = []
for p in active:
waste = calc_waste_for_project(p.id, db)
total_secs = waste.get("total_submitted_seconds", 0)
# 只用中期制作产出算进度,不把后期算进去
production_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id,
Submission.total_seconds > 0,
Submission.project_phase == PhaseGroup.PRODUCTION,
).scalar() or 0
target = p.target_total_seconds
progress = round(total_secs / target * 100, 1) if target > 0 else 0
progress = round(production_secs / target * 100, 1) if target > 0 else 0
is_overdue = (
p.estimated_completion_date and today > p.estimated_completion_date
)
@ -86,7 +92,7 @@ def get_dashboard(
"project_type": p.project_type.value if hasattr(p.project_type, 'value') else p.project_type,
"progress_percent": progress,
"target_seconds": target,
"submitted_seconds": total_secs,
"submitted_seconds": round(production_secs, 1),
"waste_rate": waste.get("waste_rate", 0),
"waste_hours": waste.get("total_waste_hours", 0),
"is_overdue": bool(is_overdue),
@ -127,13 +133,14 @@ def get_dashboard(
"profit_loss": settlement.get("profit_loss"),
})
# ── 图表数据近30天每日产出趋势 ──
# ── 图表数据近30天每日产出趋势(只算中期动画制作) ──
daily_trend = []
for i in range(29, -1, -1):
d = today - timedelta(days=i)
day_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.submit_date == d,
Submission.total_seconds > 0,
Submission.project_phase == PhaseGroup.PRODUCTION,
).scalar() or 0
daily_trend.append({
"date": str(d),
@ -145,25 +152,29 @@ def get_dashboard(
total_ai_all = 0.0
total_outsource_all = 0.0
total_overhead_all = 0.0
total_management_all = 0.0
for p in active + completed + abandoned:
total_labor_all += calc_labor_cost_for_project(p.id, db)
total_ai_all += calc_ai_tool_cost_for_project(p.id, db)
total_outsource_all += calc_outsource_cost_for_project(p.id, db)
total_overhead_all += calc_overhead_cost_for_project(p.id, db)
total_management_all += calc_management_cost_for_project(p.id, db)
cost_breakdown = [
{"name": "人力成本", "value": round(total_labor_all, 0)},
{"name": "AI工具", "value": round(total_ai_all, 0)},
{"name": "外包", "value": round(total_outsource_all, 0)},
{"name": "固定开支", "value": round(total_overhead_all, 0)},
{"name": "管理成本", "value": round(total_management_all, 0)},
]
# ── 图表数据:各项目产出对比(进行中项目 ──
# ── 图表数据:各项目产出对比(进行中项目,只算中期动画制作 ──
project_comparison = []
for p in active:
total_secs_p = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id,
Submission.total_seconds > 0,
Submission.project_phase == PhaseGroup.PRODUCTION,
).scalar() or 0
project_comparison.append({
"name": p.name,
@ -187,7 +198,7 @@ def get_dashboard(
for p in active:
if p.contract_amount:
in_progress_contract += p.contract_amount
in_progress_cost += calc_labor_cost_for_project(p.id, db) + calc_ai_tool_cost_for_project(p.id, db) + calc_outsource_cost_for_project(p.id, db) + calc_overhead_cost_for_project(p.id, db)
in_progress_cost += calc_labor_cost_for_project(p.id, db) + calc_ai_tool_cost_for_project(p.id, db) + calc_outsource_cost_for_project(p.id, db) + calc_overhead_cost_for_project(p.id, db) + calc_management_cost_for_project(p.id, db)
# 每个项目的盈亏(用于柱状图)
profit_by_project = []
@ -212,6 +223,41 @@ def get_dashboard(
from services.report_service import analyze_project_risks
risk_alerts = analyze_project_risks(db)
# ── 今日提交情况(排除豁免角色) ──
from models import Role
exempt_role_ids = set(
r.id for r in db.query(Role).filter(Role.exempt_submission == 1).all()
)
all_active_users = [
u for u in db.query(User).filter(User.is_active == 1).all()
if u.role_id not in exempt_role_ids
]
submitted_user_ids = set(
uid for (uid,) in db.query(Submission.user_id).filter(
Submission.submit_date == today,
).distinct().all()
)
submitted_users = []
not_submitted_users = []
for u in all_active_users:
info = {"id": u.id, "name": u.name}
if u.id in submitted_user_ids:
hours = db.query(sa_func.sum(Submission.hours_spent)).filter(
Submission.user_id == u.id,
Submission.submit_date == today,
).scalar() or 0
info["hours"] = round(hours, 1)
submitted_users.append(info)
else:
not_submitted_users.append(info)
daily_attendance = {
"total": len(all_active_users),
"submitted_count": len(submitted_users),
"not_submitted_count": len(not_submitted_users),
"submitted": submitted_users,
"not_submitted": not_submitted_users,
}
return {
"active_projects": len(active),
"completed_projects": len(completed),
@ -227,6 +273,7 @@ def get_dashboard(
"settled_projects": settled,
"profitability": profitability,
"risk_alerts": risk_alerts,
"daily_attendance": daily_attendance,
# 图表数据
"daily_trend": daily_trend,
"cost_breakdown": cost_breakdown,

View File

@ -1,5 +1,6 @@
"""AI 报告路由 —— 手动触发报告生成与飞书推送"""
from fastapi import APIRouter, Depends
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from database import get_db
from models import User
@ -8,22 +9,38 @@ from auth import require_permission
router = APIRouter(prefix="/api/reports", tags=["AI报告"])
async def _push_card(card: dict, test_mobile: Optional[str] = None) -> dict:
"""推送卡片,支持 test_mobile 只推单人"""
from services.feishu_service import feishu
if test_mobile:
user_id = await feishu.get_user_id_by_mobile(test_mobile)
if not user_id:
return {"success": [], "failed": [{"mobile": test_mobile, "reason": "未找到用户"}]}
ok = await feishu.send_card(user_id, card)
if ok:
return {"success": [test_mobile], "failed": []}
return {"success": [], "failed": [{"mobile": test_mobile, "reason": "发送失败"}]}
return await feishu.send_report_card_to_all(card)
@router.post("/daily")
async def trigger_daily_report(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard:view")),
test_mobile: Optional[str] = Query(None, description="测试手机号,只推这一个人"),
):
"""手动触发日报生成并推送飞书"""
from services.report_service import generate_daily_report
from services.feishu_service import feishu
from services.feishu_service import build_daily_card
report = generate_daily_report(db)
push_result = await feishu.send_report_to_all(report["title"], report["content"])
card = build_daily_card(report["title"], report["card_data"])
push_result = await _push_card(card, test_mobile)
return {
"message": "日报生成并推送完成",
"title": report["title"],
"content": report["content"],
"card_data": report["card_data"],
"push_result": push_result,
}
@ -32,18 +49,20 @@ async def trigger_daily_report(
async def trigger_weekly_report(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard:view")),
test_mobile: Optional[str] = Query(None, description="测试手机号,只推这一个人"),
):
"""手动触发周报生成并推送飞书"""
from services.report_service import generate_weekly_report
from services.feishu_service import feishu
from services.feishu_service import build_weekly_card
report = generate_weekly_report(db)
push_result = await feishu.send_report_to_all(report["title"], report["content"])
card = build_weekly_card(report["title"], report["card_data"])
push_result = await _push_card(card, test_mobile)
return {
"message": "周报生成并推送完成",
"title": report["title"],
"content": report["content"],
"card_data": report["card_data"],
"push_result": push_result,
}
@ -52,18 +71,20 @@ async def trigger_weekly_report(
async def trigger_monthly_report(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard:view")),
test_mobile: Optional[str] = Query(None, description="测试手机号,只推这一个人"),
):
"""手动触发月报生成并推送飞书"""
from services.report_service import generate_monthly_report
from services.feishu_service import feishu
from services.feishu_service import build_monthly_card
report = generate_monthly_report(db)
push_result = await feishu.send_report_to_all(report["title"], report["content"])
card = build_monthly_card(report["title"], report["card_data"])
push_result = await _push_card(card, test_mobile)
return {
"message": "月报生成并推送完成",
"title": report["title"],
"content": report["content"],
"card_data": report["card_data"],
"push_result": push_result,
}
@ -74,26 +95,32 @@ async def preview_report(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard:view")),
):
"""预览报告内容(不推送飞书)"""
"""预览报告内容(不推送飞书),返回结构化数据 + 卡片 JSON"""
from services.report_service import (
generate_daily_report, generate_weekly_report, generate_monthly_report,
)
from services.feishu_service import (
build_daily_card, build_weekly_card, build_monthly_card,
)
generators = {
"daily": generate_daily_report,
"weekly": generate_weekly_report,
"monthly": generate_monthly_report,
"daily": (generate_daily_report, build_daily_card),
"weekly": (generate_weekly_report, build_weekly_card),
"monthly": (generate_monthly_report, build_monthly_card),
}
generator = generators.get(report_type)
if not generator:
entry = generators.get(report_type)
if not entry:
return {"error": f"不支持的报告类型: {report_type},可选: daily, weekly, monthly"}
report = generator(db)
gen_fn, build_fn = entry
report = gen_fn(db)
card = build_fn(report["title"], report["card_data"])
return {
"title": report["title"],
"content": report["content"],
"data": report.get("data"),
"card_data": report["card_data"],
"card_json": card,
}

View File

@ -33,6 +33,7 @@ def list_roles(
"description": r.description,
"permissions": r.permissions or [],
"is_system": bool(r.is_system),
"exempt_submission": bool(r.exempt_submission),
"user_count": db.query(User).filter(User.role_id == r.id).count(),
"created_at": r.created_at,
}
@ -58,6 +59,7 @@ def create_role(
description=req.get("description", ""),
permissions=perms,
is_system=0,
exempt_submission=1 if req.get("exempt_submission") else 0,
)
db.add(role)
db.commit()
@ -90,6 +92,9 @@ def update_role(
if "permissions" in req:
role.permissions = [p for p in req["permissions"] if p in PERMISSION_KEYS]
if "exempt_submission" in req:
role.exempt_submission = 1 if req["exempt_submission"] else 0
db.commit()
return {"message": "角色已更新"}

View File

@ -33,13 +33,14 @@ def generate_report_summary(data_context: str, report_type: str) -> str:
system_prompt = (
"你是 AirLabs 动画团队的项目管理助手。"
"请根据提供的数据,用简洁的中文生成一段项目管理{label}总结。"
"要求:\n"
"1. 语言简练专业,适合管理层阅读\n"
"2. 先总结关键数据,再给出分析和建议\n"
"3. 如果有风险项目,重点提醒\n"
"4. 使用 markdown 格式\n"
"5. 总字数控制在 300 字以内"
"请根据提供的数据,用简洁的中文生成一段{label}点评。\n"
"重要规则:\n"
"1. 不要重复罗列原始数据(项目数、提交人次、未提交人员等已在报告上方展示)\n"
"2. 直接给出分析洞察和可执行建议\n"
"3. 如果有风险项目,重点提醒并给出具体改进方向\n"
"4. 语言简练专业,适合管理层阅读\n"
"5. 不要使用标题(如 ## 核心数据概览),直接写正文\n"
"6. 总字数控制在 200 字以内"
).format(label=label)
try:

View File

@ -10,6 +10,275 @@ logger = logging.getLogger(__name__)
FEISHU_BASE = "https://open.feishu.cn/open-apis"
# ──────────────────────── 卡片构建工具 ────────────────────────
def _col(weight: int, content: str) -> dict:
"""快捷构建一个 column"""
return {
"tag": "column",
"width": "weighted",
"weight": weight,
"vertical_align": "top",
"elements": [{"tag": "markdown", "content": content}],
}
def _column_set(columns: list, bg: str = "grey") -> dict:
"""构建 column_set 多列布局"""
return {
"tag": "column_set",
"flex_mode": "none",
"background_style": bg,
"columns": columns,
}
def _hr() -> dict:
return {"tag": "hr"}
def _md(content: str) -> dict:
return {"tag": "markdown", "content": content}
def _progress_bar(pct: float) -> str:
"""用 unicode 方块生成进度条文本"""
filled = min(int(pct / 10), 10)
empty = 10 - filled
bar = "" * filled + "" * empty
return bar
def build_daily_card(title: str, data: dict) -> dict:
"""从结构化数据构建日报卡片"""
is_weekend = data.get("is_weekend", False)
has_risks = bool(data.get("risks"))
elements = []
# ── 顶部 KPI 指标栏3列灰底 ──
if is_weekend:
elements.append(_column_set([
_col(1, f"**进行中项目**\n{data['active_project_count']}"),
_col(1, f"**周末加班**\n{data['submitter_count']} 人提交"),
_col(1, f"**加班产出**\n{data['total_output']}"),
]))
else:
elements.append(_column_set([
_col(1, f"**进行中项目**\n{data['active_project_count']}"),
_col(1, f"**今日提交**\n{data['submitter_count']} 人次"),
_col(1, f"**总产出**\n{data['total_output']}"),
]))
# ── 各项目进展(表格式对齐) ──
elements.append(_hr())
projects = data.get("projects", [])
if projects:
# 表头
elements.append(_column_set([
_col(3, "**项目名称**"),
_col(2, "**进度**"),
_col(1, "**今日产出**"),
], bg="default"))
# 每个项目一行
for p in projects:
pct = p['progress']
bar = _progress_bar(pct)
elements.append(_column_set([
_col(3, f"{p['name']}"),
_col(2, f"{bar} {pct}%"),
_col(1, f"{p['today_output']}"),
], bg="default"))
else:
elements.append(_md("暂无进行中项目"))
# ── 风险预警(始终显示) ──
elements.append(_hr())
risks = data.get("risks", [])
if risks:
level_icon = {"high": "🔴", "medium": "🟡", "low": "🟢"}
risk_lines = "\n".join(
f"{level_icon.get(r.get('level','medium'), '⚠️')} **{r['name']}**{r['detail']}"
for r in risks
)
elements.append(_md(f"**🚨 风险预警({len(risks)}项)**\n{risk_lines}"))
else:
elements.append(_md("**✅ 风险预警**\n当前无风险项目"))
# ── 未提交人员(仅工作日显示) ──
not_submitted = data.get("not_submitted", [])
if not is_weekend and not_submitted:
elements.append(_hr())
names = "".join(not_submitted)
elements.append(_md(f"**📝 未提交({len(not_submitted)}人)**\n{names}"))
elif not is_weekend:
elements.append(_hr())
elements.append(_md("**✅ 提交情况**\n今日全员已提交"))
# ── AI 点评 ──
ai_summary = data.get("ai_summary")
if ai_summary:
elements.append(_hr())
elements.append(_md(f"**💡 AI 点评**\n{ai_summary}"))
return {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "orange" if has_risks else "blue",
},
"elements": elements,
}
def build_weekly_card(title: str, data: dict) -> dict:
"""从结构化数据构建周报卡片"""
elements = []
# ── 顶部 KPI3列灰底 ──
top_cols = [
_col(1, f"**总产出**\n{data['total_output']}"),
_col(1, f"**人均日产出**\n{data['avg_daily_output']}"),
]
if data.get("top_producer"):
top_cols.append(_col(1, f"**效率之星**\n{data['top_producer']}"))
elements.append(_column_set(top_cols))
# ── 项目进展(表格式) ──
elements.append(_hr())
projects = data.get("projects", [])
if projects:
elements.append(_column_set([
_col(3, "**项目名称**"),
_col(2, "**进度**"),
_col(1, "**本周产出**"),
], bg="default"))
for p in projects:
pct = p['progress']
bar = _progress_bar(pct)
elements.append(_column_set([
_col(3, f"{p['name']}"),
_col(2, f"{bar} {pct}%"),
_col(1, f"{p['week_output']}"),
], bg="default"))
else:
elements.append(_md("暂无进行中项目"))
# ── 成本概览2列灰底 ──
elements.append(_hr())
elements.append(_column_set([
_col(1, f"**人力成本**\n{data['labor_cost']}"),
_col(1, f"**AI 工具**\n{data['ai_tool_cost']}"),
]))
# ── 损耗排行 ──
waste_ranking = data.get("waste_ranking", [])
if waste_ranking:
elements.append(_hr())
elements.append(_column_set([
_col(3, "**项目**"),
_col(2, "**损耗率**"),
], bg="default"))
for i, w in enumerate(waste_ranking):
elements.append(_column_set([
_col(3, f"{i+1}. {w['name']}"),
_col(2, f"{w['rate']}%"),
], bg="default"))
# ── AI 分析 ──
ai_summary = data.get("ai_summary")
if ai_summary:
elements.append(_hr())
elements.append(_md(f"**💡 AI 分析与建议**\n{ai_summary}"))
return {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "blue",
},
"elements": elements,
}
def build_monthly_card(title: str, data: dict) -> dict:
"""从结构化数据构建月报卡片"""
elements = []
# ── 顶部 KPI4列灰底 ──
elements.append(_column_set([
_col(1, f"**进行中**\n{data['active_count']}"),
_col(1, f"**已完成**\n{data['completed_count']}"),
_col(1, f"**总产出**\n{data['total_output']}"),
_col(1, f"**总成本**\n{data['total_cost']}"),
]))
# ── 各项目成本明细(表格式) ──
project_costs = data.get("project_costs", [])
if project_costs:
elements.append(_hr())
elements.append(_column_set([
_col(2, "**项目**"),
_col(1, "**人力**"),
_col(1, "**AI**"),
_col(1, "**外包**"),
_col(1, "**合计**"),
], bg="default"))
for pc in project_costs:
elements.append(_column_set([
_col(2, f"{pc['name']}"),
_col(1, f"{pc['labor']}"),
_col(1, f"{pc['ai_tool']}"),
_col(1, f"{pc['outsource']}"),
_col(1, f"**{pc['total']}**"),
], bg="default"))
# ── 盈亏概览 ──
profit_items = data.get("profit_items", [])
if profit_items:
elements.append(_hr())
elements.append(_column_set([
_col(2, "**项目**"),
_col(1, "**回款**"),
_col(1, "**成本**"),
_col(1, "**利润**"),
], bg="default"))
for pi in profit_items:
sign = "+" if pi['is_positive'] else ""
elements.append(_column_set([
_col(2, f"{pi['name']}"),
_col(1, f"{pi['contract']}"),
_col(1, f"{pi['cost']}"),
_col(1, f"{sign}{pi['profit']}"),
], bg="default"))
if data.get("profit_rate") is not None:
elements.append(_md(f"总利润率:**{data['profit_rate']}%**"))
# ── 损耗 + 人均2列灰底 ──
elements.append(_hr())
elements.append(_column_set([
_col(1, f"**总损耗**\n{data['waste_total']}{data['waste_rate']}%"),
_col(1, f"**人均产出**\n{data['avg_per_person']}{data['participant_count']}人)"),
]))
# ── AI 深度分析 ──
ai_summary = data.get("ai_summary")
if ai_summary:
elements.append(_hr())
elements.append(_md(f"**💡 AI 深度分析**\n{ai_summary}"))
return {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "blue",
},
"elements": elements,
}
# ──────────────────────── 飞书 API 服务 ────────────────────────
class FeishuService:
def __init__(self):
self.app_id = FEISHU_APP_ID
@ -77,22 +346,12 @@ class FeishuService:
logger.warning(f"未找到手机号 {mobile} 对应的飞书用户")
return ""
async def send_card_message(self, user_id: str, title: str, content: str):
"""发送飞书交互式卡片消息给个人"""
async def send_card(self, user_id: str, card: dict) -> bool:
"""发送构建好的卡片 JSON 给个人"""
token = await self._get_tenant_token()
if not token:
return False
card = {
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "blue",
},
"elements": [
{"tag": "markdown", "content": content},
],
}
payload = {
"receive_id": user_id,
"msg_type": "interactive",
@ -115,11 +374,44 @@ class FeishuService:
logger.info(f"飞书消息发送成功: {user_id}")
return True
async def send_card_message(self, user_id: str, title: str, content: str):
"""兼容旧接口:发送简单 title+content 卡片"""
card = {
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "blue",
},
"elements": [
{"tag": "markdown", "content": content},
],
}
return await self.send_card(user_id, card)
async def send_report_card_to_all(self, card: dict) -> dict:
"""给所有配置的接收人发送卡片报告"""
results = {"success": [], "failed": []}
if not REPORT_RECEIVERS:
logger.warning("未配置报告接收人")
return results
for mobile in REPORT_RECEIVERS:
user_id = await self.get_user_id_by_mobile(mobile)
if not user_id:
results["failed"].append({"mobile": mobile, "reason": "未找到用户"})
continue
ok = await self.send_card(user_id, card)
if ok:
results["success"].append(mobile)
else:
results["failed"].append({"mobile": mobile, "reason": "发送失败"})
logger.info(f"报告推送完成: 成功 {len(results['success'])},失败 {len(results['failed'])}")
return results
async def send_report_to_all(self, title: str, content: str) -> dict:
"""
给所有配置的接收人发送报告
返回 {"success": [...], "failed": [...]}
"""
"""兼容旧接口"""
results = {"success": [], "failed": []}
if not REPORT_RECEIVERS:

View File

@ -5,8 +5,8 @@ from sqlalchemy.orm import Session
from sqlalchemy import func as sa_func
from models import (
User, Project, Submission, AIToolCost,
ProjectStatus, WorkType,
User, Project, Submission, AIToolCost, Role,
ProjectStatus, WorkType, PhaseGroup,
)
from calculations import (
calc_waste_for_project,
@ -23,11 +23,14 @@ logger = logging.getLogger(__name__)
def _fmt_seconds(secs: float) -> str:
"""秒数格式化为 Xm Xs"""
"""秒数格式化为 Xh Xm 或 Xm Xs"""
if secs <= 0:
return "0s"
m = int(secs) // 60
h = int(secs) // 3600
m = (int(secs) % 3600) // 60
s = int(secs) % 60
if h > 0:
return f"{h}h {m}m" if m > 0 else f"{h}h"
if m > 0:
return f"{m}m {s}s" if s > 0 else f"{m}m"
return f"{s}s"
@ -40,14 +43,21 @@ def _fmt_money(amount: float) -> str:
return f"¥{amount:,.0f}"
def _is_weekend(d: date) -> bool:
"""判断是否为周末(周六=5, 周日=6"""
return d.weekday() >= 5
# ──────────────────────────── 日报 ────────────────────────────
def generate_daily_report(db: Session) -> dict:
"""
生成日报
返回 {"title": str, "content": str, "data": dict}
生成日报返回结构化数据供飞书卡片使用
"""
today = date.today()
is_weekend = _is_weekend(today)
weekday_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
weekday_label = weekday_names[today.weekday()]
# 今日提交
today_subs = db.query(Submission).filter(
@ -55,17 +65,25 @@ def generate_daily_report(db: Session) -> dict:
).all()
today_submitter_ids = set(s.user_id for s in today_subs)
today_total_secs = sum(s.total_seconds for s in today_subs if s.total_seconds > 0)
# 产出只算中期制作阶段的动画秒数
today_total_secs = sum(
s.total_seconds for s in today_subs
if s.total_seconds > 0 and s.project_phase == PhaseGroup.PRODUCTION
)
# 所有活跃用户(有提交记录的)
# 未提交人员(仅工作日统计,且排除豁免角色)
not_submitted = []
if not is_weekend:
exempt_role_ids = set(
r.id for r in db.query(Role).filter(Role.exempt_submission == 1).all()
)
all_active_user_ids = set(
uid for (uid,) in db.query(Submission.user_id).distinct().all()
)
not_submitted = []
for uid in all_active_user_ids:
if uid not in today_submitter_ids:
user = db.query(User).filter(User.id == uid).first()
if user and user.is_active:
if user and user.is_active and user.role_id not in exempt_role_ids:
not_submitted.append(user.name)
# 进行中项目
@ -73,101 +91,105 @@ def generate_daily_report(db: Session) -> dict:
Project.status == ProjectStatus.IN_PROGRESS
).all()
project_lines = []
risk_lines = []
projects_data = []
for p in active_projects:
waste = calc_waste_for_project(p.id, db)
total_secs = waste.get("total_submitted_seconds", 0)
target = p.target_total_seconds
progress = round(total_secs / target * 100, 1) if target > 0 else 0
# 今日该项目产出
proj_today_secs = sum(
s.total_seconds for s in today_subs
if s.project_id == p.id and s.total_seconds > 0
and s.project_phase == PhaseGroup.PRODUCTION
)
project_lines.append(
f"- **{p.name}**:进度 {progress}%,今日产出 {_fmt_seconds(proj_today_secs)}"
)
projects_data.append({
"name": p.name,
"progress": progress,
"today_output": _fmt_seconds(proj_today_secs),
})
# 风险检测
if p.estimated_completion_date:
days_left = (p.estimated_completion_date - today).days
if days_left < 0:
risk_lines.append(f"- **{p.name}**:已超期 {-days_left} 天,进度 {progress}%")
elif days_left <= 7 and progress < 80:
risk_lines.append(f"- **{p.name}**:距截止仅剩 {days_left} 天,进度仅 {progress}%")
# 风险检测(使用完整风险引擎)
full_risks = analyze_project_risks(db)
risks_data = [
{
"name": r["project_name"],
"level": r["risk_level"],
"detail": "".join(r["risk_factors"]),
}
for r in full_risks
]
# AI 数据上下文
project_lines = [
f"- {p['name']}:进度 {p['progress']}%,今日产出 {p['today_output']}"
for p in projects_data
]
risk_lines = [f"- {r['name']}{r['detail']}" for r in risks_data]
# 组装数据上下文(供 AI 使用)
data_context = (
f"日期:{today}\n"
f"日期:{today}{weekday_label}{',周末' if is_weekend else ''}\n"
f"进行中项目:{len(active_projects)}\n"
f"今日提交人次:{len(today_submitter_ids)}\n"
f"今日总产出:{_fmt_seconds(today_total_secs)}\n"
f"今日未提交人员:{', '.join(not_submitted) if not_submitted else ''}\n"
)
if not is_weekend:
data_context += f"今日未提交人员:{', '.join(not_submitted) if not_submitted else ''}\n"
else:
data_context += "今天是周末,不统计未提交\n"
data_context += (
f"各项目情况:\n" + "\n".join(project_lines) + "\n"
f"风险项目:\n" + ("\n".join(risk_lines) if risk_lines else "")
)
# 调用 AI 生成摘要
ai_summary = generate_report_summary(data_context, "daily")
# 组装飞书 markdown 内容
title = f"AirLabs 日报 — {today}"
lines = [
f"**【今日概览】**",
f"- 进行中项目:{len(active_projects)}",
f"- 今日提交:{len(today_submitter_ids)} 人次,总产出 {_fmt_seconds(today_total_secs)}",
]
if not_submitted:
lines.append(f"- 今日未提交:{', '.join(not_submitted)}")
title = f"AirLabs 日报 — {today}{weekday_label}"
lines.append("")
lines.append("**【各项目进展】**")
lines.extend(project_lines if project_lines else ["- 暂无进行中项目"])
if risk_lines:
lines.append("")
lines.append("**【风险提醒】**")
lines.extend(risk_lines)
if ai_summary:
lines.append("")
lines.append("**【AI 点评】**")
lines.append(ai_summary)
content = "\n".join(lines)
return {"title": title, "content": content, "data": {"date": str(today)}}
return {
"title": title,
"report_type": "daily",
"card_data": {
"is_weekend": is_weekend,
"weekday_label": weekday_label,
"active_project_count": len(active_projects),
"submitter_count": len(today_submitter_ids),
"total_output": _fmt_seconds(today_total_secs),
"not_submitted": not_submitted,
"projects": projects_data,
"risks": risks_data,
"ai_summary": ai_summary,
},
}
# ──────────────────────────── 周报 ────────────────────────────
def generate_weekly_report(db: Session) -> dict:
"""生成周报(本周一到当天的数据)"""
"""生成周报(本周一到当天的数据),返回结构化数据"""
today = date.today()
# 本周一
monday = today - timedelta(days=today.weekday())
# 本周提交
week_subs = db.query(Submission).filter(
Submission.submit_date >= monday,
Submission.submit_date <= today,
).all()
week_submitter_ids = set(s.user_id for s in week_subs)
week_total_secs = sum(s.total_seconds for s in week_subs if s.total_seconds > 0)
# 产出只算中期制作阶段的动画秒数
week_total_secs = sum(
s.total_seconds for s in week_subs
if s.total_seconds > 0 and s.project_phase == PhaseGroup.PRODUCTION
)
working_days = min((today - monday).days + 1, 5)
avg_daily = round(week_total_secs / max(1, len(week_submitter_ids)) / max(1, working_days), 1)
# 进行中项目
active_projects = db.query(Project).filter(
Project.status == ProjectStatus.IN_PROGRESS
).all()
# 各项目周报数据
project_lines = []
projects_data = []
for p in active_projects:
waste = calc_waste_for_project(p.id, db)
total_secs = waste.get("total_submitted_seconds", 0)
@ -177,12 +199,15 @@ def generate_weekly_report(db: Session) -> dict:
proj_week_secs = sum(
s.total_seconds for s in week_subs
if s.project_id == p.id and s.total_seconds > 0
and s.project_phase == PhaseGroup.PRODUCTION
)
project_lines.append(
f"- **{p.name}**:当前进度 {progress}%,本周产出 {_fmt_seconds(proj_week_secs)}"
)
projects_data.append({
"name": p.name,
"progress": progress,
"week_output": _fmt_seconds(proj_week_secs),
})
# 本周成本(简化:统计提交人的日成本)
# 本周成本
week_labor = 0.0
processed = set()
for s in week_subs:
@ -203,27 +228,48 @@ def generate_weekly_report(db: Session) -> dict:
for p in active_projects:
w = calc_waste_for_project(p.id, db)
if w.get("total_waste_seconds", 0) > 0:
waste_ranking.append({
"name": p.name,
"rate": w["waste_rate"],
})
waste_ranking.append({"name": p.name, "rate": w["waste_rate"]})
waste_ranking.sort(key=lambda x: x["rate"], reverse=True)
# 效率排行(找产出最高的人)
user_week_secs = {}
# 效率之星 —— 跨项目加权通过率(有效产出 / 总产出,扣除返工)
from collections import defaultdict
user_prod_secs = defaultdict(float) # 制作秒数
user_rev_secs = defaultdict(float) # 返工秒数
for s in week_subs:
if s.total_seconds > 0:
user_week_secs[s.user_id] = user_week_secs.get(s.user_id, 0) + s.total_seconds
if s.total_seconds > 0 and s.project_phase == PhaseGroup.PRODUCTION:
if s.work_type == WorkType.PRODUCTION:
user_prod_secs[s.user_id] += s.total_seconds
elif s.work_type == WorkType.REVISION:
user_rev_secs[s.user_id] += s.total_seconds
top_producer = None
if user_week_secs:
top_uid = max(user_week_secs, key=user_week_secs.get)
top_user = db.query(User).filter(User.id == top_uid).first()
best_rate = -1
best_uid = None
for uid, prod in user_prod_secs.items():
if prod < 60: # 至少 1 分钟制作产出才参与排名
continue
rev = user_rev_secs.get(uid, 0)
rate = (prod - rev) / prod if prod > 0 else 0
if rate > best_rate:
best_rate = rate
best_uid = uid
if best_uid is not None and best_rate >= 0:
top_user = db.query(User).filter(User.id == best_uid).first()
if top_user:
top_daily = round(user_week_secs[top_uid] / max(1, working_days), 1)
top_producer = f"{top_user.name}(日均 {_fmt_seconds(top_daily)}"
top_producer = f"{top_user.name}(通过率 {round(best_rate * 100, 1)}%"
# AI 数据上下文
project_lines = [
f"- {p['name']}:当前进度 {p['progress']}%,本周产出 {p['week_output']}"
for p in projects_data
]
# 风险检测
full_risks = analyze_project_risks(db)
risk_lines = [
f"- {r['project_name']}{''.join(r['risk_factors'])}"
for r in full_risks
]
data_context = (
f"周期:{monday} ~ {today}\n"
f"进行中项目:{len(active_projects)}\n"
@ -233,74 +279,62 @@ def generate_weekly_report(db: Session) -> dict:
f"本周人力成本:{_fmt_money(week_labor)}\n"
f"本周AI工具成本{_fmt_money(week_ai_cost)}\n"
f"各项目:\n" + "\n".join(project_lines) + "\n"
f"损耗排行:\n" + "\n".join(
f"损耗排行:\n" + ("\n".join(
f"- {w['name']}{w['rate']}%" for w in waste_ranking[:5]
) if waste_ranking else "损耗排行:无"
) if waste_ranking else "") + "\n"
f"风险项目:\n" + ("\n".join(risk_lines) if risk_lines else "")
)
ai_summary = generate_report_summary(data_context, "weekly")
# 组装内容
title = f"AirLabs 周报 — 第{today.isocalendar()[1]}周({monday} ~ {today}"
lines = [
"**【项目进展】**",
]
lines.extend(project_lines if project_lines else ["- 暂无进行中项目"])
week_num = today.isocalendar()[1]
title = f"AirLabs 周报 — 第{week_num}周({monday} ~ {today}"
lines.append("")
lines.append("**【团队产出】**")
lines.append(f"- 本周总产出:{_fmt_seconds(week_total_secs)}")
lines.append(f"- 人均日产出:{_fmt_seconds(avg_daily)}")
if top_producer:
lines.append(f"- 效率最高:{top_producer}")
lines.append("")
lines.append("**【成本概览】**")
lines.append(f"- 本周人力成本:{_fmt_money(week_labor)}")
lines.append(f"- 本周 AI 工具支出:{_fmt_money(week_ai_cost)}")
if waste_ranking:
lines.append("")
lines.append("**【损耗排行】**")
for w in waste_ranking[:5]:
lines.append(f"- {w['name']}:损耗率 {w['rate']}%")
if ai_summary:
lines.append("")
lines.append("**【AI 分析与建议】**")
lines.append(ai_summary)
content = "\n".join(lines)
return {"title": title, "content": content, "data": {"week_start": str(monday), "week_end": str(today)}}
return {
"title": title,
"report_type": "weekly",
"card_data": {
"week_range": f"{monday} ~ {today}",
"week_num": week_num,
"total_output": _fmt_seconds(week_total_secs),
"avg_daily_output": _fmt_seconds(avg_daily),
"top_producer": top_producer,
"projects": projects_data,
"labor_cost": _fmt_money(week_labor),
"ai_tool_cost": _fmt_money(week_ai_cost),
"waste_ranking": waste_ranking[:5],
"ai_summary": ai_summary,
},
}
# ──────────────────────────── 月报 ────────────────────────────
def generate_monthly_report(db: Session) -> dict:
"""生成月报(上月完整数据在每月1号调用"""
"""生成月报(上月完整数据),返回结构化数据"""
today = date.today()
# 上月日期范围
first_of_this_month = today.replace(day=1)
last_of_prev_month = first_of_this_month - timedelta(days=1)
first_of_prev_month = last_of_prev_month.replace(day=1)
month_label = f"{last_of_prev_month.year}{last_of_prev_month.month}"
# 上月提交
month_subs = db.query(Submission).filter(
Submission.submit_date >= first_of_prev_month,
Submission.submit_date <= last_of_prev_month,
).all()
month_total_secs = sum(s.total_seconds for s in month_subs if s.total_seconds > 0)
# 产出只算中期制作阶段的动画秒数
month_total_secs = sum(
s.total_seconds for s in month_subs
if s.total_seconds > 0 and s.project_phase == PhaseGroup.PRODUCTION
)
month_submitters = set(s.user_id for s in month_subs)
# 所有项目(含进行中和上月完成的)
all_projects = db.query(Project).filter(
Project.status.in_([ProjectStatus.IN_PROGRESS, ProjectStatus.COMPLETED])
).all()
# 上月完成的项目
completed_this_month = [
p for p in all_projects
if p.status == ProjectStatus.COMPLETED
@ -311,7 +345,7 @@ def generate_monthly_report(db: Session) -> dict:
active_projects = [p for p in all_projects if p.status == ProjectStatus.IN_PROGRESS]
# 各项目成本
project_cost_lines = []
project_costs = []
total_all_cost = 0.0
for p in active_projects + completed_this_month:
labor = calc_labor_cost_for_project(p.id, db)
@ -320,13 +354,17 @@ def generate_monthly_report(db: Session) -> dict:
overhead = calc_overhead_cost_for_project(p.id, db)
total = labor + ai_tool + outsource + overhead
total_all_cost += total
project_cost_lines.append(
f"- **{p.name}**:人力 {_fmt_money(labor)} / AI工具 {_fmt_money(ai_tool)} / "
f"外包 {_fmt_money(outsource)} / 固定 {_fmt_money(overhead)} → 总计 {_fmt_money(total)}"
)
project_costs.append({
"name": p.name,
"labor": _fmt_money(labor),
"ai_tool": _fmt_money(ai_tool),
"outsource": _fmt_money(outsource),
"overhead": _fmt_money(overhead),
"total": _fmt_money(total),
})
# 盈亏概览(已结算的客户正式项目)
profit_lines = []
# 盈亏
profit_items = []
total_profit = 0.0
total_contract = 0.0
for p in completed_this_month:
@ -335,11 +373,15 @@ def generate_monthly_report(db: Session) -> dict:
pl = settlement.get("profit_loss", 0)
total_profit += pl
total_contract += settlement["contract_amount"]
sign = "+" if pl >= 0 else ""
profit_lines.append(
f"- **{p.name}**:回款 {_fmt_money(settlement['contract_amount'])}"
f"成本 {_fmt_money(settlement['total_cost'])},利润 {sign}{_fmt_money(pl)}"
)
profit_items.append({
"name": p.name,
"contract": _fmt_money(settlement["contract_amount"]),
"cost": _fmt_money(settlement["total_cost"]),
"profit": _fmt_money(pl),
"is_positive": pl >= 0,
})
profit_rate = round(total_profit / total_contract * 100, 1) if total_contract > 0 else None
# 损耗汇总
total_waste_secs = 0.0
@ -350,11 +392,25 @@ def generate_monthly_report(db: Session) -> dict:
total_target_secs += p.target_total_seconds or 0
waste_rate = round(total_waste_secs / total_target_secs * 100, 1) if total_target_secs > 0 else 0
# 人均产出
working_days_month = 22
avg_per_person = round(month_total_secs / max(1, len(month_submitters)), 1)
# AI 数据上下文
project_cost_lines = [
f"- {pc['name']}:人力 {pc['labor']} / AI工具 {pc['ai_tool']} / "
f"外包 {pc['outsource']} / 固定 {pc['overhead']} → 总计 {pc['total']}"
for pc in project_costs
]
profit_lines = [
f"- {pi['name']}:回款 {pi['contract']},成本 {pi['cost']},利润 {'+'if pi['is_positive'] else ''}{pi['profit']}"
for pi in profit_items
]
# 风险检测
monthly_risks = analyze_project_risks(db)
monthly_risk_lines = [
f"- {r['project_name']}{''.join(r['risk_factors'])}"
for r in monthly_risks
]
data_context = (
f"月份:{month_label}\n"
f"进行中项目:{len(active_projects)}\n"
@ -365,53 +421,32 @@ def generate_monthly_report(db: Session) -> dict:
f"参与人数:{len(month_submitters)}\n"
f"人均产出:{_fmt_seconds(avg_per_person)}\n"
f"各项目成本:\n" + "\n".join(project_cost_lines) + "\n"
f"盈亏:\n" + ("\n".join(profit_lines) if profit_lines else "本月无结算项目")
f"盈亏:\n" + ("\n".join(profit_lines) if profit_lines else "本月无结算项目") + "\n"
f"风险项目:\n" + ("\n".join(monthly_risk_lines) if monthly_risk_lines else "")
)
ai_summary = generate_report_summary(data_context, "monthly")
# 组装内容
title = f"AirLabs 月报 — {month_label}"
lines = [
"**【月度总览】**",
f"- 进行中项目:{len(active_projects)}",
f"- 本月完成项目:{len(completed_this_month)}",
f"- 月度总产出:{_fmt_seconds(month_total_secs)}",
f"- 月度总成本:{_fmt_money(total_all_cost)}",
]
if project_cost_lines:
lines.append("")
lines.append("**【各项目成本明细】**")
lines.extend(project_cost_lines)
if profit_lines:
lines.append("")
lines.append("**【盈亏概览】**")
lines.extend(profit_lines)
if total_contract > 0:
profit_rate = round(total_profit / total_contract * 100, 1)
lines.append(f"- 总利润率:{profit_rate}%")
lines.append("")
lines.append("**【月度损耗】**")
lines.append(f"- 总损耗:{_fmt_seconds(total_waste_secs)}(损耗率 {waste_rate}%")
lines.append("")
lines.append("**【人均产出】**")
lines.append(f"- 参与人数:{len(month_submitters)}")
lines.append(f"- 月度人均产出:{_fmt_seconds(avg_per_person)}")
if ai_summary:
lines.append("")
lines.append("**【AI 深度分析】**")
lines.append(ai_summary)
content = "\n".join(lines)
return {
"title": title,
"content": content,
"data": {"month": month_label, "start": str(first_of_prev_month), "end": str(last_of_prev_month)},
"report_type": "monthly",
"card_data": {
"month_label": month_label,
"active_count": len(active_projects),
"completed_count": len(completed_this_month),
"total_output": _fmt_seconds(month_total_secs),
"total_cost": _fmt_money(total_all_cost),
"project_costs": project_costs,
"profit_items": profit_items,
"profit_rate": profit_rate,
"waste_total": _fmt_seconds(total_waste_secs),
"waste_rate": waste_rate,
"participant_count": len(month_submitters),
"avg_per_person": _fmt_seconds(avg_per_person),
"ai_summary": ai_summary,
},
}
@ -430,9 +465,14 @@ def analyze_project_risks(db: Session) -> list:
risks = []
for p in active_projects:
waste = calc_waste_for_project(p.id, db)
total_secs = waste.get("total_submitted_seconds", 0)
# 进度只算中期制作产出,不把后期算进去
production_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id,
Submission.total_seconds > 0,
Submission.project_phase == PhaseGroup.PRODUCTION,
).scalar() or 0
target = p.target_total_seconds
progress = round(total_secs / target * 100, 1) if target > 0 else 0
progress = round(production_secs / target * 100, 1) if target > 0 else 0
risk_factors = []
risk_level = "low"
@ -452,7 +492,6 @@ def analyze_project_risks(db: Session) -> list:
# 进度落后于时间线
if p.estimated_completion_date and days_left > 0:
# 估算已用天数
if hasattr(p, 'created_at') and p.created_at:
created = p.created_at.date() if hasattr(p.created_at, 'date') else p.created_at
total_days = (p.estimated_completion_date - created).days
@ -487,7 +526,7 @@ def analyze_project_risks(db: Session) -> list:
Submission.project_id == p.id,
Submission.submit_date >= week_ago,
).count()
if recent_subs == 0 and total_secs > 0:
if recent_subs == 0 and production_secs > 0:
risk_factors.append("近 7 天无提交,产出停滞")
if risk_level != "high":
risk_level = "medium"

View File

@ -14,23 +14,28 @@ async def _run_report_job(report_type: str):
from services.report_service import (
generate_daily_report, generate_weekly_report, generate_monthly_report,
)
from services.feishu_service import feishu
from services.feishu_service import (
feishu, build_daily_card, build_weekly_card, build_monthly_card,
)
logger.info(f"[定时任务] 开始生成{report_type}...")
db = SessionLocal()
try:
if report_type == "日报":
result = generate_daily_report(db)
card = build_daily_card(result["title"], result["card_data"])
elif report_type == "周报":
result = generate_weekly_report(db)
card = build_weekly_card(result["title"], result["card_data"])
elif report_type == "月报":
result = generate_monthly_report(db)
card = build_monthly_card(result["title"], result["card_data"])
else:
logger.error(f"未知报告类型: {report_type}")
return
logger.info(f"[定时任务] {report_type}生成完成,开始推送飞书...")
push_result = await feishu.send_report_to_all(result["title"], result["content"])
push_result = await feishu.send_report_card_to_all(card)
logger.info(f"[定时任务] {report_type}推送完成: {push_result}")
except Exception as e:
@ -52,7 +57,12 @@ async def monthly_report_job():
def setup_scheduler():
"""配置并启动定时任务"""
"""配置并启动定时任务(通过 DISABLE_SCHEDULER=1 可关闭)"""
import os
if os.environ.get("DISABLE_SCHEDULER") == "1":
logger.info("[定时任务] 已通过 DISABLE_SCHEDULER=1 禁用")
return
# 日报:每天 20:00
scheduler.add_job(
daily_report_job, "cron",

View File

@ -1,8 +1,11 @@
<template>
<el-container class="layout">
<!-- 手机端遮罩 -->
<div v-if="isMobile && mobileMenuOpen" class="menu-overlay" @click="mobileMenuOpen = false"></div>
<!-- 侧边栏 -->
<el-aside :width="isCollapsed ? '64px' : '240px'" class="aside">
<div class="logo" @click="isCollapsed = !isCollapsed">
<el-aside :width="isMobile ? '240px' : (isCollapsed ? '64px' : '240px')" class="aside" :class="{ 'mobile-open': isMobile && mobileMenuOpen, 'mobile-hidden': isMobile && !mobileMenuOpen }">
<div class="logo" @click="isMobile ? (mobileMenuOpen = false) : (isCollapsed = !isCollapsed)">
<div class="logo-icon">A</div>
<transition name="fade">
<div v-show="!isCollapsed" class="logo-info">
@ -20,9 +23,10 @@
class="nav-item"
:class="{ active: isActive(item.path) }"
v-show="!item.perm || (Array.isArray(item.perm) ? item.perm.some(p => authStore.hasPermission(p)) : authStore.hasPermission(item.perm))"
@click="isMobile && (mobileMenuOpen = false)"
>
<el-icon :size="18"><component :is="item.icon" /></el-icon>
<span v-show="!isCollapsed" class="nav-label">{{ item.label }}</span>
<span v-show="isMobile || !isCollapsed" class="nav-label">{{ item.label }}</span>
</router-link>
</nav>
@ -47,6 +51,9 @@
<el-container class="main-container">
<el-header class="header">
<div class="header-left">
<el-button v-if="isMobile" text class="hamburger-btn" @click="mobileMenuOpen = !mobileMenuOpen">
<el-icon :size="20"><Fold v-if="mobileMenuOpen" /><Expand v-else /></el-icon>
</el-button>
<h3 class="page-route-title">{{ currentTitle }}</h3>
</div>
<div class="header-right">
@ -83,7 +90,7 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { userApi } from '../api'
@ -93,6 +100,10 @@ const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const isCollapsed = ref(false)
const isMobile = ref(window.innerWidth <= 768)
const mobileMenuOpen = ref(false)
function onResize() { isMobile.value = window.innerWidth <= 768; if (!isMobile.value) mobileMenuOpen.value = false }
const menuItems = [
{ path: '/dashboard', label: '仪表盘', icon: 'Odometer', perm: 'dashboard:view' },
@ -123,11 +134,16 @@ function isActive(path) {
}
onMounted(async () => {
window.addEventListener('resize', onResize)
if (authStore.token && !authStore.user) {
await authStore.fetchUser()
}
})
onUnmounted(() => { window.removeEventListener('resize', onResize) })
watch(() => route.path, () => { if (isMobile.value) mobileMenuOpen.value = false })
function handleLogout() {
authStore.logout()
router.push('/login')
@ -275,4 +291,26 @@ async function handleChangePwd() {
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* ── 手机端汉堡按钮 ── */
.hamburger-btn { padding: 4px !important; margin-right: 4px; }
.header-left { display: flex; align-items: center; gap: 4px; }
/* ── 手机端遮罩 + 侧边栏 ── */
.menu-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.3);
z-index: 99; animation: fadeIn 0.2s;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@media (max-width: 768px) {
.aside {
position: fixed !important; z-index: 100; height: 100vh; top: 0; left: 0;
transition: transform 0.25s ease;
}
.aside.mobile-hidden { transform: translateX(-100%); }
.aside.mobile-open { transform: translateX(0); }
.header { padding: 0 12px; }
.main { padding: 12px; }
}
</style>

View File

@ -66,6 +66,35 @@
</div>
</div>
<!-- 今日提交情况 -->
<div class="card attendance-card" v-if="data.daily_attendance">
<div class="card-header" style="cursor:pointer" @click="attendanceExpanded = !attendanceExpanded">
<span class="card-title">
<el-icon :size="16" style="color:#3370FF;margin-right:6px;vertical-align:-2px"><User /></el-icon>
今日提交情况
</span>
<div class="attendance-summary">
<span class="att-tag submitted">已提交 {{ data.daily_attendance.submitted_count }}</span>
<span class="att-tag not-submitted" v-if="data.daily_attendance.not_submitted_count > 0">未提交 {{ data.daily_attendance.not_submitted_count }}</span>
<el-icon :size="14" style="color:var(--text-secondary);margin-left:4px;transition:transform 0.2s" :style="{transform: attendanceExpanded ? 'rotate(180deg)' : ''}"><ArrowDown /></el-icon>
</div>
</div>
<div class="card-body attendance-body" v-show="attendanceExpanded">
<div v-if="data.daily_attendance.not_submitted?.length" class="att-section">
<div class="att-section-title not-submitted-title">未提交{{ data.daily_attendance.not_submitted.length }}</div>
<div class="att-user-list">
<span v-for="u in data.daily_attendance.not_submitted" :key="u.id" class="att-user not-submitted">{{ u.name }}</span>
</div>
</div>
<div v-if="data.daily_attendance.submitted?.length" class="att-section">
<div class="att-section-title">已提交{{ data.daily_attendance.submitted.length }}</div>
<div class="att-user-list">
<span v-for="u in data.daily_attendance.submitted" :key="u.id" class="att-user submitted">{{ u.name }} <small>{{ u.hours }}h</small></span>
</div>
</div>
</div>
</div>
<!-- 风险预警 -->
<div class="card risk-card" v-if="data.risk_alerts?.length && authStore.hasPermission('dashboard:view_risk')">
<div class="card-header">
@ -206,6 +235,7 @@ const authStore = useAuthStore()
const loading = ref(false)
const data = ref({})
const attendanceExpanded = ref(false)
const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
const trendChartRef = ref(null)
@ -456,6 +486,25 @@ onUnmounted(() => {
.profit-text.loss { color: #FF3B30; }
.text-muted { color: var(--text-secondary); }
/* 今日提交情况 */
.attendance-card { margin-bottom: 16px; }
.attendance-summary { display: flex; align-items: center; gap: 8px; }
.att-tag { font-size: 13px; font-weight: 600; padding: 2px 10px; border-radius: 10px; }
.att-tag.submitted { color: #34C759; background: #E8F8EE; }
.att-tag.not-submitted { color: #FF3B30; background: #FFE8E7; }
.attendance-body { padding-top: 8px !important; padding-bottom: 12px !important; }
.att-section { margin-bottom: 12px; }
.att-section:last-child { margin-bottom: 0; }
.att-section-title { font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; font-weight: 500; }
.att-section-title.not-submitted-title { color: #FF3B30; }
.att-user-list { display: flex; flex-wrap: wrap; gap: 6px; }
.att-user {
font-size: 13px; padding: 4px 12px; border-radius: 6px; white-space: nowrap;
}
.att-user.not-submitted { background: #FFF5F5; color: #CC2D25; font-weight: 500; }
.att-user.submitted { background: #F7F8FA; color: var(--text-regular); }
.att-user.submitted small { color: var(--text-secondary); margin-left: 2px; }
/* 风险预警 */
.risk-card { margin-bottom: 16px; }
.risk-item {

View File

@ -529,12 +529,32 @@ let progressChart = null
function initProgressChart() {
if (!progressChartRef.value) return
const p = project.value
const target = p.target_total_seconds || 1
const animSecs = p.animation_seconds || 0
const w = prodWaste.value
const testSecs = w.test_waste_seconds || 0
const overSecs = w.overproduction_waste_seconds || 0
const netSecs = Math.max(0, animSecs - testSecs - overSecs)
const pct = p.progress_percent || 0
const isOver = pct > 100
// 100%
const netPct = Math.min(netSecs / target * 100, 100)
const testPct = testSecs / target * 100
const overPct = overSecs / target * 100
const remaining = Math.max(0, 100 - netPct - testPct - overPct)
if (progressChart) progressChart.dispose()
progressChart = echarts.init(progressChartRef.value)
const data = [
{ value: netPct, name: '有效产出', itemStyle: { color: '#3370FF' } },
]
if (testPct > 0) data.push({ value: testPct, name: '测试损耗', itemStyle: { color: '#FF9500' } })
if (overPct > 0) data.push({ value: overPct, name: '超产损耗', itemStyle: { color: '#FF3B30' } })
if (remaining > 0) data.push({ value: remaining, name: '剩余', itemStyle: { color: '#E5E6EB' } })
const centerColor = pct > 100 ? '#FF9500' : '#3370FF'
progressChart.setOption({
series: [{
type: 'pie',
@ -542,10 +562,7 @@ function initProgressChart() {
center: ['50%', '50%'],
silent: true,
label: { show: false },
data: [
{ value: Math.min(pct, 100), itemStyle: { color: isOver ? '#FF9500' : '#3370FF' } },
{ value: Math.max(0, 100 - pct), itemStyle: { color: '#E5E6EB' } },
],
data,
}],
graphic: [{
type: 'text',
@ -553,7 +570,7 @@ function initProgressChart() {
style: {
text: pct + '%',
fontSize: 22, fontWeight: 700,
fill: isOver ? '#FF9500' : '#3370FF',
fill: centerColor,
textAlign: 'center',
},
}],
@ -1161,4 +1178,35 @@ onUnmounted(() => {
.drawer-sub-top { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.drawer-sub-secs { font-weight: 600; color: var(--text-primary); margin-left: auto; }
.drawer-sub-desc { font-size: 12px; color: #8F959E; margin-top: 4px; }
/* ── 手机端适配 ── */
@media (max-width: 768px) {
.page-header { flex-wrap: wrap; gap: 8px; }
.page-header-left h2 { font-size: 15px; }
.page-header .el-space { flex-wrap: wrap; }
.info-grid { grid-template-columns: 1fr 1fr; }
.info-item:nth-last-child(-n+3) { border-bottom: 1px solid var(--border-light, #f0f1f2); }
.info-item:last-child { border-bottom: none; }
.stat-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.stat-card { padding: 14px; }
.stat-value { font-size: 18px; }
.post-card { padding: 12px 16px; }
.post-value { font-size: 16px; }
.segmented-bar { flex-direction: column; gap: 8px; }
.milestone-columns { grid-template-columns: 1fr; }
.production-ring-layout { flex-direction: column; align-items: center; }
.production-info { max-width: none; }
.ep-bar-wrapper { flex-wrap: wrap; gap: 4px 12px; }
.ep-bar-track { flex: 1 1 100%; }
.ep-bar-info { min-width: auto; }
.heatmap-body { padding: 10px !important; }
.card-body { padding: 12px; }
}
</style>

View File

@ -18,6 +18,11 @@
<span class="perm-count">{{ row.permissions.length }}</span>
</template>
</el-table-column>
<el-table-column label="豁免提交" width="100" align="center">
<template #default="{row}">
<el-tag v-if="row.exempt_submission" size="small" type="warning">豁免</el-tag>
</template>
</el-table-column>
<el-table-column label="用户数" width="90" align="center">
<template #default="{row}">{{ row.user_count }}</template>
</el-table-column>
@ -38,6 +43,10 @@
<el-form-item label="描述">
<el-input v-model="form.description" placeholder="(选填)角色职责说明" />
</el-form-item>
<el-form-item label="豁免提交">
<el-switch v-model="form.exempt_submission" active-text="豁免" inactive-text="" />
<span style="margin-left:8px;font-size:12px;color:#909399">开启后该角色成员不纳入每日提交统计</span>
</el-form-item>
</el-form>
<div class="perm-panel">
@ -85,7 +94,7 @@ const editingSystem = ref(false)
const roles = ref([])
const permGroups = ref([])
const form = ref({ name: '', description: '', permissions: [] })
const form = ref({ name: '', description: '', permissions: [], exempt_submission: false })
const allPermKeys = computed(() => permGroups.value.flatMap(g => g.permissions.map(p => p.key)))
const isAllSelected = computed(() => allPermKeys.value.length > 0 && allPermKeys.value.every(k => form.value.permissions.includes(k)))
@ -123,7 +132,7 @@ function toggleAll() {
function openCreate() {
editingId.value = null
editingSystem.value = false
form.value = { name: '', description: '', permissions: [] }
form.value = { name: '', description: '', permissions: [], exempt_submission: false }
showDialog.value = true
}
@ -134,6 +143,7 @@ function openEdit(role) {
name: role.name,
description: role.description || '',
permissions: [...role.permissions],
exempt_submission: !!role.exempt_submission,
}
showDialog.value = true
}

View File

@ -18,19 +18,22 @@
<!-- 成本汇总 -->
<el-row :gutter="16" class="stat-row">
<el-col :span="5">
<el-col :span="4">
<el-card shadow="hover"><div class="stat-label">人力成本</div><div class="stat-value">¥{{ fmt(data.labor_cost) }}</div></el-card>
</el-col>
<el-col :span="5">
<el-card shadow="hover"><div class="stat-label">AI 工具成本</div><div class="stat-value">¥{{ fmt(data.ai_tool_cost) }}</div></el-card>
<el-col :span="4">
<el-card shadow="hover"><div class="stat-label">AI 工具</div><div class="stat-value">¥{{ fmt(data.ai_tool_cost) }}</div></el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover"><div class="stat-label">外包成本</div><div class="stat-value">¥{{ fmt(data.outsource_cost) }}</div></el-card>
</el-col>
<el-col :span="5">
<el-col :span="4">
<el-card shadow="hover"><div class="stat-label">固定开支</div><div class="stat-value">¥{{ fmt(data.overhead_cost) }}</div></el-card>
</el-col>
<el-col :span="5">
<el-col :span="4">
<el-card shadow="hover"><div class="stat-label">管理成本</div><div class="stat-value">¥{{ fmt(data.management_cost) }}</div></el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover">
<div class="stat-label">项目总成本</div>
<div class="stat-value" style="color:#e6a23c">¥{{ fmt(data.total_cost) }}</div>