Files
cma-management/backend/scripts/verify_reclass_live_20260916.py
T
Hermes CI Fix ba85e03f2f
ci/woodpecker/push/main Pipeline failed
feat(cma-report): 资产负债表取数层重分类 R1~R12(调表不调账)
依据《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)
2026-09-16 00:38:01 +08:00

164 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())