282 lines
11 KiB
Python
282 lines
11 KiB
Python
"""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
|