feat(alerts): 新增客户集中度预警规则 customer_concentration + 预测偏差规则启用/联动每日预警生成

- alert_rules: 新增 rule_type=customer_concentration(单一/前N客户收入占比超阈值触发,黄=超阈值/红=阈值x倍数)
  数据源 zxt_masterbill 客户(往来单位)收入聚合,unit_id<=0 占位剔除;无客户数据可回退 KPI 口径
  复用现有 KPIAlert 生成链路:check-all 内联分支 + 专用端点 POST /run-customer-concentration
- alert_generator(每日9:00): 新增规则引擎步骤,复用上述检查函数跑 forecast_deviation/customer_concentration
- AlertList.vue: 规则类型筛选/编辑项 + 参数表单 + 客户集中度检查按钮
- 测试: tests/test_customer_concentration_rule.py 17 条(规则注册/聚合/双数据源/黄红灯/去重/check-all)
- 验证脚本: scripts/verify_forecast_deviation_rules_e2e.py / verify_customer_concentration_rule_e2e.py
This commit is contained in:
Hermes CI Fix
2026-09-10 09:56:50 +08:00
parent f8688713db
commit 9822fbd9ba
7 changed files with 866 additions and 8 deletions
+190 -3
View File
@@ -17,6 +17,16 @@ from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
logger = logging.getLogger("alert_rules")
# 支持的规则类型(P2 2026-09-10: 新增 customer_concentration 客户集中度)
VALID_RULE_TYPES = (
"static", # 静态阈值
"dynamic", # 动态阈值
"trend_up", # 环比上升
"trend_down", # 环比下降
"forecast_deviation", # 预测偏差(升级2b 2026-08-25)
"customer_concentration", # 客户集中度(P2 2026-09-10)
)
# ============================================================
# AlertRule 模型
# ============================================================
@@ -111,7 +121,7 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
raise HTTPException(404, "KPI不存在")
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
if rule_type not in ("static", "dynamic", "trend_up", "trend_down", "forecast_deviation"): # 升级2b: 预测偏差
if rule_type not in VALID_RULE_TYPES: # 升级2b: 预测偏差 / P2-20260910: 客户集中度
raise HTTPException(400, f"不支持的规则类型: {rule_type}")
rule = AlertRule(
@@ -171,6 +181,8 @@ def batch_create_rules(data: dict, db: Session = Depends(get_db), entity_id: int
for rule_data in rules_data:
kpi_id = rule_data.get("kpi_id")
rule_type = rule_data.get("rule_type", "static")
if rule_type not in VALID_RULE_TYPES:
continue
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi or kpi.entity_id != entity_id:
continue
@@ -263,7 +275,35 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
if not kpi or kpi.entity_id != entity_id:
continue
import json; params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
# 客户集中度规则(P2 2026-09-10):不依赖同期KPIValue,直接看客户收入占比
if rule.rule_type == "customer_concentration":
alert_level, alert_message = _check_customer_concentration(rule.kpi_id, params, db, entity_id)
if alert_level and alert_level != "green":
existing_alert = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.kpi_id == rule.kpi_id,
KPIAlert.alert_type == "concentration",
KPIAlert.status == "pending",
).first()
if existing_alert:
existing_alert.alert_level = alert_level
existing_alert.alert_message = alert_message
else:
db.add(KPIAlert(
entity_id=entity_id,
kpi_id=rule.kpi_id,
kpi_value_id=None,
alert_level=alert_level,
alert_message=alert_message,
alert_type="concentration",
status="pending",
))
alerts_generated += 1
continue
# 获取最新值
latest_value = value_cache.get(rule.kpi_id)
if latest_value is None:
@@ -278,7 +318,6 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
value = latest_value.actual_value
period = latest_value.period
import json; params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
alert_level = None
alert_message = None
@@ -718,3 +757,151 @@ def run_forecast_deviation_check(
continue
db.commit()
return {"message": f"预测偏差检查完成: {generated}条", "generated": generated}
# ============================================================
# 客户集中度预警(rule_type=customer_concentration, P2 2026-09-10)
# ============================================================
def _customer_revenue_shares(db: Session, entity_id: int, lookback_months: int = 12, top_n: int = 1):
"""聚合客户(往来单位)收入占比 — 数据源 zxt_masterbill(智享通销售单据, unit_id=客户/往来单位)
返回 (shares, total, period_range):
shares = [(unit_id, amount, pct), ...] 按金额倒序取前 top_n 名
total = 窗口内客户收入合计
period_range = (start_date, end_date) ISO 字符串
无可用数据时返回 ([], 0.0, None)。
用独立连接执行,避免表不存在/方言差异污染调用方 session 事务。
"""
from datetime import date, timedelta
from app.database import get_engine
end = date.today()
start = end - timedelta(days=max(1, int(lookback_months)) * 30)
sql = text("""
SELECT unit_id, SUM(sum_money) AS amount
FROM zxt_masterbill
WHERE entity_id = :eid
AND unit_id IS NOT NULL AND unit_id > 0
AND sum_money IS NOT NULL AND sum_money > 0
AND bill_date IS NOT NULL
AND bill_date >= :start AND bill_date <= :end
GROUP BY unit_id
ORDER BY amount DESC
""")
try:
with get_engine().connect() as conn:
rows = conn.execute(sql, {"eid": entity_id, "start": start, "end": end}).fetchall()
except Exception as e:
logger.error(f"客户收入聚合失败 entity_id={entity_id}: {e}")
return ([], 0.0, None)
if not rows:
return ([], 0.0, None)
total = float(sum(float(r[1] or 0) for r in rows))
if total <= 0:
return ([], 0.0, None)
n = max(1, int(top_n))
shares = [(int(r[0]), float(r[1] or 0), round(float(r[1] or 0) / total * 100, 2)) for r in rows[:n]]
return (shares, total, (start.isoformat(), end.isoformat()))
def _check_customer_concentration(kpi_id: int, params: dict, db: Session, entity_id: int) -> tuple:
"""客户集中度检查 — 单一(或前N)客户收入占比超阈值触发预警
params(全部可配):
threshold_pct 触发阈值,默认 50(黄灯)
red_ratio 红灯倍数,默认 1.5(占比 ≥ 阈值×1.5 → 红灯)
top_n 前N客户合计占比,默认 1(单一客户)
lookback_months 回看月数,默认 12
source auto(默认) / zxt_masterbill / kpi
auto = 优先客户收入数据,无数据回退到 KPI 口径
"""
threshold = float(params.get("threshold_pct", 50))
red_ratio = float(params.get("red_ratio", 1.5))
top_n = int(params.get("top_n", 1))
lookback = int(params.get("lookback_months", 12))
source = str(params.get("source") or "auto").lower()
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
kpi_label = f"{kpi.kpi_name}({kpi.kpi_code})" if kpi else f"KPI#{kpi_id}"
share = None
detail = ""
if source in ("auto", "zxt", "zxt_masterbill"):
shares, total, rng = _customer_revenue_shares(db, entity_id, lookback, top_n)
if total > 0 and shares:
share = round(sum(s[2] for s in shares), 2)
head = f"前{top_n}客户" if top_n > 1 else "单一客户"
items = "、".join([f"客户#{s[0]} {s[1]:.0f}元/{s[2]}%" for s in shares])
detail = f"{head}收入占比={share}%(合计{total:.0f}元,{rng[0]}~{rng[1]}:{items})"
if share is None and source in ("auto", "kpi"):
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
if latest:
share = round(float(latest.actual_value), 2)
detail = f"客户集中度KPI值={share}%(期间{latest.period})"
if share is None:
return (None, None)
if share <= threshold:
return (None, None)
level = "red" if share >= threshold * red_ratio else "yellow"
return (level, f"[客户集中度] {kpi_label} {detail},阈值{threshold}%")
@router.post("/run-customer-concentration")
def run_customer_concentration_check(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""客户集中度检查(P2 2026-09-10)— 遍历本企业 customer_concentration 规则,
计算客户收入占比,超阈值生成/更新 pending 预警(去重)。"""
rules = db.query(AlertRule).filter(
AlertRule.entity_id == entity_id,
AlertRule.rule_type == "customer_concentration",
AlertRule.enabled == 1,
).all()
if not rules:
return {"message": "无客户集中度规则,可先创建 rule_type=customer_concentration 规则", "generated": 0}
generated = 0
for rule in rules:
try:
params = rule.params
if isinstance(params, str):
params = json.loads(params)
params = params or {}
alert_level, alert_message = _check_customer_concentration(rule.kpi_id, params, db, entity_id)
if not alert_level or alert_level == "green":
continue
existing = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.kpi_id == rule.kpi_id,
KPIAlert.alert_type == "concentration",
KPIAlert.status == "pending",
).first()
if existing:
existing.alert_level = alert_level
existing.alert_message = alert_message
else:
db.add(KPIAlert(
entity_id=entity_id,
kpi_id=rule.kpi_id,
kpi_value_id=None,
alert_level=alert_level,
alert_message=alert_message,
alert_type="concentration",
status="pending",
))
generated += 1
except Exception as e:
logger.error(f"客户集中度检查失败 rule_id={rule.id}: {e}")
continue
db.commit()
return {"message": f"客户集中度检查完成: {generated}条", "generated": generated}