"""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, ) def test_reverse_uses_explicit_effective_date_across_cutoff(self) -> None: # Reversing with an independent effective date must keep the original # impact for cutoffs before it and net it to zero only on/after it — # never rewrite the historical period retroactively. from bank_importer import positions approved = self.decide("approve_new") event_id = approved["ledger_event_id"] self.assertEqual("2026-01-10", self.current(event_id)["effective_at"][:10]) manual_records.decide( self.connection, self.record["id"], "reverse", reason="误录冲销", expected_decision_id=approved["decision_id"], request_key="dec-rev-date", actor=self.admin, effective_at="2026-06-15", ) reversal = self.connection.execute( "SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'" ).fetchone() self.assertEqual("2026-06-15", reversal["effective_at"][:10]) before = positions.company_balances( self.connection, from_="2026-01-01", cutoff="2026-05-31" ) after = positions.company_balances( self.connection, from_="2026-01-01", cutoff="2026-07-31" ) for item in before["items"]: if item["company_id"] == self.company_a: self.assertEqual("-50.00", item["result"]["signed_amount"]) for item in after["items"]: if item["company_id"] == self.company_a: self.assertEqual("0.00", item["result"]["signed_amount"]) def test_reverse_defaults_to_approval_business_day(self) -> None: from datetime import datetime, timedelta, timezone from bank_importer import positions approved = self.decide("approve_new") manual_records.decide( self.connection, self.record["id"], "reverse", reason="误录冲销", expected_decision_id=approved["decision_id"], request_key="dec-rev-default", actor=self.admin, ) today = datetime.now(timezone(timedelta(hours=8))).date().isoformat() reversal = self.connection.execute( "SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'" ).fetchone() self.assertEqual(today, reversal["effective_at"][:10]) # The day before the approval business day keeps the original impact. before = positions.company_balances( self.connection, from_="2026-01-01", cutoff=(datetime.fromisoformat(today) - timedelta(days=1)).date().isoformat(), ) for item in before["items"]: if item["company_id"] == self.company_a: self.assertEqual("-50.00", item["result"]["signed_amount"]) def test_reverse_of_creation_source_with_later_linked_evidence(self) -> None: # approve_new created the event; a second manual record later links # onto it. Reversing the creation source must still create an # equal-amount reversal — the original economic impact must not survive # just because other evidence was attached later. approved = self.decide("approve_new") event_id = approved["ledger_event_id"] linked = manual_records.submit( self.connection, company_id=self.company_a, counterparty_company_id=self.company_b, occurred_at="2026-01-12T09:00:00", direction="incoming", amount="50.00", currency="CNY", funding_source="other", requested_subject="receivable", request_key="mr-link-ev", actor=self.admin, ) manual_records.decide( self.connection, linked["id"], "approve_link", reason="同源补充证据", expected_decision_id=linked["decision_id"], request_key="dec-link-ev", actor=self.admin, target_ledger_event_id=event_id, ) self.assertEqual(1, len(self.position_events())) reversed_outcome = manual_records.decide( self.connection, self.record["id"], "reverse", reason="误录冲销", expected_decision_id=approved["decision_id"], request_key="dec-rev-create", 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(Decimal(original["amount"]), Decimal(reversal["amount"])) # The later linked evidence stays attached to the event. links = self.connection.execute( "SELECT * FROM ledger_event_manual_sources ORDER BY manual_record_id" ).fetchall() self.assertEqual(2, len(links)) def test_reverse_of_linked_record_after_creation_reversal_detaches_only(self) -> None: # M1 creates the event, M2 links; after M1's reversal created the # offset, reversing the linked M2 must only detach, never add a second # reversal event. approved = self.decide("approve_new") event_id = approved["ledger_event_id"] linked = manual_records.submit( self.connection, company_id=self.company_a, counterparty_company_id=self.company_b, occurred_at="2026-01-12T09:00:00", direction="incoming", amount="50.00", currency="CNY", funding_source="other", requested_subject="receivable", request_key="mr-link-ev2", actor=self.admin, ) linked_approved = manual_records.decide( self.connection, linked["id"], "approve_link", reason="同源补充证据", expected_decision_id=linked["decision_id"], request_key="dec-link-ev2", actor=self.admin, target_ledger_event_id=event_id, ) manual_records.decide( self.connection, self.record["id"], "reverse", reason="误录冲销", expected_decision_id=approved["decision_id"], request_key="dec-rev-create2", actor=self.admin, ) manual_records.decide( self.connection, linked["id"], "reverse", reason="撤销关联证据", expected_decision_id=linked_approved["decision_id"], request_key="dec-rev-link2", actor=self.admin, ) reversals = self.connection.execute( "SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'" ).fetchone()["n"] self.assertEqual(1, reversals) links = self.connection.execute( "SELECT * FROM ledger_event_manual_sources" ).fetchall() self.assertEqual(1, len(links)) 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) def test_concurrent_submit_same_request_key_is_idempotent(self) -> None: import sqlite3 results: list[dict] = [] errors: list[Exception] = [] def worker(thread_key: str) -> None: try: connection = sqlite3.connect(self.db_path, timeout=30) connection.row_factory = sqlite3.Row try: payload = manual_records.submit( 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-submit", actor=self.admin, ) results.append(payload) 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() self.assertEqual([], errors) rows = self.connection.execute( "SELECT COUNT(*) AS n FROM manual_records" ).fetchone()["n"] self.assertEqual(1, rows) self.assertEqual(1, len({payload["id"] for payload in results})) decisions = self.connection.execute( "SELECT COUNT(*) AS n FROM manual_record_decisions" ).fetchone()["n"] self.assertEqual(1, decisions) def test_concurrent_confirm_subject_same_key_creates_single_revision(self) -> None: import sqlite3 self.pair(self.company_a, self.company_b, "100.00") from bank_importer import ledger_events ledger_events.reconcile_bank_events(self.connection, actor=self.admin) event_id = self.ledger_events()[0]["id"] outcomes: list[dict] = [] errors: list[Exception] = [] def worker(thread_key: str) -> None: try: connection = sqlite3.connect(self.db_path, timeout=30) connection.row_factory = sqlite3.Row try: payload = subjects.confirm_subject( connection, event_id, perspective_company_id=self.company_a, subject_code="other_receivable", reason="并发确认", expected_revision=1, request_key="conc-subj-key", actor=self.admin, ) outcomes.append(payload) 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() self.assertEqual([], errors) confirmed = self.connection.execute( "SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE state = 'confirmed'" ).fetchone()["n"] self.assertEqual(1, confirmed) self.assertEqual(1, len({payload["revision_id"] for payload in outcomes})) def test_repeated_confirm_same_key_appends_no_noise_revision(self) -> None: self.pair(self.company_a, self.company_b, "100.00") from bank_importer import ledger_events ledger_events.reconcile_bank_events(self.connection, actor=self.admin) event_id = self.ledger_events()[0]["id"] first = subjects.confirm_subject( self.connection, event_id, perspective_company_id=self.company_a, subject_code="other_receivable", reason="确认", expected_revision=1, request_key="noise-key", actor=self.admin, ) second = subjects.confirm_subject( self.connection, event_id, perspective_company_id=self.company_a, subject_code="other_receivable", reason="重复确认", expected_revision=1, request_key="noise-key", actor=self.admin, ) self.assertEqual(first["revision_id"], second["revision_id"]) revisions = self.connection.execute( "SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE ledger_event_id = ?", (event_id,), ).fetchone()["n"] # One pending_subject + one confirmed; the replay appended nothing. self.assertEqual(2, revisions) confirmed = self.connection.execute( "SELECT COUNT(*) AS n FROM ledger_event_revisions " "WHERE ledger_event_id = ? AND state = 'confirmed'", (event_id,), ).fetchone()["n"] self.assertEqual(1, confirmed) if __name__ == "__main__": unittest.main()