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:
@@ -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:
|
||||
|
||||
@@ -18,6 +18,21 @@ router = APIRouter(prefix="/api/cma/customer-dashboard", tags=["客户看板"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
# ── 客户维度「标准指标族」九卡(与前端 CustomerDashboard.vue 的 CUSTOMER_TEMPLATES 逐项对应)
|
||||
# (卡片名, 默认单位, 预设目标值, 实例层 kpi_code 或 None)
|
||||
# 第四列为 None = 该卡在 KPI 库**无可用定义**(页面须显示「未定义·待建」,不得显示 0/无数据)。
|
||||
STANDARD_INDICATORS = [
|
||||
("客户满意度", "分", 90, "C_SATISFACTION"),
|
||||
("客户留存率", "%", 85, None),
|
||||
("新客户增长率", "%", 20, None),
|
||||
("客户渠补率", "%", 95, None),
|
||||
("客户投诉率", "%", 5, "C_COMPLAINT_RATE"),
|
||||
("客户平均贡献值", "元", 10000, None),
|
||||
("客户转化率", "%", 30, None),
|
||||
("客户活跃度", "%", 70, None),
|
||||
("NPS净推荐值", "分", 60, None),
|
||||
]
|
||||
|
||||
|
||||
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
|
||||
"""解析时间区间"""
|
||||
@@ -160,6 +175,78 @@ def list_customer_kpis(
|
||||
return {"data": result, "period": period, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/standard-indicators")
|
||||
def standard_indicators(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""客户维度「标准指标族」九卡三态(真值 / 无数据 / 未定义·待建)——只读。
|
||||
|
||||
CPA P0-b(2026-10-04):页面 9 张预设卡此前对「KPI 库无定义」的项渲染成「无数据」,
|
||||
与「定义在库但无值」不可区分。本端点逐卡给出三态,页面据此渲染标签,**禁止空显示为 0**。
|
||||
|
||||
映射为**显式白名单**(不做名称模糊匹配),仅当实例层 KPI 与预设卡语义完全一致才连:
|
||||
客户满意度 → C_SATISFACTION(实例层同名,active,有值)
|
||||
客户投诉率 → C_COMPLAINT_RATE(实例层同名,inactive ⇒ 无值)
|
||||
其余 7 张保持未定义(依据任务 finance-20261003 现状表 §一「7 个在 KPI 库无可用定义」):
|
||||
客户留存率 ≠ 客户保留率(C_RETENTION_RATE)、客户渠补率 ≠ 渠补率(C_REBATE_RATE)、
|
||||
新客户增长率 ≠ 新客户数(C_NEW_CLIENTS)、客户平均贡献值/客户转化率/客户活跃度/NPS净推荐值 库内皆无。
|
||||
"""
|
||||
codes = [c for _, _, _, c in STANDARD_INDICATORS if c]
|
||||
defs = {}
|
||||
if codes:
|
||||
# 用 raw SQL 读定义层:**不用 KPIDefinition ORM**——该模型的 `direction` 列由他单
|
||||
# (CMA-KPI-20261003 KPI字典分层治理)在途新增,生产库尚未落列,ORM 查询会 1054;
|
||||
# raw SQL 只取本端点需要的列,与在途 DDL 解耦(与 zxt_masterbill 同一形态)。
|
||||
from sqlalchemy import bindparam, text as _text
|
||||
stmt = _text(
|
||||
"SELECT id, kpi_code, unit, target_value, status FROM kpi_definitions "
|
||||
"WHERE entity_id = :eid AND dimension = 'customer' AND kpi_code IN :codes"
|
||||
).bindparams(bindparam("codes", expanding=True))
|
||||
rows = db.execute(stmt, {"eid": entity_id, "codes": codes}).fetchall()
|
||||
defs = {r[1]: {"id": r[0], "unit": r[2], "target_value": r[3], "status": r[4]} for r in rows}
|
||||
|
||||
items = []
|
||||
summary = {"value": 0, "no_data": 0, "undefined": 0, "total": len(STANDARD_INDICATORS)}
|
||||
state_labels = {"value": "真值", "no_data": "无数据", "undefined": "未定义·待建"}
|
||||
for name, unit, target, code in STANDARD_INDICATORS:
|
||||
kpi = defs.get(code) if code else None
|
||||
if kpi is None:
|
||||
state = "undefined"
|
||||
items.append({
|
||||
"name": name, "unit": unit, "target_value": target, "state": state,
|
||||
"state_label": state_labels[state], "kpi_code": None, "kpi_id": None,
|
||||
"kpi_status": None, "actual_value": None, "period": None,
|
||||
"why": "KPI 库无该定义",
|
||||
})
|
||||
else:
|
||||
val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi["id"],
|
||||
KPIValue.entity_id == entity_id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
month_period_clause(KPIValue.period),
|
||||
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
|
||||
state = "value" if val else "no_data"
|
||||
items.append({
|
||||
"name": name, "unit": kpi["unit"] or unit,
|
||||
"target_value": kpi["target_value"] if kpi["target_value"] is not None else target,
|
||||
"state": state, "state_label": state_labels[state],
|
||||
"kpi_code": code, "kpi_id": kpi["id"], "kpi_status": kpi["status"],
|
||||
"actual_value": val.actual_value if val else None,
|
||||
"period": val.period if val else None,
|
||||
"why": None if val else "定义在库(status=%s)但无任何月期间值" % kpi["status"],
|
||||
})
|
||||
summary[state] += 1
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"items": items,
|
||||
"summary": summary,
|
||||
"note": ("state: value=定义在库且有值 / no_data=定义在库但无值 / "
|
||||
"undefined=KPI 库无定义(未定义·待建);禁止把 undefined/no_data 显示为 0"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/trend/{kpi_id}")
|
||||
def get_kpi_trend(
|
||||
kpi_id: int,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""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大)
|
||||
}
|
||||
Reference in New Issue
Block a user