依据《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:
+253
-8
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user