feat(cma-report): 资产负债表取数层重分类 R1~R12(调表不调账)
ci/woodpecker/push/main Pipeline failed

依据《CMA 资产负债表重分类规则表 v1》(wecom-finance,任总 2026-09-16 批准落地)。

新增 backend/app/services/report_reclass.py:12 条规则挂 CMA 真实科目编码的取数层重分类引擎
- 余额方向规范化:资产 sum(借-贷) / 负债·权益 sum(贷-借),正常余额一律为正
- 触发判定全部基于原始余额(单遍、幂等),R3/R4 等互为镜像的规则互不干扰
- 科目取数前缀匹配(1122% 覆盖 112201 明细);备抵科目 1602 反向取数(修正固定资产净额虚增 2 倍折旧)
- 只读 voucher_details + 作废守卫,不生成记账凭证
- 输出:触发条目 / 不变量 / 负数清单 / 模板待核 / 未列报科目 / 损益未结转 诊断

reports.py:
- /balance-sheet 接入重分类;新增 basis 参数(end=期末余额累计为默认 / period=本期发生额兼容回退)
- 有凭证明细时不再混入示例数据(展示口径与落库口径同源);行项目增 raw_value/reclass_adjustment 可追溯
- 规则落位项目(模板未单列)按虚拟行列报;补「长期待摊费用(1801)」行、未分配利润补 3103/3104 别名
- 新增 /balance-sheet/reclass-working-paper(+ /export) 底稿 JSON/Excel;statutory 导出新增「重分类底稿」Sheet

