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