feat: 新30号准则P2 — MPM计算器+旧格式双列对比

This commit is contained in:
Hermes CI Fix
2026-07-22 12:23:40 +08:00
parent 748c2da43f
commit cbcc0d0a28
5 changed files with 705 additions and 14 deletions
+238 -2
View File
@@ -9,6 +9,7 @@ CMA管理报表中心 — 管理会计OS
4. 四维度绩效评分卡 — BSC健康度雷达图 4. 四维度绩效评分卡 — BSC健康度雷达图
""" """
from fastapi import APIRouter, Depends, Query, HTTPException from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func
from typing import Optional from typing import Optional
@@ -571,10 +572,10 @@ def _get_subject_amount(db: Session, code: str, period: str) -> Optional[float]:
@router.get("/profit-statement") @router.get("/profit-statement")
def get_profit_statement( def get_profit_statement(
period: str = Query(None, description="格式 YYYY-MM"), period: str = Query(None, description="格式 YYYY-MM"),
format: str = Query("old", description="old/new"), format: str = Query("old", description="old/new/dual"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""利润表 — 支持旧格式新30号准则五板块格式""" """利润表 — 支持旧格式新30号准则五板块格式、双列对比"""
if period is None: if period is None:
period = datetime.now().strftime("%Y-%m") period = datetime.now().strftime("%Y-%m")
@@ -582,7 +583,24 @@ def get_profit_statement(
# 旧30号准则格式(保留兼容) # 旧30号准则格式(保留兼容)
return get_profit_summary(period=period, db=db) return get_profit_summary(period=period, db=db)
if format == "dual":
# 双列对比:旧准则 vs 新准则
old_data = get_profit_summary(period=period, db=db)
new_data = _build_new_format_profit(db, period)
return {
"period": period,
"format": "dual",
"title": f"利润表双列对比({period}",
"old_format": old_data,
"new_format": new_data,
}
# === 新30号准则:五板块结构 === # === 新30号准则:五板块结构 ===
return _build_new_format_profit(db, period)
def _build_new_format_profit(db: Session, period: str) -> dict:
"""构建新30号准则五板块利润表"""
blocks = [] blocks = []
total_net_profit = 0 total_net_profit = 0
all_items_have_data = True all_items_have_data = True
@@ -638,6 +656,224 @@ def get_profit_statement(
} }
# ============================================================
# MPM管理层指标计算器 (P2)
# ============================================================
# MPM指标类型定义
MPM_INDICATOR_TYPES = {
"ebitda": {
"name": "EBITDA",
"description": "息税折旧摊销前利润",
"base_label": "净利润",
"default_adjustments": [
{"code": "tax", "name": "加:所得税费用", "sign": 1, "checked": True},
{"code": "interest", "name": "加:利息支出", "sign": 1, "checked": True},
{"code": "depreciation", "name": "加:折旧与摊销", "sign": 1, "checked": True},
{"code": "impairment", "name": "加:资产减值损失", "sign": 1, "checked": False},
],
},
"adjusted_net_profit": {
"name": "调整后净利润",
"description": "剔除非经常性项目后的可持续净利润",
"base_label": "净利润(准则)",
"default_adjustments": [
{"code": "impairment", "name": "加:资产减值损失", "sign": 1, "checked": True},
{"code": "equity_incentive", "name": "加:股权激励费用", "sign": 1, "checked": True},
{"code": "ma_cost", "name": "加:并购相关费用", "sign": 1, "checked": False},
{"code": "nonrecurring_income", "name": "减:非经常性投资收益", "sign": -1, "checked": True},
{"code": "asset_disposal", "name": "减:资产处置收益", "sign": -1, "checked": False},
{"code": "government_grant", "name": "减:政府补助", "sign": -1, "checked": False},
],
},
"free_cash_flow": {
"name": "自由现金流",
"description": "经营现金流扣除资本支出后的可自由支配现金流",
"base_label": "经营现金流",
"default_adjustments": [
{"code": "capex", "name": "减:资本支出", "sign": -1, "checked": True},
{"code": "working_capital", "name": "减:营运资本增加", "sign": -1, "checked": True},
{"code": "maintenance_capex", "name": "减:维护性资本支出", "sign": -1, "checked": False},
{"code": "dividend", "name": "加:股息收入", "sign": 1, "checked": False},
],
},
"custom": {
"name": "自定义指标",
"description": "自定义管理层指标",
"base_label": "净利润",
"default_adjustments": [
{"code": "adjustment_1", "name": "调整项目1", "sign": 1, "checked": False, "amount": None},
{"code": "adjustment_2", "name": "调整项目2", "sign": -1, "checked": False, "amount": None},
{"code": "adjustment_3", "name": "调整项目3", "sign": 1, "checked": False, "amount": None},
],
},
}
# 调整项默认金额(从利润表自动取值映射)
ADJUSTMENT_VALUE_MAP = {
"tax": {"code": "6801", "sign_inverse": True}, # 所得税费用,加回需取绝对值
"interest": {"code": "660301", "sign_inverse": True},
"impairment": {"code": "6701", "sign_inverse": False}, # 资产减值损失本身是费用
"equity_incentive": None, # 无映射,需用户输入
"ma_cost": None,
"nonrecurring_income": {"code": "6111", "sign_inverse": False},
"asset_disposal": None,
"government_grant": None,
"capex": None,
"working_capital": None,
"maintenance_capex": None,
"dividend": None,
}
class MpmCalculateRequest(BaseModel):
indicator_type: str = "adjusted_net_profit"
period: str = None
adjustments: list[dict] = None # [{code, name, sign, checked, amount}]
@router.post("/mpm-calculate")
def mpm_calculate(
req: MpmCalculateRequest,
db: Session = Depends(get_db),
):
"""MPM管理层指标计算器 — 生成合规调节表"""
if req.period is None:
req.period = datetime.now().strftime("%Y-%m")
indicator_cfg = MPM_INDICATOR_TYPES.get(req.indicator_type)
if not indicator_cfg:
raise HTTPException(status_code=400, detail=f"不支持的指标类型: {req.indicator_type}")
# 获取基准值:净利润
net_profit = _calc_new_net_profit(db, req.period)
if net_profit is None:
net_profit = 0
# 经营现金流(自由现金流的基准)
operating_cf = _get_kpi_val(db, "F_OPERATING_CF", req.period)
# 确定基准值
if req.indicator_type == "free_cash_flow":
base_value = operating_cf or net_profit # fallback
base_label = "经营现金流"
else:
base_value = net_profit
base_label = indicator_cfg["base_label"]
# 获取调整项(来自请求或默认)
adjustments = req.adjustments if req.adjustments else indicator_cfg["default_adjustments"]
# 自动填充调整项金额
reconciliation_items = []
running_total = base_value
# 第一步:基准值
reconciliation_items.append({
"step": 0,
"code": "_base",
"name": base_label,
"sign": 1,
"amount": round(base_value, 2),
"effective": round(base_value, 2),
"is_base": True,
"running_total": round(base_value, 2),
})
for adj in adjustments:
code = adj.get("code", "")
checked = adj.get("checked", False)
sign = adj.get("sign", 1)
name = adj.get("name", "")
amount = adj.get("amount")
# 尝试自动取值
if amount is None and checked:
amount = _get_adjustment_value(db, code, req.period)
effective = round(amount * sign, 2) if amount is not None else None
item = {
"step": len(reconciliation_items),
"code": code,
"name": name,
"sign": sign,
"amount": round(amount, 2) if amount is not None else None,
"effective": effective,
"checked": checked,
"is_base": False,
"running_total": None,
}
if checked and effective is not None:
running_total += effective
item["running_total"] = round(running_total, 2)
reconciliation_items.append(item)
# 最终结果
final_value = round(running_total, 2)
return {
"indicator_type": req.indicator_type,
"indicator_name": indicator_cfg["name"],
"indicator_desc": indicator_cfg["description"],
"period": req.period,
"base_value": round(base_value, 2),
"base_label": base_label,
"final_value": final_value,
"final_label": indicator_cfg["name"],
"adjustment_count": sum(1 for a in adjustments if a.get("checked", False)),
"total_adjustments": len(adjustments),
"reconciliation_items": reconciliation_items,
"has_real_data": net_profit != 0,
}
def _get_kpi_val(db: Session, code: str, period: str) -> Optional[float]:
"""从KPI定义+值获取数值"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
if not kpi:
return None
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id, KPIValue.period == period
).order_by(KPIValue.id.desc()).first()
return float(v.actual_value) if v and v.actual_value is not None else None
def _calc_new_net_profit(db: Session, period: str) -> Optional[float]:
"""计算新30号准则下的净利润"""
total = 0
has_data = False
for block_key in ["operating", "investing", "financing", "tax", "discontinued"]:
block_cfg = BLOCK_INFO[block_key]
for item_cfg in block_cfg["items"]:
amount = _get_subject_amount(db, item_cfg["code"], period)
if amount is not None:
total += amount * item_cfg["sign"]
has_data = True
if not has_data:
return None
return round(total, 2)
def _get_adjustment_value(db: Session, adj_code: str, period: str) -> Optional[float]:
"""获取调整项的自动取值"""
mapping = ADJUSTMENT_VALUE_MAP.get(adj_code)
if mapping is None:
return None # 需要用户输入
code = mapping["code"]
amount = _get_subject_amount(db, code, period)
if amount is None:
return None
# sign_inverse: 如果调整项是"加回"费用,费用本身的sign是负的(在利润表中是减项)
# 但在MPM调节中,加回费用取绝对值
return abs(amount) if mapping.get("sign_inverse", False) else amount
def _get_demo_block_total(block_key: str) -> float: def _get_demo_block_total(block_key: str) -> float:
"""PRD示例数据 fallback""" """PRD示例数据 fallback"""
demo = { demo = {
+4 -3
View File
@@ -5,9 +5,9 @@
// 各角色可访问的路由列表 // 各角色可访问的路由列表
export const ROLE_ROUTES: Record<string, string[]> = { export const ROLE_ROUTES: Record<string, string[]> = {
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/okr-templates'], ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/okr-templates', '/mpm-calculator'],
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/okr-templates'], finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/okr-templates', '/mpm-calculator'],
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/deviations', '/budget', '/action-plans', '/knowledge', '/guide', '/customer', '/reports', '/alignment'], business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/deviations', '/budget', '/action-plans', '/knowledge', '/guide', '/customer', '/reports', '/alignment', '/mpm-calculator'],
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/okr-templates'], it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/okr-templates'],
} }
@@ -62,6 +62,7 @@ export const MENU_ITEMS: MenuItem[] = [
{ path: '/maps-review', label: '战略回顾会', icon: 'TrendCharts', roles: ['ceo', 'finance'], group: '🔴 A 复盘与改进' }, { path: '/maps-review', label: '战略回顾会', icon: 'TrendCharts', roles: ['ceo', 'finance'], group: '🔴 A 复盘与改进' },
{ path: '/predict', label: '预测模拟', icon: 'DataLine', roles: ['ceo', 'finance', 'it'], group: '🔴 A 复盘与改进' }, { path: '/predict', label: '预测模拟', icon: 'DataLine', roles: ['ceo', 'finance', 'it'], group: '🔴 A 复盘与改进' },
{ path: '/real-options', label: '实物期权计算器', icon: 'TrendCharts', roles: ['ceo', 'finance'], group: '🔴 A 复盘与改进' }, { path: '/real-options', label: '实物期权计算器', icon: 'TrendCharts', roles: ['ceo', 'finance'], group: '🔴 A 复盘与改进' },
{ path: '/mpm-calculator', label: 'MPM计算器', icon: 'Money', roles: ['ceo', 'finance', 'business'], group: '🔴 A 复盘与改进' },
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
// GROUP 5: 系统与支持(Infra // GROUP 5: 系统与支持(Infra
+1
View File
@@ -37,6 +37,7 @@ const routes = [
{ path: 'bi-reports', name: 'BiReports', component: () => import('@/views/BIReportCenter.vue'), meta: { title: 'BI报表', roles: ['ceo', 'finance', 'business'] } }, { 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: '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'] } }, { path: 'subjects', name: 'SubjectManage', component: () => import('@/views/SubjectManage.vue'), meta: { title: '科目打标', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'mpm-calculator', name: 'MpmCalculator', component: () => import('@/views/MpmCalculator.vue'), meta: { title: 'MPM计算器', roles: ['ceo', 'finance', 'business'] } },
] ]
}, },
] ]
+370
View File
@@ -0,0 +1,370 @@
<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>
+89 -6
View File
@@ -92,7 +92,7 @@
</div> </div>
</div> </div>
<div class="new30-net-profit"> <div class="new30-net-profit" v-if="new30Format !== 'dual'">
<div class="new30-profit-row"> <div class="new30-profit-row">
<span class="new30-profit-label">合计</span> <span class="new30-profit-label">合计</span>
<span class="new30-profit-value" :class="{ negative: new30NetProfit < 0 }"> <span class="new30-profit-value" :class="{ negative: new30NetProfit < 0 }">
@@ -102,9 +102,57 @@
</div> </div>
</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"> <div class="new30-footer">
<el-button size="small" :type="new30Format === 'new' ? 'primary' : ''" @click="switchNew30Format('new')">新准则</el-button> <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 === '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> <el-button size="small" @click="exportNew30">导出</el-button>
<span v-if="new30DemoMode" class="new30-demo-hint"> 当前显示示例数据科目打标后显示实际数据</span> <span v-if="new30DemoMode" class="new30-demo-hint"> 当前显示示例数据科目打标后显示实际数据</span>
</div> </div>
@@ -357,20 +405,36 @@ const new30Blocks = ref<any[]>([])
const new30NetProfit = ref(0) const new30NetProfit = ref(0)
const new30Format = ref('new') const new30Format = ref('new')
const new30DemoMode = ref(false) const new30DemoMode = ref(false)
const dualComparisonData = ref<any>(null)
async function loadNew30() { async function loadNew30() {
loadingNew30.value = true loadingNew30.value = true
dualComparisonData.value = null
try { try {
const r = await api.get('/reports/profit-statement', { const params: any = { period: reportPeriod.value }
params: { period: reportPeriod.value, format: 'new' } 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 || {} 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 || [] new30Blocks.value = d.blocks || []
new30NetProfit.value = d.net_profit ?? 0 new30NetProfit.value = d.net_profit ?? 0
new30DemoMode.value = !d.all_items_have_data new30DemoMode.value = !d.all_items_have_data
}
} catch (e) { } catch (e) {
new30Blocks.value = [] new30Blocks.value = []
new30NetProfit.value = 0 new30NetProfit.value = 0
dualComparisonData.value = null
ElMessage.error('加载新30号准则利润表失败') ElMessage.error('加载新30号准则利润表失败')
} finally { } finally {
loadingNew30.value = false loadingNew30.value = false
@@ -387,7 +451,7 @@ function formatMoney(val: number): string {
function switchNew30Format(fmt: string) { function switchNew30Format(fmt: string) {
new30Format.value = fmt new30Format.value = fmt
if (fmt === 'new') { if (fmt === 'new' || fmt === 'dual') {
loadNew30() loadNew30()
} else { } else {
// 切换到旧准则tab // 切换到旧准则tab
@@ -710,4 +774,23 @@ onMounted(() => {
.restate-dot-red { background: #cf1322; } .restate-dot-red { background: #cf1322; }
.restate-dot-green { background: #389e0d; } .restate-dot-green { background: #389e0d; }
.restate-dot-orange { background: #d46b08; } .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> </style>