"""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大) }