Files
caiwuzongzhang/src/bank_importer/master_data.py
T
腾讯WorkBuddy 51c301d9df B-41: 动态主数据——公司、银行账户、别名与审核轨迹
- 公司、公司账号、银行账户、账户类型、户名/账号别名和生效区间
  全部来自数据库,新增公司无需改代码即可检索与使用。
- 公司端提交账户登记,总账端审核为启用/退回/停用;未启用账户
  不参与所有权识别、上传或覆盖计算。
- 账号归一化、脱敏显示与数据库 UNIQUE 约束,并发提交不产生重复
  有效账户;别名匹配有优先级和有效期。
- 所有主数据变更记录前值、后值、操作人、时间和原因。
- 决策记录见 docs/decisions/004-master-data.md。
2026-08-16 01:49:03 +08:00

571 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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