Files
cma-management/backend/app/utils/cpa_caliber.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

253 lines
13 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.
"""CPA(客户获利性)取数口径 —— 单一事实源(2026-10-04,任务 finance-20261003-cpa-customer-profitability-mvp-fullstack。
2026-10-04 P0 口径纠错(任务 finance-20261004T0905-cpa-revenue-caliber-p0,依据财务独立复验件
`finance-20261004-cpa-mvp-independent-verify` §5.3):增列**单据类型白名单**(只取销售类),
避免把采购/资金/库存/费用单据的**供应商**当成客户。
为什么有本模块
--------------
客户级收入聚合此前只有一处实现(`app/api/alert_rules.py::_customer_revenue_shares`,
供客户集中度预警使用)。CPA 派生器必须复用**同一套**聚合,否则同一指标出现两份结果
(任务 handoff 明写「勿另写第二套客户收入聚合,抽公共函数」)。
本模块即那唯一的公共函数;`alert_rules._customer_revenue_shares` 与本派生器都调它。
基座
----
`zxt_masterbill`(智享通单据流,仅 entity1 有数据:15,755 行 / 284 个往来单位 /
2021-03 ~ 2026-09-30)。**无** unit 名称列 ⇒ 客户标识只用 `unit_id`。
口径(勿擅改)
--------------
* **单据类型白名单 = 销售类 `bill_type IN (1, 4, 14)`**(1=XSD 销售单 / 4=XSTHD 销售退货 /
14=XSHHD 销售换货;等价判据 `bill_sn LIKE 'XSD%'/'XSTH%'/'XSHH%'` 实测逐行同集)。
非销售单据一律排除:0=JHD 进货 / 128=YSKD 验收 / 17=QTRK 其它入库 / 129=YFKD 预付款 /
101=SKD 收款 / 103=YBFYD 费用 / 104=QTSRD 其它收入 / 18=QTCK 其它出库。
**依据**:这些单据的 `unit_id` 是**供应商**(`abst` 实读「向【酣客酒业(广州)】进货」「预付款单」),
混入会把「客户集中度」变成「往来单位(含供应商)集中度」——旧口径 Top1/Top2 恰是本公司最大供应商,
也是「4,883.52 万 ÷ 账套 5001 808.80 万 = 6.04× 基座差」的真因。
⚠️ 该纠错**改变对外数字**(户数 73→65、Top1 25.3835%→22.5150%、Top2 归属由供应商改真实客户);
属**事实性纠错**(财务定死 + 任总知会),非选型。
* `C_CUST_REVENUE` / 快照明细 `revenue` = Σ`zxt_masterbill.sum_money`(按 `unit_id`、期内、**不含税原值**)。
过滤:`entity_id=? AND unit_id>0 AND bill_type IN (1,4,14) AND (sum_money+sum_tax_money)>0
AND bill_date IS NOT NULL`。
* **取舍口径(符号过滤)**:`(sum_money + sum_tax_money) > 0`(`tax_incl`)与 `sum_money > 0`
(`money_only`)在本库当前数据上**完全等价**(2026-01~09 双向差集 **0 行**;全表亦 0 行)。
原报告「407 行红冲(含税为正、金额为负)⇒ 预警侧低估客户收入 17.2%」经财务复验**证否并作废**:
407 行 = 1,651(无过滤全行) − 1,244(符号过滤),由 **302 行零额 + 105 行负额**组成(负额两式同样排除,
Σ`sum_money`=−841.22 万 / Σ含税=−1,717.97 万);所谓「1,651 行 / 78 户 / 4,042.31 万」实为
**无符号过滤(全行)**的结果(原报告**标签错置**);「17.23%」= 4,883.52÷4,042.31−1,
是「加符号过滤 vs **不滤**」之差(方向为**高估**),**不是**两路径之差。
⇒ 全体系**统一书面口径** `(sum_money + sum_tax_money) > 0`(预警与 CPA 同源同式,差集 0 ⇒ 行为零变化)。
* 对拍锚点(2026-01~09,销售类白名单口径):**65 户 / 1,207.74 万元 / Top1=22.5150%(unit152) /
Top2 单户=13.9734%(unit155) / Top4 累计=46.9407% / Top10 累计=69.7511%**(见 `ANCHORS_2026_01_09`)。
与账套 5001 逐月同向同量级(01: 229.02 vs 180.87;07: 81.55 vs 81.08;08: 15.83 vs 14.45)⇒ 可勾稽。
* `C_CUST_CONCENTRATION`(**沿用** `kpi_definitions.formula`)= 前5大客户收入 ÷ 期内客户收入合计 ×100。
(任务书 §2.1 写的 Top1 与现网定义「前5大」不一致 ⇒ 见报告「口径冲突登记」;
两者本模块都算:`top_n=1` / `top_n=5`。)
* `C_CUST_COGS_ALLOC` / `C_CUST_CHAN_SUB` / `C_CUST_CONTRIB` / `C_CUST_CONTRIB_RATE`:
**fail-closed,本模块不产出可写值**(理由见 `FAIL_CLOSED_REASONS`)——任何调用方
不得据此写 0 / 分摊值(任务 §2.2 与验收「禁止事项自证」)。
无写路径:本模块只做读聚合与纯计算,不 commit。
"""
from __future__ import annotations
from datetime import date
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
# ---------------------------------------------------------------------------
# 口径常量(单一事实源)
# ---------------------------------------------------------------------------
# 销售类单据白名单(P0 纠错主体):1=销售单 XSD / 4=销售退货 XSTHD / 14=销售换货 XSHHD
SALES_BILL_TYPES: Tuple[int, ...] = (1, 4, 14)
BILL_SCOPE_FILTER = {
"sales": f"bill_type IN ({', '.join(str(t) for t in SALES_BILL_TYPES)})",
# 'all' 仅用于对拍/回归(复现旧口径与财务复验的全单据数字),**不得**用于对外指标口径。
"all": "1 = 1",
}
DEFAULT_BILL_SCOPE = "sales"
REVENUE_FILTER = (
"entity_id = :eid AND unit_id IS NOT NULL AND unit_id > 0 "
"AND {bill_type} AND {sign} AND bill_date IS NOT NULL"
)
# 金额与取舍口径(**2026-10-04 财务复验更正**)
# 度量 = sum_money(不含税原值;与任务 §2.1 字面一致)
# 取舍 = 两种「净额符号」过滤器,决定**哪些行计入**:
# 'tax_incl' = (sum_money + sum_tax_money) > 0 ← **全体系统一书面口径**(预警 + CPA 同源)
# 'money_only'= sum_money > 0 ← 保留作回归位;与本库当前数据**逐行同集**(差集 0 行)
# 2026-01~09 销售类白名单下(两式同值):624 行 / 65 户 / 1,207.74 万元 /
# Top1 unit152 22.5150% / Top2 unit155 13.9734% / Top4 累计 46.9407% / Top10 累计 69.7511%。
MEASURE_SQL = "sum_money"
SIGN_FILTER = {"tax_incl": "(sum_money + sum_tax_money) > 0", "money_only": "sum_money > 0"}
DEFAULT_SIGN_BASIS = "tax_incl"
# 2026-01~09 对拍锚点(销售类白名单口径;派生器 `--anchor` 与回归共用,禁各写一份)
ANCHORS_2026_01_09 = {
"units": 65, "total_wan": 1207.74,
"top1": 22.5150, "top1_unit": 152,
"top2": 13.9734, "top2_unit": 155,
"top4_cum": 46.9407, "top10_cum": 69.7511,
}
ANCHOR_TOL = 0.05
REVENUE_SQL_WINDOW = """
SELECT unit_id, SUM({money}) AS amount
FROM zxt_masterbill
WHERE {filter}
AND bill_date >= :start AND bill_date <= :end
GROUP BY unit_id
ORDER BY amount DESC
"""
# period 桶:SUBSTR/SUBSTRING(date,1,7) 在 MySQL 与 SQLite 上均成立(隐式转字符串)
REVENUE_SQL_MONTHLY = """
SELECT SUBSTR(bill_date, 1, 7) AS period, unit_id, SUM({money}) AS amount
FROM zxt_masterbill
WHERE {filter}
GROUP BY SUBSTR(bill_date, 1, 7), unit_id
ORDER BY period, amount DESC
"""
def _filter_sql(sign_basis: str, bill_scope: str) -> str:
if sign_basis not in SIGN_FILTER:
raise ValueError(f"unknown sign_basis: {sign_basis!r} (expect {sorted(SIGN_FILTER)})")
if bill_scope not in BILL_SCOPE_FILTER:
raise ValueError(f"unknown bill_scope: {bill_scope!r} (expect {sorted(BILL_SCOPE_FILTER)})")
return REVENUE_FILTER.format(
bill_type=BILL_SCOPE_FILTER[bill_scope], sign=SIGN_FILTER[sign_basis])
def _sql_window(sign_basis: str = DEFAULT_SIGN_BASIS, bill_scope: str = DEFAULT_BILL_SCOPE) -> str:
return REVENUE_SQL_WINDOW.format(money=MEASURE_SQL, filter=_filter_sql(sign_basis, bill_scope))
def _sql_monthly(sign_basis: str = DEFAULT_SIGN_BASIS, bill_scope: str = DEFAULT_BILL_SCOPE) -> str:
return REVENUE_SQL_MONTHLY.format(money=MEASURE_SQL, filter=_filter_sql(sign_basis, bill_scope))
CONCENTRATION_TOP_N_DEFAULT = 5 # 现网 kpi_definitions.formula = 前5大客户
FAIL_CLOSED_REASONS = {
"C_CUST_COGS_ALLOC": (
"销货成本分摊 = 客户收入 × 公司整体销货成本率(5401÷5001)——**乘法式已废止**"
"(财务定死 2026-10-04:跨基座相乘无经济意义)。"
"**基数口径待任总一字定**:客户收入基座为 zxt_masterbill **销售类**单据流(2026-01~09 = 1,207.74 万),"
"而 5401/5001 来自凭证账(同期 5001 = 808.80 万、5401 = 811.76 万,成本率 100.37%);"
"乘法式会凭空放大 6 倍(旧全单据口径 4,883.52 ÷ 808.80 = 6.04× 基座差)。"
"财务推荐「成本池 × 客户份额」分置式:`Σ5401(期间,carry_forward=0) × [客户 i 收入 ÷ 期内客户收入合计]`"
"(份额分母须与本模块口径同一事实源);选项 2 = zxt 实际单据成本;选项 3 = 维持 NULL。"
"⇒ 裁定前该列**恒 NULL**,禁 0、禁硬摊。"
),
"C_CUST_CHAN_SUB": (
"客户专属渠道补贴须先按客户/渠道打标(571103 目前为总额、不可拆分,见任务 §2.2);"
"打标台账未到 ⇒ fail-closed,禁按收入占比硬摊。"
),
"C_CUST_CONTRIB": "依赖 C_CUST_CHAN_SUB(未打标)⇒ 不可算。",
"C_CUST_CONTRIB_RATE": "依赖 C_CUST_CONTRIB(不可算)⇒ 不可算。",
}
# 快照表允许写值的列(其余列写 NULL + fail_closed_reason 留痕)
WRITABLE_SNAPSHOT_COLUMNS = ("revenue",) # cogs_alloc/chan_sub/contrib/contrib_rate 属 P1
# ---------------------------------------------------------------------------
# 读聚合
# ---------------------------------------------------------------------------
def fetch_revenue_rows(engine, entity_id: int, start: date, end: date, top_n: int = 1,
sign_basis: str = DEFAULT_SIGN_BASIS, bill_scope: str = DEFAULT_BILL_SCOPE):
"""窗口内按客户聚合收入(元),按金额倒序。
sign_basis: 'tax_incl'=按含税净额取符号(**统一书面口径**)/ 'money_only'=按 sum_money 取符号
(回归位;与本库当前数据逐行同集)
bill_scope: 'sales'=销售类单据白名单(默认,P0 纠错)/ 'all'=全单据(仅对拍用,不得对外)
返回 (shares, total, period_range):
shares = [(unit_id, amount, pct), ...] 取前 top_n
total = 窗口内客户收入合计(元)
period_range = (start.isoformat(), end.isoformat())
无可用数据返回 ([], 0.0, None)。异常返回 ([], 0.0, None)(与既有预警路径一致,不抛出)。
"""
from sqlalchemy import text
try:
with engine.connect() as conn:
rows = conn.execute(
text(_sql_window(sign_basis, bill_scope)),
{"eid": entity_id, "start": start, "end": end},
).fetchall()
except Exception:
return ([], 0.0, None)
if not rows:
return ([], 0.0, None)
total = float(sum(float(r[1] or 0) for r in rows))
if total <= 0:
return ([], 0.0, None)
n = max(1, int(top_n))
shares = [
(int(r[0]), float(r[1] or 0), round(float(r[1] or 0) / total * 100, 2))
for r in rows[:n]
]
return (shares, total, (start.isoformat(), end.isoformat()))
def fetch_monthly_revenue(engine, entity_id: int, sign_basis: str = DEFAULT_SIGN_BASIS,
bill_scope: str = DEFAULT_BILL_SCOPE) -> Dict[str, List[Tuple[int, float]]]:
"""按自然月聚合的客户收入(元)。
返回 {period: [(unit_id, amount), ...] 按金额倒序};period 形如 'YYYY-MM'。
统一只返回**有可用数据**的期次(空期不出现)。
"""
from sqlalchemy import text
out: Dict[str, List[Tuple[int, float]]] = {}
with engine.connect() as conn:
rows = conn.execute(text(_sql_monthly(sign_basis, bill_scope)),
{"eid": entity_id}).fetchall()
for period, unit_id, amount in rows:
out.setdefault(str(period), []).append((int(unit_id), float(amount or 0)))
for p in out:
out[p].sort(key=lambda x: x[1], reverse=True)
return out
# ---------------------------------------------------------------------------
# 纯计算
# ---------------------------------------------------------------------------
def concentration_pct(pairs: Sequence[Tuple[int, float]], top_n: int = CONCENTRATION_TOP_N_DEFAULT) -> Optional[float]:
"""前 top_n 客户合计收入占期内客户收入合计的百分比(%)。无数据返回 None。"""
if not pairs:
return None
total = sum(a for _, a in pairs)
if total <= 0:
return None
n = max(1, int(top_n))
top = sum(a for _, a in pairs[:n])
return round(top / total * 100, 2)
def to_wan(yuan: float) -> float:
"""元 → 万元(4 位小数;1 位 = 1 元)。"""
return round(float(yuan) / 10000.0, 4)
def active_customer_count(pairs: Sequence[Tuple[int, float]]) -> int:
"""期内活跃客户数(按 unit_id 去重,含极小金额客户——与对拍锚点 65 口径一致)。"""
return len({u for u, _ in pairs})
def monthly_metrics(pairs: Sequence[Tuple[int, float]]) -> Dict[str, Optional[float]]:
"""单期汇总口径(写入 kpi_values 的那几个数)。"""
return {
"C_CUST_REVENUE": to_wan(sum(a for _, a in pairs)) if pairs else None,
"C_CUST_COUNT": float(active_customer_count(pairs)) if pairs else None,
"C_CUST_CONCENTRATION": concentration_pct(pairs, 1), # 任务 §2.1 口径(Top1)
"C_CUST_CONCENTRATION_TOP5": concentration_pct(pairs, 5), # 现网定义口径(前5大)
}