Files
cma-management/backend/tests/test_report_reclass.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

297 lines
16 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)
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)
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)
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):
"""模板未列报科目 → 出现在诊断里,且勾稽差额与之一致"""
_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)]) # 待处理财产损溢(模板未列报)
db.add(Subject(subject_code="1901", subject_name="待处理财产损溢", category="asset"))
db.add(Subject(subject_code="190102", subject_name="待处理流动资产损溢", category="detail"))
db.commit()
p = _analyze(db)
codes = [u["subject_code"] for u in p["unmapped_subjects"]]
assert "1901" in codes
unmapped_sum = sum(u["normal_side_balance"] for u in p["unmapped_subjects"])
assert p["invariants"]["residual_after"] == pytest.approx(-unmapped_sum)