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
@@ -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