B-43: 双边流水归并与规范事件层——迁移5、匹配引擎、个人过账映射与事件API
This commit is contained in:
@@ -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;
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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