"""Personal transit account mappings and their administrator approval. A personal transit mapping states that a personal bank account represents a company for a specific flow direction inside an effective interval. The key is the normalized account number; the account holder name is display-only and never resolves a counterparty on its own (``AGENTS.md``: never infer a financial fact from a name alone). Only administrator-approved (``active``) mappings inside their effective window participate in counterparty resolution. """ from __future__ import annotations import sqlite3 from .db import utc_now from .master_data import ( ConflictError, mask_account_number, normalize_account_number, record_change, validate_date, ) MAPPING_STATUSES = ("pending", "active", "returned", "disabled") ALLOWED_DIRECTIONS = ("outgoing", "incoming", "both") def get_mapping(connection: sqlite3.Connection, mapping_id: int) -> sqlite3.Row | None: return connection.execute( "SELECT * FROM personal_transit_mappings WHERE id = ?", (mapping_id,) ).fetchone() def submit_mapping( connection: sqlite3.Connection, *, account_number: object, account_name: object, represented_company_id: int, allowed_direction: str, effective_from: object = None, actor: sqlite3.Row | None, ) -> sqlite3.Row: """Create a pending personal transit mapping; returns the new row. The account number is normalized (digits only) and unique: resubmitting a returned request from the same company reopens the same row as pending; any other existing row is a conflict guarded by the UNIQUE constraint. """ number = normalize_account_number(account_number) holder = str(account_name or "").strip() or None direction = str(allowed_direction or "").strip() if direction not in ALLOWED_DIRECTIONS: raise ValueError(f"允许方向必须是:{'、'.join(ALLOWED_DIRECTIONS)}。") if not holder: raise ValueError("账户户名不能为空(仅作展示,不作匹配键)。") company = connection.execute( "SELECT id FROM companies WHERE id = ?", (represented_company_id,) ).fetchone() if company is None: raise ValueError("代表的公司不存在。") start = validate_date(effective_from, "生效日期") now = utc_now() existing = connection.execute( "SELECT * FROM personal_transit_mappings WHERE account_number = ?", (number,) ).fetchone() if existing is None: try: with connection: cursor = connection.execute( """ INSERT INTO personal_transit_mappings ( account_number, account_name, represented_company_id, allowed_direction, status, effective_from, submitted_by, created_at, updated_at ) VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?) """, ( number, holder, represented_company_id, direction, start, actor["id"] if actor else None, now, now, ), ) except sqlite3.IntegrityError as exc: raise ConflictError("该个人过账账号已登记,请等待现有申请处理。") from exc mapping_id = int(cursor.lastrowid) with connection: record_change( connection, "personal_transit_mapping", mapping_id, "submit", None, {"account_number": number, "account_name": holder, "represented_company_id": represented_company_id, "allowed_direction": direction, "status": "pending", "effective_from": start}, None, actor, ) return get_mapping(connection, mapping_id) if existing["status"] == "returned": with connection: connection.execute( """ UPDATE personal_transit_mappings SET account_name = ?, represented_company_id = ?, allowed_direction = ?, status = 'pending', effective_from = ?, submitted_by = ?, reviewed_by = NULL, reviewed_at = NULL, review_reason = NULL, updated_at = ? WHERE id = ? AND status = 'returned' """, ( holder, represented_company_id, direction, start, actor["id"] if actor else None, now, existing["id"], ), ) record_change( connection, "personal_transit_mapping", existing["id"], "resubmit", None, {"account_number": number, "status": "pending", "allowed_direction": direction, "effective_from": start}, "退回后重新提交", actor, ) return get_mapping(connection, existing["id"]) raise ConflictError("该个人过账账号已登记,请等待现有申请处理。") def review_mapping( connection: sqlite3.Connection, mapping_id: int, decision: str, reason: str | None, actor: sqlite3.Row, *, effective_from: object = None, effective_to: object = None, ) -> sqlite3.Row: """Approve, return or disable a mapping; returns the updated row.""" mapping = get_mapping(connection, mapping_id) if mapping is None: raise LookupError("个人过账映射不存在。") reason = (reason or "").strip() or None today = utc_now()[:10] if decision == "approve": if mapping["status"] != "pending": raise ConflictError("只有待复核的映射可以审核通过。") start = validate_date(effective_from, "生效日期") or mapping["effective_from"] or today with connection: connection.execute( """ UPDATE personal_transit_mappings SET status = 'active', effective_from = ?, effective_to = NULL, reviewed_by = ?, reviewed_at = ?, review_reason = ?, updated_at = ? WHERE id = ? """, (start, actor["id"], utc_now(), reason, utc_now(), mapping_id), ) record_change( connection, "personal_transit_mapping", mapping_id, "approve", None, {"status": "active", "effective_from": start}, reason, actor, ) elif decision == "return": if mapping["status"] != "pending": raise ConflictError("只有待复核的映射可以退回。") if reason is None: raise ValueError("退回必须填写原因。") with connection: connection.execute( """ UPDATE personal_transit_mappings SET status = 'returned', reviewed_by = ?, reviewed_at = ?, review_reason = ?, updated_at = ? WHERE id = ? """, (actor["id"], utc_now(), reason, utc_now(), mapping_id), ) record_change( connection, "personal_transit_mapping", mapping_id, "return", None, {"status": "returned"}, reason, actor, ) elif decision == "disable": if mapping["status"] != "active": raise ConflictError("只有已启用的映射可以停用。") if reason is None: raise ValueError("停用必须填写原因。") end = validate_date(effective_to, "停用日期") or today with connection: connection.execute( """ UPDATE personal_transit_mappings SET status = 'disabled', effective_to = ?, reviewed_by = ?, reviewed_at = ?, review_reason = ?, updated_at = ? WHERE id = ? """, (end, actor["id"], utc_now(), reason, utc_now(), mapping_id), ) record_change( connection, "personal_transit_mapping", mapping_id, "disable", None, {"status": "disabled", "effective_to": end}, reason, actor, ) else: raise ValueError("审核决定必须是 approve、return 或 disable。") return get_mapping(connection, mapping_id) def list_mappings( connection: sqlite3.Connection, company_id: int | None = None, status: str | None = None, ) -> list[sqlite3.Row]: conditions: list[str] = [] params: list[object] = [] if company_id is not None: conditions.append("m.represented_company_id = ?") params.append(company_id) if status is not None: if status not in MAPPING_STATUSES: raise ValueError("无效的映射状态。") conditions.append("m.status = ?") params.append(status) where = f"WHERE {' AND '.join(conditions)}" if conditions else "" return connection.execute( f""" SELECT m.*, c.name AS company_name FROM personal_transit_mappings m JOIN companies c ON c.id = m.represented_company_id {where} ORDER BY m.id """, params, ).fetchall() def is_mapping_active(mapping: sqlite3.Row, on_date: str) -> bool: """True when the mapping may resolve a counterparty on ``on_date``. ``effective_to`` is the last participating day (inclusive). Disabled mappings stop resolving new rows but keep identifying historical rows inside their effective window, matching ``bank_accounts`` semantics. """ if mapping["status"] not in ("active", "disabled"): return False if mapping["effective_from"] and on_date < mapping["effective_from"]: return False if mapping["effective_to"] and on_date > mapping["effective_to"]: return False return True def direction_allowed(mapping: sqlite3.Row, row_direction: str) -> bool: """True when the personal account may represent the company for this flow. ``allowed_direction`` describes the transfer from the represented company's perspective: when the personal account appears as the counterparty of an outgoing row, the represented company is receiving (incoming); for an incoming row the represented company is paying out (outgoing). """ counterparty_flow = "incoming" if row_direction == "outgoing" else "outgoing" allowed = mapping["allowed_direction"] return allowed == "both" or allowed == counterparty_flow def mapping_payload(mapping: sqlite3.Row, *, full: bool) -> dict[str, object]: """Serialize a mapping; company-facing views only ever see masked numbers.""" payload: dict[str, object] = { "id": mapping["id"], "account_name": mapping["account_name"], "represented_company_id": mapping["represented_company_id"], "allowed_direction": mapping["allowed_direction"], "status": mapping["status"], "effective_from": mapping["effective_from"], "effective_to": mapping["effective_to"], "reviewed_at": mapping["reviewed_at"], "review_reason": mapping["review_reason"], "created_at": mapping["created_at"], } if "company_name" in mapping.keys(): payload["company_name"] = mapping["company_name"] if full: payload["account_number"] = mapping["account_number"] else: payload["account_number_masked"] = mask_account_number(mapping["account_number"]) return payload