Files
cma-management/backend/scripts/kpi_alert_attribution_backfill.py
T

178 lines
8.2 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.
#!/usr/bin/env python3
"""预算偏差告警归因回填 —— yanxue-20260914-budget-p13p14-activation(P1-③ 激活)
两类动作(都在本进程内组装,不依赖线上服务是否已加载新代码):
1) attribution 为空的历史告警 → 现算归因并落库(等价于现成接口
GET /budget/deviation-alerts/{id}/attribution 的组装逻辑,见 app/api/budget.py)
2) 已有旧口径归因的告警 → 用修正后的科目口径重算覆盖(旧值备份见
/root/WB_WS/_backups/p13p14-20260914/)
另: alert_type 为空 → 按 KPI 特征推断补上;scenario_id 为空且命中
scenario_suggestions → 补 scenario_id(让告警"能解释、能关")
用法:
/usr/bin/python3 scripts/kpi_alert_attribution_backfill.py # dry-run(只算不写)
/usr/bin/python3 scripts/kpi_alert_attribution_backfill.py --apply # 落库
落库前置: cma_api 需对 budget_deviation_alerts 有 UPDATE 权限
(SHOW GRANTS 现状: 该表未在授权清单内 → 1142 UPDATE command denied)。
"""
import json
import os
import sys
from types import SimpleNamespace
if __name__ == "__main__":
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 BudgetDeviationAlert, KPIDefinition, KPIValueSource, ScenarioSuggestion
from app.utils.alert_attribution import build_attribution, match_scenario
OUT = "/tmp/budget-p13p14/attribution-conclusions.json"
def mapping_status(db, kpi_id):
src = db.query(KPIValueSource).filter(
KPIValueSource.kpi_id == kpi_id,
KPIValueSource.source_table == "voucher_details",
).order_by(KPIValueSource.status.asc()).first()
return src.status if src else None
def conclusion(alert, kpi, attribution, scenario, map_status):
dims = attribution.get("dimensions") or []
subs = attribution.get("subjects") or []
trend = attribution.get("trend") or {}
vtype = attribution.get("variance_type")
hot = [s for s in subs if abs(s.get("amount_diff") or 0) > 0.01][:3]
if hot:
sub_txt = "; ".join(
f"{s['subject_code']} {s.get('subject_name', '')} "
f"{s['amount_diff']:+.2f}元(x{s.get('weight')})" for s in hot)
depth = f"科目级({map_status or 'kpi_subject_map原口径'})"
elif map_status:
sub_txt = (f"科目明细为空/金额为0(映射={map_status},当期凭证明细无该科目发生额)")
depth = f"KPI级(映射={map_status})"
else:
sub_txt = "无取数映射(kpi_subject_map 科目未机械桥接到 voucher_details 科目)→ 仅 KPI 级结论"
depth = "KPI级(无映射)"
dim_txt = "; ".join(
f"{d.get('kpi_name')} 偏差{d.get('deviation_value')}" for d in dims
if d.get("deviation_value") is not None and abs(d["deviation_value"]) > 0.01) or "无下级KPI层级"
trend_txt = (f"{trend.get('type')}({trend.get('message')})" if trend.get("anomaly")
else "无连续3期异常")
view = (
f"[{alert.id}] {kpi.kpi_code if kpi else alert.kpi_id} {kpi.kpi_name if kpi else ''} {alert.period}: "
f"实际{alert.actual_value} vs 预算{alert.budget_value}(偏差{alert.deviation_rate}%, {alert.alert_level})| "
f"场景={alert.alert_type or '未分类'}{'(建议:' + scenario['title'] + ')' if scenario else ''} | "
f"深度={depth} | 量价={vtype} | 主科目={sub_txt} | 子KPI={dim_txt} | 趋势={trend_txt}"
)
return view, depth
def main():
apply = "--apply" in sys.argv
db = get_session_local()()
try:
alerts = db.query(BudgetDeviationAlert).filter(
BudgetDeviationAlert.entity_id == 1).order_by(BudgetDeviationAlert.id).all()
print(f"== 告警: {len(alerts)} 条(open {sum(1 for a in alerts if a.status == 'open')})"
f" | mode={'APPLY' if apply else 'DRY-RUN'}")
filled, recomputed, type_filled, scenario_filled = 0, 0, 0, 0
lines = []
for a in alerts:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == a.kpi_id).first()
mstat = mapping_status(db, a.kpi_id)
att, inferred = build_attribution(db, a.kpi_id, a.period, a.alert_type)
had = a.attribution is not None
if apply:
if not had:
a.attribution = att
filled += 1
else:
if (a.attribution or {}) != att:
a.attribution = att
recomputed += 1
if a.alert_type is None and inferred:
a.alert_type = inferred
type_filled += 1
eff_type = a.alert_type or inferred
scenario = match_scenario(db, eff_type)
if apply and scenario and a.scenario_id is None:
a.scenario_id = scenario["scenario_id"]
scenario_filled += 1
alert_copy = a
if not apply:
# 只读预览:不改 ORM 对象(避免 autoflush 落库)
alert_copy = SimpleNamespace(
id=a.id, kpi_id=a.kpi_id, period=a.period, status=a.status,
alert_level=a.alert_level, budget_value=a.budget_value,
actual_value=a.actual_value, deviation_rate=a.deviation_rate,
deviation_value=a.deviation_value, alert_type=eff_type,
scenario_id=a.scenario_id)
view, depth = conclusion(alert_copy, kpi, att, scenario, mstat)
lines.append({
"id": a.id, "kpi_id": a.kpi_id, "kpi_code": kpi.kpi_code if kpi else None,
"kpi_name": kpi.kpi_name if kpi else None,
"period": a.period, "status": a.status, "alert_level": a.alert_level,
"budget_value": a.budget_value, "actual_value": a.actual_value,
"deviation_rate": a.deviation_rate, "alert_type": eff_type,
"scenario_id": a.scenario_id,
"mapping_status": mstat, "depth": depth,
"n_subjects": len(att.get("subjects") or []),
"n_subject_moves": sum(1 for s in (att.get("subjects") or [])
if abs(s.get("amount_diff") or 0) > 0.01),
"variance_type": att.get("variance_type"),
"had_attribution": had,
"view": view,
})
if apply:
try:
db.commit()
except Exception as e:
db.rollback()
print(f"!! 落库失败(未写任何行): {type(e).__name__}: {str(e)[:200]}")
return 2
for row in lines:
print(row["view"])
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w") as fh:
json.dump(lines, fh, ensure_ascii=False, indent=1)
print(f"\n== 结论落盘: {OUT} ({len(lines)} 条)")
depths = {}
for row in lines:
depths[row["depth"]] = depths.get(row["depth"], 0) + 1
print(f"== 归因深度分布: {json.dumps(depths, ensure_ascii=False)}")
opens = [r for r in lines if r["status"] == "open"]
print(f"== open 告警有结论: {len(opens)}/{len(opens)}"
f" | 其中科目级可解释: {sum(1 for r in opens if r['depth'].startswith('科目级'))}")
if apply:
n_attr = db.query(BudgetDeviationAlert).filter(
BudgetDeviationAlert.entity_id == 1,
BudgetDeviationAlert.attribution.isnot(None)).count()
total = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.entity_id == 1).count()
n_scen = db.query(BudgetDeviationAlert).filter(
BudgetDeviationAlert.entity_id == 1,
BudgetDeviationAlert.scenario_id.isnot(None)).count()
print(f"== 落库结果: attribution {n_attr}/{total} | scenario_id {n_scen} | "
f"新填 {filled} / 重算 {recomputed} / 场景类型补全 {type_filled} / "
f"场景建议补全 {scenario_filled}")
else:
print("== DRY-RUN: 未写库(落库需 --apply,且 cma_api 需 UPDATE 授权)")
return 0
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())