""" 客户集中度预警规则测试(P2 2026-09-10) 覆盖: 1. 规则类型 customer_concentration 创建/校验(含非法类型拒绝) 2. _customer_revenue_shares 客户收入聚合(zxt_masterbill 数据源) 3. _check_customer_concentration 双数据源(客户收入 / KPI 口径)+ 黄红灯分级 4. check-all 与 /run-customer-concentration 触发链路 + 去重 5. **销售类单据白名单**(P0 纠错 2026-10-04):bill_type IN (1,4,14) 生效、 非销售单据(供应商)被排除、预警路径与 CPA 口径同源同式 """ 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[, bill_type[, sum_tax_money]]) 测试数据 2026-10-04 P0 口径纠错后聚合过滤含**销售类白名单** `bill_type IN (1,4,14)` 与**统一符号口径** `(sum_money + sum_tax_money) > 0` ⇒ 夹具必须带 `bill_type` / `sum_tax_money` 两列(旧夹具只有 sum_money,改用例夹具属为适配新契约的**显式改写**): * bill_type 默认 1 = 销售单 XSD;逐行可指定应被排除的非销售单据 (0=JHD 进货 / 129=YFKD 预付款 / 17=QTRK 其它入库 / 103=YBFYD 费用 / 128=YSKD 验收 —— 这些行的 unit_id 是供应商)。 * sum_tax_money 默认 0.0。 """ db.execute(sa_text(""" CREATE TABLE IF NOT EXISTS zxt_masterbill ( id INTEGER PRIMARY KEY, entity_id INTEGER, unit_id INTEGER, bill_type INTEGER, sum_money FLOAT, sum_tax_money FLOAT, bill_date DATE ) """)) for i, row in enumerate(rows, start=1): unit_id, amount, bill_date = row[0], row[1], row[2] bill_type = row[3] if len(row) > 3 else 1 tax = row[4] if len(row) > 4 else 0.0 db.execute(sa_text( "INSERT INTO zxt_masterbill (id, entity_id, unit_id, bill_type, sum_money, " "sum_tax_money, bill_date) VALUES (:id, :eid, :uid, :bt, :amt, :tax, :bd)" ), {"id": i, "eid": entity_id, "uid": unit_id, "bt": bill_type, "amt": amount, "tax": tax, "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 TestSalesBillTypeWhitelist: """P0 口径纠错(2026-10-04):单据类型白名单(销售类 bill_type IN (1,4,14)) 背景:非销售单据(进货/预付款/验收/入库/收款/费用)的 unit_id 是**供应商** (abst 实读「向【酣客酒业(广州)】进货」「预付款单」),混入会把 「客户集中度」度量成「往来单位(含供应商)集中度」。 """ def test_purchase_and_prepay_rows_excluded(self, db: Session): _seed_masterbill(db, [ (152, 600.0, "2026-08-01", 1), # XSD 销售单 → 计入 (193, 300.0, "2026-08-02", 4), # XSTHD 销售退货 → 计入(销售类) (146, 9000.0, "2026-08-03", 0), # JHD 进货(供应商)→ 排除 (146, 8000.0, "2026-08-04", 129), # YFKD 预付款(供应商)→ 排除 (474, 7000.0, "2026-08-05", 17), # QTRK 其它入库(供应商)→ 排除 (101, 5000.0, "2026-08-06", 103), # YBFYD 一般费用单 → 排除 (102, 4000.0, "2026-08-07", 128), # YSKD 验收单 → 排除 ]) shares, total, _ = _customer_revenue_shares(db, 1, lookback_months=12, top_n=2) assert total == 900.0 assert [s[0] for s in shares] == [152, 193] assert shares[0][2] == round(600.0 / 900.0 * 100, 2) def test_sales_whitelist_in_generated_sql(self): from app.utils import cpa_caliber sql = cpa_caliber._sql_window() assert "bill_type IN (1, 4, 14)" in sql # 白名单生效 assert "(sum_money + sum_tax_money) > 0" in sql # 统一书面口径 assert sql == cpa_caliber._sql_window("tax_incl", "sales") all_sql = cpa_caliber._sql_window(bill_scope="all") assert "bill_type" not in all_sql # 对拍用全单据口径不带白名单 def test_unknown_scope_and_sign_rejected(self): from app.utils import cpa_caliber with pytest.raises(ValueError): cpa_caliber._sql_window(bill_scope="bogus") with pytest.raises(ValueError): cpa_caliber._sql_window(sign_basis="bogus") def test_sign_caliber_semantics_on_negative_row(self, db: Session): """负额行(sum_money<0、含税>0):tax_incl 计入 / money_only 排除 ⇒ 两式**非恒等**。 本库当前数据**不存在**此类行(2026-01~09 双向差集 0 行;全表亦 0 行), 故「两口径等价」是**数据事实**(财务复验 §5.1),不是恒等式——本用例固化语义, 防后人据「等价」误删符号过滤。 """ from app.database import get_engine from app.utils import cpa_caliber _seed_masterbill(db, [(999, -100.0, "2026-08-03", 1, 130.0)]) incl = cpa_caliber.fetch_monthly_revenue(get_engine(), 1, sign_basis="tax_incl") money = cpa_caliber.fetch_monthly_revenue(get_engine(), 1, sign_basis="money_only") assert incl.get("2026-08") == [(999, -100.0)] # 含税净额 +30 > 0 ⇒ 计入 assert "2026-08" not in money # sum_money -100 ⇒ 排除 def test_alert_path_shares_single_caliber_with_cpa(self, db: Session): """预警路径与 CPA 派生器同源同式(防「同一客户收入两份结果」)""" from datetime import date, timedelta from app.database import get_engine from app.utils import cpa_caliber _seed_masterbill(db, [(152, 600.0, "2026-08-01", 1), (146, 9000.0, "2026-08-03", 0)]) shares, total, _ = _customer_revenue_shares(db, 1, lookback_months=12, top_n=5) cpa = cpa_caliber.fetch_revenue_rows( get_engine(), 1, date.today() - timedelta(days=360), date.today(), 5) assert shares == cpa[0] and total == cpa[1] == 600.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