From 85293b79df59dbd9ebda6e50881dd4cdb0bc196b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=85=BE=E8=AE=AFWorkBuddy?= Date: Wed, 19 Aug 2026 11:58:25 +0800 Subject: [PATCH 1/6] 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) --- docs/decisions/006-intercompany-positions.md | 87 ++ server.py | 815 +++++++++++- src/bank_importer/db.py | 229 ++++ src/bank_importer/importing.py | 2 + src/bank_importer/ledger_events.py | 684 ++++++++++ src/bank_importer/manual_records.py | 778 +++++++++++ src/bank_importer/positions.py | 1210 ++++++++++++++++++ src/bank_importer/subjects.py | 302 +++++ tests/ledger_helpers.py | 175 +++ tests/test_ledger_events.py | 306 +++++ tests/test_manual_records.py | 348 +++++ tests/test_persistence.py | 110 +- tests/test_positions.py | 323 +++++ tests/test_positions_api.py | 463 +++++++ web/admin.html | 35 +- web/app.js | 1041 +++++++++++++-- web/company.html | 35 +- web/styles.css | 101 ++ 18 files changed, 6902 insertions(+), 142 deletions(-) create mode 100644 docs/decisions/006-intercompany-positions.md create mode 100644 src/bank_importer/ledger_events.py create mode 100644 src/bank_importer/manual_records.py create mode 100644 src/bank_importer/positions.py create mode 100644 src/bank_importer/subjects.py create mode 100644 tests/ledger_helpers.py create mode 100644 tests/test_ledger_events.py create mode 100644 tests/test_manual_records.py create mode 100644 tests/test_positions.py create mode 100644 tests/test_positions_api.py diff --git a/docs/decisions/006-intercompany-positions.md b/docs/decisions/006-intercompany-positions.md new file mode 100644 index 0000000..9323c37 --- /dev/null +++ b/docs/decisions/006-intercompany-positions.md @@ -0,0 +1,87 @@ +# 006: Intercompany positions, subject review and drill-down evidence (B-44) + +Status: accepted (2026-08-19) + +## Decision + +The intercompany ledger adds one canonical layer above B-43's eligible events: + +- **Append-only ledger events.** A canonical `ledger_event` carries an + append-only `ledger_event_revisions` chain. A B-43 eligible bank event first + becomes a `pending_subject` revision; an administrator confirms the subject + into a `confirmed` revision. Corrections are never in-place edits — a + reversal or adjustment is a *new* ledger event with its own effective date, + and the original event keeps its history so earlier cutoffs are not + rewritten. +- **Manual records as immutable submitted facts.** Companies submit + `manual_records`; only an administrator-approved record becomes a confirmed + ledger event (`approve_new`) or joins one (`approve_link`). Returned, + exception and pending records never affect a balance and never leak to the + counterparty. Approved facts change only through `reverse` (a new opposite + event) — the original is never edited. +- **One perspective, fixed mirror.** Subjects are stored from one participating + company's perspective (`receivable/payable/other_receivable/other_payable`); + the other side is the fixed mirror (应收<->应付, 其他应收<->其他应付), so the + two companies can never book conflicting subjects. +- **Subjects are confirmed, never auto-posted.** Bank summary/purpose text only + feeds a deterministic *suggestion* dictionary (`subject-suggest-draft-v1`, + not group-approved). Without an approved trade dictionary every bank event + stays in subject review until an administrator confirms. +- **Decimal-only aggregation.** All money is stored as TEXT decimal strings and + aggregated with Python `Decimal`. SQLite `SUM`, JavaScript `Number` and + Python `float` never touch financial math. Different currencies are + aggregated and displayed separately; nothing is converted to a group total. +- **Conservation is asserted.** For every company pair and currency the two + perspectives must mirror exactly (`C_A == -C_B`); a violation raises a + calculation exception instead of rendering an unbalanced number. +- **Unresolved is absolute gross.** Unresolved amounts are summed by absolute + value per currency (never netted), broken down by reason + (`subject_review`, `unmatched_single`, `manual_pending`), with count and + gross amount exposed on every balance response. +- **B-45 boundary.** Until the B-45 opening balance exists, every response + returns `opening.status=unavailable`, `opening.amount=null` and + `result.kind=period_net_change`; the UI labels this "期间净变动", never + "期末余额". + +## Background + +B-43 produces `eligible_intercompany_events` as the only bank-event entry +point. Before B-44 there was no canonical financial event, no statutory +subject, no manual-record approval, and no server-side balance API. The +revision-chain design is inherited from `transfer_match_decisions` in +migration 5 and from the append-only audit posture of the rest of the system. + +## Consequences + +- **Positive:** balances are deterministic, conservable, auditable and + drillable from a group directory down to bank source rows; manual records + cannot double count; corrections never mutate evidence; company portals are + tenant-scoped on the server. +- **Negative:** pending bank events and returned/exception manual records are + intentionally invisible to counterparties, which can surprise cashiers who + expect symmetric disclosure; subject confirmation is manual until a + group-approved dictionary exists. +- **Operational:** migration 6 is forward-only for production once + approvals/revisions exist; pre-production it can be rolled back with + `--rollback-to 5`. Read aggregation runs against the rebuildable current + projection (no day snapshots yet); if B-45 monthly close needs them, immutable + monthly snapshots can be added behind the same API contract. + +## Files + +- `src/bank_importer/ledger_events.py` — event lifecycle, revision chain, + bank reconciliation, reversal/adjustment/reopen, projection rebuild. +- `src/bank_importer/subjects.py` — subject constants/mirror, suggestion + dictionary, `confirm_subject`. +- `src/bank_importer/manual_records.py` — submission, approval + (new/link), return/exception/reverse, candidate hints, idempotency. +- `src/bank_importer/positions.py` — Decimal aggregation, directories, pairs, + events, evidence visibility, unresolved buckets, keyset pagination. +- `server.py` — `/api/admin/intercompany/*` and `/api/company/intercompany/*` + plus `/api/admin/subject-reviews` and `/api/admin/manual-records`. +- `db.py` migration 6 — `manual_records`, `manual_record_decisions`, + `ledger_events`, `ledger_event_revisions`, current-pointer projections, + source claim tables, `ledger_subject_suggestions`, `eligible_position_events`. +- Tests: `test_ledger_events.py`, `test_manual_records.py`, + `test_positions.py`, `test_positions_api.py`, extended + `test_persistence.py`. diff --git a/server.py b/server.py index bf89568..e05f88a 100644 --- a/server.py +++ b/server.py @@ -10,7 +10,10 @@ from http.cookies import SimpleCookie from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse -from bank_importer import auth, importing, master_data, matching, multipart, personal_transit +from bank_importer import ( + auth, importing, ledger_events, manual_records, master_data, matching, + multipart, personal_transit, positions, subjects, +) from bank_importer.db import connect, migrate, utc_now @@ -101,6 +104,68 @@ class AppHandler(SimpleHTTPRequestHandler): self._handle_company_transfer_event_detail(int(company_event_match.group(1))) return + # B-44 intercompany positions (admin) + if path == "/api/admin/intercompany/balances": + self._handle_admin_intercompany_balances(query) + return + admin_pair = re.fullmatch( + r"/api/admin/intercompany/pairs/(\d+)/(\d+)", path + ) + if admin_pair: + self._handle_admin_intercompany_pair( + int(admin_pair.group(1)), int(admin_pair.group(2)), query + ) + return + if path == "/api/admin/intercompany/events": + self._handle_admin_intercompany_events(query) + return + if path == "/api/admin/subject-reviews": + self._handle_admin_subject_reviews(query) + return + if path == "/api/admin/manual-records": + self._handle_admin_manual_records(query) + return + admin_event_match = re.fullmatch(r"/api/admin/intercompany/events/(\d+)", path) + if admin_event_match: + self._handle_admin_intercompany_event_detail(int(admin_event_match.group(1))) + return + admin_evidence = re.fullmatch( + r"/api/admin/intercompany/events/(\d+)/evidence", path + ) + if admin_evidence: + self._handle_admin_intercompany_evidence(int(admin_evidence.group(1))) + return + + # B-44 intercompany positions (company, own-company scope) + if path == "/api/company/intercompany/balances": + self._handle_company_intercompany_balances(query) + return + company_pair = re.fullmatch(r"/api/company/intercompany/pairs/(\d+)", path) + if company_pair: + self._handle_company_intercompany_pair(int(company_pair.group(1)), query) + return + if path == "/api/company/intercompany/events": + self._handle_company_intercompany_events(query) + return + if path == "/api/company/manual-records": + self._handle_company_manual_records(query) + return + if path == "/api/company/companies": + self._handle_company_companies() + return + company_event_match = re.fullmatch(r"/api/company/intercompany/events/(\d+)", path) + if company_event_match: + self._handle_company_intercompany_event_detail( + int(company_event_match.group(1)) + ) + return + company_evidence = re.fullmatch( + r"/api/company/intercompany/events/(\d+)/evidence", path + ) + if company_evidence: + self._handle_company_intercompany_evidence(int(company_evidence.group(1))) + return + if path == "/admin.html" and not self._guard_page("admin"): return if path == "/company.html" and not self._guard_page("company"): @@ -167,6 +232,29 @@ class AppHandler(SimpleHTTPRequestHandler): if mapping_review: self._handle_admin_review_personal_mapping(int(mapping_review.group(1))) return + + # B-44 intercompany positions (admin writes) + subject_decision = re.fullmatch( + r"/api/admin/intercompany/events/(\d+)/subject-decisions", path + ) + if subject_decision: + self._handle_admin_subject_decision(int(subject_decision.group(1))) + return + adjustment = re.fullmatch( + r"/api/admin/intercompany/events/(\d+)/adjustments", path + ) + if adjustment: + self._handle_admin_intercompany_adjustment(int(adjustment.group(1))) + return + manual_decision = re.fullmatch(r"/api/admin/manual-records/(\d+)/decisions", path) + if manual_decision: + self._handle_admin_manual_record_decision(int(manual_decision.group(1))) + return + + # B-44 intercompany positions (company writes) + if path == "/api/company/manual-records": + self._handle_company_manual_records_submit() + return self._send_json(404, {"status": "error", "message": "接口不存在。"}) # ------------------------------------------------------------------ @@ -1611,6 +1699,11 @@ class AppHandler(SimpleHTTPRequestHandler): except Exception as exc: self._send_json(500, {"status": "error", "message": f"重跑匹配失败:{exc}"}) return + try: + ledger_events.reconcile_bank_events(connection, actor=user) + except Exception as exc: + self._send_json(500, {"status": "error", "message": f"同步往来事件失败:{exc}"}) + return auth.audit( connection, "transfer_reconcile", actor=user, target=f"rows:{len(row_ids)}", @@ -1664,6 +1757,11 @@ class AppHandler(SimpleHTTPRequestHandler): except matching.MatchInputError as exc: self._send_json(400, {"status": "error", "message": str(exc)}) return + try: + ledger_events.reconcile_bank_events(connection, actor=user) + except Exception as exc: + self._send_json(500, {"status": "error", "message": f"同步往来事件失败:{exc}"}) + return self._send_json(200, {"status": "ok", "decision": payload}) finally: connection.close() @@ -1974,6 +2072,721 @@ class AppHandler(SimpleHTTPRequestHandler): return rows + # ------------------------------------------------------------------ + # B-44 intercompany positions (admin) + # ------------------------------------------------------------------ + + def _parse_window(self, query: dict[str, list[str]]) -> tuple[str, str]: + raw_from = (query.get("from") or [None])[0] + raw_cutoff = (query.get("cutoff") or [None])[0] + if raw_cutoff is None: + raw_cutoff = positions.today_shanghai() + try: + return positions.validate_window(raw_from, raw_cutoff) + except positions.PositionInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + raise + + def _parse_limit(self, query: dict[str, list[str]], default: int = 50) -> int: + raw = (query.get("limit") or [str(default)])[0] + try: + return max(1, min(int(raw), 200)) + except ValueError: + return default + + def _parse_int(self, query: dict[str, list[str]], key: str, label: str) -> int | None: + raw = (query.get(key) or [None])[0] + if not raw: + return None + try: + return int(raw) + except ValueError: + self._send_json(400, {"status": "error", "message": f"{label}参数无效。"}) + raise + + def _handle_admin_intercompany_balances(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + try: + from_, cutoff = self._parse_window(query) + company_id = self._parse_int(query, "company_id", "company_id") + currency = (query.get("currency") or [None])[0] + limit = self._parse_limit(query) + cursor = (query.get("cursor") or [None])[0] + except Exception: + return + try: + payload = positions.company_balances( + connection, from_=from_, cutoff=cutoff, currency=currency, + company_id=company_id, limit=limit, cursor=cursor, + ) + except positions.PositionInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + self._send_json(200, {"status": "ok", **payload}) + finally: + connection.close() + + def _handle_admin_intercompany_pair( + self, company_a: int, company_b: int, query: dict[str, list[str]] + ) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + if not self._companies_exist(connection, (company_a, company_b)): + self._send_json(404, {"status": "error", "message": "公司不存在。"}) + return + try: + from_, cutoff = self._parse_window(query) + currency = (query.get("currency") or [None])[0] + except Exception: + return + try: + payload = positions.pair_detail( + connection, company_a, company_b, + from_=from_, cutoff=cutoff, currency=currency, + ) + except positions.PositionError as exc: + self._send_json(422, {"status": "error", "message": str(exc)}) + return + except positions.PositionInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + self._send_json(200, {"status": "ok", **payload}) + finally: + connection.close() + + def _companies_exist(self, connection, ids: tuple[int, ...]) -> bool: + for company_id in ids: + row = connection.execute( + "SELECT id FROM companies WHERE id = ?", (company_id,) + ).fetchone() + if row is None: + return False + return True + + def _handle_admin_intercompany_events(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + try: + from_, cutoff = self._parse_window(query) + currency = (query.get("currency") or [None])[0] + company_id = self._parse_int(query, "company_id", "company_id") + company_a = self._parse_int(query, "company_a", "company_a") + company_b = self._parse_int(query, "company_b", "company_b") + subject = (query.get("subject") or [None])[0] + state = (query.get("state") or [None])[0] + posting_kind = (query.get("posting_kind") or [None])[0] + source_kind = (query.get("source_kind") or [None])[0] + limit = self._parse_limit(query) + cursor = (query.get("cursor") or [None])[0] + except Exception: + return + try: + payload = positions.list_events( + connection, from_=from_, cutoff=cutoff, currency=currency, + company_id=company_id, company_a=company_a, company_b=company_b, + subject=subject, state=state, posting_kind=posting_kind, + source_kind=source_kind, limit=limit, cursor=cursor, + ) + except positions.PositionInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + self._send_json(200, {"status": "ok", **payload}) + finally: + connection.close() + + def _handle_admin_subject_reviews(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + try: + from_, cutoff = self._parse_window(query) + company_id = self._parse_int(query, "company_id", "company_id") + limit = self._parse_limit(query) + cursor = (query.get("cursor") or [None])[0] + except Exception: + return + try: + payload = positions.subject_review_queue( + connection, from_=from_, cutoff=cutoff, + company_id=company_id, limit=limit, cursor=cursor, + ) + except positions.PositionInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + self._send_json(200, {"status": "ok", **payload}) + finally: + connection.close() + + def _handle_admin_manual_records(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + company_id = self._parse_int(query, "company_id", "company_id") + if company_id is None and (query.get("company_id") or [None])[0]: + return + state = (query.get("state") or [None])[0] + limit = self._parse_limit(query, 100) + try: + rows = manual_records.list_records( + connection, company_id=company_id, state=state, limit=limit + ) + except manual_records.ManualInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + items = [] + for row in rows: + payload = manual_records._row_payload(connection, row) + items.append(payload) + self._send_json(200, {"status": "ok", "records": items}) + finally: + connection.close() + + def _handle_admin_intercompany_event_detail(self, event_id: int) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + payload = positions.event_detail(connection, event_id) + if payload is None: + self._send_json(404, {"status": "error", "message": "事件不存在。"}) + return + self._send_json(200, {"status": "ok", **payload}) + finally: + connection.close() + + def _handle_admin_intercompany_evidence(self, event_id: int) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + payload = positions.event_evidence(connection, event_id) + if payload is None: + self._send_json(404, {"status": "error", "message": "事件不存在。"}) + return + self._send_json(200, {"status": "ok", **payload}) + finally: + connection.close() + + def _handle_admin_subject_decision(self, event_id: int) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + data = self._read_json_body() + if data is None: + return + try: + perspective_company_id = int(data["perspective_company_id"]) + expected_revision = ( + int(data["expected_revision"]) + if data.get("expected_revision") is not None + else None + ) + except (KeyError, TypeError, ValueError): + self._send_json( + 400, {"status": "error", "message": "perspective_company_id 或 expected_revision 无效。"} + ) + return + try: + payload = subjects.confirm_subject( + connection, + event_id, + perspective_company_id=perspective_company_id, + subject_code=str(data.get("subject_code") or ""), + reason=str(data.get("reason") or ""), + expected_revision=expected_revision, + request_key=str(data.get("request_key") or "") or None, + actor=user, + ) + except subjects.SubjectConflictError as exc: + self._send_json(409, {"status": "error", "message": str(exc)}) + return + except subjects.SubjectInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + auth.audit( + connection, "subject_confirm", actor=user, + target=f"ledger_event:{event_id}", + detail=f"revision:{payload['revision']};subject:{payload['subject_code']}", + ip=self._client_ip, + ) + self._send_json(200, {"status": "ok", "revision": payload}) + finally: + connection.close() + + def _handle_admin_intercompany_adjustment(self, event_id: int) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + data = self._read_json_body() + if data is None: + return + action = str(data.get("action") or "") + reason = str(data.get("reason") or "") + request_key = str(data.get("request_key") or "") or None + if not reason: + self._send_json(400, {"status": "error", "message": "必须填写操作原因。"}) + return + try: + if action == "reverse": + event_id, _revision_id = ledger_events.create_reversal( + connection, event_id, + source_kind="adjustment", + source_revision_token=None, + effective_at=data.get("effective_at") or None, + reason=reason, actor=user, idempotency_key=request_key, + ) + outcome: dict[str, object] = { + "action": "reverse", "ledger_event_id": event_id, + } + elif action == "adjust": + try: + effective_at = str(data.get("effective_at") or "") + amount = str(data.get("amount") or "") + currency = str(data.get("currency") or "") + payer = int(data["payer_company_id"]) + payee = int(data["payee_company_id"]) + perspective = int(data["perspective_company_id"]) + subject_code = str(data.get("subject_code") or "") + except (KeyError, TypeError, ValueError): + self._send_json( + 400, {"status": "error", "message": "adjust 参数不完整或无效。"} + ) + return + event_id, _revision_id = ledger_events.create_adjustment( + connection, event_id, + effective_at=effective_at, amount=amount, currency=currency, + payer_company_id=payer, payee_company_id=payee, + perspective_company_id=perspective, subject_code=subject_code, + reason=reason, actor=user, idempotency_key=request_key, + ) + outcome = {"action": "adjust", "ledger_event_id": event_id} + elif action == "reopen": + event_id, _revision_id = ledger_events.reopen_subject( + connection, event_id, reason=reason, actor=user, + idempotency_key=request_key, + ) + outcome = {"action": "reopen", "ledger_event_id": event_id} + else: + self._send_json( + 400, + {"status": "error", "message": "action 必须是 reverse、adjust 或 reopen。"}, + ) + return + except ledger_events.LedgerConflictError as exc: + self._send_json(409, {"status": "error", "message": str(exc)}) + return + except ledger_events.LedgerInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + auth.audit( + connection, f"ledger_{action}", actor=user, + target=f"ledger_event:{event_id}", detail=reason, ip=self._client_ip, + ) + self._send_json(200, {"status": "ok", **outcome}) + finally: + connection.close() + + def _handle_admin_manual_record_decision(self, record_id: int) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + data = self._read_json_body() + if data is None: + return + try: + expected_decision_id = ( + int(data["expected_decision_id"]) + if data.get("expected_decision_id") is not None + else None + ) + except (TypeError, ValueError): + self._send_json(400, {"status": "error", "message": "expected_decision_id 无效。"}) + return + try: + payload = manual_records.decide( + connection, + record_id, + str(data.get("action") or ""), + reason=str(data.get("reason") or ""), + expected_decision_id=expected_decision_id, + request_key=str(data.get("request_key") or "") or None, + actor=user, + subject_code=data.get("subject_code"), + target_ledger_event_id=data.get("target_ledger_event_id"), + ) + except manual_records.ManualConflictError as exc: + self._send_json(409, {"status": "error", "message": str(exc)}) + return + except manual_records.ManualInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + self._send_json(200, {"status": "ok", "decision": payload}) + finally: + connection.close() + + # ------------------------------------------------------------------ + # B-44 intercompany positions (company, own-company scope only) + # ------------------------------------------------------------------ + + def _company_intercompany_scope(self, connection): + """Reject company users with 403; return the session company id.""" + user = self._require_user(connection) + if user is None: + return None, None + if user["role"] != "company": + self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"}) + return None, None + return user, user["company_id"] + + def _handle_company_intercompany_balances(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user, company_id = self._company_intercompany_scope(connection) + if company_id is None: + return + try: + from_, cutoff = self._parse_window(query) + currency = (query.get("currency") or [None])[0] + limit = self._parse_limit(query) + cursor = (query.get("cursor") or [None])[0] + except Exception: + return + try: + payload = positions.company_balances( + connection, from_=from_, cutoff=cutoff, currency=currency, + company_id=company_id, limit=limit, cursor=cursor, + ) + counterparties = self._company_counterparty_summary( + connection, company_id, from_, cutoff, currency + ) + except positions.PositionInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + self._send_json(200, {"status": "ok", **payload, "counterparties": counterparties}) + finally: + connection.close() + + def _company_counterparty_summary( + self, connection, company_id: int, from_: str, cutoff: str, currency: str | None + ) -> list[dict[str, object]]: + events = positions.load_events( + connection, from_=from_, cutoff=cutoff, currency=currency, + company_id=company_id, + ) + buckets: dict[int, dict[str, object]] = {} + for event in events: + counterparty = ( + event["payee_company_id"] + if int(event["payer_company_id"]) == int(company_id) + else event["payer_company_id"] + ) + name = ( + event["payee_company_name"] + if int(event["payer_company_id"]) == int(company_id) + else event["payer_company_name"] + ) + bucket = buckets.setdefault( + counterparty, + { + "counterparty_company_id": counterparty, + "counterparty_company_name": name, + "currency": event["currency"], + "signed": 0, + "event_count": 0, + }, + ) + bucket["signed"] += positions.signed_amount(event, company_id) + bucket["event_count"] += 1 + + # Counterparties with pending-subject exposure also show up so the + # company portal never hides unconfirmed balances. + pending = connection.execute( + """ + SELECT p.payer_company_id, p.payee_company_id, p.amount, p.currency, + cpayer.name AS payer_company_name, cpayee.name AS payee_company_name + FROM current_ledger_event_revisions cur + JOIN ledger_event_revisions p ON p.id = cur.revision_id + JOIN companies cpayer ON cpayer.id = p.payer_company_id + JOIN companies cpayee ON cpayee.id = p.payee_company_id + WHERE p.state = 'pending_subject' + AND p.effective_at >= ? AND p.effective_at <= ? + AND (p.payer_company_id = ? OR p.payee_company_id = ?) + """, + (from_, cutoff + "T23:59:59", company_id, company_id), + ).fetchall() + for row in pending: + if currency and row["currency"] != currency: + continue + counterparty = ( + row["payee_company_id"] + if int(row["payer_company_id"]) == int(company_id) + else row["payer_company_id"] + ) + name = ( + row["payee_company_name"] + if int(row["payer_company_id"]) == int(company_id) + else row["payer_company_name"] + ) + buckets.setdefault( + counterparty, + { + "counterparty_company_id": counterparty, + "counterparty_company_name": name, + "currency": row["currency"], + "signed": 0, + "event_count": 0, + }, + ) + items = [] + for counterparty, bucket in sorted(buckets.items()): + cur = bucket["currency"] + signed = bucket["signed"] + unresolved = positions.unresolved_for_company( + connection, company_id, cutoff, currency=cur, + counterparty_filter=(company_id, counterparty), + ) + direction = "receivable" if signed > 0 else ("payable" if signed < 0 else None) + items.append( + { + "counterparty_company_id": counterparty, + "counterparty_company_name": bucket["counterparty_company_name"], + "currency": cur, + "result": { + "kind": "period_net_change", + "signed_amount": str(signed), + "direction": direction, + "label": "期间净变动", + }, + "unresolved": unresolved, + "event_count": bucket["event_count"], + } + ) + return items + + def _handle_company_intercompany_pair( + self, counterparty_id: int, query: dict[str, list[str]] + ) -> None: + connection = connect(DB_PATH) + try: + user, company_id = self._company_intercompany_scope(connection) + if company_id is None: + return + if not self._companies_exist(connection, (counterparty_id,)): + self._send_json(404, {"status": "error", "message": "公司不存在。"}) + return + try: + from_, cutoff = self._parse_window(query) + currency = (query.get("currency") or [None])[0] + except Exception: + return + try: + payload = positions.pair_detail( + connection, company_id, counterparty_id, + from_=from_, cutoff=cutoff, currency=currency, + ) + except positions.PositionError as exc: + self._send_json(422, {"status": "error", "message": str(exc)}) + return + except positions.PositionInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + self._send_json(200, {"status": "ok", **payload}) + finally: + connection.close() + + def _handle_company_intercompany_events(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user, company_id = self._company_intercompany_scope(connection) + if company_id is None: + return + try: + from_, cutoff = self._parse_window(query) + currency = (query.get("currency") or [None])[0] + subject = (query.get("subject") or [None])[0] + state = (query.get("state") or [None])[0] + posting_kind = (query.get("posting_kind") or [None])[0] + source_kind = (query.get("source_kind") or [None])[0] + limit = self._parse_limit(query) + cursor = (query.get("cursor") or [None])[0] + except Exception: + return + try: + payload = positions.list_events( + connection, from_=from_, cutoff=cutoff, currency=currency, + subject=subject, state=state, posting_kind=posting_kind, + source_kind=source_kind, viewer_company_id=company_id, + limit=limit, cursor=cursor, + ) + except positions.PositionInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + self._send_json(200, {"status": "ok", **payload}) + finally: + connection.close() + + def _handle_company_intercompany_event_detail(self, event_id: int) -> None: + connection = connect(DB_PATH) + try: + user, company_id = self._company_intercompany_scope(connection) + if company_id is None: + return + payload = positions.event_detail(connection, event_id) + if payload is None: + self._send_json(404, {"status": "error", "message": "事件不存在。"}) + return + event = payload["event"] + if company_id not in (event["payer_company_id"], event["payee_company_id"]): + self._send_json(404, {"status": "error", "message": "事件不存在。"}) + return + event.update( + positions.event_payload( + connection, + connection.execute( + positions._DETAIL_SELECT + " WHERE p.ledger_event_id = ?", + (event_id,), + ).fetchone(), + viewer_company_id=company_id, + ) + ) + self._send_json(200, {"status": "ok", **payload}) + finally: + connection.close() + + def _handle_company_intercompany_evidence(self, event_id: int) -> None: + connection = connect(DB_PATH) + try: + user, company_id = self._company_intercompany_scope(connection) + if company_id is None: + return + detail = positions.event_detail(connection, event_id) + if detail is None: + self._send_json(404, {"status": "error", "message": "事件不存在。"}) + return + event = detail["event"] + if company_id not in (event["payer_company_id"], event["payee_company_id"]): + self._send_json(404, {"status": "error", "message": "事件不存在。"}) + return + payload = positions.event_evidence( + connection, event_id, viewer_company_id=company_id + ) + if payload is None: + self._send_json(404, {"status": "error", "message": "事件不存在。"}) + return + self._send_json(200, {"status": "ok", **payload}) + finally: + connection.close() + + def _handle_company_companies(self) -> None: + connection = connect(DB_PATH) + try: + user, company_id = self._company_intercompany_scope(connection) + if company_id is None: + return + rows = connection.execute( + "SELECT id, name FROM companies ORDER BY id" + ).fetchall() + self._send_json( + 200, + {"status": "ok", "companies": [dict(row) for row in rows]}, + ) + finally: + connection.close() + + def _handle_company_manual_records(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user, company_id = self._company_intercompany_scope(connection) + if company_id is None: + return + state = (query.get("state") or [None])[0] + limit = self._parse_limit(query, 100) + try: + rows = manual_records.list_records( + connection, company_id=company_id, state=state, limit=limit + ) + except manual_records.ManualInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + # A company only ever sees its own submissions, never declarations + # where it is merely the counterparty. + own = [ + manual_records._row_payload(connection, row) + for row in rows + if row["company_id"] == company_id + ] + self._send_json(200, {"status": "ok", "records": own}) + finally: + connection.close() + + def _handle_company_manual_records_submit(self) -> None: + connection = connect(DB_PATH) + try: + user, company_id = self._company_intercompany_scope(connection) + if company_id is None: + return + data = self._read_json_body() + if data is None: + return + try: + payload = manual_records.submit( + connection, + company_id=company_id, + counterparty_company_id=int(data["counterparty_company_id"]), + occurred_at=str(data.get("occurred_at") or ""), + direction=str(data.get("direction") or ""), + amount=str(data.get("amount") or ""), + currency=str(data.get("currency") or ""), + funding_source=str(data.get("funding_source") or ""), + requested_subject=str(data.get("requested_subject") or ""), + request_key=str(data.get("request_key") or ""), + actor=user, + bank_account_id=data.get("bank_account_id"), + personal_transit_mapping_id=data.get("personal_transit_mapping_id"), + related_source_row_id=data.get("related_source_row_id"), + summary=data.get("summary"), + reason=data.get("reason"), + evidence=data.get("evidence"), + ) + except manual_records.ManualConflictError as exc: + self._send_json(409, {"status": "error", "message": str(exc)}) + return + except manual_records.ManualInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + auth.audit( + connection, "manual_submit", actor=user, + target=f"manual_record:{payload['id']}", + detail=f"amount:{payload['amount']};currency:{payload['currency']}", + ip=self._client_ip, + ) + self._send_json(200, {"status": "ok", "record": payload}) + finally: + connection.close() + + # ------------------------------------------------------------------ # Request/response plumbing # ------------------------------------------------------------------ diff --git a/src/bank_importer/db.py b/src/bank_importer/db.py index b83a618..02775bc 100644 --- a/src/bank_importer/db.py +++ b/src/bank_importer/db.py @@ -569,6 +569,235 @@ MIGRATIONS: tuple[Migration, ...] = ( ALTER TABLE import_batches DROP COLUMN upload_bank_account_id; """, ), + Migration( + version=6, + name="0006_intercompany_ledger_events", + # B-44 canonical intercompany ledger layer. Manual records and the + # ledger event revision chain are append-only facts; current pointers + # (current revision per ledger event / manual decision, source claims) + # are rebuildable projections. Bank events enter only through + # ``eligible_intercompany_events``; nothing here rewrites bank rows. + up=""" + CREATE TABLE manual_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL REFERENCES companies (id), + counterparty_company_id INTEGER NOT NULL REFERENCES companies (id), + occurred_at TEXT NOT NULL, + direction TEXT NOT NULL CHECK (direction IN ('outgoing', 'incoming')), + amount TEXT NOT NULL, + amount_scale INTEGER NOT NULL, + currency TEXT NOT NULL, + funding_source TEXT NOT NULL CHECK (funding_source IN ( + 'approved_bank_account', 'personal_transit', 'other' + )), + bank_account_id INTEGER REFERENCES bank_accounts (id), + personal_transit_mapping_id INTEGER REFERENCES personal_transit_mappings (id), + related_source_row_id INTEGER REFERENCES source_rows (id), + requested_subject TEXT NOT NULL CHECK (requested_subject IN ( + 'receivable', 'payable', 'other_receivable', 'other_payable' + )), + summary TEXT, + reason TEXT, + evidence_json TEXT, + request_key TEXT NOT NULL, + supersedes_record_id INTEGER REFERENCES manual_records (id), + submitted_by INTEGER REFERENCES users (id), + created_at TEXT NOT NULL, + UNIQUE (company_id, request_key), + CHECK (counterparty_company_id != company_id) + ); + + CREATE TABLE manual_record_decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + record_id INTEGER NOT NULL REFERENCES manual_records (id), + revision INTEGER NOT NULL, + state TEXT NOT NULL CHECK (state IN ( + 'pending', 'approved', 'returned', 'exception', 'reversed' + )), + action TEXT NOT NULL, + reason TEXT, + actor_user_id INTEGER REFERENCES users (id), + actor_username TEXT, + idempotency_key TEXT, + supersedes_decision_id INTEGER REFERENCES manual_record_decisions (id), + created_at TEXT NOT NULL, + UNIQUE (record_id, revision) + ); + + CREATE TABLE current_manual_record_decisions ( + record_id INTEGER PRIMARY KEY REFERENCES manual_records (id), + decision_id INTEGER NOT NULL UNIQUE REFERENCES manual_record_decisions (id) + ); + + CREATE TABLE ledger_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lifecycle TEXT NOT NULL DEFAULT 'active' + CHECK (lifecycle IN ('active', 'superseded')), + created_at TEXT NOT NULL + ); + + CREATE TABLE ledger_event_revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id), + revision INTEGER NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending_subject', 'confirmed')), + effective_at TEXT NOT NULL, + amount TEXT NOT NULL, + amount_scale INTEGER NOT NULL, + currency TEXT NOT NULL, + payer_company_id INTEGER NOT NULL REFERENCES companies (id), + payee_company_id INTEGER NOT NULL REFERENCES companies (id), + perspective_company_id INTEGER REFERENCES companies (id), + subject_code TEXT CHECK (subject_code IN ( + 'receivable', 'payable', 'other_receivable', 'other_payable' + )), + source_kind TEXT NOT NULL CHECK (source_kind IN ('bank', 'manual', 'adjustment')), + source_revision_token TEXT, + posting_kind TEXT NOT NULL CHECK (posting_kind IN ( + 'normal', 'reversal', 'adjustment' + )), + reverses_ledger_event_id INTEGER REFERENCES ledger_events (id), + adjusts_ledger_event_id INTEGER REFERENCES ledger_events (id), + rule_version TEXT, + evidence_json TEXT, + idempotency_key TEXT, + actor_user_id INTEGER REFERENCES users (id), + actor_username TEXT, + reason TEXT, + supersedes_revision_id INTEGER REFERENCES ledger_event_revisions (id), + created_at TEXT NOT NULL, + UNIQUE (ledger_event_id, revision), + CHECK (payer_company_id != payee_company_id), + CHECK (state = 'confirmed' OR subject_code IS NULL), + CHECK (state = 'confirmed' OR perspective_company_id IS NULL), + CHECK ( + state != 'confirmed' + OR (perspective_company_id IS NOT NULL AND subject_code IS NOT NULL) + ) + ); + + CREATE TABLE current_ledger_event_revisions ( + ledger_event_id INTEGER PRIMARY KEY REFERENCES ledger_events (id), + revision_id INTEGER NOT NULL UNIQUE REFERENCES ledger_event_revisions (id) + ); + + CREATE TABLE ledger_event_bank_sources ( + bank_event_id INTEGER PRIMARY KEY REFERENCES canonical_transfer_events (id), + ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id), + UNIQUE (ledger_event_id, bank_event_id) + ); + + CREATE TABLE ledger_event_manual_sources ( + manual_record_id INTEGER PRIMARY KEY REFERENCES manual_records (id), + ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id), + UNIQUE (ledger_event_id, manual_record_id) + ); + + CREATE TABLE ledger_subject_suggestions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id), + source_revision_id INTEGER NOT NULL REFERENCES ledger_event_revisions (id), + suggested_perspective_company_id INTEGER NOT NULL REFERENCES companies (id), + suggested_subject_code TEXT NOT NULL CHECK (suggested_subject_code IN ( + 'receivable', 'payable', 'other_receivable', 'other_payable' + )), + rule_version TEXT, + evidence_json TEXT, + created_at TEXT NOT NULL + ); + + CREATE INDEX idx_ledger_revisions_event ON ledger_event_revisions (ledger_event_id, revision); + CREATE INDEX idx_ledger_revisions_effective ON ledger_event_revisions (effective_at); + CREATE INDEX idx_ledger_revisions_pair_currency + ON ledger_event_revisions (payer_company_id, payee_company_id, currency); + CREATE INDEX idx_ledger_revisions_state + ON ledger_event_revisions (state, effective_at); + CREATE INDEX idx_manual_records_company ON manual_records (company_id, occurred_at); + CREATE INDEX idx_manual_decisions_record ON manual_record_decisions (record_id, revision); + CREATE INDEX idx_manual_decisions_state ON manual_record_decisions (state); + CREATE INDEX idx_subject_suggestions_event ON ledger_subject_suggestions (ledger_event_id); + + CREATE VIEW eligible_position_events AS + SELECT le.id AS ledger_event_id, + cur.revision_id AS ledger_revision_id, + r.effective_at AS effective_at, + r.amount AS amount, r.amount_scale AS amount_scale, + r.currency AS currency, + r.payer_company_id AS payer_company_id, + r.payee_company_id AS payee_company_id, + r.perspective_company_id AS perspective_company_id, + r.subject_code AS subject_code, + r.source_kind AS source_kind, r.posting_kind AS posting_kind, + r.reverses_ledger_event_id AS reverses_ledger_event_id, + r.adjusts_ledger_event_id AS adjusts_ledger_event_id, + COALESCE( + (SELECT bs.bank_event_id FROM ledger_event_bank_sources bs + WHERE bs.ledger_event_id = le.id LIMIT 1), + (SELECT ms.manual_record_id FROM ledger_event_manual_sources ms + WHERE ms.ledger_event_id = le.id LIMIT 1) + ) AS source_id, + ((SELECT COUNT(*) FROM ledger_event_bank_sources bs + WHERE bs.ledger_event_id = le.id) + + (SELECT COUNT(*) FROM ledger_event_manual_sources ms + WHERE ms.ledger_event_id = le.id)) AS evidence_count + FROM ledger_events le + JOIN current_ledger_event_revisions cur ON cur.ledger_event_id = le.id + JOIN ledger_event_revisions r ON r.id = cur.revision_id + WHERE le.lifecycle = 'active' AND r.state = 'confirmed'; + + CREATE TRIGGER ledger_events_no_delete BEFORE DELETE ON ledger_events + BEGIN SELECT RAISE (ABORT, 'ledger_events rows are immutable'); END; + CREATE TRIGGER ledger_events_no_update BEFORE UPDATE ON ledger_events + BEGIN + SELECT RAISE (ABORT, 'ledger_events only allow lifecycle changes') + WHERE OLD.lifecycle = NEW.lifecycle + OR OLD.id IS NOT NEW.id + OR OLD.created_at IS NOT NEW.created_at; + END; + + CREATE TRIGGER ledger_event_revisions_no_update BEFORE UPDATE ON ledger_event_revisions + BEGIN SELECT RAISE (ABORT, 'ledger_event_revisions rows are immutable'); END; + CREATE TRIGGER ledger_event_revisions_no_delete BEFORE DELETE ON ledger_event_revisions + BEGIN SELECT RAISE (ABORT, 'ledger_event_revisions rows are immutable'); END; + + CREATE TRIGGER ledger_subject_suggestions_no_update BEFORE UPDATE ON ledger_subject_suggestions + BEGIN SELECT RAISE (ABORT, 'ledger_subject_suggestions rows are immutable'); END; + CREATE TRIGGER ledger_subject_suggestions_no_delete BEFORE DELETE ON ledger_subject_suggestions + BEGIN SELECT RAISE (ABORT, 'ledger_subject_suggestions rows are immutable'); END; + + CREATE TRIGGER manual_records_no_update BEFORE UPDATE ON manual_records + BEGIN SELECT RAISE (ABORT, 'manual_records rows are immutable'); END; + CREATE TRIGGER manual_records_no_delete BEFORE DELETE ON manual_records + BEGIN SELECT RAISE (ABORT, 'manual_records rows are immutable'); END; + + CREATE TRIGGER manual_record_decisions_no_update BEFORE UPDATE ON manual_record_decisions + BEGIN SELECT RAISE (ABORT, 'manual_record_decisions rows are immutable'); END; + CREATE TRIGGER manual_record_decisions_no_delete BEFORE DELETE ON manual_record_decisions + BEGIN SELECT RAISE (ABORT, 'manual_record_decisions rows are immutable'); END; + """, + down=""" + DROP VIEW IF EXISTS eligible_position_events; + DROP TRIGGER IF EXISTS manual_record_decisions_no_delete; + DROP TRIGGER IF EXISTS manual_record_decisions_no_update; + DROP TRIGGER IF EXISTS manual_records_no_delete; + DROP TRIGGER IF EXISTS manual_records_no_update; + DROP TRIGGER IF EXISTS ledger_subject_suggestions_no_delete; + DROP TRIGGER IF EXISTS ledger_subject_suggestions_no_update; + DROP TRIGGER IF EXISTS ledger_event_revisions_no_delete; + DROP TRIGGER IF EXISTS ledger_event_revisions_no_update; + DROP TRIGGER IF EXISTS ledger_events_no_update; + DROP TRIGGER IF EXISTS ledger_events_no_delete; + DROP TABLE IF EXISTS ledger_subject_suggestions; + DROP TABLE IF EXISTS ledger_event_manual_sources; + DROP TABLE IF EXISTS ledger_event_bank_sources; + DROP TABLE IF EXISTS current_ledger_event_revisions; + DROP TABLE IF EXISTS ledger_event_revisions; + DROP TABLE IF EXISTS ledger_events; + DROP TABLE IF EXISTS current_manual_record_decisions; + DROP TABLE IF EXISTS manual_record_decisions; + DROP TABLE IF EXISTS manual_records; + """, + ), ) diff --git a/src/bank_importer/importing.py b/src/bank_importer/importing.py index 6c5fc0b..291fe9d 100644 --- a/src/bank_importer/importing.py +++ b/src/bank_importer/importing.py @@ -25,6 +25,7 @@ import sqlite3 import tempfile from . import auth +from . import ledger_events from . import matching from .db import utc_now from .models import SheetResult, StatementBatch @@ -648,6 +649,7 @@ def review_sheets( [item["id"] for item in confirmed_rows], actor=actor, ) + ledger_events.reconcile_bank_events(connection, actor=actor) if began: connection.commit() except Exception: diff --git a/src/bank_importer/ledger_events.py b/src/bank_importer/ledger_events.py new file mode 100644 index 0000000..694facc --- /dev/null +++ b/src/bank_importer/ledger_events.py @@ -0,0 +1,684 @@ +"""Canonical intercompany ledger events and the revision chain (B-44). + +Bank source rows and approved manual records are immutable evidence. This +module turns them into one canonical ledger event each through ``reconcile_*`` +functions and an append-only revision chain. Corrections are never in-place +edits: a reversal or adjustment is a new ledger event with its own effective +date, and the original event keeps its history so no earlier cutoff is +rewritten. Current revisions and source claims are rebuildable projections. + +Only B-43 ``eligible_intercompany_events`` feeds bank facts here; same-company +transfers, external transactions, unresolved rows and unlocked single +observations never reach the confirmed balance. +""" + +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +import json +import sqlite3 + +from .db import utc_now +from .subjects import MIRROR, SUBJECTS, mirror_subject + + +class LedgerConflictError(ValueError): + """A revision/claim/idempotency conflict (mapped to HTTP 409).""" + + +class LedgerInputError(ValueError): + """Invalid input for a ledger operation (mapped to HTTP 400/422).""" + + +SUBJECT_RULE_VERSION = "subject-suggest-draft-v1" + + +def amount_scale(amount: object) -> int: + """Decimal places of a decimal-string amount, never negative.""" + try: + exponent = Decimal(str(amount)).as_tuple().exponent + except InvalidOperation: + return 0 + return max(0, -int(exponent)) + + +def parse_amount(amount: object) -> Decimal: + """Parse a positive, valid decimal-string amount.""" + try: + value = Decimal(str(amount)) + except InvalidOperation: + raise LedgerInputError("金额不是有效的十进制数。") from None + if not value.is_finite() or value <= 0: + raise LedgerInputError("金额必须大于零。") + return value + + +def _company_exists(connection: sqlite3.Connection, company_id: int, label: str) -> None: + row = connection.execute( + "SELECT id FROM companies WHERE id = ?", (company_id,) + ).fetchone() + if row is None: + raise LedgerInputError(f"{label}指向的公司不存在。") + + +# --------------------------------------------------------------------------- +# Revision helpers +# --------------------------------------------------------------------------- + + +def _ensure_transaction(connection: sqlite3.Connection) -> bool: + """Begin an immediate transaction unless one is already open. + + Write helpers may run standalone (they own the transaction) or nested + inside a caller's transaction (e.g. the sheet-confirm flow); nested calls + never start their own commit. + """ + began = False + if not connection.in_transaction: + connection.execute("BEGIN IMMEDIATE") + began = True + return began + + +def current_revision(connection: sqlite3.Connection, ledger_event_id: int) -> sqlite3.Row | None: + return 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 _event_lifecycle(connection: sqlite3.Connection, ledger_event_id: int) -> str | None: + row = connection.execute( + "SELECT lifecycle FROM ledger_events WHERE id = ?", (ledger_event_id,) + ).fetchone() + return row["lifecycle"] if row is not None else None + + +def _next_revision_number(connection: sqlite3.Connection, ledger_event_id: int) -> int: + row = connection.execute( + "SELECT COALESCE(MAX(revision), 0) AS m FROM ledger_event_revisions WHERE ledger_event_id = ?", + (ledger_event_id,), + ).fetchone() + return int(row["m"]) + 1 + + +def create_event( + connection: sqlite3.Connection, + *, + state: str, + effective_at: str, + amount: str, + currency: str, + payer_company_id: int, + payee_company_id: int, + perspective_company_id: int | None, + subject_code: str | None, + source_kind: str, + source_revision_token: str | None, + posting_kind: str, + reverses_ledger_event_id: int | None = None, + adjusts_ledger_event_id: int | None = None, + rule_version: str | None = None, + evidence_json: str | None = None, + idempotency_key: str | None = None, + actor: sqlite3.Row | None = None, + reason: str | None = None, + supersedes_revision_id: int | None = None, +) -> tuple[int, int]: + """Insert a new ledger event with one revision. Returns ``(event_id, revision_id)``.""" + if state == "confirmed": + if perspective_company_id is None or subject_code is None: + raise LedgerInputError("已确认事件必须提供视角公司与科目。") + if subject_code not in SUBJECTS: + raise LedgerInputError("科目必须是应收/应付/其他应收/其他应付之一。") + if int(payer_company_id) == int(payee_company_id): + raise LedgerInputError("付款公司与收款公司不能相同。") + if perspective_company_id is not None and perspective_company_id not in ( + int(payer_company_id), int(payee_company_id), + ): + raise LedgerInputError("视角公司必须是事件参与方。") + began = _ensure_transaction(connection) + try: + now = utc_now() + cursor = connection.execute( + "INSERT INTO ledger_events (lifecycle, created_at) VALUES ('active', ?)", + (now,), + ) + event_id = int(cursor.lastrowid) + revision_id = append_revision( + connection, + event_id, + state=state, + effective_at=effective_at, + amount=amount, + currency=currency, + payer_company_id=payer_company_id, + payee_company_id=payee_company_id, + perspective_company_id=perspective_company_id, + subject_code=subject_code, + source_kind=source_kind, + source_revision_token=source_revision_token, + posting_kind=posting_kind, + reverses_ledger_event_id=reverses_ledger_event_id, + adjusts_ledger_event_id=adjusts_ledger_event_id, + rule_version=rule_version, + evidence_json=evidence_json, + idempotency_key=idempotency_key, + actor=actor, + reason=reason, + supersedes_revision_id=supersedes_revision_id, + ) + except Exception: + if began: + connection.rollback() + raise + else: + if began: + connection.commit() + return event_id, revision_id + + +def append_revision( + connection: sqlite3.Connection, + ledger_event_id: int, + *, + state: str, + effective_at: str, + amount: str, + currency: str, + payer_company_id: int, + payee_company_id: int, + perspective_company_id: int | None, + subject_code: str | None, + source_kind: str, + source_revision_token: str | None, + posting_kind: str, + reverses_ledger_event_id: int | None = None, + adjusts_ledger_event_id: int | None = None, + rule_version: str | None = None, + evidence_json: str | None = None, + idempotency_key: str | None = None, + actor: sqlite3.Row | None = None, + reason: str | None = None, + supersedes_revision_id: int | None = None, +) -> int: + """Append one immutable revision and repoint the current projection.""" + if _event_lifecycle(connection, ledger_event_id) != "active": + raise LedgerConflictError("该事件已停用,不能追加修订。") + if state == "confirmed": + if perspective_company_id is None or subject_code is None: + raise LedgerInputError("已确认事件必须提供视角公司与科目。") + if subject_code not in SUBJECTS: + raise LedgerInputError("科目必须是应收/应付/其他应收/其他应付之一。") + if perspective_company_id not in (int(payer_company_id), int(payee_company_id)): + raise LedgerInputError("视角公司必须是事件参与方。") + elif state != "pending_subject": + raise LedgerInputError("事件状态必须是 pending_subject 或 confirmed。") + revision = _next_revision_number(connection, ledger_event_id) + now = utc_now() + cursor = connection.execute( + """ + INSERT INTO ledger_event_revisions ( + ledger_event_id, revision, state, effective_at, amount, + amount_scale, currency, payer_company_id, payee_company_id, + perspective_company_id, subject_code, source_kind, + source_revision_token, posting_kind, reverses_ledger_event_id, + adjusts_ledger_event_id, rule_version, evidence_json, + idempotency_key, actor_user_id, actor_username, reason, + supersedes_revision_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + ledger_event_id, revision, state, effective_at, amount, + amount_scale(amount), currency, payer_company_id, payee_company_id, + perspective_company_id, subject_code, source_kind, + source_revision_token, posting_kind, reverses_ledger_event_id, + adjusts_ledger_event_id, rule_version, evidence_json, + idempotency_key, + actor["id"] if actor is not None else None, + actor["username"] if actor is not None else None, + reason, supersedes_revision_id, now, + ), + ) + connection.execute( + """ + INSERT OR REPLACE INTO current_ledger_event_revisions (ledger_event_id, revision_id) + VALUES (?, ?) + """, + (ledger_event_id, cursor.lastrowid), + ) + return int(cursor.lastrowid) + + +# --------------------------------------------------------------------------- +# Source claims +# --------------------------------------------------------------------------- + + +def bank_source_claim(connection: sqlite3.Connection, bank_event_id: int) -> sqlite3.Row | None: + return connection.execute( + "SELECT * FROM ledger_event_bank_sources WHERE bank_event_id = ?", + (bank_event_id,), + ).fetchone() + + +def manual_source_claim(connection: sqlite3.Connection, manual_record_id: int) -> sqlite3.Row | None: + return connection.execute( + "SELECT * FROM ledger_event_manual_sources WHERE manual_record_id = ?", + (manual_record_id,), + ).fetchone() + + +def _facts_of(connection: sqlite3.Connection, ledger_event_id: int) -> dict[str, object]: + revision = current_revision(connection, ledger_event_id) + if revision is None: + return {} + return { + "amount": revision["amount"], + "currency": revision["currency"], + "effective_at": revision["effective_at"], + "payer_company_id": revision["payer_company_id"], + "payee_company_id": revision["payee_company_id"], + } + + +def _eligible_facts(event: sqlite3.Row) -> dict[str, object]: + return { + "amount": event["amount"], + "currency": event["currency"], + "effective_at": event["effective_at"], + "payer_company_id": event["payer_company_id"], + "payee_company_id": event["payee_company_id"], + } + + +def _has_reversal(connection: sqlite3.Connection, original_event_id: int) -> bool: + row = connection.execute( + """ + SELECT 1 FROM ledger_event_revisions r + JOIN current_ledger_event_revisions c ON c.revision_id = r.id + WHERE r.reverses_ledger_event_id = ? AND r.posting_kind = 'reversal' + LIMIT 1 + """, + (original_event_id,), + ).fetchone() + return row is not None + + +def create_reversal( + connection: sqlite3.Connection, + original_event_id: int, + *, + source_kind: str, + source_revision_token: str | None = None, + effective_at: str | None = None, + reason: str, + actor: sqlite3.Row | None, + idempotency_key: str | None = None, + rule_version: str | None = None, +) -> tuple[int, int]: + """Create an equal-amount, opposite-direction reversal as a new ledger event. + + The subject mirrors the original (应收<->应付, 其他应收<->其他应付). ``effective_at`` + defaults to the original event's effective date so an earlier cutoff keeps + the original impact and later cutoffs see the net zero. The original event + is never modified or deleted. + """ + original = current_revision(connection, original_event_id) + if original is None: + raise LedgerConflictError("原事件不存在或没有当前修订。") + if original["state"] != "confirmed": + raise LedgerInputError("只有已确认事件才能生成冲销。") + perspective = mirror_perspective(original) + if effective_at is None: + effective_at = original["effective_at"] + return create_event( + connection, + state="confirmed", + effective_at=effective_at, + amount=original["amount"], + currency=original["currency"], + payer_company_id=original["payee_company_id"], + payee_company_id=original["payer_company_id"], + perspective_company_id=perspective, + subject_code=mirror_subject(original["subject_code"]), + source_kind=source_kind, + source_revision_token=source_revision_token, + posting_kind="reversal", + reverses_ledger_event_id=original_event_id, + rule_version=rule_version or original["rule_version"], + idempotency_key=idempotency_key, + actor=actor, + reason=reason, + ) + + +def mirror_perspective(revision: sqlite3.Row) -> int: + """The counterparty company from ``revision``'s perspective.""" + perspective = int(revision["perspective_company_id"]) + if perspective == int(revision["payer_company_id"]): + return int(revision["payee_company_id"]) + return int(revision["payer_company_id"]) + + +def create_adjustment( + connection: sqlite3.Connection, + ledger_event_id: int, + *, + effective_at: str, + amount: str, + currency: str, + payer_company_id: int, + payee_company_id: int, + perspective_company_id: int, + subject_code: str, + reason: str, + actor: sqlite3.Row, + idempotency_key: str | None = None, + rule_version: str | None = None, +) -> tuple[int, int]: + """Create an audit adjustment event; the original event stays unchanged.""" + return create_event( + connection, + state="confirmed", + effective_at=effective_at, + amount=str(parse_amount(amount)), + currency=currency, + payer_company_id=payer_company_id, + payee_company_id=payee_company_id, + perspective_company_id=perspective_company_id, + subject_code=subject_code, + source_kind="adjustment", + source_revision_token=None, + posting_kind="adjustment", + adjusts_ledger_event_id=ledger_event_id, + rule_version=rule_version or SUBJECT_RULE_VERSION, + idempotency_key=idempotency_key, + actor=actor, + reason=reason, + ) + + +def reopen_subject( + connection: sqlite3.Connection, + ledger_event_id: int, + *, + reason: str, + actor: sqlite3.Row, + idempotency_key: str | None = None, +) -> tuple[int, int]: + """Reverse a confirmed event and re-open it for subject re-review. + + Creates an equal-amount reversal plus a fresh ``pending_subject`` event + that re-claims the original bank source, so the administrator can confirm + a corrected subject. The original event and its reversal keep history. + """ + current = current_revision(connection, ledger_event_id) + if current is None or current["state"] != "confirmed": + raise LedgerConflictError("只有已确认事件可以重新进入科目审核。") + bank_claim = connection.execute( + "SELECT * FROM ledger_event_bank_sources WHERE ledger_event_id = ?", + (ledger_event_id,), + ).fetchone() + if bank_claim is None: + raise LedgerInputError( + "该事件没有银行来源,无法重新进入科目审核;请改用调整或冲销。" + ) + if not _has_reversal(connection, ledger_event_id): + create_reversal( + connection, ledger_event_id, + source_kind=current["source_kind"], + source_revision_token=current["source_revision_token"], + reason="科目复核:原确认事件冲销", + actor=actor, + idempotency_key=(idempotency_key + ":rev" if idempotency_key else None), + rule_version=current["rule_version"], + ) + ev = connection.execute( + "SELECT * FROM eligible_intercompany_events WHERE event_id = ?", + (bank_claim["bank_event_id"],), + ).fetchone() + if ev is None: + raise LedgerInputError("银行事件已不再纳入往来,无法重新入账。") + event_id, revision_id = _create_bank_event( + connection, ev, actor, reason="科目复核后重新入账,待确认科目", + replacing_claim=bank_claim, + ) + return event_id, revision_id + + +# --------------------------------------------------------------------------- +# Bank event reconciliation +# --------------------------------------------------------------------------- + + +def reconcile_bank_events( + connection: sqlite3.Connection, actor: sqlite3.Row | None = None +) -> dict[str, object]: + """Reconcile the current eligible intercompany events into ledger events. + + Idempotent: first sight creates a ``pending_subject`` event; a changed B-43 + decision updates a still-pending event's revision, or (for a confirmed + event) creates a reversal plus a fresh pending event. A source that left + the eligible set with a confirmed impact gets one reversal. Runs inside the + caller's transaction when one is open, otherwise in its own transaction. + """ + began = _ensure_transaction(connection) + try: + eligible = { + row["event_id"]: row + for row in connection.execute( + "SELECT * FROM eligible_intercompany_events" + ).fetchall() + } + claims = { + row["bank_event_id"]: row + for row in connection.execute( + "SELECT * FROM ledger_event_bank_sources" + ).fetchall() + } + stats = { + "created": 0, "updated_pending": 0, "reversal": 0, + "reopened": 0, "unchanged": 0, "sources": len(eligible), + } + for bank_event_id, event in sorted(eligible.items()): + claim = claims.get(bank_event_id) + if claim is None: + _create_bank_event( + connection, event, actor, reason="B-43 事件首次入账,待确认科目" + ) + stats["created"] += 1 + continue + current = current_revision(connection, claim["ledger_event_id"]) + if current is None or _facts_of(connection, claim["ledger_event_id"]) != _eligible_facts(event): + if current is not None and current["state"] == "confirmed": + if not _has_reversal(connection, claim["ledger_event_id"]): + create_reversal( + connection, claim["ledger_event_id"], + source_kind="bank", + source_revision_token=event["decision_id"], + reason="B-43 事件事实变更,原确认事件冲销", + actor=actor, + ) + stats["reversal"] += 1 + _create_bank_event( + connection, event, actor, + reason="B-43 事件事实变更后重新入账,待确认科目", + replacing_claim=claim, + ) + stats["reopened"] += 1 + elif current is None or current["state"] == "pending_subject": + _append_bank_pending_revision( + connection, claim["ledger_event_id"], event, actor + ) + stats["updated_pending"] += 1 + else: + stats["unchanged"] += 1 + else: + stats["unchanged"] += 1 + + for bank_event_id, claim in sorted(claims.items()): + if bank_event_id in eligible: + continue + current = current_revision(connection, claim["ledger_event_id"]) + if current is not None and current["state"] == "confirmed": + if not _has_reversal(connection, claim["ledger_event_id"]): + create_reversal( + connection, claim["ledger_event_id"], + source_kind="bank", + source_revision_token=None, + reason="B-43 事件不再纳入往来,原确认事件冲销", + actor=actor, + ) + stats["reversal"] += 1 + except Exception: + if began: + connection.rollback() + raise + else: + if began: + connection.commit() + return stats + + +def _create_bank_event( + connection: sqlite3.Connection, + event: sqlite3.Row, + actor: sqlite3.Row | None, + *, + reason: str, + replacing_claim: sqlite3.Row | None = None, +) -> tuple[int, int]: + event_id, revision_id = create_event( + connection, + state="pending_subject", + effective_at=event["effective_at"], + amount=event["amount"], + currency=event["currency"], + payer_company_id=event["payer_company_id"], + payee_company_id=event["payee_company_id"], + perspective_company_id=None, + subject_code=None, + source_kind="bank", + source_revision_token=event["decision_id"], + posting_kind="normal", + rule_version=SUBJECT_RULE_VERSION, + evidence_json=json.dumps( + { + "bank_event_id": event["event_id"], + "decision_id": event["decision_id"], + "pairing": event["pairing"], + "evidence_count": event["evidence_count"], + }, + ensure_ascii=False, + ), + actor=actor, + reason=reason, + ) + if replacing_claim is not None: + connection.execute( + """ + UPDATE ledger_event_bank_sources SET ledger_event_id = ? + WHERE bank_event_id = ? + """, + (event_id, event["event_id"]), + ) + else: + connection.execute( + """ + INSERT INTO ledger_event_bank_sources (bank_event_id, ledger_event_id) + VALUES (?, ?) + """, + (event["event_id"], event_id), + ) + from .subjects import store_suggestions + + store_suggestions(connection, event_id) + return event_id, revision_id + + +def _append_bank_pending_revision( + connection: sqlite3.Connection, + ledger_event_id: int, + event: sqlite3.Row, + actor: sqlite3.Row | None, +) -> int: + current = current_revision(connection, ledger_event_id) + revision_id = append_revision( + connection, + ledger_event_id, + state="pending_subject", + effective_at=event["effective_at"], + amount=event["amount"], + currency=event["currency"], + payer_company_id=event["payer_company_id"], + payee_company_id=event["payee_company_id"], + perspective_company_id=None, + subject_code=None, + source_kind="bank", + source_revision_token=event["decision_id"], + posting_kind="normal", + rule_version=SUBJECT_RULE_VERSION, + evidence_json=json.dumps( + { + "bank_event_id": event["event_id"], + "decision_id": event["decision_id"], + "pairing": event["pairing"], + "evidence_count": event["evidence_count"], + }, + ensure_ascii=False, + ), + actor=actor, + reason="B-43 事件事实更新,追加待审修订", + supersedes_revision_id=current["id"] if current is not None else None, + ) + from .subjects import store_suggestions + + store_suggestions(connection, ledger_event_id) + return revision_id + + +# --------------------------------------------------------------------------- +# Projection rebuild +# --------------------------------------------------------------------------- + + +def rebuild_current_ledger_projection(connection: sqlite3.Connection) -> int: + """Rebuild current ledger revisions from the append-only log.""" + began = _ensure_transaction(connection) + try: + connection.execute("DELETE FROM current_ledger_event_revisions") + rows = connection.execute( + """ + SELECT e.id AS ledger_event_id, + (SELECT r2.id FROM ledger_event_revisions r2 + WHERE r2.ledger_event_id = e.id + ORDER BY r2.revision DESC LIMIT 1) AS latest_id + FROM ledger_events e + WHERE e.lifecycle = 'active' + """, + ).fetchall() + rebuilt = 0 + for row in rows: + if row["latest_id"] is None: + continue + connection.execute( + """ + INSERT OR REPLACE INTO current_ledger_event_revisions (ledger_event_id, revision_id) + VALUES (?, ?) + """, + (row["ledger_event_id"], row["latest_id"]), + ) + rebuilt += 1 + except Exception: + if began: + connection.rollback() + raise + else: + if began: + connection.commit() + return rebuilt diff --git a/src/bank_importer/manual_records.py b/src/bank_importer/manual_records.py new file mode 100644 index 0000000..975e19f --- /dev/null +++ b/src/bank_importer/manual_records.py @@ -0,0 +1,778 @@ +"""Manual evidence records, administrator approval and audit-safe reversal. + +Manual records are immutable submitted facts. Only an approved record becomes +a canonical ledger event (``approve_new``) or joins one (``approve_link``); +returned/exception/pending records never affect a balance and never leak to +the counterparty. Approved facts change only through a ``reverse`` decision +that creates an opposite new event (or detaches a linked claim) — the original +is never edited. Idempotency keys and UNIQUE claims prevent double counting. +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal, InvalidOperation +import json +import sqlite3 + +from .db import utc_now +from .ledger_events import ( + LedgerConflictError, + LedgerInputError, + create_event, + create_reversal, + current_revision, + manual_source_claim, +) +from .subjects import SUBJECTS + +MANUAL_STATES = ("pending", "approved", "returned", "exception", "reversed") +FUNDING_SOURCES = ("approved_bank_account", "personal_transit", "other") +DATE_KEYS = ("occurred_at",) + + +class ManualConflictError(ValueError): + """A claim/idempotency/revision conflict (mapped to HTTP 409).""" + + +class ManualInputError(ValueError): + """Invalid manual record input (mapped to HTTP 400/422).""" + + +def _parse_amount(amount: object) -> Decimal: + try: + value = Decimal(str(amount)) + except InvalidOperation: + raise ManualInputError("金额不是有效的十进制数。") from None + if not value.is_finite() or value <= 0: + raise ManualInputError("金额必须大于零。") + return value + + +def _validate_date(value: object, field: str) -> str: + text = str(value or "").strip() + if len(text) < 10: + raise ManualInputError(f"{field}必须是 YYYY-MM-DD 或完整时间。") + try: + datetime.fromisoformat(text[:10]) + except ValueError: + raise ManualInputError(f"{field}必须是 YYYY-MM-DD 或完整时间。") from None + return text + + +# --------------------------------------------------------------------------- +# Submit +# --------------------------------------------------------------------------- + + +def submit( + connection: sqlite3.Connection, + *, + company_id: int, + counterparty_company_id: int, + occurred_at: str, + direction: str, + amount: str, + currency: str, + funding_source: str, + requested_subject: str, + request_key: str, + actor: sqlite3.Row, + bank_account_id: object = None, + personal_transit_mapping_id: object = None, + related_source_row_id: object = None, + summary: object = None, + reason: object = None, + evidence: object = None, + supersedes_record_id: object = None, +) -> dict[str, object]: + """Submit one manual record for review. Idempotent on ``(company_id, request_key)``.""" + request_key = str(request_key or "").strip() + if not request_key: + raise ManualInputError("必须提供提交幂等键 request_key。") + if int(company_id) == int(counterparty_company_id): + raise ManualInputError("对方公司不能与本公司相同。") + if direction not in ("outgoing", "incoming"): + raise ManualInputError("方向必须是 outgoing 或 incoming。") + if funding_source not in FUNDING_SOURCES: + raise ManualInputError(f"资金来源必须是:{'、'.join(FUNDING_SOURCES)}。") + if requested_subject not in SUBJECTS: + raise ManualInputError("科目必须是应收/应付/其他应收/其他应付之一。") + _validate_date(occurred_at, "业务日期") + currency = str(currency or "").strip() + if not currency: + raise ManualInputError("币种不能为空。") + amount = str(_parse_amount(amount)) + + for label, raw in ( + ("company_id", company_id), ("counterparty_company_id", counterparty_company_id), + ): + row = connection.execute("SELECT id FROM companies WHERE id = ?", (int(raw),)).fetchone() + if row is None: + raise ManualInputError(f"{label} 指向的公司不存在。") + + bank_account_id = _resolve_account_ref( + connection, bank_account_id, company_id, "银行账户" + ) + mapping_id = _resolve_account_ref( + connection, personal_transit_mapping_id, company_id, "个人过账映射" + ) + related_row = None + if related_source_row_id not in (None, ""): + related_row = connection.execute( + "SELECT r.id, b.company_id FROM source_rows r " + "JOIN sheet_batches s ON s.id = r.sheet_batch_id " + "JOIN import_batches b ON b.id = s.import_batch_id " + "WHERE r.id = ?", + (int(related_source_row_id),), + ).fetchone() + if related_row is None: + raise ManualInputError("关联银行源行不存在。") + + if funding_source == "approved_bank_account" and bank_account_id is None: + raise ManualInputError("资金来源为已批准账户时必须指定银行账户。") + if funding_source == "personal_transit" and mapping_id is None: + raise ManualInputError("资金来源为个人过账时必须指定个人过账映射。") + + supersedes_id = None + if supersedes_record_id not in (None, ""): + parent = connection.execute( + "SELECT id, company_id FROM manual_records WHERE id = ?", + (int(supersedes_record_id),), + ).fetchone() + if parent is None or parent["company_id"] != int(company_id): + raise ManualInputError("supersedes_record_id 无效。") + supersedes_id = int(supersedes_record_id) + + existing = connection.execute( + "SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?", + (int(company_id), request_key), + ).fetchone() + if existing is not None: + return _record_payload(connection, existing["id"], idempotent_replay=True) + + now = utc_now() + began = False + if not connection.in_transaction: + connection.execute("BEGIN IMMEDIATE") + began = True + try: + cursor = connection.execute( + """ + INSERT INTO manual_records ( + company_id, counterparty_company_id, occurred_at, direction, + amount, amount_scale, currency, funding_source, bank_account_id, + personal_transit_mapping_id, related_source_row_id, + requested_subject, summary, reason, evidence_json, request_key, + supersedes_record_id, submitted_by, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + int(company_id), int(counterparty_company_id), occurred_at, direction, + amount, _scale_of(amount), currency, funding_source, bank_account_id, + mapping_id, related_row["id"] if related_row is not None else None, + requested_subject, str(summary or "") or None, + str(reason or "") or None, + json.dumps(evidence, ensure_ascii=False) if evidence else None, + request_key, supersedes_id, + actor["id"], now, + ), + ) + record_id = int(cursor.lastrowid) + _append_decision( + connection, record_id, state="pending", action="submit", + reason=str(reason or "") or None, actor=actor, + ) + except Exception: + if began: + connection.rollback() + raise + else: + if began: + connection.commit() + return _record_payload(connection, record_id) + + +def _resolve_account_ref(connection, raw, company_id: int, label: str) -> int | None: + if raw in (None, ""): + return None + row = connection.execute( + "SELECT id, company_id FROM bank_accounts WHERE id = ?", (int(raw),) + ).fetchone() + if row is None: + raise ManualInputError(f"{label}不存在。") + if row["company_id"] != int(company_id): + raise ManualInputError(f"{label}必须属于提交公司。") + return int(raw) + + +def _scale_of(amount: str) -> int: + exponent = Decimal(amount).as_tuple().exponent + return max(0, -int(exponent)) + + +# --------------------------------------------------------------------------- +# Decisions +# --------------------------------------------------------------------------- + + +def _append_decision( + connection: sqlite3.Connection, + record_id: int, + *, + state: str, + action: str, + reason: str | None, + actor: sqlite3.Row, + idempotency_key: str | None = None, + supersedes_decision_id: int | None = None, +) -> int: + row = connection.execute( + "SELECT COALESCE(MAX(revision), 0) AS m FROM manual_record_decisions WHERE record_id = ?", + (record_id,), + ).fetchone() + revision = int(row["m"]) + 1 + now = utc_now() + cursor = connection.execute( + """ + INSERT INTO manual_record_decisions ( + record_id, revision, state, action, reason, actor_user_id, + actor_username, idempotency_key, supersedes_decision_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + record_id, revision, state, action, reason, + actor["id"], actor["username"], idempotency_key, + supersedes_decision_id, now, + ), + ) + decision_id = int(cursor.lastrowid) + connection.execute( + """ + INSERT OR REPLACE INTO current_manual_record_decisions (record_id, decision_id) + VALUES (?, ?) + """, + (record_id, decision_id), + ) + return decision_id + + +def _current_decision(connection: sqlite3.Connection, record_id: int) -> sqlite3.Row | None: + return connection.execute( + """ + SELECT d.* FROM current_manual_record_decisions c + JOIN manual_record_decisions d ON d.id = c.decision_id + WHERE c.record_id = ? + """, + (record_id,), + ).fetchone() + + +def decide( + connection: sqlite3.Connection, + record_id: int, + action: str, + *, + reason: str, + expected_decision_id: int | None, + request_key: str | None, + actor: sqlite3.Row, + subject_code: object = None, + target_ledger_event_id: object = None, +) -> dict[str, object]: + """Apply an administrator decision to a manual record. + + ``approve_new`` creates a confirmed ledger event; ``approve_link`` joins an + existing ledger event without adding a second economic impact; ``return`` + and ``exception`` never produce a balance; ``reverse`` creates an opposite + reversal event (or detaches a linked claim). Replays return the earlier + outcome via ``idempotency_key``. + """ + reason = (reason or "").strip() + if not reason: + raise ManualInputError("必须填写审核原因。") + if action not in ("approve_new", "approve_link", "return", "exception", "reverse"): + raise ManualInputError("未知的审核决定类型。") + + began = False + if not connection.in_transaction: + connection.execute("BEGIN IMMEDIATE") + began = True + try: + record = connection.execute( + "SELECT * FROM manual_records WHERE id = ?", (record_id,) + ).fetchone() + if record is None: + raise ManualConflictError("手工记录不存在。") + if request_key: + existing = connection.execute( + "SELECT * FROM manual_record_decisions WHERE record_id = ? AND idempotency_key = ?", + (record_id, request_key), + ).fetchone() + if existing is not None: + if began: + connection.commit() + return _decision_payload(connection, record_id, existing["id"]) + + current = _current_decision(connection, record_id) + if current is None: + raise ManualConflictError("该记录没有当前状态。") + if expected_decision_id is not None and int(expected_decision_id) != current["id"]: + raise ManualConflictError("记录已发生变更,请刷新后重试。") + + if action == "approve_new": + outcome = _approve_new( + connection, record, current, actor, subject_code, reason, request_key + ) + elif action == "approve_link": + outcome = _approve_link( + connection, record, current, actor, target_ledger_event_id, reason, + request_key, + ) + elif action == "return": + if current["state"] != "pending": + raise ManualConflictError("只有待复核的记录可以退回。") + decision_id = _append_decision( + connection, record_id, state="returned", action=action, + reason=reason, actor=actor, idempotency_key=request_key, + supersedes_decision_id=current["id"], + ) + outcome = {"decision_id": decision_id, "ledger_event_id": None} + elif action == "exception": + if current["state"] != "pending": + raise ManualConflictError("只有待复核的记录可以转为异常。") + decision_id = _append_decision( + connection, record_id, state="exception", action=action, + reason=reason, actor=actor, idempotency_key=request_key, + supersedes_decision_id=current["id"], + ) + outcome = {"decision_id": decision_id, "ledger_event_id": None} + else: # reverse + if current["state"] != "approved": + raise ManualConflictError("只有已批准记录可以冲销。") + outcome = _reverse(connection, record, current, actor, request_key, reason) + + _store_audit(connection, record, current, action, outcome, reason, actor) + except Exception: + if began: + connection.rollback() + raise + else: + if began: + connection.commit() + return _decision_payload(connection, record_id, outcome["decision_id"]) + + +def _approve_new( + connection: sqlite3.Connection, + record: sqlite3.Row, + current: sqlite3.Row, + actor: sqlite3.Row, + subject_code: object, + reason: str, + idempotency_key: str | None, +) -> dict[str, object]: + subject = str(subject_code or record["requested_subject"] or "") + if subject not in SUBJECTS: + raise ManualInputError("科目必须是应收/应付/其他应收/其他应付之一。") + if record["direction"] == "outgoing": + payer, payee = record["company_id"], record["counterparty_company_id"] + else: + payer, payee = record["counterparty_company_id"], record["company_id"] + event_id, _revision_id = create_event( + connection, + state="confirmed", + effective_at=record["occurred_at"], + amount=record["amount"], + currency=record["currency"], + payer_company_id=payer, + payee_company_id=payee, + perspective_company_id=record["company_id"], + subject_code=subject, + source_kind="manual", + source_revision_token=None, + posting_kind="normal", + rule_version="manual-record-v1", + evidence_json=json.dumps({"manual_record_id": record["id"]}, ensure_ascii=False), + actor=actor, + reason=reason, + ) + decision_id = _append_decision( + connection, record["id"], state="approved", action="approve_new", + reason=reason, actor=actor, + idempotency_key=idempotency_key, + supersedes_decision_id=current["id"], + ) + connection.execute( + """ + INSERT INTO ledger_event_manual_sources (manual_record_id, ledger_event_id) + VALUES (?, ?) + """, + (record["id"], event_id), + ) + return {"decision_id": decision_id, "ledger_event_id": event_id} + + +def _approve_link( + connection: sqlite3.Connection, + record: sqlite3.Row, + current: sqlite3.Row, + actor: sqlite3.Row, + target_ledger_event_id: object, + reason: str, + idempotency_key: str | None, +) -> dict[str, object]: + if target_ledger_event_id in (None, ""): + raise ManualInputError("approve_link 必须指定目标往来事件。") + target = connection.execute( + "SELECT id, lifecycle FROM ledger_events WHERE id = ?", + (int(target_ledger_event_id),), + ).fetchone() + if target is None or target["lifecycle"] != "active": + raise ManualConflictError("目标往来事件不存在。") + if manual_source_claim(connection, record["id"]) is not None: + raise ManualConflictError("该手工记录已关联往来事件。") + decision_id = _append_decision( + connection, record["id"], state="approved", action="approve_link", + reason=reason, actor=actor, + idempotency_key=idempotency_key, + supersedes_decision_id=current["id"], + ) + connection.execute( + """ + INSERT INTO ledger_event_manual_sources (manual_record_id, ledger_event_id) + VALUES (?, ?) + """, + (record["id"], int(target_ledger_event_id)), + ) + return {"decision_id": decision_id, "ledger_event_id": int(target_ledger_event_id)} + + +def _reverse( + connection: sqlite3.Connection, + record: sqlite3.Row, + current: sqlite3.Row, + actor: sqlite3.Row, + request_key: str | None, + reason: str, +) -> dict[str, object]: + claim = manual_source_claim(connection, record["id"]) + if claim is None: + raise ManualConflictError("该记录尚未关联往来事件,无法冲销。") + event_id = claim["ledger_event_id"] + revision = current_revision(connection, event_id) + if revision is None: + raise ManualConflictError("关联的往来事件没有当前修订。") + + if revision["source_kind"] == "manual" and not _event_has_other_sources( + connection, event_id, record["id"] + ): + # The manual record is the only economic source: reverse it with a new + # opposite event at an independent effective date. + create_reversal( + connection, + event_id, + source_kind="manual", + source_revision_token=str(record["id"]), + reason=reason, + actor=actor, + idempotency_key=request_key, + rule_version="manual-record-v1", + ) + else: + # The manual was linked evidence on a bank event: it never added a + # second impact, so reversing detaches the claim without a reversal + # event; the bank impact stays. + connection.execute( + "DELETE FROM ledger_event_manual_sources WHERE manual_record_id = ?", + (record["id"],), + ) + decision_id = _append_decision( + connection, record["id"], state="reversed", action="reverse", + reason=reason, actor=actor, idempotency_key=request_key, + supersedes_decision_id=current["id"], + ) + return {"decision_id": decision_id, "ledger_event_id": None} + + +def _event_has_other_sources(connection, event_id: int, manual_record_id: int) -> bool: + bank = connection.execute( + "SELECT 1 FROM ledger_event_bank_sources WHERE ledger_event_id = ? LIMIT 1", + (event_id,), + ).fetchone() + other_manual = connection.execute( + "SELECT 1 FROM ledger_event_manual_sources " + "WHERE ledger_event_id = ? AND manual_record_id != ? LIMIT 1", + (event_id, manual_record_id), + ).fetchone() + return bank is not None or other_manual is not None + + +def _store_audit( + connection, record, current, action, outcome, reason, actor +) -> None: + from .auth import audit + + audit( + connection, + f"manual_{action}", + actor=actor, + target=f"manual_record:{record['id']}", + detail=( + f"decision:{outcome['decision_id']};" + f"ledger_event:{outcome.get('ledger_event_id')};reason:{reason}" + ), + ) + + +# --------------------------------------------------------------------------- +# Candidates and queries +# --------------------------------------------------------------------------- + + +def find_candidates(connection: sqlite3.Connection, record_id: int) -> list[dict[str, object]]: + """Deterministic hints shown before approval; never auto-merged.""" + record = connection.execute( + "SELECT * FROM manual_records WHERE id = ?", (record_id,) + ).fetchone() + if record is None: + return [] + wanted_direction = "incoming" if record["direction"] == "outgoing" else "outgoing" + date_prefix = str(record["occurred_at"])[:10] + candidates: list[dict[str, object]] = [] + + bank_rows = connection.execute( + """ + SELECT e.event_id, e.amount, e.currency, e.effective_at, e.pairing, + e.payer_company_id, e.payee_company_id, e.decision_id + FROM eligible_intercompany_events e + WHERE (e.payer_company_id = ? AND e.payee_company_id = ?) + OR (e.payer_company_id = ? AND e.payee_company_id = ?) + ORDER BY e.event_id + """, + ( + record["company_id"], record["counterparty_company_id"], + record["counterparty_company_id"], record["company_id"], + ), + ).fetchall() + for row in bank_rows: + if row["amount"] != record["amount"] or row["currency"] != record["currency"]: + continue + event_direction = ( + "outgoing" if row["payer_company_id"] == record["company_id"] else "incoming" + ) + if event_direction != wanted_direction: + continue + candidates.append( + { + "kind": "bank_event", + "ledger_event_id": _ledger_event_of_bank(connection, row["event_id"]), + "bank_event_id": row["event_id"], + "decision_id": row["decision_id"], + "amount": row["amount"], + "currency": row["currency"], + "effective_at": row["effective_at"], + "pairing": row["pairing"], + "hint": "已存在匹配的银行规范事件,建议关联", + } + ) + + manual_rows = connection.execute( + """ + SELECT m.id, m.company_id, m.counterparty_company_id, m.direction, + m.amount, m.currency, m.occurred_at, d.state + FROM manual_records m + JOIN current_manual_record_decisions c ON c.record_id = m.id + JOIN manual_record_decisions d ON d.id = c.decision_id + WHERE m.id != ? AND m.amount = ? AND m.currency = ? + AND ( + (m.company_id = ? AND m.counterparty_company_id = ?) + OR (m.company_id = ? AND m.counterparty_company_id = ?) + ) + ORDER BY m.id + """, + ( + record["id"], record["amount"], record["currency"], + record["company_id"], record["counterparty_company_id"], + record["counterparty_company_id"], record["company_id"], + ), + ).fetchall() + for row in manual_rows: + if row["direction"] != wanted_direction: + continue + if row["state"] not in ("approved", "pending"): + continue + candidates.append( + { + "kind": "manual_record", + "ledger_event_id": None, + "manual_record_id": row["id"], + "amount": row["amount"], + "currency": row["currency"], + "occurred_at": row["occurred_at"], + "state": row["state"], + "hint": "存在方向相反的同额手工记录,建议核对后关联", + } + ) + return candidates + + +def _ledger_event_of_bank(connection, bank_event_id: int) -> int | None: + claim = connection.execute( + "SELECT ledger_event_id FROM ledger_event_bank_sources WHERE bank_event_id = ?", + (bank_event_id,), + ).fetchone() + return claim["ledger_event_id"] if claim is not None else None + + +def list_records( + connection: sqlite3.Connection, + *, + company_id: int | None = None, + state: str | None = None, + limit: int = 100, +) -> list[sqlite3.Row]: + conditions: list[str] = [] + params: list[object] = [] + if company_id is not None: + conditions.append("(m.company_id = ? OR m.counterparty_company_id = ?)") + params.extend([company_id, company_id]) + if state is not None: + if state not in MANUAL_STATES: + raise ManualInputError("无效的记录状态。") + conditions.append("d.state = ?") + params.append(state) + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + return connection.execute( + f""" + SELECT m.*, d.id AS decision_id, d.state AS state, d.revision AS decision_revision, + d.action AS action, d.reason AS decision_reason, + d.actor_username AS decision_actor, d.created_at AS decision_at, + c.name AS company_name, cc.name AS counterparty_company_name + FROM manual_records m + JOIN current_manual_record_decisions c ON c.record_id = m.id + JOIN manual_record_decisions d ON d.id = c.decision_id + LEFT JOIN companies c ON c.id = m.company_id + LEFT JOIN companies cc ON cc.id = m.counterparty_company_id + {where} + ORDER BY m.id DESC + LIMIT ? + """, + (*params, max(1, int(limit))), + ).fetchall() + + +def rebuild_current_manual_projection(connection: sqlite3.Connection) -> int: + began = False + if not connection.in_transaction: + connection.execute("BEGIN IMMEDIATE") + began = True + try: + connection.execute("DELETE FROM current_manual_record_decisions") + rows = connection.execute( + """ + SELECT m.id AS record_id, + (SELECT d2.id FROM manual_record_decisions d2 + WHERE d2.record_id = m.id + ORDER BY d2.revision DESC LIMIT 1) AS latest_id + FROM manual_records m + """ + ).fetchall() + rebuilt = 0 + for row in rows: + if row["latest_id"] is None: + continue + connection.execute( + """ + INSERT OR REPLACE INTO current_manual_record_decisions (record_id, decision_id) + VALUES (?, ?) + """, + (row["record_id"], row["latest_id"]), + ) + rebuilt += 1 + except Exception: + if began: + connection.rollback() + raise + else: + if began: + connection.commit() + return rebuilt + + +# --------------------------------------------------------------------------- +# Payloads +# --------------------------------------------------------------------------- + + +def _record_payload(connection: sqlite3.Connection, record_id: int, *, idempotent_replay: bool = False) -> dict[str, object]: + rows = list_records(connection, limit=1000) + row = next((item for item in rows if item["id"] == record_id), None) + if row is None: + raise ManualInputError("手工记录不存在。") + payload = _row_payload(connection, row) + if idempotent_replay: + payload["idempotent_replay"] = True + return payload + + +def _row_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, object]: + return { + "id": row["id"], + "company_id": row["company_id"], + "company_name": row["company_name"], + "counterparty_company_id": row["counterparty_company_id"], + "counterparty_company_name": row["counterparty_company_name"], + "occurred_at": row["occurred_at"], + "direction": row["direction"], + "amount": row["amount"], + "currency": row["currency"], + "funding_source": row["funding_source"], + "bank_account_id": row["bank_account_id"], + "personal_transit_mapping_id": row["personal_transit_mapping_id"], + "related_source_row_id": row["related_source_row_id"], + "requested_subject": row["requested_subject"], + "summary": row["summary"], + "reason": row["reason"], + "request_key": row["request_key"], + "supersedes_record_id": row["supersedes_record_id"], + "submitted_by": row["submitted_by"], + "created_at": row["created_at"], + "state": row["state"], + "decision_id": row["decision_id"], + "decision_revision": row["decision_revision"], + "decision_action": row["action"], + "decision_reason": row["decision_reason"], + "decision_actor": row["decision_actor"], + "decision_at": row["decision_at"], + "candidates": find_candidates(connection, row["id"]), + } + + +def _decision_payload(connection: sqlite3.Connection, record_id: int, decision_id: int) -> dict[str, object]: + row = connection.execute( + """ + SELECT d.* FROM manual_record_decisions d + WHERE d.id = ? + """, + (decision_id,), + ).fetchone() + record = connection.execute( + "SELECT * FROM manual_records WHERE id = ?", (record_id,) + ).fetchone() + claim = manual_source_claim(connection, record_id) + return { + "record_id": record_id, + "decision_id": decision_id, + "revision": row["revision"], + "state": row["state"], + "action": row["action"], + "reason": row["reason"], + "actor_username": row["actor_username"], + "created_at": row["created_at"], + "ledger_event_id": claim["ledger_event_id"] if claim is not None else None, + "requested_subject": record["requested_subject"], + "amount": record["amount"], + "currency": record["currency"], + "occurred_at": record["occurred_at"], + } diff --git a/src/bank_importer/positions.py b/src/bank_importer/positions.py new file mode 100644 index 0000000..1d216bb --- /dev/null +++ b/src/bank_importer/positions.py @@ -0,0 +1,1210 @@ +"""Intercompany position aggregation: Decimal math, conservation and drill-down. + +Every confirmed ledger event produces a signed claim: the payer books a debit +(claim ``+amount``) and the payee books a credit (claim ``-amount``). For one +company pair and currency the two perspectives must mirror exactly +(``C_A == -C_B``) — the module asserts that invariant after every pair +aggregation and refuses to render a non-conserving number. Subjects are +mirrored from the stored perspective; all money math uses ``Decimal`` on the +stored decimal strings, never SQLite ``SUM`` or floating point. +""" + +from __future__ import annotations + +import base64 +from datetime import datetime, timedelta, timezone +from decimal import Decimal +import json +import sqlite3 + +from .ledger_events import current_revision +from .manual_records import list_records +from .master_data import mask_account_number +from .subjects import MIRROR, SUBJECTS, mirror_subject, subject_label +from . import matching + + +class PositionError(ValueError): + """Calculation failure: conservation violated or bad parameters.""" + + +class PositionInputError(ValueError): + """Invalid query parameters (mapped to HTTP 400).""" + + +SUBJECT_LABEL_MAP = { + "receivable": "应收", "payable": "应付", + "other_receivable": "其他应收", "other_payable": "其他应付", +} + + +def today_shanghai() -> str: + return datetime.now(timezone(timedelta(hours=8))).date().isoformat() + + +def validate_window(from_: object, cutoff: object) -> tuple[str, str]: + pattern = r"^\d{4}-\d{2}-\d{2}$" + import re + + def check(value, label): + text = str(value or "") + if not text or not re.fullmatch(pattern, text): + raise PositionInputError(f"{label}必须是 YYYY-MM-DD 格式。") + try: + datetime.strptime(text, "%Y-%m-%d") + except ValueError: + raise PositionInputError(f"{label}不是有效日期。") from None + return text + + start = check(from_, "from") or "0001-01-01" + end = check(cutoff, "cutoff") + if start > end: + raise PositionInputError("from 不能晚于 cutoff。") + return start, end + + +def encode_cursor(parts: tuple[str, ...]) -> str: + raw = "|".join(str(part) for part in parts) + return base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii") + + +def decode_cursor(cursor: str | None, parts: int) -> tuple[str, ...] | None: + if not cursor: + return None + try: + raw = base64.urlsafe_b64decode(cursor.encode("ascii")).decode("utf-8") + except Exception: + raise PositionInputError("分页游标无效。") from None + values = tuple(raw.split("|")) + if len(values) != parts: + raise PositionInputError("分页游标无效。") + return values + + +def _row(r) -> dict[str, object]: + return {key: r[key] for key in r.keys()} + + +# --------------------------------------------------------------------------- +# Event loading +# --------------------------------------------------------------------------- + +_EVENT_SELECT = """ + SELECT p.ledger_event_id, p.ledger_revision_id, p.effective_at, p.amount, + p.amount_scale, p.currency, p.payer_company_id, p.payee_company_id, + p.perspective_company_id, p.subject_code, p.source_kind, + p.posting_kind, p.reverses_ledger_event_id, p.adjusts_ledger_event_id, + p.source_id, p.evidence_count, + cpayer.name AS payer_company_name, cpayee.name AS payee_company_name + FROM eligible_position_events p + JOIN companies cpayer ON cpayer.id = p.payer_company_id + JOIN companies cpayee ON cpayee.id = p.payee_company_id +""" + + +def _event_filters( + *, + from_: str, + cutoff: str, + currency: str | None = None, + company_id: int | None = None, + pair: tuple[int, int] | None = None, + subject: str | None = None, + posting_kind: str | None = None, + source_kind: str | None = None, + viewer_company_id: int | None = None, + state: str | None = None, +) -> tuple[str, list[object]]: + conditions = ["p.effective_at >= ?", "p.effective_at <= ?"] + params: list[object] = [from_, cutoff + "T23:59:59"] + if currency: + conditions.append("p.currency = ?") + params.append(currency) + if company_id is not None or pair is not None: + if pair is not None: + a, b = int(pair[0]), int(pair[1]) + conditions.append( + "(p.payer_company_id IN (?, ?) AND p.payee_company_id IN (?, ?))" + ) + params.extend([a, b, a, b]) + else: + conditions.append("(p.payer_company_id = ? OR p.payee_company_id = ?)") + params.extend([company_id, company_id]) + if subject: + if subject not in SUBJECTS: + raise PositionInputError("科目筛选无效。") + conditions.append( + "(p.subject_code = ? OR (p.subject_code = ? AND p.subject_code = ?))" + ) + params.extend([subject, MIRROR[subject], subject]) + if posting_kind: + conditions.append("p.posting_kind = ?") + params.append(posting_kind) + if source_kind: + conditions.append("p.source_kind = ?") + params.append(source_kind) + if viewer_company_id is not None: + conditions.append("(p.payer_company_id = ? OR p.payee_company_id = ?)") + params.extend([viewer_company_id, viewer_company_id]) + if state is not None: + if state not in ("confirmed", "pending_subject"): + raise PositionInputError("无效的事件状态。") + conditions.append("p.state = ?") + params.append(state) + return "WHERE " + " AND ".join(conditions), params + + +def load_events( + connection: sqlite3.Connection, + *, + from_: str, + cutoff: str, + currency: str | None = None, + company_id: int | None = None, + pair: tuple[int, int] | None = None, + subject: str | None = None, + posting_kind: str | None = None, + source_kind: str | None = None, + viewer_company_id: int | None = None, +) -> list[sqlite3.Row]: + where, params = _event_filters( + from_=from_, cutoff=cutoff, currency=currency, company_id=company_id, + pair=pair, subject=subject, posting_kind=posting_kind, + source_kind=source_kind, viewer_company_id=viewer_company_id, + ) + return connection.execute( + _EVENT_SELECT + where + " ORDER BY p.ledger_event_id", params + ).fetchall() + + +def load_all_events( + connection: sqlite3.Connection, + *, + from_: str, + cutoff: str, + currency: str | None = None, + company_id: int | None = None, + pair: tuple[int, int] | None = None, + subject: str | None = None, + state: str | None = None, + posting_kind: str | None = None, + source_kind: str | None = None, + viewer_company_id: int | None = None, +) -> list[sqlite3.Row]: + """Load every current ledger revision (confirmed and pending_subject).""" + where, params = _event_filters( + from_=from_, cutoff=cutoff, currency=currency, company_id=company_id, + pair=pair, subject=subject, posting_kind=posting_kind, + source_kind=source_kind, viewer_company_id=viewer_company_id, + state=state, + ) + return connection.execute( + _DETAIL_SELECT + where + " ORDER BY p.ledger_event_id", params + ).fetchall() + + +def signed_amount(event: sqlite3.Row, company_id: int) -> Decimal: + """+amount when the company is the payer, -amount when it is the payee.""" + value = Decimal(event["amount"]) + if int(event["payer_company_id"]) == int(company_id): + return value + return -value + + +def viewer_direction(event: sqlite3.Row, company_id: int) -> str: + return "outgoing" if int(event["payer_company_id"]) == int(company_id) else "incoming" + + +def viewer_subject(event: sqlite3.Row, company_id: int) -> str | None: + if event["perspective_company_id"] is None: + return None + if int(event["perspective_company_id"]) == int(company_id): + return event["subject_code"] + return mirror_subject(event["subject_code"]) + + +def _subject_side(event: sqlite3.Row, company_id: int) -> tuple[str, str, str]: + """``(subject_code, side)`` for ``company_id``: side is 'debit' or 'credit'.""" + perspective = int(event["perspective_company_id"]) + is_payer = int(event["payer_company_id"]) == int(company_id) + if perspective == int(company_id): + subject = event["subject_code"] + side = "debit" if is_payer else "credit" + else: + subject = mirror_subject(event["subject_code"]) + side = "debit" if is_payer else "credit" + return subject, side, perspective + + +def _balance_payload( + connection: sqlite3.Connection, + events: list[sqlite3.Row], + *, + company_id: int, + from_: str, + cutoff: str, + currency: str, +) -> dict[str, object]: + """One company/currency balance row; zero events still yields the row.""" + debit = credit = signed = Decimal("0") + event_count = 0 + for event in events: + if event["currency"] != currency: + continue + if int(event["payer_company_id"]) == int(company_id): + debit += Decimal(event["amount"]) + signed += Decimal(event["amount"]) + else: + credit += Decimal(event["amount"]) + signed -= Decimal(event["amount"]) + event_count += 1 + unresolved = unresolved_for_company( + connection, company_id, cutoff, currency=currency + ) + direction = "receivable" if signed > 0 else ("payable" if signed < 0 else None) + return { + "company_id": company_id, + "company_name": _company_name(connection, company_id), + "window": {"from": from_, "cutoff": cutoff, "cutoff_inclusive": True}, + "currency": currency, + "opening": {"status": "unavailable", "amount": None}, + "period": {"debit": str(debit), "credit": str(credit)}, + "result": { + "kind": "period_net_change", + "signed_amount": str(signed), + "direction": direction, + "label": "期间净变动", + }, + "unresolved": unresolved, + "trace": { + "event_count": event_count, + "events_url": ( + f"/api/admin/intercompany/events?company_id={company_id}" + f"&from={from_}&cutoff={cutoff}¤cy={currency}" + ), + }, + } + + +def _company_name(connection: sqlite3.Connection, company_id: int) -> str: + row = connection.execute( + "SELECT name FROM companies WHERE id = ?", (company_id,) + ).fetchone() + return row["name"] if row is not None else "" + + +# --------------------------------------------------------------------------- +# Unresolved amounts +# --------------------------------------------------------------------------- + + +def _pending_subject_events( + connection: sqlite3.Connection, cutoff: str +) -> list[sqlite3.Row]: + return connection.execute( + """ + SELECT r.ledger_event_id, r.effective_at, r.amount, r.currency, + r.payer_company_id, r.payee_company_id + FROM current_ledger_event_revisions c + JOIN ledger_event_revisions r ON r.id = c.revision_id + WHERE r.state = 'pending_subject' AND r.effective_at <= ? + ORDER BY r.ledger_event_id + """, + (cutoff + "T23:59:59",), + ).fetchall() + + +def _manual_pending( + connection: sqlite3.Connection, cutoff: str +) -> list[sqlite3.Row]: + return connection.execute( + """ + SELECT m.id, m.company_id, m.counterparty_company_id, m.occurred_at, + m.amount, m.currency + FROM manual_records m + JOIN current_manual_record_decisions c ON c.record_id = m.id + JOIN manual_record_decisions d ON d.id = c.decision_id + WHERE d.state = 'pending' AND m.occurred_at <= ? + ORDER BY m.id + """, + (cutoff + "T23:59:59",), + ).fetchall() + + +def _unmatched_singles( + connection: sqlite3.Connection, company_id: int, cutoff: str +) -> list[sqlite3.Row]: + return matching.unresolved_amounts(connection, company_id, cutoff + "T23:59:59") + + +def unresolved_for_company( + connection: sqlite3.Connection, + company_id: int, + cutoff: str, + currency: str | None = None, + *, + include_own_manual_only: bool = True, + counterparty_filter: tuple[int, int] | None = None, +) -> dict[str, object]: + """Unresolved absolute gross per currency (no plus/minus netting). + + ``by_reason`` buckets: subject_review (bank events waiting for a subject), + unmatched_single (B-43 open observations) and manual_pending (unapproved + manual records; only the submitting company / the admin may see them). + """ + gross: dict[str, Decimal] = {} + counts: dict[str, int] = {} + reasons: dict[str, dict[str, object]] = {} + + def add(reason: str, cur: str, amount: Decimal) -> None: + if currency and cur != currency: + return + if reason not in reasons: + reasons[reason] = {"gross_amount": Decimal("0"), "count": 0} + reasons[reason]["gross_amount"] += amount + reasons[reason]["count"] += 1 + gross[cur] = gross.get(cur, Decimal("0")) + amount + counts[cur] = counts.get(cur, 0) + 1 + + for event in _pending_subject_events(connection, cutoff): + if counterparty_filter is not None: + a, b = counterparty_filter + if not ({event["payer_company_id"], event["payee_company_id"]} == {a, b}): + continue + elif company_id not in (event["payer_company_id"], event["payee_company_id"]): + continue + add("subject_review", event["currency"], Decimal(event["amount"])) + + for row in _unmatched_singles(connection, company_id, cutoff): + if counterparty_filter is not None: + participants = _single_participants(connection, row["decision_id"]) + a, b = counterparty_filter + if participants != {a, b}: + continue + add("unmatched_single", row["currency"], abs(Decimal(row["amount"]))) + + for row in _manual_pending(connection, cutoff): + if counterparty_filter is not None: + a, b = counterparty_filter + if not ({row["company_id"], row["counterparty_company_id"]} == {a, b}): + continue + elif include_own_manual_only and row["company_id"] != int(company_id): + continue + elif not include_own_manual_only and company_id not in ( + row["company_id"], row["counterparty_company_id"], + ): + continue + add("manual_pending", row["currency"], Decimal(row["amount"])) + + items = sorted(gross.items()) + return { + "gross_amount": str(sum((gross[k] for k, _ in items), Decimal("0"))), + "count": sum(counts.values()), + "by_reason": { + reason: { + "gross_amount": str(payload["gross_amount"]), + "count": payload["count"], + } + for reason, payload in sorted(reasons.items()) + }, + } + + +def _single_participants(connection: sqlite3.Connection, decision_id: int) -> set[int]: + rows = connection.execute( + "SELECT company_id FROM transfer_decision_participants WHERE decision_id = ?", + (decision_id,), + ).fetchall() + return {row["company_id"] for row in rows if row["company_id"] is not None} + + +# --------------------------------------------------------------------------- +# Directory +# --------------------------------------------------------------------------- + + +def company_balances( + connection: sqlite3.Connection, + *, + from_: str, + cutoff: str, + currency: str | None = None, + company_id: int | None = None, + limit: int = 50, + cursor: str | None = None, +) -> dict[str, object]: + from_, cutoff = validate_window(from_, cutoff) + events = load_events( + connection, from_=from_, cutoff=cutoff, currency=currency, + company_id=company_id, + ) + all_companies = _event_companies(connection) + items_by_company: dict[int, list[sqlite3.Row]] = {} + for event in events: + for company in (event["payer_company_id"], event["payee_company_id"]): + items_by_company.setdefault(company, []).append(event) + + rows: list[tuple[int, str]] = [] + for company in all_companies: + if company_id is not None and company != company_id: + continue + if company in items_by_company: + rows.append((company, "has_events")) + else: + # Companies with no confirmed events still appear when they have + # unresolved exposure, so the directory never hides risk. + unresolved = unresolved_for_company(connection, company, cutoff, currency) + if unresolved["count"] > 0: + rows.append((company, "unresolved")) + + if cursor is not None: + decoded = decode_cursor(cursor, 2) + cursor_company = int(decoded[0]) + cursor_currency = decoded[1] + else: + cursor_company, cursor_currency = None, None + + flat: list[tuple[int, str]] = [] + pending_subject = _pending_subject_events(connection, cutoff) + for company, _marker in rows: + bucket_events = items_by_company.get(company, []) + pending_currencies = { + event["currency"] + for event in pending_subject + if company in (event["payer_company_id"], event["payee_company_id"]) + } + bucket_currencies = sorted( + {event["currency"] for event in bucket_events} + | pending_currencies + | { + item["currency"] + for item in _manual_pending(connection, cutoff) + if item["company_id"] == company or item["counterparty_company_id"] == company + } + | { + item["currency"] for item in _unmatched_singles(connection, company, cutoff) + } + ) + if currency: + bucket_currencies = [currency] if currency in bucket_currencies else [] + for cur in bucket_currencies: + flat.append((company, cur)) + + filtered: list[tuple[int, str]] = [] + for company, cur in flat: + if cursor_company is not None: + if company < cursor_company: + continue + if company == cursor_company and cur <= cursor_currency: + continue + filtered.append((company, cur)) + + filtered.sort(key=lambda item: (item[0], item[1])) + page = filtered[:limit] + has_more = len(filtered) > limit + next_cursor = None + if has_more: + last = page[-1] + next_cursor = encode_cursor((str(last[0]), last[1])) + + items = [] + for company, cur in page: + items.append( + _balance_payload( + connection, items_by_company.get(company, []), + company_id=company, from_=from_, cutoff=cutoff, currency=cur, + ) + ) + return { + "window": {"from": from_, "cutoff": cutoff, "cutoff_inclusive": True}, + "items": items, + "next_cursor": next_cursor, + "has_more": has_more, + } + + +def _event_companies(connection: sqlite3.Connection) -> list[int]: + """All master companies (plus any with ledger exposure) for the directory.""" + rows = connection.execute( + """ + SELECT id AS company_id FROM companies + UNION + SELECT payer_company_id AS company_id FROM eligible_position_events + UNION + SELECT payee_company_id AS company_id FROM eligible_position_events + UNION + SELECT payer_company_id AS company_id FROM ledger_event_revisions + WHERE state = 'pending_subject' + UNION + SELECT payee_company_id AS company_id FROM ledger_event_revisions + WHERE state = 'pending_subject' + ORDER BY company_id + """ + ).fetchall() + return [row["company_id"] for row in rows] + + +# --------------------------------------------------------------------------- +# Pair detail +# --------------------------------------------------------------------------- + + +def pair_detail( + connection: sqlite3.Connection, + company_a: int, + company_b: int, + *, + from_: str, + cutoff: str, + currency: str | None = None, +) -> dict[str, object]: + from_, cutoff = validate_window(from_, cutoff) + if int(company_a) == int(company_b): + raise PositionInputError("公司对的两个公司不能相同。") + pair = (int(company_a), int(company_b)) + events = load_events( + connection, from_=from_, cutoff=cutoff, currency=currency, pair=pair + ) + pending_currencies = { + event["currency"] + for event in _pending_subject_events(connection, cutoff) + if {event["payer_company_id"], event["payee_company_id"]} == set(pair) + } + manual_currencies = { + row["currency"] + for row in _manual_pending(connection, cutoff) + if {row["company_id"], row["counterparty_company_id"]} == set(pair) + } + currencies = sorted( + {event["currency"] for event in events} | pending_currencies | manual_currencies + ) + if currency: + currencies = [currency] if currency in currencies else [] + + outputs = [] + for cur in currencies: + subset = [event for event in events if event["currency"] == cur] + outputs.append( + _pair_currency( + connection, pair, subset, from_=from_, cutoff=cutoff, currency=cur + ) + ) + if not outputs: + outputs.append( + _pair_currency( + connection, pair, [], from_=from_, cutoff=cutoff, + currency=currency or "CNY", + ) + ) + return { + "window": {"from": from_, "cutoff": cutoff, "cutoff_inclusive": True}, + "companies": { + "a": {"company_id": pair[0], "name": _company_name(connection, pair[0])}, + "b": {"company_id": pair[1], "name": _company_name(connection, pair[1])}, + }, + "items": outputs, + } + + +def _pair_currency( + connection: sqlite3.Connection, + pair: tuple[int, int], + events: list[sqlite3.Row], + *, + from_: str, + cutoff: str, + currency: str, +) -> dict[str, object]: + a, b = pair + debit_a = credit_a = debit_b = credit_b = Decimal("0") + subject_sides: dict[str, dict[str, object]] = {} + for subject in SUBJECTS: + subject_sides[subject] = { + "subject_code": subject, + "label": SUBJECT_LABEL_MAP[subject], + "a_debit": Decimal("0"), "a_credit": Decimal("0"), + "b_debit": Decimal("0"), "b_credit": Decimal("0"), + "count": 0, + } + for event in events: + amount = Decimal(event["amount"]) + if int(event["payer_company_id"]) == a: + debit_a += amount + credit_b += amount + else: + debit_b += amount + credit_a += amount + for company in (a, b): + subject, side, _perspective = _subject_side(event, company) + bucket = subject_sides[subject] + bucket[f"{'a' if company == a else 'b'}_{side}"] += amount + bucket["count"] += 1 + + signed_a = debit_a - credit_a + signed_b = debit_b - credit_b + if signed_a != -signed_b: + raise PositionError( + "公司对余额不守恒:A 与 B 的净结果未镜像,拒绝展示该数据。" + ) + + def result_of(signed: Decimal) -> dict[str, object]: + direction = "receivable" if signed > 0 else ("payable" if signed < 0 else None) + return { + "kind": "period_net_change", + "signed_amount": str(signed), + "direction": direction, + "label": "期间净变动", + } + + exception_subjects = [ + subject for subject, bucket in subject_sides.items() + if _subject_abnormal(bucket["a_debit"], bucket["a_credit"], subject) + or _subject_abnormal(bucket["b_debit"], bucket["b_credit"], subject) + ] + return { + "currency": currency, + "opening": {"status": "unavailable", "amount": None}, + "a": { + "period": {"debit": str(debit_a), "credit": str(credit_a)}, + "result": result_of(signed_a), + }, + "b": { + "period": {"debit": str(debit_b), "credit": str(credit_b)}, + "result": result_of(signed_b), + }, + "conservation": { + "abs_equal": abs(signed_a) == abs(signed_b), + "opposite": signed_a == -signed_b, + }, + "subjects": { + subject: { + "subject_code": bucket["subject_code"], + "label": bucket["label"], + "a_debit": str(bucket["a_debit"]), + "a_credit": str(bucket["a_credit"]), + "b_debit": str(bucket["b_debit"]), + "b_credit": str(bucket["b_credit"]), + "count": bucket["count"], + } + for subject, bucket in subject_sides.items() + if bucket["count"] > 0 + }, + "normal_balance_exception": exception_subjects, + "unresolved": unresolved_for_company( + connection, a, cutoff, currency=currency, + counterparty_filter=pair, + ), + "trace": { + "event_count": len(events), + "events_url": ( + f"/api/admin/intercompany/events?company_a={a}&company_b={b}" + f"&from={from_}&cutoff={cutoff}¤cy={currency}" + ), + }, + } + + +def _subject_abnormal(debit: Decimal, credit: Decimal, subject: str) -> bool: + if subject in ("receivable", "other_receivable"): + return debit < credit + return credit < debit + + +# --------------------------------------------------------------------------- +# Events list and detail +# --------------------------------------------------------------------------- + + +def list_events( + connection: sqlite3.Connection, + *, + from_: str, + cutoff: str, + currency: str | None = None, + company_id: int | None = None, + company_a: int | None = None, + company_b: int | None = None, + subject: str | None = None, + state: str | None = None, + posting_kind: str | None = None, + source_kind: str | None = None, + viewer_company_id: int | None = None, + limit: int = 50, + cursor: str | None = None, +) -> dict[str, object]: + from_, cutoff = validate_window(from_, cutoff) + pair = None + if company_a is not None and company_b is not None: + pair = (int(company_a), int(company_b)) + elif company_a is not None: + company_id = int(company_a) + events = load_all_events( + connection, from_=from_, cutoff=cutoff, currency=currency, + company_id=company_id, pair=pair, subject=subject, state=state, + posting_kind=posting_kind, source_kind=source_kind, + viewer_company_id=viewer_company_id, + ) + + def sort_key(event): + return (event["effective_at"], event["ledger_event_id"]) + + events.sort(key=sort_key, reverse=True) + + if cursor is not None: + decoded = decode_cursor(cursor, 2) + cursor_date = decoded[0] + cursor_id = int(decoded[1]) + else: + cursor_date, cursor_id = None, None + filtered = [] + for event in events: + if cursor_date is not None: + key = sort_key(event) + if key[0] > cursor_date or (key[0] == cursor_date and key[1] >= cursor_id): + continue + filtered.append(event) + + page = filtered[:limit] + has_more = len(filtered) > limit + next_cursor = None + if has_more: + last = page[-1] + next_cursor = encode_cursor((last["effective_at"], str(last["ledger_event_id"]))) + + items = [] + for event in page: + item = event_payload( + connection, event, viewer_company_id=viewer_company_id + ) + item["state"] = event["state"] + items.append(item) + return { + "window": {"from": from_, "cutoff": cutoff, "cutoff_inclusive": True}, + "items": items, + "next_cursor": next_cursor, + "has_more": has_more, + } + + +def _current_state(connection: sqlite3.Connection, ledger_event_id: int) -> str: + revision = current_revision(connection, ledger_event_id) + return revision["state"] if revision is not None else "" + + +def event_payload( + connection: sqlite3.Connection, event: sqlite3.Row, *, viewer_company_id: int | None = None +) -> dict[str, object]: + item = { + "ledger_event_id": event["ledger_event_id"], + "ledger_revision_id": event["ledger_revision_id"], + "effective_at": event["effective_at"], + "amount": event["amount"], + "currency": event["currency"], + "payer_company_id": event["payer_company_id"], + "payer_company_name": event["payer_company_name"], + "payee_company_id": event["payee_company_id"], + "payee_company_name": event["payee_company_name"], + "perspective_company_id": event["perspective_company_id"], + "subject_code": event["subject_code"], + "subject_label": subject_label(event["subject_code"]), + "posting_kind": event["posting_kind"], + "source_kind": event["source_kind"], + "reverses_ledger_event_id": event["reverses_ledger_event_id"], + "adjusts_ledger_event_id": event["adjusts_ledger_event_id"], + "source_id": event["source_id"], + "evidence_count": event["evidence_count"], + } + if viewer_company_id is not None: + item["direction"] = viewer_direction(event, viewer_company_id) + own_subject = viewer_subject(event, viewer_company_id) + item["own_subject"] = own_subject + item["own_subject_label"] = ( + subject_label(own_subject) if own_subject is not None else None + ) + item["counterparty_company_id"] = ( + event["payee_company_id"] + if int(event["payer_company_id"]) == int(viewer_company_id) + else event["payer_company_id"] + ) + item["counterparty_company_name"] = ( + event["payee_company_name"] + if int(event["payer_company_id"]) == int(viewer_company_id) + else event["payer_company_name"] + ) + return item + + +_DETAIL_SELECT = """ + SELECT p.ledger_event_id, p.id AS ledger_revision_id, p.effective_at, p.amount, + p.amount_scale, p.currency, p.payer_company_id, p.payee_company_id, + p.perspective_company_id, p.subject_code, p.source_kind, + p.posting_kind, p.reverses_ledger_event_id, p.adjusts_ledger_event_id, + p.state, p.revision AS revision_number, + cpayer.name AS payer_company_name, cpayee.name AS payee_company_name, + COALESCE( + (SELECT bs.bank_event_id FROM ledger_event_bank_sources bs + WHERE bs.ledger_event_id = p.ledger_event_id LIMIT 1), + (SELECT ms.manual_record_id FROM ledger_event_manual_sources ms + WHERE ms.ledger_event_id = p.ledger_event_id LIMIT 1) + ) AS source_id, + ((SELECT COUNT(*) FROM ledger_event_bank_sources bs + WHERE bs.ledger_event_id = p.ledger_event_id) + + (SELECT COUNT(*) FROM ledger_event_manual_sources ms + WHERE ms.ledger_event_id = p.ledger_event_id)) AS evidence_count + FROM current_ledger_event_revisions cur + JOIN ledger_event_revisions p ON p.id = cur.revision_id + JOIN companies cpayer ON cpayer.id = p.payer_company_id + JOIN companies cpayee ON cpayee.id = p.payee_company_id +""" + + +def event_detail(connection: sqlite3.Connection, ledger_event_id: int) -> dict[str, object] | None: + event = connection.execute( + _DETAIL_SELECT + " WHERE p.ledger_event_id = ?", (ledger_event_id,) + ).fetchone() + if event is None: + return None + history = connection.execute( + """ + SELECT r.* FROM ledger_event_revisions r + WHERE r.ledger_event_id = ? + ORDER BY r.revision + """, + (ledger_event_id,), + ).fetchall() + suggestions = connection.execute( + """ + SELECT s.*, c.name AS company_name + FROM ledger_subject_suggestions s + LEFT JOIN companies c ON c.id = s.suggested_perspective_company_id + WHERE s.ledger_event_id = ? + ORDER BY s.id + """, + (ledger_event_id,), + ).fetchall() + return { + "event": event_payload(connection, event), + "state": _current_state(connection, ledger_event_id), + "history": [ + { + "revision": rev["revision"], + "state": rev["state"], + "effective_at": rev["effective_at"], + "amount": rev["amount"], + "currency": rev["currency"], + "perspective_company_id": rev["perspective_company_id"], + "subject_code": rev["subject_code"], + "posting_kind": rev["posting_kind"], + "source_kind": rev["source_kind"], + "source_revision_token": rev["source_revision_token"], + "actor_username": rev["actor_username"], + "reason": rev["reason"], + "created_at": rev["created_at"], + } + for rev in history + ], + "suggestions": [ + { + "suggested_perspective_company_id": sug["suggested_perspective_company_id"], + "suggested_company_name": sug["company_name"], + "suggested_subject_code": sug["suggested_subject_code"], + "suggested_subject_label": subject_label(sug["suggested_subject_code"]), + "rule_version": sug["rule_version"], + "evidence": json.loads(sug["evidence_json"]) if sug["evidence_json"] else {}, + "created_at": sug["created_at"], + } + for sug in suggestions + ], + } + + +def subject_review_queue( + connection: sqlite3.Connection, + *, + from_: str, + cutoff: str, + company_id: int | None = None, + limit: int = 50, + cursor: str | None = None, +) -> dict[str, object]: + from_, cutoff = validate_window(from_, cutoff) + conditions = ["r.state = 'pending_subject'", "r.effective_at >= ?", "r.effective_at <= ?"] + params: list[object] = [from_, cutoff + "T23:59:59"] + if company_id is not None: + conditions.append("(r.payer_company_id = ? OR r.payee_company_id = ?)") + params.extend([company_id, company_id]) + rows = connection.execute( + f""" + SELECT r.ledger_event_id, r.effective_at, r.amount, r.currency, + r.payer_company_id, r.payee_company_id, + cpayer.name AS payer_company_name, cpayee.name AS payee_company_name, + r.id AS revision_id + FROM current_ledger_event_revisions cur + JOIN ledger_event_revisions r ON r.id = cur.revision_id + JOIN companies cpayer ON cpayer.id = r.payer_company_id + JOIN companies cpayee ON cpayee.id = r.payee_company_id + WHERE {' AND '.join(conditions)} + ORDER BY r.ledger_event_id + """, + params, + ).fetchall() + rows.sort(key=lambda row: (row["effective_at"], row["ledger_event_id"]), reverse=True) + if cursor is not None: + decoded = decode_cursor(cursor, 2) + cursor_date, cursor_id = decoded[0], int(decoded[1]) + else: + cursor_date, cursor_id = None, None + filtered = [] + for row in rows: + if cursor_date is not None: + if row["effective_at"] > cursor_date or ( + row["effective_at"] == cursor_date and row["ledger_event_id"] >= cursor_id + ): + continue + filtered.append(row) + page = filtered[:limit] + has_more = len(filtered) > limit + next_cursor = None + if has_more: + last = page[-1] + next_cursor = encode_cursor((last["effective_at"], str(last["ledger_event_id"]))) + items = [] + for row in page: + suggestions = connection.execute( + """ + SELECT s.suggested_perspective_company_id, s.suggested_subject_code, + c.name AS company_name, s.rule_version, s.evidence_json + FROM ledger_subject_suggestions s + LEFT JOIN companies c ON c.id = s.suggested_perspective_company_id + WHERE s.ledger_event_id = ? + ORDER BY s.id + """, + (row["ledger_event_id"],), + ).fetchall() + items.append( + { + "ledger_event_id": row["ledger_event_id"], + "revision_id": row["revision_id"], + "effective_at": row["effective_at"], + "amount": row["amount"], + "currency": row["currency"], + "payer_company_id": row["payer_company_id"], + "payer_company_name": row["payer_company_name"], + "payee_company_id": row["payee_company_id"], + "payee_company_name": row["payee_company_name"], + "suggestions": [ + { + "suggested_perspective_company_id": sug["suggested_perspective_company_id"], + "suggested_company_name": sug["company_name"], + "suggested_subject_code": sug["suggested_subject_code"], + "suggested_subject_label": subject_label(sug["suggested_subject_code"]), + "rule_version": sug["rule_version"], + "evidence": json.loads(sug["evidence_json"]) if sug["evidence_json"] else {}, + } + for sug in suggestions + ], + } + ) + return { + "window": {"from": from_, "cutoff": cutoff, "cutoff_inclusive": True}, + "items": items, + "next_cursor": next_cursor, + "has_more": has_more, + } + + +# --------------------------------------------------------------------------- +# Evidence drill-down +# --------------------------------------------------------------------------- + + +def event_evidence( + connection: sqlite3.Connection, + ledger_event_id: int, + *, + viewer_company_id: int | None = None, +) -> dict[str, object] | None: + """Evidence blocks behind one ledger event with explicit visibility. + + ``visibility`` is always present (``visible``/``masked``/``missing``); a + company user never guesses from absent fields. Company viewers see their + own bank/manual evidence in full, the counterparty side masked, and a + ``missing`` block when no source exists. + """ + event = connection.execute( + _EVENT_SELECT + " WHERE p.ledger_event_id = ?", (ledger_event_id,) + ).fetchone() + if event is None: + return None + blocks: list[dict[str, object]] = [] + bank_claims = connection.execute( + "SELECT * FROM ledger_event_bank_sources WHERE ledger_event_id = ? ORDER BY bank_event_id", + (ledger_event_id,), + ).fetchall() + manual_claims = connection.execute( + "SELECT * FROM ledger_event_manual_sources WHERE ledger_event_id = ? ORDER BY manual_record_id", + (ledger_event_id,), + ).fetchall() + + for claim in bank_claims: + blocks.extend( + _bank_evidence_blocks(connection, claim["bank_event_id"], viewer_company_id) + ) + for claim in manual_claims: + blocks.append( + _manual_evidence_block(connection, claim["manual_record_id"], viewer_company_id) + ) + + if not blocks: + blocks.append( + { + "side": "counterparty", + "source_kind": "bank", + "visibility": "missing", + "fields": {}, + } + ) + + return { + "ledger_event_id": ledger_event_id, + "summary": { + "effective_at": event["effective_at"], + "amount": event["amount"], + "currency": event["currency"], + "payer_company_id": event["payer_company_id"], + "payer_company_name": event["payer_company_name"], + "payee_company_id": event["payee_company_id"], + "payee_company_name": event["payee_company_name"], + "subject_code": event["subject_code"], + "subject_label": subject_label(event["subject_code"]), + "posting_kind": event["posting_kind"], + }, + "blocks": blocks, + } + + +def _bank_evidence_blocks( + connection: sqlite3.Connection, + bank_event_id: int, + viewer_company_id: int | None, +) -> list[dict[str, object]]: + decision = matching._current_decision_for_event(connection, bank_event_id) + if decision is None: + return [] + observations = matching._decision_observations(connection, decision["id"]) + blocks: list[dict[str, object]] = [] + for observation in observations: + source = connection.execute( + """ + SELECT r.id, r.source_row, r.transaction_at, r.income, r.expense, + r.own_account, r.own_name, r.counterparty_account, + r.counterparty_name, r.summary, r.purpose, r.reference, + r.currency, s.sheet_name, f.original_filename, + b.company_id AS batch_company_id, c.name AS company_name + FROM source_rows r + JOIN sheet_batches s ON s.id = r.sheet_batch_id + JOIN import_batches b ON b.id = s.import_batch_id + JOIN source_files f ON f.id = b.source_file_id + LEFT JOIN companies c ON c.id = b.company_id + WHERE r.id = ? + """, + (observation["source_row_id"],), + ).fetchone() + if source is None: + continue + own = int(source["batch_company_id"]) == int(viewer_company_id) if viewer_company_id is not None else True + side = "own" if own else "counterparty" + if viewer_company_id is None or own: + visibility = "visible" + fields = { + "original_filename": source["original_filename"], + "sheet_name": source["sheet_name"], + "source_row": source["source_row"], + "transaction_at": source["transaction_at"], + "income": source["income"], + "expense": source["expense"], + "own_account": source["own_account"] if viewer_company_id is None + else mask_account_number(source["own_account"]) if source["own_account"] else None, + "own_name": source["own_name"], + "counterparty_account_masked": ( + mask_account_number(source["counterparty_account"]) + if source["counterparty_account"] else None + ), + "counterparty_name": source["counterparty_name"], + "summary": source["summary"], + "purpose": source["purpose"], + "reference": source["reference"], + "currency": source["currency"], + "role": observation["role"], + } + else: + visibility = "masked" + fields = { + "company_id": source["batch_company_id"], + "company_name": source["company_name"], + "own_account_masked": ( + mask_account_number(source["own_account"]) + if source["own_account"] else None + ), + "note": "按对方授权不可见", + } + blocks.append( + { + "side": side, + "source_kind": "bank", + "visibility": visibility, + "fields": fields, + } + ) + return blocks + + +def _manual_evidence_block( + connection: sqlite3.Connection, + manual_record_id: int, + viewer_company_id: int | None, +) -> dict[str, object]: + rows = list_records(connection, limit=10000) + record = next((row for row in rows if row["id"] == manual_record_id), None) + if record is None: + return { + "side": "counterparty", "source_kind": "manual", + "visibility": "missing", "fields": {}, + } + own = ( + int(record["company_id"]) == int(viewer_company_id) + if viewer_company_id is not None + else True + ) + if viewer_company_id is None or own: + visibility = "visible" + fields = { + "manual_record_id": record["id"], + "company_id": record["company_id"], + "company_name": record["company_name"], + "counterparty_company_id": record["counterparty_company_id"], + "counterparty_company_name": record["counterparty_company_name"], + "occurred_at": record["occurred_at"], + "direction": record["direction"], + "amount": record["amount"], + "currency": record["currency"], + "funding_source": record["funding_source"], + "requested_subject": record["requested_subject"], + "summary": record["summary"], + "reason": record["reason"], + "state": record["state"], + } + else: + visibility = "masked" + fields = { + "company_id": record["company_id"], + "company_name": record["company_name"], + "state": record["state"], + "note": "按对方授权不可见", + } + return { + "side": "own" if own else "counterparty", + "source_kind": "manual", + "visibility": visibility, + "fields": fields, + } diff --git a/src/bank_importer/subjects.py b/src/bank_importer/subjects.py new file mode 100644 index 0000000..3f290d4 --- /dev/null +++ b/src/bank_importer/subjects.py @@ -0,0 +1,302 @@ +"""Statutory subject suggestions, mirror mapping and confirmation (B-44). + +Subjects are stored from one participating company's perspective and the +other side is the fixed mirror (应收<->应付, 其他应收<->其他应付), so the two +companies can never record conflicting subjects. Bank summary/purpose text +only ever produces a *suggestion*; nothing here confirms a subject +automatically. Confirmation is an explicit administrator decision carrying +``expected_revision`` and an idempotency key. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 + +from . import matching + +SUBJECTS = ("receivable", "payable", "other_receivable", "other_payable") +SUBJECT_RULE_VERSION = "subject-suggest-draft-v1" + +MIRROR = { + "receivable": "payable", + "payable": "receivable", + "other_receivable": "other_payable", + "other_payable": "other_receivable", +} + +SUBJECT_LABELS = { + "receivable": "应收", + "payable": "应付", + "other_receivable": "其他应收", + "other_payable": "其他应付", +} + +_FULL_WIDTH = str.maketrans( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz0123456789", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz0123456789", +) + +# Draft v1 dictionary. Exact-keyword matching only; every hit is a suggestion +# and never an automatic posting. Trade-type keywords are deliberately absent +# until the group supplies an approved dictionary (they always go to review). +_LOAN_LIKE = ("借款", "往来款", "资金往来", "临时借款", "资金调拨", "代垫", "垫付") +_REPAY_LIKE = ("还款", "归还借款", "归还往来款") + + +class SubjectConflictError(ValueError): + """A stale revision or idempotency conflict (mapped to HTTP 409).""" + + +class SubjectInputError(ValueError): + """Invalid input for a subject decision (mapped to HTTP 400/422).""" + + +def mirror_subject(subject_code: str) -> str: + if subject_code not in MIRROR: + raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。") + return MIRROR[subject_code] + + +def subject_label(subject_code: str) -> str: + return SUBJECT_LABELS.get(subject_code, subject_code) + + +def _normalize(text: object) -> str: + return re.sub(r"[\s\ufeff]+", "", str(text or "").translate(_FULL_WIDTH)) + + +def _bank_evidence_texts(connection: sqlite3.Connection, ledger_event_id: int) -> dict[str, str]: + """Purpose/summary text of the B-43 source rows behind a bank event.""" + row = connection.execute( + """ + SELECT bs.bank_event_id FROM ledger_event_bank_sources bs + WHERE bs.ledger_event_id = ? + """, + (ledger_event_id,), + ).fetchone() + if row is None: + return {"purpose": "", "summary": ""} + decision = matching._current_decision_for_event(connection, row["bank_event_id"]) + if decision is None: + return {"purpose": "", "summary": ""} + observations = matching._decision_observations(connection, decision["id"]) + texts: dict[str, list[str]] = {"purpose": [], "summary": []} + for observation in observations: + source = connection.execute( + "SELECT purpose, summary FROM source_rows WHERE id = ?", + (observation["source_row_id"],), + ).fetchone() + if source is None: + continue + for key in ("purpose", "summary"): + value = str(source[key] or "").strip() + if value: + texts[key].append(value) + return { + "purpose": " ".join(texts["purpose"]), + "summary": " ".join(texts["summary"]), + } + + +def compute_suggestions( + connection: sqlite3.Connection, ledger_event_id: int +) -> list[dict[str, object]]: + """ Deterministic draft suggestions for a pending event, never confirmation. + + Purpose rules take precedence over summary rules. When both loan-like and + repay-like keywords match, both candidates are returned as a conflict for + the reviewer; no priority breaks the tie. + """ + from .ledger_events import current_revision + + revision = current_revision(connection, ledger_event_id) + if revision is None or revision["state"] != "pending_subject": + return [] + texts = _bank_evidence_texts(connection, ledger_event_id) + purpose = _normalize(texts["purpose"]) + summary = _normalize(texts["summary"]) + search = purpose or summary + payer = revision["payer_company_id"] + payee = revision["payee_company_id"] + + loan_hit = next((word for word in _LOAN_LIKE if word in search), None) + repay_hit = next((word for word in _REPAY_LIKE if word in search), None) + + suggestions: list[dict[str, object]] = [] + if loan_hit: + suggestions.append( + { + "suggested_perspective_company_id": payer, + "suggested_subject_code": "other_receivable", + "reason": f"匹配建议词典「{loan_hit}」,建议付款方其他应收", + "rule_version": SUBJECT_RULE_VERSION, + "evidence": { + "keyword": loan_hit, + "matched_text": search, + "approved": False, + }, + } + ) + if repay_hit: + suggestions.append( + { + "suggested_perspective_company_id": payee, + "suggested_subject_code": "other_receivable", + "reason": f"匹配建议词典「{repay_hit}」,建议收款方其他应收", + "rule_version": SUBJECT_RULE_VERSION, + "evidence": { + "keyword": repay_hit, + "matched_text": search, + "approved": False, + }, + } + ) + return suggestions + + +def store_suggestions(connection: sqlite3.Connection, ledger_event_id: int) -> int: + """Compute and append suggestions for a pending event. Returns count stored.""" + from .ledger_events import current_revision + + revision = current_revision(connection, ledger_event_id) + if revision is None or revision["state"] != "pending_subject": + return 0 + stored = 0 + for suggestion in compute_suggestions(connection, ledger_event_id): + from .db import utc_now + + connection.execute( + """ + INSERT INTO ledger_subject_suggestions ( + ledger_event_id, source_revision_id, + suggested_perspective_company_id, suggested_subject_code, + rule_version, evidence_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + ledger_event_id, revision["id"], + suggestion["suggested_perspective_company_id"], + suggestion["suggested_subject_code"], + suggestion["rule_version"], + json.dumps(suggestion.get("evidence", {}), ensure_ascii=False), + utc_now(), + ), + ) + stored += 1 + return stored + + +def confirm_subject( + connection: sqlite3.Connection, + ledger_event_id: int, + *, + perspective_company_id: int, + subject_code: str, + reason: str, + expected_revision: int | None, + request_key: str | None, + actor: sqlite3.Row, +) -> dict[str, object]: + """Confirm a subject, turning a pending event into a confirmed revision.""" + from .ledger_events import append_revision, current_revision + + reason = (reason or "").strip() + if not reason: + raise SubjectInputError("必须填写科目确认依据。") + if subject_code not in SUBJECTS: + raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。") + subject_code = subject_code + + if request_key: + existing = connection.execute( + """ + SELECT * FROM ledger_event_revisions + WHERE ledger_event_id = ? AND idempotency_key = ? + ORDER BY id LIMIT 1 + """, + (ledger_event_id, request_key), + ).fetchone() + if existing is not None: + return _revision_payload(connection, existing) + + current = current_revision(connection, ledger_event_id) + if current is None: + raise SubjectConflictError("该事件不存在或没有当前修订。") + if current["state"] != "pending_subject": + raise SubjectConflictError("只有待确认科目的事件可以确认科目。") + if expected_revision is not None and int(expected_revision) != current["revision"]: + raise SubjectConflictError("事件已发生变更,请刷新后重试。") + participants = {current["payer_company_id"], current["payee_company_id"]} + if perspective_company_id not in participants: + raise SubjectInputError("视角公司必须是事件参与方。") + + began = False + if not connection.in_transaction: + connection.execute("BEGIN IMMEDIATE") + began = True + try: + revision_id = append_revision( + connection, + ledger_event_id, + state="confirmed", + effective_at=current["effective_at"], + amount=current["amount"], + currency=current["currency"], + payer_company_id=current["payer_company_id"], + payee_company_id=current["payee_company_id"], + perspective_company_id=perspective_company_id, + subject_code=subject_code, + source_kind=current["source_kind"], + source_revision_token=current["source_revision_token"], + posting_kind=current["posting_kind"], + reverses_ledger_event_id=current["reverses_ledger_event_id"], + adjusts_ledger_event_id=current["adjusts_ledger_event_id"], + rule_version=current["rule_version"] or SUBJECT_RULE_VERSION, + evidence_json=current["evidence_json"], + idempotency_key=request_key, + actor=actor, + reason=reason, + supersedes_revision_id=current["id"], + ) + row = connection.execute( + "SELECT * FROM ledger_event_revisions WHERE id = ?", (revision_id,) + ).fetchone() + except Exception: + if began: + connection.rollback() + raise + else: + if began: + connection.commit() + return _revision_payload(connection, row) + + +def _revision_payload(connection: sqlite3.Connection, revision: sqlite3.Row) -> dict[str, object]: + company = connection.execute( + "SELECT name FROM companies WHERE id = ?", (revision["perspective_company_id"],) + ).fetchone() + return { + "ledger_event_id": revision["ledger_event_id"], + "revision_id": revision["id"], + "revision": revision["revision"], + "state": revision["state"], + "effective_at": revision["effective_at"], + "amount": revision["amount"], + "currency": revision["currency"], + "payer_company_id": revision["payer_company_id"], + "payee_company_id": revision["payee_company_id"], + "perspective_company_id": revision["perspective_company_id"], + "perspective_company_name": company["name"] if company else None, + "subject_code": revision["subject_code"], + "subject_label": subject_label(revision["subject_code"]) + if revision["subject_code"] + else None, + "posting_kind": revision["posting_kind"], + "source_kind": revision["source_kind"], + "reason": revision["reason"], + "created_at": revision["created_at"], + } diff --git a/tests/ledger_helpers.py b/tests/ledger_helpers.py new file mode 100644 index 0000000..475f29f --- /dev/null +++ b/tests/ledger_helpers.py @@ -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() diff --git a/tests/test_ledger_events.py b/tests/test_ledger_events.py new file mode 100644 index 0000000..4e272cf --- /dev/null +++ b/tests/test_ledger_events.py @@ -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() diff --git a/tests/test_manual_records.py b/tests/test_manual_records.py new file mode 100644 index 0000000..425bd33 --- /dev/null +++ b/tests/test_manual_records.py @@ -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() diff --git a/tests/test_persistence.py b/tests/test_persistence.py index b23c6d5..27b7c26 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -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( diff --git a/tests/test_positions.py b/tests/test_positions.py new file mode 100644 index 0000000..847b74e --- /dev/null +++ b/tests/test_positions.py @@ -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() diff --git a/tests/test_positions_api.py b/tests/test_positions_api.py new file mode 100644 index 0000000..e872ffc --- /dev/null +++ b/tests/test_positions_api.py @@ -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() diff --git a/web/admin.html b/web/admin.html index 8e95f5d..53bb995 100644 --- a/web/admin.html +++ b/web/admin.html @@ -22,7 +22,7 @@
金牛集团总账管理端
@@ -140,7 +141,7 @@
-
+
@@ -162,6 +163,25 @@
当前结果 3金额单位:元
日期银行账户方向对方户名 / 账号摘要银行流水号归集状态金额
2026.07.18中信银行 · 5316转出B公司尾号 9481往来款CIT260718018双边匹配10,000,000.00
2026.07.12建设银行 · 0845转出A公司尾号 5316同名账户调拨CCB260712031同公司调拨2,000,000.00
2026.07.06中信银行 · 5316转入B公司尾号 9481归还往来款CIT260706041双边匹配3,200,000.00
+
+

