Files
caiwuzongzhang/tests/test_manual_records.py
T
腾讯WorkBuddy 85293b79df B-44: intercompany ledger events, subject review and drill-down evidence
- migration 6: manual_records, ledger_event_revisions chain, current
  projections, source claims, subject suggestions, eligible_position_events
- ledger_events.py: bank-event reconciliation, reversal/adjustment/reopen,
  append-only revision chain and rebuildable current projection
- subjects.py: fixed subject mirror, draft suggestion dictionary, explicit
  administrator subject confirmation with expected_revision + idempotency
- manual_records.py: submit, approve new/link, return/exception/reverse,
  candidate hints, idempotent replay and concurrency-safe claims
- positions.py: Decimal aggregation, both-perspective conservation asserts,
  cutoff window, unresolved gross buckets, keyset pagination, evidence
  visibility (visible/masked/missing)
- server.py: admin + company intercompany APIs with tenant isolation (404 on
  cross-tenant reads, 403 on company writes) and auto reconcile wiring
- admin/company portals: balance directory, pair drill-down drawer, evidence
  drawer, subject/manual audit queue, company balance summary
- tests: ledger events, subjects, manual records, positions, HTTP API and
  migration persistence (233 total, all green)
2026-08-19 11:58:25 +08:00

349 lines
15 KiB
Python

