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
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 查 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