Files
cma-management/backend/app/utils/deviation_engine.py
T
Hermes CI Fix 59e65a68b9 fix(alerts): cash_plan 到期未收款预警引擎修复 — entity归属/KPI实体感知/余额快照批次/去重刷新/端点鉴权 (dama-cashplan-alert-engine-fix-20260913)
F1 两处 KPIAlert 显式写 entity_id(列默认 1 → 博海预警错挂酣客);顺带修 deviation_engine/
   alert_generator/deviation_push/bot_bridge/bot_bridge_v2 同表创建点
F2 KPI 选择实体感知:候选码 ∪ 实体前缀后缀码(_AR_BALANCE/_CASH_BALANCE/_AP_BALANCE)
   + 兜底 ORDER BY id(此前命中 uk_entity_kpi 必中 BH_ADM_EXPENSE 437 管理费用)
F3 去重命中改为刷新 alert_message/等级/suggestion.days_late/归属(此前 continue → 文本冻结首版)
F4 余额快照批次(bohai_*_20260630 / receivables_migrate)开关:默认 skip 不逐户,另备
   aggregate(按批次聚合1条) / per_customer(旧行为)
F5 POST /api/cma/admin/alerts/check 补鉴权(无 token 401)+ 可选 entity_id 只跑指定账套
2026-09-13 00:37:10 +08:00

