feat(profitability): real ROI (op profit / invested capital), enable EVA, add RI
ci/woodpecker/push/main Pipeline failed

Kaliber: wecom-finance 2026-10-02 ruling (Boss 11:59 CST "li; but (1) add real ROI").

- kpi_definitions seed scripts/sql/profitability_kpi_defs_20261003.sql:
  F_ROI renamed to F_ROA (same kpi_id=20, all 8 historical values kept), new F_ROI
  (real IMA caliber = operating profit / invested capital), F_EVA activated with unit
  and thresholds aligned to 万元, new F_RI (residual income).
- parameters are NOT hardcoded in formula text: they live in
  kpi_definitions.data_source_config.params (tax_rate / wacc / min_return_rate /
  include_factory_subsidy / include_channel_subsidy / include_sales_discount /
  annualize / capital_basis); single reader = app/utils/profitability_params.py.
- deriver scripts/kpi_profitability_deriver.py: YTD operating profit from
  voucher_details (carry_forward=0 net), invested capital from subject_balances
  level-1 rows only, idempotent upsert into kpi_values (source_type=derived, no
  data_status downgrade, fail-closed when invested capital is 0).
- read-only endpoint GET /reports/value-management reads the derived rows only
  (single source of truth, no recomputation) and exposes the parameter block.
