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.
This commit is contained in:
Hermes CI Fix
2026-10-09 17:00:37 +08:00
parent a48a116e06
commit 451c994a86
7 changed files with 1094 additions and 26 deletions
+9 -26
View File
@@ -814,44 +814,27 @@ def run_forecast_deviation_check(
def _customer_revenue_shares(db: Session, entity_id: int, lookback_months: int = 12, top_n: int = 1):
"""聚合客户(往来单位)收入占比 — 数据源 zxt_masterbill(智享通销售单据, unit_id=客户/往来单位)
返回 (shares, total, period_range):
返回 (shares, total, period_range):
shares = [(unit_id, amount, pct), ...] 按金额倒序取前 top_n 名
total = 窗口内客户收入合计
period_range = (start_date, end_date) ISO 字符串
无可用数据时返回 ([], 0.0, None)。
**口径单一事实源**:SQL 已抽到 `app/utils/cpa_caliber.py`(CPA 派生器共用同一实现,
防「同一客户收入两份结果」)。行为与抽函数前逐字一致(同过滤、同排序、同四舍五入)。
用独立连接执行,避免表不存在/方言差异污染调用方 session 事务。
"""
from datetime import date, timedelta
from app.database import get_engine
from app.utils import cpa_caliber
end = date.today()
start = end - timedelta(days=max(1, int(lookback_months)) * 30)
sql = text("""
SELECT unit_id, SUM(sum_money) AS amount
FROM zxt_masterbill
WHERE entity_id = :eid
AND unit_id IS NOT NULL AND unit_id > 0
AND sum_money IS NOT NULL AND sum_money > 0
AND bill_date IS NOT NULL
AND bill_date >= :start AND bill_date <= :end
GROUP BY unit_id
ORDER BY amount DESC
""")
try:
with get_engine().connect() as conn:
rows = conn.execute(sql, {"eid": entity_id, "start": start, "end": end}).fetchall()
except Exception as e:
logger.error(f"客户收入聚合失败 entity_id={entity_id}: {e}")
shares, total, rng = cpa_caliber.fetch_revenue_rows(
get_engine(), entity_id, start, end, top_n, sign_basis="money_only")
if not shares:
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()))
return (shares, total, rng)
def _check_customer_concentration(kpi_id: int, params: dict, db: Session, entity_id: int) -> tuple: