feat(cvp): CM≤0 fail-closed 禁写0.0 + 博海BEP扩面(新口径) + 影子CVP标注is_mock + 前端NULL守卫/不适用
ci/woodpecker/push/main Pipeline failed
ci/woodpecker/push/main Pipeline failed
- kpi_bep_deriver.py: 实体口径注册表(app/utils/bep_caliber.py 单一事实源);博海(6001/6401/6601-03/1602); fail-closed② 边际贡献非正/固定成本退化 ⇒ 五条保本值 NULL+pending+remark'不适用'(禁回落0.0) - dashboard.py /bep-card: 多实体口径 + not_applicable 态返回 label/reason - predict.py /cvp-detailed: 显式 source=scenario_simulation + is_mock=true(影子CVP标注) - 前端 BEPBreakEvenCard.vue: 全量数值判空 + '不适用'态;PredictDashboard.vue: 情景模拟·非实测标签 验收 accept-bep-cvp-20261003.py: BEFORE 2/11 → AFTER 11/11 PASS 任务 finance-20261003-cvp-fix-and-bohai-coverage-fullstack
This commit is contained in:
@@ -1,6 +1,25 @@
|
||||
<template>
|
||||
<div v-if="card" class="bep-card-wrap">
|
||||
<div class="bep-card" :class="'lvl-' + level" @click="expanded = !expanded">
|
||||
<!-- ① 不适用态:CM≤0(边际贡献非正)/ 固定成本退化 —— 派生器 fail-closed 落库;显示「不适用」,禁显示 0 -->
|
||||
<div v-if="na" class="bep-card lvl-na">
|
||||
<div class="bep-head">
|
||||
<div class="bep-title">
|
||||
<span class="bep-icon">⚖️</span>
|
||||
<span>本量利 · 保本点</span>
|
||||
</div>
|
||||
<div class="bep-meta">
|
||||
<span class="bep-period">{{ fmtPeriod(card.period) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bep-na-row">
|
||||
<span class="na-badge">不适用</span>
|
||||
<span class="na-label">{{ card.label || '边际贡献为负 · 保本点不适用' }}</span>
|
||||
</div>
|
||||
<div class="na-reason">{{ card.reason || '本期边际贡献非正 / 固定成本退化,保本点无经济意义(fail-closed,不回落 0)' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- ② 常态卡 -->
|
||||
<div v-else class="bep-card" :class="'lvl-' + level" @click="expanded = !expanded">
|
||||
<div class="bep-head">
|
||||
<div class="bep-title">
|
||||
<span class="bep-icon">⚖️</span>
|
||||
@@ -17,7 +36,7 @@
|
||||
<div class="bep-main">
|
||||
<div class="bep-cover">
|
||||
<span class="dot" :class="'dot-' + level"></span>
|
||||
<span class="bep-cover-num">{{ current.cover.toFixed(1) }}</span>
|
||||
<span class="bep-cover-num">{{ fmt(current.cover) }}</span>
|
||||
<span class="bep-cover-unit">%</span>
|
||||
</div>
|
||||
<div class="bep-cover-label">保本覆盖率<span class="lvl-text">{{ lvlText }}</span></div>
|
||||
@@ -25,21 +44,21 @@
|
||||
<!-- 副指标并排 -->
|
||||
<div class="bep-sub-grid">
|
||||
<div class="bep-sub">
|
||||
<div class="sub-val">{{ current.bep_revenue.toFixed(1) }}</div>
|
||||
<div class="sub-val">{{ fmt(current.bep_revenue) }}</div>
|
||||
<div class="sub-label">月保本销售额(万)</div>
|
||||
</div>
|
||||
<div class="bep-sub">
|
||||
<div class="sub-val">{{ inputs.sales_avg.toFixed(1) }}</div>
|
||||
<div class="sub-val">{{ fmt(inputs.sales_avg) }}</div>
|
||||
<div class="sub-label">当前月销(万)</div>
|
||||
</div>
|
||||
<div class="bep-sub">
|
||||
<div class="sub-val" :style="{ color: gapColor }">{{ current.gap.toFixed(1) }}%</div>
|
||||
<div class="sub-val" :style="{ color: gapColor }">{{ fmt(current.gap) }}%</div>
|
||||
<div class="sub-label">缺口</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 辅助行 -->
|
||||
<div class="bep-formula-line">
|
||||
固定成本 {{ current.admin_avg.toFixed(1) }}万 ÷ 边际贡献率 {{ inputs.cm_pct.toFixed(2) }}%
|
||||
固定成本 {{ fmt(current.admin_avg) }}万 ÷ 边际贡献率 {{ fmt(inputs.cm_pct, 2) }}%
|
||||
<span v-if="mode === 'cash'" class="tag-cash">现金口径</span>
|
||||
<span v-else class="tag-book">账面口径</span>
|
||||
</div>
|
||||
@@ -47,7 +66,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 下钻面板 -->
|
||||
<div v-if="expanded" class="bep-detail">
|
||||
<div v-if="expanded && !na" class="bep-detail">
|
||||
<!-- 口径切换 -->
|
||||
<div class="detail-mode">
|
||||
<el-radio-group v-model="mode" size="small">
|
||||
@@ -62,10 +81,10 @@
|
||||
<div class="detail-title">📐 公式</div>
|
||||
<div class="detail-line">{{ card.formula }}</div>
|
||||
<div v-if="mode === 'book'" class="detail-line sub">
|
||||
账面:固定成本{{ inputs.admin_avg.toFixed(1) }}万 ÷ {{ inputs.cm_pct.toFixed(2) }}% = 保本{{ current.bep_revenue.toFixed(1) }}万(管理费含折旧)
|
||||
账面:固定成本{{ fmt(inputs.admin_avg) }}万 ÷ {{ fmt(inputs.cm_pct, 2) }}% = 保本{{ fmt(current.bep_revenue) }}万(管理费含折旧)
|
||||
</div>
|
||||
<div v-if="mode === 'cash'" class="detail-line sub">
|
||||
现金:管理费{{ inputs.admin_avg.toFixed(1) }} − 折旧{{ inputs.dep_avg.toFixed(1) }} = {{ current.admin_avg.toFixed(1) }}万 ÷ {{ inputs.cm_pct.toFixed(2) }}% = 保本{{ current.bep_revenue.toFixed(1) }}万
|
||||
现金:管理费{{ fmt(inputs.admin_avg) }} − 折旧{{ fmt(inputs.dep_avg) }} = {{ fmt(current.admin_avg) }}万 ÷ {{ fmt(inputs.cm_pct, 2) }}% = 保本{{ fmt(current.bep_revenue) }}万
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-col">
|
||||
@@ -77,10 +96,10 @@
|
||||
<div class="detail-kpis">
|
||||
<div class="detail-title">📊 输入KPI</div>
|
||||
<div class="kpi-chips">
|
||||
<span class="kpi-chip">月管理费 <b>{{ inputs.admin_avg.toFixed(1) }}</b>万</span>
|
||||
<span class="kpi-chip">月收入(5001) <b>{{ inputs.sales_avg.toFixed(1) }}</b>万</span>
|
||||
<span class="kpi-chip">渠补率 <b>{{ inputs.rebate_pct.toFixed(2) }}</b>%</span>
|
||||
<span class="kpi-chip">折旧 <b>{{ inputs.dep_avg.toFixed(1) }}</b>万/月</span>
|
||||
<span class="kpi-chip">月管理费 <b>{{ fmt(inputs.admin_avg) }}</b>万</span>
|
||||
<span class="kpi-chip">月收入(5001) <b>{{ fmt(inputs.sales_avg) }}</b>万</span>
|
||||
<span class="kpi-chip">渠补率 <b>{{ fmt(inputs.rebate_pct, 2) }}</b>%</span>
|
||||
<span class="kpi-chip">折旧 <b>{{ fmt(inputs.dep_avg) }}</b>万/月</span>
|
||||
</div>
|
||||
<div class="kpi-src-note">数据源:voucher_details 凭证聚合(YTD月均口径)· {{ card.period }} · 纯只读</div>
|
||||
</div>
|
||||
@@ -96,17 +115,27 @@ const card = ref<any>(null)
|
||||
const expanded = ref(false)
|
||||
const mode = ref<'book' | 'cash'>('book')
|
||||
|
||||
// 数值渲染安全网(任务 finance-20261003-cvp-fix:所有数值判空,禁裸 toFixed 崩卡)
|
||||
function fmt(v: any, d = 1): string {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n.toFixed(d) : '—'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r: any = await api.get('/dashboard/bep-card')
|
||||
card.value = (r && (r.data || r)) || null
|
||||
if (!card.value?.available) card.value = null
|
||||
const d = (r && (r.data || r)) || null
|
||||
// available=False 但 not_applicable=True(CM≤0 / 固定成本退化)⇒ 保留以显示「不适用」(不显示 0)
|
||||
card.value = (d && (d.available === true || d.not_applicable === true)) ? d : null
|
||||
} catch (e) {
|
||||
console.error('加载保本点卡片失败', e)
|
||||
card.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 「不适用」态(后端 fail-closed 返回 label / reason)
|
||||
const na = computed(() => card.value?.not_applicable === true)
|
||||
const inputs = computed(() => card.value?.inputs || { sales_avg: 0, admin_avg: 0, dep_avg: 0, rebate_pct: 0, cm_pct: 0 })
|
||||
const current = computed(() => {
|
||||
const d = card.value?.[mode.value === 'cash' ? 'cash' : 'book'] || { bep_revenue: 0, cover: 0, gap: 0, admin_avg: 0 }
|
||||
@@ -125,6 +154,11 @@ const modeNote = computed(() =>
|
||||
)
|
||||
|
||||
const insight = computed(() => {
|
||||
if (na.value) {
|
||||
return '本期' + (card.value?.label || '保本点不适用') + ':'
|
||||
+ (card.value?.reason || '边际贡献非正 / 固定成本退化')
|
||||
+ '(fail-closed,不显示 0)。'
|
||||
}
|
||||
const sales = inputs.value.sales_avg || 0
|
||||
const bep = current.value.bep_revenue || 0
|
||||
const gap = current.value.gap || 0
|
||||
@@ -156,12 +190,18 @@ onMounted(load)
|
||||
.bep-card.lvl-green { border-left-color: #67c23a; }
|
||||
.bep-card.lvl-yellow { border-left-color: #e6a23c; }
|
||||
.bep-card.lvl-red { border-left-color: #f56c6c; }
|
||||
.bep-card.lvl-na { border-left-color: #909399; cursor: default; }
|
||||
.bep-card:hover { box-shadow: 0 4px 14px rgba(0,0,0,0.1); transform: translateY(-1px); }
|
||||
.bep-card.lvl-na:hover { box-shadow: 0 1px 4px rgba(0,0,0,0.06); transform: none; }
|
||||
.bep-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.bep-title { font-size: 15px; font-weight: 600; color: #1a1a2e; display: flex; align-items: center; gap: 8px; }
|
||||
.bep-icon { font-size: 17px; }
|
||||
.bep-meta { display: flex; align-items: center; gap: 10px; font-size: 12px; color: #999; }
|
||||
.bep-expand-hint { color: #409eff; }
|
||||
.bep-na-row { display: flex; align-items: center; gap: 10px; margin: 6px 0 4px; }
|
||||
.na-badge { background: #f4f4f5; color: #909399; border: 1px solid #e4e4e7; border-radius: 4px; font-size: 12px; padding: 2px 8px; font-weight: 600; }
|
||||
.na-label { font-size: 14px; font-weight: 600; color: #606266; }
|
||||
.na-reason { font-size: 12px; color: #909399; line-height: 1.6; margin-top: 2px; }
|
||||
.bep-body { display: flex; align-items: center; gap: 26px; flex-wrap: wrap; }
|
||||
.bep-main { display: flex; align-items: center; gap: 10px; min-width: 150px; }
|
||||
.bep-cover { display: flex; align-items: baseline; gap: 4px; }
|
||||
|
||||
@@ -35,7 +35,11 @@
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<el-card v-if="cvpResult.breakeven_revenue !== undefined">
|
||||
<template #header>分析结果</template>
|
||||
<template #header>
|
||||
<span>分析结果</span>
|
||||
<el-tag v-if="cvpResult.is_mock || cvpResult.source === 'scenario_simulation'"
|
||||
type="warning" size="small" effect="plain" style="margin-left:8px;">情景模拟 · 非实测</el-tag>
|
||||
</template>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">保本点(销售额)</div><div class="r-value red">{{ formatMoney(cvpResult.breakeven_revenue, 'wan') }} 万</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">保本点(销售量)</div><div class="r-value red">{{ cvpResult.breakeven_units?.toLocaleString() }} 瓶</div></div></el-col>
|
||||
|
||||
Reference in New Issue
Block a user