diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index 024a38fd..e0249529 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -16,7 +16,7 @@ from typing import Optional from datetime import datetime, date from app.database import get_db from app.auth_middleware import require_role, require_auth -from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User, Subject +from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User, Subject, ActionPlan, OperationLog, ReportHistory from app.utils.deviation_engine import calc_period_deviation, calc_period_diff import logging @@ -1313,3 +1313,711 @@ def get_dupont_analysis( }, } return {"error": "不支持的实体"} + + +# ============================================================ +# 自动报告生成 — ChatBI优化P1 +# 支持周报/月报/专项报告,定时/事件/手动触发 +# ============================================================ + +WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] +DIM_CN = {"finance": "财务维度", "customer": "客户维度", "process": "内部流程", "learning": "学习成长"} +ALERT_LEVEL_CN = {"red": "🔴 紧急", "yellow": "🟡 预警", "green": "🟢 正常"} + + +def _get_current_period(report_type: str) -> str: + """根据报告类型自动计算当前期间""" + now = datetime.now() + if report_type == "weekly": + iso = now.isocalendar() + return f"{iso[0]}-W{iso[1]:02d}" + elif report_type == "monthly": + return now.strftime("%Y-%m") + elif report_type == "special": + return now.strftime("%Y-%m") + return now.strftime("%Y-%m") + + +def _calc_week_range(period: str) -> tuple: + """周期间 → 起止日期""" + import datetime as dt + year, week = period.split("-W") + year, week = int(year), int(week) + # ISO week: week 1 is the week containing Jan 4 + jan4 = dt.date(year, 1, 4) + start_of_week1 = jan4 - dt.timedelta(days=jan4.isoweekday() - 1) + monday = start_of_week1 + dt.timedelta(weeks=week - 1) + sunday = monday + dt.timedelta(days=6) + return monday.strftime("%Y-%m-%d"), sunday.strftime("%Y-%m-%d") + + +def _get_month_period_prefix(period: str) -> str: + """YYYY-MM 的前期""" + y, m = period.split("-") + y, m = int(y), int(m) + m -= 1 + if m <= 0: + m += 12 + y -= 1 + return f"{y}-{m:02d}" + + +def _fetch_kpi_data(db: Session) -> list: + """获取所有活跃KPI的当前值、目标值、维度、预警""" + kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all() + result = [] + for k in kpis: + latest = db.query(KPIValue).filter( + KPIValue.kpi_id == k.id, + KPIValue.actual_value.isnot(None), + ).order_by(KPIValue.period.desc()).first() + + alerts = db.query(KPIAlert).filter( + KPIAlert.kpi_id == k.id, + KPIAlert.status == "pending", + ).order_by(KPIAlert.created_at.desc()).all() + + result.append({ + "kpi_id": k.id, + "kpi_code": k.kpi_code, + "kpi_name": k.kpi_name, + "dimension": k.dimension, + "category": k.category, + "unit": k.unit, + "target_value": k.target_value, + "current_value": latest.actual_value if latest else None, + "current_period": latest.period if latest else None, + "frequency": k.frequency, + "alerts": [ + {"level": a.alert_level, "message": a.alert_message} + for a in alerts[:3] + ], + }) + return result + + +def _build_weekly_report(db: Session, period: str) -> dict: + """生成周报""" + kpis = _fetch_kpi_data(db) + monday, sunday = _calc_week_range(period) + now_str = datetime.now().strftime("%Y-%m-%d %H:%M") + + # 最近7天新增的预警 + from datetime import timedelta + seven_days_ago = datetime.now() - timedelta(days=7) + recent_alerts = db.query(KPIAlert).filter( + KPIAlert.created_at >= seven_days_ago, + KPIAlert.status == "pending", + ).order_by(KPIAlert.created_at.desc()).all() + + # 按维度分组统计 + dim_stats = {} + for k in kpis: + d = k.get("dimension") or "other" + if d not in dim_stats: + dim_stats[d] = {"total": 0, "with_data": 0, "alert_count": 0} + dim_stats[d]["total"] += 1 + if k["current_value"] is not None: + dim_stats[d]["with_data"] += 1 + if k["alerts"]: + dim_stats[d]["alert_count"] += len(k["alerts"]) + + # KPI变动(取有环比数据的) + changes = [] + for k in kpis: + if k["current_value"] is None: + continue + prev_period = _get_month_period_prefix(k["current_period"]) if k["current_period"] else None + if prev_period: + prev_val = db.query(KPIValue).filter( + KPIValue.kpi_id == k["kpi_id"], + KPIValue.period == prev_period, + ).first() + if prev_val and prev_val.actual_value: + diff = round(k["current_value"] - prev_val.actual_value, 2) + rate = round(diff / prev_val.actual_value * 100, 2) if prev_val.actual_value != 0 else None + changes.append({ + **k, + "prev_value": prev_val.actual_value, + "change": diff, + "change_rate": rate, + }) + + changes.sort(key=lambda x: abs(x.get("change_rate") or 0), reverse=True) + top_changes = changes[:8] + + # ── 构建 Markdown ── + md_lines = [ + f"📊 **经营分析周报**", + f"📅 {monday} ~ {sunday} | 生成时间:{now_str}", + "", + "---", + "", + "## 一、本周概览", + f"• 监控KPI:{len(kpis)} 个 | 有数据:{sum(1 for k in kpis if k['current_value'] is not None)} 个", + f"• 待处理预警:{len(recent_alerts)} 条", + ] + + # 按维度展示 + for dim_key, dim_label in [("finance", "💰 财务"), ("customer", "🤝 客户"), ("process", "⚙️ 流程"), ("learning", "📚 学习成长")]: + s = dim_stats.get(dim_key) + if s: + md_lines.append(f" - {dim_label}:{s['total']}个KPI | {s['with_data']}个有数据 | {s['alert_count']}条预警") + + md_lines.extend([ + "", + "## 二、关键KPI变动 TOP8", + ]) + for c in top_changes: + direction = "📈" if (c.get("change_rate") or 0) > 0 else "📉" + rate_str = f"{c['change_rate']:+.1f}%" if c.get("change_rate") is not None else "-" + md_lines.append( + f" {direction} **{c['kpi_name']}**:{c['current_value']}{c['unit']} " + f"(上期{c.get('prev_value', '-')},变动{rate_str})" + ) + + if recent_alerts: + md_lines.extend([ + "", + "## 三、本周预警", + ]) + for a in recent_alerts[:10]: + kpi = next((k for k in kpis if k["kpi_id"] == a.kpi_id), None) + kpi_name = kpi["kpi_name"] if kpi else f"KPI#{a.kpi_id}" + md_lines.append(f" {ALERT_LEVEL_CN.get(a.alert_level, '⚠️')} {kpi_name}:{a.alert_message}") + + md_lines.extend([ + "", + "## 四、改进行动", + ]) + actions = db.query(ActionPlan).filter( + ActionPlan.status.in_(["pending", "in_progress"]), + ).order_by(ActionPlan.created_at.desc()).limit(5).all() + if actions: + for a in actions: + bar = "▓" * (a.progress // 10) + "░" * (10 - a.progress // 10) + md_lines.append(f" • {bar} {a.title}({a.progress}%)- {a.assignee or '未分配'}") + else: + md_lines.append(" (暂无进行中的改善行动)") + + md_lines.extend([ + "", + "---", + f"💡 发送「分析报告」可重新生成", + ]) + + markdown = "\n".join(md_lines) + + # ── 构建 JSON ── + json_data = { + "report_type": "weekly", + "period": period, + "date_range": {"start": monday, "end": sunday}, + "generated_at": now_str, + "overview": { + "total_kpis": len(kpis), + "kpis_with_data": sum(1 for k in kpis if k["current_value"] is not None), + "pending_alerts": len(recent_alerts), + }, + "dimensions": {dk: { + "label": DIM_CN.get(dk, dk), + "kpi_count": ds["total"], + "with_data": ds["with_data"], + "alert_count": ds["alert_count"], + } for dk, ds in dim_stats.items()}, + "top_changes": [ + { + "kpi_code": c["kpi_code"], + "kpi_name": c["kpi_name"], + "current_value": c["current_value"], + "prev_value": c.get("prev_value"), + "change": c.get("change"), + "change_rate": c.get("change_rate"), + "unit": c["unit"], + } + for c in top_changes + ], + "alerts": [ + { + "kpi_id": a.kpi_id, + "alert_level": a.alert_level, + "alert_message": a.alert_message, + } + for a in recent_alerts[:10] + ], + } + + return {"markdown": markdown, "json": json_data, "title": f"经营分析周报 {monday}~{sunday}"} + + +def _build_monthly_report(db: Session, period: str) -> dict: + """生成月报""" + kpis = _fetch_kpi_data(db) + now_str = datetime.now().strftime("%Y-%m-%d %H:%M") + prev_period = _get_month_period_prefix(period) + + # 预算执行数据 + budget_items = [] + for k in kpis: + dev = calc_period_deviation(db, k["kpi_id"], period) + if dev.get("actual_value") is not None or dev.get("budget_value") is not None: + budget_items.append({ + "kpi_name": k["kpi_name"], + "kpi_code": k["kpi_code"], + "dimension": k["dimension"], + "actual": dev.get("actual_value"), + "budget": dev.get("budget_value"), + "deviation_rate": dev.get("deviation_rate"), + "unit": k["unit"], + }) + + # 同比/环比 + comparisons = [] + for k in kpis[:20]: + if k["current_value"] is None: + continue + mom = calc_period_diff(db, k["kpi_id"], period, "mom") + yoy = calc_period_diff(db, k["kpi_id"], period, "yoy") + comparisons.append({ + "kpi_name": k["kpi_name"], + "kpi_code": k["kpi_code"], + "current": k["current_value"], + "unit": k["unit"], + "mom_rate": mom.get("diff_rate"), + "yoy_rate": yoy.get("diff_rate"), + }) + + # 预警汇总 + pending_alerts = db.query(KPIAlert).filter( + KPIAlert.status == "pending", + ).all() + red_count = sum(1 for a in pending_alerts if a.alert_level == "red") + yellow_count = sum(1 for a in pending_alerts if a.alert_level == "yellow") + + # 各维度达成情况 + dim_summary = {} + for k in kpis: + d = k.get("dimension") or "other" + if d not in dim_summary: + dim_summary[d] = {"total": 0, "achieved": 0, "warning": 0, "failed": 0} + dim_summary[d]["total"] += 1 + if k["current_value"] is not None and k["target_value"]: + ratio = k["current_value"] / k["target_value"] + if ratio >= 0.9: + dim_summary[d]["achieved"] += 1 + elif ratio >= 0.7: + dim_summary[d]["warning"] += 1 + else: + dim_summary[d]["failed"] += 1 + + # 改善行动 + actions = db.query(ActionPlan).order_by(ActionPlan.created_at.desc()).limit(5).all() + + # ── 生成 Markdown ── + md_lines = [ + f"📊 **经营分析月报**", + f"📅 {period} | 生成时间:{now_str}", + "", + "---", + "", + "## 一、月度总览", + f"• 监控KPI:{len(kpis)} 个", + f"• 预警状态:🔴 {red_count}条紧急 | 🟡 {yellow_count}条预警", + "", + "## 二、四维度达成情况", + ] + for dk in ["finance", "customer", "process", "learning"]: + ds = dim_summary.get(dk) + if ds: + label = DIM_CN.get(dk, dk) + total = ds["total"] + achieved = ds["achieved"] + pct = round(achieved / total * 100, 1) if total > 0 else 0 + bar_len = 10 + filled = int(pct / 10) + bar = "▓" * filled + "░" * (bar_len - filled) + md_lines.append(f"• {label}:{bar} {pct}%({achieved}/{total}达标)") + + md_lines.extend([ + "", + "## 三、预算执行 TOP异常", + ]) + budget_with_dev = [b for b in budget_items if b.get("deviation_rate") is not None] + budget_with_dev.sort(key=lambda x: abs(x["deviation_rate"]), reverse=True) + for b in budget_with_dev[:8]: + direction = "🔴" if (b["deviation_rate"] or 0) > 0 else "🟢" + md_lines.append( + f" {direction} **{b['kpi_name']}**:实际{b['actual']}{b['unit']} " + f"vs 预算{b['budget']}{b['unit']}(差异率{b['deviation_rate']:+.1f}%)" + ) + + md_lines.extend([ + "", + "## 四、同比/环比分析", + ]) + for c in comparisons[:8]: + mom_str = f"环比{c.get('mom_rate'):+.1f}%" if c.get("mom_rate") is not None else "环比N/A" + yoy_str = f"同比{c.get('yoy_rate'):+.1f}%" if c.get("yoy_rate") is not None else "同比N/A" + md_lines.append(f" • **{c['kpi_name']}**:{c['current']}{c['unit']} | {mom_str} | {yoy_str}") + + md_lines.extend([ + "", + "## 五、改善行动进展", + ]) + if actions: + for a in actions: + bar = "▓" * (a.progress // 10) + "░" * (10 - a.progress // 10) + status_cn = {"pending": "待开始", "in_progress": "进行中", "completed": "已完成"}.get(a.status, a.status) + md_lines.append(f" • {bar} {a.title}({a.progress}%)- {status_cn}") + else: + md_lines.append(" (暂无改善行动)") + + md_lines.extend([ + "", + "---", + f"💡 发送「生成{period}经营报告」可重新生成", + ]) + + markdown = "\n".join(md_lines) + + # ── 生成 JSON ── + json_data = { + "report_type": "monthly", + "period": period, + "generated_at": now_str, + "overview": { + "total_kpis": len(kpis), + "red_alerts": red_count, + "yellow_alerts": yellow_count, + }, + "dimensions": {dk: { + "label": DIM_CN.get(dk, dk), + "total": ds["total"], + "achieved": ds["achieved"], + "achievement_rate": round(ds["achieved"] / ds["total"] * 100, 1) if ds["total"] > 0 else 0, + } for dk, ds in dim_summary.items()}, + "budget_execution": [ + { + "kpi_code": b["kpi_code"], + "kpi_name": b["kpi_name"], + "actual": b.get("actual"), + "budget": b.get("budget"), + "deviation_rate": b.get("deviation_rate"), + "unit": b["unit"], + } + for b in budget_with_dev[:15] + ], + "comparisons": [ + { + "kpi_code": c["kpi_code"], + "kpi_name": c["kpi_name"], + "current": c["current"], + "mom_rate": c.get("mom_rate"), + "yoy_rate": c.get("yoy_rate"), + } + for c in comparisons[:15] + ], + } + + return {"markdown": markdown, "json": json_data, "title": f"经营分析月报 {period}"} + + +def _build_special_report(db: Session, period: str, alert_ref: str = None) -> dict: + """生成专项分析报告 — 聚焦KPI异常""" + kpis = _fetch_kpi_data(db) + now_str = datetime.now().strftime("%Y-%m-%d %H:%M") + + # 按偏差率排序(当前值/目标值) + scored = [] + for k in kpis: + if k["current_value"] is not None and k["target_value"] and k["target_value"] > 0: + ratio = k["current_value"] / k["target_value"] + deviation = round((ratio - 1) * 100, 2) + scored.append({**k, "achievement_ratio": ratio, "deviation_pct": deviation}) + scored.sort(key=lambda x: abs(x["deviation_pct"]), reverse=True) + + top_issues = scored[:10] + worst_issues = [s for s in scored if s["deviation_pct"] < 0][:5] + best_issues = [s for s in scored if s["deviation_pct"] > 0][:3] + + # 如果有预警引用,聚焦该预警关联的KPI + focus_kpi_name = None + if alert_ref: + alert = db.query(KPIAlert).filter(KPIAlert.id == int(alert_ref)).first() if alert_ref.isdigit() else None + if alert: + target_kpi = next((k for k in kpis if k["kpi_id"] == alert.kpi_id), None) + if target_kpi: + focus_kpi_name = target_kpi["kpi_name"] + + # 维度分布 + dim_issues = {} + for s in scored: + d = s.get("dimension") or "other" + if d not in dim_issues: + dim_issues[d] = {"on_track": 0, "at_risk": 0, "critical": 0} + if s["achievement_ratio"] >= 0.9: + dim_issues[d]["on_track"] += 1 + elif s["achievement_ratio"] >= 0.7: + dim_issues[d]["at_risk"] += 1 + else: + dim_issues[d]["critical"] += 1 + + # ── Markdown ── + md_lines = [ + f"📊 **经营分析专项报告**", + f"📅 {period} | 生成时间:{now_str}", + ] + if focus_kpi_name: + md_lines.append(f"🎯 触发事件:{focus_kpi_name} 异常预警") + md_lines.extend([ + "", + "---", + "", + "## 一、风险总览", + ]) + for dk in ["finance", "customer", "process", "learning"]: + d = dim_issues.get(dk) + if d: + label = DIM_CN.get(dk, dk) + md_lines.append( + f"• {label}:{d['on_track']}正常 / {d['at_risk']}预警 / {d['critical']}危险" + ) + + md_lines.extend([ + "", + "## 二、风险KPI TOP 5(严重未达标)", + ]) + for w in worst_issues: + md_lines.append( + f" 🔴 **{w['kpi_name']}**:实际{w['current_value']}{w['unit']} " + f"vs 目标{w['target_value']}{w['unit']}(达成率{w['achievement_ratio']*100:.1f}%)" + ) + for a in w.get("alerts", []): + md_lines.append(f" ⚠️ {a['message']}") + + md_lines.extend([ + "", + "## 三、待处理预警详情", + ]) + pending_alerts = db.query(KPIAlert).filter( + KPIAlert.status == "pending", + ).order_by(KPIAlert.created_at.desc()).limit(10).all() + if pending_alerts: + for a in pending_alerts: + target_kpi = next((k for k in kpis if k["kpi_id"] == a.kpi_id), None) + name = target_kpi["kpi_name"] if target_kpi else f"KPI#{a.kpi_id}" + md_lines.append(f" {ALERT_LEVEL_CN.get(a.alert_level, '⚠️')} {name}:{a.alert_message}") + else: + md_lines.append(" ✅ 无待处理预警") + + md_lines.extend([ + "", + "## 四、改善建议", + ]) + for w in worst_issues: + if w["target_value"] and w["current_value"]: + gap = round(w["target_value"] - w["current_value"], 2) + md_lines.append(f" • **{w['kpi_name']}**:缺口{gap}{w['unit']},需提升至{w['target_value']}{w['unit']}才能达标") + + md_lines.extend([ + "", + "## 五、表现优秀KPI", + ]) + for b in best_issues: + md_lines.append(f" 🟢 **{b['kpi_name']}**:{b['current_value']}{b['unit']},超目标{b['deviation_pct']:+.1f}%") + + md_lines.extend([ + "", + "---", + "💡 如有疑问,请回复「分析详情」获取更细颗粒度的数据", + ]) + + markdown = "\n".join(md_lines) + + # ── JSON ── + json_data = { + "report_type": "special", + "period": period, + "generated_at": now_str, + "focus_kpi": focus_kpi_name, + "alert_ref": alert_ref, + "risk_summary": {dk: { + "label": DIM_CN.get(dk, dk), + "on_track": dim_issues.get(dk, {}).get("on_track", 0), + "at_risk": dim_issues.get(dk, {}).get("at_risk", 0), + "critical": dim_issues.get(dk, {}).get("critical", 0), + } for dk in ["finance", "customer", "process", "learning"]}, + "worst_kpis": [ + { + "kpi_code": w["kpi_code"], + "kpi_name": w["kpi_name"], + "current_value": w["current_value"], + "target_value": w["target_value"], + "achievement_ratio": round(w["achievement_ratio"], 4), + "gap": round(w["target_value"] - w["current_value"], 2) if w["target_value"] and w["current_value"] else None, + "unit": w["unit"], + } + for w in worst_issues + ], + "best_kpis": [ + { + "kpi_code": b["kpi_code"], + "kpi_name": b["kpi_name"], + "current_value": b["current_value"], + "target_value": b["target_value"], + "achievement_ratio": round(b["achievement_ratio"], 4), + "unit": b["unit"], + } + for b in best_issues + ], + "alerts": [ + { + "kpi_id": a.kpi_id, + "alert_level": a.alert_level, + "alert_message": a.alert_message, + } + for a in pending_alerts[:10] + ], + } + + return {"markdown": markdown, "json": json_data, "title": f"经营分析专项报告 {period}"} + + +# ============================================================ +# POST /api/cma/reports/generate — 自动报告生成主入口 +# ============================================================ + +class GenerateReportRequest(BaseModel): + report_type: str = "monthly" # weekly / monthly / special + period: Optional[str] = None # 自动计算 if None + trigger_type: str = "manual" # manual / scheduled / event + alert_ref: Optional[str] = None # 事件触发时的预警ID + + +@router.post("/generate", response_model=None) +def generate_report( + req: GenerateReportRequest, + db: Session = Depends(get_db), + current_user=Depends(require_auth), +): + """生成经营分析报告(周报/月报/专项),返回markdown+JSON + + 触发方式: + - POST ?trigger_type=manual (用户主动触发) + - POST ?trigger_type=scheduled (定时任务触发) + - POST ?trigger_type=event&alert_ref=123 (KPI异常事件触发) + """ + # 校验报告类型 + if req.report_type not in ("weekly", "monthly", "special"): + raise HTTPException(400, f"不支持的报告类型: {req.report_type},可选: weekly/monthly/special") + + # 确定期间 + period = req.period or _get_current_period(req.report_type) + + # 生成报告 + builders = { + "weekly": lambda db, period: _build_weekly_report(db, period), + "monthly": lambda db, period: _build_monthly_report(db, period), + "special": lambda db, period: _build_special_report(db, period, alert_ref=req.alert_ref), + } + builder = builders[req.report_type] + + try: + report_data = builder(db, period) + except Exception as e: + logger.error(f"报告生成异常: {e}", exc_info=True) + raise HTTPException(500, f"报告生成失败: {str(e)}") + + # 保存到数据库 + record = ReportHistory( + report_type=req.report_type, + period=period, + title=report_data["title"], + markdown_content=report_data["markdown"], + json_content=report_data["json"], + status="generated", + trigger_type=req.trigger_type, + alert_ref=req.alert_ref, + ) + db.add(record) + db.flush() + + # 记录操作日志 + log = OperationLog( + action="generate_report", + target_type="report", + target_id=record.id, + detail=json.dumps({ + "report_type": req.report_type, + "period": period, + "trigger_type": req.trigger_type, + }, ensure_ascii=False), + ) + db.add(log) + db.commit() + db.refresh(record) + + return { + "id": record.id, + "report_type": req.report_type, + "period": period, + "title": report_data["title"], + "generated_at": record.created_at.strftime("%Y-%m-%d %H:%M:%S") if record.created_at else datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "markdown": report_data["markdown"], + "json": report_data["json"], + } + + +@router.get("/history") +def list_report_history( + report_type: Optional[str] = Query(None), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_user=Depends(require_auth), +): + """查看报告生成历史""" + query = db.query(ReportHistory).order_by(ReportHistory.created_at.desc()) + if report_type: + query = query.filter(ReportHistory.report_type == report_type) + records = query.limit(limit).all() + + return { + "total": len(records), + "data": [ + { + "id": r.id, + "report_type": r.report_type, + "period": r.period, + "title": r.title, + "status": r.status, + "trigger_type": r.trigger_type, + "created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None, + } + for r in records + ], + } + + +@router.get("/history/{report_id}") +def get_report_detail( + report_id: int, + db: Session = Depends(get_db), + current_user=Depends(require_auth), +): + """获取单条报告详情(含完整markdown内容)""" + r = db.query(ReportHistory).filter(ReportHistory.id == report_id).first() + if not r: + raise HTTPException(404, "报告不存在") + + return { + "id": r.id, + "report_type": r.report_type, + "period": r.period, + "title": r.title, + "status": r.status, + "trigger_type": r.trigger_type, + "alert_ref": r.alert_ref, + "markdown": r.markdown_content, + "json": r.json_content, + "created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None, + } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4189aee2..92ff3951 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -435,6 +435,21 @@ class BudgetDeviationAlert(Base): created_at = Column(DateTime, server_default=func.now()) +class ReportHistory(Base): + """自动生成的经营分析报告记录""" + __tablename__ = "report_history" + id = Column(Integer, primary_key=True, index=True) + report_type = Column(String(20), nullable=False, comment="weekly/monthly/special") + period = Column(String(20), nullable=False, comment="期间: 2026-W30 / 2026-07 / 2026-Q2") + title = Column(String(200), nullable=False, comment="报告标题") + markdown_content = Column(Text, nullable=True, comment="Markdown格式报告(用于微信推送)") + json_content = Column(JSON, nullable=True, comment="JSON结构化数据(写入CMA系统)") + status = Column(String(20), default="generated", comment="generated/pushed/failed") + trigger_type = Column(String(20), default="manual", comment="manual/scheduled/event") + alert_ref = Column(String(50), nullable=True, comment="事件触发时的预警引用") + created_at = Column(DateTime, server_default=func.now()) + + class AnalysisResult(Base): """财务Bot分析结论 — 带置信度评分""" __tablename__ = "analysis_results" diff --git a/backend/scripts/generate_report.py b/backend/scripts/generate_report.py new file mode 100644 index 00000000..ac168b48 --- /dev/null +++ b/backend/scripts/generate_report.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +""" +自动经营分析报告生成脚本 — ChatBI优化P1 + +用途: + 1. 定时触发:cron每周五17:00调用 python3 scripts/generate_report.py --type weekly + 2. 定时触发:cron每月1日09:00调用 python3 scripts/generate_report.py --type monthly + 3. 事件触发:python3 scripts/generate_report.py --type special --alert-ref 123 + 4. 手动触发:python3 scripts/generate_report.py --type weekly + +推送通道: + 复用现有AI简报的企微推送通道(app/utils/notifier.py) +""" +import sys +import os +import json +import argparse +import logging + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv +load_dotenv('/root/cma-management/backend/.env') + +from app.database import get_session_local + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger("cma.report_gen") + + +def generate_and_push(report_type: str, trigger_type: str = "manual", alert_ref: str = None) -> dict: + """生成报告并通过企微推送""" + import httpx + from datetime import datetime + + api_base = os.getenv("CMA_API_BASE", "http://127.0.0.1:8010") + api_url = f"{api_base}/api/cma/reports/generate" + + payload = { + "report_type": report_type, + "trigger_type": trigger_type, + } + if alert_ref: + payload["alert_ref"] = alert_ref + + try: + # 调用 CMA 系统 API 生成报告 + resp = httpx.post( + api_url, + json=payload, + timeout=30, + ) + resp.raise_for_status() + result = resp.json() + logger.info(f"报告生成成功: type={report_type}, id={result.get('id')}") + except Exception as e: + logger.error(f"报告生成API调用失败: {e}") + return {"success": False, "error": str(e)} + + # 推送到企微(复用现有通知通道) + markdown = result.get("markdown", "") + title = result.get("title", f"经营分析报告") + + if not markdown: + logger.warning("报告内容为空,跳过推送") + return {"success": True, "pushed": 0, "report_id": result.get("id")} + + # 通过系统通知通道推送 + db = get_session_local()() + try: + from app.models import NotificationChannel + from app.utils.notifier import send_wecom_app + + channels = db.query(NotificationChannel).filter( + NotificationChannel.enabled == True, + NotificationChannel.channel_type == "wecom_app", + ).all() + + # 也推送到机器人 webhook + robot_channels = db.query(NotificationChannel).filter( + NotificationChannel.enabled == True, + NotificationChannel.channel_type == "wecom", + ).all() + + pushed = 0 + for ch in channels + robot_channels: + config = ch.config or {} + if ch.channel_type == "wecom_app": + result_push = send_wecom_app( + corp_id=config.get("corp_id", ""), + corp_secret=config.get("corp_secret", ""), + agent_id=config.get("agent_id", ""), + touser=config.get("touser", "@all"), + title=title, + content=markdown, + alert_level="green", + ) + elif ch.channel_type == "wecom": + from app.utils.notifier import send_wecom_robot + webhook = config.get("webhook_url", "") + if webhook: + result_push = send_wecom_robot( + webhook_url=webhook, + title=title, + content=markdown, + alert_level="green", + ) + else: + continue + else: + continue + + if result_push.get("success"): + pushed += 1 + logger.info(f"报告推送成功: {ch.name}") + + # 更新报告状态 + from app.models import ReportHistory + record = db.query(ReportHistory).filter( + ReportHistory.id == result.get("id") + ).first() + if record: + record.status = "pushed" + db.commit() + + logger.info(f"报告推送完成: {pushed} 个渠道") + return { + "success": True, + "pushed": pushed, + "report_id": result.get("id"), + "title": title, + "markdown_length": len(markdown), + } + except Exception as e: + logger.error(f"报告推送异常: {e}") + return {"success": False, "error": str(e), "report_id": result.get("id")} + finally: + db.close() + + +def main(): + parser = argparse.ArgumentParser(description="自动经营分析报告生成") + parser.add_argument("--type", choices=["weekly", "monthly", "special"], + default="weekly", help="报告类型") + parser.add_argument("--trigger", choices=["manual", "scheduled", "event"], + default="manual", help="触发方式") + parser.add_argument("--alert-ref", type=str, default=None, + help="事件触发时的预警ID") + parser.add_argument("--no-push", action="store_true", + help="仅生成不推送") + args = parser.parse_args() + + logger.info(f"开始生成报告: type={args.type}, trigger={args.trigger}") + + result = generate_and_push( + report_type=args.type, + trigger_type=args.trigger, + alert_ref=args.alert_ref, + ) + + print(json.dumps(result, ensure_ascii=False, indent=2)) + + if not result.get("success"): + sys.exit(1) + + +if __name__ == "__main__": + main()