801 lines
40 KiB
Vue
801 lines
40 KiB
Vue
<template>
|
||
<div class="report-page">
|
||
<!-- 顶部 -->
|
||
<div class="report-header">
|
||
<h3 class="page-title">📊 CMA管理报表</h3>
|
||
<div class="header-right">
|
||
<el-date-picker
|
||
v-model="reportPeriod"
|
||
type="month"
|
||
placeholder="选择月份"
|
||
value-format="YYYY-MM"
|
||
size="small"
|
||
style="width:140px"
|
||
@change="onPeriodChange"
|
||
/>
|
||
<el-button text size="small" :loading="loading" @click="loadAll">刷新</el-button>
|
||
<el-button type="primary" size="small" @click="openDupont">📊 杜邦分析</el-button>
|
||
<el-button type="success" size="small" @click="openSubjectManage">📋 科目打标</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 标签页 -->
|
||
<el-tabs v-model="activeTab" type="border-card" class="report-tabs">
|
||
|
||
<!-- ════ 报表1:管理利润表 ════ -->
|
||
<el-tab-pane label="💰 管理利润表" name="profit">
|
||
<div v-loading="loading">
|
||
<div class="report-desc">基于管理会计视角的利润结构分析:收入 → 变动成本 → 边际贡献 → 固定成本 → 息税前利润</div>
|
||
<el-table :data="profitItems" border stripe size="small" style="width:100%;margin-top:12px;" :summary-method="profitSummary" show-summary>
|
||
<el-table-column prop="name" label="项目" width="200">
|
||
<template #default="{ row }">
|
||
<span :style="{ fontWeight: row.is_total ? 700 : row.is_subtotal ? 600 : 400 }">{{ row.name }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="本期金额" width="160" align="right">
|
||
<template #default="{ row }">
|
||
<span :style="{ fontWeight: row.is_total ? 700 : 400, color: (row.name?.includes('利润') || row.name?.includes('贡献')) && row.value != null && row.value < 0 ? '#f56c6c' : '#333' }">
|
||
{{ row.value != null ? '¥ ' + row.value.toLocaleString() : '-' }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="上期金额" width="160" align="right">
|
||
<template #default="{ row }">
|
||
<span style="color:#999;">{{ row.prev_value != null ? '¥ ' + row.prev_value.toLocaleString() : '-' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="环比变化" width="120" align="right">
|
||
<template #default="{ row }">
|
||
<span v-if="row.change_rate != null" :style="{ color: row.change_rate > 0 ? '#f56c6c' : row.change_rate < 0 ? '#67c23a' : '#999', fontWeight: 600 }">
|
||
{{ row.change_rate > 0 ? '↑' : '↓' }}{{ Math.abs(row.change_rate).toFixed(1) }}%
|
||
</span>
|
||
<span v-else style="color:#ccc;">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="占比" width="100" align="right">
|
||
<template #default="{ row }">
|
||
<span v-if="row.ratio != null" style="color:#666;">{{ row.ratio.toFixed(1) }}%</span>
|
||
<span v-else style="color:#ccc;">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ════ 新30号准则利润表 ════ -->
|
||
<el-tab-pane label="📋 新30号准则" name="new30">
|
||
<div v-loading="loadingNew30">
|
||
<div class="report-desc">基于新30号准则的五板块利润表列报(2027版)。经营/投资/筹资分类列示,财务费用拆解。</div>
|
||
|
||
<div class="new30-blocks">
|
||
<div v-for="block in new30Blocks" :key="block.key" class="new30-block">
|
||
<div class="new30-block-header" @click="block.expanded = !block.expanded">
|
||
<span class="new30-toggle">{{ block.expanded ? '▼' : '▶' }}</span>
|
||
<span class="new30-block-name">{{ block.name }}</span>
|
||
<span class="new30-block-subtotal" :class="{ negative: block.subtotal < 0 }">
|
||
{{ block.subtotal != null ? formatMoney(block.subtotal) : '-' }}
|
||
</span>
|
||
<span class="new30-block-label">{{ block.subtotal_name }}</span>
|
||
<span v-if="!block.has_real_data" class="new30-demo-tag">示例数据</span>
|
||
</div>
|
||
<div v-if="block.expanded" class="new30-block-body">
|
||
<div v-for="item in block.items" :key="item.code" class="new30-item">
|
||
<span class="new30-item-name">{{ item.name }}</span>
|
||
<span class="new30-item-amount" :class="{ negative: item.effective != null && item.effective < 0 }">
|
||
{{ item.effective != null ? formatMoney(item.effective) : '-' }}
|
||
</span>
|
||
<span v-if="item.effective != null && block.subtotal != null && block.subtotal !== 0" class="new30-item-ratio">
|
||
{{ (Math.abs(item.effective) / Math.abs(block.subtotal) * 100).toFixed(1) }}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="new30-net-profit" v-if="new30Format !== 'dual'">
|
||
<div class="new30-profit-row">
|
||
<span class="new30-profit-label">合计:</span>
|
||
<span class="new30-profit-value" :class="{ negative: new30NetProfit < 0 }">
|
||
{{ formatMoney(new30NetProfit) }}
|
||
</span>
|
||
<span class="new30-profit-name">净利润</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 双列对比模式 -->
|
||
<div v-if="new30Format === 'dual' && dualComparisonData" class="dual-comparison">
|
||
<div class="dual-desc">新旧准则双列对比 — 旧准则(一行到底) vs 新准则(五板块)</div>
|
||
|
||
<div class="dual-grid">
|
||
<!-- 旧准则列 -->
|
||
<div class="dual-col">
|
||
<div class="dual-col-header old-col">旧准则格式</div>
|
||
<el-table :data="dualComparisonData.old_format?.items || []" border stripe size="small" style="width:100%;">
|
||
<el-table-column prop="name" label="项目" width="180">
|
||
<template #default="{ row }">
|
||
<span :style="{ fontWeight: row.is_total ? 700 : row.is_subtotal ? 600 : 400 }">{{ row.name }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="金额" width="140" align="right">
|
||
<template #default="{ row }">
|
||
<span :style="{ fontWeight: row.is_total ? 700 : 400 }">
|
||
{{ row.value != null ? '¥ ' + row.value.toLocaleString() : '-' }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<!-- 新准则列 -->
|
||
<div class="dual-col">
|
||
<div class="dual-col-header new-col">新30号准则格式</div>
|
||
<div class="dual-blocks">
|
||
<div v-for="block in dualComparisonData.new_format?.blocks || []" :key="block.key" class="dual-block">
|
||
<div class="dual-block-header">
|
||
<span class="dual-block-name">{{ block.short_name }}</span>
|
||
<span class="dual-block-subtotal">{{ formatMoney(block.subtotal) }}</span>
|
||
</div>
|
||
<div v-for="item in block.items" :key="item.code" class="dual-block-item">
|
||
<span class="dual-item-name">{{ item.name }}</span>
|
||
<span class="dual-item-amount">{{ item.effective != null ? formatMoney(item.effective) : '-' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="dual-net-profit">
|
||
<span>净利润:</span>
|
||
<span class="dual-net-val">{{ formatMoney(dualComparisonData.new_format?.net_profit) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="new30-footer">
|
||
<el-button size="small" :type="new30Format === 'new' ? 'primary' : ''" @click="switchNew30Format('new')">新准则</el-button>
|
||
<el-button size="small" :type="new30Format === 'old' ? 'primary' : ''" @click="switchNew30Format('old')">旧准则</el-button>
|
||
<el-button size="small" :type="new30Format === 'dual' ? 'primary' : ''" @click="switchNew30Format('dual')">双列对比</el-button>
|
||
<el-button size="small" @click="exportNew30">导出</el-button>
|
||
<span v-if="new30DemoMode" class="new30-demo-hint">⚠️ 当前显示示例数据,科目打标后显示实际数据</span>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ════ 报表2:预算执行报告 ════ -->
|
||
<el-tab-pane label="📋 预算执行报告" name="budget">
|
||
<div v-loading="loading">
|
||
<div class="report-desc">各KPI预算 vs 实际差异分析。正值=超预算,负值=节约。</div>
|
||
<div class="summary-row" style="margin-top:12px;">
|
||
<div class="stat-card blue"><div class="stat-val">{{ budgetSummary.total }}</div><div class="stat-label">KPI总数</div></div>
|
||
<div class="stat-card green"><div class="stat-val">{{ budgetSummary.with_budget }}</div><div class="stat-label">有预算</div></div>
|
||
<div class="stat-card red"><div class="stat-val">{{ budgetSummary.over_budget }}</div><div class="stat-label">超预算</div></div>
|
||
<div class="stat-card gray"><div class="stat-val">{{ budgetSummary.under_budget }}</div><div class="stat-label">节约</div></div>
|
||
<div class="stat-card yellow"><div class="stat-val">{{ budgetSummary.normal }}</div><div class="stat-label">正常</div></div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin:12px 0;">
|
||
<el-select v-model="budgetDim" placeholder="维度" clearable size="small" style="width:120px;" @change="loadBudget">
|
||
<el-option v-for="d in dimensions" :key="d.value" :label="d.label" :value="d.value" />
|
||
</el-select>
|
||
<el-select v-model="budgetLevel" placeholder="状态" clearable size="small" style="width:120px;" @change="loadBudget">
|
||
<el-option label="超预算" value="red" />
|
||
<el-option label="预警" value="yellow" />
|
||
<el-option label="正常" value="normal" />
|
||
</el-select>
|
||
</div>
|
||
<el-table :data="budgetItems" border stripe size="small" style="width:100%;" @sort-change="onBudgetSort">
|
||
<el-table-column prop="kpi_name" label="KPI名称" min-width="150" sortable="custom" />
|
||
<el-table-column prop="dimension" label="维度" width="90">
|
||
<template #default="{ row }">{{ dimLabel(row.dimension) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="实际值" width="120" align="right" prop="actual_value" sortable="custom">
|
||
<template #default="{ row }">{{ row.actual_value != null ? row.actual_value.toLocaleString() : '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="预算值" width="120" align="right" prop="budget_value" sortable="custom">
|
||
<template #default="{ row }">{{ row.budget_value != null ? row.budget_value.toLocaleString() : '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="差异额" width="120" align="right" prop="deviation_amount" sortable="custom">
|
||
<template #default="{ row }">
|
||
<span :style="{ color: row.deviation_amount > 0 ? '#f56c6c' : row.deviation_amount < 0 ? '#67c23a' : '#999' }">
|
||
{{ row.deviation_amount != null ? (row.deviation_amount > 0 ? '+' : '') + row.deviation_amount.toLocaleString() : '-' }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="差异率" width="100" align="right" prop="deviation_rate" sortable="custom">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.deviation_rate != null" :type="row.alert_level === 'red' ? 'danger' : row.alert_level === 'yellow' ? 'warning' : 'success'" size="small">
|
||
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate.toFixed(1) }}%
|
||
</el-tag>
|
||
<span v-else style="color:#ccc;">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ════ 报表3:KPI趋势报告 ════ -->
|
||
<el-tab-pane label="📈 KPI趋势报告" name="trends">
|
||
<div v-loading="loading">
|
||
<div class="report-desc">选定KPI的历史趋势分析,含目标线标记。</div>
|
||
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;">
|
||
<el-select v-model="trendDim" placeholder="维度" clearable size="small" style="width:120px;" @change="loadTrends">
|
||
<el-option v-for="d in dimensions" :key="d.value" :label="d.label" :value="d.value" />
|
||
</el-select>
|
||
<el-select v-model="trendKpiId" placeholder="选择KPI" clearable filterable size="small" style="width:200px;" @change="loadTrends">
|
||
<el-option v-for="k in kpiOptions" :key="k.id" :label="k.kpi_name" :value="k.id" />
|
||
</el-select>
|
||
<el-select v-model="trendMonths" size="small" style="width:100px;" @change="loadTrends">
|
||
<el-option label="12期" :value="12" />
|
||
<el-option label="24期" :value="24" />
|
||
<el-option label="30期" :value="30" />
|
||
</el-select>
|
||
</div>
|
||
<div v-if="trendData.length > 0" class="trend-grid">
|
||
<el-card v-for="t in trendData" :key="t.kpi_id" class="trend-card-2">
|
||
<template #header>
|
||
<div class="trend-hd">
|
||
<span class="trend-name">{{ t.kpi_name }}</span>
|
||
<span class="trend-unit">{{ t.unit }}</span>
|
||
<span class="trend-stat">均值 {{ t.avg }} | 最高 {{ t.max }} | 最低 {{ t.min }}</span>
|
||
<span class="trend-dir" :class="t.trend_dir">{{ t.trend_dir === 'up' ? '↑ 上升' : t.trend_dir === 'down' ? '↓ 下降' : '→ 平稳' }}</span>
|
||
</div>
|
||
</template>
|
||
<div :ref="el => setTrendChartRef(t.kpi_id, el)" style="height:180px;"></div>
|
||
</el-card>
|
||
</div>
|
||
<el-empty v-else description="请选择KPI查看趋势" />
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ════ 报表4:BSC评分卡 ════ -->
|
||
<el-tab-pane label="🎯 四维度绩效评分卡" name="bsc">
|
||
<div v-loading="loading">
|
||
<div class="report-desc">基于BSC框架的四维度绩效评分,从已发布战略地图构建。</div>
|
||
<div v-if="bscData.overall_score != null" style="margin-top:12px;">
|
||
<div class="bsc-overall">
|
||
<div class="bsc-score-ring" :style="{ borderColor: bscScoreColor(bscData.overall_score) }">
|
||
<span class="bsc-score-val">{{ bscData.overall_score }}</span>
|
||
<span class="bsc-score-label">综合评分</span>
|
||
</div>
|
||
<div class="bsc-map-info" v-if="bscData.map_title">基于:{{ bscData.map_title }} | 周期:{{ bscData.period }}</div>
|
||
</div>
|
||
<div class="bsc-dims">
|
||
<div v-for="dim in bscData.dimensions" :key="dim.key" class="bsc-dim-card">
|
||
<div class="bsc-dim-head" :style="{ borderLeft: '4px solid ' + dim.color }">
|
||
<span class="bsc-dim-icon">{{ dim.icon }}</span>
|
||
<span class="bsc-dim-name">{{ dim.name }}</span>
|
||
<span class="bsc-dim-score" :style="{ color: bscScoreColor(dim.score) }">{{ dim.score }}</span>
|
||
</div>
|
||
<div class="bsc-dim-body">
|
||
<div v-for="obj in dim.objectives" :key="obj.name" class="bsc-obj-row">
|
||
<div class="bsc-obj-name">{{ obj.name }}</div>
|
||
<div class="bsc-obj-kpis" v-if="obj.kpis && obj.kpis.length > 0">
|
||
<div v-for="k in obj.kpis.slice(0, 3)" :key="k.code" class="bsc-kpi-mini">
|
||
<span class="bsc-kpi-dot" :class="'dot-' + k.level"></span>
|
||
<span class="bsc-kpi-name">{{ k.name }}</span>
|
||
<span class="bsc-kpi-score">{{ k.score ?? '-' }}</span>
|
||
</div>
|
||
<div v-if="obj.kpis.length > 3" class="bsc-kpi-more">+{{ obj.kpis.length - 3 }} 更多</div>
|
||
</div>
|
||
<div v-else class="bsc-obj-empty">暂无KPI数据</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<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>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import axios from 'axios'
|
||
|
||
// ── 状态 ──
|
||
const loading = ref(false)
|
||
const activeTab = ref('profit')
|
||
const reportPeriod = ref('2026-06')
|
||
|
||
function getEntityId() {
|
||
return Number(localStorage.getItem('cma_entity_id') || 1)
|
||
}
|
||
|
||
// API 配置
|
||
const api = axios.create({ baseURL: '/api/cma', timeout: 30000 })
|
||
api.interceptors.request.use((config: any) => {
|
||
const token = localStorage.getItem('cma_token')
|
||
if (token) config.headers.Authorization = `'Bearer ' + token`
|
||
return config
|
||
})
|
||
|
||
// ── 报表1:管理利润表 ──
|
||
const profitItems = ref<any[]>([])
|
||
|
||
function profitSummary(param: any) {
|
||
const sums: string[] = ['合计']
|
||
const last = profitItems.value[profitItems.value.length - 1]
|
||
if (last) {
|
||
sums.push(last.value != null ? '¥ ' + last.value.toLocaleString() : '-')
|
||
sums.push(last.prev_value != null ? '¥ ' + last.prev_value.toLocaleString() : '-')
|
||
sums.push(last.change_rate != null ? (last.change_rate > 0 ? '↑' : '↓') + Math.abs(last.change_rate).toFixed(1) + '%' : '-')
|
||
sums.push(last.ratio != null ? last.ratio.toFixed(1) + '%' : '-')
|
||
} else {
|
||
sums.push('-', '-', '-', '-')
|
||
}
|
||
return sums
|
||
}
|
||
|
||
// ── 新30号准则利润表 ──
|
||
const loadingNew30 = ref(false)
|
||
const new30Blocks = ref<any[]>([])
|
||
const new30NetProfit = ref(0)
|
||
const new30Format = ref('new')
|
||
const new30DemoMode = ref(false)
|
||
const dualComparisonData = ref<any>(null)
|
||
|
||
async function loadNew30() {
|
||
loadingNew30.value = true
|
||
dualComparisonData.value = null
|
||
try {
|
||
const params: any = { period: reportPeriod.value, entity_id: getEntityId() }
|
||
if (new30Format.value === 'dual') {
|
||
params.format = 'dual'
|
||
} else {
|
||
params.format = 'new'
|
||
}
|
||
const r = await api.get('/reports/profit-statement', { params })
|
||
const d = (r as any).data || {}
|
||
|
||
if (new30Format.value === 'dual') {
|
||
dualComparisonData.value = d
|
||
new30Blocks.value = d.new_format?.blocks || []
|
||
new30NetProfit.value = d.new_format?.net_profit ?? 0
|
||
new30DemoMode.value = !d.new_format?.all_items_have_data
|
||
} else {
|
||
dualComparisonData.value = null
|
||
new30Blocks.value = d.blocks || []
|
||
new30NetProfit.value = d.net_profit ?? 0
|
||
new30DemoMode.value = !d.all_items_have_data
|
||
}
|
||
} catch (e) {
|
||
new30Blocks.value = []
|
||
new30NetProfit.value = 0
|
||
dualComparisonData.value = null
|
||
ElMessage.error('加载新30号准则利润表失败')
|
||
} finally {
|
||
loadingNew30.value = false
|
||
}
|
||
}
|
||
|
||
function formatMoney(val: number): string {
|
||
if (val == null) return '-'
|
||
const abs = Math.abs(val)
|
||
let formatted = '¥ ' + abs.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
if (val < 0) formatted = '-' + formatted
|
||
return formatted
|
||
}
|
||
|
||
function switchNew30Format(fmt: string) {
|
||
new30Format.value = fmt
|
||
if (fmt === 'new' || fmt === 'dual') {
|
||
loadNew30()
|
||
} else {
|
||
// 切换到旧准则tab
|
||
activeTab.value = 'profit'
|
||
}
|
||
}
|
||
|
||
function exportNew30() {
|
||
ElMessage.success('导出功能将在P2实现')
|
||
}
|
||
|
||
function openSubjectManage() {
|
||
window.open('/subjects', '_blank')
|
||
}
|
||
|
||
// ── 报表2:预算执行报告 ──
|
||
const budgetSummary = ref({ total: 0, with_budget: 0, over_budget: 0, under_budget: 0, normal: 0 })
|
||
const budgetItems = ref<any[]>([])
|
||
const budgetDim = ref('')
|
||
const budgetLevel = ref('')
|
||
|
||
// ── 报表3:KPI趋势报告 ──
|
||
const trendDim = ref('')
|
||
const trendKpiId = ref<number | null>(null)
|
||
const trendMonths = ref(24)
|
||
const trendData = ref<any[]>([])
|
||
const kpiOptions = ref<any[]>([])
|
||
const trendChartRefs: Record<string, HTMLElement> = {}
|
||
|
||
function setTrendChartRef(id: number, el: any) {
|
||
if (el) trendChartRefs['chart-' + id] = el
|
||
}
|
||
|
||
// ── 报表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, entity_id: getEntityId() } })
|
||
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: '财务维度' },
|
||
{ value: 'customer', label: '客户维度' },
|
||
{ value: 'process', label: '内部流程' },
|
||
{ value: 'learning', label: '学习成长' },
|
||
]
|
||
|
||
function dimLabel(d: string): string {
|
||
const map: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
|
||
return map[d] || d
|
||
}
|
||
|
||
function bscScoreColor(score: number): string {
|
||
if (score >= 80) return 'var(--bsc-process)'
|
||
if (score >= 60) return 'var(--bsc-learning)'
|
||
return '#f56c6c'
|
||
}
|
||
|
||
function onPeriodChange() {
|
||
loadAll()
|
||
}
|
||
|
||
// ── 加载 ──
|
||
async function loadProfit() {
|
||
try {
|
||
const r = await api.get('/reports/profit-summary', { params: { period: reportPeriod.value, entity_id: getEntityId() } })
|
||
profitItems.value = (r as any).data?.items || []
|
||
} catch { profitItems.value = [] }
|
||
}
|
||
|
||
async function loadBudget() {
|
||
try {
|
||
const params: any = { period: reportPeriod.value, entity_id: getEntityId() }
|
||
if (budgetDim.value) params.dimension = budgetDim.value
|
||
if (budgetLevel.value) params.alert_level = budgetLevel.value
|
||
const r = await api.get('/reports/budget-execution', { params })
|
||
const d = (r as any).data || {}
|
||
budgetSummary.value = d.summary || {}
|
||
budgetItems.value = d.items || []
|
||
} catch { budgetItems.value = [] }
|
||
}
|
||
|
||
async function loadTrends() {
|
||
try {
|
||
const params: any = { months: trendMonths.value, entity_id: getEntityId() }
|
||
if (trendKpiId.value) params.kpi_id = trendKpiId.value
|
||
else if (trendDim.value) params.dimension = trendDim.value
|
||
const r = await api.get('/reports/kpi-trends', { params })
|
||
trendData.value = (r as any).data?.data || []
|
||
nextTick(() => renderTrendCharts())
|
||
} catch { trendData.value = [] }
|
||
}
|
||
|
||
async function loadBsc() {
|
||
try {
|
||
const r = await api.get('/reports/bsc-scorecard', { params: { period: reportPeriod.value, entity_id: getEntityId() } })
|
||
bscData.value = (r as any).data || {}
|
||
} catch { bscData.value = {} }
|
||
}
|
||
|
||
async function loadKpiOptions() {
|
||
try {
|
||
const r = await api.get('/kpis', { params: { page_size: 100, entity_id: getEntityId() } })
|
||
kpiOptions.value = (r as any).data?.data || []
|
||
} catch {}
|
||
}
|
||
|
||
function loadAll() {
|
||
loading.value = true
|
||
Promise.all([loadProfit(), loadNew30(), loadBudget(), loadTrends(), loadBsc(), loadRestatement()]).finally(() => { loading.value = false })
|
||
}
|
||
|
||
function openDupont() {
|
||
window.open('/dupont-analysis', '_blank')
|
||
}
|
||
|
||
// ── ECharts 渲染 ──
|
||
function renderTrendCharts() {
|
||
import('echarts').then(echarts => {
|
||
trendData.value.forEach((t: any) => {
|
||
const el = trendChartRefs['chart-' + t.kpi_id]
|
||
if (!el) return
|
||
const existing = echarts.getInstanceByDom(el)
|
||
if (existing) existing.dispose()
|
||
const chart = echarts.init(el)
|
||
const dates = t.trend.map((d: any) => d.period)
|
||
const values = t.trend.map((d: any) => d.value)
|
||
const series: any[] = [{
|
||
type: 'line', data: values, smooth: true, lineStyle: { width: 2 },
|
||
areaStyle: { opacity: 0.1 }, symbol: 'circle', symbolSize: 4,
|
||
name: '实际值',
|
||
}]
|
||
if (t.target_value != null) {
|
||
series.push({
|
||
type: 'line', data: Array(dates.length).fill(t.target_value),
|
||
lineStyle: { type: 'dashed', width: 1, color: '#f56c6c' },
|
||
symbol: 'none', name: '目标值',
|
||
})
|
||
}
|
||
chart.setOption({
|
||
grid: { left: 50, right: 15, top: 10, bottom: 28 },
|
||
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: 10, rotate: 45 } },
|
||
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed', color: '#eee' } } },
|
||
series,
|
||
tooltip: { trigger: 'axis' },
|
||
legend: { show: true, bottom: 0, icon: 'circle', itemWidth: 8, itemHeight: 8 },
|
||
})
|
||
})
|
||
})
|
||
}
|
||
|
||
// ── 排序 ──
|
||
function onBudgetSort(sort: any) {
|
||
if (!sort.prop || !sort.order) return
|
||
const items = [...budgetItems.value]
|
||
items.sort((a: any, b: any) => {
|
||
const av = a[sort.prop] ?? 0
|
||
const bv = b[sort.prop] ?? 0
|
||
return sort.order === 'ascending' ? av - bv : bv - av
|
||
})
|
||
budgetItems.value = items
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadKpiOptions()
|
||
loadAll()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.report-page { max-width: 1400px; margin: 0 auto; }
|
||
.report-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||
.header-right { display: flex; gap: 8px; align-items: center; }
|
||
.report-tabs { margin-bottom: 20px; }
|
||
.report-desc { font-size: 13px; color: #888; padding: 8px 0; }
|
||
|
||
/* 统计卡片 */
|
||
.summary-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; }
|
||
.stat-card { background: #fff; border-radius: 8px; padding: 12px 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border-top: 3px solid #ccc; }
|
||
.stat-card.blue { border-top-color: #409eff; }
|
||
.stat-card.red { border-top-color: #f56c6c; }
|
||
.stat-card.green { border-top-color: #67c23a; }
|
||
.stat-card.yellow { border-top-color: var(--bsc-learning); }
|
||
.stat-card.gray { border-top-color: #909399; }
|
||
.stat-val { font-size: 22px; font-weight: 700; color: #1a1a2e; }
|
||
.stat-label { font-size: 12px; color: #888; margin-top: 2px; }
|
||
|
||
/* 趋势卡片 */
|
||
.trend-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; margin-top: 12px; }
|
||
.trend-card-2 { border-radius: 10px; }
|
||
.trend-hd { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.trend-name { font-weight: 600; font-size: 14px; flex: 1; }
|
||
.trend-unit { color: #999; font-size: 12px; }
|
||
.trend-stat { font-size: 11px; color: #999; }
|
||
.trend-dir { font-size: 11px; padding: 2px 8px; border-radius: 4px; }
|
||
.trend-dir.up { background: #fff1f0; color: #cf1322; }
|
||
.trend-dir.down { background: #f6ffed; color: #389e0d; }
|
||
.trend-dir.stable { background: #f5f5f5; color: #999; }
|
||
|
||
/* BSC评分卡 */
|
||
.bsc-overall { text-align: center; padding: 20px 0; }
|
||
.bsc-score-ring {
|
||
display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
|
||
width: 120px; height: 120px; border-radius: 50%; border: 6px solid #67c23a;
|
||
background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||
}
|
||
.bsc-score-val { font-size: 36px; font-weight: 700; color: #1a1a2e; line-height: 1.1; }
|
||
.bsc-score-label { font-size: 12px; color: #999; margin-top: 2px; }
|
||
.bsc-map-info { font-size: 12px; color: #999; margin-top: 8px; }
|
||
.bsc-dims { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; margin-top: 16px; }
|
||
.bsc-dim-card { background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); overflow: hidden; }
|
||
.bsc-dim-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; background: #fafafa; }
|
||
.bsc-dim-icon { font-size: 18px; }
|
||
.bsc-dim-name { font-weight: 600; font-size: 14px; flex: 1; }
|
||
.bsc-dim-score { font-size: 24px; font-weight: 700; }
|
||
.bsc-dim-body { padding: 10px 14px; }
|
||
.bsc-obj-row { margin-bottom: 10px; }
|
||
.bsc-obj-name { font-size: 13px; font-weight: 500; color: #555; margin-bottom: 4px; }
|
||
.bsc-obj-kpis { display: flex; flex-direction: column; gap: 3px; }
|
||
.bsc-kpi-mini { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 6px; background: #f9f9f9; border-radius: 4px; }
|
||
.bsc-kpi-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||
.dot-green { background: #67c23a; }
|
||
.dot-yellow { background: var(--bsc-learning); }
|
||
.dot-red { background: #f56c6c; }
|
||
.dot-gray { background: #ddd; }
|
||
.bsc-kpi-name { flex: 1; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.bsc-kpi-score { font-weight: 600; color: #333; }
|
||
.bsc-kpi-more { font-size: 11px; color: #409eff; text-align: center; padding: 2px; }
|
||
.bsc-obj-empty { font-size: 11px; color: #ccc; text-align: center; padding: 4px; }
|
||
|
||
/* 新30号准则样式 */
|
||
.new30-blocks { margin-top: 12px; }
|
||
.new30-block { background: #fff; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); border: 1px solid #ebeef5; overflow: hidden; }
|
||
.new30-block-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; cursor: pointer; background: #f9fafc; border-bottom: 1px solid #ebeef5; transition: background 0.2s; }
|
||
.new30-block-header:hover { background: #f0f2f5; }
|
||
.new30-toggle { font-size: 10px; color: #999; width: 16px; }
|
||
.new30-block-name { font-weight: 600; font-size: 14px; flex: 1; color: #1a1a2e; }
|
||
.new30-block-subtotal { font-size: 16px; font-weight: 700; color: #1a1a2e; min-width: 120px; text-align: right; }
|
||
.new30-block-subtotal.negative { color: #f56c6c; }
|
||
.new30-block-label { font-size: 11px; color: #999; width: 80px; }
|
||
.new30-demo-tag { font-size: 10px; background: #fff7e6; color: #d46b08; padding: 1px 6px; border-radius: 3px; }
|
||
.new30-block-body { padding: 6px 14px 10px 38px; }
|
||
.new30-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px dashed #f0f0f0; }
|
||
.new30-item:last-child { border-bottom: none; }
|
||
.new30-item-name { flex: 1; font-size: 13px; color: #555; }
|
||
.new30-item-amount { font-size: 13px; font-weight: 500; min-width: 120px; text-align: right; color: #333; }
|
||
.new30-item-amount.negative { color: #f56c6c; }
|
||
.new30-item-ratio { font-size: 11px; color: #bbb; width: 60px; text-align: right; }
|
||
.new30-net-profit { margin-top: 16px; padding: 12px 16px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px; color: #fff; }
|
||
.new30-profit-row { display: flex; align-items: center; gap: 12px; }
|
||
.new30-profit-label { font-size: 14px; opacity: 0.9; }
|
||
.new30-profit-value { font-size: 24px; font-weight: 700; flex: 1; }
|
||
.new30-profit-value.negative { color: #ffccc7; }
|
||
.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; }
|
||
|
||
/* 双列对比样式 */
|
||
.dual-comparison { margin-top: 12px; }
|
||
.dual-desc { font-size: 13px; color: #888; margin-bottom: 12px; }
|
||
.dual-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||
@media screen and (max-width: 1000px) { .dual-grid { grid-template-columns: 1fr; } }
|
||
.dual-col { background: #fff; border-radius: 8px; border: 1px solid #ebeef5; overflow: hidden; }
|
||
.dual-col-header { padding: 10px 14px; font-weight: 600; font-size: 14px; color: #fff; }
|
||
.dual-col-header.old-col { background: #909399; }
|
||
.dual-col-header.new-col { background: #409eff; }
|
||
.dual-blocks { padding: 8px; }
|
||
.dual-block { margin-bottom: 8px; border: 1px solid #ebeef5; border-radius: 4px; overflow: hidden; }
|
||
.dual-block-header { display: flex; justify-content: space-between; padding: 6px 10px; background: #f9fafc; font-size: 13px; font-weight: 600; }
|
||
.dual-block-subtotal { color: #1a1a2e; }
|
||
.dual-block-item { display: flex; justify-content: space-between; padding: 4px 10px 4px 16px; font-size: 12px; color: #555; border-top: 1px dashed #f0f0f0; }
|
||
.dual-item-name { flex: 1; }
|
||
.dual-item-amount { font-weight: 500; }
|
||
.dual-net-profit { padding: 10px 14px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; border-radius: 4px; margin: 8px; display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||
.dual-net-val { font-size: 18px; font-weight: 700; flex: 1; }
|
||
</style>
|