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:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user