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=无预算",
}