Files
cma-management/frontend/src/views/MpmCalculator.vue
T

371 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="mpm-page">
<div class="mpm-header">
<h3 class="page-title">MPM管理层指标计算器</h3>
<div class="header-right">
<el-date-picker
v-model="period"
type="month"
placeholder="选择月份"
value-format="YYYY-MM"
size="small"
style="width:140px"
/>
<el-button type="primary" size="small" :loading="calculating" @click="calculate">计算</el-button>
</div>
</div>
<el-row :gutter="16">
<!-- 左侧参数面板 -->
<el-col :span="10">
<el-card class="param-card">
<template #header>
<span>参数设置</span>
</template>
<div class="param-section">
<label class="param-label">选择指标类型</label>
<el-radio-group v-model="indicatorType" class="indicator-types">
<el-radio
v-for="opt in indicatorOptions"
:key="opt.value"
:value="opt.value"
class="indicator-radio"
>
<div class="indicator-opt">
<span class="indicator-name">{{ opt.label }}</span>
<span class="indicator-desc">{{ opt.desc }}</span>
</div>
</el-radio>
</el-radio-group>
</div>
<el-divider />
<div class="param-section">
<label class="param-label">调整项目
<span class="param-hint">勾选需要调整的项目未勾选的项目不计入调节</span>
</label>
<div class="adjustment-list">
<div v-for="(adj, idx) in adjustments" :key="adj.code" class="adjustment-row">
<el-checkbox v-model="adj.checked" :disabled="adjusting" @change="onAdjustmentChange">
<span :class="{ 'adj-sign-positive': adj.sign > 0, 'adj-sign-negative': adj.sign < 0 }">
{{ adj.sign > 0 ? '加回' : '减' }}
</span>
{{ adj.name }}
</el-checkbox>
<el-input-number
v-model="adj.amount"
:disabled="!adj.checked || adjusting"
size="small"
:min="0"
:precision="2"
controls-position="right"
placeholder="0.00"
style="width:150px"
@change="onAdjustmentChange"
/>
</div>
</div>
<div v-if="adjustments.length === 0" class="empty-hint">该指标类型暂无可选调整项</div>
</div>
<el-divider />
<div style="text-align:right;">
<el-button size="small" @click="resetDefaults">重置默认</el-button>
</div>
</el-card>
</el-col>
<!-- 右侧调节表结果 -->
<el-col :span="14">
<el-card class="result-card">
<template #header>
<div class="result-header">
<span>调节表附注披露用</span>
<el-tag v-if="indicatorType === 'ebitda'" type="warning">EBITDA</el-tag>
<el-tag v-else-if="indicatorType === 'adjusted_net_profit'" type="success">调整后净利润</el-tag>
<el-tag v-else-if="indicatorType === 'free_cash_flow'" type="primary">自由现金流</el-tag>
<el-tag v-else type="info">自定义</el-tag>
</div>
</template>
<div v-loading="calculating">
<div v-if="result" class="reconciliation-table">
<div
v-for="item in result.reconciliation_items"
:key="item.step"
:class="['rec-item', { 'rec-base': item.is_base, 'rec-checked': item.checked && !item.is_base, 'rec-unchecked': !item.checked && !item.is_base }]"
>
<div class="rec-step">
<span v-if="item.is_base" class="rec-dot-base"></span>
<span v-else-if="item.checked" class="rec-dot-checked"></span>
<span v-else class="rec-dot-unchecked"></span>
<span class="rec-name">{{ item.name }}</span>
</div>
<div class="rec-amount" :class="{ 'rec-negative': item.effective != null && item.effective < 0 }">
<template v-if="item.effective != null">
{{ item.sign > 0 && !item.is_base ? '+ ' : '' }}{{ item.sign < 0 && !item.is_base ? '- ' : '' }}¥ {{ Math.abs(item.effective).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
</template>
<span v-else class="rec-no-data">无数据</span>
</div>
<div v-if="item.running_total != null" class="rec-running">
= ¥ {{ item.running_total.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
</div>
</div>
<div class="rec-divider"></div>
<div class="rec-item rec-final">
<div class="rec-step">
<span class="rec-dot-final"></span>
<span class="rec-name rec-final-label">{{ result.final_label }}</span>
</div>
<div class="rec-amount rec-final-amount" :class="{ 'rec-negative': result.final_value < 0 }">
¥ {{ Math.abs(result.final_value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
</div>
</div>
<div class="rec-summary">
<span class="rec-summary-item">基准值:¥ {{ result.base_value.toLocaleString() }}</span>
<span class="rec-summary-item">调整项{{ result.adjustment_count }}/{{ result.total_adjustments }}</span>
<span class="rec-summary-item">最终值:¥ {{ result.final_value.toLocaleString() }}</span>
</div>
</div>
<el-empty v-else-if="!calculating" description="选择指标类型,点击「计算」生成合规调节表" />
<div v-if="!result?.has_real_data && result" class="demo-hint">
当前部分数据为示例数据实际数据将在科目打标后显示
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 底部操作 -->
<div class="mpm-actions" v-if="result">
<el-button type="primary" @click="exportDetail">导出附注明细</el-button>
<el-button @click="saveTemplate">保存为模板</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } 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 period = ref('2026-06')
const indicatorType = ref('adjusted_net_profit')
const calculating = ref(false)
const result = ref<any>(null)
const adjusting = ref(false)
const indicatorOptions = [
{ value: 'ebitda', label: 'EBITDA', desc: '息税折旧摊销前利润' },
{ value: 'adjusted_net_profit', label: '调整后净利润', desc: '剔除非经常性项目' },
{ value: 'free_cash_flow', label: '自由现金流', desc: '经营现金流减资本支出' },
{ value: 'custom', label: '自定义', desc: '自定义管理层指标' },
]
// ── 调整项 ──
const adjustments = ref<any[]>([])
// 默认调整项配置
const DEFAULT_ADJUSTMENTS: Record<string, any[]> = {
ebitda: [
{ code: 'tax', name: '加:所得税费用', sign: 1, checked: true, amount: null },
{ code: 'interest', name: '加:利息支出', sign: 1, checked: true, amount: null },
{ code: 'depreciation', name: '加:折旧与摊销', sign: 1, checked: true, amount: null },
{ code: 'impairment', name: '加:资产减值损失', sign: 1, checked: false, amount: null },
],
adjusted_net_profit: [
{ code: 'impairment', name: '加:资产减值损失', sign: 1, checked: true, amount: null },
{ code: 'equity_incentive', name: '加:股权激励费用', sign: 1, checked: true, amount: null },
{ code: 'ma_cost', name: '加:并购相关费用', sign: 1, checked: false, amount: null },
{ code: 'nonrecurring_income', name: '减:非经常性投资收益', sign: -1, checked: true, amount: null },
{ code: 'asset_disposal', name: '减:资产处置收益', sign: -1, checked: false, amount: null },
{ code: 'government_grant', name: '减:政府补助', sign: -1, checked: false, amount: null },
],
free_cash_flow: [
{ code: 'capex', name: '减:资本支出', sign: -1, checked: true, amount: null },
{ code: 'working_capital', name: '减:营运资本增加', sign: -1, checked: true, amount: null },
{ code: 'maintenance_capex', name: '减:维护性资本支出', sign: -1, checked: false, amount: null },
{ code: 'dividend', name: '加:股息收入', sign: 1, checked: false, amount: null },
],
custom: [
{ code: 'adjustment_1', name: '调整项目1', sign: 1, checked: true, amount: null },
{ code: 'adjustment_2', name: '调整项目2', sign: -1, checked: true, amount: null },
{ code: 'adjustment_3', name: '调整项目3', sign: 1, checked: false, amount: null },
],
}
// 切换指标类型时更新调整项
watch(indicatorType, (val) => {
const defaults = DEFAULT_ADJUSTMENTS[val] || DEFAULT_ADJUSTMENTS.adjusted_net_profit
adjustments.value = JSON.parse(JSON.stringify(defaults))
result.value = null
}, { immediate: true })
function resetDefaults() {
const defaults = DEFAULT_ADJUSTMENTS[indicatorType.value] || DEFAULT_ADJUSTMENTS.adjusted_net_profit
adjustments.value = JSON.parse(JSON.stringify(defaults))
calculate()
}
function onAdjustmentChange() {
// 当用户勾选/取消或改金额时自动重新计算
calculate()
}
async function calculate() {
calculating.value = true
try {
const payload = {
indicator_type: indicatorType.value,
period: period.value,
adjustments: adjustments.value.map(a => ({
code: a.code,
name: a.name,
sign: a.sign,
checked: a.checked,
amount: a.amount,
})),
}
const r = await api.post('/reports/mpm-calculate', payload)
result.value = (r as any).data || {}
} catch (e) {
ElMessage.error('计算失败,请检查参数')
result.value = null
} finally {
calculating.value = false
}
}
function exportDetail() {
if (!result.value) return
const items = result.value.reconciliation_items || []
let text = `MPM管理层指标调节表\n`
text += `指标类型:${result.value.indicator_name}\n`
text += `期间:${result.value.period}\n`
text += `基准值(${result.value.base_label}):¥ ${result.value.base_value.toLocaleString()}\n`
text += `\n调节过程:\n`
text += `──────────────────────────\n`
items.forEach((item: any) => {
if (item.is_base) {
text += `${item.name}\${item.amount?.toLocaleString() || '-'}\n`
} else if (item.checked && item.effective != null) {
const signStr = item.sign > 0 ? '+' : '-'
text += ` ${signStr} ${item.name}\${Math.abs(item.effective).toLocaleString()}\t余额:¥ ${item.running_total?.toLocaleString() || ''}\n`
}
})
text += `──────────────────────────\n`
text += `${result.value.final_label}\${result.value.final_value.toLocaleString()}\n`
// 下载文本文件
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `MPM调节表_${result.value.period}_${result.value.indicator_name}.txt`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('调节表明细已导出')
}
function saveTemplate() {
const payload = {
indicator_type: indicatorType.value,
adjustments: adjustments.value,
period: period.value,
}
try {
const saved = JSON.parse(localStorage.getItem('mpm_templates') || '[]')
const name = `${indicatorType.value}_${period.value}_${Date.now()}`
saved.push({ name, data: payload, savedAt: new Date().toISOString() })
localStorage.setItem('mpm_templates', JSON.stringify(saved))
ElMessage.success(`模板已保存:${name}`)
} catch {
ElMessage.error('保存模板失败')
}
}
</script>
<style scoped>
.mpm-page { max-width: 1400px; margin: 0 auto; }
.mpm-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.header-right { display: flex; gap: 8px; align-items: center; }
.param-card { min-height: 400px; }
.result-card { min-height: 400px; }
.result-header { display: flex; align-items: center; gap: 8px; }
.param-section { margin-bottom: 8px; }
.param-label { font-size: 14px; font-weight: 600; color: #1a1a2e; display: block; margin-bottom: 8px; }
.param-hint { font-size: 11px; color: #999; font-weight: 400; margin-left: 8px; }
.indicator-types { display: flex; flex-direction: column; gap: 6px; }
.indicator-radio { display: flex; align-items: center; padding: 6px 0; }
.indicator-opt { display: flex; flex-direction: column; }
.indicator-name { font-weight: 600; font-size: 13px; }
.indicator-desc { font-size: 11px; color: #999; }
.adjustment-list { display: flex; flex-direction: column; gap: 6px; }
.adjustment-row {
display: flex; align-items: center; justify-content: space-between;
padding: 4px 8px; border-radius: 4px; transition: background 0.2s;
}
.adjustment-row:hover { background: #f5f7fa; }
.adj-sign-positive { color: #67c23a; font-weight: 600; }
.adj-sign-negative { color: #f56c6c; font-weight: 600; }
.empty-hint { color: #ccc; font-size: 12px; text-align: center; padding: 20px; }
/* 调节表样式 */
.reconciliation-table { padding: 4px 0; }
.rec-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-radius: 4px;
margin-bottom: 2px;
transition: background 0.2s;
}
.rec-base { background: #f0f9eb; border-left: 3px solid #67c23a; }
.rec-checked { background: #f5f7fa; border-left: 3px solid #409eff; }
.rec-unchecked { opacity: 0.4; border-left: 3px solid #ddd; }
.rec-final { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; border-radius: 6px; margin-top: 4px; }
.rec-step { flex: 1; display: flex; align-items: center; gap: 6px; }
.rec-dot-base { width: 8px; height: 8px; border-radius: 50%; background: #67c23a; flex-shrink: 0; }
.rec-dot-checked { width: 8px; height: 8px; border-radius: 50%; background: #409eff; flex-shrink: 0; }
.rec-dot-unchecked { width: 8px; height: 8px; border-radius: 50%; background: #ddd; flex-shrink: 0; }
.rec-dot-final { width: 8px; height: 8px; border-radius: 50%; background: #fff; flex-shrink: 0; }
.rec-name { font-size: 13px; }
.rec-amount { font-size: 13px; font-weight: 500; min-width: 160px; text-align: right; font-variant-numeric: tabular-nums; }
.rec-negative { color: #f56c6c; }
.rec-final .rec-amount { font-size: 16px; font-weight: 700; }
.rec-final .rec-name { font-weight: 700; }
.rec-no-data { color: #999; font-size: 11px; }
.rec-running { font-size: 12px; color: #999; min-width: 160px; text-align: right; font-variant-numeric: tabular-nums; }
.rec-final .rec-running { color: rgba(255,255,255,0.7); }
.rec-divider { height: 1px; background: #ebeef5; margin: 8px 12px; }
.rec-summary {
display: flex; gap: 16px; padding: 8px 12px; margin-top: 8px;
background: #fafafa; border-radius: 4px; font-size: 12px; color: #666;
}
.rec-summary-item { flex: 1; }
.demo-hint { font-size: 11px; color: #d46b08; margin-top: 8px; padding: 4px 12px; background: #fff7e6; border-radius: 4px; }
.mpm-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
</style>