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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user