From ec6af751a518a990847474372e1b831f6aca435e Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Wed, 22 Jul 2026 00:01:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(new30-p1):=20=E8=BF=BD=E6=BA=AF=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E6=95=B0=E6=8D=AE=E5=87=86=E5=A4=87+KPI=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 GET /api/cma/reports/restatement 端点 - 旧口径vs新口径双列对比 - 自动标记需调整项(管理费用剥离研发费用、财务费用拆解) - 返回净利润对比汇总 - 前端报表中心新增"追溯调整"Tab - 双列表格展示(旧准则/新准则) - 差异自动高亮标记 - 调整原因列、汇总统计 - KPI口径更新(entity_id=1) - F_COST_RATIO 费用率: (销售费用+管理费用-研发费用)/营业收入×100%, target=15% - 新增 F_OP_PROFIT_MARGIN 经营利润率: 经营利润/营业收入×100%, target=5% - 构建部署验证通过 --- backend/app/api/reports.py | 313 ++++++++++++++++++++++++++++ frontend/src/views/ReportCenter.vue | 148 ++++++++++++- 2 files changed, 460 insertions(+), 1 deletion(-) diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index 7507fdb1..08047222 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -650,6 +650,319 @@ def _get_demo_block_total(block_key: str) -> float: return demo.get(block_key, 0) +# ============================================================ +# 追溯调整: 2026年数据按新30号准则重述 (P1) +# ============================================================ + + +@router.get("/restatement") +def get_restatement( + period: str = Query(None, description="格式 YYYY-MM"), + db: Session = Depends(get_db), +): + """2026年数据按新准则重述 — 旧口径vs新口径双列对比,自动标记调整项""" + if period is None: + period = datetime.now().strftime("%Y-%m") + + # 旧口径数据 (传统利润表项目) + def _old_kpi_val(code: str): + 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 + + old_revenue = _old_kpi_val("F_REVENUE") + old_revenue_other = _old_kpi_val("F_REVENUE_OTHER") + old_cost = _old_kpi_val("F_COST") + old_cost_other = _old_kpi_val("F_COST_OTHER") + old_selling_exp = _old_kpi_val("F_SELLING_EXP") + old_admin_exp = _old_kpi_val("F_ADMIN_EXP") + old_finance_exp = _old_kpi_val("F_FINANCE_EXP") + old_rd_exp = _old_kpi_val("F_RD_EXP") + + # 新口径数据 (从科目映射或kpi_values获取) + new_revenue = _get_subject_amount(db, "6001", period) + new_revenue_other = _get_subject_amount(db, "6051", period) + new_cost = _get_subject_amount(db, "6401", period) + new_cost_other = _get_subject_amount(db, "6402", period) + new_selling = _get_subject_amount(db, "6601", period) + new_admin = _get_subject_amount(db, "6602", period) + new_rd = _get_subject_amount(db, "660204", period) + new_interest_income = _get_subject_amount(db, "6011", period) + new_interest_exp = _get_subject_amount(db, "660301", period) + new_fx = _get_subject_amount(db, "6603", period) + new_fx_financing = _get_subject_amount(db, "660302", period) + new_invest_income = _get_subject_amount(db, "6111", period) + new_impairment = _get_subject_amount(db, "6701", period) + new_invest_impairment = _get_subject_amount(db, "670101", period) + new_tax = _get_subject_amount(db, "6801", period) + new_discontinued = _get_subject_amount(db, "6901", period) + + # 旧口径汇总计算 + old_operating_items = [ + {"name": "营业收入", "old_value": old_revenue, "category": "operating"}, + {"name": "其他业务收入", "old_value": old_revenue_other, "category": "operating"}, + {"name": "减:营业成本", "old_value": old_cost, "category": "operating"}, + {"name": "减:其他业务成本", "old_value": old_cost_other, "category": "operating"}, + {"name": "减:销售费用", "old_value": old_selling_exp, "category": "operating"}, + {"name": "减:管理费用", "old_value": old_admin_exp, "category": "operating"}, + {"name": "减:财务费用", "old_value": old_finance_exp, "category": "financing"}, + ] + + # 新口径项目明细 + new_items_map = [ + # 经营类 + {"name": "营业收入", "new_value": new_revenue, "category": "operating"}, + {"name": "其他业务收入", "new_value": new_revenue_other, "category": "operating"}, + {"name": "减:营业成本", "new_value": new_cost, "category": "operating"}, + {"name": "减:其他业务成本", "new_value": new_cost_other, "category": "operating"}, + {"name": "减:销售费用", "new_value": new_selling, "category": "operating"}, + {"name": "减:管理费用(不含研发)", "new_value": new_admin, "category": "operating"}, + {"name": "减:研发费用", "new_value": new_rd, "category": "operating"}, + {"name": "减:经营资产减值损失", "new_value": new_impairment, "category": "operating"}, + {"name": "经营汇兑损益", "new_value": new_fx, "category": "operating"}, + # 投资类 + {"name": "利息收入", "new_value": new_interest_income, "category": "investing"}, + {"name": "投资收益", "new_value": new_invest_income, "category": "investing"}, + {"name": "减:投资类资产减值", "new_value": new_invest_impairment, "category": "investing"}, + # 筹资类 + {"name": "减:利息支出", "new_value": new_interest_exp, "category": "financing"}, + {"name": "筹资汇兑损益", "new_value": new_fx_financing, "category": "financing"}, + # 所得税 + {"name": "减:所得税费用", "new_value": new_tax, "category": "tax"}, + # 终止经营 + {"name": "终止经营损益", "new_value": new_discontinued, "category": "discontinued"}, + ] + + # 构建对比行 + items = [] + adjusted_count = 0 + new_count = 0 + unchanged_count = 0 + + # 先处理旧口径中存在的项目,匹配新口径 + old_new_mapping = { + "营业收入": "营业收入", + "其他业务收入": "其他业务收入", + "减:营业成本": "减:营业成本", + "减:其他业务成本": "减:其他业务成本", + "减:销售费用": "减:销售费用", + "减:管理费用": "减:管理费用(不含研发)", + } + + for old_item in old_operating_items: + name = old_item["name"] + old_val = old_item["old_value"] + new_name = old_new_mapping.get(name, name) + new_item = next((n for n in new_items_map if n["name"] == new_name), None) + new_val = new_item["new_value"] if new_item else None + + # 管理费用:旧口径含研发,新口径不含 → 自动调整 + if name == "减:管理费用": + # 旧管理费 - 旧研发费 = 新管理费(不含研发) + computed_new = old_val + if old_admin_exp is not None and old_rd_exp is not None: + # 如果新口径取不到值,用旧口径推算 + if new_val is None: + new_val = old_admin_exp - old_rd_exp if old_rd_exp else old_admin_exp + + diff = round(new_val - old_val, 2) if old_val is not None and new_val is not None else None + needs_adj = diff is not None and abs(diff) > 0.01 + if needs_adj: + adjusted_count += 1 + items.append({ + "item_name": name, + "old_value": old_val, + "new_value": new_val, + "difference": diff, + "needs_adjustment": needs_adj, + "adjustment_reason": "研发费用剥离" if needs_adj else None, + "category": old_item["category"], + }) + # 自动带上研发费用行 + rd_old = None # 旧口径无单独研发费用 + rd_new = new_rd or old_rd_exp + if rd_new is not None: + adjusted_count += 1 + items.append({ + "item_name": "减:研发费用(单独列示)", + "old_value": rd_old, + "new_value": rd_new, + "difference": rd_new if rd_new is not None else None, + "needs_adjustment": rd_new is not None, + "adjustment_reason": "新30号准则单独列示", + "category": "operating", + }) + continue + + # 财务费用:旧口径一行汇总,新口径拆解为利息收入(投资类)+利息支出(筹资类) + if name == "减:财务费用": + fin_old = old_val + fin_new_total = 0 + fin_new_breakdown = [] + + # 利息支出(筹资类) + int_exp_val = new_interest_exp + if int_exp_val is not None: + fin_new_total += -int_exp_val + fin_new_breakdown.append({ + "item_name": "减:利息支出(筹资类)", + "old_value": None, + "new_value": int_exp_val, + "difference": None, + "needs_adjustment": True, + "adjustment_reason": "财务费用拆解", + "category": "financing", + }) + adjusted_count += 1 + + # 筹资汇兑损益 + fx_fin_val = new_fx_financing + if fx_fin_val is not None: + fin_new_total += fx_fin_val + fin_new_breakdown.append({ + "item_name": "筹资汇兑损益", + "old_value": None, + "new_value": fx_fin_val, + "difference": None, + "needs_adjustment": True, + "adjustment_reason": "财务费用拆解", + "category": "financing", + }) + adjusted_count += 1 + + # 利息收入(投资类) + int_inc_val = new_interest_income + if int_inc_val is not None: + fin_new_breakdown.append({ + "item_name": "利息收入(投资类)", + "old_value": None, + "new_value": int_inc_val, + "difference": None, + "needs_adjustment": True, + "adjustment_reason": "财务费用拆解", + "category": "investing", + }) + adjusted_count += 1 + + # 经营汇兑损益(经营类) + fx_op_val = new_fx + if fx_op_val is not None: + fin_new_breakdown.append({ + "item_name": "经营汇兑损益(经营类)", + "old_value": None, + "new_value": fx_op_val, + "difference": None, + "needs_adjustment": True, + "adjustment_reason": "财务费用拆解", + "category": "operating", + }) + adjusted_count += 1 + + items.append({ + "item_name": "减:财务费用", + "old_value": fin_old, + "new_value": None, + "difference": None, + "needs_adjustment": True, + "adjustment_reason": "财务费用拆解为投资类利息收入和筹资类利息支出", + "category": "financing", + "breakdown": fin_new_breakdown, + }) + # 把拆解项加到主列表 + for brk in fin_new_breakdown: + items.append(brk) + continue + + # 普通项目直接对比 + diff = round(new_val - old_val, 2) if old_val is not None and new_val is not None else None + needs_adj = diff is not None and abs(diff) > 0.01 + if needs_adj: + adjusted_count += 1 + else: + unchanged_count += 1 + + items.append({ + "item_name": name, + "old_value": old_val, + "new_value": new_val, + "difference": diff, + "needs_adjustment": needs_adj, + "adjustment_reason": None, + "category": old_item["category"], + }) + + # 新增项目(只有新口径有) + existing_names = [i["item_name"] for i in items] + for new_item in new_items_map: + if new_item["name"] not in existing_names and new_item["new_value"] is not None: + items.append({ + "item_name": new_item["name"], + "old_value": None, + "new_value": new_item["new_value"], + "difference": None, + "needs_adjustment": True, + "adjustment_reason": "新30号准则新增项目", + "category": new_item["category"], + }) + new_count += 1 + adjusted_count += 1 + + # 计算旧口径净利润和新口径净利润 + def _calc_net_profit_old(): + """旧口径净利润 (简化)""" + rev = old_revenue or 0 + rev_other = old_revenue_other or 0 + c = old_cost or 0 + c_other = old_cost_other or 0 + sell = old_selling_exp or 0 + admin = old_admin_exp or 0 + fin = old_finance_exp or 0 + return rev + rev_other - c - c_other - sell - admin - fin + + def _calc_net_profit_new(): + """新口径净利润 = 经营利润 + 投资净收益 + 筹资净费用 + 所得税 + 终止经营""" + op_items = ["6001", "6051", "6401", "6402", "6601", "6602", "660204", "6603", "6701"] + inv_items = ["6011", "6111", "670101"] + fin_items = ["660301", "660302"] + tax_items = ["6801"] + dis_items = ["6901"] + + total = 0 + for codes in [op_items, inv_items, fin_items, tax_items, dis_items]: + for code in codes: + amt = _get_subject_amount(db, code, period) + if amt is not None: + # 根据BLOCK_INFO中的sign处理 + for bk in BLOCK_INFO.values(): + for ic in bk["items"]: + if ic["code"] == code: + total += amt * ic["sign"] + return round(total, 2) + + old_net = _calc_net_profit_old() + new_net = _calc_net_profit_new() + + return { + "period": period, + "items": items, + "summary": { + "total_items": len(items), + "adjusted_items": adjusted_count, + "new_items": new_count, + "unchanged_items": unchanged_count, + }, + "net_profit_comparison": { + "old_net_profit": round(old_net, 2), + "new_net_profit": new_net, + "difference": round(new_net - old_net, 2), + }, + } + + @router.get("/category-map") def get_category_map( db: Session = Depends(get_db), diff --git a/frontend/src/views/ReportCenter.vue b/frontend/src/views/ReportCenter.vue index 3df7c980..4d8c84e6 100644 --- a/frontend/src/views/ReportCenter.vue +++ b/frontend/src/views/ReportCenter.vue @@ -235,6 +235,83 @@ + + + +
+
2026年数据按新30号准则重述 — 旧口径vs新口径双列对比,差异自动高亮。
+ + +
+
+ 旧口径净利润: + {{ formatMoney(restatementNetProfit.old_net_profit) }} + + 新口径净利润: + {{ formatMoney(restatementNetProfit.new_net_profit) }} + + 差异:{{ restatementNetProfit.difference > 0 ? '+' : '' }}{{ formatMoney(restatementNetProfit.difference) }} + +
+
+ + +
+
{{ restatementSummary.total_items }} 总项目
+
{{ restatementSummary.adjusted_items }} 需调整
+
{{ restatementSummary.new_items }} 新增
+
{{ restatementSummary.unchanged_items }} 不变
+
+ + + + + + + + + + + + + + + + + + + + +
+ 旧口径 + 新口径 + 需调整项 +
+
+
@@ -347,6 +424,41 @@ function setTrendChartRef(id: number, el: any) { // ── 报表4:BSC评分卡 ── const bscData = ref({}) +// ── 追溯调整:2026年数据按新30号准则重述 ── +const loadingRestatement = ref(false) +const restatementItems = ref([]) +const restatementSummary = ref(null) +const restatementNetProfit = ref(null) + +async function loadRestatement() { + loadingRestatement.value = true + try { + const r = await api.get('/reports/restatement', { params: { period: reportPeriod.value } }) + 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: '财务维度' }, @@ -417,7 +529,7 @@ async function loadKpiOptions() { function loadAll() { loading.value = true - Promise.all([loadProfit(), loadNew30(), loadBudget(), loadTrends(), loadBsc()]).finally(() => { loading.value = false }) + Promise.all([loadProfit(), loadNew30(), loadBudget(), loadTrends(), loadBsc(), loadRestatement()]).finally(() => { loading.value = false }) } function openDupont() { @@ -564,4 +676,38 @@ onMounted(() => { .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; }