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 门控)
This commit is contained in:
Hermes CI Fix
2026-09-16 01:17:09 +08:00
parent db3c42f766
commit ae6f71dbc0
7 changed files with 833 additions and 29 deletions
+155 -7
View File
@@ -84,6 +84,32 @@ def _analyze(db, period=PERIOD, entity_id=1, basis="end"):
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)
@@ -221,6 +247,7 @@ class TestReclassEndpoints:
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"
@@ -241,6 +268,7 @@ class TestReclassEndpoints:
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()
@@ -282,15 +310,135 @@ class TestReclassEndpoints:
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)]) # 待处理财产损溢(模板未列报)
_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"))
db.commit()
mark_opening_imported(db)
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)
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 期初污染