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