- 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)
176 lines
6.8 KiB
Python
176 lines
6.8 KiB
Python
"""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()
|