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 from fastapi import APIRouter, Depends, Query, HTTPException, Request
import json import json
import os
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func, or_ 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.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.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.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 from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
import logging import logging
@@ -1378,6 +1380,8 @@ BALANCE_SHEET_SECTIONS = [
{"codes": [("1501", 1)], "name": "持有至到期投资", "ns_category": "investing"}, {"codes": [("1501", 1)], "name": "持有至到期投资", "ns_category": "investing"},
{"codes": [("1601", 1), ("1602", -1)], "name": "固定资产净额", "ns_category": "operating"}, {"codes": [("1601", 1), ("1602", -1)], "name": "固定资产净额", "ns_category": "operating"},
{"codes": [("1701", 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": [ "lines": [
{"codes": [("4001", 1)], "name": "实收资本", "ns_category": "equity"}, {"codes": [("4001", 1)], "name": "实收资本", "ns_category": "equity"},
{"codes": [("4002", 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]: 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 total = 0.0
has_data = False has_data = False
try: 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} 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") @router.get("/balance-sheet")
def get_balance_sheet( def get_balance_sheet(
period: str = Query(None, description="格式 YYYY-MM"), period: str = Query(None, description="格式 YYYY-MM"),
basis: Optional[str] = Query(None, description="取数口径 end=期末余额(默认) / period=本期发生额"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id), entity_id: int = Depends(get_entity_id),
): ):
"""资产负债表 — 新30号准则科目分类(经营/投资/筹资),期末vs期初(账套隔离 2026-08-31)""" """资产负债表 — 取数层重分类(R1~R12,调表不调账)+ 新30号准则科目分类(账套隔离 2026-08-31)
· 余额方向:资产 Σ(借−贷) / 负债·权益 Σ(贷−借) → 正常余额一律为正数
· 往来·税费类反方向余额按规则转正(转入对方科目),报表不再出现负数项目
· 有凭证明细时**不再混入示例数据**(展示口径与落库口径同源)
"""
if period is None: if period is None:
period = datetime.now().strftime("%Y-%m") period = datetime.now().strftime("%Y-%m")
use_basis = _bs_basis(basis)
prev_period = _prev_period_str(period) 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 = [] sections = []
total_assets_end = total_assets_begin = 0 total_assets_end = total_assets_begin = 0
total_liab_end = total_liab_begin = 0 total_liab_end = total_liab_begin = 0
total_equity_end = total_equity_begin = 0 total_equity_end = total_equity_begin = 0
all_real = True all_real = has_real
for sec in BALANCE_SHEET_SECTIONS: for sec in BALANCE_SHEET_SECTIONS:
lines = [] lines = []
sec_end = sec_begin = 0.0 sec_end = sec_begin = 0.0
sec_real = False sec_real = False
for line in sec["lines"]: for line in sec["lines"]:
end = _bs_line_amount(db, line, period, column="end", entity_id=entity_id) key = "|".join(c for c, _ in line["codes"])
begin = _bs_line_amount(db, line, prev_period, column="begin", entity_id=entity_id) 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"]: if end["is_demo"] or begin["is_demo"]:
all_real = False all_real = False
if end["value"] is not None: if end["value"] is not None:
@@ -1529,15 +1603,32 @@ def get_balance_sheet(
sec_real = True sec_real = True
if begin["value"] is not None: if begin["value"] is not None:
sec_begin += begin["value"] sec_begin += begin["value"]
raw_key = plan["raw"].get(key)
lines.append({ lines.append({
"name": line["name"], "name": line["name"],
"item_key": key,
"ns_category": line["ns_category"], "ns_category": line["ns_category"],
"ns_category_label": BS_CATEGORY_CN.get(line["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, "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, "begin_value": round(begin["value"], 2) if begin["value"] is not None else None,
"is_demo": end["is_demo"] or begin["is_demo"], "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"): if sec["key"] in ("current_assets", "non_current_assets"):
total_assets_end += sec_end total_assets_end += sec_end
@@ -1559,9 +1650,17 @@ def get_balance_sheet(
"has_real_data": sec_real, "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 { return {
"period": period, "period": period,
"prev_period": prev_period, "prev_period": prev_period,
"basis": use_basis,
"title": f"资产负债表 — 新30号准则({period})", "title": f"资产负债表 — 新30号准则({period})",
"sections": sections, "sections": sections,
"totals": { "totals": {
@@ -1570,13 +1669,124 @@ def get_balance_sheet(
"equity": {"end": round(total_equity_end, 2), "begin": round(total_equity_begin, 2)}, "equity": {"end": round(total_equity_end, 2), "begin": round(total_equity_begin, 2)},
"liab_equity": {"end": round(total_liab_end + total_equity_end, 2), "liab_equity": {"end": round(total_liab_end + total_equity_end, 2),
"begin": round(total_liab_begin + total_equity_begin, 2)}, "begin": round(total_liab_begin + total_equity_begin, 2)},
"balanced": abs(total_assets_end - total_liab_end - total_equity_end) < 0.01 "balanced": balanced,
and abs(total_assets_begin - total_liab_begin - total_equity_begin) < 0.01, "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, "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可选) # 现金流量表行项目: (code, 名称, 板块, 方向, kpi_code可选)
CASH_FLOW_LINES = [ 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["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, ["负债和所有者权益总计", "", 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) 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]): for col, w in zip("ABCDE", [36, 16, 18, 18, 12]):
ws2.column_dimensions[col].width = w ws2.column_dimensions[col].width = w
@@ -1852,6 +2073,30 @@ def export_statutory_reports(
for col, w in zip("ABCD", [46, 10, 18, 12]): for col, w in zip("ABCD", [46, 10, 18, 12]):
ws3.column_dimensions[col].width = w 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() buf = BytesIO()
wb.save(buf) wb.save(buf)
buf.seek(0) buf.seek(0)
+621
View File
@@ -0,0 +1,621 @@
"""资产负债表取数层重分类引擎(R1~R12,调表不调账)
依据:《CMA 资产负债表重分类规则表 v1》
(`wecom-finance/reports/CMA_资产负债表重分类规则表_v1.md`,任总 2026-09-16 批准落地)
本模块只作用于**报表取数层**:`voucher_details` 只读,**不生成记账凭证、不改账簿**
(调表不调账;账实不符走差错更正,见规则表 §四)。
口径要点
--------
1. **余额方向**:资产类 NSB = Σ(借−贷);负债/权益类 NSB = Σ(贷−借)。NSB = normal-side balance。
2. **触发**:NSB < 0(明细科目反方向余额,如预收账款出现借方余额)→ 转正额 = |NSB|,
移入目标报表项目(准则 30 号「不得抵销」)。
3. **全部触发判定基于原始余额**(单遍、顺序无关、可重复执行/幂等)——避免 R3/R4 这类互为镜像
的规则互相影响(如 2202 应收账款 ↔ 1123 预付账款 双向都有反向余额时)。
4. **科目取数一律前缀匹配**(`subject_code LIKE '1122%'`,与规则表 §二 公式一致):CMA 科目为多级
(100101 / 22210101 / 310415),精确匹配会漏掉全部明细行。
5. **basis**:`end` = 期末余额(累计至 period,含期初/结转,资产负债表口径,默认);
`period` = 本期发生额(兼容旧报表口径)。
6. **不变量**:跨方向重分类(资产↔负债)使资产合计与负债合计**同额增加**,
故「总资产不变」只在同侧列报下成立;真正恒定的不变量是
`Δ资产合计 = Δ负债合计` 且 `资产合计 − 负债合计 − 所有者权益合计` 前后不变。
本模块把这两条都算出来(`invariants`),不隐藏 `total_assets_unchanged=False` 这一事实。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Dict, List, Optional, Sequence, Tuple
from sqlalchemy import func
from app.utils.voucher_guard import apply_voucher_guard
RULE_VERSION = "reclass-v1.0-20260916"
DEFAULT_TOL = 0.01 # 元;小于该值的反方向余额不触发(浮点/分位噪声)
# 报表板块 → 余额方向
SIDE_OF_SECTION = {
"current_assets": "asset",
"non_current_assets": "asset",
"current_liabilities": "liability",
"non_current_liabilities": "liability",
"equity": "equity",
}
# 资产备抵科目(余额在贷方)→ 取数方向反转:1602 累计折旧 / 1603 固定资产减值准备 / 1604 在建工程减值准备
CONTRA_CODES: Dict[str, str] = {"1602": "liability", "1603": "liability", "1604": "liability"}
@dataclass(frozen=True)
class ReclassRule:
"""一条重分类规则(规则表 §二 的机器可读形式)。"""
code: str # R1~R12
source_code: str # 来源科目编码(前缀匹配)
source_name: str
source_side: str # asset / liability
target_item: str # 目标报表项目名(小企业准则项目池)
target_key: str # 目标项目在报表条目表里的 key
target_side: str # asset / liability
target_codes: Tuple[str, ...] = ()
status: str = "active" # active=可落地 / template_gap=目标项模板未列报(引擎按虚拟行列报)/ blocked=缺数据或准则未列报
error_check: bool = False # 触发后需先排查记账差错(R10)
note: str = ""
# ── R1~R12(规则表 §二,挂 CMA 真实科目编码) ────────────────────────────────
RECLASS_RULES: Tuple[ReclassRule, ...] = (
ReclassRule("R1", "1122", "应收账款", "asset", "预收账款", "2203", "liability",
("2203",), note="小企业准则无「合同负债」,目标为预收账款"),
ReclassRule("R2", "2203", "预收账款", "liability", "应收账款", "1122", "asset", ("1122",),
note="R1 反向对称"),
ReclassRule("R3", "2202", "应付账款", "liability", "预付账款", "1123", "asset", ("1123",),
note="应付账款出现借方余额(多付/预付)"),
ReclassRule("R4", "1123", "预付账款", "asset", "应付账款", "2202", "liability", ("2202",),
note="R3 反向对称"),
ReclassRule("R5", "1221", "其他应收款", "asset", "其他应付款", "2241", "liability", ("2241",)),
ReclassRule("R6", "2241", "其他应付款", "liability", "其他应收款", "1221", "asset", ("1221",)),
ReclassRule("R7", "1121", "应收票据", "asset", "应付票据", "2201", "liability", ("2201",),
status="template_gap", note="CMA 报表模板未单列应收/应付票据 → 引擎按虚拟行列报,待财务确认"),
ReclassRule("R8", "2201", "应付票据", "liability", "应收票据", "1121", "asset", ("1121",),
status="template_gap", note="CMA 报表模板未单列应收/应付票据 → 引擎按虚拟行列报,待财务确认"),
ReclassRule("R9", "2221", "应交税费", "liability", "其他流动资产", "OCA_OTHER", "asset",
status="template_gap",
note="留抵/预缴/多缴(借方余额);≤1年→其他流动资产,>1年→其他非流动资产。"
"CMA 无到期日字段,暂统一列报「其他流动资产」并标注待核"),
ReclassRule("R10", "2211", "应付职工薪酬", "liability", "其他应收款", "1221", "asset", ("1221",),
error_check=True, note="先查是否记账差错(多付/重复发放),差错走更正、非差错才重分类"),
ReclassRule("R11", "2501", "长期借款/应付债券", "liability", "一年内到期的非流动负债",
"NCL_DUE_1Y", "liability", status="blocked",
note="小企业准则报表未单列「一年内到期的非流动负债」,且 voucher_details 无到期日字段 → 不落地"),
ReclassRule("R12", "1501", "长期债权投资/长期应收款", "asset", "一年内到期的非流动资产",
"NCA_DUE_1Y", "asset", status="blocked",
note="小企业准则报表未单列「一年内到期的非流动资产」,且无到期日字段 → 不落地"),
)
# ── 模板未列报、但规则需要落位的报表项目(虚拟行;仅当金额非零时列报) ──────────
EXTRA_ITEMS: Dict[str, dict] = {
"1121": {"name": "应收票据", "section_key": "current_assets", "side": "asset", "codes": [("1121", 1)],
"template_gap": True, "note": "小企业准则项目池有「应收票据」,CMA 模板未列报"},
"2201": {"name": "应付票据", "section_key": "current_liabilities", "side": "liability", "codes": [("2201", 1)],
"template_gap": True, "note": "小企业准则项目池有「应付票据」,CMA 模板未列报"},
"OCA_OTHER": {"name": "其他流动资产", "section_key": "current_assets", "side": "asset", "codes": [],
"template_gap": True, "ns_category": "operating",
"note": "小企业准则项目池有「其他流动资产」,CMA 模板未列报(R9 落位项目)"},
"ONCA_OTHER": {"name": "其他非流动资产", "section_key": "non_current_assets", "side": "asset", "codes": [],
"template_gap": True, "blocked": True, "note": "R9 的 >1年落位项目;无到期日字段,暂不落地"},
"OCL_OTHER": {"name": "其他流动负债", "section_key": "current_liabilities", "side": "liability", "codes": [],
"template_gap": True, "blocked": True, "note": "R7 备选落位项目;暂不落地"},
"NCA_DUE_1Y": {"name": "一年内到期的非流动资产", "section_key": "current_assets", "side": "asset", "codes": [],
"template_gap": True, "blocked": True, "note": "小企业准则未单列(R12 待核)"},
"NCL_DUE_1Y": {"name": "一年内到期的非流动负债", "section_key": "current_liabilities", "side": "liability", "codes": [],
"template_gap": True, "blocked": True, "note": "小企业准则未单列(R11 待核)"},
}
# ── 余额取数(前缀匹配 + 作废守卫 + basis 口径) ─────────────────────────────
class BalanceSource:
"""按 (品类, 期间, basis) 取科目 normal-side balance(NSB),带会话内缓存。"""
def __init__(self, db, entity_id: int, period: str, basis: str = "end", tol: float = DEFAULT_TOL):
self.db = db
self.entity_id = entity_id
self.period = period
self.basis = "period" if basis == "period" else "end"
self.tol = tol
self._cache: Dict[Tuple[str, str], Optional[Decimal]] = {}
self._has_data: Optional[bool] = None
# -- 基础查询 ---------------------------------------------------------
def _query(self, code: str, period: str):
from app.models import VoucherDetail
q = self.db.query(
func.sum(VoucherDetail.debit_amount),
func.sum(VoucherDetail.credit_amount),
func.count(VoucherDetail.id),
).filter(
VoucherDetail.entity_id == self.entity_id,
VoucherDetail.subject_code.like(f"{code}%"),
)
if self.basis == "end":
q = q.filter(VoucherDetail.period <= period) # 期末余额 = 累计(含期初/结转)
else:
q = q.filter(VoucherDetail.period == period) # 本期发生额
return apply_voucher_guard(self.db, q, VoucherDetail)
def nsb(self, code: str, side: str, period: Optional[str] = None) -> Optional[Decimal]:
"""NSB = 资产 Σ(借−贷) / 负债·权益 Σ(贷−借);无数据返回 None。"""
period = period or self.period
ck = (f"{code}|{side}|{period}|{self.basis}", "")
if ck in self._cache:
return self._cache[ck]
d = c = n = None
try:
row = self._query(code, period).one()
d, c, n = row[0], row[1], row[2]
except Exception: # noqa: BLE001 - 无凭证明细表/查询异常 → 视为无数据
pass
if not n:
self._cache[ck] = None
return None
debit = Decimal(str(d or 0))
credit = Decimal(str(c or 0))
val = (debit - credit) if side == "asset" else (credit - debit)
self._cache[ck] = val
return val
def has_data(self) -> bool:
if self._has_data is None:
from app.models import VoucherDetail
q = self.db.query(func.count(VoucherDetail.id)).filter(
VoucherDetail.entity_id == self.entity_id
)
if self.basis == "period":
q = q.filter(VoucherDetail.period == self.period)
try:
self._has_data = bool(q.scalar())
except Exception: # noqa: BLE001
self._has_data = False
return bool(self._has_data)
# ── 报表条目表(模板行 + 规则落位项) ──────────────────────────────────────
def build_items(template_sections: Sequence[dict]) -> Dict[str, dict]:
"""从报表模板派生条目表;key 与模板行 key 一致("|".join(codes))。
`alias_codes` 只参与取数、不参与 key(例如模板用 4103/4104、数据用 3103/3104)。
"""
items: Dict[str, dict] = {}
for sec in template_sections or []:
side = SIDE_OF_SECTION.get(sec["key"], "asset")
for line in sec.get("lines", []):
key = "|".join(c for c, _ in line["codes"])
codes = list(line["codes"]) + list(line.get("alias_codes") or [])
items[key] = {
"key": key,
"name": line["name"],
"section_key": sec["key"],
"side": side,
"codes": codes,
"template": True,
"template_gap": False,
"ns_category": line.get("ns_category"),
"aliased": bool(line.get("alias_codes")),
}
for key, cfg in EXTRA_ITEMS.items():
if key in items:
continue
items[key] = {
"key": key, "name": cfg["name"], "section_key": cfg["section_key"],
"side": cfg["side"], "codes": list(cfg.get("codes") or []),
"template": False, "template_gap": bool(cfg.get("template_gap")),
"blocked": bool(cfg.get("blocked")), "ns_category": cfg.get("ns_category"),
"note": cfg.get("note", ""),
}
return items
def _item_raw(bs: BalanceSource, item: dict, period: Optional[str] = None) -> Optional[Decimal]:
"""条目原始余额:Σ sign × NSB(code);无任何数据返回 None(纯落位项目按 0 处理)。
备抵科目(累计折旧等)余额方向与所属板块相反,取数时反向(`CONTRA_CODES`)——
否则「固定资产净额 = 原值 − 累计折旧」会被算成 原值 + 累计折旧。
"""
total = Decimal(0)
has = False
for code, sign in item["codes"]:
side = CONTRA_CODES.get(code) or item["side"]
v = bs.nsb(code, side, period)
if v is not None:
total += v * sign
has = True
if not has:
return Decimal(0) if (not item["template"] or not item["codes"]) else None
return total
def _covered_code_map(items: Dict[str, dict]) -> Dict[str, str]:
"""科目编码 → 条目 key(取数代码 sign=1 的;用于判定规则来源项落位)。"""
m: Dict[str, str] = {}
for key, item in items.items():
for code, sign in item["codes"]:
if sign == 1:
m.setdefault(code, key)
return m
# ── 主入口:一次性算出「原值 / 重分类后 / 底稿 / 不变量 / 诊断」 ─────────────
def analyze(db, entity_id: int, period: str, basis: str = "end",
template_sections: Optional[Sequence[dict]] = None,
rules: Sequence[ReclassRule] = RECLASS_RULES,
tol: float = DEFAULT_TOL, include_unmapped: bool = True) -> dict:
"""资产负债表取数层重分类分析(不写库、幂等、可重复调用)。"""
bs = BalanceSource(db, entity_id, period, basis, tol)
items = build_items(template_sections or [])
code_to_key = _covered_code_map(items)
raw: Dict[str, Optional[Decimal]] = {}
prev: Dict[str, Optional[Decimal]] = {}
for key, item in items.items():
raw[key] = _item_raw(bs, item)
if basis == "end":
prev[key] = _item_raw(bs, item, _prev_period(period))
# ── 触发判定(全部基于原始余额,单遍) ──
entries: List[dict] = []
for r in rules:
if r.status == "blocked":
continue
v = bs.nsb(r.source_code, r.source_side)
if v is None or v >= -Decimal(str(tol)):
continue
amount = float(-v)
src_key = code_to_key.get(r.source_code, r.source_code)
tgt = items.get(r.target_key) or {}
entries.append({
"rule": r.code,
"source_code": r.source_code,
"source_name": r.source_name,
"source_side": r.source_side,
"source_key": src_key,
"raw_balance": round(float(v), 2),
"abnormal_direction": "贷方余额" if r.source_side == "asset" else "借方余额",
"reclass_amount": round(amount, 2),
"target_item": r.target_item,
"target_key": r.target_key,
"target_codes": list(r.target_codes),
"target_side": r.target_side,
"target_in_template": bool(tgt.get("template")),
"rule_status": r.status,
"error_check": r.error_check,
"note": r.note,
})
# ── 重分类后金额 ──
after: Dict[str, Optional[float]] = {}
for key, v in raw.items():
after[key] = float(v) if v is not None else 0.0
for e in entries:
after[e["source_key"]] = float(after.get(e["source_key"]) or 0.0) + e["reclass_amount"]
after[e["target_key"]] = float(after.get(e["target_key"]) or 0.0) + e["reclass_amount"]
def _totals(values: Dict[str, Optional[float]]) -> dict:
t = {"assets": 0.0, "liabilities": 0.0, "equity": 0.0}
for key, item in items.items():
v = values.get(key)
if v is None or item.get("blocked"):
continue
if item["side"] == "asset":
t["assets"] += float(v)
elif item["side"] == "liability":
t["liabilities"] += float(v)
else:
t["equity"] += float(v)
t["liab_equity"] = t["liabilities"] + t["equity"]
t["residual"] = round(t["assets"] - t["liab_equity"], 2)
return {k: round(v, 2) for k, v in t.items()}
before_t = _totals(raw)
after_t = _totals(after)
reclass_total = round(sum(e["reclass_amount"] for e in entries), 2)
in_scope = set()
for r in rules:
if r.status == "blocked":
continue
in_scope.add(code_to_key.get(r.source_code, r.source_code))
in_scope.add(r.target_key)
def _negatives(values: Dict[str, Optional[float]]) -> List[dict]:
out = []
for key, item in items.items():
v = values.get(key)
if v is None or v >= -tol:
continue
out.append({
"key": key, "name": item["name"], "side": item["side"],
"section_key": item["section_key"], "value": round(float(v), 2),
"in_reclass_scope": key in in_scope,
"template": item["template"],
})
return sorted(out, key=lambda x: x["value"])
neg_before = _negatives(raw)
neg_after = _negatives(after)
neg_after_in_scope = [n for n in neg_after if n["in_reclass_scope"]]
invariants = {
"basis": bs.basis,
"period": period,
"rule_version": RULE_VERSION,
"assets_before": before_t["assets"],
"assets_after": after_t["assets"],
"liabilities_before": before_t["liabilities"],
"liabilities_after": after_t["liabilities"],
"equity_before": before_t["equity"],
"equity_after": after_t["equity"],
"assets_delta": round(after_t["assets"] - before_t["assets"], 2),
"liabilities_delta": round(after_t["liabilities"] - before_t["liabilities"], 2),
"equity_delta": round(after_t["equity"] - before_t["equity"], 2),
"reclass_total": reclass_total,
# ① 调表不调账:跨方向重分类 → 资产/负债同额变动
"delta_assets_equals_delta_liabilities":
abs((after_t["assets"] - before_t["assets"]) - (after_t["liabilities"] - before_t["liabilities"])) < 0.01,
# ② 权益不受影响
"equity_unchanged": abs(after_t["equity"] - before_t["equity"]) < 0.01,
# ③ 勾稽差额不被重分类改变(重分类不制造新的不平)
"residual_before": before_t["residual"],
"residual_after": after_t["residual"],
"residual_unchanged": abs(after_t["residual"] - before_t["residual"]) < 0.01,
# ④ 勾稽恒等式(资产合计 = 负债 + 所有者权益)
"identity_holds_before": abs(before_t["residual"]) < 0.01,
"identity_holds_after": abs(after_t["residual"]) < 0.01,
# ⑤ 「总资产不变」:跨方向重分类下必然 False(见模块 docstring 第 6 条)
"total_assets_unchanged": abs(after_t["assets"] - before_t["assets"]) < 0.01,
"total_assets_unchanged_semantics": (
"跨方向重分类(资产↔负债)使资产合计与负债合计同额变动(Δ资产=Δ负债=转正额);"
"「总资产不变」仅在同侧列报时成立。恒定不变量 = Δ资产=Δ负债 且 资产−负债−权益 不变。"
),
"no_negative_in_scope_after": len(neg_after_in_scope) == 0,
"negatives_before": len(neg_before),
"negatives_after": len(neg_after),
"entries_count": len(entries),
}
template_gaps: List[dict] = []
for r in rules:
tgt = items.get(r.target_key) or {}
if r.status == "active":
continue
template_gaps.append({
"rule": r.code, "source_code": r.source_code, "target_item": r.target_item,
"target_key": r.target_key, "status": r.status,
"in_report": (r.status != "blocked") and bool(tgt.get("template_gap")),
"note": r.note,
})
if not bs.has_data():
entries = []
after = {k: (float(v) if v is not None else None) for k, v in raw.items()}
# 无凭证明细(如 entity2)→ 不做重分类,全部回落到示例数据路径
for t in (before_t, after_t):
t.update({"assets": 0.0, "liabilities": 0.0, "equity": 0.0,
"liab_equity": 0.0, "residual": 0.0})
invariants.update({
"assets_after": 0.0, "liabilities_after": 0.0, "equity_after": 0.0,
"assets_delta": 0.0, "liabilities_delta": 0.0, "equity_delta": 0.0,
"reclass_total": 0.0, "entries_count": 0,
"delta_assets_equals_delta_liabilities": True, "equity_unchanged": True,
"residual_after": 0.0, "residual_unchanged": True,
"identity_holds_after": True, "no_negative_in_scope_after": True,
"negatives_before": 0, "negatives_after": 0,
})
neg_before = neg_after = neg_after_in_scope = []
return {
"rule_version": RULE_VERSION,
"basis": bs.basis,
"period": period,
"entity_id": entity_id,
"prev_period": _prev_period(period),
"has_voucher_data": bs.has_data(),
"rules": [r.__dict__ for r in rules],
"items": items,
"raw": {k: (float(v) if v is not None else None) for k, v in raw.items()},
"after": after,
"prev_raw": {k: (float(v) if v is not None else None) for k, v in prev.items()},
"entries": entries,
"invariants": invariants,
"totals_before": before_t,
"totals_after": after_t,
"negatives": {"before": neg_before, "after": neg_after, "after_in_scope": neg_after_in_scope},
"template_gaps": template_gaps,
"unmapped_subjects": unmapped_subjects(db, entity_id, period, bs.basis, items) if include_unmapped else [],
"pl_unclosed": pl_unclosed(db, entity_id, period, bs.basis) if include_unmapped else None,
}
def _prev_period(period: str) -> str:
"""上一期间 YYYY-MM → YYYY-(MM-1)"""
try:
y, m = period.split("-")
y, m = int(y), int(m)
m -= 1
if m <= 0:
m += 12
y -= 1
return f"{y}-{m:02d}"
except Exception: # noqa: BLE001
return period
def pl_unclosed(db, entity_id: int, period: str, basis: str) -> Optional[float]:
"""损益类科目未结转余额(含结转凭证;口径 贷−借)——正常应为 0。
非 0 即为「勾稽差额」的直接来源(结转不完整),用于把不变量差额解释清楚。
"""
from app.models import VoucherDetail
from sqlalchemy import or_
try:
q = db.query(func.sum(VoucherDetail.credit_amount - VoucherDetail.debit_amount)).filter(
VoucherDetail.entity_id == entity_id,
or_(VoucherDetail.subject_code.like("5%"), VoucherDetail.subject_code.like("6%")),
)
if basis == "end":
q = q.filter(VoucherDetail.period <= period)
else:
q = q.filter(VoucherDetail.period == period)
v = apply_voucher_guard(db, q, VoucherDetail).scalar()
return round(float(v or 0), 2)
except Exception: # noqa: BLE001
return None
def _resolve_category(code, cats: Dict[str, str]) -> Optional[str]:
"""科目 → asset/liability/equity:先精确、再逐级取上级编码(明细科目 category='detail')。"""
s = str(code)
for n in (len(s), 6, 4, 2):
if n > len(s):
continue
c = cats.get(s[:n])
if c in ("asset", "liability", "equity"):
return c
return None
def unmapped_subjects(db, entity_id: int, period: str, basis: str, items: Dict[str, dict]) -> List[dict]:
"""凭证明细里出现、但报表模板/规则都未列报的资产·负债·权益科目余额(勾稽差额诊断)。
只做诊断,不参与重分类——用于定位「资产合计 ≠ 负债 + 权益」的差额来源。
"""
from app.models import VoucherDetail, Subject
try:
q = db.query(
VoucherDetail.subject_code,
func.sum(VoucherDetail.debit_amount - VoucherDetail.credit_amount),
func.count(VoucherDetail.id),
).filter(VoucherDetail.entity_id == entity_id)
if basis == "end":
q = q.filter(VoucherDetail.period <= period)
else:
q = q.filter(VoucherDetail.period == period)
rows = apply_voucher_guard(db, q, VoucherDetail).group_by(VoucherDetail.subject_code).all()
cats = {c: cat for c, cat in db.query(Subject.subject_code, Subject.category).all()}
except Exception: # noqa: BLE001
return []
covered = []
for key, item in items.items():
for code, _ in item["codes"]:
covered.append(code)
agg: Dict[str, dict] = {}
for code, net, n in rows:
cat = _resolve_category(code, cats)
if cat is None:
continue
if any(str(code) == c or str(code).startswith(c) for c in covered if c):
continue
bucket = str(code)[:4] if len(str(code)) > 4 else str(code)
side = "asset" if cat == "asset" else ("liability" if cat == "liability" else "equity")
d = agg.setdefault(bucket, {"subject_code": bucket, "category": cat, "side": side,
"net_debit_minus_credit": 0.0, "rows": 0, "detail_codes": []})
d["net_debit_minus_credit"] += float(net or 0)
d["rows"] += int(n or 0)
if str(code) != bucket:
d["detail_codes"].append(str(code))
out = []
for d in agg.values():
d["net_debit_minus_credit"] = round(d["net_debit_minus_credit"], 2)
d["normal_side_balance"] = round(
d["net_debit_minus_credit"] if d["side"] == "asset" else -d["net_debit_minus_credit"], 2)
out.append(d)
return sorted(out, key=lambda x: -abs(x["normal_side_balance"]))
# ── 底稿(审计可追溯) ─────────────────────────────────────────────────────
def entry_rows(analysis: dict) -> List[dict]:
"""重分类底稿明细行:源科目/余额/转正额/目标项/依据。"""
rows = []
for e in analysis["entries"]:
rows.append({
"规则": e["rule"],
"来源科目": e["source_code"],
"科目名称": e["source_name"],
"原余额(元)": e["raw_balance"],
"异常方向": e["abnormal_direction"],
"转正额(元)": e["reclass_amount"],
"目标报表项目": e["target_item"],
"目标科目": "/".join(e["target_codes"]) or "-",
"目标项模板已列报": "是" if e["target_in_template"] else "否(虚拟行)",
"处理": "调表不调账",
"需先行差错核查": "是" if e["error_check"] else "否",
"依据/备注": e["note"],
})
return rows
def diagnostic_rows(analysis: dict) -> List[dict]:
"""底稿诊断区:不变量 + 负数项目 + 模板未列报 + 未列报科目(勾稽差额诊断)。"""
inv = analysis["invariants"]
rows = [
{"项目": "规则版本", "值": inv["rule_version"], "说明": "取数层重分类规则集"},
{"项目": "取数口径 basis", "值": inv["basis"], "说明": "end=期末余额(累计至期间);period=本期发生额"},
{"项目": "触发规则数", "值": inv["entries_count"], "说明": "本轮实际触发的重分类条数"},
{"项目": "转正额合计(元)", "值": inv["reclass_total"], "说明": "调表不调账,不生成凭证"},
{"项目": "资产合计-重分类前(元)", "值": inv["assets_before"], "说明": ""},
{"项目": "资产合计-重分类后(元)", "值": inv["assets_after"], "说明": ""},
{"项目": "负债合计-重分类前(元)", "值": inv["liabilities_before"], "说明": ""},
{"项目": "负债合计-重分类后(元)", "值": inv["liabilities_after"], "说明": ""},
{"项目": "所有者权益合计-重分类前(元)", "值": inv["equity_before"], "说明": ""},
{"项目": "所有者权益合计-重分类后(元)", "值": inv["equity_after"], "说明": ""},
{"项目": "Δ资产 = Δ负债", "值": "PASS" if inv["delta_assets_equals_delta_liabilities"] else "FAIL",
"说明": f"Δ资产={inv['assets_delta']},Δ负债={inv['liabilities_delta']}"},
{"项目": "权益不变", "值": "PASS" if inv["equity_unchanged"] else "FAIL", "说明": ""},
{"项目": "勾稽差额不变", "值": "PASS" if inv["residual_unchanged"] else "FAIL",
"说明": f"前={inv['residual_before']},后={inv['residual_after']}"},
{"项目": "勾稽恒等式(资产=负债+权益)", "值": "平衡" if inv["identity_holds_after"] else "不平",
"说明": f"差额 {inv['residual_after']} 元(重分类前 {inv['residual_before']})"},
{"项目": "总资产不变", "值": "是" if inv["total_assets_unchanged"] else "否(跨方向重分类必然变化)",
"说明": inv["total_assets_unchanged_semantics"]},
{"项目": "规则范围内负数项目(重分类后)", "值": len(analysis["negatives"]["after_in_scope"]),
"说明": "应为 0"},
{"项目": "负数项目总数(重分类前/后)", "值": f"{inv['negatives_before']} / {inv['negatives_after']}",
"说明": "重分类范围外科目见下方清单"},
]
for n in analysis["negatives"]["after"]:
rows.append({
"项目": f"负数项目:{n['name']}",
"值": n["value"],
"说明": ("规则范围内(异常,需查)" if n["in_reclass_scope"]
else "R1~R12 未覆盖科目 → 差错更正/取数口径问题,需财务判定"),
})
for g in analysis["template_gaps"]:
rows.append({"项目": f"模板待核:{g['rule']} {g['source_code']}",
"值": g["status"], "说明": g["note"]})
plu = analysis.get("pl_unclosed")
if plu is not None:
rows.append({
"项目": "损益类未结转余额(应为 0)",
"值": plu,
"说明": ("与勾稽差额一致 → 差额来自结转不完整,需差错更正(非重分类能解决)"
if abs(plu) > 0.01 and abs(plu - inv.get("residual_after", 0)) < 0.01
else ("非 0,需差错核查" if abs(plu) > 0.01 else "已结平")),
})
for u in analysis["unmapped_subjects"]:
rows.append({
"项目": f"未列报科目:{u['subject_code']}",
"值": u["normal_side_balance"],
"说明": f"{u['side']};明细 {'/'.join(u['detail_codes']) or '-'};模板未列报 → 影响勾稽差额",
})
return rows
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""资产负债表重分类(R1~R12)现网只读验收脚本 — 2026-09-16
只读:全部经 ORM SELECT(voucher_guard 过滤生效),不写任何表、不生成凭证。
验收判据(对应派单 acceptance):
A1 R1~R12 规则在取数层落地(12 条登记,工程可执行)
A2 不变量:Δ资产=Δ负债、权益不变、勾稽差额不变(重分类不制造新的不平)
A3 酣客(entity1) 现网反向项重分类后,**规则范围内**不再出现负数项目
A4 多租户隔离:entity2 无凭证明细 → 0 条重分类,报表回示例口径
A5 底稿可用:底稿行数 == 触发条数
A6 端点集成:/balance-sheet 返回 reclass 块,负数项目全部被标注(在范围内/未覆盖)
用法:cd backend && venv/bin/python3 scripts/verify_reclass_live_20260916.py [period]
退出码:0 = 全部通过;1 = 存在 FAIL
"""
import json
import os
import sys
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local # noqa: E402
from app.api.reports import ( # noqa: E402
BALANCE_SHEET_SECTIONS, get_balance_sheet, get_reclass_working_paper,
)
from app.services import report_reclass as rr # noqa: E402
OUT = "/root/.hermes/profiles/wecom-fullstack/pending-tasks/_reports/finance-20260916-reclass-verify.json"
def main() -> int:
period = sys.argv[1] if len(sys.argv) > 1 else "2026-08"
db = get_session_local()()
checks = []
def chk(name, ok, detail="", status=None):
st = status or ("PASS" if ok else "FAIL")
checks.append({"判据": name, "结果": st, "证据": detail})
return ok
result: dict = {"period": period, "generated_at": datetime.now().isoformat(timespec="seconds"),
"mode": "read-only"}
# ── 规则登记(A1)──
chk("A1 规则登记数 = 12 (R1~R12)",
len(rr.RECLASS_RULES) == 12,
f"rule_version={rr.RULE_VERSION},codes={[r.code for r in rr.RECLASS_RULES]}")
# ── 酣客 entity1:期末余额口径 ──
bs = get_balance_sheet(period=period, basis="end", db=db, entity_id=1)
inv = bs["reclass"]["invariants"]
lines = [ln for sec in bs["sections"] for ln in sec["lines"]]
result["entity1_end"] = {
"basis": bs["basis"], "totals": bs["totals"], "invariants": inv,
"entries": bs["reclass"]["entries"],
"negatives_after": bs["reclass"]["negatives"]["after"],
"template_gaps": bs["reclass"]["template_gaps"],
"unmapped_subjects": bs["reclass"]["unmapped_subjects"],
"pl_unclosed": bs["reclass"]["pl_unclosed"],
"lines": [{k: ln.get(k) for k in ("name", "item_key", "end_value", "raw_value",
"reclass_adjustment", "is_reclass_line", "template_gap")}
for ln in lines],
}
rules_hit = {e["rule"] for e in bs["reclass"]["entries"]}
chk("A3 酣客 3 处待核反向项已处置(1123/2202/2203 无负数)",
all(ln["end_value"] is None or ln["end_value"] >= -0.01
for ln in lines if ln["item_key"] in ("1123", "2202", "2203")),
"1123=%s, 2202=%s, 2203=%s" % tuple(
next((ln["end_value"] for ln in lines if ln["item_key"] == k), None)
for k in ("1123", "2202", "2203")))
chk("A3b 规则范围内负数项目 = 0",
inv["no_negative_in_scope_after"] and not bs["reclass"]["negatives"]["after_in_scope"],
f"触发规则={sorted(rules_hit)},转正额={inv['reclass_total']}")
chk("A2 不变量:Δ资产=Δ负债",
inv["delta_assets_equals_delta_liabilities"],
f"Δ资产={inv['assets_delta']} Δ负债={inv['liabilities_delta']}")
chk("A2b 不变量:权益不变", inv["equity_unchanged"],
f"权益 前={inv['equity_before']} 后={inv['equity_after']}")
chk("A2c 不变量:勾稽差额不变(不制造新的不平)", inv["residual_unchanged"],
f"前={inv['residual_before']} 后={inv['residual_after']}")
residual_ok = abs(inv["residual_after"]) < 0.01
unmapped_net = sum(u["normal_side_balance"] for u in bs["reclass"]["unmapped_subjects"])
chk("A2d 勾稽恒等式(现网原始口径,资产=负债+所有者权益)", residual_ok,
f"期末差额={inv['residual_before']} 元;已完整归因于模板未列报科目净额={unmapped_net},"
f"损益未结转={bs['reclass']['pl_unclosed']}")
chk("A2d2 归因校验:计入模板未列报科目后恒等式成立", abs(inv["residual_after"] + unmapped_net) < 0.01,
f"差额 {inv['residual_after']} + 未列报科目净额 {unmapped_net} = "
f"{round(inv['residual_after'] + unmapped_net, 2)} → 差额来源已闭环定位(1901 待处理财产损溢未清理)")
chk("A2e 「总资产不变」口径", False,
f"不成立(Δ资产={inv['assets_delta']}):跨方向重分类使资产与负债同额变动;"
f"恒定不变量为 Δ资产=Δ负债 且 资产−负债−权益不变(见 invariants.total_assets_unchanged_semantics)",
status="INFO-口径修正")
# ── 底稿(A5)──
wp = get_reclass_working_paper(period=period, basis="end", db=db, entity_id=1)
rows = rr.entry_rows({"entries": wp["entries"]})
chk("A5 底稿行数 == 触发条数", len(rows) == len(wp["entries"]) == inv["entries_count"],
f"底稿={len(rows)} 触发={inv['entries_count']}")
result["working_paper"] = wp
# ── 多租户(A4)──
bs2 = get_balance_sheet(period=period, basis="end", db=db, entity_id=2)
chk("A4 entity2 无凭证明细 → 0 条重分类、报表不被污染",
bs2["reclass"]["entries"] == [] and bs2["reclass"]["invariants"]["entries_count"] == 0
and bs2["all_items_have_data"] is False,
f"entity2 entries={len(bs2['reclass']['entries'])},totals={bs2['totals']}")
result["entity2_end"] = {"totals": bs2["totals"], "entries": bs2["reclass"]["entries"]}
# ── 端点集成(A6)──
bad = [ln["name"] for ln in lines if ln["end_value"] is not None and ln["end_value"] < -0.01]
in_scope_bad = [ln["name"] for ln in lines
if ln["end_value"] is not None and ln["end_value"] < -0.01
and any(e["source_key"] == ln["item_key"] or e["target_key"] == ln["item_key"]
for e in bs["reclass"]["entries"])]
chk("A6 端点返回 reclass 块,负数项目全部标注(未覆盖科目如实暴露)",
bool(bs["reclass"]) and not in_scope_bad and
all(any(n["key"] == ln["item_key"] for n in bs["reclass"]["negatives"]["after"])
for ln in lines if ln["end_value"] is not None and ln["end_value"] < -0.01),
f"重分类后仍为负数的项目(R1~R12 未覆盖,需财务判差错/口径):{bad}")
result["checks"] = checks
# 待财务处理项(本次实现暴露的账实/口径问题,非重分类可解决)
result["findings_for_finance"] = {
"未覆盖科目的反方向余额(需差错更正或取数口径修正)":
[{k: n[k] for k in ("name", "value", "side")} for n in bs["reclass"]["negatives"]["after"]],
"模板未列报科目(影响勾稽差额)":
[{k: u[k] for k in ("subject_code", "side", "normal_side_balance", "detail_codes")}
for u in bs["reclass"]["unmapped_subjects"]],
"规则/模板待核": [{"rule": g["rule"], "status": g["status"], "note": g["note"]}
for g in bs["reclass"]["template_gaps"]],
"损益类未结转余额": bs["reclass"]["pl_unclosed"],
"总资产不变口径修正": inv["total_assets_unchanged_semantics"],
}
result["before_after_by_item"] = [
{"项目": ln["name"], "item_key": ln["item_key"],
"重分类前": ln["raw_value"], "重分类后": ln["end_value"],
"调整额": ln["reclass_adjustment"], "虚拟行": ln["is_reclass_line"]}
for ln in lines
]
result["summary"] = {
"PASS": sum(1 for c in checks if c["结果"] == "PASS"),
"FAIL": sum(1 for c in checks if c["结果"] == "FAIL"),
"INFO": sum(1 for c in checks if c["结果"].startswith("INFO")),
}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2, default=str)
print(f"=== 资产负债表重分类现网验收(entity1 period={period},只读)===")
for c in checks:
print(f"[{c['结果']}] {c['判据']}\n 证据: {c['证据']}")
print(f"\n触发规则:{[ (e['rule'], e['source_code'], e['raw_balance'], e['target_item'], e['reclass_amount']) for e in bs['reclass']['entries'] ]}")
print(f"不变量:Δ资产={inv['assets_delta']} Δ负债={inv['liabilities_delta']} "
f"资产合计 {inv['assets_before']} → {inv['assets_after']} 权益 {inv['equity_before']}")
print(f"负数项目(重分类后):{[(n['name'], n['value'], '在规则范围内' if n['in_reclass_scope'] else '未覆盖') for n in bs['reclass']['negatives']['after']]}")
print(f"证据文件:{OUT}({os.path.getsize(OUT)} bytes)")
db.close()
return 1 if result["summary"]["FAIL"] else 0
if __name__ == "__main__":
sys.exit(main())
+296
View File
@@ -0,0 +1,296 @@
"""资产负债表取数层重分类(R1~R12,调表不调账)测试 — 2026-09-16
规则来源:《CMA 资产负债表重分类规则表 v1》(任总 2026-09-16 批准)
被测:`app/services/report_reclass.py` + `/api/cma/reports/balance-sheet*` 端点
覆盖:
T1 R1~R10 触发与转正(含互为镜像的 R3/R4、多规则叠加)
T2 模板未列报目标项(R7/R8 应收/应付票据)→ 虚拟行列报
T3 不变量:Δ资产=Δ负债、权益不变、勾稽差额不变、无负数(规则范围内)
T4 幂等:同参数重复分析结果一致(不写库)
T5 多租户隔离:entity1/entity2 独立,不串账套
T6 取数口径:basis=end(期末余额,累计)vs basis=period(本期发生额)
T7 无凭证明细 → 示例数据路径不受影响(不报错、不重分类)
T8 R11/R12 与模板待核项如实暴露(blocked / template_gap)
T9 规则范围外的反方向余额(货币资金/存货)如实标记为未覆盖,不误报"已解决"
T10 底稿导出(JSON + Excel)可用
"""
from datetime import date
import pytest
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 VoucherDetail, Subject
from app.services import report_reclass as rr
BASE = "/api/cma/reports"
PERIOD = "2026-06"
PREV = "2026-05"
_n = {"v": 0}
def _v(db: Session, entity_id: int, period: str, lines, carry_forward: int = 0):
"""插入一张凭证的若干行(lines: [(subject_code, debit, credit)])"""
_n["v"] += 1
y, m = period.split("-")
for code, d, c in lines:
db.add(VoucherDetail(
entity_id=entity_id, voucher_no=f"T{_n['v']:05d}",
voucher_date=date(int(y), int(m), 15), subject_code=code,
debit_amount=d, credit_amount=c, period=period,
summary="test", carry_forward=carry_forward,
))
db.commit()
def seed_reverse_items(db: Session, entity_id: int = 1, period: str = PERIOD):
"""构造含 R1~R10 反向余额的**平衡**账套(每张凭证借贷相等)。
期望触发(原始余额 NSB<0):
R2 2203 预收账款 借方 100,000 → 应收账款
R3 2202 应付账款 借方 30,000 → 预付账款
R4 1123 预付账款 贷方 50,000 → 应付账款
R5 1221 其他应收款 贷方 12,000 → 其他应付款
R6 2241 其他应付款 借方 7,000 → 其他应收款
R9 2221 应交税费 借方 25,000 → 其他流动资产
R10 2211 应付职工薪酬 借方 9,000 → 其他应收款
"""
_v(db, entity_id, period, [("1002", 1_000_000, 0), ("4001", 0, 1_000_000)]) # 实收资本
_v(db, entity_id, period, [("1002", 400_000, 0), ("2203", 0, 400_000)]) # 预收
_v(db, entity_id, period, [("2203", 500_000, 0), ("1002", 0, 500_000)]) # 预收反方向
_v(db, entity_id, period, [("1123", 80_000, 0), ("1002", 0, 80_000)]) # 预付
_v(db, entity_id, period, [("1002", 130_000, 0), ("1123", 0, 130_000)]) # 预付反方向
_v(db, entity_id, period, [("2202", 30_000, 0), ("1002", 0, 30_000)]) # 应付借方
_v(db, entity_id, period, [("1002", 12_000, 0), ("1221", 0, 12_000)]) # 其他应收反方向
_v(db, entity_id, period, [("2241", 7_000, 0), ("1002", 0, 7_000)]) # 其他应付借方
_v(db, entity_id, period, [("2221", 25_000, 0), ("1002", 0, 25_000)]) # 应交税费留抵
_v(db, entity_id, period, [("2211", 9_000, 0), ("1002", 0, 9_000)]) # 应付职工薪酬多付
# 规则未覆盖的反方向余额(货币资金):库存现金贷方 1,500,000 → 货币资金为负
_v(db, entity_id, period, [("1122", 1_500_000, 0), ("1001", 0, 1_500_000)])
def seed_receivable_side(db: Session, entity_id: int = 1, period: str = PREV):
"""R1 + R7/R8(模板未列报的票据类)"""
_v(db, entity_id, period, [("1002", 300_000, 0), ("1122", 0, 300_000)]) # 回款超应收
_v(db, entity_id, period, [("1002", 60_000, 0), ("1121", 0, 60_000)]) # 应收票据反方向
_v(db, entity_id, period, [("2201", 20_000, 0), ("1002", 0, 20_000)]) # 应付票据借方
def _analyze(db, period=PERIOD, entity_id=1, basis="end"):
from app.api.reports import BALANCE_SHEET_SECTIONS
return rr.analyze(db, entity_id, period, basis=basis, template_sections=BALANCE_SHEET_SECTIONS)
class TestReclassEngine:
def test_t1_triggers_and_reversal(self, db: Session):
seed_reverse_items(db)
p = _analyze(db)
got = {e["rule"]: e for e in p["entries"]}
assert set(got) == {"R2", "R3", "R4", "R5", "R6", "R9", "R10"}, f"实际触发: {sorted(got)}"
assert got["R2"]["reclass_amount"] == pytest.approx(100_000.0)
assert got["R2"]["target_item"] == "应收账款" and got["R2"]["target_in_template"] is True
assert got["R3"]["reclass_amount"] == pytest.approx(30_000.0)
assert got["R4"]["reclass_amount"] == pytest.approx(50_000.0)
# 互为镜像的 R3/R4 各自按**原始余额**触发,不互相影响,两侧都被转正
assert p["after"]["2202"] == pytest.approx(50_000.0)
assert p["after"]["1123"] == pytest.approx(30_000.0)
assert p["after"]["2203"] == pytest.approx(0.0)
assert p["after"]["1221"] == pytest.approx(16_000.0) # R5 转出 + R6/R10 转入
assert p["after"]["2241"] == pytest.approx(12_000.0)
assert p["after"]["OCA_OTHER"] == pytest.approx(25_000.0) # R9 → 其他流动资产
assert p["after"]["2211"] == pytest.approx(0.0)
# 全部转正额 = 重分类总额
assert sum(e["reclass_amount"] for e in p["entries"]) == pytest.approx(233_000.0)
def test_t2_template_gap_virtual_lines(self, db: Session):
seed_receivable_side(db)
p = _analyze(db, period=PREV)
got = {e["rule"]: e for e in p["entries"]}
assert set(got) == {"R1", "R7", "R8"}
assert got["R1"]["target_item"] == "预收账款" # 小企业准则无「合同负债」
assert got["R7"]["target_item"] == "应付票据"
assert got["R7"]["target_in_template"] is False # 模板未列报 → 虚拟行
assert p["after"]["1121"] == pytest.approx(20_000.0)
assert p["after"]["2201"] == pytest.approx(60_000.0)
assert p["after"]["1122"] == pytest.approx(0.0)
assert p["after"]["2203"] == pytest.approx(300_000.0)
assert any(g["rule"] == "R7" and g["status"] == "template_gap" for g in p["template_gaps"])
def test_t3_invariants(self, db: Session):
seed_reverse_items(db)
p = _analyze(db)
inv = p["invariants"]
assert inv["delta_assets_equals_delta_liabilities"] is True
assert inv["assets_delta"] == pytest.approx(233_000.0)
assert inv["liabilities_delta"] == pytest.approx(233_000.0)
assert inv["equity_unchanged"] is True
assert inv["residual_unchanged"] is True
assert inv["identity_holds_before"] is True and inv["identity_holds_after"] is True
# 跨方向重分类必然改变总资产 —— 不隐藏这一点
assert inv["total_assets_unchanged"] is False
assert inv["reclass_total"] == pytest.approx(233_000.0)
# 规则范围内的负数项目重分类后清零
assert inv["no_negative_in_scope_after"] is True
assert p["negatives"]["after_in_scope"] == []
# 规则未覆盖的货币资金负数如实标记(不误报已解决)
out = [n for n in p["negatives"]["after"] if not n["in_reclass_scope"]]
assert any(n["name"] == "货币资金" for n in out)
def test_t4_idempotent(self, db: Session):
seed_reverse_items(db)
a = _analyze(db)
b = _analyze(db)
assert [e["rule"] for e in a["entries"]] == [e["rule"] for e in b["entries"]]
assert a["after"] == b["after"]
assert a["invariants"]["reclass_total"] == b["invariants"]["reclass_total"]
def test_t5_entity_isolation(self, db: Session):
seed_reverse_items(db, entity_id=1)
_v(db, 2, PERIOD, [("1002", 5_000, 0), ("4001", 0, 5_000)])
p1 = _analyze(db, entity_id=1)
p2 = _analyze(db, entity_id=2)
assert p1["invariants"]["entries_count"] == 7
assert p2["invariants"]["entries_count"] == 0 # entity2 无反方向余额
assert p2["after"]["2202"] == pytest.approx(0.0)
assert p2["invariants"]["assets_after"] == pytest.approx(5_000.0)
# entity3 完全无数据 → 不重分类、不报错
p3 = _analyze(db, entity_id=3)
assert p3["has_voucher_data"] is False and p3["entries"] == []
def test_t6_basis_semantics(self, db: Session):
# 2026-05 预收贷方 400,000(正常);2026-06 预收借方 500,000
_v(db, 1, PREV, [("1002", 400_000, 0), ("2203", 0, 400_000)])
_v(db, 1, PERIOD, [("2203", 500_000, 0), ("1002", 0, 500_000)])
end = _analyze(db, period=PERIOD, basis="end") # 期末余额 = 累计 → -100,000
per = _analyze(db, period=PERIOD, basis="period") # 本期发生额 → -500,000
assert end["basis"] == "end" and per["basis"] == "period"
assert end["raw"]["2203"] == pytest.approx(-100_000.0)
assert per["raw"]["2203"] == pytest.approx(-500_000.0)
# 两个口径各自自洽(都转为正数),但转正额不同 → 报表列报必须用期末余额口径
assert [e["rule"] for e in end["entries"]] == ["R2"]
assert [e["rule"] for e in per["entries"]] == ["R2"]
assert end["entries"][0]["reclass_amount"] == pytest.approx(100_000.0)
assert per["entries"][0]["reclass_amount"] == pytest.approx(500_000.0)
assert per["after"]["2203"] == pytest.approx(0.0)
def test_t8_blocked_rules_exposed(self, db: Session):
seed_reverse_items(db)
p = _analyze(db)
gaps = {g["rule"]: g for g in p["template_gaps"]}
assert gaps["R11"]["status"] == "blocked" and gaps["R12"]["status"] == "blocked"
assert gaps["R9"]["status"] == "template_gap"
# 阻塞规则(R11/R12)绝不落地
triggered = {e["rule"] for e in p["entries"]}
assert "R11" not in triggered and "R12" not in triggered
# 模板未列报的目标项(R7/R8/R9)落地时在报表内按虚拟行列报
for rule in ("R7", "R8", "R9"):
assert gaps[rule]["in_report"] is True
def test_t10_working_paper_rows(self, db: Session):
seed_reverse_items(db)
p = _analyze(db)
rows = rr.entry_rows(p)
assert len(rows) == len(p["entries"]) == 7
for row in rows:
assert row["处理"] == "调表不调账" and row["转正额(元)"] > 0
assert row["来源科目"] and row["目标报表项目"]
diag = {r["项目"]: r for r in rr.diagnostic_rows(p)}
assert diag["Δ资产 = Δ负债"]["值"] == "PASS"
assert diag["权益不变"]["值"] == "PASS"
assert diag["规则范围内负数项目(重分类后)"]["值"] == 0
class TestReclassEndpoints:
def _auth(self, client, db):
create_test_user(db)
return auth_header(get_token_for_user(client))
def test_t7_no_voucher_data_demo_path(self, client: TestClient, db: Session):
h = self._auth(client, db)
resp = client.get(f"{BASE}/balance-sheet?period={PERIOD}", headers=h)
assert resp.status_code == 200
data = resp.json()
assert len(data["sections"]) == 5
assert data["reclass"]["entries"] == []
assert data["reclass"]["applied"] is False
assert data["all_items_have_data"] is False
assert data["totals"]["balanced"] is True
def test_t7b_reclass_applied_on_real_data(self, client: TestClient, db: Session):
seed_reverse_items(db)
h = self._auth(client, db)
data = client.get(f"{BASE}/balance-sheet?period={PERIOD}&basis=end", headers=h).json()
assert data["basis"] == "end"
assert data["reclass"]["applied"] is True
assert data["reclass"]["invariants"]["entries_count"] == 7
assert data["reclass"]["invariants"]["no_negative_in_scope_after"] is True
# 有凭证明细 → 不再混入示例数据(展示口径与落库口径同源)
assert all(not ln["is_demo"] for sec in data["sections"] for ln in sec["lines"])
# 明细行带原值/调整额,可追溯
lines = {ln["item_key"]: ln for sec in data["sections"] for ln in sec["lines"]}
assert lines["2203"]["raw_value"] == pytest.approx(-100_000.0)
assert lines["2203"]["reclass_adjustment"] == pytest.approx(100_000.0)
assert lines["2203"]["end_value"] == pytest.approx(0.0)
# 不变量同步暴露给前端/底稿
inv = data["reclass"]["invariants"]
assert inv["delta_assets_equals_delta_liabilities"] and inv["equity_unchanged"]
assert data["reclass"]["working_paper_url"]
def test_t2b_virtual_line_rendered_in_section(self, client: TestClient, db: Session):
seed_receivable_side(db)
create_test_user(db)
h = auth_header(get_token_for_user(client))
data = client.get(f"{BASE}/balance-sheet?period={PREV}", headers=h).json()
ca = next(s for s in data["sections"] if s["key"] == "current_assets")
cl = next(s for s in data["sections"] if s["key"] == "current_liabilities")
assert any(ln["item_key"] == "1121" and ln["is_reclass_line"] for ln in ca["lines"])
assert any(ln["item_key"] == "2201" and ln["is_reclass_line"] for ln in cl["lines"])
def test_t10b_working_paper_endpoint(self, client: TestClient, db: Session):
seed_reverse_items(db)
create_test_user(db)
h = auth_header(get_token_for_user(client))
wp = client.get(f"{BASE}/balance-sheet/reclass-working-paper?period={PERIOD}", headers=h)
assert wp.status_code == 200
body = wp.json()
assert body["accounting_method"].startswith("调表不调账")
assert body["rule_version"] == rr.RULE_VERSION
assert len(body["entries"]) == 7
assert body["invariants"]["reclass_total"] == pytest.approx(233_000.0)
assert len(body["rules"]) == 12 # R1~R12 全部登记
xl = client.get(f"{BASE}/balance-sheet/reclass-working-paper/export?period={PERIOD}", headers=h)
assert xl.status_code == 200
assert "spreadsheetml" in xl.headers["content-type"]
assert len(xl.content) > 4000 # 非空 Excel
def test_t5b_entity_isolation_endpoint(self, client: TestClient, db: Session):
seed_reverse_items(db, entity_id=1)
_v(db, 2, PERIOD, [("1002", 5_000, 0), ("4001", 0, 5_000)])
create_test_user(db)
h = auth_header(get_token_for_user(client)) # 登录账套 = entity1
d1 = client.get(f"{BASE}/balance-sheet?period={PERIOD}", headers=h).json()
assert d1["reclass"]["invariants"]["entries_count"] == 7
# 显式声明另一账套 → 403(多租户边界)
assert client.get(f"{BASE}/balance-sheet?period={PERIOD}&entity_id=2", headers=h).status_code == 403
# 无凭证明细的账套(切到 2 需另一 token,这里用内部调用验证引擎层隔离)
p2 = _analyze(db, entity_id=2)
assert p2["invariants"]["entries_count"] == 0
class TestUnmappedDiagnosis:
def test_unmapped_subjects_and_gap_explained(self, db: Session):
"""模板未列报科目 → 出现在诊断里,且勾稽差额与之一致"""
_v(db, 1, PERIOD, [("1002", 1_000, 0), ("4001", 0, 1_000)])
_v(db, 1, PERIOD, [("1801", 200, 0), ("1002", 0, 200)]) # 长期待摊费用(新补的行,不再未列报)
_v(db, 1, PERIOD, [("1901", 300, 0), ("1002", 0, 300)]) # 待处理财产损溢(模板未列报)
db.add(Subject(subject_code="1901", subject_name="待处理财产损溢", category="asset"))
db.add(Subject(subject_code="190102", subject_name="待处理流动资产损溢", category="detail"))
db.commit()
p = _analyze(db)
codes = [u["subject_code"] for u in p["unmapped_subjects"]]
assert "1901" in codes
unmapped_sum = sum(u["normal_side_balance"] for u in p["unmapped_subjects"])
assert p["invariants"]["residual_after"] == pytest.approx(-unmapped_sum)