feat: 路线图R1决策建议一键落地+R2机会推送+R5预算闭环

R1(P0): AI建议一键应用到KPI/预算/行动方案
- 新表 ai_suggestions + AISuggestion 模型(init_db自动建)
- /api/cma/ai/suggestions CRUD + /{id}/apply(复用kpis/budget/action_plans) + dismiss
- 应用写 OperationLog(action=ai_suggestion_apply, detail含suggestion_id/before/after)
- 规则驱动建议生成 generate_rule_suggestions(低执行率/高执行率/预算超支/pending预警)
- 幂等: 同entity+type+target_id+title+unapplied不重复建; applied后拒绝重复应用
- 前端: Dashboard AI面板建议卡(应用到/忽略) + 建议中心页 /ai-suggestions

R2(P1): 数据找人扩大-机会类推送
- scripts/opportunity_detector.py: KPI向好(执行率>110%)/预算余量(<70%且actual>0)/预测上行
- scripts/daily_push.py: 异常+机会 每日9:15推企微(8800/send, --dry-run调试)
- crontab: 15 9 * * * (alert_generator 9:00之后)

R5(P0): 预算闭环加固
- auto-decompose批量幂等: 只取年度行(period=YYYY-00)+同KPI多版本取一行
- scripts/closed_loop_check.py: 预算执行率异常→检查现金流/行动同步→缺失提示+报告
- scripts/verify_decompose_idempotent.py: 幂等验证脚本

