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