B-44: 返修——多币种对方汇总、镜像科目筛选、冲销独立生效日、创建来源撤销边界与并发幂等
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user