Files
cma-management/backend/app/utils/alert_attribution.py
T

242 lines
9.6 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.
"""告警归因引擎 — 管理会计OS (P1-③ 2026-08-28)
告警从"差多少"到"差在哪+怎么办":
- 子KPI维度拆解: 查 kpi_hierarchy 下级KPI各自差异(量差方向)
- 科目明细拆解: 查 kpi_subject_map → voucher_details 汇总科目发生额(价差方向)
- 趋势归因: 复用 deviation_engine.check_trend_anomaly 连续3期检测
- 场景建议: 按 alert_type 联查 scenario_suggestions
attribution JSON 结构:
{
"dimensions": [{"kpi_id":1,"kpi_name":"销售费用","deviation_value":-3.2,"deviation_rate":-18.6,"weight":0.5}],
"subjects": [{"subject_code":"6601","subject_name":"销售费用","amount_diff":2.1,"share_pct":34.5}],
"variance_type": "quantity_diff|price_diff|mixed",
"trend": {"anomaly":true,"type":"continuous_decline","periods":["2026-06","2026-07","2026-08"],"message":"连续3期下滑"}
}
"""
import logging
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.models import KPIHierarchy, KPISubjectMap, VoucherDetail, ScenarioSuggestion, KPIDefinition
logger = logging.getLogger("cma.alert_attribution")
# 收入型KPI特征(量差方向: 子KPI量级偏离)
REVENUE_TYPE_CODES = (
"SALES_TOTAL", "REVENUE", "F_REVENUE", "SALES_PROFIT_RATE",
"CUSTOMER_COUNT", "NEW_CUSTOMER", "TURNOVER_RATE",
)
def build_dimension_attribution(db: Session, kpi_id: int, period: str) -> list:
"""子KPI维度拆解 — 查 kpi_hierarchy 下级KPI各自差异(实际vs预算)"""
from app.models import KPIValue, BudgetPlan
children = db.query(KPIHierarchy).filter(
KPIHierarchy.parent_kpi_id == kpi_id
).all()
if not children:
return []
result = []
for rel in children:
child_id = rel.child_kpi_id
actual = db.query(KPIValue).filter(
KPIValue.kpi_id == child_id,
KPIValue.period == period,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.calculated_at.desc()).first()
budget = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == child_id,
BudgetPlan.period == period,
BudgetPlan.status == "active",
).order_by(BudgetPlan.updated_at.desc()).first()
av = actual.actual_value if actual else None
bv = budget.budget_value if budget else None
dev_value = None
dev_rate = None
if av is not None and bv is not None and bv != 0:
dev_value = round(av - bv, 2)
dev_rate = round(dev_value / bv * 100, 2)
child_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == child_id).first()
result.append({
"kpi_id": child_id,
"kpi_name": child_kpi.kpi_name if child_kpi else f"KPI-{child_id}",
"actual_value": av,
"budget_value": bv,
"deviation_value": dev_value,
"deviation_rate": dev_rate,
"weight": float(rel.weight or 0),
})
# 按偏差绝对值降序,最异常的排前面
result.sort(key=lambda x: -(abs(x["deviation_value"]) if x["deviation_value"] is not None else 0))
return result
def _subject_patterns(db: Session, kpi_id: int) -> list:
"""科目取数口径解析(2026-09-14 P1③④ 激活):
优先用已推导的取数映射 kpi_value_sources.filter_rule.subject_codes —— 那是把
kpi_subject_map(新准则 6xxx) 机械桥接到 voucher_details 实际科目(5xxx/1xxx/2xxx) 的结果;
无映射时退回 kpi_subject_map 原始科目(新老科目体系不一致时明细多为 0)。
"""
from app.models import KPIValueSource, KPISubjectMap
src = db.query(KPIValueSource).filter(
KPIValueSource.kpi_id == kpi_id,
KPIValueSource.source_table == "voucher_details",
KPIValueSource.status.in_(("active", "draft")),
).order_by(KPIValueSource.status.asc()).first()
rule = (src.filter_rule or {}) if src else {}
labels = {m.subject_code: (m.remark or m.subject_code)
for m in db.query(KPISubjectMap).filter(KPISubjectMap.kpi_id == kpi_id).all()}
if rule.get("subject_codes"):
return [{
"code": code,
"label": labels.get(str(code).rstrip("%"), str(code)),
"weight": float((rule.get("weights") or {}).get(code, 1.0)),
"measure": rule.get("measure", "credit"),
"source": "kpi_value_sources",
} for code in rule["subject_codes"]]
return [{
"code": m.subject_code,
"label": m.remark or m.subject_code,
"weight": float(m.weight if m.weight is not None else 1.0),
"measure": "credit",
"source": "kpi_subject_map",
} for m in db.query(KPISubjectMap).filter(KPISubjectMap.kpi_id == kpi_id).all()]
def build_subject_attribution(db: Session, kpi_id: int, period: str) -> list:
"""科目明细拆解 — 科目口径取 _subject_patterns(优先修正后的取数映射),按 entity 隔离"""
patterns = _subject_patterns(db, kpi_id)
if not patterns:
return []
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
entity_id = kpi.entity_id if kpi else None
result = []
for p in patterns:
q = db.query(
func.coalesce(func.sum(VoucherDetail.credit_amount), 0),
func.coalesce(func.sum(VoucherDetail.debit_amount), 0),
func.count(VoucherDetail.id),
).filter(VoucherDetail.period == period)
if "%" in p["code"]:
q = q.filter(VoucherDetail.subject_code.like(p["code"]))
else:
q = q.filter(VoucherDetail.subject_code == p["code"])
if entity_id is not None:
q = q.filter(VoucherDetail.entity_id == entity_id)
row = q.first()
credit_sum = float(row[0] or 0)
debit_sum = float(row[1] or 0)
rows = int(row[2] or 0)
# 口径: 损益类取贷方发生额(月结结转腿=本期发生额) / 余额类取净额(借-贷)
amount = (debit_sum - credit_sum) if p["measure"] == "net" else credit_sum
amount = round(amount * p["weight"], 2)
result.append({
"subject_code": p["code"],
"subject_name": p["label"],
"amount_diff": amount,
"calc_type": p["measure"],
"weight": p["weight"],
"rows": rows,
})
total = sum(abs(r["amount_diff"]) for r in result) or 0
for r in result:
r["share_pct"] = round(abs(r["amount_diff"]) / total * 100, 1) if total else 0
result.sort(key=lambda x: -abs(x["amount_diff"]))
return result
def detect_variance_type(kpi_code: str, dimensions: list, subjects: list) -> str:
"""量价差判定简化版:
成本型KPI科目发生额偏离 → price_diff(价差)
收入型KPI子KPI量级偏离 → quantity_diff(量差)
两者都有 → mixed
"""
has_dimension_dev = any(d.get("deviation_value") is not None and abs(d["deviation_value"]) > 0.01 for d in dimensions)
has_subject_dev = any(abs(s.get("amount_diff", 0)) > 0.01 for s in subjects)
is_revenue = any(code in (kpi_code or "").upper() for code in REVENUE_TYPE_CODES)
if is_revenue:
# 收入型: 子KPI(量)偏离为主 → quantity_diff
if has_dimension_dev:
return "quantity_diff"
if has_subject_dev:
return "price_diff"
return "mixed"
else:
# 成本型: 科目发生额(价)偏离为主 → price_diff
if has_subject_dev:
return "price_diff"
if has_dimension_dev:
return "quantity_diff"
return "mixed"
def match_scenario(db: Session, alert_type: Optional[str]) -> Optional[dict]:
"""按 alert_type 取 scenario_suggestions 建议(四类模板)"""
if not alert_type:
return None
s = db.query(ScenarioSuggestion).filter(
ScenarioSuggestion.alert_type == alert_type
).order_by(ScenarioSuggestion.sort_order.asc(), ScenarioSuggestion.id.asc()).first()
if not s:
return None
return {
"scenario_id": s.id,
"alert_type": s.alert_type,
"title": s.title,
"description": s.description,
"action_template": s.action_template,
"priority": s.priority,
}
def build_attribution(db: Session, kpi_id: int, period: str, alert_type: Optional[str] = None) -> dict:
"""组装完整归因JSON(供告警生成/详情接口共用)"""
from app.utils.deviation_engine import check_trend_anomaly
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
kpi_code = kpi.kpi_code if kpi else ""
dimensions = build_dimension_attribution(db, kpi_id, period)
subjects = build_subject_attribution(db, kpi_id, period)
variance_type = detect_variance_type(kpi_code, dimensions, subjects)
trend = check_trend_anomaly(db, kpi_id, period, consecutive=3)
# 默认场景归类(未显式传入时按KPI名称特征推断)
if not alert_type:
alert_type = infer_alert_type(kpi_code, kpi.kpi_name if kpi else "")
attribution = {
"dimensions": dimensions,
"subjects": subjects,
"variance_type": variance_type,
"trend": trend,
}
return attribution, alert_type
def infer_alert_type(kpi_code: str = "", kpi_name: str = "") -> str:
"""按KPI特征推断告警场景类型(四类: cash_low/cash_critical/cost_high/revenue_drop)"""
text = (kpi_code or "").upper() + (kpi_name or "")
if any(k in text for k in ("CASH", "现金", "货币资金", "资金")):
return "cash_low"
if any(k in text for k in ("COST", "费用", "成本", "支出")):
return "cost_high"
if any(k in text for k in ("REVENUE", "收入", "销售", "营收")):
return "revenue_drop"
return "cost_high"