diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index 08047222..024a38fd 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -9,6 +9,7 @@ CMA管理报表中心 — 管理会计OS 4. 四维度绩效评分卡 — BSC健康度雷达图 """ from fastapi import APIRouter, Depends, Query, HTTPException +from pydantic import BaseModel from sqlalchemy.orm import Session from sqlalchemy import func from typing import Optional @@ -571,10 +572,10 @@ def _get_subject_amount(db: Session, code: str, period: str) -> Optional[float]: @router.get("/profit-statement") def get_profit_statement( 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), ): - """利润表 — 支持旧格式和新30号准则五板块格式""" + """利润表 — 支持旧格式、新30号准则五板块格式、双列对比""" if period is None: period = datetime.now().strftime("%Y-%m") @@ -582,7 +583,24 @@ def get_profit_statement( # 旧30号准则格式(保留兼容) 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号准则:五板块结构 === + return _build_new_format_profit(db, period) + + +def _build_new_format_profit(db: Session, period: str) -> dict: + """构建新30号准则五板块利润表""" blocks = [] total_net_profit = 0 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: """PRD示例数据 fallback""" demo = { diff --git a/frontend/src/permission.ts b/frontend/src/permission.ts index 03510700..a1ce1c3b 100644 --- a/frontend/src/permission.ts +++ b/frontend/src/permission.ts @@ -5,9 +5,9 @@ // 各角色可访问的路由列表 export const ROLE_ROUTES: Record = { - 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'], - 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'], - business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/deviations', '/budget', '/action-plans', '/knowledge', '/guide', '/customer', '/reports', '/alignment'], + 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', '/mpm-calculator'], + 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'], } @@ -62,6 +62,7 @@ export const MENU_ITEMS: MenuItem[] = [ { path: '/maps-review', label: '战略回顾会', icon: 'TrendCharts', roles: ['ceo', 'finance'], 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: '/mpm-calculator', label: 'MPM计算器', icon: 'Money', roles: ['ceo', 'finance', 'business'], group: '🔴 A 复盘与改进' }, // ══════════════════════════════════════════════════════════════ // GROUP 5: 系统与支持(Infra) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 9192210d..937bd3ee 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -37,6 +37,7 @@ const routes = [ { 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'] } }, + { path: 'mpm-calculator', name: 'MpmCalculator', component: () => import('@/views/MpmCalculator.vue'), meta: { title: 'MPM计算器', roles: ['ceo', 'finance', 'business'] } }, ] }, ] diff --git a/frontend/src/views/MpmCalculator.vue b/frontend/src/views/MpmCalculator.vue new file mode 100644 index 00000000..634ddce7 --- /dev/null +++ b/frontend/src/views/MpmCalculator.vue @@ -0,0 +1,370 @@ + + + + + diff --git a/frontend/src/views/ReportCenter.vue b/frontend/src/views/ReportCenter.vue index 4d8c84e6..b326554b 100644 --- a/frontend/src/views/ReportCenter.vue +++ b/frontend/src/views/ReportCenter.vue @@ -92,7 +92,7 @@ -
+
合计: @@ -102,9 +102,57 @@
+ +
+
新旧准则双列对比 — 旧准则(一行到底) vs 新准则(五板块)
+ +
+ +
+
旧准则格式
+ + + + + + + + +
+ + +
+
新30号准则格式
+
+
+
+ {{ block.short_name }} + {{ formatMoney(block.subtotal) }} +
+
+ {{ item.name }} + {{ item.effective != null ? formatMoney(item.effective) : '-' }} +
+
+
+
+ 净利润: + {{ formatMoney(dualComparisonData.new_format?.net_profit) }} +
+
+
+
+ @@ -357,20 +405,36 @@ const new30Blocks = ref([]) const new30NetProfit = ref(0) const new30Format = ref('new') const new30DemoMode = ref(false) +const dualComparisonData = ref(null) async function loadNew30() { loadingNew30.value = true + dualComparisonData.value = null try { - const r = await api.get('/reports/profit-statement', { - params: { period: reportPeriod.value, format: 'new' } - }) + const params: any = { period: reportPeriod.value } + 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 || {} - new30Blocks.value = d.blocks || [] - new30NetProfit.value = d.net_profit ?? 0 - new30DemoMode.value = !d.all_items_have_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 @@ -387,7 +451,7 @@ function formatMoney(val: number): string { function switchNew30Format(fmt: string) { new30Format.value = fmt - if (fmt === 'new') { + if (fmt === 'new' || fmt === 'dual') { loadNew30() } else { // 切换到旧准则tab @@ -710,4 +774,23 @@ onMounted(() => { .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; }