Same page header as the P3-1 item; keeps the header free of emoji. (cma-entity-selectors-converge-6pages-20260914)
1183 lines
61 KiB
Vue
1183 lines
61 KiB
Vue
<template>
|
||
<div class="report-page">
|
||
<!-- 顶部 -->
|
||
<div class="report-header">
|
||
<h3 class="page-title">CMA管理报表</h3>
|
||
<div class="header-right">
|
||
<!-- 企业只读标识(不可点):账套由登录窗口选定,页内无切换入口 -->
|
||
<el-tag size="small" type="primary" effect="plain" class="entity-tag">{{ currentEntityNameTag }}</el-tag>
|
||
<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>
|
||
|
||
<!-- ════ 预编报表(预算版三张报表) ════ -->
|
||
<el-tab-pane label="📋 预编报表" name="proforma">
|
||
<div v-loading="loadingProforma">
|
||
<div class="report-desc">预算版三张报表:预算 vs 实际 vs 差异(口径与预算执行报告一致)。无预算映射的行显式标注,不填示例数据。</div>
|
||
<div style="display:flex;gap:8px;margin:12px 0;align-items:center;">
|
||
<el-radio-group v-model="proformaType" size="small" @change="loadProforma">
|
||
<el-radio-button value="profit">预算版利润表</el-radio-button>
|
||
<el-radio-button value="balance">预算版资产负债表</el-radio-button>
|
||
<el-radio-button value="cash">预算版现金流量表</el-radio-button>
|
||
</el-radio-group>
|
||
<el-tag v-if="proformaBudgetVersion" size="small" type="info">预算版本: {{ proformaBudgetVersion }}</el-tag>
|
||
</div>
|
||
<el-table :data="proformaRows" border stripe size="small" style="width:100%;" :row-class-name="proformaRowClass">
|
||
<el-table-column prop="name" label="项目" min-width="180" />
|
||
<el-table-column label="预算值" width="130" align="right">
|
||
<template #default="{ row }">{{ row.budget_value != null ? row.budget_value.toLocaleString() : (row.has_budget ? '-' : '—') }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="实际值" width="130" align="right">
|
||
<template #default="{ row }">{{ row.actual_value != null ? row.actual_value.toLocaleString() : '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="差异" width="140" align="right">
|
||
<template #default="{ row }">
|
||
<span v-if="row.subtotal" style="color:#909399;">小计</span>
|
||
<span v-else-if="row.ratio_kpi" style="color:#909399;">比率型</span>
|
||
<span v-else-if="!row.has_budget" style="color:#bbb;">无预算</span>
|
||
<span v-else :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="110" align="right">
|
||
<template #default="{ row }">
|
||
<span v-if="row.subtotal" style="color:#909399;">—</span>
|
||
<el-tag v-else-if="row.ratio_kpi" type="info" size="small">比率型</el-tag>
|
||
<span v-else-if="!row.has_budget" style="color:#bbb;">无预算</span>
|
||
<el-tag v-else-if="row.deviation_rate != null" :type="proformaRateType(row.deviation_rate)" 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-column label="预算来源" width="110">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.has_budget && !row.subtotal" :type="row.budget_source === 'budget_plan' ? 'success' : 'warning'" size="small">
|
||
{{ row.budget_source === 'budget_plan' ? '预算方案' : row.budget_source === 'target_split' ? '目标分摊' : '—' }}
|
||
</el-tag>
|
||
<span v-else style="color:#bbb;">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="note" label="备注" min-width="200" show-overflow-tooltip>
|
||
<template #default="{ row }"><span style="color:#909399;">{{ row.note || '' }}</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>
|
||
|
||
<!-- ════ 对外法定报表:新30号准则三表合一 ════ -->
|
||
<el-tab-pane label="📑 对外法定报表" name="statutory">
|
||
<div v-loading="loadingStatutory">
|
||
<div class="report-desc">对外法定报表(新30号准则2027)— 利润表五板块+附注明细 / 资产负债表新准则科目分类 / 现金流量表三活动,支持导出Excel。</div>
|
||
|
||
<div class="statutory-toolbar">
|
||
<el-radio-group v-model="statutoryView" size="small">
|
||
<el-radio-button value="profit">利润表</el-radio-button>
|
||
<el-radio-button value="balance">资产负债表</el-radio-button>
|
||
<el-radio-button value="cashflow">现金流量表</el-radio-button>
|
||
</el-radio-group>
|
||
<el-button type="primary" size="small" :loading="exporting" @click="exportStatutory">📥 导出Excel(三表合一)</el-button>
|
||
<span v-if="statutoryDemoMode" class="new30-demo-hint">⚠️ 部分数据为示例数据,导入凭证/打标后显示实际数据</span>
|
||
</div>
|
||
|
||
<!-- ── 利润表(五板块+附注) ── -->
|
||
<div v-if="statutoryView === 'profit' && statutoryData.profit">
|
||
<div class="statutory-sub-title">利润表 — 新30号准则五板块({{ statutoryData.period }})</div>
|
||
<div class="new30-blocks">
|
||
<div v-for="block in statutoryData.profit.blocks || []" :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 }">{{ 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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="new30-net-profit" v-if="statutoryData.profit.net_profit != null">
|
||
<div class="new30-profit-row">
|
||
<span class="new30-profit-label">净利润:</span>
|
||
<span class="new30-profit-value" :class="{ negative: statutoryData.profit.net_profit < 0 }">{{ formatMoney(statutoryData.profit.net_profit) }}</span>
|
||
<span class="new30-profit-name">= 经营 + 投资 + 筹资 + 所得税 + 终止经营</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 附注明细 -->
|
||
<div v-if="statutoryData.profit.notes" class="statutory-notes">
|
||
<div class="statutory-notes-title">附注明细</div>
|
||
<div class="statutory-notes-grid">
|
||
<!-- 收入构成 -->
|
||
<div class="statutory-note-card">
|
||
<div class="statutory-note-head">附注一、收入构成</div>
|
||
<div v-for="n in statutoryData.profit.notes.revenue_breakdown || []" :key="n.code" class="statutory-note-row">
|
||
<span>{{ n.name }}({{ n.code }})</span>
|
||
<span>{{ n.amount != null ? formatMoney(n.amount) : '-' }}</span>
|
||
</div>
|
||
<div class="statutory-note-row total">
|
||
<span>营业收入合计</span>
|
||
<span>{{ statutoryData.profit.notes.revenue_total != null ? formatMoney(statutoryData.profit.notes.revenue_total) : '-' }}</span>
|
||
</div>
|
||
</div>
|
||
<!-- 费用构成 -->
|
||
<div class="statutory-note-card">
|
||
<div class="statutory-note-head">附注二、费用构成</div>
|
||
<div v-for="n in statutoryData.profit.notes.expense_breakdown || []" :key="n.code" class="statutory-note-row">
|
||
<span>{{ n.name }}({{ n.code }})</span>
|
||
<span>{{ n.amount != null ? formatMoney(n.amount) : '-' }}</span>
|
||
</div>
|
||
</div>
|
||
<!-- 财务费用拆解 -->
|
||
<div class="statutory-note-card">
|
||
<div class="statutory-note-head">附注三、财务费用拆解</div>
|
||
<div v-for="n in statutoryData.profit.notes.finance_breakdown || []" :key="n.code" class="statutory-note-row">
|
||
<span>{{ n.name }}({{ n.code }})</span>
|
||
<span>{{ n.amount != null ? formatMoney(n.amount) : '-' }}</span>
|
||
</div>
|
||
</div>
|
||
<!-- 板块勾稽 + 关键比率 -->
|
||
<div class="statutory-note-card">
|
||
<div class="statutory-note-head">附注四、板块勾稽</div>
|
||
<div v-for="n in statutoryData.profit.notes.block_reconciliation || []" :key="n.key" class="statutory-note-row">
|
||
<span>{{ n.name }}</span>
|
||
<span>{{ formatMoney(n.amount) }}</span>
|
||
</div>
|
||
<div class="statutory-note-row total">
|
||
<span>净利润</span>
|
||
<span>{{ formatMoney(statutoryData.profit.notes.net_profit) }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="statutory-note-card">
|
||
<div class="statutory-note-head">附注五、关键比率</div>
|
||
<div v-for="n in statutoryData.profit.notes.key_ratios || []" :key="n.name" class="statutory-note-row">
|
||
<span>{{ n.name }}</span>
|
||
<span>{{ n.value != null ? n.value.toFixed(2) + '%' : '-' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── 资产负债表(新准则科目分类) ── -->
|
||
<div v-else-if="statutoryView === 'balance' && statutoryData.balance_sheet">
|
||
<div class="statutory-sub-title">资产负债表 — 新30号准则科目分类({{ statutoryData.period }})</div>
|
||
<el-table :data="balanceSheetRows" border stripe size="small" style="width:100%;margin-top:12px;" :row-class-name="statutoryBsRowClass">
|
||
<el-table-column label="项目" min-width="220">
|
||
<template #default="{ row }">
|
||
<span :style="{ fontWeight: row.is_section ? 700 : row.is_total ? 700 : 400, paddingLeft: row.is_section ? '0' : '16px' }">{{ row.name }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="新准则分类" width="110">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.ns_category_label && !row.is_section" size="small" :type="statutoryNsTagType(row.ns_category)">{{ row.ns_category_label }}</el-tag>
|
||
<span v-else-if="row.is_section" style="color:#999;">{{ row.category_label }}</span>
|
||
<span v-else>-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="期末余额" width="150" align="right">
|
||
<template #default="{ row }">
|
||
<span :style="{ fontWeight: row.is_section || row.is_total ? 700 : 400 }">{{ row.end_value != null ? formatMoney(row.end_value) : '-' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="期初余额" width="150" align="right">
|
||
<template #default="{ row }">
|
||
<span :style="{ fontWeight: row.is_section || row.is_total ? 700 : 400 }">{{ row.begin_value != null ? formatMoney(row.begin_value) : '-' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="数据" width="80">
|
||
<template #default="{ row }">
|
||
<span v-if="row.is_demo && !row.is_section" class="new30-demo-tag">示例</span>
|
||
<span v-else-if="row.is_section" style="color:#999;font-size:12px;">小计</span>
|
||
<span v-else style="color:#999;font-size:12px;">凭证</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div class="statutory-totals">
|
||
<div class="statutory-total-card" :class="{ ok: statutoryData.balance_sheet.totals?.balanced }">
|
||
<div class="statutory-total-label">资产总计</div>
|
||
<div class="statutory-total-val">{{ formatMoney(statutoryData.balance_sheet.totals?.assets?.end) }}</div>
|
||
</div>
|
||
<div class="statutory-total-card">
|
||
<div class="statutory-total-label">负债合计</div>
|
||
<div class="statutory-total-val">{{ formatMoney(statutoryData.balance_sheet.totals?.liabilities?.end) }}</div>
|
||
</div>
|
||
<div class="statutory-total-card">
|
||
<div class="statutory-total-label">所有者权益合计</div>
|
||
<div class="statutory-total-val">{{ formatMoney(statutoryData.balance_sheet.totals?.equity?.end) }}</div>
|
||
</div>
|
||
<div class="statutory-total-card" :class="{ ok: statutoryData.balance_sheet.totals?.balanced }">
|
||
<div class="statutory-total-label">勾稽校验(资产=负债+权益)</div>
|
||
<div class="statutory-total-val" style="font-size:14px;">{{ statutoryData.balance_sheet.totals?.balanced ? '✅ 平衡' : '⚠️ 不平' }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── 现金流量表(三活动) ── -->
|
||
<div v-else-if="statutoryView === 'cashflow' && statutoryData.cash_flow">
|
||
<div class="statutory-sub-title">现金流量表 — 新30号准则三活动({{ statutoryData.period }})</div>
|
||
<div v-for="sec in statutoryData.cash_flow.sections || []" :key="sec.key" class="cf-section">
|
||
<div class="cf-section-head">
|
||
<span class="cf-section-name">{{ sec.name }}</span>
|
||
<span class="cf-section-net" :class="{ negative: sec.net < 0 }">{{ formatMoney(sec.net) }}</span>
|
||
</div>
|
||
<div v-for="line in sec.lines" :key="line.code" class="cf-line">
|
||
<span class="cf-line-name">{{ line.name }}</span>
|
||
<span class="cf-line-code">{{ line.code }}</span>
|
||
<span class="cf-line-value" :class="{ negative: line.value != null && line.value < 0 }">{{ line.value != null ? formatMoney(line.value) : '-' }}</span>
|
||
<span v-if="line.is_demo" class="new30-demo-tag">示例</span>
|
||
</div>
|
||
</div>
|
||
<div class="cf-section">
|
||
<div class="cf-section-head">
|
||
<span class="cf-section-name">{{ statutoryData.cash_flow.fx_effect?.name }}</span>
|
||
<span class="cf-section-net">{{ formatMoney(statutoryData.cash_flow.fx_effect?.value) }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="cf-summary">
|
||
<div class="cf-summary-row">
|
||
<span>现金及现金等价物净增加额</span>
|
||
<span class="cf-summary-val">{{ formatMoney(statutoryData.cash_flow.summary?.net_increase) }}</span>
|
||
</div>
|
||
<div class="cf-summary-row">
|
||
<span>加:期初现金及现金等价物余额</span>
|
||
<span class="cf-summary-val">{{ formatMoney(statutoryData.cash_flow.summary?.begin_cash) }}</span>
|
||
</div>
|
||
<div class="cf-summary-row total">
|
||
<span>期末现金及现金等价物余额</span>
|
||
<span class="cf-summary-val">{{ formatMoney(statutoryData.cash_flow.summary?.end_cash) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import axios from 'axios'
|
||
import { getFixedEntityId } from '../utils/entity'
|
||
|
||
// ── 状态 ──
|
||
const loading = ref(false)
|
||
const activeTab = ref('profit')
|
||
const reportPeriod = ref('2026-06')
|
||
|
||
// ── 当前企业(只读):2026-09-14 需求——登录即定企业,页内无切换入口
|
||
const currentEntityId = ref(Number(localStorage.getItem('cma_entity_id') || 1))
|
||
|
||
// 右上角只读企业标识(不可点)
|
||
const currentEntityNameTag = computed(() => {
|
||
const f = getFixedEntityId()
|
||
const n = localStorage.getItem('cma_entity_name')
|
||
return n || (f !== undefined ? `企业 #${f}` : '')
|
||
})
|
||
|
||
function getEntityId() {
|
||
// 固定企业版恒取构建锁定值(防 localStorage 残留串账套);默认版取登录时写入值
|
||
const f = getFixedEntityId()
|
||
if (f !== undefined) return f
|
||
return Number(localStorage.getItem('cma_entity_id') || currentEntityId.value || 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() {
|
||
// 新30号准则利润表导出 → 复用对外法定报表Excel(Sheet1=五板块利润表+附注)
|
||
exportStatutory()
|
||
}
|
||
|
||
function openSubjectManage() {
|
||
window.open('/subjects', '_blank')
|
||
}
|
||
|
||
// ════ 对外法定报表(新30号准则三表合一)════
|
||
const statutoryData = ref<any>({})
|
||
const statutoryView = ref('profit')
|
||
const statutoryDemoMode = ref(false)
|
||
const statutoryLoading = ref(false)
|
||
const exporting = ref(false)
|
||
|
||
async function loadStatutory() {
|
||
statutoryLoading.value = true
|
||
try {
|
||
const r = await api.get('/reports/statutory', { params: { period: reportPeriod.value, entity_id: getEntityId() } })
|
||
const d = (r as any).data || {}
|
||
statutoryData.value = d
|
||
statutoryDemoMode.value = !d.cash_flow?.all_items_have_data
|
||
} catch (e) {
|
||
ElMessage.error('加载法定报表失败')
|
||
} finally {
|
||
statutoryLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function exportStatutory() {
|
||
exporting.value = true
|
||
try {
|
||
const token = localStorage.getItem('cma_token') || ''
|
||
const url = `/api/cma/reports/statutory/export?period=${reportPeriod.value}&entity_id=${getEntityId()}`
|
||
const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } })
|
||
if (!resp.ok) throw new Error('导出失败')
|
||
const blob = await resp.blob()
|
||
const a = document.createElement('a')
|
||
a.href = URL.createObjectURL(blob)
|
||
a.download = `对外法定报表_${reportPeriod.value}.xlsx`
|
||
a.click()
|
||
URL.revokeObjectURL(a.href)
|
||
ElMessage.success('导出成功')
|
||
} catch (e) {
|
||
ElMessage.error('导出失败')
|
||
} finally {
|
||
exporting.value = false
|
||
}
|
||
}
|
||
|
||
// ── 报表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()
|
||
loadStatutory()
|
||
}
|
||
|
||
// ── 加载 ──
|
||
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 = [] }
|
||
}
|
||
|
||
// ── 预编报表(预算版三张报表)──
|
||
const loadingProforma = ref(false)
|
||
const proformaType = ref('profit')
|
||
const proformaData = ref<any>(null)
|
||
|
||
const proformaBudgetVersion = computed(() => {
|
||
const v = proformaData.value?.budget_version
|
||
return Array.isArray(v) ? v.join(', ') : (v || '')
|
||
})
|
||
|
||
const proformaRows = computed(() => {
|
||
const d = proformaData.value
|
||
if (!d) return []
|
||
const rows: any[] = []
|
||
if (proformaType.value === 'profit') {
|
||
for (const b of (d.blocks || [])) {
|
||
rows.push({ name: b.name, subtotal: true, actual_value: b.subtotal_actual, budget_value: b.subtotal_budget, has_budget: b.has_budget })
|
||
for (const it of (b.items || [])) rows.push({ ...it, name: it.name })
|
||
}
|
||
rows.push({ name: '= 净利润', subtotal: true, actual_value: d.net_profit_actual, budget_value: d.net_profit_budget, has_budget: true })
|
||
} else if (proformaType.value === 'balance') {
|
||
for (const s of (d.sections || [])) {
|
||
rows.push({ name: s.name, subtotal: true, actual_value: s.subtotal_actual, budget_value: s.subtotal_budget, has_budget: s.has_budget })
|
||
for (const ln of (s.lines || [])) rows.push({ ...ln, name: ln.name })
|
||
}
|
||
} else {
|
||
for (const s of (d.sections || [])) {
|
||
rows.push({ name: s.name, subtotal: true, actual_value: s.net_actual, budget_value: s.net_budget, has_budget: s.has_budget })
|
||
for (const ln of (s.lines || [])) rows.push({ ...ln, name: ln.name })
|
||
}
|
||
const sm = d.summary || {}
|
||
rows.push({ name: '= 现金净增加额', subtotal: true, actual_value: sm.net_increase_actual, budget_value: sm.net_increase_budget, has_budget: true })
|
||
}
|
||
return rows
|
||
})
|
||
|
||
async function loadProforma() {
|
||
loadingProforma.value = true
|
||
try {
|
||
const map: Record<string, string> = {
|
||
profit: '/reports/proforma/profit-statement',
|
||
balance: '/reports/proforma/balance-sheet',
|
||
cash: '/reports/proforma/cash-flow',
|
||
}
|
||
const r = await api.get(map[proformaType.value], { params: { period: reportPeriod.value, entity_id: getEntityId() } })
|
||
proformaData.value = (r as any).data || {}
|
||
} catch (e) {
|
||
proformaData.value = null
|
||
ElMessage.error('加载预编报表失败')
|
||
} finally {
|
||
loadingProforma.value = false
|
||
}
|
||
}
|
||
|
||
function proformaRowClass({ row }: any) {
|
||
if (row.subtotal) return 'proforma-subtotal'
|
||
if (!row.has_budget) return 'proforma-no-budget'
|
||
return ''
|
||
}
|
||
|
||
function proformaRateType(rate: number): string {
|
||
const abs = Math.abs(rate)
|
||
if (abs > 20) return 'danger'
|
||
if (abs > 10) return 'warning'
|
||
return 'success'
|
||
}
|
||
|
||
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(), loadProforma(), 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()
|
||
loadStatutory()
|
||
})
|
||
</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; }
|
||
|
||
/* 预编报表(预算版) */
|
||
:deep(.proforma-no-budget) { opacity: 0.55; }
|
||
:deep(.proforma-subtotal) { background: #f0f7ff !important; font-weight: 600; color: #1f2d3d; }
|
||
|
||
/* 统计卡片 */
|
||
.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>
|