"""B-44 manual record tests: submit idempotency, approve new/link, return,
exception, reverse, deduplication, idempotent replay and concurrency."""
from __future__ import annotations
from decimal import Decimal
import threading
import unittest
from bank_importer import ledger_events, manual_records, matching, subjects
from ledger_helpers import LedgerBase
class SubmitTests(LedgerBase):
def submit(self, **overrides):
params = dict(
company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00",
direction="incoming",
amount="50.00",
currency="CNY",
funding_source="other",
requested_subject="other_receivable",
request_key="mr-key",
actor=self.admin,
summary="归还往来款",
)
params.update(overrides)
return manual_records.submit(self.connection, **params)
def test_submit_creates_pending_decision(self) -> None:
payload = self.submit()
self.assertEqual("pending", payload["state"])
self.assertEqual(Decimal("50.00"), Decimal(payload["amount"]))
self.assertEqual(1, payload["decision_revision"])
self.assertEqual([], payload["candidates"])
def test_submit_idempotent_on_request_key(self) -> None:
first = self.submit()
second = self.submit()
self.assertEqual(first["id"], second["id"])
self.assertTrue(second["idempotent_replay"])
decisions = self.connection.execute(
"SELECT COUNT(*) AS n FROM manual_record_decisions"
).fetchone()["n"]
self.assertEqual(1, decisions)
def test_submit_validations(self) -> None:
cases = (
({"counterparty_company_id": self.company_a}, "不能与本公司相同"),
({"amount": "0"}, "必须大于零"),
({"amount": "abc"}, "十进制"),
({"funding_source": "credit_card"}, "资金来源"),
({"requested_subject": "equity"}, "科目"),
({"request_key": ""}, "request_key"),
({"counterparty_company_id": 9999}, "公司不存在"),
)
for overrides, expected in cases:
with self.subTest(overrides=overrides):
with self.assertRaises(manual_records.ManualInputError) as ctx:
self.submit(**overrides)
self.assertIn(expected, str(ctx.exception))
def test_bank_account_must_belong_to_submitting_company(self) -> None:
with self.assertRaises(manual_records.ManualInputError):
self.submit(
funding_source="approved_bank_account",
bank_account_id=self.account_b["id"],
)
def test_related_source_row_never_modified(self) -> None:
row_id = self.add_row(
self.company_a, own_account="6222000000000001",
cp_account="6222000000000002", expense="100.00",
)
payload = self.submit(related_source_row_id=row_id)
self.assertEqual(row_id, payload["related_source_row_id"])
with self.assertRaises(Exception):
self.connection.execute(
"UPDATE source_rows SET expense = '0' WHERE id = ?", (row_id,)
)
self.connection.rollback()
class DecisionTests(LedgerBase):
def setUp(self) -> None:
super().setUp()
self.record = manual_records.submit(
self.connection,
company_id=self.company_a, counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00", direction="incoming",
amount="50.00", currency="CNY", funding_source="other",
requested_subject="other_receivable", request_key="mr-1",
actor=self.admin, summary="归还往来款",
)
def decide(self, action, **overrides):
params = dict(
record_id=self.record["id"],
action=action,
reason="管理员审核",
expected_decision_id=self.record["decision_id"],
request_key="dec-key",
actor=self.admin,
)
params.update(overrides)
return manual_records.decide(self.connection, **params)
def test_approve_new_creates_confirmed_event_with_requested_subject(self) -> None:
outcome = self.decide("approve_new")
self.assertEqual("approved", outcome["state"])
event_id = outcome["ledger_event_id"]
revision = ledger_events.current_revision(self.connection, event_id)
self.assertEqual("confirmed", revision["state"])
# incoming 50 from A's perspective: B is the payer, A the payee.
self.assertEqual(self.company_b, revision["payer_company_id"])
self.assertEqual(self.company_a, revision["payee_company_id"])
self.assertEqual(self.company_a, revision["perspective_company_id"])
self.assertEqual("other_receivable", revision["subject_code"])
# Exactly one position impact.
positions = self.connection.execute(
"SELECT * FROM eligible_position_events"
).fetchall()
self.assertEqual(1, len(positions))
def test_approve_new_replay_is_idempotent(self) -> None:
first = self.decide("approve_new")
second = self.decide("approve_new")
self.assertEqual(first["decision_id"], second["decision_id"])
events = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_events"
).fetchone()["n"]
self.assertEqual(1, events)
def test_return_and_exception_never_produce_balance(self) -> None:
for action in ("return", "exception"):
with self.subTest(action=action):
fresh = manual_records.submit(
self.connection,
company_id=self.company_a, counterparty_company_id=self.company_b,
occurred_at="2026-01-11T09:00:00", direction="incoming",
amount="10.00", currency="CNY", funding_source="other",
requested_subject="receivable", request_key=f"mr-{action}",
actor=self.admin,
)
outcome = manual_records.decide(
self.connection, fresh["id"], action,
reason="材料不足" if action == "return" else "转入异常",
expected_decision_id=fresh["decision_id"],
request_key=f"dec-{action}", actor=self.admin,
)
expected_state = "returned" if action == "return" else "exception"
self.assertEqual(expected_state, outcome["state"])
self.assertEqual(0, self.connection.execute(
"SELECT COUNT(*) AS n FROM eligible_position_events"
).fetchone()["n"])
def test_reverse_of_approved_record_creates_reversal_event(self) -> None:
approved = self.decide("approve_new")
reversed_outcome = manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="误录,冲销", expected_decision_id=approved["decision_id"],
request_key="dec-rev", actor=self.admin,
)
self.assertEqual("reversed", reversed_outcome["state"])
rows = self.connection.execute(
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
).fetchall()
self.assertEqual(2, len(rows))
original, reversal = rows
self.assertEqual("normal", original["posting_kind"])
self.assertEqual("reversal", reversal["posting_kind"])
self.assertEqual(original["payer_company_id"], reversal["payee_company_id"])
self.assertEqual(Decimal(original["amount"]), Decimal(reversal["amount"]))
# Net position is zero.
signed = Decimal(original["amount"]) * (
1 if original["payer_company_id"] == self.company_a else -1
) + Decimal(reversal["amount"]) * (
1 if reversal["payer_company_id"] == self.company_a else -1
)
self.assertEqual(Decimal("0"), signed)
def test_approve_link_to_bank_event_adds_evidence_not_impact(self) -> None:
# Build an eligible bank event and confirm its subject.
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
bank_ledger_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, bank_ledger_id,
perspective_company_id=self.company_a, subject_code="other_receivable",
reason="借款", expected_revision=1, request_key="subj-1",
actor=self.admin,
)
before = len(self.position_events())
outcome = manual_records.decide(
self.connection, self.record["id"], "approve_link",
reason="与银行事件同源", expected_decision_id=self.record["decision_id"],
request_key="dec-link", actor=self.admin,
target_ledger_event_id=bank_ledger_id,
)
self.assertEqual("approved", outcome["state"])
self.assertEqual(bank_ledger_id, outcome["ledger_event_id"])
after = len(self.position_events())
self.assertEqual(before, after)
sources = self.connection.execute(
"SELECT * FROM ledger_event_manual_sources WHERE manual_record_id = ?",
(self.record["id"],),
).fetchall()
self.assertEqual(1, len(sources))
self.assertEqual(bank_ledger_id, sources[0]["ledger_event_id"])
def test_approve_link_requires_target(self) -> None:
with self.assertRaises(manual_records.ManualInputError):
self.decide("approve_link")
def test_reverse_of_linked_record_detaches_claim_only(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
bank_ledger_id = self.ledger_events()[0]["id"]
subjects.confirm_subject(
self.connection, bank_ledger_id,
perspective_company_id=self.company_a, subject_code="other_receivable",
reason="借款", expected_revision=1, request_key="subj-1",
actor=self.admin,
)
approved = manual_records.decide(
self.connection, self.record["id"], "approve_link",
reason="同源", expected_decision_id=self.record["decision_id"],
request_key="dec-link", actor=self.admin,
target_ledger_event_id=bank_ledger_id,
)
reversed_outcome = manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="撤销关联", expected_decision_id=approved["decision_id"],
request_key="dec-unlink", actor=self.admin,
)
self.assertEqual("reversed", reversed_outcome["state"])
# No reversal event is created for a linked claim.
reversals = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
).fetchone()["n"]
self.assertEqual(0, reversals)
# The bank impact is untouched.
self.assertEqual(1, len(self.position_events()))
def test_stale_expected_decision_conflicts(self) -> None:
approved = self.decide("approve_new")
with self.assertRaises(manual_records.ManualConflictError):
manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="冲销", expected_decision_id=self.record["decision_id"],
request_key="dec-stale", actor=self.admin,
)
def test_returned_record_cannot_be_reversed(self) -> None:
returned = self.decide("return", reason="材料不足")
with self.assertRaises(manual_records.ManualConflictError):
manual_records.decide(
self.connection, self.record["id"], "reverse",
reason="冲销", expected_decision_id=returned["decision_id"],
request_key="dec-x", actor=self.admin,
)
class DedupAndConcurrencyTests(LedgerBase):
def test_duplicate_manual_record_pairing_candidate(self) -> None:
manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00", direction="outgoing",
amount="40.00", currency="CNY", funding_source="other",
requested_subject="other_receivable", request_key="mr-a",
actor=self.admin,
)
other = manual_records.submit(
self.connection, company_id=self.company_b,
counterparty_company_id=self.company_a,
occurred_at="2026-01-10T10:00:00", direction="incoming",
amount="40.00", currency="CNY", funding_source="other",
requested_subject="other_payable", request_key="mr-b",
actor=self.admin,
)
candidates = manual_records.find_candidates(self.connection, other["id"])
self.assertEqual(1, len(candidates))
self.assertEqual("manual_record", candidates[0]["kind"])
def test_duplicate_bank_event_candidate_hints_link(self) -> None:
self.pair(self.company_a, self.company_b, "100.00")
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
record = manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-05T12:00:00", direction="incoming",
amount="100.00", currency="CNY", funding_source="other",
requested_subject="other_receivable", request_key="mr-c",
actor=self.admin,
)
candidates = manual_records.find_candidates(self.connection, record["id"])
self.assertTrue(any(c["kind"] == "bank_event" for c in candidates))
def test_concurrent_approve_new_counts_once(self) -> None:
record = manual_records.submit(
self.connection, company_id=self.company_a,
counterparty_company_id=self.company_b,
occurred_at="2026-01-10T09:00:00", direction="incoming",
amount="50.00", currency="CNY", funding_source="other",
requested_subject="receivable", request_key="mr-conc",
actor=self.admin,
)
errors: list[Exception] = []
def worker(thread_key: str) -> None:
import sqlite3
try:
connection = sqlite3.connect(self.db_path, timeout=30)
connection.row_factory = sqlite3.Row
try:
manual_records.decide(
connection, record["id"], "approve_new",
reason="并发审批", expected_decision_id=record["decision_id"],
request_key=f"dec-{thread_key}", actor=self.admin,
)
finally:
connection.close()
except Exception as exc: # pragma: no cover
errors.append(exc)
threads = [
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
# Two connections can still double-commit, so the UNIQUE manual source
# claim must make the second write fail or be a no-op; either way only
# one ledger event may exist.
events = self.connection.execute(
"SELECT COUNT(*) AS n FROM ledger_events"
).fetchone()["n"]
self.assertEqual(1, events)
if __name__ == "__main__":
unittest.main()