B-43: M3空参考号不再视为相等,单边intercompany需锁定才可计算

This commit is contained in:
腾讯WorkBuddy
2026-08-18 22:10:27 +08:00
parent df517d4a68
commit f99917321b
4 changed files with 151 additions and 14 deletions
@@ -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
+2 -1
View File
@@ -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;
+27 -11
View File
@@ -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
+119
View File
@@ -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(