feat(new30-p1): 追溯调整数据准备+KPI口径更新

- 后端新增 GET /api/cma/reports/restatement 端点
  - 旧口径vs新口径双列对比
  - 自动标记需调整项(管理费用剥离研发费用、财务费用拆解)
  - 返回净利润对比汇总
- 前端报表中心新增"追溯调整"Tab
  - 双列表格展示(旧准则/新准则)
  - 差异自动高亮标记
  - 调整原因列、汇总统计
- KPI口径更新(entity_id=1)
  - F_COST_RATIO 费用率: (销售费用+管理费用-研发费用)/营业收入×100%, target=15%
  - 新增 F_OP_PROFIT_MARGIN 经营利润率: 经营利润/营业收入×100%, target=5%
- 构建部署验证通过
This commit is contained in:
Hermes CI Fix
2026-07-22 00:01:20 +08:00
parent dd105efee3
commit ec6af751a5
2 changed files with 460 additions and 1 deletions
+147 -1
View File
@@ -235,6 +235,83 @@
<el-empty v-else description="暂无已发布的战略地图,请先在战略地图中发布" />
</div>
</el-tab-pane>
<!-- 追溯调整2026数据按新30号准则重述 -->
<el-tab-pane label="🔄 追溯调整" name="restatement">
<div v-loading="loadingRestatement">
<div class="report-desc">2026年数据按新30号准则重述 旧口径vs新口径双列对比差异自动高亮</div>
<!-- 净利润对比 -->
<div v-if="restatementNetProfit" class="restate-net-box">
<div class="restate-net-row">
<span class="restate-net-label">旧口径净利润</span>
<span class="restate-net-val">{{ formatMoney(restatementNetProfit.old_net_profit) }}</span>
<span class="restate-net-arrow"></span>
<span class="restate-net-label">新口径净利润</span>
<span class="restate-net-val" :class="{ negative: restatementNetProfit.new_net_profit < 0 }">{{ formatMoney(restatementNetProfit.new_net_profit) }}</span>
<span class="restate-net-diff" :class="{ up: restatementNetProfit.difference > 0, down: restatementNetProfit.difference < 0 }">
差异{{ restatementNetProfit.difference > 0 ? '+' : '' }}{{ formatMoney(restatementNetProfit.difference) }}
</span>
</div>
</div>
<!-- 汇总统计 -->
<div v-if="restatementSummary" class="restate-summary-row">
<div class="restate-stat green"><span class="restate-stat-val">{{ restatementSummary.total_items }}</span> 总项目</div>
<div class="restate-stat red"><span class="restate-stat-val">{{ restatementSummary.adjusted_items }}</span> 需调整</div>
<div class="restate-stat blue"><span class="restate-stat-val">{{ restatementSummary.new_items }}</span> 新增</div>
<div class="restate-stat gray"><span class="restate-stat-val">{{ restatementSummary.unchanged_items }}</span> 不变</div>
</div>
<!-- 对比表格 -->
<el-table :data="restatementItems" border stripe size="small" style="width:100%;margin-top:12px;" :row-class-name="restatementRowClass">
<el-table-column prop="item_name" label="项目" width="240">
<template #default="{ row }">
<div class="restate-item-name">
<el-tag v-if="row.needs_adjustment" type="danger" size="small" effect="plain" class="restate-tag">调整</el-tag>
<el-tag v-else size="small" effect="plain" type="info" class="restate-tag">不变</el-tag>
<span :class="{ 'restate-breakdown-item': row.category === '_breakdown' }">{{ row.item_name }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="旧准则" width="160" align="right">
<template #default="{ row }">
<span :class="{ 'restate-highlight-red': row.needs_adjustment && row.old_value != null }">
{{ row.old_value != null ? formatMoney(row.old_value) : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="新准则" width="160" align="right">
<template #default="{ row }">
<span :class="{ 'restate-highlight-green': row.needs_adjustment && row.new_value != null }">
{{ row.new_value != null ? formatMoney(row.new_value) : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="差异" width="140" align="right">
<template #default="{ row }">
<span v-if="row.difference != null" :class="restateDiffClass(row.difference)">
{{ row.difference > 0 ? '+' : '' }}{{ formatMoney(row.difference) }}
</span>
<span v-else-if="row.needs_adjustment" class="restate-new-badge"></span>
<span v-else style="color:#ccc;">-</span>
</template>
</el-table-column>
<el-table-column label="调整原因" width="260">
<template #default="{ row }">
<span v-if="row.adjustment_reason" class="restate-reason">{{ row.adjustment_reason }}</span>
<span v-else style="color:#ccc;">-</span>
</template>
</el-table-column>
</el-table>
<div class="restate-legend">
<span class="restate-legend-item"><span class="restate-dot restate-dot-red"></span> 旧口径</span>
<span class="restate-legend-item"><span class="restate-dot restate-dot-green"></span> 新口径</span>
<span class="restate-legend-item"><span class="restate-dot restate-dot-orange"></span> 需调整项</span>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
@@ -347,6 +424,41 @@ function setTrendChartRef(id: number, el: any) {
// ── 报表4:BSC评分卡 ──
const bscData = ref<any>({})
// ── 追溯调整:2026年数据按新30号准则重述 ──
const loadingRestatement = ref(false)
const restatementItems = ref<any[]>([])
const restatementSummary = ref<any>(null)
const restatementNetProfit = ref<any>(null)
async function loadRestatement() {
loadingRestatement.value = true
try {
const r = await api.get('/reports/restatement', { params: { period: reportPeriod.value } })
const d = (r as any).data || {}
restatementItems.value = d.items || []
restatementSummary.value = d.summary || null
restatementNetProfit.value = d.net_profit_comparison || null
} catch (e) {
restatementItems.value = []
restatementSummary.value = null
restatementNetProfit.value = null
ElMessage.error('加载追溯调整数据失败')
} finally {
loadingRestatement.value = false
}
}
function restatementRowClass({ row }: any) {
if (row.needs_adjustment) return 'restate-row-adjusted'
return ''
}
function restatementDiffClass(diff: number) {
if (diff > 0) return 'restate-diff-up'
if (diff < 0) return 'restate-diff-down'
return 'restate-diff-zero'
}
// ── 公共 ──
const dimensions = [
{ value: 'finance', label: '财务维度' },
@@ -417,7 +529,7 @@ async function loadKpiOptions() {
function loadAll() {
loading.value = true
Promise.all([loadProfit(), loadNew30(), loadBudget(), loadTrends(), loadBsc()]).finally(() => { loading.value = false })
Promise.all([loadProfit(), loadNew30(), loadBudget(), loadTrends(), loadBsc(), loadRestatement()]).finally(() => { loading.value = false })
}
function openDupont() {
@@ -564,4 +676,38 @@ onMounted(() => {
.new30-profit-name { font-size: 13px; opacity: 0.8; }
.new30-footer { margin-top: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.new30-demo-hint { font-size: 11px; color: #d46b08; margin-left: 8px; }
/* 追溯调整样式 */
.restate-net-box { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px; padding: 14px 18px; color: #fff; margin-top: 12px; }
.restate-net-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.restate-net-label { font-size: 13px; opacity: 0.9; }
.restate-net-val { font-size: 18px; font-weight: 700; }
.restate-net-val.negative { color: #ffccc7; }
.restate-net-arrow { font-size: 18px; opacity: 0.7; }
.restate-net-diff { font-size: 13px; padding: 2px 10px; border-radius: 4px; }
.restate-net-diff.up { background: rgba(255,255,255,0.2); color: #b7eb8f; }
.restate-net-diff.down { background: rgba(255,255,255,0.2); color: #ffccc7; }
.restate-summary-row { display: flex; gap: 12px; margin-top: 12px; }
.restate-stat { padding: 8px 14px; border-radius: 6px; font-size: 13px; color: #555; border-left: 3px solid #ccc; background: #fafafa; }
.restate-stat.green { border-left-color: #67c23a; }
.restate-stat.red { border-left-color: #f56c6c; }
.restate-stat.blue { border-left-color: #409eff; }
.restate-stat.gray { border-left-color: #909399; }
.restate-stat-val { font-weight: 700; font-size: 16px; margin-right: 4px; }
.restate-item-name { display: flex; align-items: center; gap: 6px; }
.restate-tag { flex-shrink: 0; }
.restate-breakdown-item { padding-left: 16px; color: #666; font-size: 12px; }
.restate-highlight-red { background: #fff1f0; padding: 1px 4px; border-radius: 2px; font-weight: 600; color: #cf1322; }
.restate-highlight-green { background: #f6ffed; padding: 1px 4px; border-radius: 2px; font-weight: 600; color: #389e0d; }
.restate-diff-up { color: #f56c6c; font-weight: 600; }
.restate-diff-down { color: #67c23a; font-weight: 600; }
.restate-diff-zero { color: #999; }
.restate-new-badge { background: #409eff; color: #fff; padding: 1px 6px; border-radius: 3px; font-size: 11px; }
.restate-reason { font-size: 12px; color: #d46b08; }
.restate-legend { display: flex; gap: 16px; margin-top: 12px; padding: 8px 12px; background: #fafafa; border-radius: 6px; font-size: 12px; color: #666; }
.restate-legend-item { display: flex; align-items: center; gap: 4px; }
.restate-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.restate-dot-red { background: #cf1322; }
.restate-dot-green { background: #389e0d; }
.restate-dot-orange { background: #d46b08; }
</style>