feat: ChatBI自动报告生成—周报/月报/专项
This commit is contained in:
+709
-1
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user