B-44: 返修——多币种对方汇总、镜像科目筛选、冲销独立生效日、创建来源撤销边界与并发幂等
This commit is contained in:
@@ -262,6 +262,146 @@ class DecisionTests(LedgerBase):
|
||||
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:
|
||||
@@ -343,6 +483,125 @@ class DedupAndConcurrencyTests(LedgerBase):
|
||||
).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()
|
||||
|
||||
@@ -267,6 +267,47 @@ class UnresolvedTests(LedgerBase):
|
||||
|
||||
|
||||
class PaginationTests(LedgerBase):
|
||||
def test_subject_filter_matches_stored_and_mirror_subjects(self) -> None:
|
||||
# A books "receivable" from its own perspective; B sees the mirror
|
||||
# "payable". Filtering by B's mirror subject must still find the event.
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
confirm(self.connection, event_id, self.company_a, "receivable", "k-mirror",
|
||||
self.admin)
|
||||
|
||||
stored = positions.list_events(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
subject="receivable",
|
||||
)
|
||||
self.assertTrue(any(
|
||||
item["ledger_event_id"] == event_id for item in stored["items"]
|
||||
))
|
||||
# Company B filters by its own perspective: the stored "receivable" is
|
||||
# the mirror of "payable", so it must be included.
|
||||
mirror = positions.list_events(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
subject="payable", viewer_company_id=self.company_b,
|
||||
)
|
||||
self.assertTrue(any(
|
||||
item["ledger_event_id"] == event_id for item in mirror["items"]
|
||||
), mirror)
|
||||
for item in mirror["items"]:
|
||||
if item["ledger_event_id"] == event_id:
|
||||
self.assertEqual("payable", item["own_subject"])
|
||||
|
||||
def test_subject_filter_without_match_returns_empty(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
confirm(self.connection, event_id, self.company_a, "receivable", "k-none",
|
||||
self.admin)
|
||||
other = positions.list_events(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
subject="other_receivable",
|
||||
)
|
||||
self.assertEqual([], other["items"])
|
||||
|
||||
def test_pagination_is_stable_and_complete(self) -> None:
|
||||
for index in range(7):
|
||||
at = f"2026-01-{(index % 28) + 1:02d}T10:00:00"
|
||||
|
||||
@@ -49,12 +49,14 @@ def workbook_bytes(rows) -> bytes:
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00"):
|
||||
return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "借款", ""]
|
||||
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00",
|
||||
currency: str = "RMB"):
|
||||
return [own, "测试公司", at, amount, "", "50000.00", currency, "对方", cp, "某银行", "借款", ""]
|
||||
|
||||
|
||||
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00"):
|
||||
return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", cp, "某银行", "借款", ""]
|
||||
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00",
|
||||
currency: str = "RMB"):
|
||||
return [own, "测试公司", at, "", amount, "50000.00", currency, "对方", cp, "某银行", "借款", ""]
|
||||
|
||||
|
||||
class IntercompanyApiTests(unittest.TestCase):
|
||||
@@ -167,15 +169,16 @@ class IntercompanyApiTests(unittest.TestCase):
|
||||
assert status == 200, data
|
||||
return batch_id
|
||||
|
||||
def _fresh_pair(self, amount: str = "100.00", at: str = "2026-01-05 10:00:00") -> dict:
|
||||
def _fresh_pair(self, amount: str = "100.00", at: str = "2026-01-05 10:00:00",
|
||||
currency: str = "RMB") -> dict:
|
||||
"""Upload+confirm a new A<->B pair; returns the pending review item."""
|
||||
self._upload_and_confirm(
|
||||
self.cashier_a, self.company_a,
|
||||
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, at)],
|
||||
[outgoing(ACCOUNT_A, ACCOUNT_B, amount, at, currency)],
|
||||
)
|
||||
self._upload_and_confirm(
|
||||
self.cashier_b, self.company_b,
|
||||
[incoming(ACCOUNT_B, ACCOUNT_A, amount, at.replace("10:", "11:"))],
|
||||
[incoming(ACCOUNT_B, ACCOUNT_A, amount, at.replace("10:", "11:"), currency)],
|
||||
)
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/subject-reviews?from=2026-01-01&cutoff=2026-12-31"
|
||||
@@ -458,6 +461,95 @@ class IntercompanyApiTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(404, status, data)
|
||||
|
||||
def test_company_counterparty_summary_splits_by_currency(self) -> None:
|
||||
# A->B 100 CNY and A->B 50 USD must render two rows, never a mixed
|
||||
# "CNY 150.00" bucket.
|
||||
cny = self._fresh_pair("100.00", currency="CNY")
|
||||
usd = self._fresh_pair("50.00", at="2026-01-20 10:00:00", currency="USD")
|
||||
for pending in (cny, usd):
|
||||
self._confirm(pending["ledger_event_id"])
|
||||
|
||||
status, _, data = self.cashier_a.get(
|
||||
"/api/company/intercompany/balances?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
counterparties = as_json(data)["counterparties"]
|
||||
rows = [row for row in counterparties if row["counterparty_company_id"] == self.company_b]
|
||||
self.assertEqual(2, len(rows), counterparties)
|
||||
by_currency = {row["currency"]: row for row in rows}
|
||||
self.assertEqual({"CNY", "USD"}, set(by_currency))
|
||||
self.assertEqual("100.00", by_currency["CNY"]["result"]["signed_amount"])
|
||||
self.assertEqual("50.00", by_currency["USD"]["result"]["signed_amount"])
|
||||
for row in rows:
|
||||
self.assertNotEqual("150.00", row["result"]["signed_amount"])
|
||||
self.assertEqual(1, row["event_count"])
|
||||
|
||||
def test_company_events_subject_filter_matches_mirror(self) -> None:
|
||||
# A confirms subject "receivable" (stored from A's perspective); B must
|
||||
# still find the event when filtering by the mirror subject "payable".
|
||||
pending = self._fresh_pair("100.00")
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
|
||||
{
|
||||
"perspective_company_id": self.company_a,
|
||||
"subject_code": "receivable", "reason": "确认应收",
|
||||
"expected_revision": 1, "request_key": "mirror-subj",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
|
||||
status, _, data = self.cashier_b.get(
|
||||
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31&subject=payable"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
self.assertTrue(any(
|
||||
item["ledger_event_id"] == pending["ledger_event_id"] for item in items
|
||||
), data)
|
||||
|
||||
def test_manual_reverse_via_api_uses_explicit_effective_date(self) -> None:
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
"/api/company/manual-records",
|
||||
{
|
||||
"counterparty_company_id": self.company_b,
|
||||
"occurred_at": "2026-02-01T09:00:00",
|
||||
"direction": "incoming", "amount": "20.00", "currency": "CNY",
|
||||
"funding_source": "other", "requested_subject": "other_receivable",
|
||||
"request_key": "mr-rev-api", "summary": "还款",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
record = as_json(data)["record"]
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/manual-records/{record['id']}/decisions",
|
||||
{"action": "approve_new", "reason": "确认入账",
|
||||
"expected_decision_id": record["decision_id"], "request_key": "dec-rev-api"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
event_id = as_json(data)["decision"]["ledger_event_id"]
|
||||
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/events/{event_id}"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
revision_id = as_json(data)["event"]["ledger_revision_id"]
|
||||
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/manual-records/{record['id']}/decisions",
|
||||
{"action": "reverse", "reason": "误录冲销", "effective_at": "2026-06-15",
|
||||
"expected_decision_id": None, "request_key": "dec-rev-api-2"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
reversals = [item for item in as_json(data)["items"] if item["posting_kind"] == "reversal"]
|
||||
self.assertEqual(1, len(reversals))
|
||||
self.assertEqual("2026-06-15", reversals[0]["effective_at"][:10])
|
||||
self.assertEqual(event_id, reversals[0]["reverses_ledger_event_id"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user