608 lines
27 KiB
Python
608 lines
27 KiB
Python
"""B-44 manual record tests: submit idempotency, approve new/link, return,
|
|
exception, reverse, deduplication, idempotent replay and concurrency."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
import threading
|
|
import unittest
|
|
|
|
from bank_importer import ledger_events, manual_records, matching, subjects
|
|
from ledger_helpers import LedgerBase
|
|
|
|
|
|
class SubmitTests(LedgerBase):
|
|
def submit(self, **overrides):
|
|
params = dict(
|
|
company_id=self.company_a,
|
|
counterparty_company_id=self.company_b,
|
|
occurred_at="2026-01-10T09:00:00",
|
|
direction="incoming",
|
|
amount="50.00",
|
|
currency="CNY",
|
|
funding_source="other",
|
|
requested_subject="other_receivable",
|
|
request_key="mr-key",
|
|
actor=self.admin,
|
|
summary="归还往来款",
|
|
)
|
|
params.update(overrides)
|
|
return manual_records.submit(self.connection, **params)
|
|
|
|
def test_submit_creates_pending_decision(self) -> None:
|
|
payload = self.submit()
|
|
self.assertEqual("pending", payload["state"])
|
|
self.assertEqual(Decimal("50.00"), Decimal(payload["amount"]))
|
|
self.assertEqual(1, payload["decision_revision"])
|
|
self.assertEqual([], payload["candidates"])
|
|
|
|
def test_submit_idempotent_on_request_key(self) -> None:
|
|
first = self.submit()
|
|
second = self.submit()
|
|
self.assertEqual(first["id"], second["id"])
|
|
self.assertTrue(second["idempotent_replay"])
|
|
decisions = self.connection.execute(
|
|
"SELECT COUNT(*) AS n FROM manual_record_decisions"
|
|
).fetchone()["n"]
|
|
self.assertEqual(1, decisions)
|
|
|
|
def test_submit_validations(self) -> None:
|
|
cases = (
|
|
({"counterparty_company_id": self.company_a}, "不能与本公司相同"),
|
|
({"amount": "0"}, "必须大于零"),
|
|
({"amount": "abc"}, "十进制"),
|
|
({"funding_source": "credit_card"}, "资金来源"),
|
|
({"requested_subject": "equity"}, "科目"),
|
|
({"request_key": ""}, "request_key"),
|
|
({"counterparty_company_id": 9999}, "公司不存在"),
|
|
)
|
|
for overrides, expected in cases:
|
|
with self.subTest(overrides=overrides):
|
|
with self.assertRaises(manual_records.ManualInputError) as ctx:
|
|
self.submit(**overrides)
|
|
self.assertIn(expected, str(ctx.exception))
|
|
|
|
def test_bank_account_must_belong_to_submitting_company(self) -> None:
|
|
with self.assertRaises(manual_records.ManualInputError):
|
|
self.submit(
|
|
funding_source="approved_bank_account",
|
|
bank_account_id=self.account_b["id"],
|
|
)
|
|
|
|
def test_related_source_row_never_modified(self) -> None:
|
|
row_id = self.add_row(
|
|
self.company_a, own_account="6222000000000001",
|
|
cp_account="6222000000000002", expense="100.00",
|
|
)
|
|
payload = self.submit(related_source_row_id=row_id)
|
|
self.assertEqual(row_id, payload["related_source_row_id"])
|
|
with self.assertRaises(Exception):
|
|
self.connection.execute(
|
|
"UPDATE source_rows SET expense = '0' WHERE id = ?", (row_id,)
|
|
)
|
|
self.connection.rollback()
|
|
|
|
|
|
class DecisionTests(LedgerBase):
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
self.record = manual_records.submit(
|
|
self.connection,
|
|
company_id=self.company_a, counterparty_company_id=self.company_b,
|
|
occurred_at="2026-01-10T09:00:00", direction="incoming",
|
|
amount="50.00", currency="CNY", funding_source="other",
|
|
requested_subject="other_receivable", request_key="mr-1",
|
|
actor=self.admin, summary="归还往来款",
|
|
)
|
|
|
|
def decide(self, action, **overrides):
|
|
params = dict(
|
|
record_id=self.record["id"],
|
|
action=action,
|
|
reason="管理员审核",
|
|
expected_decision_id=self.record["decision_id"],
|
|
request_key="dec-key",
|
|
actor=self.admin,
|
|
)
|
|
params.update(overrides)
|
|
return manual_records.decide(self.connection, **params)
|
|
|
|
def test_approve_new_creates_confirmed_event_with_requested_subject(self) -> None:
|
|
outcome = self.decide("approve_new")
|
|
self.assertEqual("approved", outcome["state"])
|
|
event_id = outcome["ledger_event_id"]
|
|
revision = ledger_events.current_revision(self.connection, event_id)
|
|
self.assertEqual("confirmed", revision["state"])
|
|
# incoming 50 from A's perspective: B is the payer, A the payee.
|
|
self.assertEqual(self.company_b, revision["payer_company_id"])
|
|
self.assertEqual(self.company_a, revision["payee_company_id"])
|
|
self.assertEqual(self.company_a, revision["perspective_company_id"])
|
|
self.assertEqual("other_receivable", revision["subject_code"])
|
|
# Exactly one position impact.
|
|
positions = self.connection.execute(
|
|
"SELECT * FROM eligible_position_events"
|
|
).fetchall()
|
|
self.assertEqual(1, len(positions))
|
|
|
|
def test_approve_new_replay_is_idempotent(self) -> None:
|
|
first = self.decide("approve_new")
|
|
second = self.decide("approve_new")
|
|
self.assertEqual(first["decision_id"], second["decision_id"])
|
|
events = self.connection.execute(
|
|
"SELECT COUNT(*) AS n FROM ledger_events"
|
|
).fetchone()["n"]
|
|
self.assertEqual(1, events)
|
|
|
|
def test_return_and_exception_never_produce_balance(self) -> None:
|
|
for action in ("return", "exception"):
|
|
with self.subTest(action=action):
|
|
fresh = manual_records.submit(
|
|
self.connection,
|
|
company_id=self.company_a, counterparty_company_id=self.company_b,
|
|
occurred_at="2026-01-11T09:00:00", direction="incoming",
|
|
amount="10.00", currency="CNY", funding_source="other",
|
|
requested_subject="receivable", request_key=f"mr-{action}",
|
|
actor=self.admin,
|
|
)
|
|
outcome = manual_records.decide(
|
|
self.connection, fresh["id"], action,
|
|
reason="材料不足" if action == "return" else "转入异常",
|
|
expected_decision_id=fresh["decision_id"],
|
|
request_key=f"dec-{action}", actor=self.admin,
|
|
)
|
|
expected_state = "returned" if action == "return" else "exception"
|
|
self.assertEqual(expected_state, outcome["state"])
|
|
self.assertEqual(0, self.connection.execute(
|
|
"SELECT COUNT(*) AS n FROM eligible_position_events"
|
|
).fetchone()["n"])
|
|
|
|
def test_reverse_of_approved_record_creates_reversal_event(self) -> None:
|
|
approved = self.decide("approve_new")
|
|
reversed_outcome = manual_records.decide(
|
|
self.connection, self.record["id"], "reverse",
|
|
reason="误录,冲销", expected_decision_id=approved["decision_id"],
|
|
request_key="dec-rev", actor=self.admin,
|
|
)
|
|
self.assertEqual("reversed", reversed_outcome["state"])
|
|
rows = self.connection.execute(
|
|
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
|
|
).fetchall()
|
|
self.assertEqual(2, len(rows))
|
|
original, reversal = rows
|
|
self.assertEqual("normal", original["posting_kind"])
|
|
self.assertEqual("reversal", reversal["posting_kind"])
|
|
self.assertEqual(original["payer_company_id"], reversal["payee_company_id"])
|
|
self.assertEqual(Decimal(original["amount"]), Decimal(reversal["amount"]))
|
|
# Net position is zero.
|
|
signed = Decimal(original["amount"]) * (
|
|
1 if original["payer_company_id"] == self.company_a else -1
|
|
) + Decimal(reversal["amount"]) * (
|
|
1 if reversal["payer_company_id"] == self.company_a else -1
|
|
)
|
|
self.assertEqual(Decimal("0"), signed)
|
|
|
|
def test_approve_link_to_bank_event_adds_evidence_not_impact(self) -> None:
|
|
# Build an eligible bank event and confirm its subject.
|
|
self.pair(self.company_a, self.company_b, "100.00")
|
|
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
|
bank_ledger_id = self.ledger_events()[0]["id"]
|
|
subjects.confirm_subject(
|
|
self.connection, bank_ledger_id,
|
|
perspective_company_id=self.company_a, subject_code="other_receivable",
|
|
reason="借款", expected_revision=1, request_key="subj-1",
|
|
actor=self.admin,
|
|
)
|
|
before = len(self.position_events())
|
|
outcome = manual_records.decide(
|
|
self.connection, self.record["id"], "approve_link",
|
|
reason="与银行事件同源", expected_decision_id=self.record["decision_id"],
|
|
request_key="dec-link", actor=self.admin,
|
|
target_ledger_event_id=bank_ledger_id,
|
|
)
|
|
self.assertEqual("approved", outcome["state"])
|
|
self.assertEqual(bank_ledger_id, outcome["ledger_event_id"])
|
|
after = len(self.position_events())
|
|
self.assertEqual(before, after)
|
|
sources = self.connection.execute(
|
|
"SELECT * FROM ledger_event_manual_sources WHERE manual_record_id = ?",
|
|
(self.record["id"],),
|
|
).fetchall()
|
|
self.assertEqual(1, len(sources))
|
|
self.assertEqual(bank_ledger_id, sources[0]["ledger_event_id"])
|
|
|
|
def test_approve_link_requires_target(self) -> None:
|
|
with self.assertRaises(manual_records.ManualInputError):
|
|
self.decide("approve_link")
|
|
|
|
def test_reverse_of_linked_record_detaches_claim_only(self) -> None:
|
|
self.pair(self.company_a, self.company_b, "100.00")
|
|
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
|
bank_ledger_id = self.ledger_events()[0]["id"]
|
|
subjects.confirm_subject(
|
|
self.connection, bank_ledger_id,
|
|
perspective_company_id=self.company_a, subject_code="other_receivable",
|
|
reason="借款", expected_revision=1, request_key="subj-1",
|
|
actor=self.admin,
|
|
)
|
|
approved = manual_records.decide(
|
|
self.connection, self.record["id"], "approve_link",
|
|
reason="同源", expected_decision_id=self.record["decision_id"],
|
|
request_key="dec-link", actor=self.admin,
|
|
target_ledger_event_id=bank_ledger_id,
|
|
)
|
|
reversed_outcome = manual_records.decide(
|
|
self.connection, self.record["id"], "reverse",
|
|
reason="撤销关联", expected_decision_id=approved["decision_id"],
|
|
request_key="dec-unlink", actor=self.admin,
|
|
)
|
|
self.assertEqual("reversed", reversed_outcome["state"])
|
|
# No reversal event is created for a linked claim.
|
|
reversals = self.connection.execute(
|
|
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
|
).fetchone()["n"]
|
|
self.assertEqual(0, reversals)
|
|
# The bank impact is untouched.
|
|
self.assertEqual(1, len(self.position_events()))
|
|
|
|
def test_stale_expected_decision_conflicts(self) -> None:
|
|
approved = self.decide("approve_new")
|
|
with self.assertRaises(manual_records.ManualConflictError):
|
|
manual_records.decide(
|
|
self.connection, self.record["id"], "reverse",
|
|
reason="冲销", expected_decision_id=self.record["decision_id"],
|
|
request_key="dec-stale", actor=self.admin,
|
|
)
|
|
|
|
def test_returned_record_cannot_be_reversed(self) -> None:
|
|
returned = self.decide("return", reason="材料不足")
|
|
with self.assertRaises(manual_records.ManualConflictError):
|
|
manual_records.decide(
|
|
self.connection, self.record["id"], "reverse",
|
|
reason="冲销", expected_decision_id=returned["decision_id"],
|
|
request_key="dec-x", actor=self.admin,
|
|
)
|
|
|
|
def test_reverse_uses_explicit_effective_date_across_cutoff(self) -> None:
|
|
# Reversing with an independent effective date must keep the original
|
|
# impact for cutoffs before it and net it to zero only on/after it —
|
|
# never rewrite the historical period retroactively.
|
|
from bank_importer import positions
|
|
approved = self.decide("approve_new")
|
|
event_id = approved["ledger_event_id"]
|
|
self.assertEqual("2026-01-10", self.current(event_id)["effective_at"][:10])
|
|
|
|
manual_records.decide(
|
|
self.connection, self.record["id"], "reverse",
|
|
reason="误录冲销", expected_decision_id=approved["decision_id"],
|
|
request_key="dec-rev-date", actor=self.admin,
|
|
effective_at="2026-06-15",
|
|
)
|
|
reversal = self.connection.execute(
|
|
"SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
|
).fetchone()
|
|
self.assertEqual("2026-06-15", reversal["effective_at"][:10])
|
|
|
|
before = positions.company_balances(
|
|
self.connection, from_="2026-01-01", cutoff="2026-05-31"
|
|
)
|
|
after = positions.company_balances(
|
|
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
|
)
|
|
for item in before["items"]:
|
|
if item["company_id"] == self.company_a:
|
|
self.assertEqual("-50.00", item["result"]["signed_amount"])
|
|
for item in after["items"]:
|
|
if item["company_id"] == self.company_a:
|
|
self.assertEqual("0.00", item["result"]["signed_amount"])
|
|
|
|
def test_reverse_defaults_to_approval_business_day(self) -> None:
|
|
from datetime import datetime, timedelta, timezone
|
|
from bank_importer import positions
|
|
approved = self.decide("approve_new")
|
|
manual_records.decide(
|
|
self.connection, self.record["id"], "reverse",
|
|
reason="误录冲销", expected_decision_id=approved["decision_id"],
|
|
request_key="dec-rev-default", actor=self.admin,
|
|
)
|
|
today = datetime.now(timezone(timedelta(hours=8))).date().isoformat()
|
|
reversal = self.connection.execute(
|
|
"SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
|
).fetchone()
|
|
self.assertEqual(today, reversal["effective_at"][:10])
|
|
|
|
# The day before the approval business day keeps the original impact.
|
|
before = positions.company_balances(
|
|
self.connection, from_="2026-01-01",
|
|
cutoff=(datetime.fromisoformat(today) - timedelta(days=1)).date().isoformat(),
|
|
)
|
|
for item in before["items"]:
|
|
if item["company_id"] == self.company_a:
|
|
self.assertEqual("-50.00", item["result"]["signed_amount"])
|
|
|
|
def test_reverse_of_creation_source_with_later_linked_evidence(self) -> None:
|
|
# approve_new created the event; a second manual record later links
|
|
# onto it. Reversing the creation source must still create an
|
|
# equal-amount reversal — the original economic impact must not survive
|
|
# just because other evidence was attached later.
|
|
approved = self.decide("approve_new")
|
|
event_id = approved["ledger_event_id"]
|
|
|
|
linked = manual_records.submit(
|
|
self.connection, company_id=self.company_a,
|
|
counterparty_company_id=self.company_b,
|
|
occurred_at="2026-01-12T09:00:00", direction="incoming",
|
|
amount="50.00", currency="CNY", funding_source="other",
|
|
requested_subject="receivable", request_key="mr-link-ev",
|
|
actor=self.admin,
|
|
)
|
|
manual_records.decide(
|
|
self.connection, linked["id"], "approve_link",
|
|
reason="同源补充证据", expected_decision_id=linked["decision_id"],
|
|
request_key="dec-link-ev", actor=self.admin,
|
|
target_ledger_event_id=event_id,
|
|
)
|
|
self.assertEqual(1, len(self.position_events()))
|
|
|
|
reversed_outcome = manual_records.decide(
|
|
self.connection, self.record["id"], "reverse",
|
|
reason="误录冲销", expected_decision_id=approved["decision_id"],
|
|
request_key="dec-rev-create", actor=self.admin,
|
|
)
|
|
self.assertEqual("reversed", reversed_outcome["state"])
|
|
rows = self.connection.execute(
|
|
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
|
|
).fetchall()
|
|
self.assertEqual(2, len(rows))
|
|
original, reversal = rows
|
|
self.assertEqual("normal", original["posting_kind"])
|
|
self.assertEqual("reversal", reversal["posting_kind"])
|
|
self.assertEqual(Decimal(original["amount"]), Decimal(reversal["amount"]))
|
|
# The later linked evidence stays attached to the event.
|
|
links = self.connection.execute(
|
|
"SELECT * FROM ledger_event_manual_sources ORDER BY manual_record_id"
|
|
).fetchall()
|
|
self.assertEqual(2, len(links))
|
|
|
|
def test_reverse_of_linked_record_after_creation_reversal_detaches_only(self) -> None:
|
|
# M1 creates the event, M2 links; after M1's reversal created the
|
|
# offset, reversing the linked M2 must only detach, never add a second
|
|
# reversal event.
|
|
approved = self.decide("approve_new")
|
|
event_id = approved["ledger_event_id"]
|
|
linked = manual_records.submit(
|
|
self.connection, company_id=self.company_a,
|
|
counterparty_company_id=self.company_b,
|
|
occurred_at="2026-01-12T09:00:00", direction="incoming",
|
|
amount="50.00", currency="CNY", funding_source="other",
|
|
requested_subject="receivable", request_key="mr-link-ev2",
|
|
actor=self.admin,
|
|
)
|
|
linked_approved = manual_records.decide(
|
|
self.connection, linked["id"], "approve_link",
|
|
reason="同源补充证据", expected_decision_id=linked["decision_id"],
|
|
request_key="dec-link-ev2", actor=self.admin,
|
|
target_ledger_event_id=event_id,
|
|
)
|
|
manual_records.decide(
|
|
self.connection, self.record["id"], "reverse",
|
|
reason="误录冲销", expected_decision_id=approved["decision_id"],
|
|
request_key="dec-rev-create2", actor=self.admin,
|
|
)
|
|
manual_records.decide(
|
|
self.connection, linked["id"], "reverse",
|
|
reason="撤销关联证据", expected_decision_id=linked_approved["decision_id"],
|
|
request_key="dec-rev-link2", actor=self.admin,
|
|
)
|
|
reversals = self.connection.execute(
|
|
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
|
).fetchone()["n"]
|
|
self.assertEqual(1, reversals)
|
|
links = self.connection.execute(
|
|
"SELECT * FROM ledger_event_manual_sources"
|
|
).fetchall()
|
|
self.assertEqual(1, len(links))
|
|
|
|
|
|
class DedupAndConcurrencyTests(LedgerBase):
|
|
def test_duplicate_manual_record_pairing_candidate(self) -> None:
|
|
manual_records.submit(
|
|
self.connection, company_id=self.company_a,
|
|
counterparty_company_id=self.company_b,
|
|
occurred_at="2026-01-10T09:00:00", direction="outgoing",
|
|
amount="40.00", currency="CNY", funding_source="other",
|
|
requested_subject="other_receivable", request_key="mr-a",
|
|
actor=self.admin,
|
|
)
|
|
other = manual_records.submit(
|
|
self.connection, company_id=self.company_b,
|
|
counterparty_company_id=self.company_a,
|
|
occurred_at="2026-01-10T10:00:00", direction="incoming",
|
|
amount="40.00", currency="CNY", funding_source="other",
|
|
requested_subject="other_payable", request_key="mr-b",
|
|
actor=self.admin,
|
|
)
|
|
candidates = manual_records.find_candidates(self.connection, other["id"])
|
|
self.assertEqual(1, len(candidates))
|
|
self.assertEqual("manual_record", candidates[0]["kind"])
|
|
|
|
def test_duplicate_bank_event_candidate_hints_link(self) -> None:
|
|
self.pair(self.company_a, self.company_b, "100.00")
|
|
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
|
record = manual_records.submit(
|
|
self.connection, company_id=self.company_a,
|
|
counterparty_company_id=self.company_b,
|
|
occurred_at="2026-01-05T12:00:00", direction="incoming",
|
|
amount="100.00", currency="CNY", funding_source="other",
|
|
requested_subject="other_receivable", request_key="mr-c",
|
|
actor=self.admin,
|
|
)
|
|
candidates = manual_records.find_candidates(self.connection, record["id"])
|
|
self.assertTrue(any(c["kind"] == "bank_event" for c in candidates))
|
|
|
|
def test_concurrent_approve_new_counts_once(self) -> None:
|
|
record = manual_records.submit(
|
|
self.connection, company_id=self.company_a,
|
|
counterparty_company_id=self.company_b,
|
|
occurred_at="2026-01-10T09:00:00", direction="incoming",
|
|
amount="50.00", currency="CNY", funding_source="other",
|
|
requested_subject="receivable", request_key="mr-conc",
|
|
actor=self.admin,
|
|
)
|
|
errors: list[Exception] = []
|
|
|
|
def worker(thread_key: str) -> None:
|
|
import sqlite3
|
|
|
|
try:
|
|
connection = sqlite3.connect(self.db_path, timeout=30)
|
|
connection.row_factory = sqlite3.Row
|
|
try:
|
|
manual_records.decide(
|
|
connection, record["id"], "approve_new",
|
|
reason="并发审批", expected_decision_id=record["decision_id"],
|
|
request_key=f"dec-{thread_key}", actor=self.admin,
|
|
)
|
|
finally:
|
|
connection.close()
|
|
except Exception as exc: # pragma: no cover
|
|
errors.append(exc)
|
|
|
|
threads = [
|
|
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
|
|
]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join()
|
|
|
|
# Two connections can still double-commit, so the UNIQUE manual source
|
|
# claim must make the second write fail or be a no-op; either way only
|
|
# one ledger event may exist.
|
|
events = self.connection.execute(
|
|
"SELECT COUNT(*) AS n FROM ledger_events"
|
|
).fetchone()["n"]
|
|
self.assertEqual(1, events)
|
|
|
|
def test_concurrent_submit_same_request_key_is_idempotent(self) -> None:
|
|
import sqlite3
|
|
|
|
results: list[dict] = []
|
|
errors: list[Exception] = []
|
|
|
|
def worker(thread_key: str) -> None:
|
|
try:
|
|
connection = sqlite3.connect(self.db_path, timeout=30)
|
|
connection.row_factory = sqlite3.Row
|
|
try:
|
|
payload = manual_records.submit(
|
|
connection, company_id=self.company_a,
|
|
counterparty_company_id=self.company_b,
|
|
occurred_at="2026-01-10T09:00:00", direction="incoming",
|
|
amount="50.00", currency="CNY", funding_source="other",
|
|
requested_subject="receivable",
|
|
request_key="mr-conc-submit", actor=self.admin,
|
|
)
|
|
results.append(payload)
|
|
finally:
|
|
connection.close()
|
|
except Exception as exc: # pragma: no cover
|
|
errors.append(exc)
|
|
|
|
threads = [
|
|
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
|
|
]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join()
|
|
|
|
self.assertEqual([], errors)
|
|
rows = self.connection.execute(
|
|
"SELECT COUNT(*) AS n FROM manual_records"
|
|
).fetchone()["n"]
|
|
self.assertEqual(1, rows)
|
|
self.assertEqual(1, len({payload["id"] for payload in results}))
|
|
decisions = self.connection.execute(
|
|
"SELECT COUNT(*) AS n FROM manual_record_decisions"
|
|
).fetchone()["n"]
|
|
self.assertEqual(1, decisions)
|
|
|
|
def test_concurrent_confirm_subject_same_key_creates_single_revision(self) -> None:
|
|
import sqlite3
|
|
|
|
self.pair(self.company_a, self.company_b, "100.00")
|
|
from bank_importer import ledger_events
|
|
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
|
event_id = self.ledger_events()[0]["id"]
|
|
|
|
outcomes: list[dict] = []
|
|
errors: list[Exception] = []
|
|
|
|
def worker(thread_key: str) -> None:
|
|
try:
|
|
connection = sqlite3.connect(self.db_path, timeout=30)
|
|
connection.row_factory = sqlite3.Row
|
|
try:
|
|
payload = subjects.confirm_subject(
|
|
connection, event_id,
|
|
perspective_company_id=self.company_a,
|
|
subject_code="other_receivable", reason="并发确认",
|
|
expected_revision=1, request_key="conc-subj-key",
|
|
actor=self.admin,
|
|
)
|
|
outcomes.append(payload)
|
|
finally:
|
|
connection.close()
|
|
except Exception as exc: # pragma: no cover
|
|
errors.append(exc)
|
|
|
|
threads = [
|
|
threading.Thread(target=worker, args=(f"t{i}",)) for i in range(4)
|
|
]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join()
|
|
|
|
self.assertEqual([], errors)
|
|
confirmed = self.connection.execute(
|
|
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE state = 'confirmed'"
|
|
).fetchone()["n"]
|
|
self.assertEqual(1, confirmed)
|
|
self.assertEqual(1, len({payload["revision_id"] for payload in outcomes}))
|
|
|
|
def test_repeated_confirm_same_key_appends_no_noise_revision(self) -> None:
|
|
self.pair(self.company_a, self.company_b, "100.00")
|
|
from bank_importer import ledger_events
|
|
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
|
event_id = self.ledger_events()[0]["id"]
|
|
first = subjects.confirm_subject(
|
|
self.connection, event_id,
|
|
perspective_company_id=self.company_a, subject_code="other_receivable",
|
|
reason="确认", expected_revision=1, request_key="noise-key",
|
|
actor=self.admin,
|
|
)
|
|
second = subjects.confirm_subject(
|
|
self.connection, event_id,
|
|
perspective_company_id=self.company_a, subject_code="other_receivable",
|
|
reason="重复确认", expected_revision=1, request_key="noise-key",
|
|
actor=self.admin,
|
|
)
|
|
self.assertEqual(first["revision_id"], second["revision_id"])
|
|
revisions = self.connection.execute(
|
|
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE ledger_event_id = ?",
|
|
(event_id,),
|
|
).fetchone()["n"]
|
|
# One pending_subject + one confirmed; the replay appended nothing.
|
|
self.assertEqual(2, revisions)
|
|
confirmed = self.connection.execute(
|
|
"SELECT COUNT(*) AS n FROM ledger_event_revisions "
|
|
"WHERE ledger_event_id = ? AND state = 'confirmed'",
|
|
(event_id,),
|
|
).fetchone()["n"]
|
|
self.assertEqual(1, confirmed)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|