B-43: M3空参考号不再视为相等,单边intercompany需锁定才可计算
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user