feat: 新30号准则P0 — 利润表五板块重构+费用分类打标
This commit is contained in:
@@ -36,6 +36,7 @@ const routes = [
|
||||
{ path: 'data-quality', name: 'DataQuality', component: () => import('@/views/DataQuality.vue'), meta: { title: '数据质量', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'bi-reports', name: 'BiReports', component: () => import('@/views/BIReportCenter.vue'), meta: { title: 'BI报表', roles: ['ceo', 'finance', 'business'] } },
|
||||
{ path: 'okr-templates', name: 'OKRTemplates', component: () => import('@/views/OKRTemplates.vue'), meta: { title: 'OKR模板库', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'subjects', name: 'SubjectManage', component: () => import('@/views/SubjectManage.vue'), meta: { title: '科目打标', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
/>
|
||||
<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:管理利润表 ════ -->
|
||||
@@ -61,6 +62,55 @@
|
||||
</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">
|
||||
<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 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" @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">
|
||||
@@ -129,19 +179,14 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -156,17 +201,13 @@
|
||||
<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 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 }">
|
||||
@@ -233,6 +274,58 @@ function profitSummary(param: any) {
|
||||
return sums
|
||||
}
|
||||
|
||||
// ── 新30号准则利润表 ──
|
||||
const loadingNew30 = ref(false)
|
||||
const new30Blocks = ref<any[]>([])
|
||||
const new30NetProfit = ref(0)
|
||||
const new30Format = ref('new')
|
||||
const new30DemoMode = ref(false)
|
||||
|
||||
async function loadNew30() {
|
||||
loadingNew30.value = true
|
||||
try {
|
||||
const r = await api.get('/reports/profit-statement', {
|
||||
params: { period: reportPeriod.value, format: 'new' }
|
||||
})
|
||||
const d = (r as any).data || {}
|
||||
new30Blocks.value = d.blocks || []
|
||||
new30NetProfit.value = d.net_profit ?? 0
|
||||
new30DemoMode.value = !d.all_items_have_data
|
||||
} catch (e) {
|
||||
new30Blocks.value = []
|
||||
new30NetProfit.value = 0
|
||||
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') {
|
||||
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[]>([])
|
||||
@@ -324,7 +417,7 @@ async function loadKpiOptions() {
|
||||
|
||||
function loadAll() {
|
||||
loading.value = true
|
||||
Promise.all([loadProfit(), loadBudget(), loadTrends(), loadBsc()]).finally(() => { loading.value = false })
|
||||
Promise.all([loadProfit(), loadNew30(), loadBudget(), loadTrends(), loadBsc()]).finally(() => { loading.value = false })
|
||||
}
|
||||
|
||||
function openDupont() {
|
||||
@@ -347,7 +440,6 @@ function renderTrendCharts() {
|
||||
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),
|
||||
@@ -445,4 +537,31 @@ onMounted(() => {
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="subject-page">
|
||||
<div class="subject-header">
|
||||
<h3 class="page-title">📋 会计科目管理 — 新30号准则打标</h3>
|
||||
<div class="header-right">
|
||||
<el-button size="small" @click="goBack">← 返回报表</el-button>
|
||||
<el-button type="primary" size="small" :loading="loading" @click="loadSubjects">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 过滤 -->
|
||||
<div class="filter-row">
|
||||
<el-input v-model="keyword" placeholder="搜索科目名称/编码" size="small" style="width:200px;" clearable @change="loadSubjects" />
|
||||
<el-select v-model="filterCategory" placeholder="新准则分类" clearable size="small" style="width:160px;" @change="loadSubjects">
|
||||
<el-option label="经营类" value="operating" />
|
||||
<el-option label="研发费用" value="operating_rd" />
|
||||
<el-option label="经营汇兑" value="operating_fx" />
|
||||
<el-option label="投资类" value="investing" />
|
||||
<el-option label="筹资类" value="financing" />
|
||||
<el-option label="筹资汇兑" value="financing_fx" />
|
||||
<el-option label="所得税" value="tax" />
|
||||
<el-option label="终止经营" value="discontinued" />
|
||||
</el-select>
|
||||
<span class="filter-info">共 {{ totalSubjects }} 个科目</span>
|
||||
<span style="margin-left:auto;">
|
||||
<el-button size="small" type="success" :disabled="selectedIds.length === 0" @click="showBatchDialog">
|
||||
批量打标 ({{ selectedIds.length }})
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 科目表格 -->
|
||||
<el-table
|
||||
:data="subjects"
|
||||
border stripe size="small"
|
||||
style="width:100%;margin-top:8px;"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="40" />
|
||||
<el-table-column prop="subject_code" label="科目编码" width="100" />
|
||||
<el-table-column prop="subject_name" label="科目名称" min-width="160" />
|
||||
<el-table-column prop="parent_code" label="上级编码" width="80" />
|
||||
<el-table-column prop="level" label="级别" width="50" />
|
||||
<el-table-column label="新30号准则分类" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.new_standard_category"
|
||||
size="small"
|
||||
placeholder="选择分类"
|
||||
style="width:150px;"
|
||||
@change="val => updateCategory(row, val)"
|
||||
>
|
||||
<el-option label="未分类" value="" />
|
||||
<el-option label="经营类" value="operating" />
|
||||
<el-option label="研发费用(经营类)" value="operating_rd" />
|
||||
<el-option label="经营汇兑损益" value="operating_fx" />
|
||||
<el-option label="投资类" value="investing" />
|
||||
<el-option label="筹资类" value="financing" />
|
||||
<el-option label="筹资汇兑损益" value="financing_fx" />
|
||||
<el-option label="所得税" value="tax" />
|
||||
<el-option label="终止经营" value="discontinued" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="category" label="科目类别" width="80" />
|
||||
<el-table-column label="分类标签" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.new_standard_category" :type="tagType(row.new_standard_category)" size="small">
|
||||
{{ catLabel(row.new_standard_category) }}
|
||||
</el-tag>
|
||||
<span v-else style="color:#ccc;">未分类</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 批量打标对话框 -->
|
||||
<el-dialog v-model="batchDialogVisible" title="批量设置新30号准则分类" width="400px">
|
||||
<div class="batch-dialog-body">
|
||||
<p>已选 <strong>{{ selectedIds.length }}</strong> 个科目</p>
|
||||
<el-select v-model="batchCategory" placeholder="选择分类" size="small" style="width:100%;">
|
||||
<el-option label="经营类" value="operating" />
|
||||
<el-option label="研发费用(经营类)" value="operating_rd" />
|
||||
<el-option label="经营汇兑损益" value="operating_fx" />
|
||||
<el-option label="投资类" value="investing" />
|
||||
<el-option label="筹资类" value="financing" />
|
||||
<el-option label="筹资汇兑损益" value="financing_fx" />
|
||||
<el-option label="所得税" value="tax" />
|
||||
<el-option label="终止经营" value="discontinued" />
|
||||
</el-select>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button size="small" @click="batchDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" size="small" :loading="batchLoading" @click="doBatchUpdate">确认打标</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const subjects = ref<any[]>([])
|
||||
const totalSubjects = ref(0)
|
||||
const keyword = ref('')
|
||||
const filterCategory = ref('')
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
// 批量打标
|
||||
const batchDialogVisible = ref(false)
|
||||
const batchCategory = ref('')
|
||||
const batchLoading = ref(false)
|
||||
|
||||
async function loadSubjects() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (filterCategory.value) params.category = filterCategory.value
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
const r = await api.get('/subjects', { params })
|
||||
const d = (r as any).data || {}
|
||||
subjects.value = d.data || []
|
||||
totalSubjects.value = d.total || 0
|
||||
} catch (e) {
|
||||
ElMessage.error('加载科目列表失败')
|
||||
subjects.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCategory(row: any, val: string) {
|
||||
if (!val) {
|
||||
// 清空分类
|
||||
row.new_standard_category = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.put(`/subjects/${row.id}`, null, {
|
||||
params: { new_standard_category: val }
|
||||
})
|
||||
ElMessage.success(`${row.subject_name} 分类更新成功`)
|
||||
} catch (e) {
|
||||
ElMessage.error('更新失败')
|
||||
// 回滚
|
||||
row.new_standard_category = row._old_category || ''
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectionChange(rows: any[]) {
|
||||
selectedIds.value = rows.map((r: any) => r.id)
|
||||
}
|
||||
|
||||
function showBatchDialog() {
|
||||
if (selectedIds.value.length === 0) {
|
||||
ElMessage.warning('请先选择科目')
|
||||
return
|
||||
}
|
||||
batchCategory.value = ''
|
||||
batchDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function doBatchUpdate() {
|
||||
if (!batchCategory.value) {
|
||||
ElMessage.warning('请选择分类')
|
||||
return
|
||||
}
|
||||
batchLoading.value = true
|
||||
try {
|
||||
await api.put('/subjects/batch/category', null, {
|
||||
params: {
|
||||
ids: selectedIds.value,
|
||||
new_standard_category: batchCategory.value,
|
||||
}
|
||||
})
|
||||
ElMessage.success(`批量更新 ${selectedIds.value.length} 个科目成功`)
|
||||
batchDialogVisible.value = false
|
||||
await loadSubjects()
|
||||
} catch (e) {
|
||||
ElMessage.error('批量更新失败')
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function catLabel(cat: string): string {
|
||||
const map: Record<string, string> = {
|
||||
operating: '经营类', operating_rd: '研发费用', operating_fx: '经营汇兑',
|
||||
investing: '投资类', financing: '筹资类', financing_fx: '筹资汇兑',
|
||||
tax: '所得税', discontinued: '终止经营',
|
||||
}
|
||||
return map[cat] || cat
|
||||
}
|
||||
|
||||
function tagType(cat: string): string {
|
||||
const map: Record<string, string> = {
|
||||
operating: '', operating_rd: 'warning', operating_fx: 'info',
|
||||
investing: 'success', financing: 'danger', financing_fx: 'danger',
|
||||
tax: 'info', discontinued: 'warning',
|
||||
}
|
||||
return map[cat] || ''
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
window.history.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSubjects()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subject-page { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.subject-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.header-right { display: flex; gap: 8px; align-items: center; }
|
||||
.page-title { font-size: 18px; font-weight: 600; color: #1a1a2e; margin: 0; }
|
||||
.filter-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
.filter-info { font-size: 12px; color: #999; }
|
||||
.batch-dialog-body { display: flex; flex-direction: column; gap: 12px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user