往来余额

本公司往来余额、对方公司明细与允许查看的源证据

+ 期初不可用 · 仅显示期间净变动 +
+ +
+

本公司往来汇总

统计口径

+
+
+
+

对方公司明细

点击任意对方公司查看往来事件与允许的源证据

+
+
+
+

往来确认

系统计算为主,只处理无法确定的匹配与科目

自动确认18 笔无需人工处理
单边待匹配1 笔需选择对方证据
科目待确认1 笔需选择会计科目
@@ -195,6 +215,17 @@
+ +
diff --git a/web/styles.css b/web/styles.css index 4f20d46..6b8617e 100644 --- a/web/styles.css +++ b/web/styles.css @@ -26,6 +26,8 @@ --color-danger-wash: rgba(255, 98, 109, 0.11); --color-info: #66a8ff; --color-info-wash: rgba(102, 168, 255, 0.11); + --row-h-evidence: 55px; + --z-drawer: 150; --radius-sm: 10px; --radius-md: 16px; --radius-lg: 22px; @@ -736,3 +738,102 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } + +/* ---- B-44 intercompany balances (design tokens only, no overrides) ---- */ +.status.info { border-color: rgba(102, 168, 255, 0.18); background: var(--color-info-wash); color: var(--color-info); } +.amount-neutral { color: var(--color-ink-muted); } + +/* Balance directory: 公司 | 借方 | 贷方 | 期末结果 | 未决 | 截止日 | ▸ */ +.ledger-head.is-balances, .company-ledger.is-balances summary { + grid-template-columns: minmax(180px, 1.3fr) minmax(100px, 0.7fr) minmax(100px, 0.7fr) minmax(150px, 0.9fr) minmax(110px, 0.7fr) minmax(96px, 0.55fr) 24px; +} +.company-ledger.is-balances summary { min-height: 72px; } +.company-ledger.is-balances summary .ledger-cutoff { color: var(--color-ink-muted); font-size: 11px; white-space: nowrap; } +.company-ledger.is-balances .ledger-result { display: flex; align-items: center; gap: 7px; min-width: 0; } +.company-ledger.is-balances .ledger-result b { font: 600 14px var(--font-data); font-variant-numeric: tabular-nums; white-space: nowrap; } +.company-ledger.is-balances .ledger-unresolved { font-size: 11px; white-space: nowrap; } +.company-ledger.is-balances .ledger-unresolved.is-empty { color: var(--color-ink-muted); } +.company-ledger.is-balances .ledger-unresolved.is-active { color: var(--color-warning); } +.company-ledger.is-balances .currency-tag { color: var(--color-ink-muted); font-size: 10px; } +.company-ledger.is-balances .subject-row small { white-space: nowrap; } +@media (max-width: 900px) { + .ledger-head.is-balances, .company-ledger.is-balances summary { grid-template-columns: minmax(150px, 1fr) minmax(150px, 0.9fr) minmax(110px, 0.7fr) 24px; } + .company-ledger.is-balances summary .ledger-hide-sm { display: none; } +} + +/* Pair report six-cell balance line: 期初 | 本期借 | 本期贷 | 期末 | 未决 | 截止日 */ +.pair-balance-line.is-six { grid-template-columns: repeat(6, 1fr); } +.pair-balance-line.is-six .pair-final { grid-column: auto; border-left: 1px solid var(--color-line); } +.pair-balance-line .pair-open-note { color: var(--color-ink-muted); font-size: 10px; margin-top: 3px; } +.pair-balance-line .pair-unresolved b { color: var(--color-warning); } +.pair-balance-line .pair-unresolved.is-empty b { color: var(--color-ink); } +.pair-balance-line .pair-cutoff { color: var(--color-ink-muted); font-size: 10px; margin-top: 3px; } +@media (max-width: 1180px) { + .pair-balance-line.is-six { grid-template-columns: repeat(3, 1fr); } + .pair-balance-line.is-six div:nth-child(4) { border-top: 1px solid var(--color-line); border-left: 0; } + .pair-balance-line.is-six div:nth-child(5), .pair-balance-line.is-six div:nth-child(6) { border-top: 1px solid var(--color-line); } +} +@media (max-width: 720px) { + .pair-balance-line.is-six { grid-template-columns: 1fr 1fr; } +} + +/* Evidence drawer (single instance, single-layer replacement) */ +.drawer-scrim { position: fixed; inset: 0; z-index: calc(var(--z-drawer) - 1); background: rgba(5, 5, 5, 0.6); opacity: 0; pointer-events: none; transition: opacity var(--duration-standard) var(--ease-out); } +.drawer-scrim.is-open { opacity: 1; pointer-events: auto; } +.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: var(--z-drawer); width: 640px; max-width: 100%; display: flex; flex-direction: column; background: rgba(17, 17, 17, 0.96); border-left: 1px solid var(--color-line-strong); box-shadow: var(--shadow-panel); transform: translateX(24px); opacity: 0; visibility: hidden; transition: transform var(--duration-standard) var(--ease-out), opacity var(--duration-standard) var(--ease-out), visibility 0s linear var(--duration-standard); } +.drawer.is-open { transform: translateX(0); opacity: 1; visibility: visible; transition-delay: 0s; } +@media (min-width: 1024px) and (max-width: 1180px) { .drawer { width: 480px; } } +@media (max-width: 768px) { .drawer { width: 100%; } } +.drawer-header { min-height: 74px; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--color-line); } +.drawer-header .drawer-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; color: var(--color-ink-muted); font-size: 11px; } +.drawer-header .drawer-breadcrumb button { border: 0; padding: 0; background: none; color: var(--color-info); cursor: pointer; font-size: 11px; } +.drawer-header .drawer-breadcrumb button:hover { text-decoration: underline; } +.drawer-header h2 { margin-top: 6px; font-size: 17px; } +.drawer-body { flex: 1; overflow: auto; padding: 16px 18px 24px; contain: layout paint; } +.drawer .pair-balance-line { border: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; margin-bottom: 16px; } +.drawer .pair-balance-line div { min-height: 64px; } +.drawer .event-table { min-width: 640px; } +.drawer .event-table td { height: var(--row-h-evidence); } +.drawer .event-row { cursor: pointer; } +.drawer .event-row:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: -2px; } +.drawer .evidence-stack { display: flex; flex-direction: column; gap: 10px; } +.drawer .evidence-midline { display: flex; align-items: center; gap: 10px; color: var(--color-ink-muted); font-size: 11px; } +.drawer .evidence-midline::before, .drawer .evidence-midline::after { content: ""; flex: 1; height: 1px; background: var(--color-line); } +.evidence-block.is-missing { border-color: rgba(102, 168, 255, 0.28); background: var(--color-info-wash); } +.evidence-block .evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 14px; margin-top: 10px; } +.evidence-block .evidence-grid div { display: flex; flex-direction: column; min-width: 0; } +.evidence-block .evidence-grid dt { color: var(--color-ink-muted); font-size: 10px; } +.evidence-block .evidence-grid dd { color: var(--color-ink-soft); font-size: 12px; word-break: break-all; } +.evidence-block .evidence-masked { color: var(--color-ink-muted); font-style: normal; } +@media (max-width: 720px) { .evidence-block .evidence-grid { grid-template-columns: 1fr; } } + +/* Loading skeletons */ +.skeleton-row { height: var(--row-h-evidence); display: flex; align-items: center; gap: 12px; padding: 0 14px; border-bottom: 1px solid var(--color-line); } +.skeleton-row span { height: 12px; border-radius: 6px; background: rgba(255, 255, 255, 0.06); } +.skeleton-row span:nth-child(1) { width: 22%; } .skeleton-row span:nth-child(2) { width: 14%; } +.skeleton-row span:nth-child(3) { width: 14%; } .skeleton-row span:nth-child(4) { width: 18%; } +.skeleton-row span:nth-child(5) { width: 12%; } +.skeleton-block { animation: skeleton-breathe 1200ms ease-in-out infinite; } + +/* Empty / error / no-permission states */ +.state-panel { display: flex; flex-direction: column; align-items: center; gap: 10px; justify-content: center; min-height: 240px; padding: 28px 18px; text-align: center; color: var(--color-ink-muted); } +.state-panel .state-icon { width: 40px; height: 40px; color: var(--color-ink-muted); opacity: 0.7; } +.state-panel strong { color: var(--color-ink-soft); font-size: 14px; } +.state-panel p { max-width: 420px; font-size: 12px; } +.state-panel .button { margin-top: 6px; } +.state-panel.is-error { border: 1px solid rgba(255, 98, 109, 0.3); border-radius: var(--radius-lg); background: var(--color-danger-wash); } +.state-panel.is-error strong { color: var(--color-danger); } +.state-panel.is-denied { border: 1px solid var(--color-line); border-radius: var(--radius-lg); background: var(--color-surface); } + +/* Company portal alert variant + direction chips */ +.balance-alert { border-color: rgba(255, 188, 82, 0.24); background: var(--color-warning-wash); } +.balance-alert > svg { color: var(--color-warning); } +.balance-alert .button { color: var(--color-warning); } +.company-row .result-direction { font-weight: 600; font-variant-numeric: tabular-nums; } +.subject-strip button { white-space: nowrap; } + +/* Direction chips: text-first, arrows as affordance, never color-only */ +.direction-chip { display: inline-flex; align-items: center; gap: 4px; min-height: 23px; padding: 2px 8px; border-radius: 8px; border: 1px solid var(--color-line); background: rgba(255, 255, 255, 0.045); color: var(--color-ink-soft); font-size: 10px; white-space: nowrap; } + +@keyframes skeleton-breathe { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } } +@media (prefers-reduced-motion: reduce) { .skeleton-block { animation: none; } } -- 2.54.0 From d59576739f793445bf22973b089fe9b9b86a4bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=85=BE=E8=AE=AFWorkBuddy?= Date: Wed, 19 Aug 2026 12:00:59 +0800 Subject: [PATCH 2/6] B-44: fix empty-state retry selector in balance directory --- web/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app.js b/web/app.js index e59c3e9..4f04123 100644 --- a/web/app.js +++ b/web/app.js @@ -643,7 +643,7 @@ async function loadAdminBalances(container) { container.setAttribute("aria-busy", "false"); if (!data.items.length) { regionEmpty(container, "该区间无往来事件", "请调整统计区间后重试", ''); - container.querySelector("[data-adjust-window]")?.addEventListener("click", () => $("#balanceFrom")?.focus()); + container.querySelector("[data-adjust-window]")?.addEventListener("click", () => document.querySelector("[data-balance-form] [name='from']")?.focus()); return; } renderBalanceDirectory(container, data.items); -- 2.54.0 From f16e8aad34ce52362eeddfbe365202bb5c398596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=85=BE=E8=AE=AFWorkBuddy?= Date: Wed, 19 Aug 2026 13:28:28 +0800 Subject: [PATCH 3/6] =?UTF-8?q?B-44:=20=E8=BF=94=E4=BF=AE=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=A4=9A=E5=B8=81=E7=A7=8D=E5=AF=B9=E6=96=B9=E6=B1=87?= =?UTF-8?q?=E6=80=BB=E3=80=81=E9=95=9C=E5=83=8F=E7=A7=91=E7=9B=AE=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E3=80=81=E5=86=B2=E9=94=80=E7=8B=AC=E7=AB=8B=E7=94=9F?= =?UTF-8?q?=E6=95=88=E6=97=A5=E3=80=81=E5=88=9B=E5=BB=BA=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E6=92=A4=E9=94=80=E8=BE=B9=E7=95=8C=E4=B8=8E=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=B9=82=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.py | 13 +- src/bank_importer/manual_records.py | 137 +++++++++------ src/bank_importer/positions.py | 9 +- src/bank_importer/subjects.py | 54 +++--- tests/test_manual_records.py | 259 ++++++++++++++++++++++++++++ tests/test_positions.py | 41 +++++ tests/test_positions_api.py | 106 +++++++++++- 7 files changed, 528 insertions(+), 91 deletions(-) diff --git a/server.py b/server.py index e05f88a..3ad02b8 100644 --- a/server.py +++ b/server.py @@ -2435,6 +2435,7 @@ class AppHandler(SimpleHTTPRequestHandler): actor=user, subject_code=data.get("subject_code"), target_ledger_event_id=data.get("target_ledger_event_id"), + effective_at=data.get("effective_at"), ) except manual_records.ManualConflictError as exc: self._send_json(409, {"status": "error", "message": str(exc)}) @@ -2495,7 +2496,9 @@ class AppHandler(SimpleHTTPRequestHandler): connection, from_=from_, cutoff=cutoff, currency=currency, company_id=company_id, ) - buckets: dict[int, dict[str, object]] = {} + # Buckets are keyed by ``(counterparty, currency)``: amounts never mix + # across currencies, so one counterparty with CNY and USD yields two rows. + buckets: dict[tuple[int, str], dict[str, object]] = {} for event in events: counterparty = ( event["payee_company_id"] @@ -2507,8 +2510,9 @@ class AppHandler(SimpleHTTPRequestHandler): if int(event["payer_company_id"]) == int(company_id) else event["payer_company_name"] ) + key = (int(counterparty), event["currency"]) bucket = buckets.setdefault( - counterparty, + key, { "counterparty_company_id": counterparty, "counterparty_company_name": name, @@ -2550,7 +2554,7 @@ class AppHandler(SimpleHTTPRequestHandler): else row["payer_company_name"] ) buckets.setdefault( - counterparty, + (int(counterparty), row["currency"]), { "counterparty_company_id": counterparty, "counterparty_company_name": name, @@ -2560,8 +2564,7 @@ class AppHandler(SimpleHTTPRequestHandler): }, ) items = [] - for counterparty, bucket in sorted(buckets.items()): - cur = bucket["currency"] + for (counterparty, cur), bucket in sorted(buckets.items()): signed = bucket["signed"] unresolved = positions.unresolved_for_company( connection, company_id, cutoff, currency=cur, diff --git a/src/bank_importer/manual_records.py b/src/bank_importer/manual_records.py index 975e19f..f8eaca4 100644 --- a/src/bank_importer/manual_records.py +++ b/src/bank_importer/manual_records.py @@ -10,7 +10,7 @@ is never edited. Idempotency keys and UNIQUE claims prevent double counting. from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta, timezone from decimal import Decimal, InvalidOperation import json import sqlite3 @@ -60,6 +60,11 @@ def _validate_date(value: object, field: str) -> str: return text +def _business_today() -> str: + """Shanghai business date (the default reversal effective date).""" + return datetime.now(timezone(timedelta(hours=8))).date().isoformat() + + # --------------------------------------------------------------------------- # Submit # --------------------------------------------------------------------------- @@ -144,40 +149,56 @@ def submit( raise ManualInputError("supersedes_record_id 无效。") supersedes_id = int(supersedes_record_id) - existing = connection.execute( - "SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?", - (int(company_id), request_key), - ).fetchone() - if existing is not None: - return _record_payload(connection, existing["id"], idempotent_replay=True) - now = utc_now() began = False if not connection.in_transaction: connection.execute("BEGIN IMMEDIATE") began = True try: - cursor = connection.execute( - """ - INSERT INTO manual_records ( - company_id, counterparty_company_id, occurred_at, direction, - amount, amount_scale, currency, funding_source, bank_account_id, - personal_transit_mapping_id, related_source_row_id, - requested_subject, summary, reason, evidence_json, request_key, - supersedes_record_id, submitted_by, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - int(company_id), int(counterparty_company_id), occurred_at, direction, - amount, _scale_of(amount), currency, funding_source, bank_account_id, - mapping_id, related_row["id"] if related_row is not None else None, - requested_subject, str(summary or "") or None, - str(reason or "") or None, - json.dumps(evidence, ensure_ascii=False) if evidence else None, - request_key, supersedes_id, - actor["id"], now, - ), - ) + # Re-check inside the write transaction: concurrent identical submits + # serialize here, so a replay is found before any INSERT. + existing = connection.execute( + "SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?", + (int(company_id), request_key), + ).fetchone() + if existing is not None: + if began: + connection.commit() + return _record_payload(connection, existing["id"], idempotent_replay=True) + try: + cursor = connection.execute( + """ + INSERT INTO manual_records ( + company_id, counterparty_company_id, occurred_at, direction, + amount, amount_scale, currency, funding_source, bank_account_id, + personal_transit_mapping_id, related_source_row_id, + requested_subject, summary, reason, evidence_json, request_key, + supersedes_record_id, submitted_by, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + int(company_id), int(counterparty_company_id), occurred_at, direction, + amount, _scale_of(amount), currency, funding_source, bank_account_id, + mapping_id, related_row["id"] if related_row is not None else None, + requested_subject, str(summary or "") or None, + str(reason or "") or None, + json.dumps(evidence, ensure_ascii=False) if evidence else None, + request_key, supersedes_id, + actor["id"], now, + ), + ) + except sqlite3.IntegrityError: + # A concurrent identical submit won the race and committed first; + # surface the existing record idempotently instead of a UNIQUE 500. + if began: + connection.rollback() + existing = connection.execute( + "SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?", + (int(company_id), request_key), + ).fetchone() + if existing is not None: + return _record_payload(connection, existing["id"], idempotent_replay=True) + raise record_id = int(cursor.lastrowid) _append_decision( connection, record_id, state="pending", action="submit", @@ -279,14 +300,16 @@ def decide( actor: sqlite3.Row, subject_code: object = None, target_ledger_event_id: object = None, + effective_at: object = None, ) -> dict[str, object]: """Apply an administrator decision to a manual record. ``approve_new`` creates a confirmed ledger event; ``approve_link`` joins an existing ledger event without adding a second economic impact; ``return`` and ``exception`` never produce a balance; ``reverse`` creates an opposite - reversal event (or detaches a linked claim). Replays return the earlier - outcome via ``idempotency_key``. + reversal event (or detaches a linked claim) with an independent business + effective date — explicit ``effective_at`` or the approval business day. + Replays return the earlier outcome via ``idempotency_key``. """ reason = (reason or "").strip() if not reason: @@ -350,7 +373,10 @@ def decide( else: # reverse if current["state"] != "approved": raise ManualConflictError("只有已批准记录可以冲销。") - outcome = _reverse(connection, record, current, actor, request_key, reason) + outcome = _reverse( + connection, record, current, actor, request_key, reason, + effective_at=effective_at, + ) _store_audit(connection, record, current, action, outcome, reason, actor) except Exception: @@ -455,6 +481,7 @@ def _reverse( actor: sqlite3.Row, request_key: str | None, reason: str, + effective_at: object = None, ) -> dict[str, object]: claim = manual_source_claim(connection, record["id"]) if claim is None: @@ -464,25 +491,30 @@ def _reverse( if revision is None: raise ManualConflictError("关联的往来事件没有当前修订。") - if revision["source_kind"] == "manual" and not _event_has_other_sources( - connection, event_id, record["id"] - ): - # The manual record is the only economic source: reverse it with a new - # opposite event at an independent effective date. + if _is_manual_creation_source(revision, record["id"]): + # This record created the event (approve_new): it added the economic + # impact, so reversing it must always produce an equal-amount reversal + # event — even when other manual evidence was later linked onto the + # same event. The original impact must not survive in balances. + if effective_at is not None and str(effective_at).strip(): + effective_at = _validate_date(effective_at, "冲销生效日") + else: + effective_at = _business_today() create_reversal( connection, event_id, source_kind="manual", source_revision_token=str(record["id"]), + effective_at=effective_at, reason=reason, actor=actor, idempotency_key=request_key, rule_version="manual-record-v1", ) else: - # The manual was linked evidence on a bank event: it never added a - # second impact, so reversing detaches the claim without a reversal - # event; the bank impact stays. + # The manual was linked evidence (approve_link) on an event it never + # created: it added no second impact, so reversing detaches the claim + # and the underlying economic impact stays. connection.execute( "DELETE FROM ledger_event_manual_sources WHERE manual_record_id = ?", (record["id"],), @@ -495,17 +527,20 @@ def _reverse( return {"decision_id": decision_id, "ledger_event_id": None} -def _event_has_other_sources(connection, event_id: int, manual_record_id: int) -> bool: - bank = connection.execute( - "SELECT 1 FROM ledger_event_bank_sources WHERE ledger_event_id = ? LIMIT 1", - (event_id,), - ).fetchone() - other_manual = connection.execute( - "SELECT 1 FROM ledger_event_manual_sources " - "WHERE ledger_event_id = ? AND manual_record_id != ? LIMIT 1", - (event_id, manual_record_id), - ).fetchone() - return bank is not None or other_manual is not None +def _is_manual_creation_source(revision: sqlite3.Row, manual_record_id: int) -> bool: + """True when ``manual_record_id`` created the event via ``approve_new``. + + The creation source is recorded in the event's immutable revision + ``evidence_json``; a linked evidence record is never the creation source + and carries no second economic impact. + """ + if revision["source_kind"] != "manual": + return False + try: + evidence = json.loads(revision["evidence_json"] or "{}") + except (TypeError, ValueError): + return False + return evidence.get("manual_record_id") == manual_record_id def _store_audit( diff --git a/src/bank_importer/positions.py b/src/bank_importer/positions.py index 1d216bb..3016a3e 100644 --- a/src/bank_importer/positions.py +++ b/src/bank_importer/positions.py @@ -133,10 +133,11 @@ def _event_filters( if subject: if subject not in SUBJECTS: raise PositionInputError("科目筛选无效。") - conditions.append( - "(p.subject_code = ? OR (p.subject_code = ? AND p.subject_code = ?))" - ) - params.extend([subject, MIRROR[subject], subject]) + # The stored subject lives on one company's perspective; the viewer on + # the other side sees its mirror. Match both so mirror events are never + # dropped from the filter. + conditions.append("(p.subject_code = ? OR p.subject_code = ?)") + params.extend([subject, MIRROR[subject]]) if posting_kind: conditions.append("p.posting_kind = ?") params.append(posting_kind) diff --git a/src/bank_importer/subjects.py b/src/bank_importer/subjects.py index 3f290d4..2312af8 100644 --- a/src/bank_importer/subjects.py +++ b/src/bank_importer/subjects.py @@ -209,36 +209,42 @@ def confirm_subject( raise SubjectInputError("必须填写科目确认依据。") if subject_code not in SUBJECTS: raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。") - subject_code = subject_code - - if request_key: - existing = connection.execute( - """ - SELECT * FROM ledger_event_revisions - WHERE ledger_event_id = ? AND idempotency_key = ? - ORDER BY id LIMIT 1 - """, - (ledger_event_id, request_key), - ).fetchone() - if existing is not None: - return _revision_payload(connection, existing) - - current = current_revision(connection, ledger_event_id) - if current is None: - raise SubjectConflictError("该事件不存在或没有当前修订。") - if current["state"] != "pending_subject": - raise SubjectConflictError("只有待确认科目的事件可以确认科目。") - if expected_revision is not None and int(expected_revision) != current["revision"]: - raise SubjectConflictError("事件已发生变更,请刷新后重试。") - participants = {current["payer_company_id"], current["payee_company_id"]} - if perspective_company_id not in participants: - raise SubjectInputError("视角公司必须是事件参与方。") began = False if not connection.in_transaction: connection.execute("BEGIN IMMEDIATE") began = True try: + if request_key: + existing = connection.execute( + """ + SELECT * FROM ledger_event_revisions + WHERE ledger_event_id = ? AND idempotency_key = ? + ORDER BY id LIMIT 1 + """, + (ledger_event_id, request_key), + ).fetchone() + if existing is not None: + if began: + connection.commit() + return _revision_payload(connection, existing) + + current = current_revision(connection, ledger_event_id) + if current is None: + raise SubjectConflictError("该事件不存在或没有当前修订。") + if current["state"] != "pending_subject": + raise SubjectConflictError("只有待确认科目的事件可以确认科目。") + # ``expected_revision`` may be the revision row id (what the API/UI + # sends as ``ledger_revision_id``) or the per-event sequence number; + # both identify the exact revision the client saw. + if expected_revision is not None and int(expected_revision) not in ( + current["id"], current["revision"], + ): + raise SubjectConflictError("事件已发生变更,请刷新后重试。") + participants = {current["payer_company_id"], current["payee_company_id"]} + if perspective_company_id not in participants: + raise SubjectInputError("视角公司必须是事件参与方。") + revision_id = append_revision( connection, ledger_event_id, diff --git a/tests/test_manual_records.py b/tests/test_manual_records.py index 425bd33..da69324 100644 --- a/tests/test_manual_records.py +++ b/tests/test_manual_records.py @@ -262,6 +262,146 @@ class DecisionTests(LedgerBase): request_key="dec-x", actor=self.admin, ) + def test_reverse_uses_explicit_effective_date_across_cutoff(self) -> None: + # Reversing with an independent effective date must keep the original + # impact for cutoffs before it and net it to zero only on/after it — + # never rewrite the historical period retroactively. + from bank_importer import positions + approved = self.decide("approve_new") + event_id = approved["ledger_event_id"] + self.assertEqual("2026-01-10", self.current(event_id)["effective_at"][:10]) + + manual_records.decide( + self.connection, self.record["id"], "reverse", + reason="误录冲销", expected_decision_id=approved["decision_id"], + request_key="dec-rev-date", actor=self.admin, + effective_at="2026-06-15", + ) + reversal = self.connection.execute( + "SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'" + ).fetchone() + self.assertEqual("2026-06-15", reversal["effective_at"][:10]) + + 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("-50.00", item["result"]["signed_amount"]) + for item in after["items"]: + if item["company_id"] == self.company_a: + self.assertEqual("0.00", item["result"]["signed_amount"]) + + def test_reverse_defaults_to_approval_business_day(self) -> None: + from datetime import datetime, timedelta, timezone + from bank_importer import positions + approved = self.decide("approve_new") + manual_records.decide( + self.connection, self.record["id"], "reverse", + reason="误录冲销", expected_decision_id=approved["decision_id"], + request_key="dec-rev-default", actor=self.admin, + ) + today = datetime.now(timezone(timedelta(hours=8))).date().isoformat() + reversal = self.connection.execute( + "SELECT * FROM ledger_event_revisions WHERE posting_kind = 'reversal'" + ).fetchone() + self.assertEqual(today, reversal["effective_at"][:10]) + + # The day before the approval business day keeps the original impact. + before = positions.company_balances( + self.connection, from_="2026-01-01", + cutoff=(datetime.fromisoformat(today) - timedelta(days=1)).date().isoformat(), + ) + for item in before["items"]: + if item["company_id"] == self.company_a: + self.assertEqual("-50.00", item["result"]["signed_amount"]) + + def test_reverse_of_creation_source_with_later_linked_evidence(self) -> None: + # approve_new created the event; a second manual record later links + # onto it. Reversing the creation source must still create an + # equal-amount reversal — the original economic impact must not survive + # just because other evidence was attached later. + approved = self.decide("approve_new") + event_id = approved["ledger_event_id"] + + linked = manual_records.submit( + self.connection, company_id=self.company_a, + counterparty_company_id=self.company_b, + occurred_at="2026-01-12T09:00:00", direction="incoming", + amount="50.00", currency="CNY", funding_source="other", + requested_subject="receivable", request_key="mr-link-ev", + actor=self.admin, + ) + manual_records.decide( + self.connection, linked["id"], "approve_link", + reason="同源补充证据", expected_decision_id=linked["decision_id"], + request_key="dec-link-ev", actor=self.admin, + target_ledger_event_id=event_id, + ) + self.assertEqual(1, len(self.position_events())) + + reversed_outcome = manual_records.decide( + self.connection, self.record["id"], "reverse", + reason="误录冲销", expected_decision_id=approved["decision_id"], + request_key="dec-rev-create", 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(Decimal(original["amount"]), Decimal(reversal["amount"])) + # The later linked evidence stays attached to the event. + links = self.connection.execute( + "SELECT * FROM ledger_event_manual_sources ORDER BY manual_record_id" + ).fetchall() + self.assertEqual(2, len(links)) + + def test_reverse_of_linked_record_after_creation_reversal_detaches_only(self) -> None: + # M1 creates the event, M2 links; after M1's reversal created the + # offset, reversing the linked M2 must only detach, never add a second + # reversal event. + approved = self.decide("approve_new") + event_id = approved["ledger_event_id"] + linked = manual_records.submit( + self.connection, company_id=self.company_a, + counterparty_company_id=self.company_b, + occurred_at="2026-01-12T09:00:00", direction="incoming", + amount="50.00", currency="CNY", funding_source="other", + requested_subject="receivable", request_key="mr-link-ev2", + actor=self.admin, + ) + linked_approved = manual_records.decide( + self.connection, linked["id"], "approve_link", + reason="同源补充证据", expected_decision_id=linked["decision_id"], + request_key="dec-link-ev2", actor=self.admin, + target_ledger_event_id=event_id, + ) + manual_records.decide( + self.connection, self.record["id"], "reverse", + reason="误录冲销", expected_decision_id=approved["decision_id"], + request_key="dec-rev-create2", actor=self.admin, + ) + manual_records.decide( + self.connection, linked["id"], "reverse", + reason="撤销关联证据", expected_decision_id=linked_approved["decision_id"], + request_key="dec-rev-link2", actor=self.admin, + ) + reversals = self.connection.execute( + "SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'" + ).fetchone()["n"] + self.assertEqual(1, reversals) + links = self.connection.execute( + "SELECT * FROM ledger_event_manual_sources" + ).fetchall() + self.assertEqual(1, len(links)) + class DedupAndConcurrencyTests(LedgerBase): def test_duplicate_manual_record_pairing_candidate(self) -> None: @@ -343,6 +483,125 @@ class DedupAndConcurrencyTests(LedgerBase): ).fetchone()["n"] self.assertEqual(1, events) + def test_concurrent_submit_same_request_key_is_idempotent(self) -> None: + import sqlite3 + + results: list[dict] = [] + errors: list[Exception] = [] + + def worker(thread_key: str) -> None: + try: + connection = sqlite3.connect(self.db_path, timeout=30) + connection.row_factory = sqlite3.Row + try: + payload = manual_records.submit( + 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-submit", actor=self.admin, + ) + results.append(payload) + 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() + + self.assertEqual([], errors) + rows = self.connection.execute( + "SELECT COUNT(*) AS n FROM manual_records" + ).fetchone()["n"] + self.assertEqual(1, rows) + self.assertEqual(1, len({payload["id"] for payload in results})) + decisions = self.connection.execute( + "SELECT COUNT(*) AS n FROM manual_record_decisions" + ).fetchone()["n"] + self.assertEqual(1, decisions) + + def test_concurrent_confirm_subject_same_key_creates_single_revision(self) -> None: + import sqlite3 + + self.pair(self.company_a, self.company_b, "100.00") + from bank_importer import ledger_events + ledger_events.reconcile_bank_events(self.connection, actor=self.admin) + event_id = self.ledger_events()[0]["id"] + + outcomes: list[dict] = [] + errors: list[Exception] = [] + + def worker(thread_key: str) -> None: + try: + connection = sqlite3.connect(self.db_path, timeout=30) + connection.row_factory = sqlite3.Row + try: + payload = subjects.confirm_subject( + connection, event_id, + perspective_company_id=self.company_a, + subject_code="other_receivable", reason="并发确认", + expected_revision=1, request_key="conc-subj-key", + actor=self.admin, + ) + outcomes.append(payload) + 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() + + self.assertEqual([], errors) + confirmed = self.connection.execute( + "SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE state = 'confirmed'" + ).fetchone()["n"] + self.assertEqual(1, confirmed) + self.assertEqual(1, len({payload["revision_id"] for payload in outcomes})) + + def test_repeated_confirm_same_key_appends_no_noise_revision(self) -> None: + self.pair(self.company_a, self.company_b, "100.00") + from bank_importer import ledger_events + ledger_events.reconcile_bank_events(self.connection, actor=self.admin) + event_id = self.ledger_events()[0]["id"] + first = subjects.confirm_subject( + self.connection, event_id, + perspective_company_id=self.company_a, subject_code="other_receivable", + reason="确认", expected_revision=1, request_key="noise-key", + actor=self.admin, + ) + second = subjects.confirm_subject( + self.connection, event_id, + perspective_company_id=self.company_a, subject_code="other_receivable", + reason="重复确认", expected_revision=1, request_key="noise-key", + actor=self.admin, + ) + self.assertEqual(first["revision_id"], second["revision_id"]) + revisions = self.connection.execute( + "SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE ledger_event_id = ?", + (event_id,), + ).fetchone()["n"] + # One pending_subject + one confirmed; the replay appended nothing. + self.assertEqual(2, revisions) + confirmed = self.connection.execute( + "SELECT COUNT(*) AS n FROM ledger_event_revisions " + "WHERE ledger_event_id = ? AND state = 'confirmed'", + (event_id,), + ).fetchone()["n"] + self.assertEqual(1, confirmed) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_positions.py b/tests/test_positions.py index 847b74e..54cbf56 100644 --- a/tests/test_positions.py +++ b/tests/test_positions.py @@ -267,6 +267,47 @@ class UnresolvedTests(LedgerBase): class PaginationTests(LedgerBase): + def test_subject_filter_matches_stored_and_mirror_subjects(self) -> None: + # A books "receivable" from its own perspective; B sees the mirror + # "payable". Filtering by B's mirror subject must still find the event. + 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, "receivable", "k-mirror", + self.admin) + + stored = positions.list_events( + self.connection, from_="2026-01-01", cutoff="2026-07-31", + subject="receivable", + ) + self.assertTrue(any( + item["ledger_event_id"] == event_id for item in stored["items"] + )) + # Company B filters by its own perspective: the stored "receivable" is + # the mirror of "payable", so it must be included. + mirror = positions.list_events( + self.connection, from_="2026-01-01", cutoff="2026-07-31", + subject="payable", viewer_company_id=self.company_b, + ) + self.assertTrue(any( + item["ledger_event_id"] == event_id for item in mirror["items"] + ), mirror) + for item in mirror["items"]: + if item["ledger_event_id"] == event_id: + self.assertEqual("payable", item["own_subject"]) + + def test_subject_filter_without_match_returns_empty(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"] + confirm(self.connection, event_id, self.company_a, "receivable", "k-none", + self.admin) + other = positions.list_events( + self.connection, from_="2026-01-01", cutoff="2026-07-31", + subject="other_receivable", + ) + self.assertEqual([], other["items"]) + 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" diff --git a/tests/test_positions_api.py b/tests/test_positions_api.py index e872ffc..6ce2cfc 100644 --- a/tests/test_positions_api.py +++ b/tests/test_positions_api.py @@ -49,12 +49,14 @@ def workbook_bytes(rows) -> bytes: 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 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"): - return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", 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): @@ -167,15 +169,16 @@ class IntercompanyApiTests(unittest.TestCase): assert status == 200, data return batch_id - def _fresh_pair(self, amount: str = "100.00", at: str = "2026-01-05 10:00:00") -> dict: + 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)], + [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:"))], + [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" @@ -458,6 +461,95 @@ class IntercompanyApiTests(unittest.TestCase): ) 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"]) + if __name__ == "__main__": unittest.main() -- 2.54.0 From 1fe073066e086cc7f76ee7092b300d3b9b8c1bd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=85=BE=E8=AE=AFWorkBuddy?= Date: Thu, 20 Aug 2026 00:37:27 +0800 Subject: [PATCH 4/6] =?UTF-8?q?B-44:=20=E8=BF=94=E4=BF=AE=E2=80=94?= =?UTF-8?q?=E2=80=94=E4=BD=99=E9=A2=9D=E7=9B=AE=E5=BD=95=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E3=80=81=E5=85=AB=E5=88=97=E4=BA=8B=E4=BB=B6=E8=A1=A8=E3=80=81?= =?UTF-8?q?=E6=8A=BD=E5=B1=89=E9=94=AE=E7=9B=98=E4=B8=8E=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E4=B8=89=E9=A1=B9=E5=86=B3=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- server.py | 32 +- src/bank_importer/manual_records.py | 7 +- src/bank_importer/positions.py | 105 ++++- src/bank_importer/subjects.py | 90 ++++ tests/b44_ui_check.js | 45 ++ tests/test_b44_frontend.py | 94 ++++ tests/test_positions.py | 66 +++ tests/test_positions_api.py | 39 ++ web/admin.html | 10 +- web/app.js | 693 +++++++++++++++++++--------- web/company.html | 2 +- web/styles.css | 101 +++- 12 files changed, 1018 insertions(+), 266 deletions(-) create mode 100644 tests/b44_ui_check.js create mode 100644 tests/test_b44_frontend.py diff --git a/server.py b/server.py index 3ad02b8..71110e9 100644 --- a/server.py +++ b/server.py @@ -2305,16 +2305,28 @@ class AppHandler(SimpleHTTPRequestHandler): ) return try: - payload = subjects.confirm_subject( - connection, - event_id, - perspective_company_id=perspective_company_id, - subject_code=str(data.get("subject_code") or ""), - reason=str(data.get("reason") or ""), - expected_revision=expected_revision, - request_key=str(data.get("request_key") or "") or None, - actor=user, - ) + action = str(data.get("action") or "confirm") + if action in ("return", "exception"): + payload = subjects.park_subject( + connection, + event_id, + disposition=action, + reason=str(data.get("reason") or ""), + expected_revision=expected_revision, + request_key=str(data.get("request_key") or "") or None, + actor=user, + ) + else: + payload = subjects.confirm_subject( + connection, + event_id, + perspective_company_id=perspective_company_id, + subject_code=str(data.get("subject_code") or ""), + reason=str(data.get("reason") or ""), + expected_revision=expected_revision, + request_key=str(data.get("request_key") or "") or None, + actor=user, + ) except subjects.SubjectConflictError as exc: self._send_json(409, {"status": "error", "message": str(exc)}) return diff --git a/src/bank_importer/manual_records.py b/src/bank_importer/manual_records.py index f8eaca4..bb81059 100644 --- a/src/bank_importer/manual_records.py +++ b/src/bank_importer/manual_records.py @@ -683,12 +683,14 @@ def list_records( SELECT m.*, d.id AS decision_id, d.state AS state, d.revision AS decision_revision, d.action AS action, d.reason AS decision_reason, d.actor_username AS decision_actor, d.created_at AS decision_at, - c.name AS company_name, cc.name AS counterparty_company_name + c.name AS company_name, cc.name AS counterparty_company_name, + u.username AS submitted_by_username FROM manual_records m JOIN current_manual_record_decisions c ON c.record_id = m.id JOIN manual_record_decisions d ON d.id = c.decision_id LEFT JOIN companies c ON c.id = m.company_id LEFT JOIN companies cc ON cc.id = m.counterparty_company_id + LEFT JOIN users u ON u.id = m.submitted_by {where} ORDER BY m.id DESC LIMIT ? @@ -752,6 +754,7 @@ def _record_payload(connection: sqlite3.Connection, record_id: int, *, idempoten def _row_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, object]: + evidence = json.loads(row["evidence_json"] or "{}") if row["evidence_json"] else {} return { "id": row["id"], "company_id": row["company_id"], @@ -772,6 +775,8 @@ def _row_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, "request_key": row["request_key"], "supersedes_record_id": row["supersedes_record_id"], "submitted_by": row["submitted_by"], + "submitted_by_username": row["submitted_by_username"] if "submitted_by_username" in row.keys() else None, + "attachment_name": evidence.get("attachment_name"), "created_at": row["created_at"], "state": row["state"], "decision_id": row["decision_id"], diff --git a/src/bank_importer/positions.py b/src/bank_importer/positions.py index 3016a3e..9c484b9 100644 --- a/src/bank_importer/positions.py +++ b/src/bank_importer/positions.py @@ -832,9 +832,106 @@ def event_payload( if int(event["payer_company_id"]) == int(viewer_company_id) else event["payer_company_name"] ) + item.update(_event_line_display(connection, event, viewer_company_id)) return item +_REPAY_MARKERS = ("还款", "归还借款", "归还往来款") + + +def bank_short_name(name: str | None) -> str: + text = str(name or "").strip() + if text.startswith("中国"): + text = text[2:] + if text.endswith("银行"): + text = text[:-2] + return text or "银行" + + +def _account_chip(visibility: str, bank_name: str | None, account: str | None) -> dict[str, object]: + if visibility == "missing": + return {"visibility": "missing", "label": None} + if visibility == "masked": + return {"visibility": "masked", "label": "按对方授权不可见"} + number = str(account or "") + tail = number[-4:] if number else "" + short = bank_short_name(bank_name) + label = f"{short} {tail}".strip() if tail else short + return {"visibility": "visible", "label": label} + + +def _event_line_display( + connection: sqlite3.Connection, + event: sqlite3.Row, + viewer_company_id: int | None, +) -> dict[str, object]: + """Account chips, summary and repayment flag for the event table.""" + missing = _account_chip("missing", None, None) + payer_chip, payee_chip = missing, missing + summary = None + texts: list[str] = [] + sides = _event_source_sides(connection, event["ledger_event_id"]) + for side in sides: + company_id = side["company_id"] + own = viewer_company_id is None or int(company_id) == int(viewer_company_id) + visibility = "visible" if own else "masked" + chip = _account_chip(visibility, side.get("bank_name"), side.get("own_account")) + if int(company_id) == int(event["payer_company_id"]): + payer_chip = chip + elif int(company_id) == int(event["payee_company_id"]): + payee_chip = chip + if own or viewer_company_id is None: + if side.get("summary"): + texts.append(str(side["summary"])) + if side.get("reason"): + texts.append(str(side["reason"])) + if texts: + summary = texts[0] + blob = " ".join(texts) + is_repayment = any(marker in blob for marker in _REPAY_MARKERS) + return { + "payer_account": payer_chip, + "payee_account": payee_chip, + "summary": summary, + "is_repayment": is_repayment, + } + + +def _event_source_sides( + connection: sqlite3.Connection, ledger_event_id: int +) -> list[dict[str, object]]: + rows = connection.execute( + """ + SELECT b.company_id AS company_id, s.bank_name AS bank_name, + r.own_account AS own_account, r.summary AS summary, + r.purpose AS purpose, NULL AS reason + FROM ledger_event_bank_sources bs + JOIN current_transfer_decisions cur ON cur.event_id = bs.bank_event_id + JOIN transfer_decision_observations o ON o.decision_id = cur.decision_id + JOIN source_rows r ON r.id = o.source_row_id + JOIN sheet_batches s ON s.id = r.sheet_batch_id + JOIN import_batches b ON b.id = s.import_batch_id + WHERE bs.ledger_event_id = ? + """, + (ledger_event_id,), + ).fetchall() + if rows: + return [_row(row) for row in rows] + manuals = connection.execute( + """ + SELECT m.company_id AS company_id, ba.bank_name AS bank_name, + ba.account_number AS own_account, m.summary AS summary, + NULL AS purpose, m.reason AS reason + FROM ledger_event_manual_sources ms + JOIN manual_records m ON m.id = ms.manual_record_id + LEFT JOIN bank_accounts ba ON ba.id = m.bank_account_id + WHERE ms.ledger_event_id = ? + """, + (ledger_event_id,), + ).fetchall() + return [_row(row) for row in manuals] + + _DETAIL_SELECT = """ SELECT p.ledger_event_id, p.id AS ledger_revision_id, p.effective_at, p.amount, p.amount_scale, p.currency, p.payer_company_id, p.payee_company_id, @@ -939,7 +1036,7 @@ def subject_review_queue( SELECT r.ledger_event_id, r.effective_at, r.amount, r.currency, r.payer_company_id, r.payee_company_id, cpayer.name AS payer_company_name, cpayee.name AS payee_company_name, - r.id AS revision_id + r.id AS revision_id, r.evidence_json AS evidence_json FROM current_ledger_event_revisions cur JOIN ledger_event_revisions r ON r.id = cur.revision_id JOIN companies cpayer ON cpayer.id = r.payer_company_id @@ -957,6 +1054,9 @@ def subject_review_queue( cursor_date, cursor_id = None, None filtered = [] for row in rows: + evidence = json.loads(row["evidence_json"] or "{}") if row["evidence_json"] else {} + if evidence.get("admin_disposition") == "exception": + continue if cursor_date is not None: if row["effective_at"] > cursor_date or ( row["effective_at"] == cursor_date and row["ledger_event_id"] >= cursor_id @@ -982,6 +1082,7 @@ def subject_review_queue( """, (row["ledger_event_id"],), ).fetchall() + display = _event_line_display(connection, row, None) items.append( { "ledger_event_id": row["ledger_event_id"], @@ -993,6 +1094,8 @@ def subject_review_queue( "payer_company_name": row["payer_company_name"], "payee_company_id": row["payee_company_id"], "payee_company_name": row["payee_company_name"], + "summary": display.get("summary"), + "is_repayment": display.get("is_repayment"), "suggestions": [ { "suggested_perspective_company_id": sug["suggested_perspective_company_id"], diff --git a/src/bank_importer/subjects.py b/src/bank_importer/subjects.py index 2312af8..b891331 100644 --- a/src/bank_importer/subjects.py +++ b/src/bank_importer/subjects.py @@ -281,6 +281,96 @@ def confirm_subject( return _revision_payload(connection, row) +def park_subject( + connection: sqlite3.Connection, + ledger_event_id: int, + *, + disposition: str, + reason: str, + expected_revision: int | None, + request_key: str | None, + actor: sqlite3.Row, +) -> dict[str, object]: + """Record 退回/转异常 without confirming a statutory subject. + + The event stays ``pending_subject`` so it never enters confirmed balances. + ``exception`` is hidden from the active review queue; ``return`` remains + visible so the company can supplement materials. + """ + from .ledger_events import append_revision, current_revision + + if disposition not in ("return", "exception"): + raise SubjectInputError("科目处理只能是退回或转异常。") + reason = (reason or "").strip() + if not reason: + raise SubjectInputError("必须填写处理依据。") + + began = False + if not connection.in_transaction: + connection.execute("BEGIN IMMEDIATE") + began = True + try: + if request_key: + existing = connection.execute( + """ + SELECT * FROM ledger_event_revisions + WHERE ledger_event_id = ? AND idempotency_key = ? + ORDER BY id LIMIT 1 + """, + (ledger_event_id, request_key), + ).fetchone() + if existing is not None: + if began: + connection.commit() + return _revision_payload(connection, existing) + + current = current_revision(connection, ledger_event_id) + if current is None: + raise SubjectConflictError("该事件不存在或没有当前修订。") + if current["state"] != "pending_subject": + raise SubjectConflictError("只有待确认科目的事件可以退回或转异常。") + if expected_revision is not None and int(expected_revision) not in ( + current["id"], current["revision"], + ): + raise SubjectConflictError("事件已发生变更,请刷新后重试。") + evidence = json.loads(current["evidence_json"] or "{}") if current["evidence_json"] else {} + evidence["admin_disposition"] = disposition + revision_id = append_revision( + connection, + ledger_event_id, + state="pending_subject", + effective_at=current["effective_at"], + amount=current["amount"], + currency=current["currency"], + payer_company_id=current["payer_company_id"], + payee_company_id=current["payee_company_id"], + perspective_company_id=None, + subject_code=None, + source_kind=current["source_kind"], + source_revision_token=current["source_revision_token"], + posting_kind=current["posting_kind"], + reverses_ledger_event_id=current["reverses_ledger_event_id"], + adjusts_ledger_event_id=current["adjusts_ledger_event_id"], + rule_version=current["rule_version"] or SUBJECT_RULE_VERSION, + evidence_json=json.dumps(evidence, ensure_ascii=False), + idempotency_key=request_key, + actor=actor, + reason=reason, + supersedes_revision_id=current["id"], + ) + row = connection.execute( + "SELECT * FROM ledger_event_revisions WHERE id = ?", (revision_id,) + ).fetchone() + except Exception: + if began: + connection.rollback() + raise + else: + if began: + connection.commit() + return _revision_payload(connection, row) + + def _revision_payload(connection: sqlite3.Connection, revision: sqlite3.Row) -> dict[str, object]: company = connection.execute( "SELECT name FROM companies WHERE id = ?", (revision["perspective_company_id"],) diff --git a/tests/b44_ui_check.js b/tests/b44_ui_check.js new file mode 100644 index 0000000..dd658a6 --- /dev/null +++ b/tests/b44_ui_check.js @@ -0,0 +1,45 @@ +"use strict"; + +const path = require("path"); +const assert = require("assert"); +const ui = require(path.join(__dirname, "..", "web", "app.js")); + +assert.strictEqual(ui.fmtAbsMoney(-1280), "1,280.00"); +assert.strictEqual(ui.fmtAbsMoney(1280), "1,280.00"); +assert.strictEqual(ui.fmtAbsMoney("60.5"), "60.50"); + +assert.strictEqual(ui.eventIsNegative({ posting_kind: "reversal" }), true); +assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: true }), true); +assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: false }), false); + +assert.strictEqual(ui.cycleTab(0, 4, false), 1); +assert.strictEqual(ui.cycleTab(3, 4, false), 0); +assert.strictEqual(ui.cycleTab(0, 4, true), 3); +assert.strictEqual(ui.cycleTab(2, 5, true), 1); +assert.strictEqual(ui.cycleTab(0, 0, false), 0); + +assert.strictEqual(ui.drawerEscAction(1), "close"); +assert.strictEqual(ui.drawerEscAction(0), "close"); +assert.strictEqual(ui.drawerEscAction(2), "back"); +assert.strictEqual(ui.drawerEscAction(3), "back"); + +const abs = ui.amountWithCurrency(1280, "CNY"); +assert.ok(abs.includes("CNY")); +assert.ok(abs.includes("1,280.00")); +assert.ok(!abs.includes("+")); +assert.ok(!abs.includes("−")); + +const repay = ui.amountWithCurrency(3200000, "CNY", { signed: true, negative: true }); +assert.ok(repay.includes("−")); +assert.ok(!repay.includes("+")); +assert.ok(repay.includes("3,200,000.00")); + +assert.strictEqual(ui.resultDirection(10).label, "应收"); +assert.strictEqual(ui.resultDirection(-10).label, "应付"); +assert.strictEqual(ui.resultDirection(0).label, "持平"); + +assert.ok(ui.accountCell({ visibility: "visible", label: "中信 5316" }).includes("中信 5316")); +assert.ok(ui.accountCell({ visibility: "masked" }).includes("按对方授权不可见")); +assert.ok(ui.accountCell({ visibility: "missing" }).includes("源行缺失")); + +console.log("b44_ui_check ok"); diff --git a/tests/test_b44_frontend.py b/tests/test_b44_frontend.py new file mode 100644 index 0000000..516b1cb --- /dev/null +++ b/tests/test_b44_frontend.py @@ -0,0 +1,94 @@ +"""Static + Node checks for the B-44 visual rework. + +Covers unique ids, nav copy, drawer/directory breakpoints, eight-column +event table, and the exported keyboard/amount helpers. +""" + +from __future__ import annotations + +from pathlib import Path +import re +import subprocess +import unittest + +ROOT = Path(__file__).resolve().parents[1] +WEB = ROOT / "web" + + +class B44FrontendContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.admin = (WEB / "admin.html").read_text(encoding="utf-8") + cls.company = (WEB / "company.html").read_text(encoding="utf-8") + cls.css = (WEB / "styles.css").read_text(encoding="utf-8") + cls.js = (WEB / "app.js").read_text(encoding="utf-8") + + def test_admin_ids_are_unique(self) -> None: + self.assertEqual(1, self.admin.count('id="companyLedgers"')) + self.assertEqual(1, self.admin.count('id="balanceLedgers"')) + self.assertIn('data-view="pair"', self.admin) + self.assertRegex(self.admin, r'data-view="pair"[^>]*>[\s\S]*?往来查询') + self.assertIn("

