Files
cma-management/backend/scripts/alert_generator.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

250 lines
10 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
比对 KPI 实际值与阈值配置(threshold_green/yellow/red),超出则写入 kpi_alerts
在 daily_sync 之后运行
"""
import sys
import os
import json
import logging
from datetime import datetime, timedelta
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, KPIAlert, ActionPlan
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
logger = logging.getLogger("cma.alert_gen")
def _parse_threshold(expr: str) -> tuple:
"""[兼容保留] 统一委托 app.utils.threshold_parser.parse_threshold"""
from app.utils.threshold_parser import parse_threshold as _p
return _p(expr)
def cleanup_green_alerts(db_session) -> int:
"""Bug1修复: 清理历史遗留的green级别pending预警(正常值不应出现在待办)"""
greens = db_session.query(KPIAlert).filter(
KPIAlert.alert_level == "green",
KPIAlert.status.in_(["pending", "processing"]),
).all()
count = 0
for g in greens:
g.status = "resolved"
g.resolution = "自动清理: green(正常)级别不生成待处理预警"
g.resolved_at = datetime.now()
count += 1
if count:
db_session.commit()
logger.info(f" 清理历史green预警 {count} 条 → resolved")
return count
def _check_threshold(actual: float, threshold_expr: str, level: str) -> tuple:
"""检查实际值是否触发阈值,返回 (触发, 消息)(统一走 app.utils.threshold_parser)"""
if not threshold_expr or actual is None:
return False, ""
if not eval_threshold(actual, threshold_expr):
return False, ""
msg = (f"KPI当前值 {actual:.2f},触发{ {'green': '正常', 'yellow': '预警', 'red': '紧急'}.get(level, level) }阈值 "
f"({threshold_expr})")
return True, msg
def run_alert_check(db_session, period: str = None) -> int:
"""检查所有KPI的实际值是否触发预警,返回新生成的预警数"""
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:
# 跳过无阈值的KPI
thr = {
"red": kpi.threshold_red,
"yellow": kpi.threshold_yellow,
"green": kpi.threshold_green,
}
if not any(thr.values()):
continue
# 获取该KPI当前期间的最新值
latest = db_session.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
).order_by(KPIValue.calculated_at.desc()).first()
if not latest or latest.actual_value is None:
continue
actual = latest.actual_value
# 判定顺序:红灯优先 → 绿灯 → 黄灯(最严重者优先,消除宽黄灯遮蔽红灯)
# 2026-09-11 修复「黄灯遮蔽红灯」:原顺序 绿→黄→红命中即止,宽黄灯(如 Y='>76')
# 会让红灯(如 R='>91')永远不可达(id=6 实测值 95 被判黄);
# green 先于 yellow:green 是主条件,yellow 是更宽的兜底带(id=1 G='>=800' Y='>=700')。
# Bug1修复: green(正常)不生成pending预警,只对yellow/red落库
for level in LEVEL_PRIORITY:
expr = thr[level]
if not expr:
continue
triggered, msg = _check_threshold(actual, expr, level)
if triggered:
if level == "green":
# 正常状态:不生成待处理预警(如有旧的red/yellow预警自动解除)
break
# 检查是否已有该期间该KPI同级别的预警
existing = db_session.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id,
KPIAlert.kpi_value_id == latest.id,
KPIAlert.alert_level == level,
).first()
if existing:
# 已有预警,跳过
break
# 创建新预警
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id,
kpi_value_id=latest.id,
alert_level=level,
alert_message=f"{kpi.kpi_name}[{period}] {msg}",
status="pending",
)
db_session.add(alert)
db_session.flush() # 获取alert.id
new_count += 1
logger.info(f" 新增预警 [{level}] {kpi.kpi_name}: {msg}")
# Bug2修复: red/yellow预警自动创建行动计划(预警→行动闭环)
if level in ("red", "yellow"):
plan_title = f"改善: {kpi.kpi_name} {msg}"
if kpi.kpi_name and "现金" in kpi.kpi_name or kpi.kpi_code == "F_OP_CFLOW":
plan_title = f"资金缺口应对: {msg} —— 催收应收+安排融资"
elif kpi.kpi_code == "F_AR_DAYS":
plan_title = f"应收催收: {msg} —— 逾期客户专项跟进"
elif kpi.kpi_code == "C_REBATE_RATE":
plan_title = f"渠补谈判: {msg} —— 下游渠道返利谈判"
elif kpi.kpi_code == "F_COST_RATIO":
plan_title = f"费用压缩: {msg} —— 管理费削减计划"
plan = ActionPlan(
alert_id=alert.id,
kpi_id=kpi.id,
title=plan_title,
assignee="财务部",
priority="high" if level == "red" else "medium",
due_date=datetime.now().date() + timedelta(days=3 if level == "red" else 7),
created_by="alert_generator",
)
db_session.add(plan)
db_session.flush()
alert.action_plan_linked_id = plan.id
logger.info(f" ↳ 自动创建行动计划 #{plan.id}: {plan_title}")
break # 只取最高级别
elif level == "red" and not triggered:
# 红没触发(判定顺序中 red 最先)→ 若该期间该KPI已有 pending/processing 红灯,
# 说明当前值已不再触发红色阈值,自动解除(保留原有自动降级行为)
existing_red = db_session.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id,
KPIAlert.kpi_value_id == latest.id,
KPIAlert.alert_level == "red",
KPIAlert.status.in_(["pending", "processing"]),
).first()
if existing_red:
existing_red.status = "resolved"
existing_red.resolution = "自动解除: 当前值不再触发红色阈值"
existing_red.resolved_at = datetime.now()
logger.info(f" 自动解除预警 [red] {kpi.kpi_name}")
db_session.commit()
return new_count
def run_rule_engine_checks(db_session) -> dict:
"""预警规则引擎(rule_type 驱动)— 预测偏差 + 客户集中度(P2 2026-09-10)
复用 alert_rules 的检查端点实现(FastAPI 路由函数=带 Depends 默认值的普通函数,
显式传参即可复用,零逻辑复制),按存在规则的账套逐个执行。
任何单点失败只记录日志,不影响静态阈值主流程。
"""
from app.api.alert_rules import (
AlertRule, run_forecast_deviation_check, run_customer_concentration_check,
)
stats = {"forecast_deviation": 0, "customer_concentration": 0}
try:
rows = db_session.query(AlertRule.entity_id).filter(
AlertRule.enabled == 1,
AlertRule.rule_type.in_(("forecast_deviation", "customer_concentration")),
).distinct().all()
except Exception as e:
logger.error(f"规则引擎检查跳过(规则查询失败): {e}")
return stats
for (eid,) in rows:
for key, fn in (("forecast_deviation", run_forecast_deviation_check),
("customer_concentration", run_customer_concentration_check)):
try:
r = fn(db=db_session, entity_id=eid)
n = int(r.get("generated", 0) or 0)
stats[key] += n
if n:
logger.info(f" 规则预警 entity={eid} {key}: {n}条")
except Exception as e:
logger.error(f"规则检查失败 entity={eid} {key}: {e}")
return stats
def generate_and_push(db_session) -> dict:
"""生成预警并推送,返回统计"""
period = datetime.now().strftime("%Y-%m")
# 0. Bug1修复: 先清理历史green预警
cleaned = cleanup_green_alerts(db_session)
# 1. 生成预警
logger.info(f"开始预警检查 ({period})...")
new_count = run_alert_check(db_session, period)
logger.info(f"预警检查完成: 新增 {new_count} 条")
# 1b. 规则引擎检查(预测偏差 / 客户集中度,P2 2026-09-10)
rule_stats = run_rule_engine_checks(db_session)
# 2. 推送
pushed = 0
if new_count > 0:
try:
from app.utils.notifier import push_pending_alerts
pushed = push_pending_alerts(db_session)
logger.info(f"推送完成: {pushed} 条")
except Exception as e:
logger.error(f"推送失败: {e}")
return {"period": period, "new_alerts": new_count, "pushed": pushed,
"cleaned_green": cleaned, "rule_engine": rule_stats}
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
db = get_session_local()()
try:
result = generate_and_push(db)
print(f"预警检查: {result['new_alerts']}条新预警 / {result['pushed']}条已推送"
f" / 规则引擎{result['rule_engine']}")
finally:
db.close()