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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user