fix(cpa): sales bill-type whitelist for customer revenue caliber (P0)
ci/woodpecker/push/main Pipeline failed

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).
This commit is contained in:
Hermes CI Fix
2026-10-09 17:00:37 +08:00
parent 451c994a86
commit 1613f7f961
4 changed files with 298 additions and 79 deletions
@@ -6,6 +6,8 @@
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
@@ -37,21 +39,37 @@ def _isolate_zxt_masterbill():
def _seed_masterbill(db: Session, rows, entity_id: int = 1):
"""在测试库建 zxt_masterbill 并写入 (unit_id, amount, bill_date) 测试数据"""
"""在测试库建 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, (unit_id, amount, bill_date) in enumerate(rows, start=1):
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, sum_money, bill_date) "
"VALUES (:id, :eid, :uid, :amt, :bd)"
), {"id": i, "eid": entity_id, "uid": unit_id, "amt": amount, "bd": bill_date})
"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()
@@ -140,6 +158,72 @@ class TestCustomerRevenueAggregation:
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:
"""占比判定:阈值 / 黄红灯 / 双数据源"""