- 公司、公司账号、银行账户、账户类型、户名/账号别名和生效区间 全部来自数据库,新增公司无需改代码即可检索与使用。 - 公司端提交账户登记,总账端审核为启用/退回/停用;未启用账户 不参与所有权识别、上传或覆盖计算。 - 账号归一化、脱敏显示与数据库 UNIQUE 约束,并发提交不产生重复 有效账户;别名匹配有优先级和有效期。 - 所有主数据变更记录前值、后值、操作人、时间和原因。 - 决策记录见 docs/decisions/004-master-data.md。
571 lines
21 KiB
Python
571 lines
21 KiB
Python
"""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
|