595 lines
26 KiB
Python
595 lines
26 KiB
Python
"""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",
|
|
currency: str = "RMB"):
|
|
return [own, "测试公司", at, amount, "", "50000.00", currency, "对方", cp, "某银行", "借款", ""]
|
|
|
|
|
|
def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00",
|
|
currency: str = "RMB"):
|
|
return [own, "测试公司", at, "", amount, "50000.00", currency, "对方", 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",
|
|
currency: str = "RMB") -> 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, currency)],
|
|
)
|
|
self._upload_and_confirm(
|
|
self.cashier_b, self.company_b,
|
|
[incoming(ACCOUNT_B, ACCOUNT_A, amount, at.replace("10:", "11:"), currency)],
|
|
)
|
|
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)
|
|
|
|
def test_company_counterparty_summary_splits_by_currency(self) -> None:
|
|
# A->B 100 CNY and A->B 50 USD must render two rows, never a mixed
|
|
# "CNY 150.00" bucket.
|
|
cny = self._fresh_pair("100.00", currency="CNY")
|
|
usd = self._fresh_pair("50.00", at="2026-01-20 10:00:00", currency="USD")
|
|
for pending in (cny, usd):
|
|
self._confirm(pending["ledger_event_id"])
|
|
|
|
status, _, data = self.cashier_a.get(
|
|
"/api/company/intercompany/balances?from=2026-01-01&cutoff=2026-12-31"
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
counterparties = as_json(data)["counterparties"]
|
|
rows = [row for row in counterparties if row["counterparty_company_id"] == self.company_b]
|
|
self.assertEqual(2, len(rows), counterparties)
|
|
by_currency = {row["currency"]: row for row in rows}
|
|
self.assertEqual({"CNY", "USD"}, set(by_currency))
|
|
self.assertEqual("100.00", by_currency["CNY"]["result"]["signed_amount"])
|
|
self.assertEqual("50.00", by_currency["USD"]["result"]["signed_amount"])
|
|
for row in rows:
|
|
self.assertNotEqual("150.00", row["result"]["signed_amount"])
|
|
self.assertEqual(1, row["event_count"])
|
|
|
|
def test_company_events_subject_filter_matches_mirror(self) -> None:
|
|
# A confirms subject "receivable" (stored from A's perspective); B must
|
|
# still find the event when filtering by the mirror subject "payable".
|
|
pending = self._fresh_pair("100.00")
|
|
status, _, data = self.admin.post_json(
|
|
f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
|
|
{
|
|
"perspective_company_id": self.company_a,
|
|
"subject_code": "receivable", "reason": "确认应收",
|
|
"expected_revision": 1, "request_key": "mirror-subj",
|
|
},
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
|
|
status, _, data = self.cashier_b.get(
|
|
"/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31&subject=payable"
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
items = as_json(data)["items"]
|
|
self.assertTrue(any(
|
|
item["ledger_event_id"] == pending["ledger_event_id"] for item in items
|
|
), data)
|
|
|
|
def test_manual_reverse_via_api_uses_explicit_effective_date(self) -> None:
|
|
status, _, data = self.cashier_a.post_json(
|
|
"/api/company/manual-records",
|
|
{
|
|
"counterparty_company_id": self.company_b,
|
|
"occurred_at": "2026-02-01T09:00:00",
|
|
"direction": "incoming", "amount": "20.00", "currency": "CNY",
|
|
"funding_source": "other", "requested_subject": "other_receivable",
|
|
"request_key": "mr-rev-api", "summary": "还款",
|
|
},
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
record = as_json(data)["record"]
|
|
status, _, data = self.admin.post_json(
|
|
f"/api/admin/manual-records/{record['id']}/decisions",
|
|
{"action": "approve_new", "reason": "确认入账",
|
|
"expected_decision_id": record["decision_id"], "request_key": "dec-rev-api"},
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
event_id = as_json(data)["decision"]["ledger_event_id"]
|
|
|
|
status, _, data = self.admin.get(
|
|
f"/api/admin/intercompany/events/{event_id}"
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
revision_id = as_json(data)["event"]["ledger_revision_id"]
|
|
|
|
status, _, data = self.admin.post_json(
|
|
f"/api/admin/manual-records/{record['id']}/decisions",
|
|
{"action": "reverse", "reason": "误录冲销", "effective_at": "2026-06-15",
|
|
"expected_decision_id": None, "request_key": "dec-rev-api-2"},
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
|
|
status, _, data = self.admin.get(
|
|
"/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
reversals = [item for item in as_json(data)["items"] if item["posting_kind"] == "reversal"]
|
|
self.assertEqual(1, len(reversals))
|
|
self.assertEqual("2026-06-15", reversals[0]["effective_at"][:10])
|
|
self.assertEqual(event_id, reversals[0]["reverses_ledger_event_id"])
|
|
|
|
def test_admin_events_include_account_chips(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)
|
|
event = next(
|
|
item for item in as_json(data)["items"]
|
|
if item["ledger_event_id"] == revision["ledger_event_id"]
|
|
)
|
|
self.assertIn("payer_account", event)
|
|
self.assertIn("payee_account", event)
|
|
self.assertEqual("visible", event["payer_account"]["visibility"])
|
|
self.assertTrue(event["payer_account"]["label"])
|
|
self.assertIn("summary", event)
|
|
self.assertIn("is_repayment", event)
|
|
|
|
def test_subject_exception_drops_from_review_queue(self) -> None:
|
|
pending = self._fresh_pair("77.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"],
|
|
"action": "exception",
|
|
"reason": "转异常待核查",
|
|
"expected_revision": pending["revision_id"],
|
|
"request_key": "park-exc-api",
|
|
},
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
self.assertEqual("pending_subject", as_json(data)["revision"]["state"])
|
|
status, _, data = self.admin.get(
|
|
"/api/admin/subject-reviews?from=2026-01-01&cutoff=2026-12-31"
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
ids = [item["ledger_event_id"] for item in as_json(data)["items"]]
|
|
self.assertNotIn(pending["ledger_event_id"], ids)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|