feat: ChatBI自动报告生成—周报/月报/专项

This commit is contained in:
Hermes CI Fix
2026-07-29 18:16:37 +08:00
parent 68e40ac9c5
commit bc02d09520
3 changed files with 895 additions and 1 deletions
+709 -1
View File
@@ -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,
}
+15
View File
@@ -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"
+171
View File
@@ -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()