diff --git a/docs/decisions/005-canonical-transfer-matching.md b/docs/decisions/005-canonical-transfer-matching.md index 568356b..7f8fc05 100644 --- a/docs/decisions/005-canonical-transfer-matching.md +++ b/docs/decisions/005-canonical-transfer-matching.md @@ -10,8 +10,9 @@ - `current_transfer_decisions` 与 `transfer_observation_claims` 是可更新、可重建的 当前投影,`source_row_id` 主键保证一条观察不可能同时属于两个当前事件。 - B-44 余额计算只读 `eligible_intercompany_events` 视图,只含 - `active + current + classification='intercompany'`;单边内部、待审、同公司调拨、 - 外部事件全部排除。 + `active + current + classification='intercompany'`,且单边事件必须 + `locked = 1`(管理员按证据确认)才可计算;paired 事件不受锁定状态限制; + 单边内部、待审、同公司调拨、外部事件全部排除。 ## 已确认业务口径(B-114) diff --git a/src/bank_importer/db.py b/src/bank_importer/db.py index 2f8fc48..b83a618 100644 --- a/src/bank_importer/db.py +++ b/src/bank_importer/db.py @@ -507,7 +507,8 @@ MIGRATIONS: tuple[Migration, ...] = ( ON payer.decision_id = d.id AND payer.role = 'payer' JOIN transfer_decision_participants payee ON payee.decision_id = d.id AND payee.role = 'payee' - WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'; + WHERE e.lifecycle = 'active' AND d.classification = 'intercompany' + AND (d.pairing = 'paired' OR d.locked = 1); CREATE TRIGGER canonical_transfer_events_no_delete BEFORE DELETE ON canonical_transfer_events BEGIN SELECT RAISE (ABORT, 'canonical_transfer_events rows are immutable'); END; diff --git a/src/bank_importer/matching.py b/src/bank_importer/matching.py index c642872..e0f58da 100644 --- a/src/bank_importer/matching.py +++ b/src/bank_importer/matching.py @@ -570,6 +570,24 @@ def _mirror_level( return None +def _reference_same(row: sqlite3.Row, other: sqlite3.Row) -> bool: + """Positive reference evidence: both sides carry one equal reference. + + ``None == None`` must never count as reference agreement; only "both + reference numbers exist and are equal" satisfies the reference layer of the + M1/M3 evidence. A single-sided reference is not equality either. + """ + ref_row = normalize_reference(row["reference"]) + ref_other = normalize_reference(other["reference"]) + return bool(ref_row and ref_other and ref_row == ref_other) + + +def _reference_conflict(row: sqlite3.Row, other: sqlite3.Row) -> bool: + ref_row = normalize_reference(row["reference"]) + ref_other = normalize_reference(other["reference"]) + return bool(ref_row and ref_other and ref_row != ref_other) + + def _classify_tier( row: sqlite3.Row, other: sqlite3.Row, @@ -582,19 +600,19 @@ def _classify_tier( row_date = _parse_datetime(row["transaction_at"]).date() other_date = _parse_datetime(other["transaction_at"]).date() date_diff = abs((row_date - other_date).days) - ref_row = normalize_reference(row["reference"]) - ref_other = normalize_reference(other["reference"]) - ref_conflict = bool(ref_row and ref_other and ref_row != ref_other) - summary_match = bool(ref_row and ref_row == ref_other) or _summary_equal(row, other) + ref_same = _reference_same(row, other) + ref_conflict = _reference_conflict(row, other) if mirror == "exact": - if ref_row and ref_other and ref_row == ref_other and date_diff <= 3: + if ref_same and date_diff <= 3: return "M1" if date_diff <= 1 and not ref_conflict: return "M2" return "R1" - # alias / personal-mapping mirror - if date_diff <= 1 and not ref_conflict and (ref_row == ref_other or _summary_equal(row, other)): + # alias / personal-mapping mirror: only equal references (both present) or a + # deterministically equal summary is positive evidence; an absent reference + # is not. + if date_diff <= 1 and not ref_conflict and (ref_same or _summary_equal(row, other)): return "M3" return "R1" @@ -602,11 +620,9 @@ def _classify_tier( def _tier_evidence( row: sqlite3.Row, other: sqlite3.Row ) -> tuple[str | None, bool]: - ref_row = normalize_reference(row["reference"]) - ref_other = normalize_reference(other["reference"]) - if ref_row and ref_other and ref_row == ref_other: + if _reference_same(row, other): ref_match = "same" - elif ref_row and ref_other: + elif _reference_conflict(row, other): ref_match = "conflict" else: ref_match = None diff --git a/tests/test_matching.py b/tests/test_matching.py index ddc9069..01a5b29 100644 --- a/tests/test_matching.py +++ b/tests/test_matching.py @@ -677,6 +677,125 @@ class ResolutionTests(MatchingBase): 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(