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
@@ -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())