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