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,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