diff --git a/backend/app/api/alert_rules.py b/backend/app/api/alert_rules.py
index 539d78ba..4b9350e6 100644
--- a/backend/app/api/alert_rules.py
+++ b/backend/app/api/alert_rules.py
@@ -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}
diff --git a/backend/scripts/alert_generator.py b/backend/scripts/alert_generator.py
index 7262123a..0e6532c8 100644
--- a/backend/scripts/alert_generator.py
+++ b/backend/scripts/alert_generator.py
@@ -190,6 +190,41 @@ def run_alert_check(db_session, period: str = None) -> int:
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")
@@ -202,6 +237,9 @@ def generate_and_push(db_session) -> dict:
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:
@@ -212,7 +250,8 @@ def generate_and_push(db_session) -> dict:
except Exception as e:
logger.error(f"推送失败: {e}")
- return {"period": period, "new_alerts": new_count, "pushed": pushed, "cleaned_green": cleaned}
+ return {"period": period, "new_alerts": new_count, "pushed": pushed,
+ "cleaned_green": cleaned, "rule_engine": rule_stats}
if __name__ == "__main__":
@@ -224,6 +263,7 @@ if __name__ == "__main__":
db = get_session_local()()
try:
result = generate_and_push(db)
- print(f"预警检查: {result['new_alerts']}条新预警 / {result['pushed']}条已推送")
+ print(f"预警检查: {result['new_alerts']}条新预警 / {result['pushed']}条已推送"
+ f" / 规则引擎{result['rule_engine']}")
finally:
db.close()
diff --git a/backend/scripts/verify_customer_concentration_rule_e2e.py b/backend/scripts/verify_customer_concentration_rule_e2e.py
new file mode 100644
index 00000000..425cda58
--- /dev/null
+++ b/backend/scripts/verify_customer_concentration_rule_e2e.py
@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+"""任务B 交付验收脚本 — customer_concentration 客户集中度预警(生产 E2E)
+
+做四件事(可复跑):
+ 1. 为 entity1 配置常驻客户集中度规则(KPI=C_CUST_CONCENTRATION, source=auto → zxt 客户收入)
+ 2. 打印真实数据下的单一客户占比(zxt_masterbill 近12个月)作为基线
+ 3. 触发测试:建带标记的临时规则(source=kpi)+ 带标记的临时 KPIValue(占比 68%)
+ → 调 POST /api/cma/alert-rules/run-customer-concentration → 断言 pending 预警生成
+ 4. 清理临时规则/KPIValue/预警,回查归零
+
+用法:cd /root/cma-management/backend && venv/bin/python3 scripts/verify_customer_concentration_rule_e2e.py
+"""
+import json
+import sys
+import urllib.request
+import urllib.error
+
+BASE = "http://127.0.0.1:8010"
+ADMIN = {"username": "admin", "password": "admin123"}
+MARK = "【系统验证临时数据-验证后删除】"
+TEMP_TAG = "e2e-verify-temp"
+RULE_TYPE = "customer_concentration"
+KPI_ID = 23 # C_CUST_CONCENTRATION 客户集中度 (entity1)
+
+
+def http(method, path, token=None, body=None, timeout=60):
+ req = urllib.request.Request(
+ BASE + path,
+ data=json.dumps(body).encode() if body is not None else None,
+ method=method,
+ )
+ req.add_header("Content-Type", "application/json")
+ if token:
+ req.add_header("Authorization", f"Bearer {token}")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ return r.status, json.loads(r.read().decode() or "{}")
+ except urllib.error.HTTPError as e:
+ return e.code, {"detail": e.read().decode()[:300]}
+
+
+def login(entity_id=1):
+ st, r = http("POST", "/api/cma/auth/login", body={**ADMIN, "entity_id": entity_id})
+ assert st == 200, f"登录失败: {st} {r}"
+ return r["token"]
+
+
+def main():
+ sys.path.insert(0, ".")
+ from app.database import get_session_local
+ from app.models import KPIValue, KPIAlert, KPIDefinition
+ from app.api.alert_rules import AlertRule, _customer_revenue_shares
+
+ token = login(1)
+ db = get_session_local()()
+ created_ids = []
+ try:
+ # ── 1+2. 常驻规则 + 真实基线 ──────────────────────────────
+ print("=== 1. 常驻客户集中度规则(entity1)===")
+ st, rules = http("GET", f"/api/cma/alert-rules?rule_type={RULE_TYPE}", token=token)
+ assert st == 200, f"列规则失败: {st} {rules}"
+ perm = [r for r in rules.get("data", []) if r["kpi_id"] == KPI_ID and r["entity_id"] == 1
+ and not (r.get("params") or {}).get("_verify")]
+ if perm:
+ print(f" 已存在 rule_id={perm[0]['id']}(幂等跳过)")
+ else:
+ st, r = http("POST", "/api/cma/alert-rules", token=token, body={
+ "kpi_id": KPI_ID,
+ "rule_type": RULE_TYPE,
+ "trigger_on": "actual",
+ "enabled": 1,
+ "params": {"threshold_pct": 50, "red_ratio": 1.5, "top_n": 1, "source": "auto"},
+ })
+ assert st == 200, f"建规则失败: {st} {r}"
+ print(f" 新建 rule_id={r['data']['id']} params={r['data']['params']}")
+
+ print("=== 2. 真实数据基线(zxt_masterbill 近12个月, entity1)===")
+ db.rollback()
+ shares, total, rng = _customer_revenue_shares(db, 1, lookback_months=12, top_n=5)
+ assert total > 0, "entity1 无客户收入数据 → 基线取数失败"
+ print(f" 客户收入合计={total:,.0f}元 ({rng[0]}~{rng[1]}), 客户数={len(shares)}")
+ for uid, amt, pct in shares:
+ print(f" 客户#{uid}: {amt:,.0f}元 / {pct}%")
+ top1 = shares[0][2]
+ print(f" → 单一客户占比 {top1}%(阈值50% → {'触发' if top1 > 50 else '未触发'})")
+
+ # ── 3. 触发测试(临时规则 + 临时KPI值)────────────────────
+ print("=== 3. 触发测试(构造占比超阈值场景)===")
+ st, r = http("POST", "/api/cma/alert-rules", token=token, body={
+ "kpi_id": KPI_ID,
+ "rule_type": RULE_TYPE,
+ "trigger_on": "actual",
+ "enabled": 1,
+ "params": {"threshold_pct": 50, "red_ratio": 1.5, "top_n": 1,
+ "source": "kpi", "_verify": TEMP_TAG},
+ })
+ assert st == 200, f"建临时规则失败: {st} {r}"
+ temp_rule_id = r["data"]["id"]
+ created_ids.append(temp_rule_id)
+ print(f" 临时规则 rule_id={temp_rule_id} (source=kpi, 阈值50%)")
+
+ db.rollback()
+ temp = KPIValue(kpi_id=KPI_ID, entity_id=1, period="2026-08", actual_value=68.0,
+ source_type="manual", data_status="pending", remark=MARK)
+ db.add(temp)
+ db.commit()
+ db.refresh(temp)
+ temp_kv_id = temp.id
+ print(f" 临时KPIValue id={temp_kv_id} 占比=68.0%")
+
+ st, r = http("POST", "/api/cma/alert-rules/run-customer-concentration", token=token)
+ assert st == 200, f"触发失败: {st} {r}"
+ print(f" run-customer-concentration → {r}")
+
+ db.rollback()
+ alerts = db.query(KPIAlert).filter(
+ KPIAlert.entity_id == 1, KPIAlert.kpi_id == KPI_ID,
+ KPIAlert.alert_type == "concentration", KPIAlert.status == "pending",
+ ).order_by(KPIAlert.id.desc()).all()
+ assert alerts, "未生成客户集中度预警 → 触发测试 FAIL"
+ a = alerts[0]
+ alert_id = a.id
+ print(f" ✅ 预警生成: id={a.id} level={a.alert_level} type={a.alert_type}")
+ print(f" message={a.alert_message}")
+ assert a.alert_level == "yellow", f"68%应为黄灯(50%~75%), 实际={a.alert_level}"
+
+ # ── 4. 清理 ────────────────────────────────────────────
+ print("=== 4. 清理临时数据(生产不留假数据)===")
+ db.rollback()
+ for obj in (db.query(KPIAlert).filter(KPIAlert.id == alert_id).first(),
+ db.query(KPIValue).filter(KPIValue.id == temp_kv_id).first(),
+ db.query(AlertRule).filter(AlertRule.id == temp_rule_id).first()):
+ if obj:
+ db.delete(obj)
+ db.commit()
+ left = {
+ "alert": db.query(KPIAlert).filter(KPIAlert.id == alert_id).count(),
+ "kpi_value": db.query(KPIValue).filter(KPIValue.remark == MARK).count(),
+ "temp_rule": db.query(AlertRule).filter(AlertRule.id == temp_rule_id).count(),
+ }
+ print(f" 回查: {left}")
+ assert all(v == 0 for v in left.values())
+
+ # ── 5. 终态 ────────────────────────────────────────────
+ print("=== 5. 常驻规则终态 ===")
+ st, rules = http("GET", f"/api/cma/alert-rules?rule_type={RULE_TYPE}", token=token)
+ for r in rules.get("data", []):
+ kpi = db.query(KPIDefinition).filter(KPIDefinition.id == r["kpi_id"]).first()
+ print(f" rule_id={r['id']} entity={r['entity_id']} kpi={kpi.kpi_code if kpi else r['kpi_id']}"
+ f" enabled={r['enabled']} params={r['params']}")
+ finally:
+ db.close()
+
+ print("\nALL PASS")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/backend/scripts/verify_forecast_deviation_rules_e2e.py b/backend/scripts/verify_forecast_deviation_rules_e2e.py
new file mode 100644
index 00000000..c479a807
--- /dev/null
+++ b/backend/scripts/verify_forecast_deviation_rules_e2e.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+"""任务A 交付验收脚本 — forecast_deviation 预测偏差规则(酣客entity1 / 博海entity2)
+
+做四件事(可复跑,幂等):
+ 1. 经生产 API(管理员 JWT)为 entity1 / entity2 各建 1 条 forecast_deviation 规则
+ 2. 回查 API 列表 + 直查 DB 实证规则存在(含 rule_id / kpi / params)
+ 3. 触发测试:给 entity1 规则构造一期「预测偏差超阈值」场景(带标记的临时 KPIValue)
+ → 调 POST /api/cma/alert-rules/run-forecast-deviation → 断言 pending 预警生成
+ 4. 清理:删除临时 KPIValue 与该次生成的预警,回查归零(生产不留假数据)
+
+用法:cd /root/cma-management/backend && venv/bin/python3 scripts/verify_forecast_deviation_rules_e2e.py
+"""
+import json
+import sys
+import urllib.request
+import urllib.error
+
+BASE = "http://127.0.0.1:8010"
+ADMIN = {"username": "admin", "password": "admin123"}
+MARK = "【系统验证临时数据-验证后删除】"
+
+# 规则目标:酣客 → 应收账款周转天数(F_AR_DAYS, id=6,有 2026-09 预测日志)
+# 博海 → 营收(BH_REVENUE, id=421)
+TARGETS = [
+ {"entity_id": 1, "kpi_id": 6, "kpi_label": "F_AR_DAYS"},
+ {"entity_id": 2, "kpi_id": 421, "kpi_label": "BH_REVENUE"},
+]
+RULE_TYPE = "forecast_deviation"
+PARAMS = {"threshold_pct": 15, "red_ratio": 2} # >15% 黄,>30% 红(实现按 threshold×2 定红)
+
+
+def http(method, path, token=None, body=None, timeout=30):
+ url = BASE + path
+ data = json.dumps(body).encode() if body is not None else None
+ req = urllib.request.Request(url, data=data, method=method)
+ req.add_header("Content-Type", "application/json")
+ if token:
+ req.add_header("Authorization", f"Bearer {token}")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ return r.status, json.loads(r.read().decode() or "{}")
+ except urllib.error.HTTPError as e:
+ return e.code, {"detail": e.read().decode()[:300]}
+
+
+def login(entity_id):
+ st, r = http("POST", "/api/cma/auth/login", body={**ADMIN, "entity_id": entity_id})
+ assert st == 200, f"登录 entity{entity_id} 失败: {st} {r}"
+ return r["token"], r.get("entity_name")
+
+
+def main():
+ ok = True
+
+ # ── 1+2. 建规则 ────────────────────────────────────────────
+ print("=== 1. 创建 forecast_deviation 规则 ===")
+ created = {}
+ for t in TARGETS:
+ eid = t["entity_id"]
+ token, ename = login(eid)
+ st, rules = http("GET", f"/api/cma/alert-rules?rule_type={RULE_TYPE}", token=token)
+ assert st == 200, f"列规则失败: {st} {rules}"
+ exist = [r for r in rules.get("data", []) if r["kpi_id"] == t["kpi_id"] and r["rule_type"] == RULE_TYPE]
+ if exist:
+ rid = exist[0]["id"]
+ print(f" entity{eid}({ename}) {t['kpi_label']}: 已存在 rule_id={rid}(幂等跳过)")
+ else:
+ st, r = http("POST", "/api/cma/alert-rules", token=token, body={
+ "kpi_id": t["kpi_id"],
+ "rule_type": RULE_TYPE,
+ "trigger_on": "actual",
+ "enabled": 1,
+ "params": PARAMS,
+ })
+ assert st == 200, f"建规则失败 entity{eid}: {st} {r}"
+ rid = r["data"]["id"]
+ print(f" entity{eid}({ename}) {t['kpi_label']}: 新建 rule_id={rid} params={r['data']['params']}")
+ created[eid] = rid
+
+ # ── 3. DB 回查 ─────────────────────────────────────────────
+ print("=== 2. DB 回查实证 ===")
+ sys.path.insert(0, ".")
+ from app.database import get_session_local
+ from app.models import KPIDefinition, KPIValue, KPIAlert, KpiForecastLog
+ from app.api.alert_rules import AlertRule
+ db = get_session_local()()
+ try:
+ for eid, rid in created.items():
+ row = db.query(AlertRule).filter(AlertRule.id == rid).first()
+ kpi = db.query(KPIDefinition).filter(KPIDefinition.id == row.kpi_id).first()
+ print(f" rule_id={row.id} entity_id={row.entity_id} kpi_id={row.kpi_id}"
+ f"({kpi.kpi_code if kpi else '?'}) type={row.rule_type} enabled={row.enabled} params={row.params}")
+ assert row.entity_id == eid and row.rule_type == RULE_TYPE
+ assert row.enabled == 1
+
+ # ── 4. 触发测试(entity1)─────────────────────────────
+ print("=== 3. 触发测试(构造偏差场景, entity1)===")
+ t1 = TARGETS[0]
+ rid = created[1]
+ rule = db.query(AlertRule).filter(AlertRule.id == rid).first()
+ log = (db.query(KpiForecastLog)
+ .filter(KpiForecastLog.entity_id == 1, KpiForecastLog.kpi_id == rule.kpi_id)
+ .order_by(KpiForecastLog.created_at.desc()).first())
+ forecast = float(log.forecast_value)
+ # 构造:实际值 = 预测值的一半 → 偏差 100% > 15% → red(>30%)
+ actual = round(forecast / 2, 2)
+ temp = KPIValue(kpi_id=rule.kpi_id, entity_id=1, period=log.period,
+ actual_value=actual, source_type="manual", data_status="pending", remark=MARK)
+ db.add(temp)
+ db.commit()
+ db.refresh(temp)
+ print(f" 临时KPIValue id={temp.id} period={log.period} 预测={forecast} 实际={actual}")
+
+ token1, _ = login(1)
+ st, r = http("POST", "/api/cma/alert-rules/run-forecast-deviation", token=token1, timeout=60)
+ assert st == 200, f"触发失败: {st} {r}"
+ print(f" run-forecast-deviation → {r}")
+
+ db.expire_all()
+ # MySQL REPEATABLE READ:API 在另一进程提交,本 session 需结束旧事务快照才能读到
+ db.rollback()
+ alerts = db.query(KPIAlert).filter(
+ KPIAlert.entity_id == 1, KPIAlert.kpi_id == rule.kpi_id,
+ KPIAlert.alert_type == "forecast", KPIAlert.status == "pending",
+ ).order_by(KPIAlert.id.desc()).all()
+ assert alerts, "未生成预测偏差预警 → 触发测试 FAIL"
+ a = alerts[0]
+ alert_id = a.id
+ print(f" ✅ 预警生成: id={a.id} level={a.alert_level} type={a.alert_type}")
+ print(f" message={a.alert_message}")
+ assert a.alert_level == "red", f"偏差100%应为红灯, 实际={a.alert_level}"
+
+ # ── 5. 清理 ────────────────────────────────────────────
+ print("=== 4. 清理临时数据(生产不留假数据)===")
+ temp_id = temp.id
+ db.rollback()
+ a_row = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
+ temp_row = db.query(KPIValue).filter(KPIValue.id == temp_id).first()
+ if a_row:
+ db.delete(a_row)
+ if temp_row:
+ db.delete(temp_row)
+ db.commit()
+ left_v = db.query(KPIValue).filter(KPIValue.remark == MARK).count()
+ left_a = db.query(KPIAlert).filter(KPIAlert.id == alert_id).count()
+ print(f" 回查: 临时KPIValue剩余={left_v} / 本次预警剩余={left_a}")
+ assert left_v == 0 and left_a == 0
+
+ print("=== 5. 规则终态(交付物保留)===")
+ for eid, rid in created.items():
+ row = db.query(AlertRule).filter(AlertRule.id == rid).first()
+ print(f" entity{eid}: rule_id={row.id} kpi_id={row.kpi_id} type={row.rule_type} enabled={row.enabled}")
+ finally:
+ db.close()
+
+ print("\nALL PASS" if ok else "\nFAILED")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/backend/tests/test_customer_concentration_rule.py b/backend/tests/test_customer_concentration_rule.py
new file mode 100644
index 00000000..c1729251
--- /dev/null
+++ b/backend/tests/test_customer_concentration_rule.py
@@ -0,0 +1,271 @@
+"""
+客户集中度预警规则测试(P2 2026-09-10)
+
+覆盖:
+ 1. 规则类型 customer_concentration 创建/校验(含非法类型拒绝)
+ 2. _customer_revenue_shares 客户收入聚合(zxt_masterbill 数据源)
+ 3. _check_customer_concentration 双数据源(客户收入 / KPI 口径)+ 黄红灯分级
+ 4. check-all 与 /run-customer-concentration 触发链路 + 去重
+"""
+import pytest
+from sqlalchemy import text as sa_text
+from fastapi.testclient import TestClient
+from sqlalchemy.orm import Session
+
+from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
+from app.models import KPIAlert, KPIValue
+from app.api.alert_rules import _check_customer_concentration, _customer_revenue_shares
+
+
+@pytest.fixture(autouse=True)
+def _isolate_zxt_masterbill():
+ """zxt_masterbill 是原生表(不在 Base.metadata 里),conftest 的 drop_all 清不掉它,
+ 这里在每个用例前后显式丢弃,保证用例间数据隔离。"""
+ from app.database import get_engine
+
+ def _drop():
+ try:
+ with get_engine().connect() as conn:
+ conn.execute(sa_text("DROP TABLE IF EXISTS zxt_masterbill"))
+ conn.commit()
+ except Exception:
+ pass
+
+ _drop()
+ yield
+ _drop()
+
+
+def _seed_masterbill(db: Session, rows, entity_id: int = 1):
+ """在测试库建 zxt_masterbill 并写入 (unit_id, amount, bill_date) 测试数据"""
+ db.execute(sa_text("""
+ CREATE TABLE IF NOT EXISTS zxt_masterbill (
+ id INTEGER PRIMARY KEY,
+ entity_id INTEGER,
+ unit_id INTEGER,
+ sum_money FLOAT,
+ bill_date DATE
+ )
+ """))
+ for i, (unit_id, amount, bill_date) in enumerate(rows, start=1):
+ db.execute(sa_text(
+ "INSERT INTO zxt_masterbill (id, entity_id, unit_id, sum_money, bill_date) "
+ "VALUES (:id, :eid, :uid, :amt, :bd)"
+ ), {"id": i, "eid": entity_id, "uid": unit_id, "amt": amount, "bd": bill_date})
+ db.commit()
+
+
+class TestRuleTypeRegistration:
+ """规则类型注册与校验"""
+
+ def test_create_customer_concentration_rule(self, client: TestClient, db: Session):
+ create_test_user(db)
+ token = get_token_for_user(client)
+ kpi = create_test_kpi(db, kpi_code="C_CONC_T1", kpi_name="客户集中度", dimension="customer")
+
+ resp = client.post("/api/cma/alert-rules", headers=auth_header(token), json={
+ "kpi_id": kpi.id,
+ "rule_type": "customer_concentration",
+ "trigger_on": "actual",
+ "params": {"threshold_pct": 50},
+ })
+ assert resp.status_code == 200, resp.text
+ data = resp.json()["data"]
+ assert data["rule_type"] == "customer_concentration"
+ assert data["entity_id"] == 1
+
+ def test_reject_unknown_rule_type(self, client: TestClient, db: Session):
+ create_test_user(db)
+ token = get_token_for_user(client)
+ kpi = create_test_kpi(db, kpi_code="C_CONC_T2")
+
+ resp = client.post("/api/cma/alert-rules", headers=auth_header(token), json={
+ "kpi_id": kpi.id,
+ "rule_type": "no_such_type",
+ })
+ assert resp.status_code == 400
+
+ def test_batch_create_skips_unknown_type(self, client: TestClient, db: Session):
+ create_test_user(db)
+ token = get_token_for_user(client)
+ kpi = create_test_kpi(db, kpi_code="C_CONC_T3")
+
+ resp = client.post("/api/cma/alert-rules/batch", headers=auth_header(token), json={
+ "rules": [
+ {"kpi_id": kpi.id, "rule_type": "customer_concentration", "params": {"threshold_pct": 50}},
+ {"kpi_id": kpi.id, "rule_type": "bogus_type"},
+ ]
+ })
+ assert resp.status_code == 200
+ assert resp.json()["created"] == 1
+
+
+class TestCustomerRevenueAggregation:
+ """客户收入聚合(zxt_masterbill)"""
+
+ def test_shares_top1_and_top2(self, db: Session):
+ _seed_masterbill(db, [
+ (101, 600.0, "2026-08-01"),
+ (102, 300.0, "2026-08-02"),
+ (103, 100.0, "2026-08-03"),
+ ])
+ shares, total, rng = _customer_revenue_shares(db, 1, lookback_months=12, top_n=1)
+ assert total == 1000.0
+ assert len(shares) == 1
+ assert shares[0][0] == 101
+ assert shares[0][2] == 60.0
+
+ shares2, total2, _ = _customer_revenue_shares(db, 1, lookback_months=12, top_n=2)
+ assert total2 == 1000.0
+ assert round(sum(s[2] for s in shares2), 2) == 90.0
+
+ def test_no_data_returns_empty(self, db: Session):
+ shares, total, rng = _customer_revenue_shares(db, 1)
+ assert shares == [] and total == 0.0 and rng is None
+
+ def test_entity_isolation(self, db: Session):
+ _seed_masterbill(db, [(201, 500.0, "2026-08-01")], entity_id=2)
+ shares, total, _ = _customer_revenue_shares(db, 1)
+ assert total == 0.0 and shares == []
+
+ def test_placeholder_unit_excluded(self, db: Session):
+ """unit_id<=0 是占位(无客户/散客),不计入集中度分母"""
+ _seed_masterbill(db, [
+ (-1, 9000.0, "2026-08-01"),
+ (101, 600.0, "2026-08-02"),
+ (102, 400.0, "2026-08-03"),
+ ])
+ shares, total, _ = _customer_revenue_shares(db, 1, lookback_months=12, top_n=1)
+ assert total == 1000.0
+ assert shares[0][0] == 101 and shares[0][2] == 60.0
+
+
+class TestConcentrationChecker:
+ """占比判定:阈值 / 黄红灯 / 双数据源"""
+
+ def test_kpi_source_yellow(self, db: Session):
+ create_test_user(db)
+ kpi = create_test_kpi(db, kpi_code="C_CONC_10", kpi_name="客户集中度", dimension="customer")
+ db.add(KPIValue(kpi_id=kpi.id, period="2026-08", actual_value=68.0, entity_id=1))
+ db.commit()
+
+ level, msg = _check_customer_concentration(kpi.id, {"threshold_pct": 50, "source": "kpi"}, db, 1)
+ assert level == "yellow"
+ assert "68.0%" in msg
+
+ def test_kpi_source_red(self, db: Session):
+ create_test_user(db)
+ kpi = create_test_kpi(db, kpi_code="C_CONC_11", kpi_name="客户集中度", dimension="customer")
+ db.add(KPIValue(kpi_id=kpi.id, period="2026-08", actual_value=85.0, entity_id=1))
+ db.commit()
+
+ level, msg = _check_customer_concentration(kpi.id, {"threshold_pct": 50, "source": "kpi"}, db, 1)
+ assert level == "red"
+
+ def test_below_threshold_no_alert(self, db: Session):
+ create_test_user(db)
+ kpi = create_test_kpi(db, kpi_code="C_CONC_12", kpi_name="客户集中度", dimension="customer")
+ db.add(KPIValue(kpi_id=kpi.id, period="2026-08", actual_value=40.0, entity_id=1))
+ db.commit()
+
+ level, msg = _check_customer_concentration(kpi.id, {"threshold_pct": 50, "source": "kpi"}, db, 1)
+ assert level is None and msg is None
+
+ def test_no_data_no_alert(self, db: Session):
+ create_test_user(db)
+ kpi = create_test_kpi(db, kpi_code="C_CONC_13", kpi_name="客户集中度", dimension="customer")
+
+ level, msg = _check_customer_concentration(kpi.id, {"threshold_pct": 50, "source": "kpi"}, db, 1)
+ assert level is None and msg is None
+
+ def test_masterbill_source_with_kpi_fallback_disabled(self, db: Session):
+ create_test_user(db)
+ kpi = create_test_kpi(db, kpi_code="C_CONC_14", kpi_name="客户集中度", dimension="customer")
+ # KPI 值虽高,但显式指定 source=zxt_masterbill 且无客户收入数据 → 不触发
+ db.add(KPIValue(kpi_id=kpi.id, period="2026-08", actual_value=90.0, entity_id=1))
+ db.commit()
+ level, msg = _check_customer_concentration(kpi.id, {"threshold_pct": 50, "source": "zxt_masterbill"}, db, 1)
+ assert level is None
+
+ _seed_masterbill(db, [(101, 600.0, "2026-08-01"), (102, 400.0, "2026-08-05")])
+ level, msg = _check_customer_concentration(kpi.id, {"threshold_pct": 50, "source": "zxt_masterbill"}, db, 1)
+ assert level == "yellow"
+ assert "单一客户收入占比=60.0%" in msg
+
+ def test_auto_prefers_masterbill_then_kpi(self, db: Session):
+ create_test_user(db)
+ kpi = create_test_kpi(db, kpi_code="C_CONC_15", kpi_name="客户集中度", dimension="customer")
+ db.add(KPIValue(kpi_id=kpi.id, period="2026-08", actual_value=70.0, entity_id=1))
+ db.commit()
+
+ level, msg = _check_customer_concentration(kpi.id, {"threshold_pct": 50}, db, 1)
+ assert level == "yellow"
+ assert "KPI值=70.0%" in msg # 无客户收入数据 → 回退KPI口径
+
+ _seed_masterbill(db, [(101, 900.0, "2026-08-01"), (102, 100.0, "2026-08-05")])
+ level, msg = _check_customer_concentration(kpi.id, {"threshold_pct": 50}, db, 1)
+ assert level == "red" # 90% ≥ 50×1.5
+ assert "单一客户收入占比=90.0%" in msg
+
+
+class TestConcentrationTriggerChain:
+ """触发链路:check-all / 专用端点 + 去重"""
+
+ def _mk_rule(self, client, db, token, kpi, params=None):
+ return client.post("/api/cma/alert-rules", headers=auth_header(token), json={
+ "kpi_id": kpi.id,
+ "rule_type": "customer_concentration",
+ "params": params or {"threshold_pct": 50, "source": "kpi"},
+ })
+
+ def test_run_endpoint_generates_alert(self, client: TestClient, db: Session):
+ create_test_user(db)
+ token = get_token_for_user(client)
+ kpi = create_test_kpi(db, kpi_code="C_CONC_20", kpi_name="客户集中度", dimension="customer")
+ db.add(KPIValue(kpi_id=kpi.id, period="2026-08", actual_value=72.0, entity_id=1))
+ db.commit()
+ assert self._mk_rule(client, db, token, kpi).status_code == 200
+
+ resp = client.post("/api/cma/alert-rules/run-customer-concentration", headers=auth_header(token))
+ assert resp.status_code == 200, resp.text
+ assert resp.json()["generated"] == 1
+
+ alerts = db.query(KPIAlert).filter(KPIAlert.alert_type == "concentration").all()
+ assert len(alerts) == 1
+ assert alerts[0].alert_level == "yellow"
+ assert alerts[0].entity_id == 1
+ assert "客户集中度" in alerts[0].alert_message
+
+ def test_run_endpoint_dedupes(self, client: TestClient, db: Session):
+ create_test_user(db)
+ token = get_token_for_user(client)
+ kpi = create_test_kpi(db, kpi_code="C_CONC_21", kpi_name="客户集中度", dimension="customer")
+ db.add(KPIValue(kpi_id=kpi.id, period="2026-08", actual_value=72.0, entity_id=1))
+ db.commit()
+ self._mk_rule(client, db, token, kpi)
+
+ client.post("/api/cma/alert-rules/run-customer-concentration", headers=auth_header(token))
+ client.post("/api/cma/alert-rules/run-customer-concentration", headers=auth_header(token))
+ alerts = db.query(KPIAlert).filter(KPIAlert.alert_type == "concentration").all()
+ assert len(alerts) == 1 # 第二次是更新而非新增
+
+ def test_check_all_includes_concentration(self, client: TestClient, db: Session):
+ create_test_user(db)
+ token = get_token_for_user(client)
+ kpi = create_test_kpi(db, kpi_code="C_CONC_22", kpi_name="客户集中度", dimension="customer")
+ db.add(KPIValue(kpi_id=kpi.id, period="2026-08", actual_value=80.0, entity_id=1))
+ db.commit()
+ self._mk_rule(client, db, token, kpi)
+
+ resp = client.post("/api/cma/alert-rules/check-all", headers=auth_header(token))
+ assert resp.status_code == 200
+ alerts = db.query(KPIAlert).filter(KPIAlert.alert_type == "concentration").all()
+ assert len(alerts) == 1
+ assert alerts[0].alert_level == "red"
+
+ def test_run_endpoint_without_rule(self, client: TestClient, db: Session):
+ create_test_user(db)
+ token = get_token_for_user(client)
+ resp = client.post("/api/cma/alert-rules/run-customer-concentration", headers=auth_header(token))
+ assert resp.status_code == 200
+ assert resp.json()["generated"] == 0
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index 76091cc5..1ddc07fd 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -172,6 +172,8 @@ export const alertRulesApi = {
dynamicThresholds: (params?: any) => api.get('/alert-rules/dynamic-thresholds', { params }),
// AI事前预警
checkForecast: () => api.post('/alert-rules/check-forecast'),
+ checkForecastDeviation: () => api.post('/alert-rules/run-forecast-deviation'),
+ checkConcentration: () => api.post('/alert-rules/run-customer-concentration'),
generateSuggestions: () => api.post('/alert-rules/generate-suggestions'),
}
diff --git a/frontend/src/views/AlertList.vue b/frontend/src/views/AlertList.vue
index 02d91163..91f1d942 100644
--- a/frontend/src/views/AlertList.vue
+++ b/frontend/src/views/AlertList.vue
@@ -145,6 +145,8 @@
+
+
@@ -152,6 +154,7 @@
查询
🔮 检查预测值
+ 🏦 检查客户集中度
💡 生成情景建议
@@ -268,6 +271,8 @@
+
+
@@ -298,6 +303,26 @@
超过此百分比触发预警
+
+
+
+ 预测值与该期实际值偏差超过此比例触发(红灯=2倍)
+
+
+
+
+
+ 单一客户收入占比超过此比例触发(默认50)
+
+
+
+ 占比 ≥ 阈值×倍数 → 红灯(默认1.5)
+
+
+
+ 默认1=单一客户占比
+
+
取消
@@ -383,7 +408,7 @@ const ruleForm = ref({
rule_type: 'static',
trigger_on: 'actual',
enabled: 1,
- params: { green: '', yellow: '', red: '', sensitivity: 1.0, threshold_pct: 10 },
+ params: { green: '', yellow: '', red: '', sensitivity: 1.0, threshold_pct: 10, red_ratio: 1.5, top_n: 1 },
})
// ── 改善计划 ──
@@ -416,10 +441,10 @@ function formatTime(t: string): string {
}
function ruleTypeLabel(type: string): string {
- return { static: '静态阈值', dynamic: '动态阈值', trend_up: '上升趋势', trend_down: '下降趋势' }[type] || type
+ return { static: '静态阈值', dynamic: '动态阈值', trend_up: '上升趋势', trend_down: '下降趋势', forecast_deviation: '预测偏差', customer_concentration: '客户集中度' }[type] || type
}
function ruleTypeTag(type: string): string {
- return { static: 'primary', dynamic: 'success', trend_up: 'warning', trend_down: 'danger' }[type] || 'info'
+ return { static: 'primary', dynamic: 'success', trend_up: 'warning', trend_down: 'danger', forecast_deviation: 'warning', customer_concentration: 'danger' }[type] || 'info'
}
function formatRuleParams(params: any, type: string): string {
@@ -428,6 +453,8 @@ function formatRuleParams(params: any, type: string): string {
if (type === 'static') return `绿${params.green||'-'} / 黄${params.yellow||'-'} / 红${params.red||'-'}`
if (type === 'dynamic') return `敏感度=${params.sensitivity||1.0}`
if (type === 'trend_up' || type === 'trend_down') return `阈值>${params.threshold_pct||10}%`
+ if (type === 'forecast_deviation') return `偏差>${params.threshold_pct||15}%`
+ if (type === 'customer_concentration') return `单一客户占比>${params.threshold_pct||50}%(前${params.top_n||1}客户)`
return JSON.stringify(params)
}
@@ -549,6 +576,16 @@ async function checkForecastAlerts() {
fcLoading.value = false
}
+async function checkConcentrationAlerts() {
+ ccLoading.value = true
+ try {
+ const r: any = await alertRulesApi.checkConcentration()
+ ElMessage.success(r.message || '客户集中度检查完成')
+ loadAlerts()
+ } catch (e) { ElMessage.error('检查失败') }
+ ccLoading.value = false
+}
+
async function generateAlertSuggestions() {
sgLoading.value = true
try {
@@ -561,6 +598,7 @@ async function generateAlertSuggestions() {
const fcLoading = ref(false)
const sgLoading = ref(false)
+const ccLoading = ref(false)
async function calcDynamic() {
calcLoading.value = true