#!/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())