B-44: 返修——多币种对方汇总、镜像科目筛选、冲销独立生效日、创建来源撤销边界与并发幂等

This commit is contained in:
腾讯WorkBuddy
2026-08-19 13:28:28 +08:00
parent d59576739f
commit f16e8aad34
7 changed files with 528 additions and 91 deletions
+8 -5
View File
@@ -2435,6 +2435,7 @@ class AppHandler(SimpleHTTPRequestHandler):
actor=user, actor=user,
subject_code=data.get("subject_code"), subject_code=data.get("subject_code"),
target_ledger_event_id=data.get("target_ledger_event_id"), target_ledger_event_id=data.get("target_ledger_event_id"),
effective_at=data.get("effective_at"),
) )
except manual_records.ManualConflictError as exc: except manual_records.ManualConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)}) self._send_json(409, {"status": "error", "message": str(exc)})
@@ -2495,7 +2496,9 @@ class AppHandler(SimpleHTTPRequestHandler):
connection, from_=from_, cutoff=cutoff, currency=currency, connection, from_=from_, cutoff=cutoff, currency=currency,
company_id=company_id, company_id=company_id,
) )
buckets: dict[int, dict[str, object]] = {} # Buckets are keyed by ``(counterparty, currency)``: amounts never mix
# across currencies, so one counterparty with CNY and USD yields two rows.
buckets: dict[tuple[int, str], dict[str, object]] = {}
for event in events: for event in events:
counterparty = ( counterparty = (
event["payee_company_id"] event["payee_company_id"]
@@ -2507,8 +2510,9 @@ class AppHandler(SimpleHTTPRequestHandler):
if int(event["payer_company_id"]) == int(company_id) if int(event["payer_company_id"]) == int(company_id)
else event["payer_company_name"] else event["payer_company_name"]
) )
key = (int(counterparty), event["currency"])
bucket = buckets.setdefault( bucket = buckets.setdefault(
counterparty, key,
{ {
"counterparty_company_id": counterparty, "counterparty_company_id": counterparty,
"counterparty_company_name": name, "counterparty_company_name": name,
@@ -2550,7 +2554,7 @@ class AppHandler(SimpleHTTPRequestHandler):
else row["payer_company_name"] else row["payer_company_name"]
) )
buckets.setdefault( buckets.setdefault(
counterparty, (int(counterparty), row["currency"]),
{ {
"counterparty_company_id": counterparty, "counterparty_company_id": counterparty,
"counterparty_company_name": name, "counterparty_company_name": name,
@@ -2560,8 +2564,7 @@ class AppHandler(SimpleHTTPRequestHandler):
}, },
) )
items = [] items = []
for counterparty, bucket in sorted(buckets.items()): for (counterparty, cur), bucket in sorted(buckets.items()):
cur = bucket["currency"]
signed = bucket["signed"] signed = bucket["signed"]
unresolved = positions.unresolved_for_company( unresolved = positions.unresolved_for_company(
connection, company_id, cutoff, currency=cur, connection, company_id, cutoff, currency=cur,
+65 -30
View File
@@ -10,7 +10,7 @@ is never edited. Idempotency keys and UNIQUE claims prevent double counting.
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
import json import json
import sqlite3 import sqlite3
@@ -60,6 +60,11 @@ def _validate_date(value: object, field: str) -> str:
return text return text
def _business_today() -> str:
"""Shanghai business date (the default reversal effective date)."""
return datetime.now(timezone(timedelta(hours=8))).date().isoformat()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Submit # Submit
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -144,18 +149,22 @@ def submit(
raise ManualInputError("supersedes_record_id 无效。") raise ManualInputError("supersedes_record_id 无效。")
supersedes_id = int(supersedes_record_id) supersedes_id = int(supersedes_record_id)
existing = connection.execute(
"SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?",
(int(company_id), request_key),
).fetchone()
if existing is not None:
return _record_payload(connection, existing["id"], idempotent_replay=True)
now = utc_now() now = utc_now()
began = False began = False
if not connection.in_transaction: if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE") connection.execute("BEGIN IMMEDIATE")
began = True began = True
try:
# Re-check inside the write transaction: concurrent identical submits
# serialize here, so a replay is found before any INSERT.
existing = connection.execute(
"SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?",
(int(company_id), request_key),
).fetchone()
if existing is not None:
if began:
connection.commit()
return _record_payload(connection, existing["id"], idempotent_replay=True)
try: try:
cursor = connection.execute( cursor = connection.execute(
""" """
@@ -178,6 +187,18 @@ def submit(
actor["id"], now, actor["id"], now,
), ),
) )
except sqlite3.IntegrityError:
# A concurrent identical submit won the race and committed first;
# surface the existing record idempotently instead of a UNIQUE 500.
if began:
connection.rollback()
existing = connection.execute(
"SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?",
(int(company_id), request_key),
).fetchone()
if existing is not None:
return _record_payload(connection, existing["id"], idempotent_replay=True)
raise
record_id = int(cursor.lastrowid) record_id = int(cursor.lastrowid)
_append_decision( _append_decision(
connection, record_id, state="pending", action="submit", connection, record_id, state="pending", action="submit",
@@ -279,14 +300,16 @@ def decide(
actor: sqlite3.Row, actor: sqlite3.Row,
subject_code: object = None, subject_code: object = None,
target_ledger_event_id: object = None, target_ledger_event_id: object = None,
effective_at: object = None,
) -> dict[str, object]: ) -> dict[str, object]:
"""Apply an administrator decision to a manual record. """Apply an administrator decision to a manual record.
``approve_new`` creates a confirmed ledger event; ``approve_link`` joins an ``approve_new`` creates a confirmed ledger event; ``approve_link`` joins an
existing ledger event without adding a second economic impact; ``return`` existing ledger event without adding a second economic impact; ``return``
and ``exception`` never produce a balance; ``reverse`` creates an opposite and ``exception`` never produce a balance; ``reverse`` creates an opposite
reversal event (or detaches a linked claim). Replays return the earlier reversal event (or detaches a linked claim) with an independent business
outcome via ``idempotency_key``. effective date — explicit ``effective_at`` or the approval business day.
Replays return the earlier outcome via ``idempotency_key``.
""" """
reason = (reason or "").strip() reason = (reason or "").strip()
if not reason: if not reason:
@@ -350,7 +373,10 @@ def decide(
else: # reverse else: # reverse
if current["state"] != "approved": if current["state"] != "approved":
raise ManualConflictError("只有已批准记录可以冲销。") raise ManualConflictError("只有已批准记录可以冲销。")
outcome = _reverse(connection, record, current, actor, request_key, reason) outcome = _reverse(
connection, record, current, actor, request_key, reason,
effective_at=effective_at,
)
_store_audit(connection, record, current, action, outcome, reason, actor) _store_audit(connection, record, current, action, outcome, reason, actor)
except Exception: except Exception:
@@ -455,6 +481,7 @@ def _reverse(
actor: sqlite3.Row, actor: sqlite3.Row,
request_key: str | None, request_key: str | None,
reason: str, reason: str,
effective_at: object = None,
) -> dict[str, object]: ) -> dict[str, object]:
claim = manual_source_claim(connection, record["id"]) claim = manual_source_claim(connection, record["id"])
if claim is None: if claim is None:
@@ -464,25 +491,30 @@ def _reverse(
if revision is None: if revision is None:
raise ManualConflictError("关联的往来事件没有当前修订。") raise ManualConflictError("关联的往来事件没有当前修订。")
if revision["source_kind"] == "manual" and not _event_has_other_sources( if _is_manual_creation_source(revision, record["id"]):
connection, event_id, record["id"] # This record created the event (approve_new): it added the economic
): # impact, so reversing it must always produce an equal-amount reversal
# The manual record is the only economic source: reverse it with a new # event — even when other manual evidence was later linked onto the
# opposite event at an independent effective date. # same event. The original impact must not survive in balances.
if effective_at is not None and str(effective_at).strip():
effective_at = _validate_date(effective_at, "冲销生效日")
else:
effective_at = _business_today()
create_reversal( create_reversal(
connection, connection,
event_id, event_id,
source_kind="manual", source_kind="manual",
source_revision_token=str(record["id"]), source_revision_token=str(record["id"]),
effective_at=effective_at,
reason=reason, reason=reason,
actor=actor, actor=actor,
idempotency_key=request_key, idempotency_key=request_key,
rule_version="manual-record-v1", rule_version="manual-record-v1",
) )
else: else:
# The manual was linked evidence on a bank event: it never added a # The manual was linked evidence (approve_link) on an event it never
# second impact, so reversing detaches the claim without a reversal # created: it added no second impact, so reversing detaches the claim
# event; the bank impact stays. # and the underlying economic impact stays.
connection.execute( connection.execute(
"DELETE FROM ledger_event_manual_sources WHERE manual_record_id = ?", "DELETE FROM ledger_event_manual_sources WHERE manual_record_id = ?",
(record["id"],), (record["id"],),
@@ -495,17 +527,20 @@ def _reverse(
return {"decision_id": decision_id, "ledger_event_id": None} return {"decision_id": decision_id, "ledger_event_id": None}
def _event_has_other_sources(connection, event_id: int, manual_record_id: int) -> bool: def _is_manual_creation_source(revision: sqlite3.Row, manual_record_id: int) -> bool:
bank = connection.execute( """True when ``manual_record_id`` created the event via ``approve_new``.
"SELECT 1 FROM ledger_event_bank_sources WHERE ledger_event_id = ? LIMIT 1",
(event_id,), The creation source is recorded in the event's immutable revision
).fetchone() ``evidence_json``; a linked evidence record is never the creation source
other_manual = connection.execute( and carries no second economic impact.
"SELECT 1 FROM ledger_event_manual_sources " """
"WHERE ledger_event_id = ? AND manual_record_id != ? LIMIT 1", if revision["source_kind"] != "manual":
(event_id, manual_record_id), return False
).fetchone() try:
return bank is not None or other_manual is not None evidence = json.loads(revision["evidence_json"] or "{}")
except (TypeError, ValueError):
return False
return evidence.get("manual_record_id") == manual_record_id
def _store_audit( def _store_audit(
+5 -4
View File
@@ -133,10 +133,11 @@ def _event_filters(
if subject: if subject:
if subject not in SUBJECTS: if subject not in SUBJECTS:
raise PositionInputError("科目筛选无效。") raise PositionInputError("科目筛选无效。")
conditions.append( # The stored subject lives on one company's perspective; the viewer on
"(p.subject_code = ? OR (p.subject_code = ? AND p.subject_code = ?))" # the other side sees its mirror. Match both so mirror events are never
) # dropped from the filter.
params.extend([subject, MIRROR[subject], subject]) conditions.append("(p.subject_code = ? OR p.subject_code = ?)")
params.extend([subject, MIRROR[subject]])
if posting_kind: if posting_kind:
conditions.append("p.posting_kind = ?") conditions.append("p.posting_kind = ?")
params.append(posting_kind) params.append(posting_kind)
+13 -7
View File
@@ -209,8 +209,12 @@ def confirm_subject(
raise SubjectInputError("必须填写科目确认依据。") raise SubjectInputError("必须填写科目确认依据。")
if subject_code not in SUBJECTS: if subject_code not in SUBJECTS:
raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。") raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。")
subject_code = subject_code
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
if request_key: if request_key:
existing = connection.execute( existing = connection.execute(
""" """
@@ -221,6 +225,8 @@ def confirm_subject(
(ledger_event_id, request_key), (ledger_event_id, request_key),
).fetchone() ).fetchone()
if existing is not None: if existing is not None:
if began:
connection.commit()
return _revision_payload(connection, existing) return _revision_payload(connection, existing)
current = current_revision(connection, ledger_event_id) current = current_revision(connection, ledger_event_id)
@@ -228,17 +234,17 @@ def confirm_subject(
raise SubjectConflictError("该事件不存在或没有当前修订。") raise SubjectConflictError("该事件不存在或没有当前修订。")
if current["state"] != "pending_subject": if current["state"] != "pending_subject":
raise SubjectConflictError("只有待确认科目的事件可以确认科目。") raise SubjectConflictError("只有待确认科目的事件可以确认科目。")
if expected_revision is not None and int(expected_revision) != current["revision"]: # ``expected_revision`` may be the revision row id (what the API/UI
# sends as ``ledger_revision_id``) or the per-event sequence number;
# both identify the exact revision the client saw.
if expected_revision is not None and int(expected_revision) not in (
current["id"], current["revision"],
):
raise SubjectConflictError("事件已发生变更,请刷新后重试。") raise SubjectConflictError("事件已发生变更,请刷新后重试。")
participants = {current["payer_company_id"], current["payee_company_id"]} participants = {current["payer_company_id"], current["payee_company_id"]}
if perspective_company_id not in participants: if perspective_company_id not in participants:
raise SubjectInputError("视角公司必须是事件参与方。") raise SubjectInputError("视角公司必须是事件参与方。")
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
revision_id = append_revision( revision_id = append_revision(
connection, connection,
ledger_event_id, ledger_event_id,
+259
View File
@@ -262,6 +262,146 @@ class DecisionTests(LedgerBase):
request_key="dec-x", actor=self.admin, 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): class DedupAndConcurrencyTests(LedgerBase):
def test_duplicate_manual_record_pairing_candidate(self) -> None: def test_duplicate_manual_record_pairing_candidate(self) -> None:
@@ -343,6 +483,125 @@ class DedupAndConcurrencyTests(LedgerBase):
).fetchone()["n"] ).fetchone()["n"]
self.assertEqual(1, events) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+41
View File
@@ -267,6 +267,47 @@ class UnresolvedTests(LedgerBase):
class PaginationTests(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: def test_pagination_is_stable_and_complete(self) -> None:
for index in range(7): for index in range(7):
at = f"2026-01-{(index % 28) + 1:02d}T10:00:00" at = f"2026-01-{(index % 28) + 1:02d}T10:00:00"
+99 -7
View File
@@ -49,12 +49,14 @@ def workbook_bytes(rows) -> bytes:
return buffer.getvalue() return buffer.getvalue()
def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00"): def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00",
return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "借款", ""] 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"): def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00",
return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", cp, "某银行", "借款", ""] currency: str = "RMB"):
return [own, "测试公司", at, "", amount, "50000.00", currency, "对方", cp, "某银行", "借款", ""]
class IntercompanyApiTests(unittest.TestCase): class IntercompanyApiTests(unittest.TestCase):
@@ -167,15 +169,16 @@ class IntercompanyApiTests(unittest.TestCase):
assert status == 200, data assert status == 200, data
return batch_id 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.""" """Upload+confirm a new A<->B pair; returns the pending review item."""
self._upload_and_confirm( self._upload_and_confirm(
self.cashier_a, self.company_a, 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._upload_and_confirm(
self.cashier_b, self.company_b, 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( status, _, data = self.admin.get(
"/api/admin/subject-reviews?from=2026-01-01&cutoff=2026-12-31" "/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) 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__": if __name__ == "__main__":
unittest.main() unittest.main()