From 27d8269667cf84862c34f1cf211249b00c5bdadd Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Sun, 30 Aug 2026 23:13:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(proforma):=20=E9=A2=84=E7=BC=96=E6=8A=A5?= =?UTF-8?q?=E8=A1=A8=EF=BC=88=E9=A2=84=E7=AE=97=E7=89=88=E4=B8=89=E5=BC=A0?= =?UTF-8?q?=E6=8A=A5=E8=A1=A8=EF=BC=89P2=20=E2=80=94=20=E9=A2=84=E7=AE=97v?= =?UTF-8?q?s=E5=AE=9E=E9=99=85vs=E5=B7=AE=E5=BC=82=EF=BC=8C=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B/proforma=E7=AB=AF=E7=82=B9=EF=BC=8C=E5=A4=8D=E7=94=A8?= =?UTF-8?q?budget=5Fplans+=E6=8A=A5=E8=A1=A8=E6=A8=A1=E6=9D=BF=EF=BC=8C?= =?UTF-8?q?=E6=97=A0=E9=A2=84=E7=AE=97=E8=A1=8C=E6=98=BE=E5=BC=8F=E6=A0=87?= =?UTF-8?q?=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/reports.py | 373 ++++++++++++++++++++++++- backend/tests/test_proforma_reports.py | 212 ++++++++++++++ frontend/src/views/ReportCenter.vue | 129 ++++++++- 3 files changed, 711 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_proforma_reports.py diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index 22469d09..4cfd23b4 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -12,13 +12,14 @@ from fastapi import APIRouter, Depends, Query, HTTPException import json from pydantic import BaseModel from sqlalchemy.orm import Session -from sqlalchemy import func +from sqlalchemy import func, or_ from typing import Optional from datetime import datetime, date from app.database import get_db from app.auth_middleware import require_role, require_auth from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User, Subject, ActionPlan, OperationLog, ReportHistory -from app.utils.deviation_engine import calc_period_deviation, calc_period_diff +from app.utils.deviation_engine import calc_period_deviation, calc_period_diff, calc_deviation, get_budget_for_kpi +from app.deps import get_entity_id import logging logger = logging.getLogger("cma.reports") @@ -2718,3 +2719,371 @@ def get_report_detail( "json": r.json_content, "created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None, } + + +# ============================================================ +# 预编报表(预算版三张报表)— P2 2026-08-30 +# 用 budget_plans 预算数据 + 现有报表模板,生成预算版 +# 利润表 / 资产负债表 / 现金流量表,供高层拍板预算方案。 +# 差异口径与 budget-execution 一致(calc_deviation: 实际-预算)。 +# 无预算映射的行 has_budget=false 显式标注,不静默丢弃、不塞 demo 数据。 +# ============================================================ + +# 利润表科目编码 → KPI 编码(与 _get_subject_amount 内 kpi_code_map 一致) +PROFIT_SUBJECT_KPI_MAP = { + "6001": "F_REVENUE", + "6051": "F_REVENUE_OTHER", + "6401": "F_COST", + "6402": "F_COST_OTHER", + "6601": "F_SELLING_EXP", + "6602": "F_ADMIN_EXP", + "660204": "F_RD_EXP", + "6603": "F_FINANCE_EXP", + "6701": "F_IMPAIRMENT_LOSS", + "6011": "F_INTEREST_INCOME", + "6111": "F_INVEST_INCOME", + "611101": "F_INVEST_INCOME", + "660301": "F_INTEREST_EXP", + "660302": "F_FX_LOSS", + "6801": "F_TAX_EXP", + "6901": "F_DISCONTINUED", +} + +# 资产负债表行项目(科目组合 key 以 "|" 连接,与 _bs_line_amount 一致)→ KPI 映射 +# ratio_kpi=true 表示该KPI为比率/天数型,预算值与金额不可直接比较,需单独展示 +BALANCE_SHEET_PROFORMA_MAP = { + "1001|1002|1012": { + "kpi_code": "F_OP_CFLOW", "ratio_kpi": False, + "note": "货币资金以经营性现金流预算近似(无直接科目预算)", + }, + "1122": { + "kpi_code": "F_AR_DAYS", "ratio_kpi": True, + "note": "比率型KPI(应收账款周转天数),预算为天数指标,与金额不可直接比较,需单独展示", + }, +} + +# 现金流量表行项目 → KPI 映射(CF行无直接预算,用金额KPI近似;经营净额走 F_OP_CFLOW) +CASH_FLOW_PROFORMA_MAP = { + "CF01": {"kpi_code": "F_REVENUE", "note": "销售商品收到的现金以营业收入预算近似"}, + "CF04": {"kpi_code": "F_COST", "note": "购买商品支付的现金以营业成本预算近似"}, + "CF05": {"kpi_code": "F_ADMIN_EXP", "note": "支付给职工的现金以管理费用预算近似"}, + "CF06": {"kpi_code": "F_TAX_EXP", "note": "支付的各项税费以所得税费用预算近似"}, +} + + +def _find_kpi_by_code(db: Session, kpi_code: Optional[str], entity_id: int): + """按 entity + kpi_code 查 KPI(proforma 专用,带租户隔离,兼容历史 NULL entity 行)""" + if not kpi_code: + return None + return db.query(KPIDefinition).filter( + or_(KPIDefinition.entity_id == entity_id, KPIDefinition.entity_id.is_(None)), + KPIDefinition.kpi_code == kpi_code, + ).first() + + +def _proforma_budget(db: Session, kpi_id: int, period: str, version: Optional[str] = None): + """预编报表预算取数:budget_plan → target_split → none + + 与 calc_period_deviation 口径一致(无预算时用 KPI 目标值按月分摊)。 + 返回 (budget_value, budget_source, budget_version) + """ + budget = get_budget_for_kpi(db, kpi_id, period, version) + if budget is not None: + query = db.query(BudgetPlan).filter( + BudgetPlan.kpi_id == kpi_id, + BudgetPlan.period == period, + BudgetPlan.status == "active", + ) + if version: + query = query.filter(BudgetPlan.version == version) + plan = query.order_by(BudgetPlan.updated_at.desc()).first() + return round(float(budget), 2), "budget_plan", (plan.version if plan else version) + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + if kpi: + try: + month = int(period.split("-")[1]) + except Exception: + month = 1 + target = kpi.target_value + if target and target > 0 and kpi.frequency == "monthly": + return round(float(target) / 12, 2), "target_split", None + return None, "none", None + + +def _proforma_deviation(actual: Optional[float], budget: Optional[float], ratio_kpi: bool = False) -> dict: + """差异三列 — 口径与 calc_period_deviation 一致(实际-预算,实际为空不计算); + 比率型KPI不计算金额差异""" + if ratio_kpi or actual is None: + return {"deviation_amount": None, "deviation_rate": None, "is_over_budget": None} + return calc_deviation(actual, budget) + + +def _proforma_cf_actual(db: Session, line: dict, period: str) -> Optional[float]: + """现金流量表行项目实际值 — 真实数据优先(KPI → 凭证),不塞 demo 数据""" + if line.get("kpi_code"): + v = _get_kpi_val(db, line["kpi_code"], period) + if v is not None: + return float(v) + v = _get_bs_amount(db, [(line["code"], line["sign"])], period) + if v is not None: + return float(v) + return None + + +def _proforma_versions(found_versions: set): + """budget_version 输出:单一版本→字符串,多版本→列表,无→None""" + if not found_versions: + return None + vs = sorted(found_versions) + return vs[0] if len(vs) == 1 else vs + + +@router.get("/proforma/profit-statement") +def get_proforma_profit_statement( + period: str = Query(None, description="格式 YYYY-MM"), + version: Optional[str] = Query(None, description="预算版本,默认取最新active"), + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """预算版利润表 — 新30号准则五板块结构,每行叠加 预算值/实际值/差异""" + if period is None: + period = datetime.now().strftime("%Y-%m") + + blocks = [] + net_actual = net_budget = 0.0 + found_versions = set() + + for block_key in ["operating", "investing", "financing", "tax", "discontinued"]: + block_cfg = BLOCK_INFO[block_key] + items = [] + block_actual = block_budget = 0.0 + block_has_budget = False + for item_cfg in block_cfg["items"]: + code = item_cfg["code"] + kpi_code = PROFIT_SUBJECT_KPI_MAP.get(code) + actual = _get_subject_amount(db, code, period) + budget, source, ver = None, "none", None + kpi = _find_kpi_by_code(db, kpi_code, entity_id) if kpi_code else None + if kpi: + budget, source, ver = _proforma_budget(db, kpi.id, period, version) + has_budget = budget is not None + if has_budget: + block_has_budget = True + if ver: + found_versions.add(ver) + if actual is not None: + block_actual += actual * item_cfg["sign"] + if budget is not None: + block_budget += budget * item_cfg["sign"] + dev = _proforma_deviation(actual, budget) + items.append({ + "code": code, + "name": item_cfg["name"], + "sign": item_cfg["sign"], + "actual_value": round(actual, 2) if actual is not None else None, + "budget_value": budget, + "deviation_amount": dev.get("deviation_amount"), + "deviation_rate": dev.get("deviation_rate"), + "has_budget": has_budget, + "mapped_kpi_code": kpi_code, + "budget_source": source, + "ratio_kpi": False, + "note": None, + }) + blocks.append({ + "key": block_key, + "name": block_cfg["name"], + "short_name": block_cfg["short_name"], + "subtotal_actual": round(block_actual, 2), + "subtotal_budget": round(block_budget, 2), + "subtotal_name": block_cfg["result_name"], + "has_budget": block_has_budget, + "items": items, + }) + net_actual += block_actual + net_budget += block_budget + + return { + "period": period, + "budget_version": _proforma_versions(found_versions), + "requested_version": version, + "title": f"预算版利润表 — 新30号准则({period})", + "blocks": blocks, + "net_profit_actual": round(net_actual, 2), + "net_profit_budget": round(net_budget, 2), + "budget_source_hint": "budget_plan=预算方案 / target_split=KPI目标值按月分摊 / none=无预算", + } + + +@router.get("/proforma/balance-sheet") +def get_proforma_balance_sheet( + period: str = Query(None, description="格式 YYYY-MM"), + version: Optional[str] = Query(None, description="预算版本,默认取最新active"), + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """预算版资产负债表 — 复用 BALANCE_SHEET_SECTIONS,每行叠加 预算值/实际值/差异""" + if period is None: + period = datetime.now().strftime("%Y-%m") + + sections = [] + found_versions = set() + + for sec in BALANCE_SHEET_SECTIONS: + lines = [] + sec_actual = sec_budget = 0.0 + sec_has_budget = False + for line in sec["lines"]: + key = "|".join(c for c, _ in line["codes"]) + map_cfg = BALANCE_SHEET_PROFORMA_MAP.get(key) or {} + kpi_code = map_cfg.get("kpi_code") + ratio_kpi = map_cfg.get("ratio_kpi", False) + note = map_cfg.get("note") + # 实际值:真实凭证数据(预编报表不塞 demo 示例数据) + actual = _get_bs_amount(db, line["codes"], period) + budget, source, ver = None, "none", None + kpi = _find_kpi_by_code(db, kpi_code, entity_id) if kpi_code else None + if kpi: + budget, source, ver = _proforma_budget(db, kpi.id, period, version) + has_budget = budget is not None + if has_budget: + sec_has_budget = True + if ver: + found_versions.add(ver) + if actual is not None: + sec_actual += actual + if budget is not None and not ratio_kpi: + sec_budget += budget + dev = _proforma_deviation(actual, budget, ratio_kpi) + lines.append({ + "name": line["name"], + "ns_category": line["ns_category"], + "ns_category_label": BS_CATEGORY_CN.get(line["ns_category"], line["ns_category"]), + "actual_value": round(actual, 2) if actual is not None else None, + "budget_value": budget, + "deviation_amount": dev.get("deviation_amount"), + "deviation_rate": dev.get("deviation_rate"), + "has_budget": has_budget, + "mapped_kpi_code": kpi_code, + "ratio_kpi": ratio_kpi, + "budget_source": source, + "note": note, + }) + sections.append({ + "key": sec["key"], + "name": sec["name"], + "category_label": sec["category_label"], + "subtotal_actual": round(sec_actual, 2), + "subtotal_budget": round(sec_budget, 2), + "has_budget": sec_has_budget, + "lines": lines, + }) + + return { + "period": period, + "budget_version": _proforma_versions(found_versions), + "requested_version": version, + "title": f"预算版资产负债表({period})", + "sections": sections, + "budget_source_hint": "budget_plan=预算方案 / target_split=KPI目标值按月分摊 / none=无预算", + } + + +@router.get("/proforma/cash-flow") +def get_proforma_cash_flow( + period: str = Query(None, description="格式 YYYY-MM"), + version: Optional[str] = Query(None, description="预算版本,默认取最新active"), + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """预算版现金流量表 — 复用 CASH_FLOW_LINES,每行叠加 预算值/实际值/差异""" + if period is None: + period = datetime.now().strftime("%Y-%m") + + section_cfg = [ + {"key": "operating", "name": "一、经营活动产生的现金流量", "short": "经营活动"}, + {"key": "investing", "name": "二、投资活动产生的现金流量", "short": "投资活动"}, + {"key": "financing", "name": "三、筹资活动产生的现金流量", "short": "筹资活动"}, + ] + + sections = [] + found_versions = set() + + for sc in section_cfg: + lines = [] + subtotal_actual = subtotal_budget = 0.0 + sec_has_budget = False + for line in CASH_FLOW_LINES: + if line["section"] != sc["key"]: + continue + map_cfg = CASH_FLOW_PROFORMA_MAP.get(line["code"]) or {} + kpi_code = map_cfg.get("kpi_code") + note = map_cfg.get("note") + actual = _proforma_cf_actual(db, line, period) + budget, source, ver = None, "none", None + kpi = _find_kpi_by_code(db, kpi_code, entity_id) if kpi_code else None + if kpi: + budget, source, ver = _proforma_budget(db, kpi.id, period, version) + has_budget = budget is not None + if has_budget: + sec_has_budget = True + if ver: + found_versions.add(ver) + if actual is not None: + subtotal_actual += actual + if budget is not None: + subtotal_budget += budget + dev = _proforma_deviation(actual, budget) + lines.append({ + "code": line["code"], + "name": line["name"], + "actual_value": round(actual, 2) if actual is not None else None, + "budget_value": budget, + "deviation_amount": dev.get("deviation_amount"), + "deviation_rate": dev.get("deviation_rate"), + "has_budget": has_budget, + "mapped_kpi_code": kpi_code, + "ratio_kpi": False, + "budget_source": source, + "note": note, + }) + # 经营净额:优先取 F_OP_CFLOW(真实),预算取 F_OP_CFLOW 预算 + net_actual = subtotal_actual + net_budget = subtotal_budget + if sc["key"] == "operating": + op_kpi = _find_kpi_by_code(db, "F_OP_CFLOW", entity_id) + if op_kpi: + op_actual = _get_kpi_val(db, "F_OP_CFLOW", period) + if op_actual is not None: + net_actual = round(float(op_actual), 2) + op_budget, op_source, op_ver = _proforma_budget(db, op_kpi.id, period, version) + if op_budget is not None: + net_budget = op_budget + sec_has_budget = True + if op_ver: + found_versions.add(op_ver) + sections.append({ + "key": sc["key"], + "name": sc["name"], + "short": sc["short"], + "net_actual": round(net_actual, 2), + "net_budget": round(net_budget, 2), + "has_budget": sec_has_budget, + "lines": lines, + }) + + net_increase_actual = round(sum(s["net_actual"] for s in sections), 2) + net_increase_budget = round(sum(s["net_budget"] for s in sections), 2) + + return { + "period": period, + "budget_version": _proforma_versions(found_versions), + "requested_version": version, + "title": f"预算版现金流量表({period})", + "sections": sections, + "summary": { + "net_increase_actual": net_increase_actual, + "net_increase_budget": net_increase_budget, + }, + "budget_source_hint": "budget_plan=预算方案 / target_split=KPI目标值按月分摊 / none=无预算", + } diff --git a/backend/tests/test_proforma_reports.py b/backend/tests/test_proforma_reports.py new file mode 100644 index 00000000..67fa01ef --- /dev/null +++ b/backend/tests/test_proforma_reports.py @@ -0,0 +1,212 @@ +""" +预编报表(预算版三张报表)测试 — P2 2026-08-30 + +覆盖: + 1. 三张预算版报表接口 200 + 2. has_budget 标注正确(有预算行 true / 无预算映射行 false) + 3. 差异计算与 budget-execution 一致(同 KPI 同 period 对比) + 4. 无预算行显式标注(budget_source=none) + 5. 比率型KPI单独标注(ratio_kpi=true,不计算金额差异) +""" +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from tests.conftest import create_test_user, get_token_for_user, auth_header +from app.models import KPIDefinition, KPIValue, BudgetPlan + + +def _create_kpi(db: Session, code: str, name: str, entity_id: int = 1, frequency: str = "monthly", target_value=None) -> KPIDefinition: + kpi = KPIDefinition( + entity_id=entity_id, + kpi_code=code, + kpi_name=name, + dimension="finance", + category="financial_report", + formula="-", + data_source="测试", + data_owner="财务部", + frequency=frequency, + unit="元", + target_value=target_value, + kpi_level="operational", + status="active", + ) + db.add(kpi) + db.commit() + db.refresh(kpi) + return kpi + + +def _create_budget(db: Session, kpi_id: int, period: str, value: float, version: str = "v1.0") -> BudgetPlan: + plan = BudgetPlan( + entity_id=1, + kpi_id=kpi_id, + period=period, + budget_value=value, + budget_year=int(period.split("-")[0]), + budget_month=int(period.split("-")[1]), + version=version, + status="active", + ) + db.add(plan) + db.commit() + db.refresh(plan) + return plan + + +def _create_actual(db: Session, kpi_id: int, period: str, value: float) -> KPIValue: + v = KPIValue(kpi_id=kpi_id, period=period, actual_value=value, source_type="manual") + db.add(v) + db.commit() + db.refresh(v) + return v + + +class TestProformaProfitStatement: + BASE = "/api/cma/reports/proforma/profit-statement" + + def _setup(self, db: Session): + """F_REVENUE: 预算150 / 实际123.45;F_OP_CFLOW: 预算20""" + create_test_user(db) + rev = _create_kpi(db, "F_REVENUE", "营业收入", frequency="quarterly", target_value=1200) + op = _create_kpi(db, "F_OP_CFLOW", "经营性现金流") + _create_budget(db, rev.id, "2026-08", 150.0) + _create_budget(db, op.id, "2026-08", 20.0) + _create_actual(db, rev.id, "2026-08", 123.45) + + def test_returns_200_and_budget_mapping(self, client: TestClient, db: Session): + self._setup(db) + token = get_token_for_user(client) + resp = client.get(f"{self.BASE}?period=2026-08", headers=auth_header(token)) + assert resp.status_code == 200 + data = resp.json() + assert data["period"] == "2026-08" + assert data["budget_version"] == "v1.0" + + # 营业收入(6001) → F_REVENUE:预算150 实际123.45 差异-26.55/-17.7% + rev_line = None + for block in data["blocks"]: + for item in block["items"]: + if item["code"] == "6001": + rev_line = item + assert rev_line is not None, "利润表应含营业收入(6001)行" + assert rev_line["has_budget"] is True + assert rev_line["mapped_kpi_code"] == "F_REVENUE" + assert rev_line["budget_source"] == "budget_plan" + assert rev_line["budget_value"] == 150.0 + assert rev_line["actual_value"] == 123.45 + assert rev_line["deviation_amount"] == -26.55 + assert rev_line["deviation_rate"] == -17.7 + + def test_no_budget_line_explicit(self, client: TestClient, db: Session): + """无预算映射的行(如 6402 其他业务成本)显式 has_budget=false""" + self._setup(db) + token = get_token_for_user(client) + resp = client.get(f"{self.BASE}?period=2026-08", headers=auth_header(token)) + assert resp.status_code == 200 + data = resp.json() + line = None + for block in data["blocks"]: + for item in block["items"]: + if item["code"] == "6402": + line = item + assert line is not None + assert line["has_budget"] is False + assert line["budget_source"] == "none" + assert line["budget_value"] is None + + def test_deviation_matches_budget_execution(self, client: TestClient, db: Session): + """同 KPI 同 period:proforma 差异与 budget-execution 一致""" + self._setup(db) + token = get_token_for_user(client) + # budget-execution 里的 F_REVENUE + be = client.get("/api/cma/reports/budget-execution?period=2026-08", headers=auth_header(token)) + assert be.status_code == 200 + be_item = next(i for i in be.json()["items"] if i["kpi_code"] == "F_REVENUE") + # proforma 利润表 6001 行 + pf = client.get(f"{self.BASE}?period=2026-08", headers=auth_header(token)) + pf_item = None + for block in pf.json()["blocks"]: + for item in block["items"]: + if item["code"] == "6001": + pf_item = item + assert pf_item is not None + assert pf_item["actual_value"] == be_item["actual_value"] + assert pf_item["budget_value"] == be_item["budget_value"] + assert pf_item["deviation_amount"] == be_item["deviation_amount"] + assert pf_item["deviation_rate"] == be_item["deviation_rate"] + + +class TestProformaBalanceSheet: + BASE = "/api/cma/reports/proforma/balance-sheet" + + def _setup(self, db: Session): + create_test_user(db) + op = _create_kpi(db, "F_OP_CFLOW", "经营性现金流") + ar = _create_kpi(db, "F_AR_DAYS", "应收账款周转天数", frequency="monthly", target_value=5) + _create_budget(db, op.id, "2026-08", 20.0) + _create_budget(db, ar.id, "2026-08", 5.0) + + def test_returns_200_and_mappings(self, client: TestClient, db: Session): + self._setup(db) + token = get_token_for_user(client) + resp = client.get(f"{self.BASE}?period=2026-08", headers=auth_header(token)) + assert resp.status_code == 200 + data = resp.json() + assert data["budget_version"] == "v1.0" + + lines = {} + for sec in data["sections"]: + for ln in sec["lines"]: + lines[ln["name"]] = ln + + # 货币资金 → F_OP_CFLOW(有预算) + assert lines["货币资金"]["has_budget"] is True + assert lines["货币资金"]["mapped_kpi_code"] == "F_OP_CFLOW" + assert lines["货币资金"]["budget_value"] == 20.0 + # 应收账款 → F_AR_DAYS(比率型,单独标注,不计算金额差异) + assert lines["应收账款"]["has_budget"] is True + assert lines["应收账款"]["ratio_kpi"] is True + assert lines["应收账款"]["mapped_kpi_code"] == "F_AR_DAYS" + assert lines["应收账款"]["deviation_amount"] is None + assert lines["应收账款"]["note"] is not None + # 无映射行(存货 1405)显式无预算 + assert lines["存货"]["has_budget"] is False + assert lines["存货"]["budget_source"] == "none" + + +class TestProformaCashFlow: + BASE = "/api/cma/reports/proforma/cash-flow" + + def _setup(self, db: Session): + create_test_user(db) + rev = _create_kpi(db, "F_REVENUE", "营业收入", frequency="quarterly") + op = _create_kpi(db, "F_OP_CFLOW", "经营性现金流") + _create_budget(db, rev.id, "2026-08", 150.0) + _create_budget(db, op.id, "2026-08", 20.0) + _create_actual(db, rev.id, "2026-08", 123.45) + + def test_returns_200_and_mappings(self, client: TestClient, db: Session): + self._setup(db) + token = get_token_for_user(client) + resp = client.get(f"{self.BASE}?period=2026-08", headers=auth_header(token)) + assert resp.status_code == 200 + data = resp.json() + assert data["budget_version"] == "v1.0" + + lines = {} + sections = {s["key"]: s for s in data["sections"]} + for sc in data["sections"]: + for ln in sc["lines"]: + lines[ln["code"]] = ln + + # CF01 → F_REVENUE(有预算) + assert lines["CF01"]["has_budget"] is True + assert lines["CF01"]["mapped_kpi_code"] == "F_REVENUE" + assert lines["CF01"]["budget_value"] == 150.0 + # CF02 无映射 → 显式无预算 + assert lines["CF02"]["has_budget"] is False + assert lines["CF02"]["budget_source"] == "none" + # 经营净额 → F_OP_CFLOW 预算 + assert sections["operating"]["net_budget"] == 20.0 + assert sections["operating"]["has_budget"] is True diff --git a/frontend/src/views/ReportCenter.vue b/frontend/src/views/ReportCenter.vue index e22f128b..68ac9208 100644 --- a/frontend/src/views/ReportCenter.vue +++ b/frontend/src/views/ReportCenter.vue @@ -219,6 +219,62 @@ + + +
+
预算版三张报表:预算 vs 实际 vs 差异(口径与预算执行报告一致)。无预算映射的行显式标注,不填示例数据。
+
+ + 预算版利润表 + 预算版资产负债表 + 预算版现金流量表 + + 预算版本: {{ proformaBudgetVersion }} +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
@@ -858,6 +914,73 @@ async function loadBudget() { } catch { budgetItems.value = [] } } +// ── 预编报表(预算版三张报表)── +const loadingProforma = ref(false) +const proformaType = ref('profit') +const proformaData = ref(null) + +const proformaBudgetVersion = computed(() => { + const v = proformaData.value?.budget_version + return Array.isArray(v) ? v.join(', ') : (v || '') +}) + +const proformaRows = computed(() => { + const d = proformaData.value + if (!d) return [] + const rows: any[] = [] + if (proformaType.value === 'profit') { + for (const b of (d.blocks || [])) { + rows.push({ name: b.name, subtotal: true, actual_value: b.subtotal_actual, budget_value: b.subtotal_budget, has_budget: b.has_budget }) + for (const it of (b.items || [])) rows.push({ ...it, name: it.name }) + } + rows.push({ name: '= 净利润', subtotal: true, actual_value: d.net_profit_actual, budget_value: d.net_profit_budget, has_budget: true }) + } else if (proformaType.value === 'balance') { + for (const s of (d.sections || [])) { + rows.push({ name: s.name, subtotal: true, actual_value: s.subtotal_actual, budget_value: s.subtotal_budget, has_budget: s.has_budget }) + for (const ln of (s.lines || [])) rows.push({ ...ln, name: ln.name }) + } + } else { + for (const s of (d.sections || [])) { + rows.push({ name: s.name, subtotal: true, actual_value: s.net_actual, budget_value: s.net_budget, has_budget: s.has_budget }) + for (const ln of (s.lines || [])) rows.push({ ...ln, name: ln.name }) + } + const sm = d.summary || {} + rows.push({ name: '= 现金净增加额', subtotal: true, actual_value: sm.net_increase_actual, budget_value: sm.net_increase_budget, has_budget: true }) + } + return rows +}) + +async function loadProforma() { + loadingProforma.value = true + try { + const map: Record = { + profit: '/reports/proforma/profit-statement', + balance: '/reports/proforma/balance-sheet', + cash: '/reports/proforma/cash-flow', + } + const r = await api.get(map[proformaType.value], { params: { period: reportPeriod.value, entity_id: getEntityId() } }) + proformaData.value = (r as any).data || {} + } catch (e) { + proformaData.value = null + ElMessage.error('加载预编报表失败') + } finally { + loadingProforma.value = false + } +} + +function proformaRowClass({ row }: any) { + if (row.subtotal) return 'proforma-subtotal' + if (!row.has_budget) return 'proforma-no-budget' + return '' +} + +function proformaRateType(rate: number): string { + const abs = Math.abs(rate) + if (abs > 20) return 'danger' + if (abs > 10) return 'warning' + return 'success' +} + async function loadTrends() { try { const params: any = { months: trendMonths.value, entity_id: getEntityId() } @@ -885,7 +1008,7 @@ async function loadKpiOptions() { function loadAll() { loading.value = true - Promise.all([loadProfit(), loadNew30(), loadBudget(), loadTrends(), loadBsc(), loadRestatement()]).finally(() => { loading.value = false }) + Promise.all([loadProfit(), loadNew30(), loadBudget(), loadProforma(), loadTrends(), loadBsc(), loadRestatement()]).finally(() => { loading.value = false }) } function openDupont() { @@ -954,6 +1077,10 @@ onMounted(() => { .report-tabs { margin-bottom: 20px; } .report-desc { font-size: 13px; color: #888; padding: 8px 0; } +/* 预编报表(预算版) */ +:deep(.proforma-no-budget) { opacity: 0.55; } +:deep(.proforma-subtotal) { background: #f0f7ff !important; font-weight: 600; color: #1f2d3d; } + /* 统计卡片 */ .summary-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; } .stat-card { background: #fff; border-radius: 8px; padding: 12px 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border-top: 3px solid #ccc; }