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
170 lines
6.4 KiB
Python
170 lines
6.4 KiB
Python
"""机会检测器 — 路线图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()
|