B-44: intercompany ledger events, subject review and drill-down evidence
- migration 6: manual_records, ledger_event_revisions chain, current projections, source claims, subject suggestions, eligible_position_events - ledger_events.py: bank-event reconciliation, reversal/adjustment/reopen, append-only revision chain and rebuildable current projection - subjects.py: fixed subject mirror, draft suggestion dictionary, explicit administrator subject confirmation with expected_revision + idempotency - manual_records.py: submit, approve new/link, return/exception/reverse, candidate hints, idempotent replay and concurrency-safe claims - positions.py: Decimal aggregation, both-perspective conservation asserts, cutoff window, unresolved gross buckets, keyset pagination, evidence visibility (visible/masked/missing) - server.py: admin + company intercompany APIs with tenant isolation (404 on cross-tenant reads, 403 on company writes) and auto reconcile wiring - admin/company portals: balance directory, pair drill-down drawer, evidence drawer, subject/manual audit queue, company balance summary - tests: ledger events, subjects, manual records, positions, HTTP API and migration persistence (233 total, all green)
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""Shared fixtures for B-44 ledger / manual / position tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from bank_importer import auth, matching, master_data
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
|
||||
class LedgerBase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
root = Path(self.temp_dir.name)
|
||||
self.db_path = root / "app.db"
|
||||
self.connection = connect(self.db_path)
|
||||
self.addCleanup(self.connection.close)
|
||||
migrate(self.connection)
|
||||
self.admin = self._admin()
|
||||
self.company_a = self._company("甲公司")
|
||||
self.company_b = self._company("乙公司")
|
||||
self.company_c = self._company("丙公司")
|
||||
self.account_a = self._approved_account(self.company_a, "6222000000000001")
|
||||
self.account_b = self._approved_account(self.company_b, "6222000000000002")
|
||||
|
||||
def _admin(self):
|
||||
auth.create_user(self.connection, "admin-u", "AdminPass123", "admin")
|
||||
return self.connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'admin-u'"
|
||||
).fetchone()
|
||||
|
||||
def _company(self, name: str) -> int:
|
||||
with self.connection:
|
||||
cursor = self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES (?, ?, ?)",
|
||||
(name, utc_now(), utc_now()),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def _approved_account(self, company_id: int, number: str, start: str = "2026-01-01"):
|
||||
account = master_data.submit_bank_account(
|
||||
self.connection, company_id=company_id, bank_name="中信银行",
|
||||
account_type="基本户", account_number=number, start_date=start,
|
||||
actor=None,
|
||||
)
|
||||
return master_data.review_bank_account(
|
||||
self.connection, account["id"], "approve", None, self.admin,
|
||||
effective_from=start,
|
||||
)
|
||||
|
||||
def add_row(
|
||||
self,
|
||||
company_id: int,
|
||||
*,
|
||||
own_account: str,
|
||||
cp_account: str | None = None,
|
||||
income: str = "0",
|
||||
expense: str = "0",
|
||||
at: str = "2026-01-05T10:00:00",
|
||||
currency: str = "CNY",
|
||||
reference: str | None = None,
|
||||
summary: str | None = None,
|
||||
purpose: str | None = None,
|
||||
) -> int:
|
||||
with self.connection:
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
|
||||
VALUES (?, '测试.xlsx', 1, 'data/files/测试.xlsx', ?)
|
||||
""",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
source_file_id = int(cursor.lastrowid)
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (source_file_id, status, company_id, created_at, updated_at)
|
||||
VALUES (?, 'parsing', ?, ?, ?)
|
||||
""",
|
||||
(source_file_id, company_id, utc_now(), utc_now()),
|
||||
)
|
||||
batch_id = int(cursor.lastrowid)
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_batches (
|
||||
import_batch_id, sheet_name, bank_name, template_id, template_version,
|
||||
header_row, own_account, own_name, period_start, period_end,
|
||||
transaction_count, warnings, created_at
|
||||
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, NULL, NULL, NULL, NULL, 1, '[]', ?)
|
||||
""",
|
||||
(batch_id, utc_now()),
|
||||
)
|
||||
sheet_batch_id = int(cursor.lastrowid)
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_reviews (
|
||||
import_batch_id, sheet_name, outcome, sheet_batch_id,
|
||||
review_status, created_at
|
||||
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
|
||||
""",
|
||||
(batch_id, sheet_batch_id, utc_now()),
|
||||
)
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO source_rows (
|
||||
sheet_batch_id, source_row, transaction_at, income, expense, balance,
|
||||
own_account, own_name, counterparty_account, counterparty_name,
|
||||
counterparty_bank, summary, purpose, reference, currency, created_at
|
||||
) VALUES (?, 1, ?, ?, ?, NULL, ?, '测试', ?, '对方', NULL, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
sheet_batch_id, at, income, expense, own_account,
|
||||
cp_account, summary, purpose, reference, currency, utc_now(),
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def pair(
|
||||
self,
|
||||
payer: int,
|
||||
payee: int,
|
||||
amount: str,
|
||||
at: str = "2026-01-05T10:00:00",
|
||||
*,
|
||||
currency: str = "CNY",
|
||||
summary: str = "借款",
|
||||
purpose: str = "往来款",
|
||||
) -> tuple[int, int]:
|
||||
"""Create a mirrored A/B pair and reconcile into an eligible event."""
|
||||
payer_account = self._account_of(payer)
|
||||
payee_account = self._account_of(payee)
|
||||
row_payer = self.add_row(
|
||||
payer, own_account=payer_account, cp_account=payee_account,
|
||||
expense=amount, at=at, currency=currency, summary=summary, purpose=purpose,
|
||||
)
|
||||
row_payee = self.add_row(
|
||||
payee, own_account=payee_account, cp_account=payer_account,
|
||||
income=amount, at=at.replace("T10:", "T11:"), currency=currency,
|
||||
summary=summary, purpose=purpose,
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_payer, row_payee])
|
||||
return row_payer, row_payee
|
||||
|
||||
def _account_of(self, company_id: int) -> str:
|
||||
if company_id == self.company_a:
|
||||
return self.account_a["account_number"]
|
||||
if company_id == self.company_b:
|
||||
return self.account_b["account_number"]
|
||||
return "6222000000000005"
|
||||
|
||||
def eligible(self) -> list[sqlite3.Row]:
|
||||
return matching.eligible_intercompany_events(self.connection)
|
||||
|
||||
def ledger_events(self) -> list[sqlite3.Row]:
|
||||
return self.connection.execute(
|
||||
"SELECT * FROM ledger_events ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
def current(self, ledger_event_id: int) -> sqlite3.Row | None:
|
||||
return self.connection.execute(
|
||||
"""
|
||||
SELECT r.* FROM current_ledger_event_revisions c
|
||||
JOIN ledger_event_revisions r ON r.id = c.revision_id
|
||||
WHERE c.ledger_event_id = ?
|
||||
""",
|
||||
(ledger_event_id,),
|
||||
).fetchone()
|
||||
|
||||
def position_events(self) -> list[sqlite3.Row]:
|
||||
return self.connection.execute(
|
||||
"SELECT * FROM eligible_position_events ORDER BY ledger_event_id"
|
||||
).fetchall()
|
||||
@@ -0,0 +1,306 @@
|
||||
"""B-44 ledger event layer tests: bank reconciliation, revision chain,
|
||||
reversal/adjustment, projection rebuild and immutability boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from bank_importer import ledger_events, matching, subjects
|
||||
from ledger_helpers import LedgerBase
|
||||
|
||||
|
||||
class BankReconcileTests(LedgerBase):
|
||||
def test_paired_eligible_event_creates_pending_subject_only(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.assertEqual(1, len(self.eligible()))
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(1, stats["created"])
|
||||
events = self.ledger_events()
|
||||
self.assertEqual(1, len(events))
|
||||
revision = self.current(events[0]["id"])
|
||||
self.assertEqual("pending_subject", revision["state"])
|
||||
self.assertEqual(Decimal("100.00"), Decimal(revision["amount"]))
|
||||
self.assertEqual("CNY", revision["currency"])
|
||||
# Nothing confirmed yet -> no position event.
|
||||
self.assertEqual([], self.position_events())
|
||||
# The event is unresolved as subject_review.
|
||||
from bank_importer import positions
|
||||
unresolved = positions.unresolved_for_company(self.connection, self.company_a, "2026-12-31")
|
||||
self.assertEqual("100.00", unresolved["by_reason"]["subject_review"]["gross_amount"])
|
||||
|
||||
def test_unlocked_single_never_enters_ledger(self) -> None:
|
||||
self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000002", expense="100.00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [1])
|
||||
self.assertEqual([], self.eligible())
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(0, stats["created"])
|
||||
self.assertEqual([], self.ledger_events())
|
||||
|
||||
def test_same_company_transfer_never_enters_ledger(self) -> None:
|
||||
self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000001", expense="100.00",
|
||||
)
|
||||
self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000001", income="100.00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [1, 2])
|
||||
eligible = self.eligible()
|
||||
self.assertEqual(0, len([e for e in eligible if e["payer_company_id"] == e["payee_company_id"]]))
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(0, stats["created"])
|
||||
|
||||
def test_reconcile_is_idempotent(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
events = self.ledger_events()
|
||||
self.assertEqual(1, len(events))
|
||||
before_revisions = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions"
|
||||
).fetchone()["n"]
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(1, stats["unchanged"])
|
||||
after_revisions = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(before_revisions, after_revisions)
|
||||
|
||||
def test_reconcile_picks_up_newly_linked_rows_as_new_pending_event(self) -> None:
|
||||
# A single observation locked as intercompany -> pending ledger event.
|
||||
row_a = self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000002", expense="100.00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_a])
|
||||
single = self.connection.execute(
|
||||
"SELECT * FROM current_transfer_decisions"
|
||||
).fetchone()
|
||||
event_id = single["event_id"]
|
||||
decision = self.connection.execute(
|
||||
"SELECT * FROM transfer_match_decisions WHERE id = ?",
|
||||
(single["decision_id"],),
|
||||
).fetchone()
|
||||
matching.apply_manual_decision(
|
||||
self.connection, event_id, "assign_participant",
|
||||
reason="函证确认对方",
|
||||
expected_revision=decision["revision"], request_key="assign-1",
|
||||
actor=self.admin,
|
||||
participant={"role": "payee", "company_id": self.company_b},
|
||||
)
|
||||
self.assertEqual(1, len(self.eligible()))
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(1, len(self.ledger_events()))
|
||||
|
||||
# The administrator links two fresh rows with a different amount onto a
|
||||
# new canonical event; reconcile creates a second pending event.
|
||||
row_a2 = self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000002", expense="150.00",
|
||||
at="2026-02-01T10:00:00",
|
||||
)
|
||||
row_b2 = self.add_row(
|
||||
self.company_b, own_account="6222000000000002",
|
||||
cp_account="6222000000000001", income="150.00",
|
||||
at="2026-02-01T11:00:00",
|
||||
)
|
||||
current_row = self.connection.execute(
|
||||
"SELECT d.* FROM current_transfer_decisions c "
|
||||
"JOIN transfer_match_decisions d ON d.id = c.decision_id "
|
||||
"WHERE c.event_id = ?",
|
||||
(event_id,),
|
||||
).fetchone()
|
||||
matching.apply_manual_decision(
|
||||
self.connection, event_id, "link_rows",
|
||||
reason="补录双边流水",
|
||||
expected_revision=current_row["revision"], request_key="link-1",
|
||||
actor=self.admin, source_row_ids=[row_a2, row_b2],
|
||||
)
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
amounts = sorted(
|
||||
row["amount"] for row in self.connection.execute(
|
||||
"SELECT amount FROM ledger_event_revisions WHERE state = 'pending_subject'"
|
||||
).fetchall()
|
||||
)
|
||||
self.assertEqual(["100.00", "150.00"], amounts)
|
||||
|
||||
def test_confirm_subject_enters_position_view(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"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="other_receivable",
|
||||
reason="借款建议其他应收", expected_revision=1, request_key="k1",
|
||||
actor=self.admin,
|
||||
)
|
||||
positions = self.position_events()
|
||||
self.assertEqual(1, len(positions))
|
||||
self.assertEqual("other_receivable", positions[0]["subject_code"])
|
||||
self.assertEqual(self.company_a, positions[0]["perspective_company_id"])
|
||||
|
||||
def test_confirm_stale_revision_conflicts(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"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="确认应收", expected_revision=1, request_key="k1",
|
||||
actor=self.admin,
|
||||
)
|
||||
with self.assertRaises(subjects.SubjectConflictError):
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="重复确认", expected_revision=1, request_key="k2",
|
||||
actor=self.admin,
|
||||
)
|
||||
|
||||
def test_reversal_mirrors_direction_and_subject(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"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="other_receivable",
|
||||
reason="借款", expected_revision=1, request_key="k1", actor=self.admin,
|
||||
)
|
||||
reversal_id, _ = ledger_events.create_reversal(
|
||||
self.connection, event_id, source_kind="adjustment",
|
||||
reason="冲销错误确认", actor=self.admin,
|
||||
)
|
||||
reversal = self.current(reversal_id)
|
||||
self.assertEqual("reversal", reversal["posting_kind"])
|
||||
self.assertEqual(self.company_b, reversal["payer_company_id"])
|
||||
self.assertEqual(self.company_a, reversal["payee_company_id"])
|
||||
self.assertEqual("other_payable", reversal["subject_code"])
|
||||
self.assertEqual(self.company_b, reversal["perspective_company_id"])
|
||||
# Original event stays confirmed and still in the position view.
|
||||
self.assertEqual("confirmed", self.current(event_id)["state"])
|
||||
positions = self.position_events()
|
||||
self.assertEqual(2, len(positions))
|
||||
signed = sum(
|
||||
Decimal(position["amount"]) * (
|
||||
1 if position["payer_company_id"] == self.company_a else -1
|
||||
)
|
||||
for position in positions
|
||||
)
|
||||
self.assertEqual(Decimal("0"), signed)
|
||||
|
||||
def test_evidence_rows_stay_immutable_under_reversal(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"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
|
||||
)
|
||||
before = self.connection.execute(
|
||||
"SELECT income, expense FROM source_rows ORDER BY id"
|
||||
).fetchall()
|
||||
ledger_events.create_reversal(
|
||||
self.connection, event_id, source_kind="adjustment",
|
||||
reason="冲销", actor=self.admin,
|
||||
)
|
||||
after = self.connection.execute(
|
||||
"SELECT income, expense FROM source_rows ORDER BY id"
|
||||
).fetchall()
|
||||
self.assertEqual(before, after)
|
||||
|
||||
def test_reversal_idempotent_guard(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"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
|
||||
)
|
||||
# Simulate the B-43 event leaving the eligible set.
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"UPDATE canonical_transfer_events SET lifecycle = 'superseded' WHERE id = ?",
|
||||
(self.eligible()[0]["event_id"],),
|
||||
)
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(1, stats["reversal"])
|
||||
reversals = self.connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, reversals)
|
||||
# Re-run does not create a second reversal.
|
||||
stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
self.assertEqual(0, stats["reversal"])
|
||||
|
||||
|
||||
class ProjectionTests(LedgerBase):
|
||||
def test_rebuild_current_projection(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"]
|
||||
subjects.confirm_subject(
|
||||
self.connection, event_id,
|
||||
perspective_company_id=self.company_a, subject_code="receivable",
|
||||
reason="确认", expected_revision=1, request_key="k1", actor=self.admin,
|
||||
)
|
||||
self.connection.execute("DELETE FROM current_ledger_event_revisions")
|
||||
rebuilt = ledger_events.rebuild_current_ledger_projection(self.connection)
|
||||
self.assertGreaterEqual(rebuilt, 1)
|
||||
revision = self.current(event_id)
|
||||
self.assertEqual("confirmed", revision["state"])
|
||||
self.assertEqual("receivable", revision["subject_code"])
|
||||
|
||||
|
||||
class SubjectSuggestionTests(LedgerBase):
|
||||
def test_mirror_mapping_is_symmetric(self) -> None:
|
||||
for subject, mirror in (
|
||||
("receivable", "payable"), ("payable", "receivable"),
|
||||
("other_receivable", "other_payable"), ("other_payable", "other_receivable"),
|
||||
):
|
||||
self.assertEqual(mirror, subjects.mirror_subject(subject))
|
||||
self.assertEqual(subject, subjects.mirror_subject(mirror))
|
||||
|
||||
def test_loan_keyword_suggests_payer_other_receivable(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00", summary="借款", purpose="往来款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
suggestions = subjects.compute_suggestions(self.connection, event_id)
|
||||
self.assertEqual(1, len(suggestions))
|
||||
self.assertEqual(self.company_a, suggestions[0]["suggested_perspective_company_id"])
|
||||
self.assertEqual("other_receivable", suggestions[0]["suggested_subject_code"])
|
||||
self.assertFalse(suggestions[0]["evidence"]["approved"])
|
||||
|
||||
def test_repay_keyword_suggests_payee(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00", summary="归还往来款", purpose="还款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
suggestions = subjects.compute_suggestions(self.connection, event_id)
|
||||
self.assertEqual(1, len(suggestions))
|
||||
self.assertEqual(self.company_b, suggestions[0]["suggested_perspective_company_id"])
|
||||
|
||||
def test_trade_keyword_never_suggests(self) -> None:
|
||||
# Trade vocabulary needs group approval; no dictionary hit -> review.
|
||||
self.pair(self.company_a, self.company_b, "100.00", summary="货款", purpose="采购货款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
self.assertEqual([], subjects.compute_suggestions(self.connection, event_id))
|
||||
|
||||
def test_name_alone_never_confirms(self) -> None:
|
||||
# A name match on the counterparty cannot decide the subject.
|
||||
self.pair(self.company_a, self.company_b, "100.00", summary="某客户往来", purpose=None)
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
event_id = self.ledger_events()[0]["id"]
|
||||
current = self.current(event_id)
|
||||
self.assertEqual("pending_subject", current["state"])
|
||||
self.assertEqual([], subjects.compute_suggestions(self.connection, event_id))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,348 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+105
-5
@@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase):
|
||||
class MigrationTests(PersistenceTestCase):
|
||||
def test_migrate_creates_schema_and_is_idempotent(self) -> None:
|
||||
first = applied_versions(self.connection)
|
||||
self.assertEqual([1, 2, 3, 4, 5], first)
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6], first)
|
||||
self.assertEqual([], migrate(self.connection))
|
||||
self.assertEqual(first, applied_versions(self.connection))
|
||||
tables = {
|
||||
@@ -73,19 +73,28 @@ class MigrationTests(PersistenceTestCase):
|
||||
"transfer_match_candidates",
|
||||
"current_transfer_decisions",
|
||||
"transfer_observation_claims",
|
||||
"manual_records",
|
||||
"manual_record_decisions",
|
||||
"current_manual_record_decisions",
|
||||
"ledger_events",
|
||||
"ledger_event_revisions",
|
||||
"current_ledger_event_revisions",
|
||||
"ledger_event_bank_sources",
|
||||
"ledger_event_manual_sources",
|
||||
"ledger_subject_suggestions",
|
||||
"schema_migrations",
|
||||
):
|
||||
self.assertIn(table, tables)
|
||||
|
||||
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
|
||||
self.assertEqual([5, 4, 3, 2, 1], rollback(self.connection, 0))
|
||||
self.assertEqual([6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
|
||||
self.assertEqual([], applied_versions(self.connection))
|
||||
remaining = self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
|
||||
).fetchone()
|
||||
self.assertIsNone(remaining)
|
||||
self.assertEqual([1, 2, 3, 4, 5], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5], applied_versions(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6], applied_versions(self.connection))
|
||||
|
||||
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
|
||||
self.import_sample()
|
||||
@@ -93,7 +102,7 @@ class MigrationTests(PersistenceTestCase):
|
||||
"SELECT COUNT(*) AS n FROM source_rows"
|
||||
).fetchone()["n"]
|
||||
self.assertGreater(row_count, 0)
|
||||
self.assertEqual([5], rollback(self.connection, 4))
|
||||
self.assertEqual([6, 5], rollback(self.connection, 4))
|
||||
# The pre-migration evidence and schema are untouched.
|
||||
self.assertEqual(
|
||||
row_count,
|
||||
@@ -109,6 +118,10 @@ class MigrationTests(PersistenceTestCase):
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'transfer_match_decisions'"
|
||||
).fetchone()
|
||||
self.assertIsNone(remaining)
|
||||
ledger_remaining = self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ledger_event_revisions'"
|
||||
).fetchone()
|
||||
self.assertIsNone(ledger_remaining)
|
||||
|
||||
def test_event_layer_views_exist_after_migration(self) -> None:
|
||||
view = self.connection.execute(
|
||||
@@ -116,6 +129,93 @@ class MigrationTests(PersistenceTestCase):
|
||||
).fetchone()
|
||||
self.assertIsNotNone(view)
|
||||
|
||||
def test_ledger_layer_views_exist_after_migration(self) -> None:
|
||||
view = self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'view' AND name = 'eligible_position_events'"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(view)
|
||||
table = self.connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ledger_event_revisions'"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(table)
|
||||
|
||||
def test_ledger_revision_log_is_immutable(self) -> None:
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
company_a, company_b = [
|
||||
row["id"]
|
||||
for row in self.connection.execute("SELECT id FROM companies ORDER BY id")
|
||||
]
|
||||
self.connection.execute(
|
||||
"INSERT INTO ledger_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(utc_now(),),
|
||||
)
|
||||
event_id = self.connection.execute(
|
||||
"SELECT id FROM ledger_events LIMIT 1"
|
||||
).fetchone()["id"]
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_revisions (
|
||||
ledger_event_id, revision, state, effective_at, amount,
|
||||
amount_scale, currency, payer_company_id, payee_company_id,
|
||||
source_kind, posting_kind, created_at
|
||||
) VALUES (?, 1, 'pending_subject', '2026-01-01T00:00:00', '1.00', 2,
|
||||
'CNY', ?, ?, 'bank', 'normal', ?)
|
||||
""",
|
||||
(event_id, company_a, company_b, utc_now()),
|
||||
)
|
||||
for statement in (
|
||||
"UPDATE ledger_event_revisions SET amount = '9.99'",
|
||||
"DELETE FROM ledger_event_revisions",
|
||||
"UPDATE ledger_events SET created_at = '2020-01-01T00:00:00'",
|
||||
"DELETE FROM ledger_events",
|
||||
):
|
||||
with self.subTest(statement=statement):
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.connection.execute(statement)
|
||||
self.connection.rollback()
|
||||
|
||||
def test_manual_record_facts_are_immutable(self) -> None:
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
self.connection.execute(
|
||||
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
|
||||
(utc_now(), utc_now()),
|
||||
)
|
||||
company_a, company_b = [
|
||||
row["id"]
|
||||
for row in self.connection.execute("SELECT id FROM companies ORDER BY id")
|
||||
]
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO manual_records (
|
||||
company_id, counterparty_company_id, occurred_at, direction,
|
||||
amount, amount_scale, currency, funding_source, requested_subject,
|
||||
request_key, created_at
|
||||
) VALUES (?, ?, '2026-01-01T00:00:00', 'incoming', '1.00', 2,
|
||||
'CNY', 'other', 'receivable', 'k1', ?)
|
||||
""",
|
||||
(company_a, company_b, utc_now()),
|
||||
)
|
||||
for statement in (
|
||||
"UPDATE manual_records SET amount = '9.99'",
|
||||
"DELETE FROM manual_records",
|
||||
):
|
||||
with self.subTest(statement=statement):
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.connection.execute(statement)
|
||||
self.connection.rollback()
|
||||
|
||||
def test_decision_log_immutability_triggers(self) -> None:
|
||||
self.import_sample()
|
||||
source_row_id = self.connection.execute(
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"""B-44 position aggregation tests: conservation, repayments, reversals across
|
||||
cutoffs, currency isolation, Decimal precision, unresolved gross, normal
|
||||
balance exceptions and keyset pagination."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, getcontext
|
||||
import unittest
|
||||
|
||||
from bank_importer import ledger_events, manual_records, positions, subjects
|
||||
from ledger_helpers import LedgerBase
|
||||
|
||||
|
||||
def confirm(connection, event_id: int, perspective: int, subject: str, key: str, actor):
|
||||
return subjects.confirm_subject(
|
||||
connection, event_id,
|
||||
perspective_company_id=perspective, subject_code=subject,
|
||||
reason="审核确认", expected_revision=1, request_key=key, actor=actor,
|
||||
)
|
||||
|
||||
|
||||
class ConservationTests(LedgerBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
getcontext().prec = 50
|
||||
|
||||
def _confirm_all(self, subject: str = "other_receivable"):
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
confirm(
|
||||
self.connection, event["id"],
|
||||
revision["payer_company_id"], subject, f"subj-{event['id']}",
|
||||
self.admin,
|
||||
)
|
||||
|
||||
def test_both_perspectives_mirror_exactly(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.pair(self.company_a, self.company_b, "30.00", at="2026-01-10T10:00:00")
|
||||
self.pair(self.company_a, self.company_b, "70.00", at="2026-02-05T10:00:00")
|
||||
self._confirm_all()
|
||||
pair = positions.pair_detail(
|
||||
self.connection, self.company_a, self.company_b,
|
||||
from_="2026-01-01", cutoff="2026-07-31",
|
||||
)
|
||||
item = pair["items"][0]
|
||||
self.assertTrue(item["conservation"]["opposite"])
|
||||
self.assertTrue(item["conservation"]["abs_equal"])
|
||||
self.assertEqual(Decimal("200.00"), Decimal(item["a"]["result"]["signed_amount"]))
|
||||
self.assertEqual(Decimal("-200.00"), Decimal(item["b"]["result"]["signed_amount"]))
|
||||
|
||||
def test_repayment_reduces_the_original_balance(self) -> None:
|
||||
# A lends B 100 (A pays) then B repays 40 (B pays).
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.pair(self.company_b, self.company_a, "40.00", at="2026-03-01T10:00:00",
|
||||
summary="还款", purpose="归还借款")
|
||||
self._confirm_all()
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
for item in balances["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual(Decimal("60.00"), Decimal(item["result"]["signed_amount"]))
|
||||
self.assertEqual(Decimal("100.00"), Decimal(item["period"]["debit"]))
|
||||
self.assertEqual(Decimal("40.00"), Decimal(item["period"]["credit"]))
|
||||
else:
|
||||
self.assertEqual(Decimal("-60.00"), Decimal(item["result"]["signed_amount"]))
|
||||
self.assertEqual(Decimal("40.00"), Decimal(item["period"]["debit"]))
|
||||
self.assertEqual(Decimal("100.00"), Decimal(item["period"]["credit"]))
|
||||
|
||||
def test_over_repayment_flags_normal_balance_exception(self) -> None:
|
||||
# A lends B 100, then B repays 150 while A books the repayment against
|
||||
# its receivable: A's receivable and B's payable both go negative.
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.pair(self.company_b, self.company_a, "150.00", at="2026-03-01T10:00:00",
|
||||
summary="还款", purpose="归还借款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
# Book every event from A's perspective on the receivable side so
|
||||
# the over-repayment produces abnormal balances.
|
||||
confirm(
|
||||
self.connection, event["id"], self.company_a,
|
||||
"other_receivable", f"k-{event['id']}", self.admin,
|
||||
)
|
||||
pair = positions.pair_detail(
|
||||
self.connection, self.company_a, self.company_b,
|
||||
from_="2026-01-01", cutoff="2026-07-31",
|
||||
)
|
||||
item = pair["items"][0]
|
||||
self.assertTrue(item["normal_balance_exception"])
|
||||
self.assertIn("other_receivable", item["normal_balance_exception"])
|
||||
self.assertIn("other_payable", item["normal_balance_exception"])
|
||||
|
||||
|
||||
class CutoffAndReversalTests(LedgerBase):
|
||||
def _loan(self) -> int:
|
||||
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, "other_receivable", "k1", self.admin)
|
||||
return event_id
|
||||
|
||||
def test_cutoff_before_reversal_keeps_original_impact(self) -> None:
|
||||
event_id = self._loan()
|
||||
# Reversal dated after the original event.
|
||||
ledger_events.create_reversal(
|
||||
self.connection, event_id, source_kind="adjustment",
|
||||
effective_at="2026-06-15T00:00:00", reason="冲销", actor=self.admin,
|
||||
)
|
||||
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(Decimal("100.00"), Decimal(item["result"]["signed_amount"]))
|
||||
for item in after["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual(Decimal("0"), Decimal(item["result"]["signed_amount"]))
|
||||
|
||||
def test_from_gt_cutoff_is_rejected(self) -> None:
|
||||
with self.assertRaises(positions.PositionInputError):
|
||||
positions.validate_window("2026-08-01", "2026-07-31")
|
||||
|
||||
def test_events_before_from_are_excluded_from_period(self) -> None:
|
||||
self._loan()
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-06-01", cutoff="2026-07-31"
|
||||
)
|
||||
self.assertEqual([], balances["items"])
|
||||
|
||||
def test_reversal_uses_approved_effective_date(self) -> None:
|
||||
event_id = self._loan()
|
||||
ledger_events.create_reversal(
|
||||
self.connection, event_id, source_kind="adjustment",
|
||||
effective_at="2026-08-01T00:00:00", reason="未来冲销", actor=self.admin,
|
||||
)
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
for item in balances["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
# The future reversal does not rewrite the earlier cutoff.
|
||||
self.assertEqual(Decimal("100.00"), Decimal(item["result"]["signed_amount"]))
|
||||
|
||||
|
||||
class CurrencyAndPrecisionTests(LedgerBase):
|
||||
def test_currencies_never_mix(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "100.00", currency="CNY")
|
||||
self.pair(self.company_a, self.company_b, "50.00", at="2026-01-20T10:00:00",
|
||||
currency="USD")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
events = self.ledger_events()
|
||||
for event in events:
|
||||
revision = self.current(event["id"])
|
||||
confirm(self.connection, event["id"], revision["payer_company_id"],
|
||||
"other_receivable", f"k-{event['id']}", self.admin)
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
by_company = {}
|
||||
for item in balances["items"]:
|
||||
by_company.setdefault(item["company_id"], {})[item["currency"]] = item["result"]
|
||||
self.assertEqual(Decimal("100.00"), Decimal(by_company[self.company_a]["CNY"]["signed_amount"]))
|
||||
self.assertEqual(Decimal("50.00"), Decimal(by_company[self.company_a]["USD"]["signed_amount"]))
|
||||
|
||||
def test_decimal_precision_is_preserved(self) -> None:
|
||||
self.pair(self.company_a, self.company_b, "0.01")
|
||||
self.pair(self.company_a, self.company_b, "12345.6789", at="2026-01-11T10:00:00")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
confirm(self.connection, event["id"], revision["payer_company_id"],
|
||||
"other_receivable", f"k-{event['id']}", self.admin)
|
||||
balances = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31"
|
||||
)
|
||||
for item in balances["items"]:
|
||||
if item["company_id"] == self.company_a:
|
||||
self.assertEqual(
|
||||
Decimal("12345.6889"), Decimal(item["result"]["signed_amount"])
|
||||
)
|
||||
# The stored string keeps every decimal place.
|
||||
self.assertIn("12345.6889", item["result"]["signed_amount"])
|
||||
|
||||
def test_manual_record_preserves_amount_scale(self) -> None:
|
||||
record = manual_records.submit(
|
||||
self.connection, company_id=self.company_a,
|
||||
counterparty_company_id=self.company_b,
|
||||
occurred_at="2026-02-01T09:00:00", direction="incoming",
|
||||
amount="12.3400", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable", request_key="mr-prec",
|
||||
actor=self.admin,
|
||||
)
|
||||
manual_records.decide(
|
||||
self.connection, record["id"], "approve_new",
|
||||
reason="确认", expected_decision_id=record["decision_id"],
|
||||
request_key="dec-prec", actor=self.admin,
|
||||
)
|
||||
stored = self.connection.execute(
|
||||
"SELECT amount, amount_scale FROM eligible_position_events"
|
||||
).fetchone()
|
||||
self.assertEqual("12.3400", stored["amount"])
|
||||
self.assertEqual(4, stored["amount_scale"])
|
||||
|
||||
|
||||
class UnresolvedTests(LedgerBase):
|
||||
def test_unresolved_gross_never_nets(self) -> None:
|
||||
# Two pending_subject events of opposite economic signs must sum their
|
||||
# absolute values, never cancel.
|
||||
self.pair(self.company_a, self.company_b, "100.00")
|
||||
self.pair(self.company_a, self.company_b, "40.00", at="2026-01-20T10:00:00",
|
||||
summary="还款", purpose="归还借款")
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
unresolved = positions.unresolved_for_company(
|
||||
self.connection, self.company_a, "2026-07-31"
|
||||
)
|
||||
self.assertEqual(2, unresolved["count"])
|
||||
self.assertEqual(
|
||||
Decimal("140.00"), Decimal(unresolved["gross_amount"])
|
||||
)
|
||||
self.assertEqual(
|
||||
Decimal("140.00"),
|
||||
Decimal(unresolved["by_reason"]["subject_review"]["gross_amount"]),
|
||||
)
|
||||
|
||||
def test_unmatched_single_reason_bucket(self) -> None:
|
||||
self.add_row(
|
||||
self.company_a, own_account="6222000000000001",
|
||||
cp_account="6222000000000002", expense="88.00",
|
||||
)
|
||||
from bank_importer import matching
|
||||
matching.reconcile_rows(self.connection, [1])
|
||||
unresolved = positions.unresolved_for_company(
|
||||
self.connection, self.company_a, "2026-07-31"
|
||||
)
|
||||
self.assertEqual(1, unresolved["count"])
|
||||
self.assertEqual(
|
||||
Decimal("88.00"),
|
||||
Decimal(unresolved["by_reason"]["unmatched_single"]["gross_amount"]),
|
||||
)
|
||||
|
||||
def test_pending_manual_only_counts_for_submitting_company(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="incoming",
|
||||
amount="50.00", currency="CNY", funding_source="other",
|
||||
requested_subject="receivable", request_key="mr-a",
|
||||
actor=self.admin,
|
||||
)
|
||||
a_unresolved = positions.unresolved_for_company(
|
||||
self.connection, self.company_a, "2026-07-31"
|
||||
)
|
||||
b_unresolved = positions.unresolved_for_company(
|
||||
self.connection, self.company_b, "2026-07-31"
|
||||
)
|
||||
self.assertEqual(1, a_unresolved["count"])
|
||||
self.assertEqual(
|
||||
Decimal("50.00"), Decimal(a_unresolved["by_reason"]["manual_pending"]["gross_amount"])
|
||||
)
|
||||
# The counterparty never sees the unapproved declaration.
|
||||
self.assertEqual(0, b_unresolved["count"])
|
||||
|
||||
|
||||
class PaginationTests(LedgerBase):
|
||||
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"
|
||||
self.pair(self.company_a, self.company_b, f"{index + 1}.00", at=at)
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
confirm(self.connection, event["id"], revision["payer_company_id"],
|
||||
"other_receivable", f"k-{event['id']}", self.admin)
|
||||
|
||||
seen: list[int] = []
|
||||
cursor = None
|
||||
pages = 0
|
||||
while True:
|
||||
page = positions.list_events(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
limit=3, cursor=cursor,
|
||||
)
|
||||
pages += 1
|
||||
seen.extend(item["ledger_event_id"] for item in page["items"])
|
||||
cursor = page["next_cursor"]
|
||||
if not page["has_more"]:
|
||||
break
|
||||
self.assertEqual(7, len(seen))
|
||||
self.assertEqual(len(set(seen)), len(seen))
|
||||
self.assertGreater(pages, 2)
|
||||
|
||||
def test_directory_pagination_complete(self) -> None:
|
||||
for index in range(6):
|
||||
at = f"2026-01-{(index % 28) + 1:02d}T10:00:00"
|
||||
self.pair(self.company_a, self.company_b, f"{index + 1}.00", at=at)
|
||||
ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
|
||||
for event in self.ledger_events():
|
||||
revision = self.current(event["id"])
|
||||
confirm(self.connection, event["id"], revision["payer_company_id"],
|
||||
"other_receivable", f"k-{event['id']}", self.admin)
|
||||
seen: set[tuple[int, str]] = set()
|
||||
cursor = None
|
||||
while True:
|
||||
page = positions.company_balances(
|
||||
self.connection, from_="2026-01-01", cutoff="2026-07-31",
|
||||
limit=1, cursor=cursor,
|
||||
)
|
||||
seen.update((item["company_id"], item["currency"]) for item in page["items"])
|
||||
cursor = page["next_cursor"]
|
||||
if not page["has_more"]:
|
||||
break
|
||||
self.assertEqual(
|
||||
{(self.company_a, "CNY"), (self.company_b, "CNY")}, seen
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,463 @@
|
||||
"""HTTP integration tests for the B-44 intercompany position APIs.
|
||||
|
||||
Covers the admin balances/pair/events/evidence/subject-review/manual-record
|
||||
endpoints and the company-side scoped reads with masked evidence, plus
|
||||
cross-tenant 404s and company-forbidden admin writes. Every test method spins
|
||||
up its own server with a fresh database so subject confirmations and matched
|
||||
fixtures never leak across tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
from bank_importer.db import connect, migrate
|
||||
|
||||
import server
|
||||
from test_server_auth import Client, as_json
|
||||
|
||||
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
CASHIER_PASSWORD = "Cashier123"
|
||||
|
||||
CCB_HEADER = [
|
||||
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
|
||||
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
|
||||
]
|
||||
|
||||
ACCOUNT_A = "6222000000000001"
|
||||
ACCOUNT_B = "6222000000000002"
|
||||
|
||||
|
||||
def workbook_bytes(rows) -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "正常流水"
|
||||
sheet.append(CCB_HEADER)
|
||||
for row in rows:
|
||||
sheet.append(row)
|
||||
buffer = io.BytesIO()
|
||||
workbook.save(buffer)
|
||||
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 incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00"):
|
||||
return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", cp, "某银行", "借款", ""]
|
||||
|
||||
|
||||
class IntercompanyApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
root = Path(self.temp_dir.name)
|
||||
self.db_path = root / "app.db"
|
||||
self.storage = root / "files"
|
||||
self.initial_passwords: dict[str, str] = {}
|
||||
|
||||
self._old_db_path = server.DB_PATH
|
||||
self._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = self.db_path
|
||||
server.STORAGE_DIR = self.storage
|
||||
|
||||
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
|
||||
connection = connect(self.db_path)
|
||||
migrate(connection)
|
||||
assert server.ensure_bootstrap_admin(connection) is None
|
||||
connection.close()
|
||||
|
||||
class QuietHandler(server.AppHandler):
|
||||
def log_message(self, *args) -> None:
|
||||
pass
|
||||
|
||||
self.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
self.port = self.httpd.server_address[1]
|
||||
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
self.admin = Client("127.0.0.1", self.port)
|
||||
status, _, data = self.admin.post_json(
|
||||
"/api/login",
|
||||
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
|
||||
)
|
||||
assert status == 200, data
|
||||
status, _, data = self.admin.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
self.company_a = self._create_company("甲公司", "cashier-a")
|
||||
self.company_b = self._create_company("乙公司", "cashier-b")
|
||||
self.company_c = self._create_company("丙公司", "cashier-c")
|
||||
self.cashier_a = self._login_company("cashier-a", self.company_a)
|
||||
self.cashier_b = self._login_company("cashier-b", self.company_b)
|
||||
self.cashier_c = self._login_company("cashier-c", self.company_c)
|
||||
|
||||
self._approve_account(self.company_a, ACCOUNT_A)
|
||||
self._approve_account(self.company_b, ACCOUNT_B)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.httpd.shutdown()
|
||||
self.httpd.server_close()
|
||||
server.DB_PATH = self._old_db_path
|
||||
server.STORAGE_DIR = self._old_storage
|
||||
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
|
||||
|
||||
def _create_company(self, name: str, username: str) -> int:
|
||||
status, _, data = self.admin.post_json(
|
||||
"/api/admin/companies", {"name": name, "username": username}
|
||||
)
|
||||
assert status == 200, data
|
||||
self.initial_passwords.setdefault(username, as_json(data)["initial_password"])
|
||||
return as_json(data)["company_id"]
|
||||
|
||||
def _login_company(self, username: str, company_id: int) -> Client:
|
||||
client = Client("127.0.0.1", self.port)
|
||||
initial = self.initial_passwords[username]
|
||||
status, _, data = client.post_json(
|
||||
"/api/login", {"username": username, "password": initial, "portal": "company"}
|
||||
)
|
||||
assert status == 200, data
|
||||
status, _, data = client.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": initial, "new_password": CASHIER_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
return client
|
||||
|
||||
def _approve_account(self, company_id: int, number: str) -> None:
|
||||
client = {self.company_a: self.cashier_a, self.company_b: self.cashier_b}[company_id]
|
||||
status, _, data = client.post_json(
|
||||
"/api/company/accounts",
|
||||
{"bank_name": "中信银行", "account_type": "基本户",
|
||||
"account_number": number, "start_date": "2026-01-01"},
|
||||
)
|
||||
assert status == 200, data
|
||||
account_id = as_json(data)["account"]["id"]
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/review",
|
||||
{"decision": "approve", "reason": "测试启用", "effective_from": "2026-01-01"},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
def _upload_and_confirm(self, client, company_id: int, rows) -> int:
|
||||
content = workbook_bytes(rows)
|
||||
status, _, data = self.admin.post_multipart(
|
||||
"/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content
|
||||
)
|
||||
assert status == 200, data
|
||||
batch_id = as_json(data)["batch_id"]
|
||||
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
|
||||
names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"]
|
||||
status, _, data = client.post_json(
|
||||
f"/api/batches/{batch_id}/confirm", {"sheets": names}
|
||||
)
|
||||
assert status == 200, data
|
||||
return batch_id
|
||||
|
||||
def _fresh_pair(self, amount: str = "100.00", at: str = "2026-01-05 10:00:00") -> 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)],
|
||||
)
|
||||
self._upload_and_confirm(
|
||||
self.cashier_b, self.company_b,
|
||||
[incoming(ACCOUNT_B, ACCOUNT_A, amount, at.replace("10:", "11:"))],
|
||||
)
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/subject-reviews?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
pending = [item for item in items if item["amount"] == amount]
|
||||
self.assertEqual(1, len(pending), data)
|
||||
return pending[0]
|
||||
|
||||
def _confirm(self, ledger_event_id: int) -> dict:
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/events/{ledger_event_id}"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
detail = as_json(data)
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/intercompany/events/{ledger_event_id}/subject-decisions",
|
||||
{
|
||||
"perspective_company_id": detail["event"]["payer_company_id"],
|
||||
"subject_code": "other_receivable",
|
||||
"reason": "借款确认其他应收",
|
||||
"expected_revision": detail["event"]["ledger_revision_id"],
|
||||
"request_key": f"subj-{ledger_event_id}",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
return as_json(data)["revision"]
|
||||
|
||||
def balances(self, client=None, path="/api/admin/intercompany/balances") -> dict:
|
||||
client = client or self.admin
|
||||
status, _, data = client.get(path + "?from=2026-01-01&cutoff=2026-12-31")
|
||||
self.assertEqual(200, status, data)
|
||||
return as_json(data)
|
||||
|
||||
def _by_company(self, payload: dict, company_id: int) -> dict:
|
||||
return next(item for item in payload["items"] if item["company_id"] == company_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Admin balances / pair / events
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_admin_balances_directory(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
payload = self.balances()
|
||||
self.assertEqual("2026-12-31", payload["window"]["cutoff"])
|
||||
companies = {item["company_id"] for item in payload["items"]}
|
||||
self.assertIn(self.company_a, companies)
|
||||
self.assertIn(self.company_b, companies)
|
||||
for item in payload["items"]:
|
||||
self.assertEqual("unavailable", item["opening"]["status"])
|
||||
self.assertIsNone(item["opening"]["amount"])
|
||||
self.assertEqual("period_net_change", item["result"]["kind"])
|
||||
self.assertIn("gross_amount", item["unresolved"])
|
||||
self.assertIn("count", item["unresolved"])
|
||||
self.assertIn("by_reason", item["unresolved"])
|
||||
self.assertEqual("RMB", item["currency"])
|
||||
|
||||
def test_admin_balances_pending_subject_shows_unresolved(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
payload = self.balances()
|
||||
item = self._by_company(payload, self.company_a)
|
||||
self.assertEqual(1, item["unresolved"]["count"])
|
||||
self.assertEqual(
|
||||
"100.00", item["unresolved"]["by_reason"]["subject_review"]["gross_amount"]
|
||||
)
|
||||
self.assertEqual("0", item["period"]["debit"])
|
||||
self.assertEqual("0", item["period"]["credit"])
|
||||
|
||||
def test_admin_pair_detail_conserves(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/pairs/{self.company_a}/{self.company_b}"
|
||||
"?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
payload = as_json(data)
|
||||
item = payload["items"][0]
|
||||
self.assertTrue(item["conservation"]["opposite"])
|
||||
self.assertTrue(item["conservation"]["abs_equal"])
|
||||
self.assertEqual("unavailable", item["opening"]["status"])
|
||||
self.assertIn("subjects", item)
|
||||
self.assertEqual(1, item["unresolved"]["count"])
|
||||
|
||||
def test_admin_subject_decision_flows_to_balances(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
revision = self._confirm(pending["ledger_event_id"])
|
||||
self.assertEqual("confirmed", revision["state"])
|
||||
self.assertEqual("other_receivable", revision["subject_code"])
|
||||
payload = self.balances()
|
||||
item = self._by_company(payload, self.company_a)
|
||||
self.assertEqual(0, item["unresolved"]["count"])
|
||||
self.assertEqual("100.00", item["period"]["debit"])
|
||||
self.assertEqual("100.00", item["result"]["signed_amount"])
|
||||
|
||||
def test_admin_subject_decision_stale_revision_conflicts(self) -> None:
|
||||
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": pending["payer_company_id"],
|
||||
"subject_code": "other_receivable", "reason": "确认",
|
||||
"expected_revision": 999, "request_key": "stale-key",
|
||||
},
|
||||
)
|
||||
self.assertEqual(409, status, data)
|
||||
|
||||
def test_admin_events_list_and_evidence(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
revision = self._confirm(pending["ledger_event_id"])
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
self.assertTrue(items)
|
||||
event = next(
|
||||
item for item in items
|
||||
if item["ledger_event_id"] == revision["ledger_event_id"]
|
||||
)
|
||||
self.assertEqual("confirmed", event["state"])
|
||||
self.assertEqual("other_receivable", event["subject_code"])
|
||||
self.assertIn("payer_company_name", event)
|
||||
self.assertIn("amount", event)
|
||||
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/events/{event['ledger_event_id']}"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual("confirmed", as_json(data)["state"])
|
||||
|
||||
status, _, data = self.admin.get(
|
||||
f"/api/admin/intercompany/events/{event['ledger_event_id']}/evidence"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
blocks = as_json(data)["blocks"]
|
||||
self.assertTrue(blocks)
|
||||
for block in blocks:
|
||||
self.assertEqual("visible", block["visibility"])
|
||||
|
||||
def test_admin_events_list_includes_pending_subject(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
status, _, data = self.admin.get(
|
||||
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
item = next(
|
||||
item for item in items
|
||||
if item["ledger_event_id"] == pending["ledger_event_id"]
|
||||
)
|
||||
self.assertEqual("pending_subject", item["state"])
|
||||
self.assertIsNone(item["subject_code"])
|
||||
|
||||
def test_admin_manual_record_decision_via_api(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-api-1", "summary": "还款",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
record = as_json(data)["record"]
|
||||
self.assertEqual("pending", record["state"])
|
||||
|
||||
status, _, data = self.admin.get("/api/admin/manual-records")
|
||||
self.assertEqual(200, status, data)
|
||||
records = as_json(data)["records"]
|
||||
self.assertTrue(any(item["id"] == record["id"] for item in records))
|
||||
|
||||
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-api-1"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual("approved", as_json(data)["decision"]["state"])
|
||||
|
||||
status, _, data = self.cashier_a.get("/api/company/manual-records")
|
||||
self.assertEqual(200, status, data)
|
||||
own = [item for item in as_json(data)["records"] if item["id"] == record["id"]]
|
||||
self.assertEqual(1, len(own))
|
||||
self.assertEqual("approved", own[0]["state"])
|
||||
|
||||
status, _, data = self.cashier_b.get("/api/company/manual-records")
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual([], [
|
||||
item for item in as_json(data)["records"] if item["id"] == record["id"]
|
||||
])
|
||||
|
||||
def test_admin_adjustment_reverse_via_api(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
revision = self._confirm(pending["ledger_event_id"])
|
||||
status, _, data = self.admin.post_json(
|
||||
f"/api/admin/intercompany/events/{revision['ledger_event_id']}/adjustments",
|
||||
{"action": "reverse", "reason": "科目误判,冲销"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual("reverse", as_json(data)["action"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Company scope and masking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_company_balances_scoped_to_own_company(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
status, _, data = self.cashier_a.get(
|
||||
"/api/company/intercompany/balances?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
payload = as_json(data)
|
||||
for item in payload["items"]:
|
||||
self.assertEqual(self.company_a, item["company_id"])
|
||||
self.assertIn("counterparties", payload)
|
||||
self.assertTrue(payload["counterparties"])
|
||||
|
||||
def test_company_pair_and_events_are_own_scope(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
status, _, data = self.cashier_a.get(
|
||||
f"/api/company/intercompany/pairs/{self.company_b}"
|
||||
"?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
self.assertEqual(self.company_a, as_json(data)["companies"]["a"]["company_id"])
|
||||
|
||||
status, _, data = self.cashier_a.get(
|
||||
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
items = as_json(data)["items"]
|
||||
self.assertTrue(items)
|
||||
for item in items:
|
||||
self.assertIn("direction", item)
|
||||
self.assertIn("own_subject_label", item)
|
||||
self.assertIn(item["direction"], ("incoming", "outgoing"))
|
||||
|
||||
def test_company_evidence_masks_counterparty_side(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
self._confirm(pending["ledger_event_id"])
|
||||
status, _, data = self.cashier_a.get(
|
||||
f"/api/company/intercompany/events/{pending['ledger_event_id']}/evidence"
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
blocks = as_json(data)["blocks"]
|
||||
visibilities = {block["visibility"] for block in blocks}
|
||||
self.assertTrue(visibilities & {"visible", "masked"})
|
||||
for block in blocks:
|
||||
if block["visibility"] == "masked":
|
||||
self.assertEqual("按对方授权不可见", block["fields"].get("note"))
|
||||
self.assertNotIn(ACCOUNT_B, json.dumps(block["fields"], ensure_ascii=False))
|
||||
|
||||
def test_company_cannot_read_or_write_admin_intercompany(self) -> None:
|
||||
self._fresh_pair("100.00")
|
||||
status, _, data = self.cashier_a.get("/api/admin/intercompany/balances")
|
||||
self.assertEqual(403, status, data)
|
||||
status, _, data = self.cashier_a.get("/api/admin/manual-records")
|
||||
self.assertEqual(403, status, data)
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
"/api/admin/manual-records/1/decisions", {"action": "approve_new", "reason": "x"}
|
||||
)
|
||||
self.assertEqual(403, status, data)
|
||||
status, _, data = self.cashier_a.post_json(
|
||||
"/api/admin/intercompany/events/1/adjustments",
|
||||
{"action": "reverse", "reason": "越权"},
|
||||
)
|
||||
self.assertEqual(403, status, data)
|
||||
|
||||
def test_cross_company_reads_are_404(self) -> None:
|
||||
pending = self._fresh_pair("100.00")
|
||||
status, _, data = self.cashier_c.get(
|
||||
f"/api/company/intercompany/events/{pending['ledger_event_id']}"
|
||||
)
|
||||
self.assertEqual(404, status, data)
|
||||
status, _, data = self.cashier_c.get(
|
||||
f"/api/company/intercompany/events/{pending['ledger_event_id']}/evidence"
|
||||
)
|
||||
self.assertEqual(404, status, data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user