feat(dupont): period-basis design (cumulative flow x average stock, optional annualize, identity guard)
ci/woodpecker/push/main Pipeline failed

Task: finance-20261003-dupont-period-basis-design-fullstack (P1; proposer wecom-finance)
Basis ruling: finance 2026-10-03 (Boss 09:49 CST DM approval of the design task)

- /reports/dupont accepts period/start/end/annualize (default YTD), returns
  period_start/period_end/basis/annualized metadata; identity guard
  |ROE - npm x turnover x multiplier| <= 0.05 pp, else identity_broken (no values).
- new app/utils/dupont_basis.py = single source of truth (window resolve, flow
  cumulative via voucher_details, stock average via subject_balances level=1,
  fail-closed basis_fallback, pure calc + identity check).
- scripts/kpi_dupont_deriver.py (idempotent derived writer, dry-run/check/write),
  scripts/apply_dupont_kpi_defs.py + sql/dupont_kpi_defs_20261003.sql (seed:
  F_ASSET_TOTAL/F_EQUITY_TOTAL/F_EQUITY_MULTIPLIER as derived; F_ASSET_TURNOVER
  manual -> derived). Seed already applied under approval 296cc1fd.
- scripts/verify_dupont_period_basis.py: read-only acceptance battery A1-A9
  (independent SQL recompute). BEFORE (base tree) 1/10 PASS -> AFTER 10/13 PASS;
  remaining 3 need the deriver value write (approval-gated).
- frontend DupontAnalysis.vue: period selector (YTD default) + annualize switch
  + caliber metadata bar + identity_broken alert.

