"""HEL-168: company workspace pending unilateral counts sync after confirm.""" from __future__ import annotations import io import os from pathlib import Path import tempfile import threading import unittest from openpyxl import Workbook import server from test_server_auth import Client, as_json BOOTSTRAP_PASSWORD = "BootAdmin123" ADMIN_PASSWORD = "AdminPass123" CASHIER_PASSWORD = "Cashier123" CCB_HEADER = [ "客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)", "余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注", ] ACCOUNT_A = "6222000000001001" ACCOUNT_B = "6222000000001002" 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) -> list: return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""] class CompanyWorkspaceTodoTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.temp_dir = tempfile.TemporaryDirectory() root = Path(cls.temp_dir.name) cls.db_path = root / "app.db" cls.storage = root / "files" cls._old_db_path = server.DB_PATH cls._old_storage = server.STORAGE_DIR server.DB_PATH = cls.db_path server.STORAGE_DIR = cls.storage os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD from bank_importer.db import connect, migrate connection = connect(cls.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 cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler) cls.port = cls.httpd.server_address[1] cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True) cls.thread.start() cls.admin = Client("127.0.0.1", cls.port) status, _, data = cls.admin.post_json( "/api/login", {"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"}, ) assert status == 200, data status, _, data = cls.admin.post_json( "/api/password/change", {"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD}, ) assert status == 200, data cls.initial_passwords: dict[str, str] = {} cls.company_a = cls._create_company("甲公司", "ws-cashier-a") cls.company_b = cls._create_company("乙公司", "ws-cashier-b") cls.cashier_a = cls._login_company("ws-cashier-a") cls.cashier_b = cls._login_company("ws-cashier-b") cls._approve_account(cls.cashier_a, ACCOUNT_A) cls._approve_account(cls.cashier_b, ACCOUNT_B) @classmethod def tearDownClass(cls) -> None: cls.httpd.shutdown() cls.httpd.server_close() server.DB_PATH = cls._old_db_path server.STORAGE_DIR = cls._old_storage os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None) cls.temp_dir.cleanup() @classmethod def _create_company(cls, name: str, username: str) -> int: status, _, data = cls.admin.post_json( "/api/admin/companies", {"name": name, "username": username}, ) assert status == 200, data payload = as_json(data) cls.initial_passwords[username] = payload["initial_password"] return payload["company_id"] @classmethod def _login_company(cls, username: str) -> Client: client = Client("127.0.0.1", cls.port) initial = cls.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 @classmethod def _approve_account(cls, company_client: Client, number: str) -> int: status, _, data = company_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 = cls.admin.post_json( f"/api/admin/accounts/{account_id}/review", {"decision": "approve", "reason": "测试启用", "effective_from": "2026-01-01"}, ) assert status == 200, data return account_id def _seed_unilateral(self, amount: str, at: str, cp_account: str = "9999999999999999") -> dict: status, _, data = self.admin.post_multipart( "/api/parse", {"company_id": str(self.company_a)}, f"单边-{amount}.xlsx", workbook_bytes([outgoing(ACCOUNT_A, cp_account, amount, at)]), ) self.assertEqual(200, status, data) batch_id = as_json(data)["batch_id"] status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets") self.assertEqual(200, status, data) names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"] status, _, data = self.cashier_a.post_json( f"/api/batches/{batch_id}/confirm", {"sheets": names} ) self.assertEqual(200, status, data) status, _, data = self.cashier_a.get("/api/company/workspace") self.assertEqual(200, status, data) events = [ e for e in as_json(data)["unilateral_events"] if e["amount"] == amount ] self.assertEqual(1, len(events), data) return events[0] def _workspace(self, client: Client) -> dict: status, _, data = client.get("/api/company/workspace") self.assertEqual(200, status, data) payload = as_json(data) self.assertEqual("ok", payload["status"]) return payload def test_seed_three_confirm_sync_and_isolation(self) -> None: # Seed 3 pending unilaterals for company A. e1 = self._seed_unilateral("11.00", "2026-03-01 10:00:00") e2 = self._seed_unilateral("22.00", "2026-03-02 10:00:00") e3 = self._seed_unilateral("33.00", "2026-03-03 10:00:00") seeded_ids = {e1["event_id"], e2["event_id"], e3["event_id"]} ws = self._workspace(self.cashier_a) own = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids] self.assertEqual(3, len(own)) self.assertEqual(ws["pending_unilateral"], ws["pending_total"]) self.assertGreaterEqual(ws["pending_unilateral"], 3) # 两处数字与列表同一权威口径 self.assertEqual(ws["pending_unilateral"], len(ws["unilateral_events"])) # 另一公司看不到甲公司这 3 笔 ws_b = self._workspace(self.cashier_b) self.assertEqual( [], [e for e in ws_b["unilateral_events"] if e["event_id"] in seeded_ids], ) # match-exceptions 与 workspace 同集合 status, _, data = self.cashier_a.get("/api/company/match-exceptions") self.assertEqual(200, status, data) exceptions = as_json(data)["exceptions"] exception_ids = {e["event_id"] for e in exceptions} self.assertTrue(seeded_ids.issubset(exception_ids)) remaining = [e1, e2, e3] for expected in (2, 1, 0): event = remaining.pop(0) status, _, data = self.cashier_a.post_json( f"/api/company/transfer-events/{event['event_id']}/confirm", { "expected_revision": event["revision"], "request_key": f"hel168-{event['event_id']}-ok", "counterparty_company_id": self.company_b, "reason": "公司端确认单边流水", }, ) self.assertEqual(200, status, data) body = as_json(data) self.assertIn("workspace", body) left = [ e for e in body["workspace"]["unilateral_events"] if e["event_id"] in seeded_ids ] self.assertEqual(expected, len(left)) self.assertEqual(body["workspace"]["pending_unilateral"], len(body["workspace"]["unilateral_events"])) # 刷新等价:重新 GET 仍为同步后的数字 ws = self._workspace(self.cashier_a) left = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids] self.assertEqual(expected, len(left)) # 重复确认:事件已离开待确认集合且 request_key 不同 → 404,数字不得误减 status, _, data = self.cashier_a.post_json( f"/api/company/transfer-events/{e1['event_id']}/confirm", { "expected_revision": e1["revision"], "request_key": f"hel168-{e1['event_id']}-again", "counterparty_company_id": self.company_b, "reason": "公司端确认单边流水", }, ) self.assertEqual(404, status, data) ws = self._workspace(self.cashier_a) left = [e for e in ws["unilateral_events"] if e["event_id"] in seeded_ids] self.assertEqual(0, len(left)) # 同一 request_key 重放:幂等成功,数字不误减 e4 = self._seed_unilateral("44.00", "2026-03-04 10:00:00") key = f"hel168-{e4['event_id']}-replay" status, _, data = self.cashier_a.post_json( f"/api/company/transfer-events/{e4['event_id']}/confirm", { "expected_revision": e4["revision"], "request_key": key, "counterparty_company_id": self.company_b, "reason": "公司端确认单边流水", }, ) self.assertEqual(200, status, data) after_first = self._workspace(self.cashier_a)["pending_unilateral"] status, _, data = self.cashier_a.post_json( f"/api/company/transfer-events/{e4['event_id']}/confirm", { "expected_revision": e4["revision"], "request_key": key, "counterparty_company_id": self.company_b, "reason": "公司端确认单边流水", }, ) self.assertEqual(200, status, data) self.assertEqual(after_first, as_json(data)["workspace"]["pending_unilateral"]) self.assertEqual(after_first, self._workspace(self.cashier_a)["pending_unilateral"]) # 失败请求不得误减:用不存在的事件 before = self._workspace(self.cashier_a)["pending_unilateral"] status, _, data = self.cashier_a.post_json( "/api/company/transfer-events/999999/confirm", { "expected_revision": 1, "request_key": "hel168-fail", "counterparty_company_id": self.company_b, }, ) self.assertEqual(404, status, data) self.assertEqual(before, self._workspace(self.cashier_a)["pending_unilateral"]) # 乙公司不能确认甲公司事件(即使猜到 id) status, _, data = self.cashier_b.post_json( f"/api/company/transfer-events/{e2['event_id']}/confirm", { "expected_revision": e2["revision"], "request_key": "hel168-cross", "counterparty_company_id": self.company_a, }, ) self.assertEqual(404, status, data) def test_admin_forbidden_on_company_workspace(self) -> None: status, _, data = self.admin.get("/api/company/workspace") self.assertEqual(403, status, data) if __name__ == "__main__": unittest.main()