Files
caiwuzongzhang/tests/test_matching.py
T

985 lines
43 KiB
Python

"""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()