Files
cma-management/backend/tests/test_report_reclass.py
T
Hermes CI Fix ae6f71dbc0 feat(cma-report): 建账期初层 + 方向判定可信度 + 权益行科目集(M1/M2/M7,规则表 v1.1)
- M1 时点报表口径:余额 = 期初(report_opening_balances) + 累计发生额;期初只作用于取数层
  (不生成凭证/不写 voucher_details/不改 kpi_values);basis=period 不套期初;受 effective_date 约束
- M2 期初未补 → direction_check_unreliable=true:报表/底稿带"期初未补,方向判定不可信"水印,
  重分类触发只进底稿诊断(diagnostic_only),不落地对外行项目
- M7 权益行科目集 = {3001,3002,3101,3103,3104,3105}(含 4001/4002/4101/4103/4104 别名):补实收资本 3001
- 1901 待处理财产损溢过渡列报「其他流动资产」以保恒等式(M6 归因文案)
- 只读验收脚本 + 幂等导入器(dry-run 默认,--commit 门控)
2026-09-16 01:17:09 +08:00

445 lines
25 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.
"""资产负债表取数层重分类(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 期初污染