393 lines
14 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
实际 vs 预算/目标对比,超阈值自动推送预警
功能:
1. 实际 vs 预算差异计算(差异额/差异率)
2. 同比/环比差异计算
3. 趋势异常检测(连续N期下滑/上升)
4. 差异预警触发(集成到现有预警系统)
"""
import logging
import json
from datetime import datetime
from typing import Optional
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConfig
logger = logging.getLogger("cma.deviation")
# 越高越好型KPI默认列表(P2-⑤ 2026-08-28: 提为 system_configs 可配置)
DEFAULT_HIGHER_BETTER = [
"SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE",
"RECEIVABLE_TURNOVER", "TURNOVER_RATE",
"CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE",
]
CONFIG_KEY_HIGHER_BETTER = "kpi_alert_higher_better"
def get_higher_better_codes(db, entity_id: int = None) -> list:
"""读取越高越好型KPI编码列表(system_configs 可维护,无配置回落默认)"""
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
).first()
if cfg and cfg.config_value:
try:
codes = json.loads(cfg.config_value)
if isinstance(codes, list):
return [str(c) for c in codes]
except Exception:
logger.warning("system_configs[%s] 解析失败, 回落默认", CONFIG_KEY_HIGHER_BETTER)
return list(DEFAULT_HIGHER_BETTER)
# ============================================================
# 差异计算
# ============================================================
def calc_deviation(actual: float, budget: float) -> dict:
"""计算差异额和差异率"""
if budget is None or budget == 0:
return {
"deviation_amount": None,
"deviation_rate": None,
"is_over_budget": None,
}
amount = round(actual - budget, 2)
rate = round(amount / budget * 100, 2)
return {
"deviation_amount": amount,
"deviation_rate": rate,
"is_over_budget": amount > 0,
}
def get_budget_for_kpi(db, kpi_id: int, period: str, version: str = None) -> Optional[float]:
"""获取指定KPI在指定期间的预算值"""
query = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.status == "active",
)
if version:
query = query.filter(BudgetPlan.version == version)
plan = query.order_by(BudgetPlan.updated_at.desc()).first()
return plan.budget_value if plan else None
def get_actual_for_kpi(db, kpi_id: int, period: str) -> Optional[float]:
"""获取指定KPI在指定期间的实际值"""
val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == period,
).order_by(KPIValue.calculated_at.desc()).first()
return val.actual_value if val else None
def calc_period_deviation(db, kpi_id: int, period: str) -> dict:
"""单KPI单期的差异计算"""
actual = get_actual_for_kpi(db, kpi_id, period)
budget = get_budget_for_kpi(db, kpi_id, period)
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
kpi_code = kpi.kpi_code if kpi else "unknown"
# 如果没预算值,用 target_value 作为替代
if budget is None and kpi:
# 尝试把年度目标按月均分
month = int(period.split("-")[1])
target = kpi.target_value
if target and target > 0 and kpi.frequency == "monthly":
budget = round(target / 12, 2)
deviation = calc_deviation(actual, budget) if actual is not None else None
result = {
"kpi_id": kpi_id,
"kpi_code": kpi_code,
"period": period,
"actual_value": actual,
"budget_value": budget,
}
if deviation:
result.update(deviation)
return result
# ============================================================
# 同比/环比差异
# ============================================================
def calc_period_diff(db, kpi_id: int, current_period: str, diff_type: str = "yoy") -> dict:
"""计算同比(上年同期)或环比(上期)差异"""
year, month = current_period.split("-")
y, m = int(year), int(month)
if diff_type == "yoy":
# 同比:上年同期
prev_period = f"{y-1}-{m:02d}"
label = "同比"
elif diff_type == "mom":
# 环比:上个月
prev_m = m - 1
prev_y = y
if prev_m <= 0:
prev_m += 12
prev_y -= 1
prev_period = f"{prev_y}-{prev_m:02d}"
label = "环比"
else:
return {"error": f"未知比较类型: {diff_type}"}
current = get_actual_for_kpi(db, kpi_id, current_period)
previous = get_actual_for_kpi(db, kpi_id, prev_period)
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
kpi_code = kpi.kpi_code if kpi else "unknown"
if current is None or previous is None or previous == 0:
return {
"kpi_id": kpi_id,
"kpi_code": kpi_code,
"type": diff_type,
"label": label,
"current_period": current_period,
"prev_period": prev_period,
"current_value": current,
"prev_value": previous,
"diff_amount": None,
"diff_rate": None,
}
diff_amount = round(current - previous, 2)
diff_rate = round(diff_amount / previous * 100, 2)
return {
"kpi_id": kpi_id,
"kpi_code": kpi_code,
"type": diff_type,
"label": label,
"current_period": current_period,
"prev_period": prev_period,
"current_value": current,
"prev_value": previous,
"diff_amount": diff_amount,
"diff_rate": diff_rate,
}
# ============================================================
# 趋势检测
# ============================================================
def check_trend_anomaly(db, kpi_id: int, period: str, consecutive: int = 3) -> dict:
"""检测连续N期下滑或上升的趋势异常"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
return {"anomaly": False}
# 获取包括当前期在内的近期数据
year, month = period.split("-")
y, m = int(year), int(month)
values = []
for i in range(consecutive + 2): # 多取2期做参考
p = f"{y}-{m:02d}"
v = get_actual_for_kpi(db, kpi_id, p)
if v is not None:
values.append({"period": p, "value": v})
m -= 1
if m <= 0:
m += 12
y -= 1
values.reverse() # 按时间正序
if len(values) < consecutive:
return {"anomaly": False, "reason": "数据不足"}
last_n = values[-consecutive:]
all_decreasing = all(last_n[i]["value"] > last_n[i + 1]["value"] for i in range(len(last_n) - 1))
all_increasing = all(last_n[i]["value"] < last_n[i + 1]["value"] for i in range(len(last_n) - 1))
if all_decreasing:
return {
"anomaly": True,
"type": "continuous_decline",
"level": "yellow" if consecutive >= 3 else "green",
"periods": [v["period"] for v in last_n],
"values": [v["value"] for v in last_n],
"message": f"{kpi.kpi_name} 连续{consecutive}期下滑",
}
if all_increasing:
return {
"anomaly": True,
"type": "continuous_rise",
"level": "yellow" if consecutive >= 3 else "green",
"periods": [v["period"] for v in last_n],
"values": [v["value"] for v in last_n],
"message": f"{kpi.kpi_name} 连续{consecutive}期上升(可能过热)",
}
return {"anomaly": False}
# ============================================================
# 差异预警触发
# ============================================================
def build_deviation_alert(db, kpi, period: str, entity_id: int = 1, min_rate: float = 10.0) -> dict:
"""统一告警构建 — 双出口共享一套逻辑 (P2-⑤ 2026-08-28)
预算告警(budget_deviation_alerts) 与 KPIAlert 都调用本函数,差异仅级别映射:
- budget 出口: warning/critical @ 20/50
- KPIAlert 出口: yellow/red @ 10/30
归因(P1-③): attribution 拆解 + 场景建议 由 alert_attribution 组装。
返回:
triggered: bool 是否触发
level: budget出口级别 warning/critical
kpi_alert_level: KPIAlert出口级别 yellow/red
deviation: calc_period_deviation 结果
suggestion: 模板建议文案
alert_type: 归因场景类型
attribution: 归因JSON dict
scenario_id: 场景建议ID
"""
from app.utils.alert_attribution import build_attribution, match_scenario
deviation = calc_period_deviation(db, kpi.id, period)
if deviation.get("deviation_rate") is None:
return {"triggered": False}
rate = abs(deviation["deviation_rate"])
actual = deviation.get("actual_value")
budget = deviation.get("budget_value")
# 方向性:越高越好型(配置化,system_configs.kpi_alert_higher_better)
higher_better = kpi.kpi_code in get_higher_better_codes(db, entity_id)
if higher_better:
# 实际低于预算才是问题
if not (actual is not None and budget is not None and actual < budget and rate >= min_rate):
return {"triggered": False}
suggestion = (
f"实际值低于预算 {rate}%,建议分析业务量未达预期的原因(子KPI拆解见归因),"
f"制定增量获客或转化提升计划"
)
else:
# 实际高于预算才是问题(成本型)
if not (actual is not None and budget is not None and actual > budget and rate >= min_rate):
return {"triggered": False}
suggestion = (
f"实际值超出预算 {rate}%,建议核查超支原因(科目明细拆解见归因)并采取控制措施"
)
# 级别映射(双出口)
budget_level = "critical" if rate > 50 else "warning"
kpi_alert_level = "red" if rate >= 30 else "yellow"
# 归因组装 (P1-③)
alert_type = None
attribution = None
scenario_id = None
try:
attribution, alert_type = build_attribution(db, kpi.id, period)
scenario = match_scenario(db, alert_type)
if scenario:
scenario_id = scenario["scenario_id"]
except Exception as e: # 归因失败不阻断告警主流程
logger.warning("归因组装失败 kpi=%s: %s", kpi.kpi_code, e)
return {
"triggered": True,
"level": budget_level,
"kpi_alert_level": kpi_alert_level,
"deviation": deviation,
"suggestion": suggestion,
"alert_type": alert_type,
"attribution": attribution,
"scenario_id": scenario_id,
}
def run_deviation_check(db_session, period: str = None) -> int:
"""运行差异预警检查,返回新增预警数(统一走 build_deviation_alert,P2-⑤)"""
if period is None:
period = datetime.now().strftime("%Y-%m")
kpis = db_session.query(KPIDefinition).filter(
KPIDefinition.status == "active"
).all()
new_count = 0
for kpi in kpis:
# 1. 差异预警:实际 vs 预算(统一逻辑)
result = build_deviation_alert(db_session, kpi, period)
if result["triggered"]:
deviation = result["deviation"]
alert_msg = (
f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} "
f"vs 预算{deviation['budget_value']},"
f"差异率{deviation['deviation_rate']}%"
)
# 检查是否已有同KPI同期间的差异预警
existing = db_session.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id,
KPIAlert.alert_message.contains("[差异预警]"),
KPIAlert.alert_message.contains(period),
KPIAlert.status.in_(["pending", "processing"]),
).first()
if not existing:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属(原缺省=1 串账套)
kpi_id=kpi.id,
alert_level=result["kpi_alert_level"],
alert_message=f"[差异预警] {alert_msg}",
alert_type=result["alert_type"] or "actual",
suggestion=result["suggestion"],
status="pending",
)
db_session.add(alert)
new_count += 1
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
# 2. 趋势异常检测(每期检查连续3期)
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
if trend.get("anomaly") and trend.get("level") in ("yellow", "red"):
trend_alert_msg = trend["message"]
existing_trend = db_session.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id,
KPIAlert.alert_message.contains("[趋势预警]"),
KPIAlert.status.in_(["pending", "processing"]),
).first()
if not existing_trend:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id,
alert_level=trend["level"],
alert_message=f"[趋势预警] {trend_alert_msg}",
status="pending",
)
db_session.add(alert)
new_count += 1
logger.info(f" 新增趋势预警 [{trend['level']}] {trend_alert_msg}")
db_session.commit()
return new_count
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
db = get_session_local()()
try:
n = run_deviation_check(db)
print(f"差异预警检查完成: 新增 {n} 条")
finally:
db.close()