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
@@ -0,0 +1,56 @@
"""验证年度预算分解幂等 — R5 (2026-08-30)
调用 /api/cma/budget/auto-decompose 3 次,对比月度预算值是否不变。
用法: cd /root/cma-management/backend && ./venv/bin/python3 scripts/verify_decompose_idempotent.py
"""
import sys
import os
import json
import urllib.request
BASE = os.getenv("CMA_BASE", "http://127.0.0.1:8010")
def post(path, body, token=None):
req = urllib.request.Request(
BASE + path,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {token}" if token else ""},
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
def main():
# 登录(账套模式必须 entity_id)
login = post("/api/cma/auth/login", {"username": "admin", "password": "admin123", "entity_id": 1})
token = login.get("token") or login.get("access_token")
if not token:
print("❌ 登录失败:", login)
sys.exit(1)
print("✅ 登录成功")
runs = []
for i in range(3):
r = post("/api/cma/budget/auto-decompose", {"year": 2026, "method": "equal", "version": "v1.0"}, token)
print(f"{i+1}次: {r.get('message', '')} created={r.get('created', 0)}")
# 提取 (kpi_id -> monthly tuple)
snap = {}
for res in r.get("results", []):
snap[res["kpi_id"]] = tuple(res.get("monthly") or [])
runs.append(snap)
# 对比三次结果
same = runs[0] == runs[1] == runs[2]
print(f"\n三次结果一致: {'✅ 是(幂等)' if same else '❌ 否(不幂等)'}")
if not same:
for i in range(1, 3):
for kid in runs[0]:
if runs[0].get(kid) != runs[i].get(kid):
print(f" KPI {kid} 第1次={runs[0].get(kid)}{i+1}次={runs[i].get(kid)}")
sys.exit(0 if same else 1)
if __name__ == "__main__":
main()