- DupontAnalysis.vue: value management section (ROI / ROA / ROE / EVA / RI).
- caliber_units.py: RATIO_CALIBER_TABLE key F_ROI renamed to F_ROA, new F_ROI row.
- tests: backend/tests/test_profitability_indicators.py (21 cases).
This commit is contained in:
Hermes CI Fix
2026-10-03 02:10:47 +08:00
parent 1da0a74568
commit 8631ce718f
7 changed files with 1113 additions and 4 deletions
+119 -2
View File
@@ -10,6 +10,41 @@
<el-button type="primary" size="small" @click="fetchDupont" :loading="loading">刷新</el-button>
</div>
<!-- 价值管理区:真 ROI / ROA / EVA / RI(口径:财务裁定 2026-10-02,任总 11:59「立;但①要补真ROI」) -->
<div class="value-mgmt" v-if="vm && vm.period">
<div class="vm-title">
<svg class="vm-icon" viewBox="0 0 24 24" width="16" height="16" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 17l6-6 4 4 8-8"/><path d="M21 7v10H11"/>
</svg>
<span>价值管理({{ vm.period }})</span>
<span class="vm-sub">YTD 累计口径 · 派生值单源(source_type=derived)</span>
</div>
<div class="vm-cards">
<div class="vm-card" v-for="m in vmCards" :key="m.key">
<div class="vm-value" :class="{ neg: m.raw !== null && m.raw !== undefined && m.raw < 0 }">
{{ m.text }}<span class="vm-unit">{{ m.unit }}</span>
</div>
<div class="vm-label">{{ m.label }}</div>
<div class="vm-desc">{{ m.desc }}</div>
</div>
</div>
<div class="vm-params" v-if="vm.params">
<span class="vm-params-title">参数(kpi_definitions.data_source_config)</span>
所得税率 {{ pct(vm.params.tax_rate) }} · WACC {{ pct(vm.params.wacc) }} ·
最低报酬率 {{ pct(vm.params.min_return_rate) }} ·
厂补{{ vm.params.include_factory_subsidy ? '计入' : '不计入' }} ·
渠补{{ vm.params.include_channel_subsidy ? '计入' : '不计入' }} ·
商品折扣{{ vm.params.include_sales_discount ? '计入' : '不计入' }} ·
{{ vm.params.annualize ? '年化(×12/月数)' : 'YTD 非年化' }} ·
投入资本{{ vm.params.capital_basis === 'average' ? '平均' : '期末' }}
</div>
</div>
<div class="value-mgmt empty" v-else-if="vmLoaded && !loading">
<div class="vm-title"><span>价值管理</span></div>
<div class="vm-empty">ROI / ROA / EVA / RI 尚无派生值(请先运行 kpi_profitability_deriver.py)</div>
</div>
<template v-if="data.roe !== null && data.roe !== undefined">
<!-- 1. ROE大数字 -->
<div class="roe-hero">
@@ -111,7 +146,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { sessionEntityName } from '../utils/entity'
// 统一走 api/index.ts 的 axios 实例(拦截器自动带 Bearer / 401 跳登录;语义同为「已剥层」)
import api from '../api/index'
@@ -119,6 +154,36 @@ import api from '../api/index'
const loading = ref(false)
const entityLabel = ref(sessionEntityName())
const data = ref<any>({})
// 价值管理区(真 ROI / ROA / EVA / RI)——只读 /reports/value-management(派生值单源)
const vm = ref<any>({})
const vmLoaded = ref(false)
function fmtVal(v: any): string {
if (v === null || v === undefined) return '-'
const n = Number(v)
return Number.isFinite(n) ? n.toFixed(2) : '-'
}
function pct(v: any): string {
const n = Number(v)
return Number.isFinite(n) ? (n * 100).toFixed(2).replace(/\.00$/, '') + '%' : '-'
}
const vmCards = computed(() => {
const d = vm.value || {}
return [
{ key: 'roi', label: d.roi?.kpi_name || '投入资本回报率(ROI)', raw: d.roi?.value,
unit: d.roi?.unit || '%', text: fmtVal(d.roi?.value), desc: '营业利润 ÷ 投入资本(真 ROI)' },
{ key: 'roa', label: d.roa?.kpi_name || '总资产报酬率(ROA)', raw: d.roa?.value,
unit: d.roa?.unit || '%', text: fmtVal(d.roa?.value), desc: '净利润 ÷ 平均总资产(原 F_ROI 正名)' },
{ key: 'roe', label: d.roe?.kpi_name || '净资产收益率(ROE)', raw: d.roe?.value,
unit: d.roe?.unit || '%', text: fmtVal(d.roe?.value), desc: '净利润 ÷ 平均净资产' },
{ key: 'eva', label: d.eva?.kpi_name || '经济增加值(EVA)', raw: d.eva?.value,
unit: d.eva?.unit || '万元', text: fmtVal(d.eva?.value), desc: 'NOPAT − 投入资本 × WACC' },
{ key: 'ri', label: d.ri?.kpi_name || '剩余收益(RI)', raw: d.ri?.value,
unit: d.ri?.unit || '万元', text: fmtVal(d.ri?.value), desc: '营业利润 − 投入资本 × 最低报酬率' },
].filter(m => m.raw !== null && m.raw !== undefined)
})
function formatPct(val: number): string {
if (val === null || val === undefined) return '-'
@@ -153,7 +218,22 @@ async function fetchDupont() {
}
}
onMounted(fetchDupont)
async function fetchValueMgmt() {
try {
const r: any = await api.get('/reports/value-management')
vm.value = r.data || r
} catch (e) {
console.error('价值管理加载失败:', e)
vm.value = {}
} finally {
vmLoaded.value = true
}
}
onMounted(() => {
fetchDupont()
fetchValueMgmt()
})
</script>
<style scoped>
@@ -305,4 +385,41 @@ onMounted(fetchDupont)
.insight-label { font-size: 14px; font-weight: 600; color: #d48806; margin-bottom: 8px; }
.insight-text { font-size: 14px; color: #5a4e2b; line-height: 1.7; }
.insight-improvement { font-size: 14px; color: #2c3e50; font-weight: 600; margin-top: 8px; padding-top: 8px; border-top: 1px solid #ffe58f; }
/* 价值管理区(真 ROI / ROA / EVA / RI) */
.value-mgmt {
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
}
.value-mgmt.empty { padding: 16px 20px; }
.vm-title { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 600; color: #2c3e50; }
.vm-icon { color: #667eea; }
.vm-sub { font-size: 12px; font-weight: 400; color: #95a5a6; margin-left: 4px; }
.vm-empty { font-size: 13px; color: #95a5a6; margin-top: 8px; }
.vm-cards { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
.vm-card {
flex: 1 1 150px;
min-width: 150px;
background: #f8f9ff;
border: 1px solid #e4e8f7;
border-radius: 10px;
padding: 14px 16px;
}
.vm-value { font-size: 24px; font-weight: 700; color: #2c3e50; }
.vm-value.neg { color: #e74c3c; }
.vm-unit { font-size: 12px; font-weight: 400; color: #95a5a6; margin-left: 4px; }
.vm-label { font-size: 13px; color: #5a6b7f; margin-top: 4px; }
.vm-desc { font-size: 11px; color: #b0b0b0; margin-top: 6px; }
.vm-params {
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed #e8e8e8;
font-size: 12px;
color: #7f8c8d;
line-height: 1.9;
}
.vm-params-title { font-weight: 600; color: #5a6b7f; margin-right: 6px; }
</style>