"""Dynamic master data: companies, bank accounts, aliases and audit trail. Companies, cashier logins and bank accounts live in the database instead of being hard-coded in the UI. Bank account numbers are stored normalized (digits only) under a database UNIQUE constraint, so two concurrent submissions can never produce two usable accounts for the same number. Company-side registrations are requests: only administrator-approved (``active``) accounts inside their effective interval may identify ownership, accept uploads or take part in coverage calculation. Every change is recorded in ``master_data_changes`` with before/after values, actor, time and reason. """ from __future__ import annotations from datetime import date, datetime, timezone import json import re import sqlite3 from .db import utc_now ACCOUNT_TYPES = ("基本户", "一般户", "专用户") ACCOUNT_STATUSES = ("pending", "active", "returned", "disabled") ALIAS_KINDS = ("name", "account") # Deterministic alias match priority: an exact account-number hit always # beats an account alias, which always beats a name alias. The per-alias # ``priority`` column only orders matches within the same tier. PRIORITY_EXACT_ACCOUNT = 0 PRIORITY_ACCOUNT_ALIAS = 1000 PRIORITY_NAME_ALIAS = 2000 _ACCOUNT_NUMBER_PATTERN = re.compile(r"^[0-9]{6,32}$") _DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") _FULL_WIDTH_DIGITS = str.maketrans("0123456789", "0123456789") class ConflictError(ValueError): """A uniqueness or ownership conflict (mapped to HTTP 409).""" def utc_today() -> str: return datetime.now(timezone.utc).date().isoformat() def normalize_account_number(raw: object) -> str: """Normalize an account number to digits only; raise ValueError if invalid. Spaces, dashes and full-width digits entered by cashiers or emitted by bank exports collapse to one canonical form, which is what the UNIQUE constraint and all matching operate on. """ text = str(raw or "").translate(_FULL_WIDTH_DIGITS) text = re.sub(r"[\s\-‐-‒–—]+", "", text) if not _ACCOUNT_NUMBER_PATTERN.fullmatch(text): raise ValueError("银行账号须为 6-32 位数字(可含空格或短横线分隔)。") return text def mask_account_number(number: str) -> str: """Masked display form; the full number stays server-side.""" if len(number) <= 4: return f"****{number}" return f"****{number[-4:]}" def validate_date(value: object, field: str, *, required: bool = False) -> str | None: text = str(value or "").strip() if not text: if required: raise ValueError(f"{field}不能为空。") return None if not _DATE_PATTERN.fullmatch(text): raise ValueError(f"{field}须为 YYYY-MM-DD 格式。") try: date.fromisoformat(text) except ValueError: raise ValueError(f"{field}不是有效日期。") from None return text def validate_account_type(value: object) -> str: text = str(value or "").strip() or "一般户" if text not in ACCOUNT_TYPES: raise ValueError(f"账户类型必须是:{'、'.join(ACCOUNT_TYPES)}。") return text def _snapshot(row: sqlite3.Row | None) -> dict[str, object] | None: if row is None: return None return {key: row[key] for key in row.keys()} def record_change( connection: sqlite3.Connection, entity_type: str, entity_id: int, action: str, before: dict[str, object] | None, after: dict[str, object] | None, reason: str | None, actor: sqlite3.Row | None, ) -> None: """Append a before/after audit entry for a master data change.""" connection.execute( """ INSERT INTO master_data_changes ( entity_type, entity_id, action, before_json, after_json, reason, actor_user_id, actor_username, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( entity_type, entity_id, action, json.dumps(before, ensure_ascii=False) if before is not None else None, json.dumps(after, ensure_ascii=False) if after is not None else None, reason, actor["id"] if actor is not None else None, actor["username"] if actor is not None else None, utc_now(), ), ) # --------------------------------------------------------------------------- # Companies # --------------------------------------------------------------------------- def create_company( connection: sqlite3.Connection, name: str, credit_code: str | None, cashier_name: str | None, actor: sqlite3.Row | None, ) -> int: """Create a company master record; returns the new id.""" name = name.strip() if not name: raise ValueError("公司名称不能为空。") now = utc_now() try: with connection: cursor = connection.execute( """ INSERT INTO companies ( name, credit_code, cashier_name, status, created_at, updated_at ) VALUES (?, ?, ?, 'active', ?, ?) """, (name, (credit_code or "").strip() or None, (cashier_name or "").strip() or None, now, now), ) except sqlite3.IntegrityError as exc: raise ConflictError("公司名称已存在。") from exc company_id = int(cursor.lastrowid) with connection: record_change( connection, "company", company_id, "create", None, {"name": name, "credit_code": credit_code or None, "cashier_name": cashier_name or None, "status": "active"}, None, actor, ) return company_id # --------------------------------------------------------------------------- # Bank accounts # --------------------------------------------------------------------------- def get_account(connection: sqlite3.Connection, account_id: int) -> sqlite3.Row | None: return connection.execute( "SELECT * FROM bank_accounts WHERE id = ?", (account_id,) ).fetchone() def submit_bank_account( connection: sqlite3.Connection, *, company_id: int, bank_name: str, account_type: object, account_number: object, account_name: object = None, start_date: object = None, actor: sqlite3.Row | None, ) -> sqlite3.Row: """Register an account for review; returns the resulting account row. A normalized number already present is never duplicated: resubmitting a returned request from the same company reopens that same row as pending; any other existing row is a conflict. The UNIQUE constraint on ``account_number`` is the final guard for concurrent submissions. """ number = normalize_account_number(account_number) bank = str(bank_name or "").strip() if not bank: raise ValueError("开户银行不能为空。") kind = validate_account_type(account_type) holder = str(account_name or "").strip() or None requested_from = validate_date(start_date, "启用日期") now = utc_now() existing = connection.execute( "SELECT * FROM bank_accounts WHERE account_number = ?", (number,) ).fetchone() if existing is None: try: with connection: cursor = connection.execute( """ INSERT INTO bank_accounts ( company_id, account_number, account_name, bank_name, account_type, status, effective_from, submitted_by, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?) """, (company_id, number, holder, bank, kind, requested_from, actor["id"] if actor else None, now, now), ) except sqlite3.IntegrityError as exc: # Lost a concurrent-insert race on the UNIQUE constraint. raise ConflictError("该银行账号已登记,请等待现有申请处理。") from exc account_id = int(cursor.lastrowid) with connection: record_change( connection, "bank_account", account_id, "submit", None, {"company_id": company_id, "account_number": number, "bank_name": bank, "account_type": kind, "status": "pending", "effective_from": requested_from}, None, actor, ) return get_account(connection, account_id) if existing["status"] == "returned" and existing["company_id"] == company_id: before = _snapshot(existing) with connection: connection.execute( """ UPDATE bank_accounts SET account_name = ?, bank_name = ?, account_type = ?, status = 'pending', effective_from = ?, submitted_by = ?, reviewed_by = NULL, reviewed_at = NULL, review_reason = NULL, updated_at = ? WHERE id = ? AND status = 'returned' """, (holder, bank, kind, requested_from, actor["id"] if actor else None, now, existing["id"]), ) record_change( connection, "bank_account", existing["id"], "resubmit", before, {"account_number": number, "bank_name": bank, "account_type": kind, "status": "pending", "effective_from": requested_from}, "退回后重新提交", actor, ) return get_account(connection, existing["id"]) if existing["company_id"] == company_id: raise ConflictError("该银行账号已登记,请等待现有申请处理。") raise ConflictError("该银行账号已被其他公司登记,请联系总账管理员核对。") def review_bank_account( connection: sqlite3.Connection, account_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 registration; returns the updated row.""" account = get_account(connection, account_id) if account is None: raise LookupError("账户不存在。") reason = (reason or "").strip() or None today = utc_today() before = _snapshot(account) if decision == "approve": if account["status"] != "pending": raise ConflictError("只有待复核的账户可以审核通过。") start = validate_date(effective_from, "启用日期") or account["effective_from"] or today with connection: connection.execute( """ UPDATE bank_accounts 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(), account_id), ) record_change( connection, "bank_account", account_id, "approve", before, {"status": "active", "effective_from": start}, reason, actor, ) elif decision == "return": if account["status"] != "pending": raise ConflictError("只有待复核的账户可以退回。") if reason is None: raise ValueError("退回必须填写原因。") with connection: connection.execute( """ UPDATE bank_accounts SET status = 'returned', reviewed_by = ?, reviewed_at = ?, review_reason = ?, updated_at = ? WHERE id = ? """, (actor["id"], utc_now(), reason, utc_now(), account_id), ) record_change( connection, "bank_account", account_id, "return", before, {"status": "returned"}, reason, actor, ) elif decision == "disable": if account["status"] != "active": raise ConflictError("只有已启用的账户可以停用。") if reason is None: raise ValueError("停用必须填写原因。") end = validate_date(effective_to, "停用日期") or today with connection: connection.execute( """ UPDATE bank_accounts SET status = 'disabled', effective_to = ?, reviewed_by = ?, reviewed_at = ?, review_reason = ?, updated_at = ? WHERE id = ? """, (end, actor["id"], utc_now(), reason, utc_now(), account_id), ) record_change( connection, "bank_account", account_id, "disable", before, {"status": "disabled", "effective_to": end}, reason, actor, ) else: raise ValueError("审核决定必须是 approve、return 或 disable。") return get_account(connection, account_id) def in_effective_window(account: sqlite3.Row, on_date: str) -> bool: """True when ``on_date`` falls inside the account's effective interval. ``effective_to`` is the last participating day (inclusive). """ if account["effective_from"] and on_date < account["effective_from"]: return False if account["effective_to"] and on_date > account["effective_to"]: return False return True def is_usable(account: sqlite3.Row, on_date: str) -> bool: """True when the account may accept uploads / be selected on a date.""" return account["status"] == "active" and in_effective_window(account, on_date) def is_identifiable(account: sqlite3.Row, on_date: str) -> bool: """True when the account may resolve ownership for a transaction date. A disabled account keeps identifying historical rows inside its effective window — disabling stops future participation, it never rewrites history. """ return account["status"] in ("active", "disabled") and in_effective_window( account, on_date ) def list_accounts( 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("a.company_id = ?") params.append(company_id) if status is not None: if status not in ACCOUNT_STATUSES: raise ValueError("无效的账户状态。") conditions.append("a.status = ?") params.append(status) where = f"WHERE {' AND '.join(conditions)}" if conditions else "" return connection.execute( f""" SELECT a.*, c.name AS company_name FROM bank_accounts a JOIN companies c ON c.id = a.company_id {where} ORDER BY a.id """, params, ).fetchall() def usable_accounts( connection: sqlite3.Connection, company_id: int, on_date: str | None = None ) -> list[sqlite3.Row]: """Accounts allowed to upload / identify ownership for the company today.""" day = on_date or utc_today() return [ account for account in list_accounts(connection, company_id=company_id) if is_usable(account, day) ] # --------------------------------------------------------------------------- # Aliases # --------------------------------------------------------------------------- def add_alias( connection: sqlite3.Connection, account_id: int, alias_kind: str, alias_value: object, priority: object = None, effective_from: object = None, effective_to: object = None, actor: sqlite3.Row | None = None, ) -> int: """Attach a name/account alias with an effective interval; returns the id.""" if get_account(connection, account_id) is None: raise LookupError("账户不存在。") if alias_kind not in ALIAS_KINDS: raise ValueError("别名类型必须是 name 或 account。") if alias_kind == "account": value = normalize_account_number(alias_value) else: value = re.sub(r"\s+", "", str(alias_value or "")) if not value: raise ValueError("户名别名不能为空。") try: rank = int(priority) if priority not in (None, "") else 100 except (TypeError, ValueError): raise ValueError("优先级必须是整数。") from None start = validate_date(effective_from, "别名生效日期") end = validate_date(effective_to, "别名失效日期") if start and end and end < start: raise ValueError("别名失效日期不能早于生效日期。") try: with connection: cursor = connection.execute( """ INSERT INTO account_aliases ( bank_account_id, alias_kind, alias_value, priority, effective_from, effective_to, created_by, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (account_id, alias_kind, value, rank, start, end, actor["id"] if actor else None, utc_now()), ) except sqlite3.IntegrityError as exc: raise ConflictError("该账户下相同别名已存在。") from exc alias_id = int(cursor.lastrowid) with connection: record_change( connection, "account_alias", alias_id, "create", None, {"bank_account_id": account_id, "alias_kind": alias_kind, "alias_value": value, "priority": rank, "effective_from": start, "effective_to": end}, None, actor, ) return alias_id def list_aliases(connection: sqlite3.Connection, account_id: int) -> list[sqlite3.Row]: return connection.execute( "SELECT * FROM account_aliases WHERE bank_account_id = ? ORDER BY id", (account_id,), ).fetchall() def match_account( connection: sqlite3.Connection, *, account_number: object = None, name: object = None, on_date: str | None = None, ) -> list[dict[str, object]]: """Resolve an observed counterparty to operating, in-window accounts. Deterministic priority: exact account number < account alias < name alias; within one tier the alias ``priority`` column orders the hits. Pending and returned accounts never match; disabled accounts still match transaction dates inside their effective window (see ``is_identifiable``). """ day = on_date or utc_today() hits: list[tuple[int, sqlite3.Row, str]] = [] number = str(account_number or "").strip() if number: try: normalized = normalize_account_number(number) except ValueError: normalized = None if normalized is not None: exact = connection.execute( "SELECT * FROM bank_accounts WHERE account_number = ?", (normalized,), ).fetchone() if exact is not None and is_identifiable(exact, day): hits.append((PRIORITY_EXACT_ACCOUNT, exact, "account_exact")) alias_rows = connection.execute( """ SELECT a.*, al.priority AS alias_priority, al.effective_from AS alias_from, al.effective_to AS alias_to FROM account_aliases al JOIN bank_accounts a ON a.id = al.bank_account_id WHERE al.alias_kind = 'account' AND al.alias_value = ? """, (normalized,), ).fetchall() for alias in alias_rows: if _alias_in_window(alias, day) and is_identifiable(alias, day): hits.append( (PRIORITY_ACCOUNT_ALIAS + alias["alias_priority"], alias, "account_alias") ) normalized_name = re.sub(r"\s+", "", str(name or "")) if normalized_name: alias_rows = connection.execute( """ SELECT a.*, al.priority AS alias_priority, al.effective_from AS alias_from, al.effective_to AS alias_to FROM account_aliases al JOIN bank_accounts a ON a.id = al.bank_account_id WHERE al.alias_kind = 'name' AND al.alias_value = ? """, (normalized_name,), ).fetchall() for alias in alias_rows: if _alias_in_window(alias, day) and is_identifiable(alias, day): hits.append( (PRIORITY_NAME_ALIAS + alias["alias_priority"], alias, "name_alias") ) hits.sort(key=lambda item: (item[0], item[1]["id"])) seen: set[int] = set() results: list[dict[str, object]] = [] for rank, account, via in hits: if account["id"] in seen: continue seen.add(account["id"]) results.append( { "bank_account_id": account["id"], "company_id": account["company_id"], "via": via, "priority": rank, } ) return results def _alias_in_window(alias: sqlite3.Row, on_date: str) -> bool: if alias["alias_from"] and on_date < alias["alias_from"]: return False if alias["alias_to"] and on_date > alias["alias_to"]: return False return True