""" 客户集中度预警规则测试(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