fix(cma): 缺失值 NULL 语义——同环比/执行率/进度不把缺失当 0
ci/woodpecker/push/main Pipeline failed

财务裁定 2026-09-21(博海 2026-08 批次 §2):前端对缺失值须显式 NULL 语义,
同环比不得按 0 参与。

- ProfitBreakdown: revChg/profitChg/rateChg 上期缺失→null(原 return 0 显示为持平 0.0%),
  去掉 prev || 0.01 假分母;模板 null 显示「缺上期数据」
- MyDashboard: 进度条 actual 缺失不再显示 0%,改显 —
- DeviationDashboard: 趋势图 actual/budget 保留 null,barHeight 对 null 不画柱
- BudgetManagement: 执行率/偏差额 actual 缺失→null(表格显示 -),平均执行率只对非空取平均
This commit is contained in:
fullstack-bot
2026-09-21 16:07:03 +08:00
parent f88ed94c15
commit e89629228e
4 changed files with 28 additions and 18 deletions
@@ -61,21 +61,24 @@
<div v-if="prevRevenue" class="pb-compare">
<div class="pb-compare-item">
<span class="pb-compare-label">营收较上期</span>
<span :class="revChg >= 0 ? 'pb-pos' : 'pb-neg'">
<span v-if="revChg != null" :class="revChg >= 0 ? 'pb-pos' : 'pb-neg'">
{{ revChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(revChg) * 100).toFixed(1) }}%
</span>
<span v-else style="color:#909399">缺上期数据</span>
</div>
<div class="pb-compare-item">
<span class="pb-compare-label">净利润较上期</span>
<span :class="profitChg >= 0 ? 'pb-pos' : 'pb-neg'">
<span v-if="profitChg != null" :class="profitChg >= 0 ? 'pb-pos' : 'pb-neg'">
{{ profitChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(profitChg) * 100).toFixed(1) }}%
</span>
<span v-else style="color:#909399">缺上期数据</span>
</div>
<div class="pb-compare-item">
<span class="pb-compare-label">净利率较上期</span>
<span :class="rateChg >= 0 ? 'pb-pos' : 'pb-neg'">
<span v-if="rateChg != null" :class="rateChg >= 0 ? 'pb-pos' : 'pb-neg'">
{{ rateChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(rateChg) * 100).toFixed(1) }}%
</span>
<span v-else style="color:#909399">缺上期数据</span>
</div>
</div>
@@ -129,19 +132,22 @@ const costRate = computed(() => {
return (props.cost / props.revenue * 100).toFixed(1) + '%'
})
const revChg = computed(() => {
if (!props.prevRevenue) return 0
// 缺失值 NULL 语义(财务裁定 2026-09-21 §2):上期缺失/为 0 时返回 null,
// 不得回落 0(0 会被显示成「持平」,把「无数据」当真实 0 参与同环比)。
const revChg = computed<number | null>(() => {
if (props.prevRevenue == null || props.prevRevenue === 0) return null
return (props.revenue - props.prevRevenue) / props.prevRevenue
})
const profitChg = computed(() => {
if (!props.prevProfit) return 0
const profitChg = computed<number | null>(() => {
if (props.prevProfit == null || props.prevProfit === 0) return null
return (props.profit - props.prevProfit) / Math.abs(props.prevProfit)
})
const rateChg = computed(() => {
if (!props.prevRevenue || !props.prevProfit) return 0
const rateChg = computed<number | null>(() => {
if (props.prevRevenue == null || props.prevProfit == null || props.prevRevenue === 0) return null
const cur = props.profit / props.revenue
const prev = props.prevProfit / props.prevRevenue
return (cur - prev) / Math.abs(prev || 0.01)
if (prev === 0) return null
return (cur - prev) / Math.abs(prev)
})
const detailItems = computed(() => {
+2 -2
View File
@@ -200,7 +200,7 @@
</template></el-table-column>
<el-table-column prop="actual_value" label="实际值" width="130"><template #default="{ row }">{{ formatNumber(row.actual_value, row.unit) }}</template></el-table-column>
<el-table-column prop="deviation_value" label="偏差" width="130"><template #default="{ row }"><span :style="{color: row.deviation_value > 0 ? '#f56c6c' : row.deviation_value < 0 ? '#67c23a' : '#999'}">{{ row.deviation_value > 0 ? '+' : '' }}{{ formatNumber(row.deviation_value, row.unit) }}</span></template></el-table-column>
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress :percentage="row.execution_rate || 0" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /></template></el-table-column>
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress v-if="row.execution_rate != null" :percentage="row.execution_rate" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /><span v-else style="color:#909399">-</span></template></el-table-column>
<el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
</el-table>
@@ -1094,7 +1094,7 @@ async function loadVersionDiff() { if (selectedVersions.value.length !== 2) retu
const execFilterYear = ref(currentYear); const execFilterMonth = ref(new Date().getMonth()); const execFilterDim = ref(''); const execLoading = ref(false); const execReport = ref<any[]>([]); const execSummaryCards = ref<any[]>([])
async function loadExecutionReport() {
execLoading.value = true
try { const r: any = await budgetApi.deviationReport({ year: execFilterYear.value, month: execFilterMonth.value, dimension: execFilterDim.value || undefined, hierarchical: true, entity_id: getEntityId() }); const items: any[] = r.items || []; const en = items.map((i: any) => ({ ...i, execution_rate: i.budget_value && i.budget_value > 0 ? Math.round((i.actual_value || 0) / i.budget_value * 100) : 0, deviation_value: (i.actual_value || 0) - (i.budget_value || 0) })); execReport.value = en; const hb = en.filter((i: any) => i.budget_value != null); const ob = en.filter((i: any) => i.is_over_budget); const ar = hb.length > 0 ? Math.round(hb.reduce((s, i) => s + (i.execution_rate || 0), 0) / hb.length) : 0; const tb = hb.reduce((s, i) => s + (i.budget_value || 0), 0); const ta = hb.reduce((s, i) => s + (i.actual_value || 0), 0); execSummaryCards.value = [{ label: '有预算KPI', value: `${hb.length}/${en.length}`, color: '#409eff' }, { label: '超预算KPI', value: ob.length.toString(), color: '#f56c6c' }, { label: '平均执行率', value: `${ar}%`, color: ar > 100 ? '#f56c6c' : '#67c23a' }, { label: '预算执行进度', value: tb > 0 ? `${Math.round(ta / tb * 100)}%` : '-', color: '#e6a23c' }] } catch (e) { ElMessage.error('加载执行报告失败') }
try { const r: any = await budgetApi.deviationReport({ year: execFilterYear.value, month: execFilterMonth.value, dimension: execFilterDim.value || undefined, hierarchical: true, entity_id: getEntityId() }); const items: any[] = r.items || []; const en = items.map((i: any) => ({ ...i, execution_rate: (i.budget_value && i.budget_value > 0 && i.actual_value != null) ? Math.round(i.actual_value / i.budget_value * 100) : null, deviation_value: i.actual_value != null ? i.actual_value - (i.budget_value ?? 0) : null })); execReport.value = en; const hb = en.filter((i: any) => i.budget_value != null); const ob = en.filter((i: any) => i.is_over_budget); const hbR = hb.filter((i: any) => i.execution_rate != null); const ar = hbR.length > 0 ? Math.round(hbR.reduce((s: number, i: any) => s + i.execution_rate, 0) / hbR.length) : null; const tb = hb.reduce((s, i) => s + (i.budget_value || 0), 0); const ta = hb.reduce((s, i) => s + (i.actual_value || 0), 0); execSummaryCards.value = [{ label: '有预算KPI', value: `${hb.length}/${en.length}`, color: '#409eff' }, { label: '超预算KPI', value: ob.length.toString(), color: '#f56c6c' }, { label: '平均执行率', value: ar == null ? '-' : `${ar}%`, color: (ar ?? 0) > 100 ? '#f56c6c' : '#67c23a' }, { label: '预算执行进度', value: tb > 0 ? `${Math.round(ta / tb * 100)}%` : '-', color: '#e6a23c' }] } catch (e) { ElMessage.error('加载执行报告失败') }
execLoading.value = false
}
+6 -5
View File
@@ -342,19 +342,20 @@ async function loadTrend() {
const keyB = b.period || ''
return keyA.localeCompare(keyB)
})
// 缺失值 NULL 语义(财务裁定 2026-09-21 §2):actual/budget 缺失保留 null(不回落 0)
trendData.value = monthly.map((i: any) => ({
period: i.period,
actual: i.actual_value || 0,
budget: i.budget_value || 0,
actual: i.actual_value ?? null,
budget: i.budget_value ?? null,
deviation: i.deviation_rate,
}))
maxTrend.value = Math.max(...trendData.value.flatMap((d: any) => [d.actual, d.budget]), 1)
maxTrend.value = Math.max(...trendData.value.flatMap((d: any) => [d.actual ?? 0, d.budget ?? 0]), 1)
} catch (e) { /* ignore */ }
trendLoading.value = false
}
function barHeight(val: number, max: number) {
if (!max) return 0
function barHeight(val: number | null, max: number) {
if (!max || val == null) return 0 // 缺失值 NULL 语义:不画柱,不以 0 参与
return Math.max(4, (val / max) * 150)
}
+4 -1
View File
@@ -78,12 +78,15 @@
<span v-if="isStale(k)" class="kpi-stale-tag">非当期</span>{{ targetLabel(k) }} {{ k.target_value ?? '-' }}
</div>
</div>
<!-- 缺失值 NULL 语义(财务裁定 2026-09-21 §2):actual 缺失时不得显示 0% 进度 -->
<el-progress
:percentage="k.target_value ? Math.min(100, Math.round((k.actual_value || 0) / k.target_value * 100)) : 0"
v-if="k.actual_value != null && k.target_value"
:percentage="Math.min(100, Math.round(k.actual_value / k.target_value * 100))"
:status="k.level === 'green' ? 'success' : k.level === 'red' ? 'exception' : undefined"
:stroke-width="6"
:show-text="false"
/>
<div v-else style="height:6px;line-height:6px;color:#c0c4cc;font-size:11px">—</div>
</div>
</div>
</div>