B-43: M3空参考号不再视为相等,单边intercompany需锁定才可计算
This commit is contained in:
@@ -10,8 +10,9 @@
|
|||||||
- `current_transfer_decisions` 与 `transfer_observation_claims` 是可更新、可重建的
|
- `current_transfer_decisions` 与 `transfer_observation_claims` 是可更新、可重建的
|
||||||
当前投影,`source_row_id` 主键保证一条观察不可能同时属于两个当前事件。
|
当前投影,`source_row_id` 主键保证一条观察不可能同时属于两个当前事件。
|
||||||
- B-44 余额计算只读 `eligible_intercompany_events` 视图,只含
|
- B-44 余额计算只读 `eligible_intercompany_events` 视图,只含
|
||||||
`active + current + classification='intercompany'`;单边内部、待审、同公司调拨、
|
`active + current + classification='intercompany'`,且单边事件必须
|
||||||
外部事件全部排除。
|
`locked = 1`(管理员按证据确认)才可计算;paired 事件不受锁定状态限制;
|
||||||
|
单边内部、待审、同公司调拨、外部事件全部排除。
|
||||||
|
|
||||||
## 已确认业务口径(B-114)
|
## 已确认业务口径(B-114)
|
||||||
|
|
||||||
|
|||||||
@@ -507,7 +507,8 @@ MIGRATIONS: tuple[Migration, ...] = (
|
|||||||
ON payer.decision_id = d.id AND payer.role = 'payer'
|
ON payer.decision_id = d.id AND payer.role = 'payer'
|
||||||
JOIN transfer_decision_participants payee
|
JOIN transfer_decision_participants payee
|
||||||
ON payee.decision_id = d.id AND payee.role = '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
|
CREATE TRIGGER canonical_transfer_events_no_delete BEFORE DELETE ON canonical_transfer_events
|
||||||
BEGIN SELECT RAISE (ABORT, 'canonical_transfer_events rows are immutable'); END;
|
BEGIN SELECT RAISE (ABORT, 'canonical_transfer_events rows are immutable'); END;
|
||||||
|
|||||||
@@ -570,6 +570,24 @@ def _mirror_level(
|
|||||||
return None
|
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(
|
def _classify_tier(
|
||||||
row: sqlite3.Row,
|
row: sqlite3.Row,
|
||||||
other: sqlite3.Row,
|
other: sqlite3.Row,
|
||||||
@@ -582,19 +600,19 @@ def _classify_tier(
|
|||||||
row_date = _parse_datetime(row["transaction_at"]).date()
|
row_date = _parse_datetime(row["transaction_at"]).date()
|
||||||
other_date = _parse_datetime(other["transaction_at"]).date()
|
other_date = _parse_datetime(other["transaction_at"]).date()
|
||||||
date_diff = abs((row_date - other_date).days)
|
date_diff = abs((row_date - other_date).days)
|
||||||
ref_row = normalize_reference(row["reference"])
|
ref_same = _reference_same(row, other)
|
||||||
ref_other = normalize_reference(other["reference"])
|
ref_conflict = _reference_conflict(row, other)
|
||||||
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)
|
|
||||||
|
|
||||||
if mirror == "exact":
|
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"
|
return "M1"
|
||||||
if date_diff <= 1 and not ref_conflict:
|
if date_diff <= 1 and not ref_conflict:
|
||||||
return "M2"
|
return "M2"
|
||||||
return "R1"
|
return "R1"
|
||||||
# alias / personal-mapping mirror
|
# alias / personal-mapping mirror: only equal references (both present) or a
|
||||||
if date_diff <= 1 and not ref_conflict and (ref_row == ref_other or _summary_equal(row, other)):
|
# 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 "M3"
|
||||||
return "R1"
|
return "R1"
|
||||||
|
|
||||||
@@ -602,11 +620,9 @@ def _classify_tier(
|
|||||||
def _tier_evidence(
|
def _tier_evidence(
|
||||||
row: sqlite3.Row, other: sqlite3.Row
|
row: sqlite3.Row, other: sqlite3.Row
|
||||||
) -> tuple[str | None, bool]:
|
) -> tuple[str | None, bool]:
|
||||||
ref_row = normalize_reference(row["reference"])
|
if _reference_same(row, other):
|
||||||
ref_other = normalize_reference(other["reference"])
|
|
||||||
if ref_row and ref_other and ref_row == ref_other:
|
|
||||||
ref_match = "same"
|
ref_match = "same"
|
||||||
elif ref_row and ref_other:
|
elif _reference_conflict(row, other):
|
||||||
ref_match = "conflict"
|
ref_match = "conflict"
|
||||||
else:
|
else:
|
||||||
ref_match = None
|
ref_match = None
|
||||||
|
|||||||
@@ -677,6 +677,125 @@ class ResolutionTests(MatchingBase):
|
|||||||
self.assertEqual("M3", candidate["rule_tier"])
|
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):
|
class ManualLinkTests(MatchingBase):
|
||||||
def test_manual_link_locks_pair(self) -> None:
|
def test_manual_link_locks_pair(self) -> None:
|
||||||
row_a = self.add_row(
|
row_a = self.add_row(
|
||||||
|
|||||||
Reference in New Issue
Block a user