Files
cma-management/backend/tests/test_customer_concentration_rule.py
Hermes CI Fix 9822fbd9ba 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
2026-09-10 09:56:50 +08:00

272 lines
12 KiB
Python
Raw Permalink 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.
"""
客户集中度预警规则测试(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