diff --git a/backend/airlabs.db b/backend/airlabs.db index 2cb3e0d..64f3027 100644 Binary files a/backend/airlabs.db and b/backend/airlabs.db differ diff --git a/backend/calculations.py b/backend/calculations.py index 18cd0b4..ffd015c 100644 --- a/backend/calculations.py +++ b/backend/calculations.py @@ -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, diff --git a/backend/main.py b/backend/main.py index c762d65..5e3e727 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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"): diff --git a/backend/models.py b/backend/models.py index 3a57534..b8000b1 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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") diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py index e2b1d47..f0e39f2 100644 --- a/backend/routers/dashboard.py +++ b/backend/routers/dashboard.py @@ -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, diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 7d702cb..2fdfa21 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -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, } diff --git a/backend/routers/roles.py b/backend/routers/roles.py index cf4f560..faaa348 100644 --- a/backend/routers/roles.py +++ b/backend/routers/roles.py @@ -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": "角色已更新"} diff --git a/backend/services/ai_service.py b/backend/services/ai_service.py index 6573baf..33a46bc 100644 --- a/backend/services/ai_service.py +++ b/backend/services/ai_service.py @@ -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: diff --git a/backend/services/feishu_service.py b/backend/services/feishu_service.py index 35dc64e..4ae215d 100644 --- a/backend/services/feishu_service.py +++ b/backend/services/feishu_service.py @@ -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 = [] + + # ── 顶部 KPI(3列灰底) ── + 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 = [] + + # ── 顶部 KPI(4列灰底) ── + 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: diff --git a/backend/services/report_service.py b/backend/services/report_service.py index f905e37..a5eceec 100644 --- a/backend/services/report_service.py +++ b/backend/services/report_service.py @@ -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,119 +65,131 @@ 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) - - # 所有活跃用户(有提交记录的) - all_active_user_ids = set( - uid for (uid,) in db.query(Submission.user_id).distinct().all() + # 产出只算中期制作阶段的动画秒数 + 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 = [] - 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: - not_submitted.append(user.name) + 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() + ) + 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 and user.role_id not in exempt_role_ids: + not_submitted.append(user.name) # 进行中项目 active_projects = db.query(Project).filter( 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" diff --git a/backend/services/scheduler_service.py b/backend/services/scheduler_service.py index 21c2028..eaa565d 100644 --- a/backend/services/scheduler_service.py +++ b/backend/services/scheduler_service.py @@ -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", diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 9d2a38c..ce16271 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -1,8 +1,11 @@