"""Matching engine tests: bilateral ordering, cross-day windows, ambiguity, same-company transfers, personal transit mappings, manual decisions, locking, idempotency, concurrency, Decimal precision and B-44 eligibility.""" from __future__ import annotations from decimal import Decimal from pathlib import Path import sqlite3 import tempfile import threading import unittest from bank_importer import auth, matching, master_data, personal_transit from bank_importer.db import connect, migrate, utc_now class MatchingBase(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() self.addCleanup(self.temp_dir.cleanup) root = Path(self.temp_dir.name) self.db_path = root / "app.db" self.connection = connect(self.db_path) self.addCleanup(self.connection.close) migrate(self.connection) self.admin = self._admin() self.company_a = self._company("甲公司") self.company_b = self._company("乙公司") self.company_c = self._company("丙公司") self.account_a = self._approved_account(self.company_a, "6222000000000001") self.account_b = self._approved_account(self.company_b, "6222000000000002") self.account_a2 = self._approved_account(self.company_a, "6222000000000003") self.account_b2 = self._approved_account(self.company_b, "6222000000000004") def _admin(self): auth.create_user(self.connection, "admin-u", "AdminPass123", "admin") return self.connection.execute( "SELECT * FROM users WHERE username = 'admin-u'" ).fetchone() def _company(self, name: str) -> int: with self.connection: cursor = self.connection.execute( "INSERT INTO companies (name, created_at, updated_at) VALUES (?, ?, ?)", (name, utc_now(), utc_now()), ) return int(cursor.lastrowid) def _approved_account(self, company_id: int, number: str, start: str = "2026-01-01"): account = master_data.submit_bank_account( self.connection, company_id=company_id, bank_name="中信银行", account_type="基本户", account_number=number, start_date=start, actor=None, ) return master_data.review_bank_account( self.connection, account["id"], "approve", None, self.admin, effective_from=start, ) def add_row( self, company_id: int, *, own_account: str, own_name: str = "测试公司", cp_account: str | None = None, cp_name: str | None = None, income: str = "0", expense: str = "0", at: str = "2026-01-05T10:00:00", currency: str = "CNY", reference: str | None = None, summary: str | None = None, sheet: str = "流水", ) -> int: with self.connection: cursor = self.connection.execute( """ INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at) VALUES (?, '测试.xlsx', 1, 'data/files/测试.xlsx', ?) """, (utc_now(), utc_now()), ) source_file_id = int(cursor.lastrowid) cursor = self.connection.execute( """ INSERT INTO import_batches (source_file_id, status, company_id, created_at, updated_at) VALUES (?, 'parsing', ?, ?, ?) """, (source_file_id, company_id, utc_now(), utc_now()), ) batch_id = int(cursor.lastrowid) cursor = self.connection.execute( """ INSERT INTO sheet_batches ( import_batch_id, sheet_name, bank_name, template_id, template_version, header_row, own_account, own_name, period_start, period_end, transaction_count, warnings, created_at ) VALUES (?, ?, '测试银行', 'test-v1', 1, 1, NULL, NULL, NULL, NULL, 1, '[]', ?) """, (batch_id, sheet, utc_now()), ) sheet_batch_id = int(cursor.lastrowid) self.connection.execute( """ INSERT INTO sheet_reviews ( import_batch_id, sheet_name, outcome, sheet_batch_id, review_status, created_at ) VALUES (?, ?, 'parsed', ?, 'confirmed', ?) """, (batch_id, sheet, sheet_batch_id, utc_now()), ) cursor = self.connection.execute( """ INSERT INTO source_rows ( sheet_batch_id, source_row, transaction_at, income, expense, balance, own_account, own_name, counterparty_account, counterparty_name, counterparty_bank, summary, purpose, reference, currency, created_at ) VALUES (?, 1, ?, ?, ?, NULL, ?, ?, ?, ?, NULL, ?, NULL, ?, ?, ?) """, ( sheet_batch_id, at, income, expense, own_account, own_name, cp_account, cp_name, summary, reference, currency, utc_now(), ), ) return int(cursor.lastrowid) def current(self, row_id: int): return self.connection.execute( """ SELECT d.classification, d.pairing, d.amount, d.currency, d.effective_at, d.mode, d.locked, d.revision FROM transfer_observation_claims c JOIN transfer_match_decisions d ON d.id = c.decision_id WHERE c.source_row_id = ? """, (row_id,), ).fetchone() def _event_of(self, row_id: int) -> int: return self.connection.execute( "SELECT event_id FROM transfer_observation_claims WHERE source_row_id = ?", (row_id,), ).fetchone()["event_id"] def eligible(self) -> list[sqlite3.Row]: return matching.eligible_intercompany_events(self.connection) def event_count(self) -> int: return self.connection.execute( "SELECT COUNT(*) AS n FROM canonical_transfer_events" ).fetchone()["n"] def decision_count(self) -> int: return self.connection.execute( "SELECT COUNT(*) AS n FROM transfer_match_decisions" ).fetchone()["n"] class BilateralOrderTests(MatchingBase): def test_a_outgoing_then_b_incoming_pairs_once(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", at="2026-01-05T10:00:00", ) matching.reconcile_rows(self.connection, [row_a]) self.assertEqual("internal_single", self.current(row_a)["classification"]) # A single internal observation must not enter the B-44 balance yet. self.assertEqual([], self.eligible()) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", at="2026-01-05T11:00:00", ) matching.reconcile_rows(self.connection, [row_b]) for row_id in (row_a, row_b): self.assertEqual("intercompany", self.current(row_id)["classification"]) self.assertEqual("paired", self.current(row_id)["pairing"]) eligible = self.eligible() self.assertEqual(1, len(eligible)) self.assertEqual(Decimal("100.00"), Decimal(eligible[0]["amount"])) self.assertEqual("CNY", eligible[0]["currency"]) self.assertEqual(self.company_a, eligible[0]["payer_company_id"]) self.assertEqual(self.company_b, eligible[0]["payee_company_id"]) self.assertEqual("paired", eligible[0]["pairing"]) # One event, one paired decision for both rows. self.assertEqual(1, self.event_count()) def test_b_incoming_then_a_outgoing_same_result(self) -> None: row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", at="2026-01-05T11:00:00", ) matching.reconcile_rows(self.connection, [row_b]) row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", at="2026-01-05T10:00:00", ) matching.reconcile_rows(self.connection, [row_a]) eligible = self.eligible() self.assertEqual(1, len(eligible)) self.assertEqual("100.00", eligible[0]["amount"]) # Economic date is the payer's outgoing posting time, import-order free. self.assertEqual("2026-01-05T10:00:00", eligible[0]["effective_at"]) self.assertEqual(self.company_a, eligible[0]["payer_company_id"]) def test_same_batch_both_sides_pairs_once(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", ) matching.reconcile_rows(self.connection, [row_b, row_a]) eligible = self.eligible() self.assertEqual(1, len(eligible)) self.assertEqual("paired", self.current(row_a)["pairing"]) # One event, one auto decision carrying both observations. self.assertEqual(1, self.event_count()) self.assertEqual(1, self.decision_count()) def test_repeated_reconcile_is_idempotent(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", ) matching.reconcile_rows(self.connection, [row_a, row_b]) before_decisions = self.decision_count() stats = matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual(self.decision_count(), before_decisions) self.assertEqual(2, stats["unchanged"]) self.assertEqual(0, stats["created_events"]) self.assertEqual(0, stats["updated_events"]) class DateWindowTests(MatchingBase): def test_m1_same_reference_within_3_days(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", at="2026-01-01T09:00:00", reference="R1001", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", at="2026-01-04T15:00:00", reference="R1001", ) matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual("matched", matching.exposed_status(self.current(row_a))) candidate = self.connection.execute( "SELECT rule_tier FROM transfer_match_candidates WHERE accepted = 1" ).fetchone() self.assertEqual("M1", candidate["rule_tier"]) def test_m2_exact_mirror_same_day_without_reference(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", ) matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual("matched", matching.exposed_status(self.current(row_a))) candidate = self.connection.execute( "SELECT rule_tier FROM transfer_match_candidates WHERE accepted = 1" ).fetchone() self.assertEqual("M2", candidate["rule_tier"]) def test_over_window_goes_to_review(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", at="2026-01-01T09:00:00", reference="R1001", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", at="2026-01-05T15:00:00", reference="R1001", ) matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual("needs_review", matching.exposed_status(self.current(row_a))) self.assertEqual([], self.eligible()) def test_reference_conflict_goes_to_review(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", reference="R-A", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", reference="R-B", ) matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual("needs_review", matching.exposed_status(self.current(row_a))) class AmbiguityTests(MatchingBase): def test_alias_across_companies_goes_to_review(self) -> None: # The same observed number is an account alias of TWO companies; the # counterparty cannot be uniquely decided, so it must go to review. company_c_account = self._approved_account(self.company_c, "6222000000000005") master_data.add_alias( self.connection, self.account_b["id"], "account", "770077007700", actor=self.admin, ) master_data.add_alias( self.connection, company_c_account["id"], "account", "770077007700", actor=self.admin, ) row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="770077007700", expense="100.00", ) matching.reconcile_rows(self.connection, [row_a]) self.assertEqual("needs_review", matching.exposed_status(self.current(row_a))) self.assertEqual([], self.eligible()) def test_two_same_tier_candidates_go_to_review(self) -> None: # Two identical incoming observations mirror the outgoing row exactly; # no deterministic rule may pick one, so the pair goes to review. row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) row_b1 = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", ) row_b2 = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", ) matching.reconcile_rows(self.connection, [row_a, row_b1, row_b2]) # The ambiguous side stays in review; the two identical observations # may not be auto-grabbed by a processing-order tie-break. self.assertEqual("needs_review", matching.exposed_status(self.current(row_a))) self.assertEqual("internal_single", matching.exposed_status(self.current(row_b1))) self.assertEqual("internal_single", matching.exposed_status(self.current(row_b2))) self.assertEqual([], self.eligible()) def test_different_amounts_never_match(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.01", ) matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual("internal_single", matching.exposed_status(self.current(row_a))) self.assertEqual("internal_single", matching.exposed_status(self.current(row_b))) self.assertEqual([], self.eligible()) class SameCompanyTests(MatchingBase): def test_same_company_transfer_excluded_from_intercompany(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000003", expense="50.00", ) row_b = self.add_row( self.company_a, own_account="6222000000000003", cp_account="6222000000000001", income="50.00", ) matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual("same_company_transfer", matching.exposed_status(self.current(row_a))) self.assertEqual("same_company_transfer", matching.exposed_status(self.current(row_b))) self.assertEqual([], self.eligible()) # The cash trail is kept: both rows remain claimed by one event. self.assertEqual(1, self.event_count()) def test_same_company_single_observation_classified_immediately(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000003", expense="50.00", ) matching.reconcile_rows(self.connection, [row_a]) self.assertEqual("same_company_transfer", matching.exposed_status(self.current(row_a))) class UnresolvedAndExternalTests(MatchingBase): def test_unknown_counterparty_stays_unresolved(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="9999999999999999", expense="100.00", ) matching.reconcile_rows(self.connection, [row_a]) self.assertEqual("unresolved", matching.exposed_status(self.current(row_a))) self.assertEqual([], self.eligible()) unresolved = matching.unresolved_amounts(self.connection, self.company_a) self.assertEqual(1, len(unresolved)) self.assertEqual("100.00", unresolved[0]["amount"]) def test_internal_single_counts_as_unresolved_amount(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) matching.reconcile_rows(self.connection, [row_a]) self.assertEqual("internal_single", matching.exposed_status(self.current(row_a))) self.assertEqual([], self.eligible()) unresolved = matching.unresolved_amounts(self.connection, self.company_a) self.assertEqual(1, len(unresolved)) self.assertEqual("100.00", unresolved[0]["amount"]) def test_admin_confirm_single_becomes_eligible(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) matching.reconcile_rows(self.connection, [row_a]) current = self.current(row_a) result = matching.apply_manual_decision( self.connection, self._event_of(row_a), "assign_participant", reason="对方公司函证确认", expected_revision=current["revision"], request_key="assign-1", actor=self.admin, participant={"role": "payee", "company_id": self.company_b}, ) self.assertEqual("intercompany", result["classification"]) self.assertTrue(result["locked"]) eligible = self.eligible() self.assertEqual(1, len(eligible)) self.assertEqual("single", eligible[0]["pairing"]) # The manual confirmation is skipped by auto reconcile. decisions_before = self.decision_count() stats = matching.reconcile_rows(self.connection, [row_a]) self.assertEqual(1, stats["skipped_locked"]) self.assertEqual(decisions_before, self.decision_count()) def test_mark_external_requires_admin_and_excludes(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="9999999999999999", expense="100.00", ) matching.reconcile_rows(self.connection, [row_a]) event_id = self._event_of(row_a) result = matching.apply_manual_decision( self.connection, event_id, "mark_external", reason="经核实为外部供应商付款", expected_revision=self.current(row_a)["revision"], request_key=None, actor=self.admin, ) self.assertEqual("external", result["classification"]) self.assertEqual([], self.eligible()) def test_reverse_frees_claims_for_reconcile(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", ) matching.reconcile_rows(self.connection, [row_a, row_b]) event_id = self._event_of(row_a) decisions_before = self.decision_count() result = matching.apply_manual_decision( self.connection, event_id, "reverse", reason="配对依据有误,需要重新匹配", expected_revision=self.current(row_a)["revision"], request_key="rev-1", actor=self.admin, ) self.assertEqual(decisions_before + 1, self.decision_count()) # Claims and current pointer are gone; B-44 sees nothing. self.assertIsNone(self.connection.execute( "SELECT 1 FROM transfer_observation_claims WHERE source_row_id = ?", (row_a,), ).fetchone()) self.assertEqual([], self.eligible()) # A re-run is allowed and re-derives deterministically. matching.reconcile_rows(self.connection, [row_a]) self.assertEqual("matched", matching.exposed_status(self.current(row_a))) class PersonalTransitTests(MatchingBase): def _submit_mapping(self, company_id: int, number: str, direction: str) -> int: mapping = personal_transit.submit_mapping( self.connection, account_number=number, account_name="张个人", represented_company_id=company_id, allowed_direction=direction, effective_from="2026-01-01", actor=self.admin, ) approved = personal_transit.review_mapping( self.connection, mapping["id"], "approve", None, self.admin, effective_from="2026-01-01", ) return approved["id"] def test_personal_mapping_resolves_counterparty(self) -> None: self._submit_mapping(self.company_b, "880088008800", "incoming") # A pays to the personal account -> the counterparty represents B. row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="880088008800", expense="100.00", ) matching.reconcile_rows(self.connection, [row_a]) self.assertEqual("internal_single", matching.exposed_status(self.current(row_a))) unresolved = matching.unresolved_amounts(self.connection, self.company_a) self.assertEqual(1, len(unresolved)) def test_personal_mapping_direction_mismatch_unresolved(self) -> None: self._submit_mapping(self.company_b, "880088008800", "incoming") # An outgoing row to the personal account is NOT covered by an # incoming-only mapping. row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="880088008800", expense="100.00", ) # reverse direction check: make it incoming via income row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="880088008800", income="100.00", ) matching.reconcile_rows(self.connection, [row_a]) matching.reconcile_rows(self.connection, [row_b]) self.assertEqual("unresolved", matching.exposed_status(self.current(row_b))) def test_unapproved_mapping_never_resolves(self) -> None: personal_transit.submit_mapping( self.connection, account_number="880088008801", account_name="李个人", represented_company_id=self.company_b, allowed_direction="both", actor=self.admin, ) row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="880088008801", expense="100.00", ) matching.reconcile_rows(self.connection, [row_a]) self.assertEqual("unresolved", matching.exposed_status(self.current(row_a))) def test_mapping_effective_window_and_resubmit_reuse(self) -> None: mapping = personal_transit.submit_mapping( self.connection, account_number="880088008802", account_name="王个人", represented_company_id=self.company_b, allowed_direction="both", effective_from="2026-03-01", actor=self.admin, ) personal_transit.review_mapping( self.connection, mapping["id"], "approve", None, self.admin, effective_from="2026-03-01", ) # Outside the effective window the mapping does not resolve. row_before = self.add_row( self.company_a, own_account="6222000000000001", cp_account="880088008802", expense="100.00", at="2026-02-20T10:00:00", ) matching.reconcile_rows(self.connection, [row_before]) self.assertEqual("unresolved", matching.exposed_status(self.current(row_before))) # Inside the window it does. row_inside = self.add_row( self.company_a, own_account="6222000000000001", cp_account="880088008802", expense="100.00", at="2026-03-05T10:00:00", ) matching.reconcile_rows(self.connection, [row_inside]) self.assertEqual("internal_single", matching.exposed_status(self.current(row_inside))) # Returned mappings reopen on resubmission reusing the same row. returned = personal_transit.submit_mapping( self.connection, account_number="880088008803", account_name="赵个人", represented_company_id=self.company_b, allowed_direction="both", actor=self.admin, ) personal_transit.review_mapping( self.connection, returned["id"], "return", "资料待补充", self.admin, ) resubmitted = personal_transit.submit_mapping( self.connection, account_number="8800 8800 8803", account_name="赵个人", represented_company_id=self.company_b, allowed_direction="incoming", actor=self.admin, ) self.assertEqual(returned["id"], resubmitted["id"]) self.assertEqual("pending", resubmitted["status"]) class ResolutionTests(MatchingBase): def test_name_alias_never_resolves_alone(self) -> None: master_data.add_alias( self.connection, self.account_b["id"], "name", "乙公司贸易部", actor=self.admin, ) row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account=None, cp_name="乙公司贸易部", expense="100.00", ) matching.reconcile_rows(self.connection, [row_a]) self.assertEqual("unresolved", matching.exposed_status(self.current(row_a))) def test_own_account_conflict_with_upload_account_goes_to_review(self) -> None: # own_account belongs to company B while the batch is uploaded for A. row = self.add_row( self.company_a, own_account="6222000000000002", cp_account="6222000000000001", expense="100.00", ) # Simulate an upload account pointing at A. self.connection.execute( """ UPDATE import_batches SET upload_bank_account_id = ? WHERE id = (SELECT b.id FROM import_batches b JOIN sheet_batches s ON s.import_batch_id = b.id JOIN source_rows r ON r.sheet_batch_id = s.id WHERE r.id = ?) """, (self.account_a["id"], row), ) matching.reconcile_rows(self.connection, [row]) self.assertEqual("needs_review", matching.exposed_status(self.current(row))) def test_upload_account_fallback_resolves_own(self) -> None: row = self.add_row( self.company_a, own_account=None, cp_account="6222000000000002", expense="100.00", ) self.connection.execute( """ UPDATE import_batches SET upload_bank_account_id = ? WHERE id = (SELECT b.id FROM import_batches b JOIN sheet_batches s ON s.import_batch_id = b.id JOIN source_rows r ON r.sheet_batch_id = s.id WHERE r.id = ?) """, (self.account_a["id"], row), ) matching.reconcile_rows(self.connection, [row]) self.assertEqual("internal_single", matching.exposed_status(self.current(row))) def test_alias_mirror_m3_pairs(self) -> None: # A's own account has an account alias; B's statement references the # alias, so the mirror is proven through the approved alias (M3). master_data.add_alias( self.connection, self.account_a["id"], "account", "770077007700", actor=self.admin, ) row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", summary="货款", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="770077007700", income="100.00", summary="货款", ) matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual("matched", matching.exposed_status(self.current(row_a))) candidate = self.connection.execute( "SELECT rule_tier FROM transfer_match_candidates WHERE accepted = 1" ).fetchone() self.assertEqual("M3", candidate["rule_tier"]) class M3EvidenceTests(MatchingBase): """M3 requires positive reference or summary evidence; a missing reference on either side is never treated as agreement (None == None is not equal).""" def _alias_for_a(self) -> None: master_data.add_alias( self.connection, self.account_a["id"], "account", "770077007700", actor=self.admin, ) def test_both_references_empty_and_summaries_different_do_not_pair(self) -> None: self._alias_for_a() row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", summary="货款A", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="770077007700", income="100.00", summary="货款B", ) matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual("needs_review", matching.exposed_status(self.current(row_a))) self.assertEqual("internal_single", matching.exposed_status(self.current(row_b))) self.assertEqual([], self.eligible()) self.assertIsNone(self.connection.execute( "SELECT rule_tier FROM transfer_match_candidates WHERE accepted = 1" ).fetchone()) def test_single_sided_reference_is_not_reference_equality(self) -> None: self._alias_for_a() row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", reference="R-ONLY-A", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="770077007700", income="100.00", ) matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual("needs_review", matching.exposed_status(self.current(row_a))) self.assertEqual("internal_single", matching.exposed_status(self.current(row_b))) self.assertEqual([], self.eligible()) def test_both_references_empty_but_summary_equal_still_pairs_m3(self) -> None: self._alias_for_a() row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", summary="货款", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="770077007700", income="100.00", summary="货款", ) matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual("matched", matching.exposed_status(self.current(row_a))) candidate = self.connection.execute( "SELECT rule_tier FROM transfer_match_candidates WHERE accepted = 1" ).fetchone() self.assertEqual("M3", candidate["rule_tier"]) class EligibleViewTests(MatchingBase): """``eligible_intercompany_events``: single-sided intercompany events only enter the B-44 output when locked; paired events are unaffected.""" def _insert_intercompany(self, *, pairing: str, locked: int) -> int: now = utc_now() with self.connection: cursor = self.connection.execute( "INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)", (now,), ) event_id = int(cursor.lastrowid) cursor = self.connection.execute( """ INSERT INTO transfer_match_decisions ( event_id, revision, classification, pairing, amount, currency, effective_at, mode, rule_version, locked, created_at ) VALUES (?, 1, 'intercompany', ?, '100.00', 'CNY', '2026-01-05T10:00:00', 'auto', 'transfer-match-v1', ?, ?) """, (event_id, pairing, locked, now), ) decision_id = int(cursor.lastrowid) for role, company_id in (("payer", self.company_a), ("payee", self.company_b)): self.connection.execute( """ INSERT INTO transfer_decision_participants ( decision_id, role, company_id, bank_account_id, resolve_method, evidence, created_at ) VALUES (?, ?, ?, NULL, 'own_exact', '{}', ?) """, (decision_id, role, company_id, now), ) self.connection.execute( "INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)", (event_id, decision_id), ) return event_id def test_unlocked_single_intercompany_is_excluded(self) -> None: self._insert_intercompany(pairing="single", locked=0) self.assertEqual([], self.eligible()) def test_locked_single_intercompany_is_included(self) -> None: self._insert_intercompany(pairing="single", locked=1) eligible = self.eligible() self.assertEqual(1, len(eligible)) self.assertEqual("single", eligible[0]["pairing"]) self.assertEqual("100.00", eligible[0]["amount"]) def test_paired_intercompany_is_included_regardless_of_lock(self) -> None: self._insert_intercompany(pairing="paired", locked=0) self._insert_intercompany(pairing="paired", locked=1) eligible = self.eligible() self.assertEqual(2, len(eligible)) self.assertTrue(all(item["pairing"] == "paired" for item in eligible)) class ManualLinkTests(MatchingBase): def test_manual_link_locks_pair(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", ) matching.reconcile_rows(self.connection, [row_a]) matching.reconcile_rows(self.connection, [row_b]) event_id = self._event_of(row_a) current_revision = self.current(row_a)["revision"] result = matching.apply_manual_decision( self.connection, event_id, "link_rows", reason="人工核对后确认是同一笔", expected_revision=current_revision, request_key="link-1", actor=self.admin, source_row_ids=[row_a, row_b], ) self.assertTrue(result["locked"]) self.assertEqual("paired", result["pairing"]) # Auto reconcile never touches locked decisions. stats = matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual(2, stats["skipped_locked"]) def test_manual_decision_requires_reason(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) matching.reconcile_rows(self.connection, [row_a]) with self.assertRaises(matching.MatchInputError): matching.apply_manual_decision( self.connection, self._event_of(row_a), "reverse", reason=" ", expected_revision=None, request_key=None, actor=self.admin, ) def test_stale_expected_revision_conflicts(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) matching.reconcile_rows(self.connection, [row_a]) event_id = self._event_of(row_a) with self.assertRaises(matching.MatchConflictError): matching.apply_manual_decision( self.connection, event_id, "reverse", reason="测试", expected_revision=999, request_key=None, actor=self.admin, ) class DecimalPrecisionTests(MatchingBase): def test_equivalent_decimal_strings_match(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.0", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", ) matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual("matched", matching.exposed_status(self.current(row_a))) def test_cent_difference_never_matches(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.01", ) matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual("internal_single", matching.exposed_status(self.current(row_a))) def test_large_amount_precision(self) -> None: amount = "99999999999999999999.99" row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense=amount, ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income=amount, ) matching.reconcile_rows(self.connection, [row_a, row_b]) eligible = self.eligible() self.assertEqual(1, len(eligible)) self.assertEqual(Decimal(amount), Decimal(eligible[0]["amount"])) def test_currency_mismatch_never_matches(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", currency="CNY", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", currency="USD", ) matching.reconcile_rows(self.connection, [row_a, row_b]) self.assertEqual("internal_single", matching.exposed_status(self.current(row_a))) class ProjectionRebuildTests(MatchingBase): def test_rebuild_matches_current_projection(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", ) matching.reconcile_rows(self.connection, [row_a, row_b]) before = [ (item["event_id"], item["decision_id"]) for item in self.connection.execute( "SELECT * FROM current_transfer_decisions" ).fetchall() ] claims_before = [ (item["source_row_id"], item["event_id"], item["decision_id"]) for item in self.connection.execute( "SELECT * FROM transfer_observation_claims" ).fetchall() ] matching.rebuild_current_projection(self.connection) after = [ (item["event_id"], item["decision_id"]) for item in self.connection.execute( "SELECT * FROM current_transfer_decisions" ).fetchall() ] claims_after = [ (item["source_row_id"], item["event_id"], item["decision_id"]) for item in self.connection.execute( "SELECT * FROM transfer_observation_claims" ).fetchall() ] self.assertEqual(sorted(before), sorted(after)) self.assertEqual(sorted(claims_before), sorted(claims_after)) class ConcurrentReconcileTests(MatchingBase): def test_concurrent_reconcile_creates_one_event(self) -> None: row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) row_b = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="100.00", ) errors: list[Exception] = [] def run() -> None: db = connect(self.db_path) try: matching.reconcile_rows(db, [row_a, row_b]) except Exception as exc: # pragma: no cover errors.append(exc) finally: db.close() threads = [threading.Thread(target=run) for _ in range(2)] for thread in threads: thread.start() for thread in threads: thread.join() self.assertEqual([], errors) self.assertEqual(1, self.event_count()) eligible = self.eligible() self.assertEqual(1, len(eligible)) claims = self.connection.execute( "SELECT COUNT(*) AS n FROM transfer_observation_claims" ).fetchone()["n"] self.assertEqual(2, claims) if __name__ == "__main__": unittest.main()