B-43: 双边流水归并与规范事件层——迁移5、匹配引擎、个人过账映射与事件API
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user