Files
cma-management/backend/tests/test_customer_concentration_rule.py
T
Hermes CI Fix 1613f7f961
ci/woodpecker/push/main Pipeline failed
fix(cpa): sales bill-type whitelist for customer revenue caliber (P0)
Customer revenue/concentration now filters bill_type IN (1,4,14) (XSD/XSTHD/XSHHD).

Non-sales docs (JHD purchase, YFKD prepay, YSKD accept, QTRK, SKD, YBFYD) carry SUPPLIER unit_id and were polluting 'customer concentration' (old Top1/Top2 were our biggest suppliers; root cause of 6.04x base gap).

Unify sign filter to (sum_money+sum_tax_money)>0 (equivalent to money_only on current data, diff 0 rows) and correct the withdrawn 407-row/17.2% wording in cpa_caliber docstring.

Deriver: anchors from cpa_caliber single source; new --concentration overwrite and --only concentration (minimal 9-row write face) with pre-overwrite backup.

Tests: fixture gains bill_type/sum_tax_money; +5 whitelist cases (44 passed targeted).
2026-10-09 17:00:37 +08:00

356 lines
17 KiB
Python
Raw 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 触发链路 + 去重
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