B-43: 双边流水归并与规范事件层——迁移5、匹配引擎、个人过账映射与事件API

This commit is contained in:
腾讯WorkBuddy
2026-08-18 21:06:08 +08:00
parent 486842963e
commit df517d4a68
10 changed files with 4565 additions and 49 deletions
+214
View File
@@ -354,6 +354,220 @@ MIGRATIONS: tuple[Migration, ...] = (
DROP TABLE IF EXISTS sheet_reviews;
""",
),
Migration(
version=5,
name="0005_canonical_transfer_matching",
# Canonical transfer event layer (B-43). Immutable source rows stay
# untouched; every matching decision, its participant/observation
# bindings and its candidate evidence are append-only history, and the
# current decision + row claims are a rebuildable projection that the
# B-44 balance layer reads through the eligible view.
up="""
ALTER TABLE import_batches ADD COLUMN upload_bank_account_id INTEGER REFERENCES bank_accounts (id);
CREATE TABLE master_data_changes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias', 'personal_transit_mapping')),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes;
DROP TABLE master_data_changes;
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
CREATE TABLE personal_transit_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_number TEXT NOT NULL UNIQUE,
account_name TEXT,
represented_company_id INTEGER NOT NULL REFERENCES companies (id),
allowed_direction TEXT NOT NULL
CHECK (allowed_direction IN ('outgoing', 'incoming', 'both')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'returned', 'disabled')),
effective_from TEXT,
effective_to TEXT,
submitted_by INTEGER REFERENCES users (id),
reviewed_by INTEGER REFERENCES users (id),
reviewed_at TEXT,
review_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE canonical_transfer_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lifecycle TEXT NOT NULL DEFAULT 'active'
CHECK (lifecycle IN ('active', 'superseded')),
created_at TEXT NOT NULL
);
CREATE TABLE transfer_match_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES canonical_transfer_events (id),
revision INTEGER NOT NULL,
classification TEXT NOT NULL CHECK (classification IN (
'unresolved', 'needs_review', 'internal_single',
'intercompany', 'same_company', 'external'
)),
pairing TEXT NOT NULL
CHECK (pairing IN ('single', 'paired', 'not_applicable')),
amount TEXT,
currency TEXT,
effective_at TEXT,
mode TEXT NOT NULL CHECK (mode IN ('auto', 'manual', 'reversal')),
rule_version TEXT,
locked INTEGER NOT NULL DEFAULT 0 CHECK (locked IN (0, 1)),
reason TEXT,
idempotency_key TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
supersedes_decision_id INTEGER REFERENCES transfer_match_decisions (id),
created_at TEXT NOT NULL,
UNIQUE (event_id, revision)
);
CREATE TABLE transfer_decision_observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id),
source_row_id INTEGER NOT NULL REFERENCES source_rows (id),
role TEXT NOT NULL CHECK (role IN ('outgoing', 'incoming')),
created_at TEXT NOT NULL,
UNIQUE (decision_id, source_row_id)
);
CREATE TABLE transfer_decision_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id),
role TEXT NOT NULL CHECK (role IN ('payer', 'payee')),
company_id INTEGER REFERENCES companies (id),
bank_account_id INTEGER REFERENCES bank_accounts (id),
resolve_method TEXT NOT NULL CHECK (resolve_method IN (
'own_exact', 'upload_account', 'counterparty_exact',
'account_alias', 'personal_mapping', 'manual'
)),
alias_id INTEGER REFERENCES account_aliases (id),
mapping_id INTEGER REFERENCES personal_transit_mappings (id),
evidence TEXT,
created_at TEXT NOT NULL,
UNIQUE (decision_id, role)
);
CREATE TABLE transfer_match_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id),
source_row_id INTEGER NOT NULL REFERENCES source_rows (id),
rule_tier TEXT NOT NULL,
date_diff_days INTEGER,
account_mirror INTEGER NOT NULL DEFAULT 0,
reference_match TEXT,
summary_match INTEGER NOT NULL DEFAULT 0,
accepted INTEGER,
reason TEXT,
rule_version TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE current_transfer_decisions (
event_id INTEGER PRIMARY KEY REFERENCES canonical_transfer_events (id),
decision_id INTEGER NOT NULL UNIQUE REFERENCES transfer_match_decisions (id)
);
CREATE TABLE transfer_observation_claims (
source_row_id INTEGER PRIMARY KEY REFERENCES source_rows (id),
event_id INTEGER NOT NULL REFERENCES canonical_transfer_events (id),
decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id)
);
CREATE INDEX idx_obs_decision ON transfer_decision_observations (decision_id);
CREATE INDEX idx_participants_decision ON transfer_decision_participants (decision_id);
CREATE INDEX idx_candidates_decision ON transfer_match_candidates (decision_id);
CREATE INDEX idx_claims_event ON transfer_observation_claims (event_id);
CREATE VIEW eligible_intercompany_events AS
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
payer.company_id AS payer_company_id,
payer.bank_account_id AS payer_account_id,
payee.company_id AS payee_company_id,
payee.bank_account_id AS payee_account_id,
d.pairing AS pairing, d.rule_version AS rule_version,
(SELECT COUNT(*) FROM transfer_decision_observations o
WHERE o.decision_id = d.id) AS evidence_count
FROM current_transfer_decisions c
JOIN canonical_transfer_events e ON e.id = c.event_id
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany';
CREATE TRIGGER canonical_transfer_events_no_delete BEFORE DELETE ON canonical_transfer_events
BEGIN SELECT RAISE (ABORT, 'canonical_transfer_events rows are immutable'); END;
CREATE TRIGGER canonical_transfer_events_no_update BEFORE UPDATE ON canonical_transfer_events
BEGIN
SELECT RAISE (ABORT, 'canonical_transfer_events only allow lifecycle changes')
WHERE OLD.lifecycle = NEW.lifecycle
OR OLD.id IS NOT NEW.id
OR OLD.created_at IS NOT NEW.created_at;
END;
CREATE TRIGGER transfer_match_decisions_no_update BEFORE UPDATE ON transfer_match_decisions
BEGIN SELECT RAISE (ABORT, 'transfer_match_decisions rows are immutable'); END;
CREATE TRIGGER transfer_match_decisions_no_delete BEFORE DELETE ON transfer_match_decisions
BEGIN SELECT RAISE (ABORT, 'transfer_match_decisions rows are immutable'); END;
CREATE TRIGGER transfer_decision_observations_no_update BEFORE UPDATE ON transfer_decision_observations
BEGIN SELECT RAISE (ABORT, 'transfer_decision_observations rows are immutable'); END;
CREATE TRIGGER transfer_decision_observations_no_delete BEFORE DELETE ON transfer_decision_observations
BEGIN SELECT RAISE (ABORT, 'transfer_decision_observations rows are immutable'); END;
CREATE TRIGGER transfer_decision_participants_no_update BEFORE UPDATE ON transfer_decision_participants
BEGIN SELECT RAISE (ABORT, 'transfer_decision_participants rows are immutable'); END;
CREATE TRIGGER transfer_decision_participants_no_delete BEFORE DELETE ON transfer_decision_participants
BEGIN SELECT RAISE (ABORT, 'transfer_decision_participants rows are immutable'); END;
CREATE TRIGGER transfer_match_candidates_no_update BEFORE UPDATE ON transfer_match_candidates
BEGIN SELECT RAISE (ABORT, 'transfer_match_candidates rows are immutable'); END;
CREATE TRIGGER transfer_match_candidates_no_delete BEFORE DELETE ON transfer_match_candidates
BEGIN SELECT RAISE (ABORT, 'transfer_match_candidates rows are immutable'); END;
""",
down="""
DROP VIEW IF EXISTS eligible_intercompany_events;
DROP TABLE IF EXISTS transfer_observation_claims;
DROP TABLE IF EXISTS current_transfer_decisions;
DROP TABLE IF EXISTS transfer_match_candidates;
DROP TABLE IF EXISTS transfer_decision_participants;
DROP TABLE IF EXISTS transfer_decision_observations;
DROP TABLE IF EXISTS transfer_match_decisions;
DROP TABLE IF EXISTS canonical_transfer_events;
DROP TABLE IF EXISTS personal_transit_mappings;
CREATE TABLE master_data_changes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias')),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes;
DROP TABLE master_data_changes;
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
ALTER TABLE import_batches DROP COLUMN upload_bank_account_id;
""",
),
)
+85 -23
View File
@@ -22,8 +22,10 @@ import os
from pathlib import Path
import shutil
import sqlite3
import tempfile
from . import auth
from . import matching
from .db import utc_now
from .models import SheetResult, StatementBatch
from .parser import StatementParseError, analyze_workbook
@@ -52,6 +54,7 @@ def import_statement(
original_filename: str,
content: bytes,
company_id: int | None = None,
upload_bank_account_id: int | None = None,
) -> ImportResult:
sha256 = hashlib.sha256(content).hexdigest()
existing_file = connection.execute(
@@ -64,7 +67,8 @@ def import_statement(
stored_path = _store_immutable(Path(storage_dir), original_filename, content, sha256)
try:
source_file_id, batch_id = _create_batch_records(
connection, sha256, original_filename, len(content), stored_path, company_id
connection, sha256, original_filename, len(content), stored_path,
company_id, upload_bank_account_id,
)
except sqlite3.IntegrityError:
# Lost a concurrent-insert race on the sha256 UNIQUE constraint: the
@@ -87,13 +91,16 @@ def import_statement_path(
original_filename: str,
upload_path: str | Path,
company_id: int | None = None,
upload_bank_account_id: int | None = None,
) -> ImportResult:
"""Import from an already-downloaded upload file (streaming-friendly).
``upload_path`` names the temp file the multipart handler streamed to
disk; it is hashed incrementally and either discarded (duplicate) or
published into the content-addressed store. The upload file itself is
never modified.
never modified. The approved ``upload_bank_account_id`` used at upload time
is persisted on the batch so ownership can be resolved later even when the
source rows carry no own account.
"""
source = Path(upload_path)
sha256 = _file_sha256(source)
@@ -109,7 +116,8 @@ def import_statement_path(
size = stored_path.stat().st_size
try:
source_file_id, batch_id = _create_batch_records(
connection, sha256, original_filename, size, stored_path, company_id
connection, sha256, original_filename, size, stored_path,
company_id, upload_bank_account_id,
)
except sqlite3.IntegrityError:
# Lost a concurrent-insert race on the sha256 UNIQUE constraint: the
@@ -133,6 +141,7 @@ def _create_batch_records(
size_bytes: int,
stored_path: Path,
company_id: int | None,
upload_bank_account_id: int | None,
) -> tuple[int, int]:
now = utc_now()
with connection:
@@ -146,10 +155,10 @@ def _create_batch_records(
source_file_id = cursor.lastrowid
cursor = connection.execute(
"""
INSERT INTO import_batches (source_file_id, status, company_id, created_at, updated_at)
VALUES (?, 'parsing', ?, ?, ?)
INSERT INTO import_batches (source_file_id, status, company_id, upload_bank_account_id, created_at, updated_at)
VALUES (?, 'parsing', ?, ?, ?, ?)
""",
(source_file_id, company_id, now, now),
(source_file_id, company_id, upload_bank_account_id, now, now),
)
batch_id = cursor.lastrowid
return source_file_id, batch_id
@@ -278,17 +287,24 @@ def _store_immutable(
target_dir = storage_dir / sha256[:2]
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / f"{sha256}{suffix}"
# Publish via a temporary file + hard link: the content-addressed target
# either appears complete or not at all, and is never overwritten.
temp = target_dir / f".{sha256}.tmp"
temp.write_bytes(content)
# Publish via a unique temporary file + hard link: the content-addressed
# target either appears complete or not at all, and is never overwritten.
# The temp name is unique per writer, so concurrent uploads of the same
# bytes cannot corrupt each other's staging file (B-43 baseline fix).
fd, temp_path = tempfile.mkstemp(prefix=f".{sha256}.", suffix=".tmp", dir=target_dir)
try:
os.link(temp, target)
except FileExistsError:
# Content-addressed name means identical bytes; never overwrite.
pass
with os.fdopen(fd, "wb") as handle:
handle.write(content)
try:
os.link(temp_path, target)
except FileExistsError:
# Content-addressed name means identical bytes; never overwrite.
pass
finally:
temp.unlink(missing_ok=True)
try:
os.unlink(temp_path)
except FileNotFoundError:
pass
return target
@@ -299,17 +315,24 @@ def _publish_immutable(
target_dir = storage_dir / sha256[:2]
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / f"{sha256}{suffix}"
temp = target_dir / f".{sha256}.tmp"
fd, temp_path = tempfile.mkstemp(prefix=f".{sha256}.", suffix=".tmp", dir=target_dir)
try:
# Copy keeps the same-filesystem guarantee even if the upload temp
# lives elsewhere; the hard link then publishes atomically.
shutil.copyfile(upload_path, temp)
with os.fdopen(fd, "wb") as handle:
# Copy keeps the same-filesystem guarantee even if the upload temp
# lives elsewhere; the hard link then publishes atomically. The
# staging file is unique per writer, so two concurrent uploads of
# the same bytes never corrupt each other's copy.
with upload_path.open("rb") as source:
shutil.copyfileobj(source, handle, length=1024 * 1024)
try:
os.link(temp, target)
os.link(temp_path, target)
except FileExistsError:
pass
finally:
temp.unlink(missing_ok=True)
try:
os.unlink(temp_path)
except FileNotFoundError:
pass
upload_path.unlink(missing_ok=True)
return target
@@ -572,7 +595,15 @@ def review_sheets(
now = utc_now()
updated: list[str] = []
already: list[str] = []
with connection:
matching_result: dict[str, object] | None = None
# BEGIN IMMEDIATE serializes concurrent confirmations: the write lock is
# taken before the read, so two cashiers confirming matching worksheets can
# never derive their match decisions from stale snapshots.
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
for row in rows:
if row["review_status"] == target:
already.append(row["sheet_name"])
@@ -596,6 +627,34 @@ def review_sheets(
if cursor.rowcount:
updated.append(row["sheet_name"])
# Confirming a worksheet and reconciling its rows must succeed or fail
# together: any matching failure rolls the confirmation back, so there
# is never a "confirmed but unmatched" half state.
if updated and decision == "confirm":
placeholders = ",".join("?" for _ in updated)
confirmed_rows = connection.execute(
f"""
SELECT r.id FROM source_rows r
JOIN sheet_batches s ON s.id = r.sheet_batch_id
JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id
WHERE rv.sheet_name IN ({placeholders})
AND rv.import_batch_id = ?
ORDER BY r.id
""",
(*updated, batch_id),
).fetchall()
matching_result = matching.reconcile_rows(
connection,
[item["id"] for item in confirmed_rows],
actor=actor,
)
if began:
connection.commit()
except Exception:
if began:
connection.rollback()
raise
if updated:
auth.audit(
connection,
@@ -604,4 +663,7 @@ def review_sheets(
target=f"batch:{batch_id}",
detail=f"sheets:{','.join(updated)}" + (f";reason:{reason}" if reason else ""),
)
return {"updated": updated, "already": already}
payload: dict[str, object] = {"updated": updated, "already": already}
if matching_result is not None:
payload["matching"] = matching_result
return payload
File diff suppressed because it is too large Load Diff
+281
View File
@@ -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