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
+70
View File
@@ -3,6 +3,42 @@
<div class="page-header">
<h3 class="page-title">客户维度KPI看板</h3>
</div>
<!-- 客户标准指标族(9 卡三态:真值 / 无数据 / 未定义·待建)—— CPA P0-b 2026-10-04 -->
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header-flex">
<span>客户标准指标族(9 项)</span>
<span class="std-summary">
真值 {{ stdSummary.value }} · 无数据 {{ stdSummary.no_data }} · 未定义·待建 {{ stdSummary.undefined }}
</span>
</div>
</template>
<div class="std-grid">
<div
v-for="it in stdItems" :key="it.name"
class="std-card" :class="'std-' + it.state"
>
<div class="std-head">
<span class="std-name">{{ it.name }}</span>
<span class="std-badge" :class="'badge-' + it.state">{{ it.state_label }}</span>
</div>
<div class="std-value">
<template v-if="it.state === 'value'">
<span class="std-num">{{ it.actual_value }}</span>
<span class="std-unit">{{ it.unit }}</span>
</template>
<span v-else-if="it.state === 'no_data'" class="std-ph">定义在库 · 暂无数据</span>
<span v-else class="std-ph">未定义 · 待建</span>
</div>
<div class="std-meta">
<span v-if="it.kpi_code">{{ it.kpi_code }}</span>
<span v-if="it.period">最近期间 {{ it.period }}</span>
<span v-else>目标 {{ it.target_value }}{{ it.unit }}</span>
</div>
</div>
</div>
</el-card>
<KPIListView
ref="kpiListRef"
:dimension="'customer'"
@@ -58,6 +94,22 @@ const CUSTOMER_TEMPLATES = [
{ name: 'NPS净推荐值', unit: '分', targetValue: 60 },
]
// ── 客户标准指标族三态(CPA P0-b 2026-10-04)──
// 数据源 = GET /customer-dashboard/standard-indicators(只读;显式白名单映射,不做名称模糊匹配)
// state: value=真值 / no_data=定义在库无值 / undefined=KPI 库无定义(未定义·待建)——禁止显示为 0
const stdItems = ref<any[]>([])
const stdSummary = ref<any>({ value: 0, no_data: 0, undefined: 0, total: 0 })
async function loadStandardIndicators() {
const eid = Number(localStorage.getItem('cma_entity_id') || 1)
try {
const r: any = await api.get('/customer-dashboard/standard-indicators', { params: { entity_id: eid } })
stdItems.value = r.items || []
stdSummary.value = r.summary || stdSummary.value
} catch {
stdItems.value = []
}
}
// ── 趋势分析 ──
const kpiListRef = ref<InstanceType<typeof KPIListView> | null>(null)
const trendType = ref('monthly')
@@ -116,6 +168,7 @@ function renderChart() {
}
onMounted(() => {
loadStandardIndicators()
// 等待 KPIListView 加载完成后加载趋势
setTimeout(() => loadTrend(), 500)
// 尝试加载 ECharts
@@ -139,4 +192,21 @@ onUnmounted(() => {
.customer-dashboard { padding: 0; }
.section-card { margin-bottom: 16px; margin-top: 16px; }
.card-header-flex { display: flex; justify-content: space-between; align-items: center; }
.std-summary { font-size: 12px; color: #909399; }
.std-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
.std-card { border: 1px solid #ebeef5; border-radius: 8px; padding: 12px 14px; background: #fff; }
.std-card.std-value { border-left: 3px solid #67c23a; }
.std-card.std-no_data { border-left: 3px solid #e6a23c; }
.std-card.std-undefined { border-left: 3px solid #dcdfe6; background: #fafafa; }
.std-head { display: flex; justify-content: space-between; align-items: center; gap: 6px; }
.std-name { font-size: 13px; font-weight: 600; color: #303133; }
.std-badge { font-size: 11px; padding: 1px 6px; border-radius: 3px; white-space: nowrap; }
.badge-value { background: #f0f9eb; color: #67c23a; }
.badge-no_data { background: #fdf6ec; color: #e6a23c; }
.badge-undefined { background: #f4f4f5; color: #909399; }
.std-value { margin: 8px 0 4px; min-height: 32px; display: flex; align-items: baseline; }
.std-num { font-size: 22px; font-weight: 700; color: #303133; }
.std-unit { font-size: 12px; color: #909399; margin-left: 4px; }
.std-ph { font-size: 13px; color: #a8abb2; }
.std-meta { display: flex; gap: 10px; font-size: 11px; color: #c0c4cc; }
</style>