Files
cma-management/backend/scripts/opportunity_detector.py
T
Hermes CI Fix ad68471b29 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
2026-08-30 12:05:36 +08:00

170 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""机会检测器 — 路线图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()