测试: test_ai_suggestions(10例)+test_roadmap_r2r5(14例); 修test_budget幂等契约适配年度行
全量: 673 passed
This commit is contained in:
Hermes CI Fix
2026-08-30 12:05:36 +08:00
parent 5b920df8a0
commit ad68471b29
22 changed files with 2170 additions and 25 deletions
+188
View File
@@ -0,0 +1,188 @@
"""预算↔现金流↔行动 三闭环异常自检 — 路线图R5 (2026-08-30)
预算闭环加固:预算执行率异常(<70% 或 >110%)的KPI
检查是否同步了 现金流计划(CashPlan) 和 行动方案(ActionPlan)
缺失则输出提示(防止"预算改了,现金流/行动没跟上")。
输出:控制台 + reports/closed_loop_check_YYYYMMDD.md
用法: /root/cma-management/backend/venv/bin/python3 scripts/closed_loop_check.py [--period 2026-08] [--push]
"""
import sys
import os
import json
import argparse
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, BudgetPlan, CashPlan, ActionPlan
LOW_RATIO = 0.7
HIGH_RATIO = 1.1
REPORTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "reports")
def check_entity(db, entity_id: int, period: str) -> dict:
"""检测一个账套的闭环状态"""
issues = []
rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.status == "active",
BudgetPlan.period == period,
BudgetPlan.budget_value > 0,
).all()
seen = set()
for b in rows:
key = (b.kpi_id, b.period)
if key in seen:
continue
seen.add(key)
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
kpi_name = k.kpi_name if k else f"KPI#{b.kpi_id}"
actual = db.query(KPIValue).filter(
KPIValue.kpi_id == b.kpi_id,
KPIValue.period == b.period,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.calculated_at.desc()).first()
actual_val = actual.actual_value if actual else None
if actual_val is None:
continue
ratio = actual_val / b.budget_value
abnormal = ratio < LOW_RATIO or ratio > HIGH_RATIO
if not abnormal:
continue
# 现金流检查:该KPI该期间是否有收付款计划(related_kpi_id 或 budget_plan_id 关联)
period_start = datetime.strptime(period + "-01", "%Y-%m-%d")
if period.endswith("-12"):
period_end = datetime(period_start.year + 1, 1, 1)
else:
period_end = datetime(period_start.year, period_start.month + 1, 1)
cash_plans = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.status.in_(["pending", "completed"]),
CashPlan.plan_date >= period_start,
CashPlan.plan_date < period_end,
).filter(
(CashPlan.related_kpi_id == b.kpi_id) | (CashPlan.budget_plan_id == b.id)
).count()
# 兜底:无关联但期间内有任意现金流计划也算基本闭环
any_cash = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.status.in_(["pending", "completed"]),
CashPlan.plan_date >= period_start,
CashPlan.plan_date < period_end,
).count()
# 行动检查:该KPI是否有非完成的行动方案
actions = db.query(ActionPlan).filter(
ActionPlan.kpi_id == b.kpi_id,
ActionPlan.status.in_(["pending", "in_progress"]),
).count()
missing = []
if cash_plans == 0:
if any_cash > 0:
missing.append("现金流(本期间有其他计划但未关联本KPI)")
else:
missing.append("现金流")
if actions == 0:
missing.append("行动方案")
level = "critical" if ratio > HIGH_RATIO else "warning"
issues.append({
"kpi_id": b.kpi_id,
"kpi_name": kpi_name,
"period": period,
"budget_value": b.budget_value,
"actual_value": actual_val,
"exec_ratio": round(ratio * 100, 1),
"abnormal_type": "超预算" if ratio > HIGH_RATIO else "低执行",
"level": level,
"cash_plan_count": cash_plans,
"action_plan_count": actions,
"missing": missing,
"suggestion": (
f"预算执行率{ratio*100:.0f}%异常,请同步"
+ ("现金流计划" if "现金流" in missing else "现金流情况核对")
+ ("、行动方案" if "行动方案" in missing else "")
+ f"{kpi_name} {period}"
),
})
return {"entity_id": entity_id, "period": period, "issues": issues}
def build_report(results: list, checked_at: str) -> str:
lines = [f"# 预算↔现金流↔行动 闭环自检报告", f"**检查时间**: {checked_at}", ""]
total_issues = 0
for r in results:
lines.append(f"## 账套 #{r['entity_id']} · 期间 {r['period']}")
if not r["issues"]:
lines.append("- ✅ 无预算执行率异常")
for it in r["issues"]:
total_issues += 1
icon = "🔴" if it["level"] == "critical" else "🟡"
lines.append(f"- {icon} {it['kpi_name']}{it['period']}")
lines.append(f" 预算 {it['budget_value']:g} / 实际 {it['actual_value']:g} = 执行率 {it['exec_ratio']}%{it['abnormal_type']}")
lines.append(f" 现金流计划: {it['cash_plan_count']} 条 | 行动方案: {it['action_plan_count']}")
if it["missing"]:
lines.append(f" ⚠️ 缺失: {''.join(it['missing'])}")
lines.append(f" 💡 {it['suggestion']}")
else:
lines.append(f" ✅ 三闭环已同步")
lines.append("")
lines.append(f"---")
lines.append(f"共发现异常 {total_issues}")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--period", default=datetime.now().strftime("%Y-%m"))
parser.add_argument("--entity-id", type=int, default=1)
parser.add_argument("--push", action="store_true", help="异常时推送企微(8800/send)")
args = parser.parse_args()
os.makedirs(REPORTS_DIR, exist_ok=True)
checked_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
db = get_session_local()()
try:
result = check_entity(db, args.entity_id, args.period)
report = build_report([result], checked_at)
print(report)
# 写报告文件
fname = f"closed_loop_check_{datetime.now().strftime('%Y%m%d')}.md"
fpath = os.path.join(REPORTS_DIR, fname)
with open(fpath, "w", encoding="utf-8") as f:
f.write(report)
print(f"\n📄 报告已写入: {fpath}")
# 异常推送
if args.push and result["issues"]:
try:
import urllib.request
import urllib.parse
content = f"## 🔄 预算闭环自检({args.period})\n"
for it in result["issues"][:10]:
content += f"- {it['kpi_name']} 执行率{it['exec_ratio']}% 缺{'/'.join(it['missing']) or ''}\n"
content += f"\n{len(result['issues'])}项异常,详见系统报告"
data = urllib.parse.urlencode({"msg": content, "source": "管理会计OS"}).encode("utf-8")
req = urllib.request.Request("http://127.0.0.1:8800/send", data=data)
with urllib.request.urlopen(req, timeout=15) as resp:
print("推送:", resp.read().decode()[:200])
except Exception as e:
print(f"推送失败: {e}")
finally:
db.close()
if __name__ == "__main__":
main()