往来查询

", self.admin) + self.assertIn("转为异常后,该记录暂不纳入余额计算", self.admin) + self.assertIn('id="auditExceptionNote"', self.admin) + + def test_company_balance_groups_and_nav(self) -> None: + self.assertIn('id="companyBalanceGroups"', self.company) + self.assertNotIn('id="companyBalanceLine"', self.company) + self.assertRegex(self.company, r'data-view="balances"[^>]*>[\s\S]*?往来余额') + self.assertIn("

往来余额

", self.company) + + def test_css_directory_and_drawer_breakpoints(self) -> None: + self.assertIn("@media (max-width: 768px)", self.css) + self.assertIn("@media (max-width: 375px)", self.css) + self.assertIn("@media (max-width: 1179px)", self.css) + self.assertIn("@media (max-width: 767px)", self.css) + self.assertIn(".drawer {", self.css) + self.assertIn("width: 640px", self.css) + self.assertIn(".drawer { width: 480px; }", self.css) + self.assertIn(".drawer { width: 100%; }", self.css) + self.assertIn(".drawer .pair-balance-line.is-six", self.css) + self.assertIn("repeat(3, 1fr)", self.css) + self.assertIn(".drawer .event-table { min-width: 860px; }", self.css) + self.assertIn(".company-row.is-balance-counterparty", self.css) + self.assertIn(".ledger-hide-md", self.css) + + def test_js_selectors_and_event_table(self) -> None: + self.assertIn('loadAdminBalances($("#balanceLedgers"))', self.js) + self.assertNotIn('loadAdminBalances($("#companyLedgers"))', self.js) + self.assertIn("交易日期", self.js) + self.assertIn("本方账户", self.js) + self.assertIn("对方账户", self.js) + self.assertIn("摘要", self.js) + self.assertIn("匹配状态", self.js) + self.assertIn("function eventTableHead()", self.js) + self.assertIn("colspan=\"8\"", self.js) + self.assertIn("function cycleTab(", self.js) + self.assertIn("function drawerEscAction(", self.js) + self.assertIn("function eventIsNegative(", self.js) + self.assertIn("fmtAbsMoney", self.js) + self.assertIn("is-balance-counterparty", self.js) + heads = re.search( + r"function eventTableHead\(\) \{\s*return `([^`]+)`", + self.js, + ) + self.assertIsNotNone(heads) + markup = heads.group(1) + self.assertNotIn("来源", markup) + for label in ("交易日期", "方向", "科目", "本方账户", "对方账户", "摘要", "匹配状态", "金额"): + self.assertIn(label, markup) + self.assertEqual(8, len(re.findall(r" None: + result = subprocess.run( + ["node", str(ROOT / "tests" / "b44_ui_check.js")], + capture_output=True, + text=True, + cwd=str(ROOT), + ) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("b44_ui_check ok", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_positions.py b/tests/test_positions.py index 54cbf56..3fb42f5 100644 --- a/tests/test_positions.py +++ b/tests/test_positions.py @@ -359,6 +359,72 @@ class PaginationTests(LedgerBase): {(self.company_a, "CNY"), (self.company_b, "CNY")}, seen ) + def test_event_payload_account_chip_and_repayment_flag(self) -> None: + self.pair( + self.company_a, self.company_b, "40.00", + summary="归还往来款", purpose="还款", + ) + ledger_events.reconcile_bank_events(self.connection, actor=self.admin) + event_id = int(self.ledger_events()[0]["id"]) + detail = positions.event_detail(self.connection, event_id) + event = detail["event"] + self.assertEqual("visible", event["payer_account"]["visibility"]) + self.assertEqual("测试 0001", event["payer_account"]["label"]) + self.assertEqual("visible", event["payee_account"]["visibility"]) + self.assertEqual("测试 0002", event["payee_account"]["label"]) + self.assertEqual("归还往来款", event["summary"]) + self.assertTrue(event["is_repayment"]) + + company_view = positions.event_payload( + self.connection, + self.connection.execute( + positions._DETAIL_SELECT + " WHERE p.ledger_event_id = ?", + (event_id,), + ).fetchone(), + viewer_company_id=self.company_a, + ) + self.assertEqual("visible", company_view["payer_account"]["visibility"]) + self.assertEqual("masked", company_view["payee_account"]["visibility"]) + self.assertEqual("按对方授权不可见", company_view["payee_account"]["label"]) + + def test_park_subject_exception_hides_from_review_queue(self) -> None: + self.pair(self.company_a, self.company_b, "100.00") + ledger_events.reconcile_bank_events(self.connection, actor=self.admin) + event_id = int(self.ledger_events()[0]["id"]) + current = self.current(event_id) + subjects.park_subject( + self.connection, event_id, + disposition="exception", reason="转异常核查", + expected_revision=current["id"], request_key="park-exc-1", + actor=self.admin, + ) + queue = positions.subject_review_queue( + self.connection, from_="2026-01-01", cutoff="2026-07-31", + ) + self.assertFalse( + any(item["ledger_event_id"] == event_id for item in queue["items"]) + ) + revision = self.current(event_id) + self.assertEqual("pending_subject", revision["state"]) + + def test_park_subject_return_stays_in_review_queue(self) -> None: + self.pair(self.company_a, self.company_b, "80.00") + ledger_events.reconcile_bank_events(self.connection, actor=self.admin) + event_id = int(self.ledger_events()[0]["id"]) + current = self.current(event_id) + subjects.park_subject( + self.connection, event_id, + disposition="return", reason="退回补充摘要", + expected_revision=current["id"], request_key="park-ret-1", + actor=self.admin, + ) + queue = positions.subject_review_queue( + self.connection, from_="2026-01-01", cutoff="2026-07-31", + ) + self.assertTrue( + any(item["ledger_event_id"] == event_id for item in queue["items"]) + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_positions_api.py b/tests/test_positions_api.py index 6ce2cfc..58ba707 100644 --- a/tests/test_positions_api.py +++ b/tests/test_positions_api.py @@ -550,6 +550,45 @@ class IntercompanyApiTests(unittest.TestCase): 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() diff --git a/web/admin.html b/web/admin.html index 53bb995..3c84c21 100644 --- a/web/admin.html +++ b/web/admin.html @@ -22,7 +22,7 @@
金牛集团总账管理端
-

往来余额

公司间往来余额目录、公司对明细与逐层追溯

+

往来查询

公司间往来余额目录、公司对明细与逐层追溯

期初不可用 · 仅显示期间净变动
@@ -160,8 +160,8 @@

公司余额目录

每行余额都附带截止日、期初状态、本期借贷、结果与未决金额

-
公司借方合计贷方合计期末结果未决截止日
-
+
公司借方合计贷方合计期末结果未决截止日
+
@@ -219,7 +219,7 @@

审核事项

原始证据与处理决定将一并留痕

-
+
diff --git a/web/app.js b/web/app.js index 4f04123..bf27241 100644 --- a/web/app.js +++ b/web/app.js @@ -1,9 +1,17 @@ -const $ = (selector, scope = document) => scope.querySelector(selector); -const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)]; +const $ = (selector, scope) => { + const root = scope ?? (typeof document !== "undefined" ? document : null); + return root ? root.querySelector(selector) : null; +}; +const $$ = (selector, scope) => { + const root = scope ?? (typeof document !== "undefined" ? document : null); + return root ? [...root.querySelectorAll(selector)] : []; +}; -const portal = document.body.dataset.portal || "entry"; +const portal = (typeof document !== "undefined" && document.body) + ? (document.body.dataset.portal || "entry") + : "entry"; const viewNames = portal === "admin" - ? { dashboard: "管理总览", pair: "往来余额", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" } + ? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" } : { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", balances: "往来余额", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" }; const storageKeys = { @@ -23,7 +31,9 @@ const state = { parseResult: null, }; -const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); +const motionQuery = (typeof window !== "undefined" && window.matchMedia) + ? window.matchMedia("(prefers-reduced-motion: reduce)") + : { matches: true, addEventListener() {}, removeEventListener() {} }; function animateView(view, { initial = false } = {}) { if (!view || motionQuery.matches || typeof view.animate !== "function") return; @@ -493,6 +503,65 @@ function fmtMoney(value) { return Number.isFinite(num) ? num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"; } +function fmtAbsMoney(value) { + const num = Number(value); + return Number.isFinite(num) ? Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"; +} + +function homeCrumb() { + return portal === "admin" ? "往来查询" : "往来余额"; +} + +function resultDirection(signedOrDirection) { + if (signedOrDirection === "receivable" || Number(signedOrDirection) > 0) return { className: "success", label: "应收" }; + if (signedOrDirection === "payable" || Number(signedOrDirection) < 0) return { className: "danger", label: "应付" }; + return { className: "neutral", label: "持平" }; +} + +function eventIsNegative(event) { + return event.posting_kind === "reversal" || Boolean(event.is_repayment); +} + +function amountWithCurrency(value, currency, { signed = false, negative = false } = {}) { + const abs = fmtAbsMoney(value); + const sign = (signed && (negative || Number(value) < 0)) ? "−" : ""; + const code = currency ? `${esc(currency)}` : ""; + return `${code}${sign}${abs}`; +} + +function accountCell(chip) { + if (!chip || chip.visibility === "missing") return '源行缺失'; + if (chip.visibility === "masked") return `${esc(chip.label || "按对方授权不可见")}`; + return ``; +} + +function pickAccount(event, companyId, side) { + const isPayer = Number(event.payer_company_id) === Number(companyId); + if (side === "own") return isPayer ? event.payer_account : event.payee_account; + return isPayer ? event.payee_account : event.payer_account; +} + +function eventSummaryText(event) { + const text = String(event.summary || "").trim(); + if (text) return text; + const reason = String(event.reason || "").trim(); + return reason ? reason.split(/\r?\n/)[0] : "—"; +} + +function sixLineHtml({ currency, debit, credit, signed, resultLabel, unresolved, cutoff, conservationNote }) { + const direction = resultDirection(signed); + const unresolvedBlock = unresolved && "html" in (unresolved || {}) ? unresolved : unresolvedText(unresolved); + return ` +
+
期初余额期初不可用B-45 前无可靠期初
+
本期借方${amountWithCurrency(debit, currency)}
+
本期贷方${amountWithCurrency(credit, currency)}
+
期末结果${direction.label}${amountWithCurrency(signed, currency)}${esc(resultLabel || "期间净变动")}
+
未决金额${amountWithCurrency(unresolvedBlock.gross ?? unresolved?.gross_amount ?? 0, currency)}${unresolvedBlock.count ?? unresolved?.count ?? 0} 笔
+
截止日${fmtDate(cutoff)}${esc(conservationNote || "按币种独立展示")}
+
`; +} + function fmtDate(iso) { const text = String(iso || ""); return text.slice(0, 10).replace(/-/g, "."); @@ -531,46 +600,47 @@ function postingLabel(event) { function unresolvedText(unresolved) { const gross = Number(unresolved?.gross_amount || 0); const count = unresolved?.count || 0; - if (!count) return { html: '未决 0.00', active: false }; + if (!count) return { html: '未决 0.00', active: false, gross: 0, count: 0 }; const reasons = Object.entries(unresolved.by_reason || {}) - .map(([key, value]) => `${b44.reasonLabels[key] || key} ${fmtMoney(value.gross_amount)} · ${value.count} 笔`) + .map(([key, value]) => `${b44.reasonLabels[key] || key} ${fmtAbsMoney(value.gross_amount)} · ${value.count} 笔`) .join(";"); return { - html: `未决 ${fmtMoney(gross)} · ${count} 笔${esc(reasons)}`, + html: `未决 ${fmtAbsMoney(gross)} · ${count} 笔${esc(reasons)}`, active: true, + gross, + count, }; } +const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +function drawerFocusables(root) { + return [...root.querySelectorAll(FOCUSABLE)].filter((node) => !node.hasAttribute("disabled") && node.getAttribute("aria-hidden") !== "true"); +} + +function cycleTab(index, length, shift) { + if (!length) return 0; + if (shift) return (index - 1 + length) % length; + return (index + 1) % length; +} + +function drawerEscAction(depth) { + return depth > 1 ? "back" : "close"; +} + function drawerState() { const drawer = $("#evidenceDrawer"); const scrim = $("#drawerScrim"); - let trigger = null; - const layerStack = []; + let opener = null; + const stack = []; - function open(triggerElement) { - if (!drawer) return; - trigger = triggerElement || trigger; - drawer.classList.add("is-open"); - drawer.setAttribute("aria-hidden", "false"); - scrim?.classList.add("is-open"); - requestAnimationFrame(() => $("#drawerBody", drawer)?.focus({ preventScroll: true })); - } - - function close({ restoreFocus = true } = {}) { - if (!drawer) return; - drawer.classList.remove("is-open"); - drawer.setAttribute("aria-hidden", "true"); - scrim?.classList.remove("is-open"); - layerStack.length = 0; - if (restoreFocus && trigger) trigger.focus(); - } - - function renderLayer(title, crumbs, html, onMount) { - if (!drawer) return; - $("#drawerTitle", drawer).textContent = title; + function paint() { + if (!drawer || !stack.length) return; + const layer = stack[stack.length - 1]; + $("#drawerTitle", drawer).textContent = layer.title; const breadcrumb = $("#drawerBreadcrumb", drawer); breadcrumb.replaceChildren(); - crumbs.forEach((crumb, index) => { + layer.crumbs.forEach((crumb, index) => { if (index) breadcrumb.append(document.createTextNode("/")); if (crumb.action) { const button = document.createElement("button"); @@ -584,18 +654,84 @@ function drawerState() { breadcrumb.append(span); } }); - $("#drawerBody", drawer).innerHTML = html; - if (typeof onMount === "function") onMount($("#drawerBody", drawer)); + const body = $("#drawerBody", drawer); + body.innerHTML = layer.html; + if (typeof layer.onMount === "function") layer.onMount(body); + requestAnimationFrame(() => { + const nodes = drawerFocusables(drawer); + (nodes[0] || body).focus({ preventScroll: true }); + if (layer.restoreSelector) { + const target = body.querySelector(layer.restoreSelector); + if (target) target.focus({ preventScroll: true }); + } + }); + } + + function open(triggerElement) { + if (!drawer) return; + if (!stack.length) opener = triggerElement || opener; + drawer.classList.add("is-open"); + drawer.setAttribute("aria-hidden", "false"); + scrim?.classList.add("is-open"); + } + + function close({ restoreFocus = true } = {}) { + if (!drawer) return; + drawer.classList.remove("is-open"); + drawer.setAttribute("aria-hidden", "true"); + scrim?.classList.remove("is-open"); + stack.length = 0; + if (restoreFocus && opener && document.contains(opener)) opener.focus(); + opener = null; + } + + function back() { + if (stack.length <= 1) { + close(); + return; + } + const leaving = stack.pop(); + paint(); + requestAnimationFrame(() => { + const body = $("#drawerBody", drawer); + const selector = leaving.triggerSelector; + const target = selector ? body?.querySelector(selector) : null; + if (target) target.focus({ preventScroll: true }); + }); + } + + function renderLayer(title, crumbs, html, onMount, options = {}) { + if (!drawer) return; + if (options.replaceTop && stack.length) stack.pop(); + stack.push({ + title, + crumbs, + html, + onMount, + triggerSelector: options.triggerSelector || null, + restoreSelector: options.restoreSelector || null, + }); + paint(); } $$("[data-close-drawer]").forEach((button) => button.addEventListener("click", () => close())); drawer?.addEventListener("keydown", (event) => { if (event.key === "Escape") { event.stopPropagation(); - close(); + event.preventDefault(); + if (drawerEscAction(stack.length) === "back") back(); + else close(); + return; } + if (event.key !== "Tab" || !drawer.classList.contains("is-open")) return; + const nodes = drawerFocusables(drawer); + if (!nodes.length) return; + const current = nodes.indexOf(document.activeElement); + const next = cycleTab(current < 0 ? 0 : current, nodes.length, event.shiftKey); + event.preventDefault(); + nodes[next].focus(); }); - return { open, close, renderLayer }; + return { open, close, back, renderLayer, stack }; } const drawer = drawerState(); @@ -658,21 +794,17 @@ function renderBalanceDirectory(container, items) { const details = document.createElement("details"); details.className = "company-ledger is-balances"; details.dataset.companyId = item.company_id; - const direction = item.result.direction === "receivable" - ? { className: "success", label: "应收" } - : item.result.direction === "payable" - ? { className: "danger", label: "应付" } - : { className: "neutral", label: "持平" }; + const direction = resultDirection(item.result.direction); const unresolved = unresolvedText(item.unresolved); const name = item.company_name || "未命名公司"; const summary = document.createElement("summary"); summary.innerHTML = ` - ${esc(name.slice(0, 1))}${esc(name)}${esc(item.currency)} - ${fmtMoney(item.period.debit)} - ${fmtMoney(item.period.credit)} - ${direction.label}${fmtMoney(item.result.signed_amount)} + ${esc(name.slice(0, 1))}${esc(name)}${esc(item.currency)} + ${amountWithCurrency(item.period.debit, item.currency)} + ${amountWithCurrency(item.period.credit, item.currency)} + ${direction.label}${amountWithCurrency(item.result.signed_amount, item.currency)} ${unresolved.html} - 截止 ${fmtDate(item.window.cutoff)} + 截止 ${fmtDate(item.window.cutoff)} `; details.append(summary); const breakdown = document.createElement("div"); @@ -697,12 +829,13 @@ async function loadCompanyBreakdown(container, item, close) { const byPair = new Map(); for (const event of data.items) { if (event.state !== "confirmed") continue; + if (event.currency && item.currency && event.currency !== item.currency) continue; const isPayer = event.payer_company_id === item.company_id; const counterpartyId = isPayer ? event.payee_company_id : event.payer_company_id; const counterpartyName = isPayer ? event.payee_company_name : event.payer_company_name; const subject = subjectOf(event, item.company_id); - const key = `${counterpartyId}|${subject}`; - const bucket = byPair.get(key) || { counterpartyId, counterpartyName, subject, count: 0, amount: 0, isPayer }; + const key = `${counterpartyId}|${subject}|${event.currency}`; + const bucket = byPair.get(key) || { counterpartyId, counterpartyName, subject, currency: event.currency, count: 0, amount: 0, isPayer }; bucket.count += 1; bucket.amount += Number(event.amount); byPair.set(key, bucket); @@ -712,18 +845,18 @@ async function loadCompanyBreakdown(container, item, close) { regionEmpty(container, "暂无已确认明细", "该公司的确认往来将在科目确认后出现"); return; } - const section = (title, total, rows) => ` -

${title}

${fmtMoney(total)}
+ const section = (title, total, rows, currency) => ` +

${title}

${amountWithCurrency(total, currency)}
${rows.map((row) => ` `).join("")}
`; - const debitRows = [...new Map(debit.map((row) => [row.counterpartyId, row])).values()]; - const creditRows = [...new Map(credit.map((row) => [row.counterpartyId, row])).values()]; - container.innerHTML = section("借方明细", debit.reduce((sum, row) => sum + row.amount, 0), debitRows) - + section("贷方明细", credit.reduce((sum, row) => sum + row.amount, 0), creditRows); + const debitRows = [...new Map(debit.map((row) => [`${row.counterpartyId}|${row.subject}`, row])).values()]; + const creditRows = [...new Map(credit.map((row) => [`${row.counterpartyId}|${row.subject}`, row])).values()]; + container.innerHTML = section("借方明细", debitRows.reduce((sum, row) => sum + row.amount, 0), debitRows, item.currency) + + section("贷方明细", creditRows.reduce((sum, row) => sum + row.amount, 0), creditRows, item.currency); container.querySelectorAll("[data-open-pair]").forEach((button) => { button.addEventListener("click", () => { const counterpartyId = Number(button.dataset.openPair); @@ -736,13 +869,57 @@ async function loadCompanyBreakdown(container, item, close) { } } +function eventTableHead() { + return `交易日期方向科目本方账户对方账户摘要匹配状态金额`; +} + +function eventRowHtml(event, companyId) { + const subject = subjectOf(event, companyId) || event.own_subject; + const direction = event.direction || (event.payer_company_id === companyId ? "outgoing" : "incoming"); + const posting = event.posting_kind !== "normal" ? `${postingLabel(event)}` : ""; + const otherName = event.counterparty_company_name + || (event.payer_company_id === companyId ? event.payee_company_name : event.payer_company_name); + const summary = eventSummaryText(event); + return ` + + ${fmtDate(event.effective_at)}${posting} + ${directionChip(direction)} + ${event.state === "confirmed" ? esc(b44.subjectLabels[subject] || event.own_subject_label || "") : '待确认科目'} + ${accountCell(pickAccount(event, companyId, "own"))} + ${accountCell(pickAccount(event, companyId, "counterparty"))} + ${esc(summary)} + ${eventStateChip(event.state)} + ${amountWithCurrency(event.amount, event.currency, { signed: true, negative: eventIsNegative(event) })} + `; +} + +function bindEventRows(root, tableId, companyId) { + $$(`#${tableId} tbody tr.event-row`, root).forEach((row) => { + const open = () => { + renderEventEvidence(Number(row.dataset.eventId), companyId, { + triggerSelector: `[data-event-id="${row.dataset.eventId}"]`, + }); + }; + row.addEventListener("click", open); + row.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + open(); + } + }); + }); +} + // --- Admin: pair detail + events ------------------------------------------- async function renderAdminPair(companyId, counterpartyId, companyName, extraCrumbs = []) { const otherName = b44.companyNames.get(counterpartyId) || "对方公司"; + drawer.open(); drawer.renderLayer( `${companyName || "公司"} ↔ ${otherName}`, - [{ label: "往来余额", action: () => drawer.close() }, { label: `${companyName || companyId} ↔ ${otherName}` }], + [{ label: homeCrumb(), action: () => drawer.close() }, { label: `${companyName || companyId} ↔ ${otherName}` }], regionSkeleton(4), ); try { @@ -755,43 +932,44 @@ async function renderAdminPair(companyId, counterpartyId, companyName, extraCrum } function renderAdminPairBody(data, companyId, counterpartyId) { - const item = data.items[0]; const a = data.companies.a; const b = data.companies.b; - const aResult = item.a.result; - const direction = aResult.direction === "receivable" ? "应收" : aResult.direction === "payable" ? "应付" : "持平"; - const directionClass = aResult.direction === "receivable" ? "success" : aResult.direction === "payable" ? "danger" : "neutral"; - const unresolved = unresolvedText(item.unresolved); - const subjectButtons = Object.values(item.subjects || {}).map((subject) => ` + const lines = (data.items || []).map((item) => sixLineHtml({ + currency: item.currency, + debit: item.a.period.debit, + credit: item.a.period.credit, + signed: item.a.result.signed_amount, + resultLabel: item.a.result.label, + unresolved: item.unresolved, + cutoff: data.window.cutoff, + conservationNote: "双方守恒已校验", + })).join(""); + const first = data.items[0]; + const subjectButtons = Object.values(first?.subjects || {}).map((subject) => ` `).join(""); + const eventCount = (data.items || []).reduce((sum, item) => sum + (item.trace?.event_count || 0), 0); const body = ` -
-
期初余额期初不可用B-45 前无可靠期初
-
本期借方${fmtMoney(item.a.period.debit)}
-
本期贷方${fmtMoney(item.a.period.credit)}
-
期末结果${direction}${fmtMoney(aResult.signed_amount)}${esc(item.a.result.label)}
-
未决金额${fmtMoney(item.unresolved.gross_amount)}${item.unresolved.count} 笔
-
截止日${fmtDate(data.window.cutoff)}双方守恒已校验
-
+ ${lines}
- + ${subjectButtons}
- + ${eventTableHead()}
交易日期方向科目本方账户对方账户摘要匹配状态金额
`; drawer.renderLayer( `${a.name} ↔ ${b.name}`, [ - { label: "往来余额", action: () => drawer.close() }, + { label: homeCrumb(), action: () => drawer.close() }, { label: `${a.name} ↔ ${b.name}` }, ], body, (root) => { - loadPairEvents(root, companyId, counterpartyId, item.trace.events_url); + const urls = (data.items || []).map((item) => item.trace.events_url); + loadPairEvents(root, companyId, counterpartyId, urls); $$("[data-subject]", root).forEach((button) => button.addEventListener("click", () => { $$("[data-subject]", root).forEach((item) => { const active = item === button; @@ -804,54 +982,26 @@ function renderAdminPairBody(data, companyId, counterpartyId) { }); })); }, + { replaceTop: true }, ); } -async function loadPairEvents(root, companyId, counterpartyId, url) { +async function loadPairEvents(root, companyId, counterpartyId, urls) { const tbody = $("#pairEventsTable tbody", root); if (!tbody) return; tbody.setAttribute("aria-busy", "true"); const pageSize = 50; try { - const data = await apiJson(`${url}&limit=${pageSize}`); + const pages = await Promise.all((Array.isArray(urls) ? urls : [urls]).map((url) => apiJson(`${url}&limit=${pageSize}`))); + const items = pages.flatMap((page) => page.items || []); tbody.setAttribute("aria-busy", "false"); - if (!data.items.length) { + if (!items.length) { tbody.innerHTML = '
该区间无往来事件

当前公司对在统计区间内没有逐笔事件

'; return; } - tbody.innerHTML = data.items.map((event) => { - const subject = subjectOf(event, companyId); - const direction = event.payer_company_id === companyId ? "outgoing" : "incoming"; - const isReversalOrAdjustment = event.posting_kind !== "normal"; - const sign = event.payer_company_id === companyId ? 1 : -1; - return ` - - ${fmtDate(event.effective_at)}${isReversalOrAdjustment ? `${postingLabel(event)}` : ""} - ${directionChip(direction)} - ${event.state === "confirmed" ? esc(b44.subjectLabels[subject] || "") : '待确认科目'} - ${esc(event.payer_company_id === companyId ? event.payee_company_name : event.payer_company_name)} - ${esc(event.payer_company_id === companyId ? event.payer_company_name : event.payee_company_name)} - ${esc(event.source_kind === "manual" ? "手工记录" : "银行往来")} - ${eventStateChip(event.state)} - ${sign < 0 ? "−" : ""}${fmtMoney(event.amount)} - `; - }).join(""); - $$("#pairEventsTable tbody tr.event-row", root).forEach((row) => { - const open = () => { - drawer.open(row); - renderEventEvidence(Number(row.dataset.eventId), companyId, row); - }; - row.addEventListener("click", open); - row.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - open(); - } - }); - }); - if (data.has_more) { + tbody.innerHTML = items.map((event) => eventRowHtml(event, companyId)).join(""); + bindEventRows(root, "pairEventsTable", companyId); + if (pages.some((page) => page.has_more)) { const footer = document.createElement("tr"); footer.innerHTML = '
超过 50 笔,请在服务端按截止日分段查看
'; tbody.append(footer); @@ -864,9 +1014,12 @@ async function loadPairEvents(root, companyId, counterpartyId, url) { // --- Evidence drawer --------------------------------------------------------- -async function renderEventEvidence(eventId, companyId, trigger) { +async function renderEventEvidence(eventId, companyId, options = {}) { const base = portal === "company" ? "/api/company/intercompany" : "/api/admin/intercompany"; - drawer.renderLayer("事件证据", [{ label: "往来余额", action: () => drawer.close() }], regionSkeleton(5)); + drawer.renderLayer("事件证据", [ + { label: homeCrumb(), action: () => drawer.close() }, + { label: "返回上一层", action: () => drawer.back() }, + ], regionSkeleton(5), null, { triggerSelector: options.triggerSelector }); try { const [detail, evidence] = await Promise.all([ apiJson(`${base}/events/${eventId}`), @@ -876,27 +1029,26 @@ async function renderEventEvidence(eventId, companyId, trigger) { const direction = companyId ? (event.payer_company_id === companyId ? "outgoing" : "incoming") : null; const subjectLabel = companyId && event.subject_code ? (b44.subjectLabels[subjectOf(event, companyId)] || "") : (b44.subjectLabels[event.subject_code] || ""); const blocks = evidence.blocks.map(renderEvidenceBlock).join('
双边匹配 · 已去重为同一事件
'); - const pairCrumb = companyId - ? { label: `${event.payer_company_name} ↔ ${event.payee_company_name}`, action: () => { if (portal !== "company") renderAdminPair(event.payer_company_id, event.payee_company_id, event.payer_company_name); } } - : { label: `${event.payer_company_name} ↔ ${event.payee_company_name}` }; drawer.renderLayer( `${fmtDate(event.effective_at)} 事件`, [ - { label: "往来余额", action: () => drawer.close() }, - pairCrumb, + { label: homeCrumb(), action: () => drawer.close() }, + { label: `${event.payer_company_name} ↔ ${event.payee_company_name}`, action: () => drawer.back() }, { label: fmtDate(event.effective_at) }, ], `
方向${direction ? directionChip(direction) : ""}${esc(subjectLabel)}
-
金额${fmtMoney(event.amount)} ${esc(event.currency)}
+
金额${amountWithCurrency(event.amount, event.currency, { signed: true, negative: eventIsNegative(event) })}
匹配状态${eventStateChip(detail.state)}
${blocks}
`, + null, + { replaceTop: true, triggerSelector: options.triggerSelector }, ); } catch (error) { $("#drawerBody").innerHTML = `
证据加载失败

${esc(error.message)}

`; - $("#drawerBody [data-retry]")?.addEventListener("click", () => renderEventEvidence(eventId, companyId, trigger)); + $("#drawerBody [data-retry]")?.addEventListener("click", () => renderEventEvidence(eventId, companyId, options)); } } @@ -957,6 +1109,11 @@ function appendSubjectReviewRow(item) { row.dataset.expectedRevision = item.revision_id; row.dataset.company = item.payer_company_name; const suggestions = (item.suggestions || []).map((s) => `${s.suggested_company_name} · ${s.suggested_subject_label}`).join(";") || "无规则建议(需人工判定)"; + row.dataset.suggestions = JSON.stringify(item.suggestions || []); + row.dataset.summary = item.summary || ""; + row.dataset.amount = String(item.amount || ""); + row.dataset.currency = item.currency || ""; + row.dataset.effectiveAt = item.effective_at || ""; row.innerHTML = ` ${esc(item.payer_company_name)} ↔ ${esc(item.payee_company_name)}规则建议:${esc(suggestions)} @@ -977,6 +1134,12 @@ function appendManualRecordReviewRow(record) { row.dataset.recordId = record.id; row.dataset.expectedDecisionId = record.decision_id; row.dataset.company = record.company_name; + row.dataset.submittedBy = record.submitted_by_username || ""; + row.dataset.attachmentName = record.attachment_name || ""; + row.dataset.reasonText = record.reason || ""; + row.dataset.summary = record.summary || ""; + row.dataset.amount = String(record.amount || ""); + row.dataset.currency = record.currency || ""; row.innerHTML = ` ${esc(record.company_name)} · MR-${record.id}${esc(record.direction === "outgoing" ? "付款" : "收款")} ${fmtMoney(record.amount)} ${esc(record.currency)} · ${esc(record.counterparty_company_name)} · ${esc(b44.subjectLabels[record.requested_subject] || "")} @@ -988,6 +1151,56 @@ function appendManualRecordReviewRow(record) { tbody.append(row); } +function fillAuditEvidence(evidence, row, cells) { + const heading = document.createElement("strong"); + heading.textContent = cells[1].querySelector("strong")?.textContent || ""; + evidence.append(heading); + if (row.dataset.recordKind === "subject-review") { + const meta = document.createElement("small"); + meta.textContent = `${fmtDate(row.dataset.effectiveAt)} · ${fmtAbsMoney(row.dataset.amount)} ${row.dataset.currency || ""}`.trim(); + evidence.append(meta); + if (row.dataset.summary) { + const summary = document.createElement("small"); + summary.textContent = `摘要:${row.dataset.summary}`; + evidence.append(summary); + } + const suggestions = JSON.parse(row.dataset.suggestions || "[]"); + const list = document.createElement("ul"); + list.className = "candidate-list"; + if (!suggestions.length) { + const item = document.createElement("li"); + item.textContent = "无规则建议(需退回或转异常,不能猜测科目)"; + list.append(item); + } else { + suggestions.forEach((suggestion) => { + const item = document.createElement("li"); + item.textContent = `规则建议(只读):${suggestion.suggested_company_name || ""} · ${suggestion.suggested_subject_label || suggestion.suggested_subject_code || ""}`; + list.append(item); + }); + } + evidence.append(list); + return; + } + if (row.dataset.recordKind === "manual-review") { + const meta = document.createElement("small"); + meta.textContent = `提交人 ${row.dataset.submittedBy || "—"} · 附件 ${row.dataset.attachmentName || "无"}`; + evidence.append(meta); + const summary = document.createElement("small"); + summary.textContent = `摘要:${row.dataset.summary || "—"} · ${fmtAbsMoney(row.dataset.amount)} ${row.dataset.currency || ""}`.trim(); + evidence.append(summary); + const reason = document.createElement("p"); + reason.className = "reason-full"; + reason.textContent = row.dataset.reasonText || cells[1].querySelector("small")?.textContent || ""; + evidence.append(reason); + return; + } + const detail = document.createElement("small"); + detail.textContent = cells[1].querySelector("small")?.textContent || ""; + const source = document.createElement("small"); + source.textContent = `证据:${row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明"}`; + evidence.append(detail, source); +} + async function loadAdminAuditQueue() { try { const [subjects, records] = await Promise.all([ @@ -1006,7 +1219,7 @@ async function loadAdminAuditQueue() { // --- Company portal: balances ------------------------------------------------ async function loadCompanyBalances() { - const summaryLine = $("#companyBalanceLine"); + const summaryLine = $("#companyBalanceGroups") || $("#companyBalanceLine"); const counterpartyList = $("#companyCounterparties"); const alertBox = $("#companyUnresolvedAlert"); if (!summaryLine && !counterpartyList) return; @@ -1014,30 +1227,34 @@ async function loadCompanyBalances() { if (counterpartyList) counterpartyList.innerHTML = regionSkeleton(2); try { const data = await apiJson(`/api/company/intercompany/balances?${balanceQuery()}`); - const totals = { debit: 0, credit: 0, signed: 0, unresolvedGross: 0, unresolvedCount: 0 }; - (data.items || []).forEach((item) => { - totals.debit += Number(item.period.debit || 0); - totals.credit += Number(item.period.credit || 0); - totals.signed += Number(item.result.signed_amount || 0); - totals.unresolvedGross += Number(item.unresolved.gross_amount || 0); - totals.unresolvedCount += item.unresolved.count || 0; - }); $("#companyBalancePeriod").textContent = `统计口径 ${fmtDate(data.window.from)}—${fmtDate(data.window.cutoff)} · 期初不可用,仅显示期间净变动`; if (summaryLine) { - const direction = totals.signed > 0 ? "应收" : totals.signed < 0 ? "应付" : "持平"; - const directionClass = totals.signed > 0 ? "success" : totals.signed < 0 ? "danger" : "neutral"; - summaryLine.innerHTML = ` -
期初余额期初不可用B-45 前无可靠期初
-
本期借方${fmtMoney(totals.debit)}
-
本期贷方${fmtMoney(totals.credit)}
-
期末结果${direction}${fmtMoney(totals.signed)}期间净变动
-
未决金额${fmtMoney(totals.unresolvedGross)}${totals.unresolvedCount} 笔
-
截止日${fmtDate(data.window.cutoff)}按币种独立展示
`; + const items = data.items || []; + if (!items.length) { + summaryLine.innerHTML = sixLineHtml({ + currency: "", debit: 0, credit: 0, signed: 0, resultLabel: "期间净变动", + unresolved: { gross_amount: "0", count: 0 }, cutoff: data.window.cutoff, + }); + } else { + summaryLine.innerHTML = items.map((item) => sixLineHtml({ + currency: item.currency, + debit: item.period.debit, + credit: item.period.credit, + signed: item.result.signed_amount, + resultLabel: item.result.label, + unresolved: item.unresolved, + cutoff: item.window?.cutoff || data.window.cutoff, + })).join(""); + } } + const unresolvedCount = (data.items || []).reduce((sum, item) => sum + (item.unresolved?.count || 0), 0); if (alertBox) { - if (totals.unresolvedCount) { + if (unresolvedCount) { alertBox.hidden = false; - $("#companyUnresolvedText").textContent = `有 ${totals.unresolvedCount} 笔往来未确认,合计 ${fmtMoney(totals.unresolvedGross)},不影响已确认余额`; + const parts = (data.items || []) + .filter((item) => item.unresolved?.count) + .map((item) => `${item.currency} ${fmtAbsMoney(item.unresolved.gross_amount)}`); + $("#companyUnresolvedText").textContent = `有 ${unresolvedCount} 笔往来未确认,合计 ${parts.join("、")},不影响已确认余额`; } else { alertBox.hidden = true; } @@ -1057,21 +1274,17 @@ function renderCompanyCounterparties(container, counterparties, cutoff) { } container.replaceChildren(...counterparties.map((row) => { const article = document.createElement("article"); - article.className = "company-row"; + article.className = "company-row is-balance-counterparty"; article.tabIndex = 0; article.dataset.counterpartyId = row.counterparty_company_id; article.setAttribute("role", "button"); article.setAttribute("aria-label", `查看与 ${row.counterparty_company_name} 的往来明细`); - const direction = row.result.direction === "receivable" - ? { className: "success", label: "应收" } - : row.result.direction === "payable" - ? { className: "danger", label: "应付" } - : { className: "neutral", label: "持平" }; + const direction = resultDirection(row.result.direction); const unresolved = unresolvedText(row.unresolved); article.innerHTML = ` ${esc(row.counterparty_company_name.slice(0, 1))} -
${esc(row.counterparty_company_name)}${esc(row.currency)} · ${unresolved.active ? `未决 ${fmtMoney(row.unresolved.gross_amount)} · ${row.unresolved.count} 笔` : "未决 0.00"}
-
${direction.label} ${fmtMoney(row.result.signed_amount)}截止 ${fmtDate(cutoff)}
`; +
${esc(row.counterparty_company_name)}${esc(row.currency)} · ${unresolved.active ? `未决 ${fmtAbsMoney(row.unresolved.gross_amount)} · ${row.unresolved.count} 笔` : "未决 0.00"}
+
${direction.label} ${amountWithCurrency(row.result.signed_amount, row.currency)}截止 ${fmtDate(cutoff)}
`; const open = () => { drawer.open(article); renderCompanyPair(row.counterparty_company_id, row.counterparty_company_name); @@ -1088,33 +1301,33 @@ function renderCompanyCounterparties(container, counterparties, cutoff) { } async function renderCompanyPair(counterpartyId, counterpartyName) { - drawer.renderLayer(`${counterpartyName} · 往来明细`, [{ label: "往来余额", action: () => drawer.close() }], regionSkeleton(4)); + drawer.open(); + drawer.renderLayer(`${counterpartyName} · 往来明细`, [{ label: homeCrumb(), action: () => drawer.close() }], regionSkeleton(4)); try { const data = await apiJson(`/api/company/intercompany/pairs/${counterpartyId}?${balanceQuery()}`); - const item = data.items[0]; const own = data.companies.a; - const aResult = item.a.result; - const direction = aResult.direction === "receivable" ? "应收" : aResult.direction === "payable" ? "应付" : "持平"; - const directionClass = aResult.direction === "receivable" ? "success" : aResult.direction === "payable" ? "danger" : "neutral"; - const unresolved = unresolvedText(item.unresolved); + const lines = (data.items || []).map((item) => sixLineHtml({ + currency: item.currency, + debit: item.a.period.debit, + credit: item.a.period.credit, + signed: item.a.result.signed_amount, + resultLabel: item.a.result.label, + unresolved: item.unresolved, + cutoff: data.window.cutoff, + conservationNote: "双方守恒已校验", + })).join(""); const body = ` -
-
期初余额期初不可用B-45 前无可靠期初
-
本期借方${fmtMoney(item.a.period.debit)}
-
本期贷方${fmtMoney(item.a.period.credit)}
-
期末结果${direction}${fmtMoney(aResult.signed_amount)}${esc(item.a.result.label)}
-
未决金额${fmtMoney(item.unresolved.gross_amount)}${item.unresolved.count} 笔
-
截止日${fmtDate(data.window.cutoff)}双方守恒已校验
-
+ ${lines}
- - + ${eventTableHead()} +
交易日期方向科目对方公司来源匹配状态金额
`; drawer.renderLayer( `${own.name} ↔ ${counterpartyName}`, - [{ label: "往来余额", action: () => drawer.close() }, { label: `${own.name} ↔ ${counterpartyName}` }], + [{ label: homeCrumb(), action: () => drawer.close() }, { label: `${own.name} ↔ ${counterpartyName}` }], body, (root) => loadCompanyEvents(root, counterpartyId), + { replaceTop: true }, ); } catch (error) { $("#drawerBody").innerHTML = `
余额计算暂时不可用

${esc(error.message)}

`; @@ -1130,35 +1343,13 @@ async function loadCompanyEvents(root, counterpartyId) { const ownCompanyId = state.me?.company_id; const filtered = (data.items || []).filter((event) => event.counterparty_company_id === counterpartyId); if (!filtered.length) { - tbody.innerHTML = '
该区间无往来事件

当前统计区间内没有与对方公司的逐笔事件

'; + tbody.innerHTML = '
该区间无往来事件

当前统计区间内没有与对方公司的逐笔事件

'; return; } - tbody.innerHTML = filtered.map((event) => ` - - ${fmtDate(event.effective_at)} - ${directionChip(event.direction)} - ${event.state === "confirmed" ? esc(event.own_subject_label || "") : '待确认科目'} - ${esc(event.counterparty_company_name)} - ${esc(event.source_kind === "manual" ? "手工记录" : "银行往来")} - ${eventStateChip(event.state)} - ${event.direction === "outgoing" ? "−" : ""}${fmtMoney(event.amount)} - `).join(""); - $$("#companyEventsTable tbody tr.event-row", root).forEach((row) => { - const open = () => { - drawer.open(row); - renderEventEvidence(Number(row.dataset.eventId), ownCompanyId, row); - }; - row.addEventListener("click", open); - row.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - open(); - } - }); - }); + tbody.innerHTML = filtered.map((event) => eventRowHtml(event, ownCompanyId)).join(""); + bindEventRows(root, "companyEventsTable", ownCompanyId); } catch (error) { - tbody.innerHTML = `
逐笔事件加载失败

${esc(error.message)}

`; + tbody.innerHTML = `
逐笔事件加载失败

${esc(error.message)}

`; } } @@ -1178,7 +1369,7 @@ function initBalanceQueries() { b44.from = from; b44.cutoff = cutoff; b44.currency = currency; - loadAdminBalances($("#companyLedgers")); + loadAdminBalances($("#balanceLedgers")); showToast("查询结果已更新", `${from} 至 ${cutoff} · 截止日口径`); }); }); @@ -1255,6 +1446,10 @@ async function submitCompanyManualRecord(form) { reason: String(data.get("remark") || "").trim(), request_key: `mr-${Date.now().toString(36)}`, }; + const evidenceFile = data.get("evidence"); + if (evidenceFile instanceof File && evidenceFile.name) { + payload.evidence = { attachment_name: evidenceFile.name }; + } if (!payload.counterparty_company_id) { showToast("请选择对方公司", "手工记录对方必须是集团内部公司"); return; @@ -1586,7 +1781,7 @@ function initAdmin() { loadAdminCompanies(); initBalanceQueries(); loadCompanyOptions().then(() => { - loadAdminBalances($("#companyLedgers")); + loadAdminBalances($("#balanceLedgers")); }); loadAdminAuditQueue(); const companySearch = $('[data-filter-target="companyLedgers"]'); @@ -1615,8 +1810,15 @@ function initAdmin() { })); $("#auditCompany")?.addEventListener("change", filterAuditRows); + const TRIAGE_DECISIONS = ["确认并纳入计算", "退回公司补充材料", "转为异常"]; const auditDialog = $("#auditDialog"); + const syncExceptionNote = () => { + const note = $("#auditExceptionNote"); + const decision = $('#auditForm [name="decision"]'); + if (note) note.hidden = !String(decision?.value || "").includes("转为异常"); + }; $$('[data-close-audit]').forEach((button) => button.addEventListener("click", () => auditDialog.close())); + $("#auditForm [name='decision']")?.addEventListener("change", syncExceptionNote); // Delegated: account review rows arrive asynchronously from the API. $("#auditRows")?.addEventListener("click", (event) => { const button = event.target.closest("[data-audit-action]"); @@ -1632,11 +1834,9 @@ function initAdmin() { const isActiveAccount = row.dataset.recordKind === "account" && row.dataset.accountStatus === "active"; const decisions = row.dataset.recordKind === "account" ? (isActiveAccount ? ["停用并驳回"] : ["复核通过并启用账户", "退回公司修改", "停用并驳回"]) - : row.dataset.recordKind === "subject-review" - ? ["应收", "应付", "其他应收", "其他应付"] - : row.dataset.recordKind === "manual-review" - ? ["确认并纳入计算", "退回公司补充材料", "转为异常待后续处理"] - : ["确认并纳入计算", "退回公司补充材料", "转为异常待后续处理"]; + : (row.dataset.recordKind === "subject-review" || row.dataset.recordKind === "manual-review") + ? TRIAGE_DECISIONS + : TRIAGE_DECISIONS; decision.replaceChildren(new Option("请选择", ""), ...decisions.map((item) => new Option(item, item))); decision.disabled = false; reason.disabled = false; @@ -1645,10 +1845,8 @@ function initAdmin() { $("#auditDialogMeta").textContent = `${cells[3].innerText.trim()} · 影响 ${cells[4].innerText.trim()}`; const evidence = $("#auditEvidence"); evidence.replaceChildren(); - const heading = document.createElement("strong"); heading.textContent = cells[1].querySelector("strong").textContent; - const detail = document.createElement("small"); detail.textContent = cells[1].querySelector("small").textContent; - const source = document.createElement("small"); source.textContent = `证据:${row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明"}`; - evidence.append(heading, detail, source); + fillAuditEvidence(evidence, row, cells); + syncExceptionNote(); if (button.dataset.record && !isActiveAccount) { decision.value = button.dataset.decision; reason.value = button.dataset.reason; @@ -1659,6 +1857,7 @@ function initAdmin() { const record = document.createElement("small"); record.textContent = `处理记录:${button.dataset.record}`; evidence.append(record); + syncExceptionNote(); } auditDialog.showModal(); }); @@ -1669,33 +1868,53 @@ function initAdmin() { const decision = String(data.get("decision")); const reason = String(data.get("reason")); - // B-44 subject review: confirm the statutory subject on a pending event. + // B-44 subject review: confirm uses the rule suggestion; return/exception park the event. if (row.dataset.recordKind === "subject-review") { - const subjectCode = b44.subjectCodes[decision] || Object.keys(b44.subjectLabels).find((key) => b44.subjectLabels[key] === decision); - if (!subjectCode) { - showToast("请选择确认科目", "科目必须是应收、应付、其他应收或其他应付之一"); + const actionMap = { + "确认并纳入计算": "confirm", + "退回公司补充材料": "return", + "转为异常": "exception", + }; + const action = actionMap[decision]; + if (!action) { + showToast("请选择处理决定", "科目审核仅支持确认、退回或转异常"); return; } + const suggestions = JSON.parse(row.dataset.suggestions || "[]"); + const suggested = suggestions[0]; + if (action === "confirm" && !suggested?.suggested_subject_code) { + showToast("没有可入账的规则建议", "请退回公司补充材料或转为异常,不要猜测科目"); + return; + } + const body = { + perspective_company_id: Number(suggested?.suggested_perspective_company_id || row.dataset.perspectiveId), + reason, + expected_revision: Number(row.dataset.expectedRevision), + request_key: `subj-${row.dataset.ledgerEventId}-${Date.now().toString(36)}`, + }; + if (action === "confirm") { + body.subject_code = suggested.suggested_subject_code; + } else { + body.action = action; + } const response = await apiJson(`/api/admin/intercompany/events/${row.dataset.ledgerEventId}/subject-decisions`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - perspective_company_id: Number(row.dataset.perspectiveId), - subject_code: subjectCode, - reason, - expected_revision: Number(row.dataset.expectedRevision), - request_key: `subj-${row.dataset.ledgerEventId}-${Date.now().toString(36)}`, - }), + body: JSON.stringify(body), }).catch(() => null); if (!response) { - showToast("科目确认失败", "请刷新后重试"); + showToast("科目处理失败", "请刷新后重试"); return; } row.remove(); updateAuditCounts(); auditDialog.close(); event.currentTarget.reset(); - showToast("科目已确认并纳入计算", `确认科目:${b44.subjectLabels[subjectCode]} · 已写入修订链`); + syncExceptionNote(); + showToast( + action === "confirm" ? "科目已确认并纳入计算" : action === "return" ? "已退回公司补充材料" : "已转为异常", + action === "confirm" ? `确认科目:${b44.subjectLabels[suggested.suggested_subject_code]} · 已写入修订链` : "当前记录暂不纳入余额计算", + ); await loadAdminAuditQueue(); return; } @@ -1705,6 +1924,7 @@ function initAdmin() { const actionMap = { "确认并纳入计算": "approve_new", "退回公司补充材料": "return", + "转为异常": "exception", "转为异常待后续处理": "exception", }; const action = actionMap[decision]; @@ -2409,17 +2629,32 @@ function initCompany() { }); } -if (portal === "entry") { - initEntry(); -} else { - initAuthGuard().then((allowed) => { - if (!allowed) return; - initShell(); - initFlowTools(); - if (portal === "admin") { - initAdmin(); - } else { - initCompany(); - } - }); +if (typeof document !== "undefined" && document.body) { + if (portal === "entry") { + initEntry(); + } else { + initAuthGuard().then((allowed) => { + if (!allowed) return; + initShell(); + initFlowTools(); + if (portal === "admin") { + initAdmin(); + } else { + initCompany(); + } + }); + } +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { + fmtAbsMoney, + fmtMoney, + eventIsNegative, + cycleTab, + drawerEscAction, + amountWithCurrency, + resultDirection, + accountCell, + }; } diff --git a/web/company.html b/web/company.html index e3bbe62..573490e 100644 --- a/web/company.html +++ b/web/company.html @@ -174,7 +174,7 @@

本公司往来汇总

统计口径

-
+

对方公司明细

点击任意对方公司查看往来事件与允许的源证据

diff --git a/web/styles.css b/web/styles.css index 6b8617e..30224c3 100644 --- a/web/styles.css +++ b/web/styles.css @@ -487,8 +487,8 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; } .pair-query-form .field:nth-of-type(3) { grid-column: 1 / 3; } .pair-query-form .button { grid-column: 3; } .ledger-head { display: none; } - .company-ledger summary { grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(100px, 0.7fr)) 28px; } - .company-ledger summary > span:nth-child(4), .company-ledger summary > span:nth-child(5) { display: none; } + .company-ledger:not(.is-balances) summary { grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(100px, 0.7fr)) 28px; } + .company-ledger:not(.is-balances) summary > span:nth-child(4), .company-ledger:not(.is-balances) summary > span:nth-child(5) { display: none; } .ledger-breakdown { grid-template-columns: 1fr; } .ledger-breakdown section + section { border-top: 1px solid var(--color-line); border-left: 0; } .pair-balance-line { grid-template-columns: repeat(3, 1fr); } @@ -534,8 +534,8 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; } .task-list button > b { display: none; } .quick-pair-form { grid-template-columns: 1fr 38px 1fr; padding-inline: 12px; } .quick-result { margin-inline: 12px; } - .company-ledger summary { grid-template-columns: minmax(145px, 1fr) 100px 24px; padding-inline: 12px; } - .company-ledger summary > strong:nth-of-type(2), .company-ledger summary > span:nth-child(4), .company-ledger summary > span:nth-child(5) { display: none; } + .company-ledger:not(.is-balances) summary { grid-template-columns: minmax(145px, 1fr) 100px 24px; padding-inline: 12px; } + .company-ledger:not(.is-balances) summary > strong:nth-of-type(2), .company-ledger:not(.is-balances) summary > span:nth-child(4), .company-ledger:not(.is-balances) summary > span:nth-child(5) { display: none; } .company-name { grid-template-columns: 30px minmax(0, 1fr); } .company-name i { width: 28px; height: 28px; } .filter-bar { align-items: stretch; flex-direction: column; } @@ -594,8 +594,8 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; } } @media (max-width: 900px) { - .company-row { grid-template-columns: 34px minmax(0, 1fr) auto; } - .company-row-figure { display: none; } + .company-row:not(.is-balance-counterparty) { grid-template-columns: 34px minmax(0, 1fr) auto; } + .company-row:not(.is-balance-counterparty) .company-row-figure { display: none; } } @media (max-width: 720px) { @@ -739,9 +739,20 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } -/* ---- B-44 intercompany balances (design tokens only, no overrides) ---- */ +/* ---- B-44 intercompany balances ---- */ .status.info { border-color: rgba(102, 168, 255, 0.18); background: var(--color-info-wash); color: var(--color-info); } .amount-neutral { color: var(--color-ink-muted); } +.currency-code { font-size: 10px; color: var(--color-ink-muted); margin-right: 4px; } +.amount-with-currency { white-space: nowrap; font-variant-numeric: tabular-nums; } +.account-cell { white-space: nowrap; } +.summary-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.candidate-list { margin: 8px 0 0; padding: 0; list-style: none; color: var(--color-ink-soft); font-size: 12px; } +.candidate-list li { padding: 4px 0; border-bottom: 1px dashed var(--color-line); } +.candidate-list li:last-child { border-bottom: 0; } +.reason-full { max-height: calc(1.6em * 6); overflow: auto; white-space: pre-wrap; } +.decision-danger { margin-top: 8px; color: var(--color-danger); font-size: 12px; } +.balance-currency-groups { display: grid; gap: 12px; } +.balance-currency-groups .pair-balance-line { border: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; } /* Balance directory: 公司 | 借方 | 贷方 | 期末结果 | 未决 | 截止日 | ▸ */ .ledger-head.is-balances, .company-ledger.is-balances summary { @@ -757,11 +768,33 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event .company-ledger.is-balances .currency-tag { color: var(--color-ink-muted); font-size: 10px; } .company-ledger.is-balances .subject-row small { white-space: nowrap; } @media (max-width: 900px) { - .ledger-head.is-balances, .company-ledger.is-balances summary { grid-template-columns: minmax(150px, 1fr) minmax(150px, 0.9fr) minmax(110px, 0.7fr) 24px; } - .company-ledger.is-balances summary .ledger-hide-sm { display: none; } + .ledger-head.is-balances { display: grid; } +} +@media (max-width: 768px) { + .ledger-head.is-balances, .company-ledger.is-balances summary { + grid-template-columns: minmax(140px, 1.2fr) minmax(140px, 0.9fr) minmax(110px, 0.7fr) minmax(96px, 0.55fr) 24px; + } + .ledger-head.is-balances .ledger-hide-md, + .company-ledger.is-balances summary .ledger-hide-md { display: none; } +} +@media (max-width: 375px) { + .ledger-head.is-balances { display: none; } + .company-ledger.is-balances summary { + grid-template-columns: minmax(0, 1fr) minmax(0, auto) 24px; + grid-template-rows: auto auto; + align-items: center; + column-gap: 10px; + row-gap: 4px; + } + .company-ledger.is-balances summary .company-name { grid-column: 1; grid-row: 1; } + .company-ledger.is-balances summary .ledger-result { grid-column: 2; grid-row: 1; justify-self: end; } + .company-ledger.is-balances summary .ledger-unresolved { grid-column: 1; grid-row: 2; } + .company-ledger.is-balances summary .ledger-cutoff { grid-column: 2; grid-row: 2; justify-self: end; } + .company-ledger.is-balances summary > svg { grid-column: 3; grid-row: 1 / span 2; align-self: center; } + .company-ledger.is-balances summary .ledger-hide-md { display: none; } } -/* Pair report six-cell balance line: 期初 | 本期借 | 本期贷 | 期末 | 未决 | 截止日 */ +/* Pair report six-cell: page may be six-across; drawer is always two rows of three */ .pair-balance-line.is-six { grid-template-columns: repeat(6, 1fr); } .pair-balance-line.is-six .pair-final { grid-column: auto; border-left: 1px solid var(--color-line); } .pair-balance-line .pair-open-note { color: var(--color-ink-muted); font-size: 10px; margin-top: 3px; } @@ -773,26 +806,35 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event .pair-balance-line.is-six div:nth-child(4) { border-top: 1px solid var(--color-line); border-left: 0; } .pair-balance-line.is-six div:nth-child(5), .pair-balance-line.is-six div:nth-child(6) { border-top: 1px solid var(--color-line); } } -@media (max-width: 720px) { - .pair-balance-line.is-six { grid-template-columns: 1fr 1fr; } -} +.amount-with-currency { font-variant-numeric: tabular-nums; white-space: nowrap; } +.amount-with-currency .currency-code { margin-right: 4px; color: var(--color-ink-muted); font-size: 10px; font-weight: 500; } -/* Evidence drawer (single instance, single-layer replacement) */ +/* Evidence drawer: <768 full, 768–1179 480px, >=1180 640px */ .drawer-scrim { position: fixed; inset: 0; z-index: calc(var(--z-drawer) - 1); background: rgba(5, 5, 5, 0.6); opacity: 0; pointer-events: none; transition: opacity var(--duration-standard) var(--ease-out); } .drawer-scrim.is-open { opacity: 1; pointer-events: auto; } .drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: var(--z-drawer); width: 640px; max-width: 100%; display: flex; flex-direction: column; background: rgba(17, 17, 17, 0.96); border-left: 1px solid var(--color-line-strong); box-shadow: var(--shadow-panel); transform: translateX(24px); opacity: 0; visibility: hidden; transition: transform var(--duration-standard) var(--ease-out), opacity var(--duration-standard) var(--ease-out), visibility 0s linear var(--duration-standard); } .drawer.is-open { transform: translateX(0); opacity: 1; visibility: visible; transition-delay: 0s; } -@media (min-width: 1024px) and (max-width: 1180px) { .drawer { width: 480px; } } -@media (max-width: 768px) { .drawer { width: 100%; } } +@media (max-width: 1179px) { .drawer { width: 480px; } } +@media (max-width: 767px) { .drawer { width: 100%; } } .drawer-header { min-height: 74px; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--color-line); } .drawer-header .drawer-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; color: var(--color-ink-muted); font-size: 11px; } .drawer-header .drawer-breadcrumb button { border: 0; padding: 0; background: none; color: var(--color-info); cursor: pointer; font-size: 11px; } .drawer-header .drawer-breadcrumb button:hover { text-decoration: underline; } .drawer-header h2 { margin-top: 6px; font-size: 17px; } .drawer-body { flex: 1; overflow: auto; padding: 16px 18px 24px; contain: layout paint; } -.drawer .pair-balance-line { border: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; margin-bottom: 16px; } -.drawer .pair-balance-line div { min-height: 64px; } -.drawer .event-table { min-width: 640px; } +.drawer .pair-balance-line { border: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; margin-bottom: 16px; } +.drawer .pair-balance-line div { min-height: 64px; min-width: 0; overflow: hidden; } +.drawer .pair-balance-line strong { overflow-wrap: anywhere; } +.drawer .pair-balance-line .pair-cutoff, +.drawer .pair-balance-line strong.amount-neutral { white-space: nowrap; } +.drawer .pair-balance-line.is-six { + grid-template-columns: repeat(3, 1fr); +} +.drawer .pair-balance-line.is-six .pair-final { grid-column: auto; } +.drawer .pair-balance-line.is-six div:nth-child(4) { border-top: 1px solid var(--color-line); border-left: 0; } +.drawer .pair-balance-line.is-six div:nth-child(5), +.drawer .pair-balance-line.is-six div:nth-child(6) { border-top: 1px solid var(--color-line); } +.drawer .event-table { min-width: 860px; } .drawer .event-table td { height: var(--row-h-evidence); } .drawer .event-row { cursor: pointer; } .drawer .event-row:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: -2px; } @@ -807,6 +849,27 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event .evidence-block .evidence-masked { color: var(--color-ink-muted); font-style: normal; } @media (max-width: 720px) { .evidence-block .evidence-grid { grid-template-columns: 1fr; } } +.company-row.is-balance-counterparty { grid-template-columns: 34px minmax(0, 1fr) minmax(0, auto); } +.company-row.is-balance-counterparty .company-row-figure { + display: flex; flex-direction: column; align-items: flex-end; gap: 4px; min-width: 0; +} +.company-row.is-balance-counterparty .company-row-figure strong { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; } +@media (max-width: 900px) { + .company-row.is-balance-counterparty { + grid-template-columns: 34px minmax(0, 1fr); + row-gap: 8px; + } + .company-row.is-balance-counterparty .company-row-figure { + grid-column: 2; + align-items: flex-start; + text-align: left; + flex-wrap: wrap; + flex-direction: row; + column-gap: 10px; + row-gap: 4px; + } +} + /* Loading skeletons */ .skeleton-row { height: var(--row-h-evidence); display: flex; align-items: center; gap: 12px; padding: 0 14px; border-bottom: 1px solid var(--color-line); } .skeleton-row span { height: 12px; border-radius: 6px; background: rgba(255, 255, 255, 0.06); } -- 2.54.0 From 95ad9c939a83c9908bb222300eea465d0a01110d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=85=BE=E8=AE=AFWorkBuddy?= Date: Thu, 20 Aug 2026 01:54:32 +0800 Subject: [PATCH 5/6] =?UTF-8?q?B-44:=20=E8=BF=94=E4=BF=AE=E2=80=94?= =?UTF-8?q?=E2=80=94375px=20=E8=B6=85=E5=A4=A7=E9=87=91=E9=A2=9D=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E4=B8=8E=E5=AE=A1=E6=A0=B8=E5=BC=B9=E7=AA=97=E5=BF=85?= =?UTF-8?q?=E5=B1=95=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- tests/b44_ui_check.js | 41 ++++++++++++++++++ tests/test_b44_frontend.py | 12 +++++- web/app.js | 87 +++++++++++++++++++++++++++++++------- web/styles.css | 32 +++++++++----- 4 files changed, 146 insertions(+), 26 deletions(-) diff --git a/tests/b44_ui_check.js b/tests/b44_ui_check.js index dd658a6..b801e9a 100644 --- a/tests/b44_ui_check.js +++ b/tests/b44_ui_check.js @@ -38,6 +38,47 @@ assert.strictEqual(ui.resultDirection(10).label, "应收"); assert.strictEqual(ui.resultDirection(-10).label, "应付"); assert.strictEqual(ui.resultDirection(0).label, "持平"); +assert.strictEqual(ui.isCompactAmount(1280), false); +assert.strictEqual(ui.isCompactAmount(999999999), false); +assert.strictEqual(ui.isCompactAmount(1000000000), true); +assert.strictEqual(ui.isCompactAmount("123456789012345"), true); +assert.ok(ui.amountWithCurrency("123456789012345", "CNY").includes("is-compact")); +assert.ok(!ui.amountWithCurrency(1280, "CNY").includes("is-compact")); + +assert.strictEqual(ui.cashDirectionLabel("outgoing"), "转出"); +assert.strictEqual(ui.cashDirectionLabel("incoming"), "转入"); +assert.strictEqual(ui.relatedFlowLabel(null), "无关联流水"); +assert.strictEqual(ui.relatedFlowLabel(""), "无关联流水"); +assert.strictEqual(ui.relatedFlowLabel("42"), "42"); + +const subjectFields = ui.auditEvidenceFields("subject-review", { + direction: "outgoing", + effectiveAt: "2026-07-18T09:00:00", + amount: "600", + currency: "CNY", + summary: "资金调拨", +}); +assert.deepStrictEqual(subjectFields.map((item) => item[0]), ["方向", "日期", "金额", "摘要"]); +assert.strictEqual(subjectFields[0][1], "转出"); + +const manualFields = ui.auditEvidenceFields("manual-review", { + counterpartyName: "乙公司", + direction: "incoming", + relatedSourceRowId: "", + submittedBy: "出纳甲", + attachmentName: "", + amount: "80", + currency: "CNY", + summary: "补记", +}); +assert.deepStrictEqual( + manualFields.map((item) => item[0]), + ["对方公司名", "方向", "关联流水号", "提交人", "附件", "摘要", "金额"], +); +assert.strictEqual(manualFields[0][1], "乙公司"); +assert.strictEqual(manualFields[1][1], "转入"); +assert.strictEqual(manualFields[2][1], "无关联流水"); + assert.ok(ui.accountCell({ visibility: "visible", label: "中信 5316" }).includes("中信 5316")); assert.ok(ui.accountCell({ visibility: "masked" }).includes("按对方授权不可见")); assert.ok(ui.accountCell({ visibility: "missing" }).includes("源行缺失")); diff --git a/tests/test_b44_frontend.py b/tests/test_b44_frontend.py index 516b1cb..0eb2b9e 100644 --- a/tests/test_b44_frontend.py +++ b/tests/test_b44_frontend.py @@ -39,7 +39,6 @@ class B44FrontendContractTests(unittest.TestCase): self.assertIn("

往来余额

", self.company) def test_css_directory_and_drawer_breakpoints(self) -> None: - self.assertIn("@media (max-width: 768px)", self.css) self.assertIn("@media (max-width: 375px)", self.css) self.assertIn("@media (max-width: 1179px)", self.css) self.assertIn("@media (max-width: 767px)", self.css) @@ -52,6 +51,11 @@ class B44FrontendContractTests(unittest.TestCase): self.assertIn(".drawer .event-table { min-width: 860px; }", self.css) self.assertIn(".company-row.is-balance-counterparty", self.css) self.assertIn(".ledger-hide-md", self.css) + self.assertIn(".company-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }", self.css) + self.assertIn(".amount-with-currency.is-compact { font-size: 11px; }", self.css) + self.assertIn("minmax(88px, 1fr) minmax(0, max-content) 24px", self.css) + self.assertIn("grid-row: 1 / span 2", self.css) + self.assertIn("flex-direction: column; align-items: flex-end; gap: 2px;", self.css) def test_js_selectors_and_event_table(self) -> None: self.assertIn('loadAdminBalances($("#balanceLedgers"))', self.js) @@ -68,6 +72,12 @@ class B44FrontendContractTests(unittest.TestCase): self.assertIn("function eventIsNegative(", self.js) self.assertIn("fmtAbsMoney", self.js) self.assertIn("is-balance-counterparty", self.js) + self.assertIn("function isCompactAmount(", self.js) + self.assertIn("function auditEvidenceFields(", self.js) + self.assertIn("无关联流水", self.js) + self.assertIn('row.dataset.direction = "outgoing"', self.js) + self.assertIn("row.dataset.counterpartyName", self.js) + self.assertIn("row.dataset.relatedSourceRowId", self.js) heads = re.search( r"function eventTableHead\(\) \{\s*return `([^`]+)`", self.js, diff --git a/web/app.js b/web/app.js index bf27241..c24fb9c 100644 --- a/web/app.js +++ b/web/app.js @@ -522,11 +522,57 @@ function eventIsNegative(event) { return event.posting_kind === "reversal" || Boolean(event.is_repayment); } +function isCompactAmount(value) { + const num = Number(value); + if (!Number.isFinite(num)) return false; + return String(Math.trunc(Math.abs(num))).length >= 10; +} + function amountWithCurrency(value, currency, { signed = false, negative = false } = {}) { const abs = fmtAbsMoney(value); const sign = (signed && (negative || Number(value) < 0)) ? "−" : ""; const code = currency ? `${esc(currency)}` : ""; - return `${code}${sign}${abs}`; + const compact = isCompactAmount(value) ? " is-compact" : ""; + return `${code}${sign}${abs}`; +} + +function cashDirectionLabel(direction) { + if (direction === "outgoing") return "转出"; + if (direction === "incoming") return "转入"; + return "—"; +} + +function relatedFlowLabel(relatedId) { + if (relatedId === null || relatedId === undefined || String(relatedId).trim() === "") { + return "无关联流水"; + } + return String(relatedId); +} + +function auditEvidenceFields(kind, data = {}) { + const amountText = data.amount + ? `${fmtAbsMoney(data.amount)}${data.currency ? ` ${data.currency}` : ""}`.trim() + : "—"; + if (kind === "subject-review") { + return [ + ["方向", cashDirectionLabel(data.direction || "outgoing")], + ["日期", data.effectiveAt ? fmtDate(data.effectiveAt) : "—"], + ["金额", amountText], + ["摘要", data.summary || "—"], + ]; + } + if (kind === "manual-review") { + return [ + ["对方公司名", data.counterpartyName || "—"], + ["方向", cashDirectionLabel(data.direction)], + ["关联流水号", relatedFlowLabel(data.relatedSourceRowId)], + ["提交人", data.submittedBy || "—"], + ["附件", data.attachmentName || "无"], + ["摘要", data.summary || "—"], + ["金额", amountText], + ]; + } + return []; } function accountCell(chip) { @@ -1114,6 +1160,7 @@ function appendSubjectReviewRow(item) { row.dataset.amount = String(item.amount || ""); row.dataset.currency = item.currency || ""; row.dataset.effectiveAt = item.effective_at || ""; + row.dataset.direction = "outgoing"; row.innerHTML = ` ${esc(item.payer_company_name)} ↔ ${esc(item.payee_company_name)}规则建议:${esc(suggestions)} @@ -1140,6 +1187,9 @@ function appendManualRecordReviewRow(record) { row.dataset.summary = record.summary || ""; row.dataset.amount = String(record.amount || ""); row.dataset.currency = record.currency || ""; + row.dataset.direction = record.direction || ""; + row.dataset.counterpartyName = record.counterparty_company_name || ""; + row.dataset.relatedSourceRowId = record.related_source_row_id == null ? "" : String(record.related_source_row_id); row.innerHTML = ` ${esc(record.company_name)} · MR-${record.id}${esc(record.direction === "outgoing" ? "付款" : "收款")} ${fmtMoney(record.amount)} ${esc(record.currency)} · ${esc(record.counterparty_company_name)} · ${esc(b44.subjectLabels[record.requested_subject] || "")} @@ -1151,19 +1201,27 @@ function appendManualRecordReviewRow(record) { tbody.append(row); } +function appendEvidenceGrid(parent, fields) { + const grid = document.createElement("div"); + grid.className = "evidence-grid"; + fields.forEach(([label, value]) => { + const wrap = document.createElement("div"); + const dt = document.createElement("dt"); + dt.textContent = label; + const dd = document.createElement("dd"); + dd.textContent = value; + wrap.append(dt, dd); + grid.append(wrap); + }); + parent.append(grid); +} + function fillAuditEvidence(evidence, row, cells) { const heading = document.createElement("strong"); heading.textContent = cells[1].querySelector("strong")?.textContent || ""; evidence.append(heading); if (row.dataset.recordKind === "subject-review") { - const meta = document.createElement("small"); - meta.textContent = `${fmtDate(row.dataset.effectiveAt)} · ${fmtAbsMoney(row.dataset.amount)} ${row.dataset.currency || ""}`.trim(); - evidence.append(meta); - if (row.dataset.summary) { - const summary = document.createElement("small"); - summary.textContent = `摘要:${row.dataset.summary}`; - evidence.append(summary); - } + appendEvidenceGrid(evidence, auditEvidenceFields("subject-review", row.dataset)); const suggestions = JSON.parse(row.dataset.suggestions || "[]"); const list = document.createElement("ul"); list.className = "candidate-list"; @@ -1182,12 +1240,7 @@ function fillAuditEvidence(evidence, row, cells) { return; } if (row.dataset.recordKind === "manual-review") { - const meta = document.createElement("small"); - meta.textContent = `提交人 ${row.dataset.submittedBy || "—"} · 附件 ${row.dataset.attachmentName || "无"}`; - evidence.append(meta); - const summary = document.createElement("small"); - summary.textContent = `摘要:${row.dataset.summary || "—"} · ${fmtAbsMoney(row.dataset.amount)} ${row.dataset.currency || ""}`.trim(); - evidence.append(summary); + appendEvidenceGrid(evidence, auditEvidenceFields("manual-review", row.dataset)); const reason = document.createElement("p"); reason.className = "reason-full"; reason.textContent = row.dataset.reasonText || cells[1].querySelector("small")?.textContent || ""; @@ -2654,7 +2707,11 @@ if (typeof module !== "undefined" && module.exports) { cycleTab, drawerEscAction, amountWithCurrency, + isCompactAmount, resultDirection, accountCell, + cashDirectionLabel, + relatedFlowLabel, + auditEvidenceFields, }; } diff --git a/web/styles.css b/web/styles.css index 30224c3..8655feb 100644 --- a/web/styles.css +++ b/web/styles.css @@ -220,7 +220,7 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; } .company-row.is-warning .company-row-mark { background: var(--color-warning-wash); color: var(--color-warning); } .company-row.is-danger .company-row-mark { background: var(--color-danger-wash); color: var(--color-danger); } .company-row-body { display: flex; flex-direction: column; min-width: 0; } -.company-row-body strong { font-size: 14px; } +.company-row-body strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; } .company-row-body small { color: var(--color-ink-muted); font-size: 11px; } .company-row-figure { display: flex; flex-direction: column; align-items: flex-end; text-align: right; min-width: 92px; } .company-row-figure strong { font: 600 14px var(--font-data); color: var(--color-ink); } @@ -261,8 +261,9 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; } .company-ledger summary:hover { background: rgba(255, 255, 255, 0.035); } .company-ledger summary > svg { transition: transform var(--duration-standard) var(--ease-out); } .company-ledger[open] summary > svg { transform: rotate(180deg); } -.company-name { display: grid; grid-template-columns: 38px minmax(0, 1fr); align-items: center; column-gap: 10px; } +.company-name { display: grid; grid-template-columns: 38px minmax(0, 1fr); align-items: center; column-gap: 10px; min-width: 0; } .company-name i { width: 36px; height: 36px; grid-row: 1 / 3; display: grid; place-items: center; border-radius: 12px; background: var(--color-primary-wash); color: var(--color-primary); font-style: normal; font-weight: 800; } +.company-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .company-name small { color: var(--color-ink-muted); } .amount { font: 600 13px var(--font-data); } .amount.debit { color: var(--color-positive); } @@ -744,6 +745,7 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event .amount-neutral { color: var(--color-ink-muted); } .currency-code { font-size: 10px; color: var(--color-ink-muted); margin-right: 4px; } .amount-with-currency { white-space: nowrap; font-variant-numeric: tabular-nums; } +.amount-with-currency.is-compact { font-size: 11px; } .account-cell { white-space: nowrap; } .summary-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .candidate-list { margin: 8px 0 0; padding: 0; list-style: none; color: var(--color-ink-soft); font-size: 12px; } @@ -756,12 +758,13 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event /* Balance directory: 公司 | 借方 | 贷方 | 期末结果 | 未决 | 截止日 | ▸ */ .ledger-head.is-balances, .company-ledger.is-balances summary { - grid-template-columns: minmax(180px, 1.3fr) minmax(100px, 0.7fr) minmax(100px, 0.7fr) minmax(150px, 0.9fr) minmax(110px, 0.7fr) minmax(96px, 0.55fr) 24px; + grid-template-columns: minmax(180px, 1.3fr) minmax(0, 0.7fr) minmax(0, 0.7fr) minmax(0, 0.9fr) minmax(0, 0.7fr) minmax(96px, 0.55fr) 24px; } .company-ledger.is-balances summary { min-height: 72px; } +.company-ledger.is-balances summary .company-name { min-width: 96px; } .company-ledger.is-balances summary .ledger-cutoff { color: var(--color-ink-muted); font-size: 11px; white-space: nowrap; } .company-ledger.is-balances .ledger-result { display: flex; align-items: center; gap: 7px; min-width: 0; } -.company-ledger.is-balances .ledger-result b { font: 600 14px var(--font-data); font-variant-numeric: tabular-nums; white-space: nowrap; } +.company-ledger.is-balances .ledger-result b { font: 600 14px var(--font-data); font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 0; } .company-ledger.is-balances .ledger-unresolved { font-size: 11px; white-space: nowrap; } .company-ledger.is-balances .ledger-unresolved.is-empty { color: var(--color-ink-muted); } .company-ledger.is-balances .ledger-unresolved.is-active { color: var(--color-warning); } @@ -770,25 +773,32 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event @media (max-width: 900px) { .ledger-head.is-balances { display: grid; } } -@media (max-width: 768px) { +@media (max-width: 1179px) { .ledger-head.is-balances, .company-ledger.is-balances summary { - grid-template-columns: minmax(140px, 1.2fr) minmax(140px, 0.9fr) minmax(110px, 0.7fr) minmax(96px, 0.55fr) 24px; + grid-template-columns: minmax(120px, 1.2fr) minmax(0, 0.9fr) minmax(0, 0.7fr) minmax(96px, 0.55fr) 24px; } .ledger-head.is-balances .ledger-hide-md, .company-ledger.is-balances summary .ledger-hide-md { display: none; } + .company-ledger.is-balances summary .amount-with-currency .currency-code { display: none; } } @media (max-width: 375px) { .ledger-head.is-balances { display: none; } .company-ledger.is-balances summary { - grid-template-columns: minmax(0, 1fr) minmax(0, auto) 24px; + grid-template-columns: minmax(88px, 1fr) minmax(0, max-content) 24px; grid-template-rows: auto auto; align-items: center; column-gap: 10px; row-gap: 4px; } - .company-ledger.is-balances summary .company-name { grid-column: 1; grid-row: 1; } - .company-ledger.is-balances summary .ledger-result { grid-column: 2; grid-row: 1; justify-self: end; } - .company-ledger.is-balances summary .ledger-unresolved { grid-column: 1; grid-row: 2; } + .company-ledger.is-balances summary .company-name { grid-column: 1; grid-row: 1; min-width: 88px; } + .company-ledger.is-balances summary .ledger-result { + grid-column: 2; grid-row: 1; justify-self: end; + flex-direction: column; align-items: flex-end; gap: 2px; + } + .company-ledger.is-balances summary .ledger-result .currency-code { display: none; } + .company-ledger.is-balances summary .ledger-unresolved { + grid-column: 1; grid-row: 2; min-width: 0; white-space: normal; + } .company-ledger.is-balances summary .ledger-cutoff { grid-column: 2; grid-row: 2; justify-self: end; } .company-ledger.is-balances summary > svg { grid-column: 3; grid-row: 1 / span 2; align-self: center; } .company-ledger.is-balances summary .ledger-hide-md { display: none; } @@ -807,6 +817,7 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event .pair-balance-line.is-six div:nth-child(5), .pair-balance-line.is-six div:nth-child(6) { border-top: 1px solid var(--color-line); } } .amount-with-currency { font-variant-numeric: tabular-nums; white-space: nowrap; } +.amount-with-currency.is-compact { font-size: 11px; } .amount-with-currency .currency-code { margin-right: 4px; color: var(--color-ink-muted); font-size: 10px; font-weight: 500; } /* Evidence drawer: <768 full, 768–1179 480px, >=1180 640px */ @@ -825,6 +836,7 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event .drawer .pair-balance-line { border: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; margin-bottom: 16px; } .drawer .pair-balance-line div { min-height: 64px; min-width: 0; overflow: hidden; } .drawer .pair-balance-line strong { overflow-wrap: anywhere; } +.drawer .pair-balance-line .amount-with-currency.is-compact { white-space: normal; overflow-wrap: anywhere; } .drawer .pair-balance-line .pair-cutoff, .drawer .pair-balance-line strong.amount-neutral { white-space: nowrap; } .drawer .pair-balance-line.is-six { -- 2.54.0 From 4add17f2b8f9a8a19c584c7e82fc66763f0d5224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=85=BE=E8=AE=AFWorkBuddy?= Date: Thu, 20 Aug 2026 03:22:13 +0800 Subject: [PATCH 6/6] =?UTF-8?q?B-44:=20=E8=BF=94=E4=BF=AE=E2=80=94?= =?UTF-8?q?=E2=80=94768px=20=E8=B6=85=E5=A4=A7=E6=9C=9F=E6=9C=AB=E9=87=91?= =?UTF-8?q?=E9=A2=9D=E4=B8=8E=E6=9C=AA=E5=86=B3=E7=8A=B6=E6=80=81=E9=81=BF?= =?UTF-8?q?=E8=AE=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 376–900px 五列目录把方向 chip 与期末金额分行,并加 Chromium 盒模型回归,避免 15 位金额盖住未决状态。 Co-authored-by: Cursor --- tests/fixtures/b44-balance-directory.html | 138 ++++++ tests/test_b44_frontend.py | 5 + tests/test_b44_layout.py | 530 ++++++++++++++++++++++ web/styles.css | 30 +- 4 files changed, 699 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/b44-balance-directory.html create mode 100644 tests/test_b44_layout.py diff --git a/tests/fixtures/b44-balance-directory.html b/tests/fixtures/b44-balance-directory.html new file mode 100644 index 0000000..541c33d --- /dev/null +++ b/tests/fixtures/b44-balance-directory.html @@ -0,0 +1,138 @@ + + + + + + B-44 余额目录布局回归 + + + +
+ +
+
+
+

往来查询

公司间往来余额目录

+
+

公司余额目录

每行余额都附带截止日、期初状态、本期借贷、结果与未决金额

+
公司借方合计贷方合计期末结果未决截止日
+
+
+ + 甲公司CNY + CNY1,280.00 + CNY320.00 + 应收CNY960.00 + 未决 0.00 + 截止 2026.07.31 + + +
+
+ + 东南沿海综合贸易与供应链管理股份有限公司CNY + CNY123,456,789,012,345.00 + CNY100,000,000,000,000.00 + 应收CNY23,456,789,012,345.00 + 未决 2,150.00 · 3 笔手工记录待审 800.00 · 1 笔;待确认科目 1,350.00 · 2 笔 + 截止 2026.07.31 + + +
+
+
+
+
+
+
+ + + + + diff --git a/tests/test_b44_frontend.py b/tests/test_b44_frontend.py index 0eb2b9e..b7aba20 100644 --- a/tests/test_b44_frontend.py +++ b/tests/test_b44_frontend.py @@ -41,6 +41,11 @@ class B44FrontendContractTests(unittest.TestCase): def test_css_directory_and_drawer_breakpoints(self) -> None: self.assertIn("@media (max-width: 375px)", self.css) self.assertIn("@media (max-width: 1179px)", self.css) + self.assertIn("@media (min-width: 376px) and (max-width: 900px)", self.css) + self.assertIn( + "minmax(80px, 0.85fr) minmax(152px, 1.2fr) minmax(96px, 0.9fr) minmax(84px, 0.5fr) 24px", + self.css, + ) self.assertIn("@media (max-width: 767px)", self.css) self.assertIn(".drawer {", self.css) self.assertIn("width: 640px", self.css) diff --git a/tests/test_b44_layout.py b/tests/test_b44_layout.py new file mode 100644 index 0000000..4323a66 --- /dev/null +++ b/tests/test_b44_layout.py @@ -0,0 +1,530 @@ +"""Chromium layout regression for the B-44 admin balance directory. + +Measures 15-digit period-end amounts against the unresolved chip using +getBoundingClientRect — not static text presence. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +import socket +import subprocess +import tempfile +import threading +import time +from urllib.parse import urlparse +from urllib.request import urlopen +import unittest +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(Path(__file__).resolve().parent) not in sys.path: + sys.path.insert(0, str(Path(__file__).resolve().parent)) +FIXTURE = "/tests/fixtures/b44-balance-directory.html" +CHROME_CANDIDATES = [ + Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) + / "Google/Chrome/Application/chrome.exe", + Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) + / "Microsoft/Edge/Application/msedge.exe", +] +VIEWPORTS = (720, 768, 800, 900, 375, 1024, 1440) + + +def chrome_bin() -> Path: + for path in CHROME_CANDIDATES: + if path.exists(): + return path + raise FileNotFoundError("Chrome/Edge not found for layout regression") + + +class QuietHandler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, directory=str(ROOT), **kwargs) + + def log_message(self, *_args) -> None: + pass + + def handle(self) -> None: + try: + super().handle() + except (ConnectionResetError, BrokenPipeError, TimeoutError): + pass + + +class _Cdp: + def __init__(self, ws_url: str) -> None: + parsed = urlparse(ws_url) + self._sock = socket.create_connection((parsed.hostname, parsed.port or 80), timeout=10) + self._sock.settimeout(10) + key = base64.b64encode(os.urandom(16)).decode() + path = parsed.path + (f"?{parsed.query}" if parsed.query else "") + self._sock.sendall( + ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {parsed.netloc}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n" + ).encode() + ) + header = b"" + while b"\r\n\r\n" not in header: + chunk = self._sock.recv(4096) + if not chunk: + raise ConnectionError("CDP websocket handshake failed") + header += chunk + expected = base64.b64encode( + hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest() + ).decode() + if expected not in header.decode("latin1"): + raise ConnectionError("CDP websocket accept mismatch") + leftover = header.split(b"\r\n\r\n", 1)[1] + self._buf = leftover + self._next_id = 1 + + def call(self, method: str, params: dict | None = None, timeout: float = 15) -> dict: + msg_id = self._next_id + self._next_id += 1 + self._send({"id": msg_id, "method": method, "params": params or {}}) + deadline = time.time() + timeout + while time.time() < deadline: + payload = self._recv() + if payload.get("id") == msg_id: + if "error" in payload: + raise RuntimeError(f"{method}: {payload['error']}") + return payload.get("result") or {} + raise TimeoutError(method) + + def wait_event(self, name: str, timeout: float = 15) -> dict: + deadline = time.time() + timeout + while time.time() < deadline: + payload = self._recv() + if payload.get("method") == name: + return payload.get("params") or {} + raise TimeoutError(name) + + def close(self) -> None: + try: + self._sock.close() + except OSError: + pass + + def _send(self, obj: dict) -> None: + data = json.dumps(obj, separators=(",", ":")).encode() + mask = os.urandom(4) + header = bytearray([0x81]) + length = len(data) + if length < 126: + header.append(0x80 | length) + elif length < 65536: + header.append(0x80 | 126) + header.extend(length.to_bytes(2, "big")) + else: + header.append(0x80 | 127) + header.extend(length.to_bytes(8, "big")) + header.extend(mask) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) + self._sock.sendall(header + masked) + + def _recv(self) -> dict: + while True: + opcode, payload = self._read_frame() + if opcode == 0x9: + self._send_pong(payload) + continue + if opcode == 0xA: + continue + if opcode == 0x8: + raise ConnectionError("CDP websocket closed") + return json.loads(payload.decode()) + + def _send_pong(self, payload: bytes) -> None: + mask = os.urandom(4) + header = bytearray([0x8A, 0x80 | len(payload)]) + header.extend(mask) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + self._sock.sendall(header + masked) + + def _read_frame(self) -> tuple[int, bytes]: + header = self._read_exact(2) + opcode = header[0] & 0x0F + length = header[1] & 0x7F + masked = bool(header[1] & 0x80) + if length == 126: + length = int.from_bytes(self._read_exact(2), "big") + elif length == 127: + length = int.from_bytes(self._read_exact(8), "big") + mask = self._read_exact(4) if masked else b"" + payload = self._read_exact(length) + if masked: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + return opcode, payload + + def _read_exact(self, size: int) -> bytes: + while len(self._buf) < size: + chunk = self._sock.recv(4096) + if not chunk: + raise ConnectionError("CDP websocket closed") + self._buf += chunk + data, self._buf = self._buf[:size], self._buf[size:] + return data + + +class B44LayoutRegressionTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.chrome = chrome_bin() + cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler) + cls.http_thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True) + cls.http_thread.start() + cls.port = cls.httpd.server_address[1] + cls.url = f"http://127.0.0.1:{cls.port}{FIXTURE}" + cls.tmp = tempfile.TemporaryDirectory(prefix="b44-layout-", ignore_cleanup_errors=True) + user_dir = cls.tmp.name + cls.proc = subprocess.Popen( + [ + str(cls.chrome), + "--headless=new", + "--disable-gpu", + "--no-first-run", + "--disable-extensions", + "--remote-debugging-port=0", + f"--user-data-dir={user_dir}", + "about:blank", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + port_file = Path(user_dir) / "DevToolsActivePort" + deadline = time.time() + 15 + listing = None + while time.time() < deadline: + if port_file.exists() and port_file.stat().st_size: + text = port_file.read_text(encoding="utf-8").strip().splitlines() + if text: + cls.debug_port = int(text[0]) + try: + listing = json.loads( + urlopen( + f"http://127.0.0.1:{cls.debug_port}/json/list", + timeout=5, + ).read() + ) + except Exception: + listing = None + if listing and any(item.get("type") == "page" for item in listing): + break + time.sleep(0.05) + else: + raise RuntimeError("Chrome DevTools port not ready") + page = next(item for item in listing if item.get("type") == "page") + cls.cdp = _Cdp(page["webSocketDebuggerUrl"]) + cls.cdp.call("Runtime.enable") + cls.cdp.call("Page.enable") + + @classmethod + def tearDownClass(cls) -> None: + if getattr(cls, "cdp", None): + cls.cdp.close() + if getattr(cls, "proc", None): + cls.proc.terminate() + try: + cls.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + cls.proc.kill() + cls.proc.wait(timeout=5) + time.sleep(0.2) + if getattr(cls, "httpd", None): + cls.httpd.shutdown() + cls.httpd.server_close() + if getattr(cls, "tmp", None): + cls.tmp.cleanup() + + def _metrics(self, width: int) -> dict: + self.cdp.call("Emulation.setDeviceMetricsOverride", { + "width": width, + "height": 1100, + "deviceScaleFactor": 1, + "mobile": False, + }) + self.cdp.call("Page.navigate", {"url": f"{self.url}?w={width}"}) + deadline = time.time() + 10 + last = None + while time.time() < deadline: + result = self.cdp.call( + "Runtime.evaluate", + { + "expression": ( + "typeof window.measureB175 === 'function' " + "? (window.measureB175(), document.getElementById('b175-metrics').textContent) " + ": null" + ), + "returnByValue": True, + }, + ) + last = result.get("result", {}).get("value") + if last: + return json.loads(last) + time.sleep(0.1) + raise TimeoutError(f"layout metrics not ready: {last}") + + def test_fifteen_digit_amount_clear_of_unresolved_chip(self) -> None: + for width in VIEWPORTS: + with self.subTest(width=width): + metrics = self._metrics(width) + self.assertEqual(width, metrics["viewport"], metrics) + self.assertIn("23,456,789,012,345.00", metrics["amountText"]) + self.assertIn("未决", metrics["unresolvedText"]) + self.assertIn("2026.07.31", metrics["cutoffText"]) + self.assertTrue(metrics["amountVisible"]) + self.assertTrue(metrics["unresolvedVisible"]) + self.assertTrue(metrics["cutoffVisible"]) + self.assertFalse(metrics["amountClipped"], metrics) + self.assertFalse(metrics["unresolvedClipped"], metrics) + self.assertFalse(metrics["cutoffClipped"], metrics) + self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics) + self.assertEqual(0, metrics["amountCutoffOverlap"], metrics) + self.assertEqual(0, metrics["unresolvedCutoffOverlap"], metrics) + self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics) + self.assertEqual(0, metrics["cutoffChevronOverlap"], metrics) + self.assertFalse(metrics["drawerAmountClipped"], metrics) + if 376 <= width <= 1179: + self.assertTrue(metrics["fiveColVisible"], metrics) + if 376 <= width <= 900: + self.assertTrue(metrics["stackedResult"], metrics) + if width <= 375: + self.assertFalse(metrics["fiveColVisible"], metrics) + + +LIVE_MEASURE = r""" +(() => { + const pair = document.querySelector('[data-view="pair"]'); + if (pair) pair.click(); + const rows = [...document.querySelectorAll("#balanceLedgers .company-ledger.is-balances")]; + if (!rows.length) return null; + const row = rows.find((item) => (item.textContent || "").includes("123,456,789,012,345")) || rows[0]; + const amount = row.querySelector(".ledger-result .amount-with-currency"); + const direction = row.querySelector(".ledger-result .status"); + const unresolved = row.querySelector(".ledger-unresolved .status") || row.querySelector(".ledger-unresolved"); + const cutoff = row.querySelector(".ledger-cutoff"); + const chevron = row.querySelector("summary > svg"); + const box = (el) => { + const r = el.getBoundingClientRect(); + return { left: r.left, right: r.right, top: r.top, bottom: r.bottom }; + }; + const overlap = (a, b) => { + const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left); + const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + if (dx <= 0 || dy <= 0) return 0; + return Math.round(dx); + }; + const clipped = (el) => el.scrollWidth > el.clientWidth + 1; + const amountBox = box(amount); + const unresolvedBox = box(unresolved); + return { + viewport: window.innerWidth, + amountText: (amount.textContent || "").replace(/\s+/g, " ").trim(), + unresolvedText: (unresolved.textContent || "").replace(/\s+/g, " ").trim(), + cutoffText: (cutoff.textContent || "").replace(/\s+/g, " ").trim(), + amountUnresolvedOverlap: overlap(amountBox, unresolvedBox), + directionUnresolvedOverlap: overlap(box(direction), unresolvedBox), + amountCutoffOverlap: overlap(amountBox, box(cutoff)), + unresolvedCutoffOverlap: overlap(unresolvedBox, box(cutoff)), + cutoffChevronOverlap: overlap(box(cutoff), box(chevron)), + amountClipped: clipped(amount), + unresolvedClipped: clipped(unresolved), + cutoffClipped: clipped(cutoff), + fiveColVisible: getComputedStyle(document.querySelector(".ledger-head.is-balances")).display !== "none", + stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column", + rowCount: rows.length, + }; +})() +""" + +COMPANY_MEASURE = r""" +(() => { + const balances = document.querySelector('[data-view="balances"]'); + if (balances) balances.click(); + const row = document.querySelector(".company-row.is-balance-counterparty"); + if (!row) return null; + const amount = row.querySelector(".amount-with-currency") || row.querySelector(".company-row-figure strong"); + const direction = row.querySelector(".status"); + const cutoff = row.querySelector(".company-row-figure small"); + const box = (el) => { + const r = el.getBoundingClientRect(); + return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height }; + }; + const overlap = (a, b) => { + const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left); + const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + if (dx <= 0 || dy <= 0) return 0; + return Math.round(dx); + }; + return { + viewport: window.innerWidth, + amountText: (amount?.textContent || "").replace(/\s+/g, " ").trim(), + directionText: (direction?.textContent || "").replace(/\s+/g, " ").trim(), + cutoffText: (cutoff?.textContent || "").replace(/\s+/g, " ").trim(), + amountDirectionOverlap: amount && direction ? overlap(box(amount), box(direction)) : 0, + visible: Boolean(amount && amount.getBoundingClientRect().width > 4), + }; +})() +""" + + +class LiveAdminDirectoryLayoutTests(unittest.TestCase): + """Chromium against a real login/API page with B-168-scale 15-digit amounts.""" + + @classmethod + def setUpClass(cls) -> None: + from test_positions_api import IntercompanyApiTests + + cls.api = IntercompanyApiTests("test_admin_balances_directory") + cls.api.setUp() + confirmed = cls.api._fresh_pair("123456789012345.00") + cls.api._confirm(confirmed["ledger_event_id"]) + cls.api._fresh_pair("2150.00") + cls.tmp = tempfile.TemporaryDirectory(prefix="b44-live-", ignore_cleanup_errors=True) + cls.proc = subprocess.Popen( + [ + str(chrome_bin()), + "--headless=new", + "--disable-gpu", + "--no-first-run", + "--disable-extensions", + "--remote-debugging-port=0", + f"--user-data-dir={cls.tmp.name}", + "about:blank", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + port_file = Path(cls.tmp.name) / "DevToolsActivePort" + deadline = time.time() + 15 + listing = None + while time.time() < deadline: + if port_file.exists() and port_file.stat().st_size: + text = port_file.read_text(encoding="utf-8").strip().splitlines() + if text: + cls.debug_port = int(text[0]) + try: + listing = json.loads( + urlopen( + f"http://127.0.0.1:{cls.debug_port}/json/list", + timeout=5, + ).read() + ) + except Exception: + listing = None + if listing and any(item.get("type") == "page" for item in listing): + break + time.sleep(0.05) + else: + raise RuntimeError("Chrome DevTools port not ready") + page = next(item for item in listing if item.get("type") == "page") + cls.cdp = _Cdp(page["webSocketDebuggerUrl"]) + cls.cdp.call("Runtime.enable") + cls.cdp.call("Page.enable") + cls.cdp.call("Network.enable") + token = cls.api.admin.cookies["cw_session"] + cls.cdp.call( + "Network.setCookie", + { + "name": "cw_session", + "value": token, + "url": f"http://127.0.0.1:{cls.api.port}/", + }, + ) + + @classmethod + def tearDownClass(cls) -> None: + if getattr(cls, "cdp", None): + cls.cdp.close() + if getattr(cls, "proc", None): + cls.proc.terminate() + try: + cls.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + cls.proc.kill() + cls.proc.wait(timeout=5) + time.sleep(0.2) + if getattr(cls, "api", None): + cls.api.tearDown() + cls.api.doCleanups() + if getattr(cls, "tmp", None): + cls.tmp.cleanup() + + def _eval(self, expression: str): + deadline = time.time() + 12 + last = None + while time.time() < deadline: + result = self.cdp.call( + "Runtime.evaluate", + {"expression": expression, "returnByValue": True}, + ) + last = result.get("result", {}).get("value") + if last: + return last + time.sleep(0.15) + raise TimeoutError(f"live page metrics not ready: {last}") + + def _open(self, path: str, width: int) -> None: + self.cdp.call("Emulation.setDeviceMetricsOverride", { + "width": width, + "height": 1100, + "deviceScaleFactor": 1, + "mobile": False, + }) + self.cdp.call("Page.navigate", {"url": f"http://127.0.0.1:{self.api.port}/{path}"}) + time.sleep(0.4) + + def test_live_admin_directory_viewports(self) -> None: + for width in (720, 768, 800, 900, 375, 1024, 1440): + with self.subTest(width=width): + self._open("admin.html", width) + metrics = self._eval(LIVE_MEASURE) + self.assertEqual(width, metrics["viewport"], metrics) + self.assertGreaterEqual(metrics["rowCount"], 1, metrics) + self.assertIn("123,456,789,012,345", metrics["amountText"], metrics) + self.assertIn("未决", metrics["unresolvedText"], metrics) + self.assertIn("2026.07.31", metrics["cutoffText"], metrics) + self.assertFalse(metrics["amountClipped"], metrics) + self.assertFalse(metrics["unresolvedClipped"], metrics) + self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics) + self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics) + self.assertEqual(0, metrics["amountCutoffOverlap"], metrics) + if 376 <= width <= 900: + self.assertTrue(metrics["stackedResult"], metrics) + if width <= 375: + self.assertFalse(metrics["fiveColVisible"], metrics) + + def test_live_company_balance_rows(self) -> None: + token = self.api.cashier_a.cookies["cw_session"] + self.cdp.call( + "Network.setCookie", + { + "name": "cw_session", + "value": token, + "url": f"http://127.0.0.1:{self.api.port}/", + }, + ) + for width in (375, 1440): + with self.subTest(width=width): + self._open("company.html", width) + metrics = self._eval(COMPANY_MEASURE) + self.assertTrue(metrics["visible"], metrics) + self.assertTrue(metrics["amountText"], metrics) + self.assertTrue(metrics["directionText"], metrics) + self.assertIn("截止", metrics["cutoffText"], metrics) + self.assertEqual(0, metrics["amountDirectionOverlap"], metrics) + + +if __name__ == "__main__": + unittest.main() + diff --git a/web/styles.css b/web/styles.css index 8655feb..4e9408a 100644 --- a/web/styles.css +++ b/web/styles.css @@ -770,17 +770,39 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event .company-ledger.is-balances .ledger-unresolved.is-active { color: var(--color-warning); } .company-ledger.is-balances .currency-tag { color: var(--color-ink-muted); font-size: 10px; } .company-ledger.is-balances .subject-row small { white-space: nowrap; } -@media (max-width: 900px) { - .ledger-head.is-balances { display: grid; } -} @media (max-width: 1179px) { .ledger-head.is-balances, .company-ledger.is-balances summary { - grid-template-columns: minmax(120px, 1.2fr) minmax(0, 0.9fr) minmax(0, 0.7fr) minmax(96px, 0.55fr) 24px; + grid-template-columns: minmax(120px, 1.2fr) minmax(136px, 0.9fr) minmax(0, 0.7fr) minmax(96px, 0.55fr) 24px; } .ledger-head.is-balances .ledger-hide-md, .company-ledger.is-balances summary .ledger-hide-md { display: none; } .company-ledger.is-balances summary .amount-with-currency .currency-code { display: none; } } +@media (min-width: 376px) and (max-width: 900px) { + .ledger-head.is-balances { display: grid; } + .ledger-head.is-balances, .company-ledger.is-balances summary { + grid-template-columns: minmax(80px, 0.85fr) minmax(152px, 1.2fr) minmax(96px, 0.9fr) minmax(84px, 0.5fr) 24px; + } + .company-ledger.is-balances summary .ledger-result { + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 2px; + } + .company-ledger.is-balances .ledger-result b { font-size: 11px; } + .company-ledger.is-balances .ledger-unresolved { + min-width: 0; + white-space: normal; + } + .company-ledger.is-balances .ledger-unresolved .status { + width: auto; + max-width: 100%; + height: auto; + white-space: normal; + justify-content: flex-start; + text-align: left; + } +} @media (max-width: 375px) { .ledger-head.is-balances { display: none; } .company-ledger.is-balances summary { -- 2.54.0