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()
+125
View File
@@ -0,0 +1,125 @@
"""每日数据找人推送 — 路线图R2 (2026-08-30)
北极星③:主动推送扩大 —— 异常 + 机会两类。
- 异常类:待处理预警(kpi_alerts pending
- 机会类:KPI向好 / 预算余量 / 预测上行(opportunity_detector
复用企微通道 8800/send(公司群中继服务)。
用法: /root/cma-management/backend/venv/bin/python3 scripts/daily_push.py [--dry-run]
cron: 15 9 * * * (alert_generator 9:00 之后)
"""
import sys
import os
import json
import logging
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, KPIAlert
from scripts.opportunity_detector import detect_all, flatten
logger = logging.getLogger("cma.daily_push")
RELAY_URL = "http://127.0.0.1:8800/send"
SOURCE = "管理会计OS"
def collect_exceptions(db, limit: int = 10) -> list:
"""异常类:待处理预警(red/yellow)"""
out = []
alerts = db.query(KPIAlert).filter(
KPIAlert.status == "pending",
KPIAlert.alert_level.in_(["red", "yellow"]),
).order_by(KPIAlert.created_at.desc()).limit(limit).all()
for a in alerts:
k = db.query(KPIDefinition).filter(KPIDefinition.id == a.kpi_id).first()
kpi_name = k.kpi_name if k else f"KPI#{a.kpi_id}"
icon = "🔴" if a.alert_level == "red" else "🟡"
out.append({
"type": "exception",
"title": f"{icon} {kpi_name} 预警",
"detail": f"({a.alert_level}) {a.alert_message}",
"kpi_id": a.kpi_id,
"kpi_name": kpi_name,
"period": "",
})
return out
def build_message(exceptions: list, opportunities: list) -> str:
"""组装 markdown 推送内容"""
now = datetime.now().strftime("%Y-%m-%d %H:%M")
lines = [f"## 📊 管理会计OS · 每日经营播报", f"**{now}**", ""]
lines.append("### ⚠️ 异常关注")
if exceptions:
for e in exceptions:
lines.append(f"- {e['title']}")
lines.append(f" {e['detail']}")
else:
lines.append("- 今日无待处理预警 ✅")
lines.append("")
lines.append("### 🎯 机会发现")
if opportunities:
for o in opportunities:
lines.append(f"- {o['title']}")
lines.append(f" {o['detail']}")
else:
lines.append("- 今日暂无显著机会")
lines.append("")
lines.append("---")
lines.append("💡 数据找人:异常要处理,机会要把握。详情见 CMA 系统。")
return "\n".join(lines)
def push_wecom(msg: str) -> dict:
"""通过8800中继推送企微"""
import urllib.request
import urllib.parse
data = urllib.parse.urlencode({
"msg": msg,
"source": SOURCE,
"msgtype": "markdown",
}).encode("utf-8")
req = urllib.request.Request(RELAY_URL, data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
result = json.loads(resp.read().decode("utf-8"))
return result
except Exception as e:
return {"ok": False, "error": f"推送异常: {e}"}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true", help="只打印不推送")
parser.add_argument("--entity-id", type=int, default=1)
args = parser.parse_args()
db = get_session_local()()
try:
exceptions = collect_exceptions(db)
opportunities = flatten(detect_all(db, args.entity_id))
msg = build_message(exceptions, opportunities)
if args.dry_run:
print(msg)
print(f"\n[DRY-RUN] 异常{len(exceptions)}条 / 机会{len(opportunities)}")
return
result = push_wecom(msg)
print(f"推送结果: {json.dumps(result, ensure_ascii=False)}")
print(f"统计: 异常{len(exceptions)}条 / 机会{len(opportunities)}")
finally:
db.close()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
main()
+169
View File
@@ -0,0 +1,169 @@
"""机会检测器 — 路线图R2 数据找人扩大 (2026-08-30)
北极星③:主动推送扩大 —— 异常 + 机会两类。
本脚本检测三类机会(复用 budget/kpi 数据,不新建表):
1. KPI向好 (kpi_improving) : 最近3期执行率>110% 且最新期呈上升趋势
2. 预算余量 (budget_headroom): 可用预算>30%(预算执行率<70%
3. 滚动机会 (rolling_up) : 预测值上升(kpi_forecast_log 最新>上期)
输出:机会列表 [{type, title, detail, kpi_id, kpi_name, period}]
"""
import sys
import os
import json
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, KpiForecastLog
HIGH_RATIO = 1.1 # 执行率>110% = 超预期
LOW_EXEC_RATIO = 0.7 # 执行率<70% = 预算余量大(可用>30%
def _exec_ratio(actual, target):
if target is None or target == 0:
return None
return actual / target
def detect_kpi_improving(db, entity_id: int, min_ratio: float = HIGH_RATIO) -> list:
"""KPI向好:最近3期执行率均>110%,且最新期>上期(上升中)"""
out = []
kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
).all()
now = datetime.now()
for k in kpis:
if not k.target_value or k.target_value <= 0:
continue
vals = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).limit(3).all()
if len(vals) < 3:
continue
ratios = [_exec_ratio(v.actual_value, k.target_value) for v in vals]
if any(r is None or r < min_ratio for r in ratios):
continue
# 最新期 > 上期(上升趋势);若最新期低于上期但整体仍>110%,也算(持续向好)
latest, prev = vals[0], vals[1]
trend = "上升" if latest.actual_value > prev.actual_value else "高位"
out.append({
"type": "kpi_improving",
"title": f"📈 {k.kpi_name} 持续向好",
"detail": (f"{latest.period}实际{latest.actual_value:g}/目标{k.target_value:g}"
f" 达成率{ratios[0]*100:.0f}%({trend}),近3期均超110%"),
"kpi_id": k.id,
"kpi_name": k.kpi_name,
"period": latest.period,
})
return out
def detect_budget_headroom(db, entity_id: int, max_ratio: float = LOW_EXEC_RATIO) -> list:
"""预算余量:当月预算执行率<70%(可用预算>30%
注意:跳过实际值为负的行(现金流/利润为负是异常不是余量),
同 KPI 同期间多版本预算只取一条(去重)。
"""
out = []
period = datetime.now().strftime("%Y-%m")
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)
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()
if not actual or actual.actual_value is None or actual.actual_value <= 0:
continue
ratio = actual.actual_value / b.budget_value
if ratio < max_ratio:
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
kpi_name = k.kpi_name if k else f"KPI#{b.kpi_id}"
headroom = (1 - ratio) * 100
out.append({
"type": "budget_headroom",
"title": f"💼 {kpi_name} 预算余量充足",
"detail": (f"{period}预算{b.budget_value:g}/实际{actual.actual_value:g}"
f" 执行率{ratio*100:.0f}%,可用预算余量约{headroom:.0f}%"),
"kpi_id": b.kpi_id,
"kpi_name": kpi_name,
"period": period,
})
return out
def detect_rolling_up(db, entity_id: int) -> list:
"""滚动机会:预测值上升(最新预测 > 上期预测)"""
out = []
# 每个KPI取最近两条预测记录
kpi_ids = [r[0] for r in db.query(KpiForecastLog.kpi_id).filter(
KpiForecastLog.entity_id == entity_id).distinct().limit(50).all()]
for kid in kpi_ids:
rows = db.query(KpiForecastLog).filter(
KpiForecastLog.entity_id == entity_id,
KpiForecastLog.kpi_id == kid,
KpiForecastLog.forecast_value.isnot(None),
).order_by(KpiForecastLog.created_at.desc(), KpiForecastLog.id.desc()).limit(2).all()
if len(rows) < 2:
continue
latest, prev = rows[0], rows[1]
if latest.forecast_value > prev.forecast_value:
k = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first()
kpi_name = k.kpi_name if k else f"KPI#{kid}"
pct = (latest.forecast_value / prev.forecast_value - 1) * 100 if prev.forecast_value else 0
out.append({
"type": "rolling_up",
"title": f"🔮 {kpi_name} 预测上行",
"detail": (f"预测值 {prev.forecast_value:g}{latest.forecast_value:g}"
f" (+{pct:.1f}%){latest.period}期间"),
"kpi_id": kid,
"kpi_name": kpi_name,
"period": latest.period,
})
return out
def detect_all(db, entity_id: int = 1) -> dict:
"""检测全部机会,按类型分组"""
return {
"kpi_improving": detect_kpi_improving(db, entity_id),
"budget_headroom": detect_budget_headroom(db, entity_id),
"rolling_up": detect_rolling_up(db, entity_id),
}
def flatten(detected: dict) -> list:
out = []
for cat in ("kpi_improving", "budget_headroom", "rolling_up"):
out.extend(detected.get(cat, []))
return out
def main():
db = get_session_local()()
try:
detected = detect_all(db)
total = sum(len(v) for v in detected.values())
print(json.dumps(detected, ensure_ascii=False, indent=2))
print(f"\n机会总数: {total}")
finally:
db.close()
if __name__ == "__main__":
main()
@@ -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()