feat(proforma): 预编报表(预算版三张报表)P2 — 预算vs实际vs差异,独立/proforma端点,复用budget_plans+报表模板,无预算行显式标注

This commit is contained in:
Hermes CI Fix
2026-08-30 23:13:32 +08:00
parent db0f7aa591
commit 27d8269667
3 changed files with 711 additions and 3 deletions
+371 -2
View File
@@ -12,13 +12,14 @@ from fastapi import APIRouter, Depends, Query, HTTPException
import json import json
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func, or_
from typing import Optional from typing import Optional
from datetime import datetime, date from datetime import datetime, date
from app.database import get_db from app.database import get_db
from app.auth_middleware import require_role, require_auth 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.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 import logging
logger = logging.getLogger("cma.reports") logger = logging.getLogger("cma.reports")
@@ -2718,3 +2719,371 @@ def get_report_detail(
"json": r.json_content, "json": r.json_content,
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None, "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 查 KPIproforma 专用,带租户隔离,兼容历史 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=无预算",
}
+212
View File
@@ -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.45F_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 同 periodproforma 差异与 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
+128 -1
View File
@@ -219,6 +219,62 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<!-- 预编报表预算版三张报表 -->
<el-tab-pane label="📋 预编报表" name="proforma">
<div v-loading="loadingProforma">
<div class="report-desc">预算版三张报表预算 vs 实际 vs 差异口径与预算执行报告一致无预算映射的行显式标注不填示例数据</div>
<div style="display:flex;gap:8px;margin:12px 0;align-items:center;">
<el-radio-group v-model="proformaType" size="small" @change="loadProforma">
<el-radio-button value="profit">预算版利润表</el-radio-button>
<el-radio-button value="balance">预算版资产负债表</el-radio-button>
<el-radio-button value="cash">预算版现金流量表</el-radio-button>
</el-radio-group>
<el-tag v-if="proformaBudgetVersion" size="small" type="info">预算版本: {{ proformaBudgetVersion }}</el-tag>
</div>
<el-table :data="proformaRows" border stripe size="small" style="width:100%;" :row-class-name="proformaRowClass">
<el-table-column prop="name" label="项目" min-width="180" />
<el-table-column label="预算值" width="130" align="right">
<template #default="{ row }">{{ row.budget_value != null ? row.budget_value.toLocaleString() : (row.has_budget ? '-' : '—') }}</template>
</el-table-column>
<el-table-column label="实际值" width="130" align="right">
<template #default="{ row }">{{ row.actual_value != null ? row.actual_value.toLocaleString() : '-' }}</template>
</el-table-column>
<el-table-column label="差异" width="140" align="right">
<template #default="{ row }">
<span v-if="row.subtotal" style="color:#909399;">小计</span>
<span v-else-if="row.ratio_kpi" style="color:#909399;">比率型</span>
<span v-else-if="!row.has_budget" style="color:#bbb;">无预算</span>
<span v-else :style="{ color: row.deviation_amount > 0 ? '#f56c6c' : row.deviation_amount < 0 ? '#67c23a' : '#999' }">
{{ row.deviation_amount != null ? (row.deviation_amount > 0 ? '+' : '') + row.deviation_amount.toLocaleString() : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="差异率" width="110" align="right">
<template #default="{ row }">
<span v-if="row.subtotal" style="color:#909399;"></span>
<el-tag v-else-if="row.ratio_kpi" type="info" size="small">比率型</el-tag>
<span v-else-if="!row.has_budget" style="color:#bbb;">无预算</span>
<el-tag v-else-if="row.deviation_rate != null" :type="proformaRateType(row.deviation_rate)" size="small">
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate.toFixed(1) }}%
</el-tag>
<span v-else style="color:#ccc;">-</span>
</template>
</el-table-column>
<el-table-column label="预算来源" width="110">
<template #default="{ row }">
<el-tag v-if="row.has_budget && !row.subtotal" :type="row.budget_source === 'budget_plan' ? 'success' : 'warning'" size="small">
{{ row.budget_source === 'budget_plan' ? '预算方案' : row.budget_source === 'target_split' ? '目标分摊' : '—' }}
</el-tag>
<span v-else style="color:#bbb;"></span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="200" show-overflow-tooltip>
<template #default="{ row }"><span style="color:#909399;">{{ row.note || '' }}</span></template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- 报表3KPI趋势报告 --> <!-- 报表3KPI趋势报告 -->
<el-tab-pane label="📈 KPI趋势报告" name="trends"> <el-tab-pane label="📈 KPI趋势报告" name="trends">
<div v-loading="loading"> <div v-loading="loading">
@@ -858,6 +914,73 @@ async function loadBudget() {
} catch { budgetItems.value = [] } } catch { budgetItems.value = [] }
} }
//
const loadingProforma = ref(false)
const proformaType = ref('profit')
const proformaData = ref<any>(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<string, string> = {
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() { async function loadTrends() {
try { try {
const params: any = { months: trendMonths.value, entity_id: getEntityId() } const params: any = { months: trendMonths.value, entity_id: getEntityId() }
@@ -885,7 +1008,7 @@ async function loadKpiOptions() {
function loadAll() { function loadAll() {
loading.value = true 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() { function openDupont() {
@@ -954,6 +1077,10 @@ onMounted(() => {
.report-tabs { margin-bottom: 20px; } .report-tabs { margin-bottom: 20px; }
.report-desc { font-size: 13px; color: #888; padding: 8px 0; } .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; } .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; } .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; }