测试 tests/test_report_reclass.py 14 例(触发/不变量/幂等/多租户/口径/底稿/虚拟行/阻塞规则)
全量 pytest:1108 passed, 1 xfailed。现网只读验收 scripts/verify_reclass_live_20260916.py(酣客 entity1 2026-08)
This commit is contained in:
Hermes CI Fix
2026-09-16 00:38:01 +08:00
parent 7b74736980
commit ba85e03f2f
4 changed files with 1333 additions and 8 deletions
+253 -8
View File
@@ -10,6 +10,7 @@ CMA管理报表中心 — 管理会计OS
"""
from fastapi import APIRouter, Depends, Query, HTTPException, Request
import json
import os
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
@@ -20,6 +21,7 @@ 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, calc_deviation, get_budget_for_kpi
from app.utils.voucher_guard import apply_voucher_guard
from app.services import report_reclass
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
import logging
@@ -1378,6 +1380,8 @@ BALANCE_SHEET_SECTIONS = [
{"codes": [("1501", 1)], "name": "持有至到期投资", "ns_category": "investing"},
{"codes": [("1601", 1), ("1602", -1)], "name": "固定资产净额", "ns_category": "operating"},
{"codes": [("1701", 1)], "name": "无形资产", "ns_category": "operating"},
# 长期待摊费用(1801):小企业准则项目池含该项,原模板漏列 → 致勾稽差额 128,758.24(2026-09-16 补)
{"codes": [("1801", 1)], "name": "长期待摊费用", "ns_category": "operating"},
],
},
{
@@ -1409,7 +1413,10 @@ BALANCE_SHEET_SECTIONS = [
"lines": [
{"codes": [("4001", 1)], "name": "实收资本", "ns_category": "equity"},
{"codes": [("4002", 1)], "name": "资本公积", "ns_category": "equity"},
{"codes": [("4103", 1), ("4104", 1)], "name": "未分配利润", "ns_category": "equity"},
# alias_codes:模板用 4103/4104,智享通凭证明细实际用 3103/3104(2026-09-16 补)
{"codes": [("4103", 1), ("4104", 1)],
"alias_codes": [("3103", 1), ("3104", 1)],
"name": "未分配利润", "ns_category": "equity"},
],
},
]
@@ -1463,7 +1470,10 @@ def _prev_period_str(period: str) -> str:
def _get_bs_amount(db: Session, codes: list, period: str, entity_id: int = 1) -> Optional[float]:
"""资产负债表科目余额 — 优先凭证明细,无数据返回 None(账套隔离 2026-08-31)"""
"""资产负债表科目余额 — 优先凭证明细,无数据返回 None(账套隔离 2026-08-31)
⚠️ 保留给现金流量表/预编报表使用(精确匹配 + 单期口径);资产负债表本体走
`app/services/report_reclass.py`(前缀匹配 + 期末余额口径 + 取数层重分类)。"""
total = 0.0
has_data = False
try:
@@ -1498,30 +1508,94 @@ def _bs_line_amount(db: Session, line: dict, period: str, column: str = "end", e
return {"value": None, "is_demo": True}
# ── 资产负债表取数口径(2026-09-16 重分类落地) ──
# end = 期末余额(累计至期间,含期初/结转)——资产负债表定义口径,**默认**
# period = 本期发生额(旧报表口径,兼容回退;可用 CMA_BS_BASIS_DEFAULT / ?basis= 切换)
_BS_BASIS_DEFAULT = os.getenv("CMA_BS_BASIS_DEFAULT", "end")
def _bs_basis(basis) -> str:
"""口径解析:兼容内部直调(未传值时 FastAPI 的 Query 默认对象会漏进来 → 取默认口径)"""
b = basis if isinstance(basis, str) else _BS_BASIS_DEFAULT
b = (b or "end").strip().lower()
return "period" if b == "period" else "end"
def _bs_analysis(db: Session, entity_id: int, period: str, basis: str, include_unmapped: bool = True) -> dict:
"""取数层重分类分析(规则表 §二 R1~R12;调表不调账,不生成凭证)"""
return report_reclass.analyze(
db, entity_id, period, basis=basis,
template_sections=BALANCE_SHEET_SECTIONS, include_unmapped=include_unmapped,
)
@router.get("/balance-sheet")
def get_balance_sheet(
period: str = Query(None, description="格式 YYYY-MM"),
basis: Optional[str] = Query(None, description="取数口径 end=期末余额(默认) / period=本期发生额"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""资产负债表 — 新30号准则科目分类(经营/投资/筹资),期末vs期初(账套隔离 2026-08-31)"""
"""资产负债表 — 取数层重分类(R1~R12,调表不调账)+ 新30号准则科目分类(账套隔离 2026-08-31)
· 余额方向:资产 Σ(借−贷) / 负债·权益 Σ(贷−借) → 正常余额一律为正数
· 往来·税费类反方向余额按规则转正(转入对方科目),报表不再出现负数项目
· 有凭证明细时**不再混入示例数据**(展示口径与落库口径同源)
"""
if period is None:
period = datetime.now().strftime("%Y-%m")
use_basis = _bs_basis(basis)
prev_period = _prev_period_str(period)
plan = _bs_analysis(db, entity_id, period, use_basis)
has_real = bool(plan["has_voucher_data"])
plan_prev = _bs_analysis(db, entity_id, prev_period, use_basis, include_unmapped=False) if has_real else None
def _line_value(pl: Optional[dict], key: str, col: str):
if pl is None:
return None
return pl["after"].get(key)
# 规则落位项目(模板未列报的目标项)→ 按板块挂载虚拟行
virtual: dict = {}
for key, item in plan["items"].items():
if item["template"] or item.get("blocked"):
continue
end_v = plan["after"].get(key)
begin_v = plan_prev["after"].get(key) if plan_prev else None
if not end_v and not begin_v:
continue
virtual.setdefault(item["section_key"], []).append({
"name": item["name"],
"item_key": key,
"ns_category": item.get("ns_category") or "operating",
"ns_category_label": BS_CATEGORY_CN.get(item.get("ns_category") or "operating", ""),
"end_value": round(float(end_v or 0), 2),
"begin_value": round(float(begin_v), 2) if begin_v is not None else None,
"is_demo": False,
"is_reclass_line": True,
"template_gap": True,
"note": item.get("note", ""),
})
sections = []
total_assets_end = total_assets_begin = 0
total_liab_end = total_liab_begin = 0
total_equity_end = total_equity_begin = 0
all_real = True
all_real = has_real
for sec in BALANCE_SHEET_SECTIONS:
lines = []
sec_end = sec_begin = 0.0
sec_real = False
for line in sec["lines"]:
end = _bs_line_amount(db, line, period, column="end", entity_id=entity_id)
begin = _bs_line_amount(db, line, prev_period, column="begin", entity_id=entity_id)
key = "|".join(c for c, _ in line["codes"])
if has_real:
end = {"value": _line_value(plan, key, "end"), "is_demo": False}
begin = {"value": _line_value(plan_prev, key, "begin"), "is_demo": False}
else:
end = _bs_line_amount(db, line, period, column="end", entity_id=entity_id)
begin = _bs_line_amount(db, line, prev_period, column="begin", entity_id=entity_id)
if end["is_demo"] or begin["is_demo"]:
all_real = False
if end["value"] is not None:
@@ -1529,15 +1603,32 @@ def get_balance_sheet(
sec_real = True
if begin["value"] is not None:
sec_begin += begin["value"]
raw_key = plan["raw"].get(key)
lines.append({
"name": line["name"],
"item_key": key,
"ns_category": line["ns_category"],
"ns_category_label": BS_CATEGORY_CN.get(line["ns_category"], line["ns_category"]),
"end_value": round(end["value"], 2) if end["value"] is not None else None,
"begin_value": round(begin["value"], 2) if begin["value"] is not None else None,
"is_demo": end["is_demo"] or begin["is_demo"],
"is_reclass_line": False,
"template_gap": False,
"raw_value": None if raw_key is None else round(float(raw_key), 2),
"reclass_adjustment": None if raw_key is None else round(
float(plan["after"].get(key) or 0) - float(raw_key), 2),
})
# 规则落位项目(虚拟行)
for vline in virtual.get(sec["key"], []):
if vline["end_value"] is not None and sec["key"] in (
"current_assets", "non_current_assets", "current_liabilities", "non_current_liabilities"):
sec_end += vline["end_value"]
if vline["begin_value"] is not None and sec["key"] in (
"current_assets", "non_current_assets", "current_liabilities", "non_current_liabilities"):
sec_begin += vline["begin_value"]
lines.append(vline)
# 归属汇总
if sec["key"] in ("current_assets", "non_current_assets"):
total_assets_end += sec_end
@@ -1559,9 +1650,17 @@ def get_balance_sheet(
"has_real_data": sec_real,
})
inv = plan["invariants"]
if has_real:
balanced = abs(total_assets_end - total_liab_end - total_equity_end) < 0.01 and \
abs(total_assets_begin - total_liab_begin - total_equity_begin) < 0.01
else:
balanced = abs(total_assets_end - total_liab_end - total_equity_end) < 0.01
return {
"period": period,
"prev_period": prev_period,
"basis": use_basis,
"title": f"资产负债表 — 新30号准则({period})",
"sections": sections,
"totals": {
@@ -1570,13 +1669,124 @@ def get_balance_sheet(
"equity": {"end": round(total_equity_end, 2), "begin": round(total_equity_begin, 2)},
"liab_equity": {"end": round(total_liab_end + total_equity_end, 2),
"begin": round(total_liab_begin + total_equity_begin, 2)},
"balanced": abs(total_assets_end - total_liab_end - total_equity_end) < 0.01
and abs(total_assets_begin - total_liab_begin - total_equity_begin) < 0.01,
"balanced": balanced,
"identity_gap": round(total_assets_end - total_liab_end - total_equity_end, 2),
},
# ── 重分类(调表不调账)──
"reclass": {
"applied": bool(plan["entries"]),
"rule_version": plan["rule_version"],
"basis": plan["basis"],
"has_voucher_data": has_real,
"entries": plan["entries"],
"invariants": inv,
"negatives": plan["negatives"],
"template_gaps": plan["template_gaps"],
"unmapped_subjects": plan["unmapped_subjects"],
"pl_unclosed": plan["pl_unclosed"],
"working_paper_url": f"/api/cma/reports/balance-sheet/reclass-working-paper?period={period}&basis={use_basis}",
},
"all_items_have_data": all_real,
}
@router.get("/balance-sheet/reclass-working-paper")
def get_reclass_working_paper(
period: str = Query(None, description="格式 YYYY-MM"),
basis: Optional[str] = Query(None, description="取数口径 end=期末余额(默认) / period=本期发生额"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""重分类底稿(审计可追溯)— 源科目/余额/转正额/目标项 + 不变量 + 诊断(账套隔离)"""
if period is None:
period = datetime.now().strftime("%Y-%m")
use_basis = _bs_basis(basis)
plan = _bs_analysis(db, entity_id, period, use_basis)
return {
"period": period,
"basis": use_basis,
"entity_id": entity_id,
"rule_version": plan["rule_version"],
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"accounting_method": "调表不调账(报表取数层重分类,不生成记账凭证)",
"rules": plan["rules"],
"entries": plan["entries"],
"invariants": plan["invariants"],
"template_gaps": plan["template_gaps"],
"negatives": plan["negatives"],
"unmapped_subjects": plan["unmapped_subjects"],
"pl_unclosed": plan["pl_unclosed"],
"totals_before": plan["totals_before"],
"totals_after": plan["totals_after"],
}
@router.get("/balance-sheet/reclass-working-paper/export")
def export_reclass_working_paper(
period: str = Query(None, description="格式 YYYY-MM"),
basis: Optional[str] = Query(None, description="取数口径 end=期末余额(默认) / period=本期发生额"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""重分类底稿导出(Excel)— 底稿明细 + 诊断 + 规则表(账套隔离)"""
if period is None:
period = datetime.now().strftime("%Y-%m")
use_basis = _bs_basis(basis)
plan = _bs_analysis(db, entity_id, period, use_basis)
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from fastapi.responses import StreamingResponse
from urllib.parse import quote
wb = Workbook()
head_fill = PatternFill("solid", fgColor="305496")
head_font = Font(color="FFFFFF", bold=True, size=11)
def _sheet(ws, title, headers, rows):
ws.cell(row=1, column=1, value=title).font = Font(bold=True, size=13, color="305496")
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max(len(headers), 1))
for ci, h in enumerate(headers, start=1):
c = ws.cell(row=2, column=ci, value=h)
c.font = head_font
c.fill = head_fill
c.alignment = Alignment(horizontal="center")
for ri, row in enumerate(rows, start=3):
for ci, h in enumerate(headers, start=1):
ws.cell(row=ri, column=ci, value=row.get(h))
for ci, h in enumerate(headers, start=1):
ws.column_dimensions[ws.cell(row=2, column=ci).column_letter].width = max(12, min(46, len(str(h)) * 2 + 10))
e_rows = report_reclass.entry_rows(plan)
ws1 = wb.active
ws1.title = "重分类底稿"
if e_rows:
_sheet(ws1, f"资产负债表重分类底稿(调表不调账) {period} 账套 entity_id={entity_id} 口径basis={use_basis} {plan['rule_version']}",
list(e_rows[0].keys()), e_rows)
else:
ws1.cell(row=1, column=1, value=f"本期({period})无触发的重分类规则").font = Font(bold=True)
d_rows = report_reclass.diagnostic_rows(plan)
ws2 = wb.create_sheet("不变量与诊断")
_sheet(ws2, "重分类不变量 / 负数项目 / 模板待核 / 未列报科目", ["项目", "值", "说明"], d_rows)
ws3 = wb.create_sheet("规则表")
_sheet(ws3, f"CMA 资产负债表重分类规则表 {plan['rule_version']}",
["code", "source_code", "source_name", "source_side", "target_item", "target_key", "target_side", "status", "note"],
plan["rules"])
buf = BytesIO()
wb.save(buf)
buf.seek(0)
filename = f"资产负债表重分类底稿_{period}_entity{entity_id}.xlsx"
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
# 现金流量表行项目: (code, 名称, 板块, 方向, kpi_code可选)
CASH_FLOW_LINES = [
# 经营活动
@@ -1829,6 +2039,17 @@ def export_statutory_reports(
r = write_row(ws2, r, ["所有者权益合计", "", t["equity"]["end"], t["equity"]["begin"], ""], total_font, total_fill)
r = write_row(ws2, r, ["负债和所有者权益总计", "", t["liab_equity"]["end"], t["liab_equity"]["begin"], ""], total_font, total_fill)
r = write_row(ws2, r, ["勾稽校验(资产=负债+权益)", "", "✓ 平衡" if t.get("balanced") else "✗ 不平", "", ""], total_font, total_fill)
rc = bs.get("reclass") or {}
if rc:
inv = rc.get("invariants") or {}
r = write_row(ws2, r, ["重分类(调表不调账)", rc.get("rule_version", ""),
f"触发 {inv.get('entries_count', 0)} 条 / 转正额 {inv.get('reclass_total', 0)}",
("basis=end(期末余额口径)" if rc.get("basis") == "end" else f"basis={rc.get('basis')}(本期发生额口径)"),
"不生成凭证"], section_font, section_fill)
if inv:
r = write_row(ws2, r, ["不变量:Δ资产=Δ负债 / 权益不变", "",
"PASS" if inv.get("delta_assets_equals_delta_liabilities") and inv.get("equity_unchanged") else "FAIL",
f"Δ资产={inv.get('assets_delta', 0)},Δ负债={inv.get('liabilities_delta', 0)}", ""])
for col, w in zip("ABCDE", [36, 16, 18, 18, 12]):
ws2.column_dimensions[col].width = w
@@ -1852,6 +2073,30 @@ def export_statutory_reports(
for col, w in zip("ABCD", [46, 10, 18, 12]):
ws3.column_dimensions[col].width = w
# ── Sheet 4: 重分类底稿(调表不调账,审计可追溯) ──
ws4 = wb.create_sheet("重分类底稿")
style_header(ws4, 1, 12, f"资产负债表重分类底稿(调表不调账) {period} 单位: 元")
e_rows = report_reclass.entry_rows(bs.get("reclass") and {"entries": bs["reclass"]["entries"]} or {"entries": []})
if e_rows:
r = write_row(ws4, 2, list(e_rows[0].keys()), head_font, head_fill)
for row in e_rows:
r = write_row(ws4, r, list(row.values()))
else:
r = write_row(ws4, 2, ["本期无触发的重分类规则"], section_font, section_fill)
r += 1
r = write_row(ws4, r, ["不变量与诊断", "", "", "", "", "", "", "", "", "", "", ""], section_font, section_fill)
plan_like = {
"entries": (bs.get("reclass") or {}).get("entries") or [],
"invariants": (bs.get("reclass") or {}).get("invariants") or {},
"negatives": (bs.get("reclass") or {}).get("negatives") or {"before": [], "after": [], "after_in_scope": []},
"template_gaps": (bs.get("reclass") or {}).get("template_gaps") or [],
"unmapped_subjects": (bs.get("reclass") or {}).get("unmapped_subjects") or [],
}
for row in report_reclass.diagnostic_rows(plan_like):
r = write_row(ws4, r, [row.get("项目"), row.get("值"), row.get("说明")])
for col, w in zip("ABC", [42, 20, 60]):
ws4.column_dimensions[col].width = w
buf = BytesIO()
wb.save(buf)
buf.seek(0)