"""资产负债表取数层重分类(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) # ── 建账期初层夹具(M1,2026-09-16 v1.1) ──────────────────────────────────── def seed_opening(db: Session, entity_id: int = 1, rows=None, effective_date: str = "2021-03"): """登记账套建账期初(`report_opening_balances`)。 期初层只作用于报表取数(余额 = 期初 + 累计),不生成凭证、不写 voucher_details。 rows: [(subject_code, subject_name, balance_side, amount)] """ from app.models import ReportOpeningBalance for code, name, side, amt in (rows or []): db.add(ReportOpeningBalance( entity_id=entity_id, subject_code=code, subject_name=name, balance_side=side, amount=amt, effective_date=effective_date, source="test-fixture", evidence="unit-test", status="derived", )) db.commit() def mark_opening_imported(db: Session, entity_id: int = 1): """登记"期初已补(显式为零)"标记行 —— M2 可信度两态测试用。 零额登记行也是"该账套期初已核对"的合法表达(表内有行 ⇒ direction_check_unreliable=False)。 """ seed_opening(db, entity_id, [("1001", "库存现金", "debit", 0)]) 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) mark_opening_imported(db) # v1.1:期初已登记(方向判定可信)才呈现转正 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) mark_opening_imported(db) # v1.1:期初登记后虚拟行才作为对外列报呈现 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): """1901 待处理财产损溢:v1.1 起过渡列报「其他流动资产」→ 不再是未列报科目,恒等式闭合""" _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)]) # 待处理财产损溢(v1.1:过渡列报) db.add(Subject(subject_code="1901", subject_name="待处理财产损溢", category="asset")) db.add(Subject(subject_code="190102", subject_name="待处理流动资产损溢", category="detail")) mark_opening_imported(db) p = _analyze(db) codes = [u["subject_code"] for u in p["unmapped_subjects"]] assert "1901" not in codes # v1.1:已按「其他流动资产」列报 assert p["raw"]["OCA_OTHER"] == pytest.approx(300.0) # 过渡列报后恒等式闭合(勾稽差额 0),且底稿如实标注"待清理" assert p["invariants"]["residual_after"] == pytest.approx(0.0) assert p["invariants"]["identity_holds_after"] is True diag = {r["项目"]: r for r in rr.diagnostic_rows(p)} assert "过渡列报:1901 待处理财产损溢" in diag assert "清理为零" in diag["过渡列报:1901 待处理财产损溢"]["说明"] # ── M1/M2/M7(《CMA 资产负债表重分类规则表 v1.1》§零/§三,2026-09-16) ──────── class TestOpeningBalanceLayer: """建账期初层(余额 = 期初 + 累计)+ 方向判定可信度 + 权益行科目集""" def test_t11_opening_plus_cumulative(self, db: Session): """M1:余额 = 期初 + 累计;期初只作用于 basis=end,且受 effective_date 约束""" _v(db, 1, PREV, [("1002", 400_000, 0), ("2203", 0, 400_000)]) _v(db, 1, PERIOD, [("2203", 500_000, 0), ("1002", 0, 500_000)]) seed_opening(db, 1, [ ("2203", "预收账款", "credit", 1_000_000), ("1601", "固定资产", "debit", 286_847.83), ("1602", "累计折旧", "credit", 84_690.11), ("1002", "银行存款", "debit", 797_842.28), ]) p = _analyze(db, period=PERIOD, basis="end") # 2203 = 期初贷 1,000,000 + (贷 400,000 − 借 500,000) = 900,000(贷方,正常)→ 伪触发 R2 消失 assert p["raw"]["2203"] == pytest.approx(900_000.0) assert p["entries"] == [] assert p["invariants"]["opening_imported"] is True assert p["invariants"]["opening_rows"] == 4 assert p["invariants"]["opening_balanced"] is True # 备抵科目(1602 累计折旧,贷方)反向取数:固定资产净额 = 原值 − 累计折旧 assert p["raw"]["1601|1602"] == pytest.approx(286_847.83 - 84_690.11) # basis=period(本期发生额)不含期初 —— 期初是时点余额概念 per = _analyze(db, period=PERIOD, basis="period") assert per["raw"]["2203"] == pytest.approx(-500_000.0) assert per["invariants"]["opening_imported"] is True # 期初仍在(只是不被套用) # effective_date 早于建账期 → 不套用期初 bs = rr.BalanceSource(db, 1, "2021-02", "end") assert bs.opening_nsb("2203", "liability") is None bs2 = rr.BalanceSource(db, 1, "2021-03", "end") assert bs2.opening_nsb("2203", "liability") == pytest.approx(1_000_000.0) def test_t12_direction_unreliable_two_states(self, client: TestClient, db: Session): """M2:期初未补 → direction_check_unreliable=True 且不呈现"重分类转正";期初补 → 自动转 False""" seed_reverse_items(db) create_test_user(db) h = auth_header(get_token_for_user(client)) # 态①:期初未补(现有实体实况) p = _analyze(db) assert p["direction_check_unreliable"] is True assert p["caliber_warning"].startswith("期初未补,方向判定不可信") assert all(e["diagnostic_only"] for e in p["entries"]) d = client.get(f"{BASE}/balance-sheet?period={PERIOD}&basis=end", headers=h).json() assert d["reclass"]["applied"] is False assert d["reclass"]["direction_check_unreliable"] is True assert d["reclass"]["opening"]["imported"] is False lines = {ln["item_key"]: ln for sec in d["sections"] for ln in sec["lines"]} # 对外行项目 = 原始余额(不转正);触发只进底稿诊断 assert lines["2203"]["end_value"] == pytest.approx(-100_000.0) assert lines["2203"]["reclass_adjustment"] == pytest.approx(0.0) assert lines["2203"]["diagnostic_reclass_adjustment"] == pytest.approx(100_000.0) wp = client.get(f"{BASE}/balance-sheet/reclass-working-paper?period={PERIOD}", headers=h).json() assert wp["direction_check_unreliable"] is True and wp["diagnostic_only"] is True assert len(wp["entries"]) == 7 # 诊断条目仍在底稿 assert all(e["diagnostic_only"] for e in wp["entries"]) # 态②:期初补齐(2203 期初贷 400,000 + 累计 −100,000 = 300,000 正常)→ 伪触发消失、可信 seed_opening(db, 1, [("2203", "预收账款", "credit", 400_000)]) p2 = _analyze(db) assert p2["direction_check_unreliable"] is False assert p2["caliber_warning"] == "" d2 = client.get(f"{BASE}/balance-sheet?period={PERIOD}&basis=end", headers=h).json() assert d2["reclass"]["direction_check_unreliable"] is False assert d2["reclass"]["opening"]["imported"] is True lines2 = {ln["item_key"]: ln for sec in d2["sections"] for ln in sec["lines"]} assert lines2["2203"]["end_value"] == pytest.approx(300_000.0) assert "2203" not in {e["rule"] for e in d2["reclass"]["entries"]} def test_t13_idempotent_and_readonly(self, db: Session): """幂等(同参数重复分析一致)+ 只读(期初层不生成凭证、不写 voucher_details)""" seed_reverse_items(db) seed_opening(db, 1, [("2203", "预收账款", "credit", 400_000)]) n_voucher = db.query(VoucherDetail).count() a = _analyze(db) b = _analyze(db) assert a["raw"] == b["raw"] and a["after"] == b["after"] assert [e["rule"] for e in a["entries"]] == [e["rule"] for e in b["entries"]] assert db.query(VoucherDetail).count() == n_voucher # 凭证零写入 from app.models import ReportOpeningBalance assert db.query(ReportOpeningBalance).count() == 1 # 期初行未被分析改写 def test_t14_equity_code_set(self, db: Session): """M7:权益行科目集 = {3001,3002,3101,3103,3104,3105}(含 4001/4002/4101/4103/4104 别名)""" _v(db, 1, PERIOD, [("1002", 3_000_000, 0), ("3001", 0, 2_000_000)]) _v(db, 1, PERIOD, [("1002", 300_000, 0), ("3002", 0, 300_000)]) _v(db, 1, PERIOD, [("1002", 200_000, 0), ("3101", 0, 200_000)]) _v(db, 1, PERIOD, [("3103", 0, 14_918_101.66), ("3104", 5_985_512.16, 0)]) seed_opening(db, 1, [("1002", "银行存款", "debit", 0)]) p = _analyze(db) items = p["items"] assert ("3001", 1) in items["4001"]["codes"] # 实收资本(漏 3001 = 漏 200 万) assert ("3002", 1) in items["4002"]["codes"] # 资本公积 assert ("3101", 1) in items["4101"]["codes"] # 盈余公积 assert ("3103", 1) in items["4103|4104"]["codes"] and ("3105", 1) in items["4103|4104"]["codes"] # 权益合计 = 2,000,000 + 300,000 + 200,000 + 14,918,101.66 − 5,985,512.16 assert p["totals_after"]["equity"] == pytest.approx(11_432_589.50) lines = {k: v for k, v in p["after"].items()} assert lines["4001"] == pytest.approx(2_000_000.0) # 实收资本不再为 0 / 不漏 def test_t15_opening_is_entity_scoped(self, db: Session): """期初层按账套隔离:他账套期初不串账;无期初账套 = 不可信""" seed_opening(db, 1, [("1002", "银行存款", "debit", 500_000)]) _v(db, 2, PERIOD, [("1002", 9_999, 0), ("4001", 0, 9_999)]) p1 = _analyze(db, entity_id=1) p2 = _analyze(db, entity_id=2) assert p1["invariants"]["opening_imported"] is True assert p2["invariants"]["opening_imported"] is False assert p2["direction_check_unreliable"] is True # entity2 期初未补 → 不可信 assert p2["raw"]["1001|1002|1012"] == pytest.approx(9_999.0) # 未被 entity1 期初污染