No DDL. No ERP direct connection (old register_dupont_kpis.py caliber dropped).
This commit is contained in:
Hermes CI Fix
2026-10-04 13:35:25 +08:00
parent 1f1385ebbf
commit 38a6de9325
7 changed files with 1435 additions and 166 deletions
+82 -5
View File
@@ -4,12 +4,42 @@
<h3 class="page-title">杜邦分析</h3>
</div>
<!-- 顶部控制:企业只读标识(不可点)——账套由登录时选定,页内无切换入口 -->
<div class="flex-row" style="gap:12px;margin-bottom:16px;align-items:center;">
<div class="flex-row" style="gap:12px;margin-bottom:8px;align-items:center;flex-wrap:wrap;">
<span style="font-weight:600;font-size:14px;">分析对象:</span>
<el-tag size="small" type="primary" effect="plain">{{ entityLabel }}</el-tag>
<span style="font-weight:600;font-size:14px;margin-left:8px;">区间:</span>
<el-select v-model="periodPreset" size="small" style="width:170px" @change="fetchDupont">
<el-option v-for="o in periodOptions" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
<template v-if="periodPreset === 'custom'">
<el-date-picker v-model="customStart" type="month" size="small" style="width:130px"
value-format="YYYY-MM" placeholder="起始月" />
<span style="color:#909399;">至</span>
<el-date-picker v-model="customEnd" type="month" size="small" style="width:130px"
value-format="YYYY-MM" placeholder="截止月" />
</template>
<el-checkbox v-model="annualize" size="small">年化(x12/区间月数)</el-checkbox>
<el-button type="primary" size="small" @click="fetchDupont" :loading="loading">刷新</el-button>
</div>
<!-- 口径元数据(随值返回:period_start / period_end / basis / annualized) -->
<div class="caliber-bar" v-if="data.period_start">
<span class="caliber-item">区间 <b>{{ data.period_start }} ~ {{ data.period_end }}</b>({{ data.months_label }})</span>
<span class="caliber-item">流量 <b>累计</b></span>
<span class="caliber-item">存量 <b>{{ data.basis === 'average' ? '平均 (期初+期末)/2' : '期末(期初缺失回落)' }}</b></span>
<span class="caliber-item">年化 <b>{{ data.annualized ? 'true(x' + data.annualize_scale + ')' : 'false' }}</b></span>
<span class="caliber-item" :class="data.identity && data.identity.status === 'pass' ? 'caliber-ok' : 'caliber-bad'">
恒等式 {{ data.identity ? data.identity.status : '-' }}
<template v-if="data.identity && data.identity.diff !== undefined">(偏差 {{ data.identity.diff }} / 容差 {{ data.identity.tolerance }})</template>
</span>
<span class="caliber-item caliber-warn" v-if="data.basis_fallback">basis_fallback=true</span>
</div>
<el-alert v-if="data.identity_broken" type="error" :closable="false" show-icon
title="恒等式不成立(identity_broken):按口径守护不返回数值,请按 factors 的 period_start/basis 定位漂移源"
:description="data.message" style="margin-bottom:16px;" />
<el-alert v-else-if="errMsg" type="warning" :closable="false" show-icon :title="errMsg" style="margin-bottom:16px;" />
<!-- 价值管理区:真 ROI / ROA / EVA / RI(口径:财务裁定 2026-10-02,任总 11:59「立;但①要补真ROI」) -->
<div class="value-mgmt" v-if="vm && vm.period">
<div class="vm-title">
@@ -154,6 +184,26 @@ import api from '../api/index'
const loading = ref(false)
const entityLabel = ref(sessionEntityName())
const data = ref<any>({})
const errMsg = ref('')
// 区间维度(默认 YTD,与后端缺省一致);值即后端 period 参数原样
const periodPreset = ref('ytd')
const customStart = ref('')
const customEnd = ref('')
const annualize = ref(false)
const periodOptions = computed(() => {
const opts: any[] = [
{ value: 'ytd', label: 'YTD(年初至今,默认)' },
{ value: 'ttm', label: 'TTM(滚动 12 月)' },
]
for (const y of [2026, 2025, 2024]) {
opts.push({ value: String(y), label: `${y} 年 全年` })
for (const h of [1, 2]) opts.push({ value: `${y}-H${h}`, label: `${y} 年 半年 H${h}` })
for (const q of [1, 2, 3, 4]) opts.push({ value: `${y}-Q${q}`, label: `${y} 年 季度 Q${q}` })
for (let m = 1; m <= 12; m++) opts.push({ value: `${y}-${String(m).padStart(2, '0')}`, label: `${y} 年 ${m} 月` })
}
opts.push({ value: 'custom', label: '自定义起止' })
return opts
})
// 价值管理区(真 ROI / ROA / EVA / RI)——只读 /reports/value-management(派生值单源)
const vm = ref<any>({})
const vmLoaded = ref(false)
@@ -192,8 +242,8 @@ function formatPct(val: number): string {
function formatFactor(val: number, key: string): string {
if (val === null || val === undefined) return '-'
if (key === 'financial_leverage') return val.toFixed(2) + 'x'
if (key === 'asset_turnover') return val.toFixed(2) + 'x'
if (key === 'financial_leverage' || key === 'equity_multiplier') return val.toFixed(2) + 'x'
if (key === 'asset_turnover') return val.toFixed(4) + 'x'
return val.toFixed(2) + '%'
}
@@ -206,13 +256,29 @@ function formatMoney(val: number): string {
async function fetchDupont() {
loading.value = true
errMsg.value = ''
try {
// 分析对象由会话账套决定(后端按 token 解析),不再传 entity 参数
const r: any = await api.get('/reports/dupont')
// 区间取值:period=ytd/ttm/YYYY-MM/YYYY-Qn/YYYY-Hn/YYYY/custom + annualize
const params: any = {
period: periodPreset.value,
annualize: annualize.value,
}
if (periodPreset.value === 'custom') {
if (!customStart.value || !customEnd.value) {
errMsg.value = '自定义区间需同时选择起始月与截止月'
data.value = {}
return
}
params.start = customStart.value
params.end = customEnd.value
}
const r: any = await api.get('/reports/dupont', { params })
data.value = r.data || r
} catch (e) {
} catch (e: any) {
console.error('杜邦分析加载失败:', e)
data.value = {}
errMsg.value = '区间参数不被接受或数据不可用:' + (e?.response?.data?.detail || e?.message || '请求失败')
} finally {
loading.value = false
}
@@ -422,4 +488,15 @@ onMounted(() => {
line-height: 1.9;
}
.vm-params-title { font-weight: 600; color: #5a6b7f; margin-right: 6px; }
/* 口径元数据条(区间取值设计 2026-10-03) */
.caliber-bar {
display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center;
background: #f5f7fb; border: 1px solid #e4e8f7; border-radius: 10px;
padding: 10px 14px; margin-bottom: 16px; font-size: 12px; color: #5a6b7f;
}
.caliber-item b { color: #2c3e50; }
.caliber-ok { color: #52c41a; }
.caliber-bad { color: #e74c3c; font-weight: 600; }
.caliber-warn { color: #d48806; font-weight: 600; }
</style>