Files
cma-management/backend/app/utils/cpa_caliber.py
T
Hermes CI Fix 451c994a86 feat(cpa): customer profitability MVP P0-a/b - revenue caliber single source + snapshot table + 9-card tri-state
Task: finance-20261003-cpa-customer-profitability-mvp-fullstack (P0-a / P0-b)

- backend/app/utils/cpa_caliber.py (new): single source of truth for
  customer-level revenue aggregation from zxt_masterbill. Reused by
  alert_rules._customer_revenue_shares (dedupe: no second implementation).
- backend/scripts/migrate_cpa_snapshot_20261004.py (new): idempotent DDL for
  customer_profitability_snapshot (UNIQUE entity_id+period+unit_id),
  --check / --apply / --rollback / --status / --show-create + journal.
- backend/scripts/kpi_customer_profitability_deriver.py (new): monthly
  snapshot + kpi_values upsert (single transaction, idempotent).
  fail-closed: cogs_alloc / chan_sub / contrib / contrib_rate stay NULL and
  are never written as 0 or as an apportioned value.
- backend/scripts/sql/cpa_kpi_defs_20261004.sql (new): C_CUST_REVENUE and
  C_CUST_COUNT active; 5 P1 codes registered inactive with fail-closed reason.
- backend/app/api/customer_dashboard.py: new read-only endpoint
  GET /api/cma/customer-dashboard/standard-indicators returning the 9-card
  tri-state (value / no_data / undefined). Reads kpi_definitions via raw SQL
  to stay decoupled from the in-flight KPI direction DDL.
- frontend/src/views/CustomerDashboard.vue: 9-card panel rendering the three
  states (never renders an empty value as 0).

Anchor check 2026-01..09 (offline sqlite rehearsal, ALL_PASS=true):
73 customers / 4883.52 wan / top1 25.3835 / top2 23.4905 / top4 63.3249 /
top10 81.7675 - all six match the task acceptance anchors.

No production write and no deploy in this commit; release is gated.
2026-10-09 17:00:37 +08:00

203 lines
9.6 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)。
为什么有本模块
--------------
客户级收入聚合此前只有一处实现(`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`。
口径(勿擅改)
--------------
* `C_CUST_REVENUE` / 快照明细 `revenue` = Σ`zxt_masterbill.sum_money`(按 `unit_id`、期内、**含税**原值)。
过滤:`entity_id=? AND unit_id>0 AND sum_money>0 AND bill_date NOT NULL`(与预警路径逐字一致)。
这组过滤下的对拍锚点(2026-01~09):73 客户 / 4,883.52 万元 / Top1=25.3835% /
Top2=23.4905% / Top4 累计=63.3249% / Top10 累计=81.7675%。
* `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
# ---------------------------------------------------------------------------
# 口径常量(单一事实源)
# ---------------------------------------------------------------------------
REVENUE_FILTER = (
"entity_id = :eid AND unit_id IS NOT NULL AND unit_id > 0 "
"AND {sign} AND bill_date IS NOT NULL"
)
# 金额与取舍口径(**2026-10-04 侦查实证**,逐项复现任务验收锚点)
# 度量 = sum_money(不含税原值;与任务 §2.1 字面一致)
# 取舍 = 两种「净额符号」过滤器,决定**哪些行计入**:
# 'tax_incl' = (sum_money + sum_tax_money) > 0 ← CPA 采用:唯此口径能逐项复现验收锚点
# 2026-01~09:1,244 行 / 73 户 / 4,883.52 万元 / Top1 unit146 25.3835% /
# 次席 23.4905% / top4 累计 63.3249% / top10 累计 81.7675%(六项全中)。
# 与 'money_only' 的差集 = 407 行红冲/退货类(sum_money<0 但含税为正,合计 −841.19 万)。
# 'money_only'= sum_money > 0 ← 客户集中度预警 `_customer_revenue_shares` 既有口径,
# 为不改变既有预警阈值行为而**原样保留**(1,651 行 / 78 户 / 4,042.31 万元)。
# ⚠️ 取舍口径并存属**在途分歧**:预警侧若沿用 money_only,会系统性低估客户收入(−17.2%)。
# 已登记 ASK 交财务裁定;未裁定前不擅自改预警侧口径(改动=预警阈值漂移)。
MEASURE_SQL = "sum_money"
SIGN_FILTER = {"tax_incl": "(sum_money + sum_tax_money) > 0", "money_only": "sum_money > 0"}
DEFAULT_SIGN_BASIS = "tax_incl"
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 _sql_window(sign_basis: str = DEFAULT_SIGN_BASIS) -> str:
return REVENUE_SQL_WINDOW.format(
money=MEASURE_SQL, filter=REVENUE_FILTER.format(sign=SIGN_FILTER[sign_basis]))
def _sql_monthly(sign_basis: str = DEFAULT_SIGN_BASIS) -> str:
return REVENUE_SQL_MONTHLY.format(
money=MEASURE_SQL, filter=REVENUE_FILTER.format(sign=SIGN_FILTER[sign_basis]))
CONCENTRATION_TOP_N_DEFAULT = 5 # 现网 kpi_definitions.formula = 前5大客户
FAIL_CLOSED_REASONS = {
"C_CUST_COGS_ALLOC": (
"销货成本分摊 = 客户收入 × 公司整体销货成本率(5401÷5001)。"
"**基数口径未决**:客户收入基座为 zxt_masterbill 含税单据流(2026-01~09 = 4,883.52 万),"
"而 5401/5001 来自凭证账(同期主营业务收入量级 ~900 万),两者相差约 5.4×;"
"直接相乘产出无经济意义的数。且 handoff 要求与「统一后的 CVP 变动成本口径」一致,"
"而 CVP 口径统一单(finance-20261003-cvp-fix-and-bohai-coverage-fullstack 第⑤项)尚在途。"
"⇒ 待财务裁定基数后方可落值。"
),
"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):
"""窗口内按客户聚合收入(元),按金额倒序。
sign_basis: 'tax_incl'=按含税净额取符号(CPA 默认,复现验收锚点)/ 'money_only'=按 sum_money 取符号
(客户集中度预警既有行为,原样保留)
返回 (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)), {"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) -> 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)), {"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 去重,含极小金额客户——与对拍锚点 73 口径一致)。"""
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大)
}