feat(proforma): 预编报表(预算版三张报表)P2 — 预算vs实际vs差异,独立/proforma端点,复用budget_plans+报表模板,无预算行显式标注
This commit is contained in:
+371
-2
@@ -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=无预算",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -219,6 +219,62 @@
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- ════ 报表3:KPI趋势报告 ════ -->
|
||||
<el-tab-pane label="📈 KPI趋势报告" name="trends">
|
||||
<div v-loading="loading">
|
||||
@@ -858,6 +914,73 @@ async function loadBudget() {
|
||||
} 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() {
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user