1602 lines
58 KiB
Python
1602 lines
58 KiB
Python
"""Canonical transfer matching: deterministic bilateral pairing and event layer.
|
|
|
|
Immutable bank source rows are observations. This module turns one or two
|
|
confirmed observations into one canonical transfer event by appending
|
|
decisions to an append-only log and maintaining a rebuildable current
|
|
projection. It never modifies ``source_rows``.
|
|
|
|
Confirmed business rules (B-43/B-114):
|
|
|
|
- A single observation stays in the unresolved amount bucket even when both
|
|
participants are uniquely identified by approved accounts; only a bilateral
|
|
merge (``intercompany`` + ``paired``) or an administrator confirmation based
|
|
on evidence (``intercompany`` + locked manual single) enters the confirmed
|
|
B-44 balance.
|
|
- The economic date of a cross-day event is the payer's outgoing bank posting
|
|
time, never the first-import time.
|
|
- Automatic window v1: same non-empty reference + mirrored accounts <= 3
|
|
calendar days (M1); exact account mirror without reference <= 1 day (M2);
|
|
alias/personal-mapping mirror <= 1 day with reference or summary equality
|
|
(M3). Anything over-window or with 2+ same-tier candidates goes to review.
|
|
- Personal transit mappings bind a specific account, an effective interval, an
|
|
allowed direction and a represented company, and require administrator
|
|
approval; names are supporting evidence only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from decimal import Decimal, InvalidOperation
|
|
import json
|
|
import re
|
|
import sqlite3
|
|
|
|
from .auth import audit
|
|
from .db import utc_now
|
|
from .master_data import (
|
|
is_identifiable,
|
|
normalize_account_number,
|
|
)
|
|
from . import personal_transit
|
|
|
|
RULE_VERSION = "transfer-match-v1"
|
|
|
|
CLASSIFICATION_UNRESOLVED = "unresolved"
|
|
CLASSIFICATION_NEEDS_REVIEW = "needs_review"
|
|
CLASSIFICATION_INTERNAL_SINGLE = "internal_single"
|
|
CLASSIFICATION_INTERCOMPANY = "intercompany"
|
|
CLASSIFICATION_SAME_COMPANY = "same_company"
|
|
CLASSIFICATION_EXTERNAL = "external"
|
|
|
|
PAIRING_SINGLE = "single"
|
|
PAIRING_PAIRED = "paired"
|
|
PAIRING_NA = "not_applicable"
|
|
|
|
MODE_AUTO = "auto"
|
|
MODE_MANUAL = "manual"
|
|
MODE_REVERSAL = "reversal"
|
|
|
|
MAX_DATE_DIFF_DAYS = 3
|
|
AUTO_TIERS = ("M1", "M2", "M3")
|
|
REVIEW_TIERS = ("R1", "R2")
|
|
|
|
_FULL_WIDTH = str.maketrans(
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
"abcdefghijklmnopqrstuvwxyz0123456789",
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
"abcdefghijklmnopqrstuvwxyz0123456789",
|
|
)
|
|
# Bank noise words stripped from summaries before comparison (M3). Only exact
|
|
# equality after normalization decides; no fuzzy matching or AI guesses.
|
|
_SUMMARY_NOISE = (
|
|
"转账", "汇款", "网上银行", "手机银行", "跨行", "行内", "对公",
|
|
"跨行转账", "行内转账", "网银转账", "速汇", "实时", "普通", "加急",
|
|
)
|
|
|
|
|
|
class MatchConflictError(ValueError):
|
|
"""A decision/claim/lock conflict (mapped to HTTP 409)."""
|
|
|
|
|
|
class MatchInputError(ValueError):
|
|
"""Invalid input for matching (mapped to HTTP 400/422)."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Small value objects
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Resolved:
|
|
"""One participant endpoint resolved from approved identifiers."""
|
|
|
|
role: str
|
|
company_id: int
|
|
bank_account_id: int | None
|
|
resolve_method: str
|
|
alias_id: int | None = None
|
|
mapping_id: int | None = None
|
|
evidence: dict = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RowResolution:
|
|
direction: str | None
|
|
own: Resolved | None
|
|
own_reason: str | None
|
|
counterparty: Resolved | None
|
|
counterparty_reason: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CandidateHit:
|
|
row_id: int
|
|
tier: str
|
|
date_diff_days: int
|
|
account_mirror: str # exact | alias | none
|
|
reference_match: str | None # same | conflict | None
|
|
summary_match: bool
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parsing and normalization helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def parse_direction(income: object, expense: object) -> str | None:
|
|
"""Exactly one positive amount decides the direction; anything else is None."""
|
|
income = Decimal(str(income or "0"))
|
|
expense = Decimal(str(expense or "0"))
|
|
if expense > 0 and income == 0:
|
|
return "outgoing"
|
|
if income > 0 and expense == 0:
|
|
return "incoming"
|
|
return None
|
|
|
|
|
|
def amount_of_row(row: sqlite3.Row) -> Decimal | None:
|
|
income = Decimal(str(row["income"] or "0"))
|
|
expense = Decimal(str(row["expense"] or "0"))
|
|
if income > 0 and expense == 0:
|
|
return income
|
|
if expense > 0 and income == 0:
|
|
return expense
|
|
return None
|
|
|
|
|
|
def normalize_reference(value: object) -> str | None:
|
|
text = str(value or "").translate(_FULL_WIDTH)
|
|
text = re.sub(r"\s+", "", text)
|
|
return text or None
|
|
|
|
|
|
def normalize_summary(row: sqlite3.Row) -> str:
|
|
parts = []
|
|
for key in ("summary", "purpose"):
|
|
text = str(row[key] or "").translate(_FULL_WIDTH)
|
|
text = re.sub(r"[\s\ufeff]+", "", text)
|
|
for noise in _SUMMARY_NOISE:
|
|
text = text.replace(noise, "")
|
|
if text:
|
|
parts.append(text)
|
|
return "|".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Transaction helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ensure_transaction(connection: sqlite3.Connection):
|
|
"""Begin an immediate transaction unless one is already open.
|
|
|
|
``reconcile_rows`` / ``apply_manual_decision`` may run standalone (they own
|
|
the transaction) or nested inside the caller's ``with connection:`` block
|
|
(the worksheet-confirm flow); nested calls never start their own commit.
|
|
"""
|
|
began = False
|
|
if not connection.in_transaction:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
began = True
|
|
return began
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Participant resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _normalized_account(value: object) -> str | None:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return None
|
|
try:
|
|
return normalize_account_number(text)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _resolve_own(
|
|
connection: sqlite3.Connection,
|
|
row: sqlite3.Row,
|
|
on_date: str,
|
|
upload_account_id: int | None,
|
|
) -> tuple[Resolved | None, str | None]:
|
|
"""Resolve the owner endpoint: source-row own account first, the approved
|
|
upload account as fallback. A company conflict between the two is a review
|
|
signal, never a guess.
|
|
"""
|
|
own_number = _normalized_account(row["own_account"])
|
|
|
|
own: Resolved | None = None
|
|
own_reason: str | None = None
|
|
if own_number is not None:
|
|
account = connection.execute(
|
|
"SELECT * FROM bank_accounts WHERE account_number = ?", (own_number,)
|
|
).fetchone()
|
|
if account is not None and is_identifiable(account, on_date):
|
|
own = Resolved(
|
|
role="", company_id=account["company_id"],
|
|
bank_account_id=account["id"],
|
|
resolve_method="own_exact",
|
|
evidence={"account_number": own_number, "via": "own_account"},
|
|
)
|
|
else:
|
|
own_reason = "本方账号未能匹配到生效期内已批准的账户"
|
|
|
|
if upload_account_id is not None:
|
|
upload = connection.execute(
|
|
"SELECT * FROM bank_accounts WHERE id = ?", (upload_account_id,)
|
|
).fetchone()
|
|
if upload is not None and is_identifiable(upload, on_date):
|
|
if own is not None and own.company_id != upload["company_id"]:
|
|
return None, "本方账号与上传账户归属公司冲突"
|
|
if own is None:
|
|
own = Resolved(
|
|
role="", company_id=upload["company_id"],
|
|
bank_account_id=upload["id"],
|
|
resolve_method="upload_account",
|
|
evidence={"account_number": upload["account_number"],
|
|
"via": "upload_bank_account"},
|
|
)
|
|
own_reason = None
|
|
|
|
return own, own_reason
|
|
|
|
|
|
def _resolve_counterparty(
|
|
connection: sqlite3.Connection,
|
|
row: sqlite3.Row,
|
|
on_date: str,
|
|
direction: str | None,
|
|
) -> tuple[Resolved | None, str | None]:
|
|
"""Resolve the counterparty strictly by account identifier tiers.
|
|
|
|
Tier order: exact ``bank_accounts`` number -> approved ``account`` alias ->
|
|
approved in-window personal transit mapping (with direction allowed). Names
|
|
never resolve a counterparty; they only supply conflict evidence.
|
|
"""
|
|
number = _normalized_account(row["counterparty_account"])
|
|
if number is None:
|
|
return None, "无对方账号"
|
|
|
|
exact = connection.execute(
|
|
"SELECT * FROM bank_accounts WHERE account_number = ?", (number,)
|
|
).fetchone()
|
|
if exact is not None and is_identifiable(exact, on_date):
|
|
return _finish_counterparty(connection, row, exact, "counterparty_exact", None, None)
|
|
|
|
alias_rows = connection.execute(
|
|
"""
|
|
SELECT a.*, al.id AS alias_row_id, 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 = ?
|
|
""",
|
|
(number,),
|
|
).fetchall()
|
|
in_window = [
|
|
alias for alias in alias_rows
|
|
if _alias_in_window(alias, on_date) and is_identifiable(alias, on_date)
|
|
]
|
|
if len(in_window) == 1:
|
|
alias = in_window[0]
|
|
return _finish_counterparty(
|
|
connection, row, alias, "account_alias", alias["alias_row_id"], None,
|
|
)
|
|
if len(in_window) > 1:
|
|
return None, "对方账号命中多个账号别名,落入人工审核"
|
|
|
|
mapping = connection.execute(
|
|
"SELECT * FROM personal_transit_mappings WHERE account_number = ?", (number,)
|
|
).fetchone()
|
|
if mapping is not None and personal_transit.is_mapping_active(mapping, on_date):
|
|
if direction is not None and not personal_transit.direction_allowed(mapping, direction):
|
|
return None, "个人过账映射方向与该笔交易不符"
|
|
return _finish_counterparty(
|
|
connection, row, mapping, "personal_mapping", None, mapping["id"],
|
|
)
|
|
|
|
return None, "对方账号未能匹配到已批准账户、账号别名或个人过账映射"
|
|
|
|
|
|
def _finish_counterparty(
|
|
connection: sqlite3.Connection,
|
|
row: sqlite3.Row,
|
|
target: sqlite3.Row,
|
|
method: str,
|
|
alias_id: int | None,
|
|
mapping_id: int | None,
|
|
) -> tuple[Resolved | None, str | None]:
|
|
"""Attach a resolved counterparty; a conflicting name alias downgrades it.
|
|
|
|
A name alias belonging to a different company than the account evidence is
|
|
a conflict that must go to review. A name alias matching the same company
|
|
is recorded as supporting evidence only.
|
|
"""
|
|
company_id = target["company_id"] if "company_id" in target.keys() else target["represented_company_id"]
|
|
bank_account_id = target["id"] if "company_id" in target.keys() else None
|
|
evidence: dict[str, object] = {
|
|
"account_number": _normalized_account(row["counterparty_account"]),
|
|
"via": method,
|
|
}
|
|
if mapping_id is not None:
|
|
evidence["mapping_id"] = mapping_id
|
|
if alias_id is not None:
|
|
evidence["alias_id"] = alias_id
|
|
|
|
name = str(row["counterparty_name"] or "").strip()
|
|
if name:
|
|
name_rows = connection.execute(
|
|
"""
|
|
SELECT a.company_id, al.bank_account_id
|
|
FROM account_aliases al
|
|
JOIN bank_accounts a ON a.id = al.bank_account_id
|
|
WHERE al.alias_kind = 'name' AND al.alias_value = ?
|
|
""",
|
|
(re.sub(r"\s+", "", name),),
|
|
).fetchall()
|
|
conflicts = [hit for hit in name_rows if hit["company_id"] != company_id]
|
|
if conflicts:
|
|
return None, "对方户名别名归属与账号证据冲突,落入人工审核"
|
|
if name_rows:
|
|
evidence["name_alias"] = name
|
|
|
|
resolved = Resolved(
|
|
role="", company_id=company_id, bank_account_id=bank_account_id,
|
|
resolve_method=method, alias_id=alias_id, mapping_id=mapping_id,
|
|
evidence=evidence,
|
|
)
|
|
return resolved, None
|
|
|
|
|
|
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
|
|
|
|
|
|
def _load_rows(
|
|
connection: sqlite3.Connection, row_ids: list[int]
|
|
) -> list[sqlite3.Row]:
|
|
"""Load source rows joined with their batch ownership/upload account."""
|
|
placeholders = ",".join("?" for _ in row_ids)
|
|
return connection.execute(
|
|
f"""
|
|
SELECT r.*, s.sheet_name, rv.review_status,
|
|
b.company_id AS batch_company_id,
|
|
b.upload_bank_account_id AS upload_bank_account_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
|
|
JOIN import_batches b ON b.id = s.import_batch_id
|
|
WHERE r.id IN ({placeholders})
|
|
ORDER BY r.id
|
|
""",
|
|
row_ids,
|
|
).fetchall()
|
|
|
|
|
|
def resolve_row(
|
|
connection: sqlite3.Connection, row: sqlite3.Row
|
|
) -> RowResolution:
|
|
"""Resolve one source row's direction, own and counterparty endpoints."""
|
|
on_date = _date_only(row["transaction_at"])
|
|
direction = parse_direction(row["income"], row["expense"])
|
|
own, own_reason = _resolve_own(
|
|
connection, row, on_date,
|
|
row["upload_bank_account_id"] if "upload_bank_account_id" in row.keys() else None,
|
|
)
|
|
|
|
if (
|
|
own is not None
|
|
and row["batch_company_id"] is not None
|
|
and own.resolve_method == "upload_account"
|
|
and own.company_id != row["batch_company_id"]
|
|
):
|
|
own, own_reason = None, "上传账户与批次公司不一致"
|
|
|
|
counterparty, cp_reason = None, None
|
|
if direction is not None and own is not None:
|
|
counterparty, cp_reason = _resolve_counterparty(
|
|
connection, row, on_date, direction
|
|
)
|
|
elif direction is not None:
|
|
cp_reason = "本方未解析,无法解析对方"
|
|
|
|
return RowResolution(
|
|
direction, own, own_reason, counterparty, cp_reason,
|
|
)
|
|
|
|
|
|
def _date_only(value: object) -> str:
|
|
text = str(value or "")[:10]
|
|
return text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Candidate search
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _confirmed_rows(connection: sqlite3.Connection) -> list[sqlite3.Row]:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT r.*, s.sheet_name, rv.review_status,
|
|
b.company_id AS batch_company_id,
|
|
b.upload_bank_account_id AS upload_bank_account_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
|
|
JOIN import_batches b ON b.id = s.import_batch_id
|
|
WHERE rv.review_status = 'confirmed'
|
|
ORDER BY r.id
|
|
"""
|
|
).fetchall()
|
|
locked = connection.execute(
|
|
"""
|
|
SELECT c.source_row_id FROM transfer_observation_claims c
|
|
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
|
WHERE d.locked = 1
|
|
"""
|
|
).fetchall()
|
|
locked_ids = {item["source_row_id"] for item in locked}
|
|
return [row for row in rows if row["id"] not in locked_ids]
|
|
|
|
|
|
def _find_candidates(
|
|
connection: sqlite3.Connection,
|
|
row: sqlite3.Row,
|
|
res: RowResolution,
|
|
pool: list[sqlite3.Row],
|
|
resolutions: dict[int, RowResolution],
|
|
) -> tuple[list[CandidateHit], str | None]:
|
|
"""Deterministic candidate search over confirmed rows.
|
|
|
|
Returns ``(hits, None)`` when a unique auto-tier candidate decides the
|
|
match, ``(hits, reason)`` with reason ``r1_review``/``r2_ambiguous``/None
|
|
when the outcome needs review. Only hits passing the hard gates are kept.
|
|
"""
|
|
row_amount = amount_of_row(row)
|
|
row_currency = str(row["currency"] or "").strip()
|
|
if res.own is None or res.counterparty is None:
|
|
return [], None
|
|
if row_amount is None or not row_currency:
|
|
return [], None
|
|
|
|
row_date = _parse_datetime(row["transaction_at"]).date()
|
|
|
|
# Rows already awaiting review may not be grabbed by another automatic
|
|
# match: pairing with one of several same-tier candidates would be a
|
|
# processing-order tie-break, which is never allowed.
|
|
blocked = {
|
|
item["source_row_id"]
|
|
for item in connection.execute(
|
|
"""
|
|
SELECT c.source_row_id FROM transfer_observation_claims c
|
|
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
|
WHERE d.classification = ?
|
|
""",
|
|
(CLASSIFICATION_NEEDS_REVIEW,),
|
|
)
|
|
}
|
|
|
|
hits: list[CandidateHit] = []
|
|
for other in pool:
|
|
if other["id"] == row["id"] or other["id"] in blocked:
|
|
continue
|
|
other_res = resolutions[other["id"]]
|
|
if other_res.direction is None or other_res.own is None or other_res.counterparty is None:
|
|
continue
|
|
if other_res.direction == res.direction:
|
|
continue
|
|
other_amount = amount_of_row(other)
|
|
other_currency = str(other["currency"] or "").strip()
|
|
if other_amount != row_amount or not other_currency or other_currency != row_currency:
|
|
continue
|
|
other_date = _parse_datetime(other["transaction_at"]).date()
|
|
date_diff = abs((row_date - other_date).days)
|
|
# Over-window pairs stay as R1 review candidates: the hard gate only
|
|
# filters money/direction/company reciprocity, never the date window.
|
|
if not _reciprocal(row, other, res, other_res):
|
|
continue
|
|
tier = _classify_tier(row, other, res, other_res)
|
|
if tier is None:
|
|
continue
|
|
mirror = _mirror_level(row, other, res, other_res) or "none"
|
|
ref_match, summary_match = _tier_evidence(row, other)
|
|
hits.append(
|
|
CandidateHit(
|
|
row_id=other["id"], tier=tier, date_diff_days=date_diff,
|
|
account_mirror=mirror, reference_match=ref_match,
|
|
summary_match=summary_match,
|
|
)
|
|
)
|
|
|
|
if not hits:
|
|
return [], None
|
|
|
|
best_tier = min(hits, key=lambda hit: AUTO_TIERS.index(hit.tier) if hit.tier in AUTO_TIERS else len(AUTO_TIERS)).tier
|
|
if best_tier in AUTO_TIERS:
|
|
same_tier = [hit for hit in hits if hit.tier == best_tier]
|
|
if len(same_tier) == 1:
|
|
return same_tier, None
|
|
return hits, "r2_ambiguous"
|
|
return hits, "r1_review"
|
|
|
|
|
|
def _reciprocal(
|
|
row: sqlite3.Row,
|
|
other: sqlite3.Row,
|
|
res: RowResolution,
|
|
other_res: RowResolution,
|
|
) -> bool:
|
|
return (
|
|
res.own.company_id == other_res.counterparty.company_id
|
|
and res.counterparty.company_id == other_res.own.company_id
|
|
)
|
|
|
|
|
|
def _mirror_level(
|
|
row: sqlite3.Row,
|
|
other: sqlite3.Row,
|
|
res: RowResolution,
|
|
other_res: RowResolution,
|
|
) -> str | None:
|
|
row_own = _normalized_account(row["own_account"])
|
|
row_cp = _normalized_account(row["counterparty_account"])
|
|
other_own = _normalized_account(other["own_account"])
|
|
other_cp = _normalized_account(other["counterparty_account"])
|
|
|
|
exact_pairs = 0
|
|
if row_own and row_own == other_cp:
|
|
exact_pairs += 1
|
|
if row_cp and row_cp == other_own:
|
|
exact_pairs += 1
|
|
if exact_pairs == 2:
|
|
return "exact"
|
|
if exact_pairs == 1:
|
|
methods = {
|
|
res.counterparty.resolve_method,
|
|
other_res.counterparty.resolve_method,
|
|
}
|
|
if methods & {"account_alias", "personal_mapping"}:
|
|
return "alias"
|
|
return None
|
|
|
|
|
|
def _reference_same(row: sqlite3.Row, other: sqlite3.Row) -> bool:
|
|
"""Positive reference evidence: both sides carry one equal reference.
|
|
|
|
``None == None`` must never count as reference agreement; only "both
|
|
reference numbers exist and are equal" satisfies the reference layer of the
|
|
M1/M3 evidence. A single-sided reference is not equality either.
|
|
"""
|
|
ref_row = normalize_reference(row["reference"])
|
|
ref_other = normalize_reference(other["reference"])
|
|
return bool(ref_row and ref_other and ref_row == ref_other)
|
|
|
|
|
|
def _reference_conflict(row: sqlite3.Row, other: sqlite3.Row) -> bool:
|
|
ref_row = normalize_reference(row["reference"])
|
|
ref_other = normalize_reference(other["reference"])
|
|
return bool(ref_row and ref_other and ref_row != ref_other)
|
|
|
|
|
|
def _classify_tier(
|
|
row: sqlite3.Row,
|
|
other: sqlite3.Row,
|
|
res: RowResolution,
|
|
other_res: RowResolution,
|
|
) -> str | None:
|
|
mirror = _mirror_level(row, other, res, other_res)
|
|
if mirror is None:
|
|
return "R1"
|
|
row_date = _parse_datetime(row["transaction_at"]).date()
|
|
other_date = _parse_datetime(other["transaction_at"]).date()
|
|
date_diff = abs((row_date - other_date).days)
|
|
ref_same = _reference_same(row, other)
|
|
ref_conflict = _reference_conflict(row, other)
|
|
|
|
if mirror == "exact":
|
|
if ref_same and date_diff <= 3:
|
|
return "M1"
|
|
if date_diff <= 1 and not ref_conflict:
|
|
return "M2"
|
|
return "R1"
|
|
# alias / personal-mapping mirror: only equal references (both present) or a
|
|
# deterministically equal summary is positive evidence; an absent reference
|
|
# is not.
|
|
if date_diff <= 1 and not ref_conflict and (ref_same or _summary_equal(row, other)):
|
|
return "M3"
|
|
return "R1"
|
|
|
|
|
|
def _tier_evidence(
|
|
row: sqlite3.Row, other: sqlite3.Row
|
|
) -> tuple[str | None, bool]:
|
|
if _reference_same(row, other):
|
|
ref_match = "same"
|
|
elif _reference_conflict(row, other):
|
|
ref_match = "conflict"
|
|
else:
|
|
ref_match = None
|
|
return ref_match, _summary_equal(row, other)
|
|
|
|
|
|
def _summary_equal(row: sqlite3.Row, other: sqlite3.Row) -> bool:
|
|
left = normalize_summary(row)
|
|
right = normalize_summary(other)
|
|
return bool(left) and left == right
|
|
|
|
|
|
def _parse_datetime(value: object) -> datetime:
|
|
text = str(value)
|
|
try:
|
|
return datetime.fromisoformat(text)
|
|
except ValueError:
|
|
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Derivation and application
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DerivedDecision:
|
|
classification: str
|
|
pairing: str
|
|
amount: str
|
|
currency: str | None
|
|
effective_at: str
|
|
observations: tuple[tuple[int, str], ...] # (row_id, role)
|
|
participants: tuple[dict[str, object], ...]
|
|
reason: str | None
|
|
candidates: tuple[dict[str, object], ...] = ()
|
|
|
|
|
|
def _derive(
|
|
connection: sqlite3.Connection,
|
|
row: sqlite3.Row,
|
|
res: RowResolution,
|
|
pool: list[sqlite3.Row],
|
|
resolutions: dict[int, RowResolution],
|
|
) -> DerivedDecision:
|
|
amount = amount_of_row(row)
|
|
amount_text = str(amount) if amount is not None else None
|
|
currency = str(row["currency"] or "").strip() or None
|
|
row_time = _parse_datetime(row["transaction_at"]).isoformat()
|
|
|
|
if res.direction is None:
|
|
return DerivedDecision(
|
|
CLASSIFICATION_UNRESOLVED, PAIRING_NA, amount_text, currency,
|
|
row_time, ((row["id"], "outgoing"),), (), "金额方向不满足一出一入",
|
|
)
|
|
if res.own is None:
|
|
classification = (
|
|
CLASSIFICATION_NEEDS_REVIEW if _own_is_conflict(res)
|
|
else CLASSIFICATION_UNRESOLVED
|
|
)
|
|
role = "outgoing" if res.direction == "outgoing" else "incoming"
|
|
return DerivedDecision(
|
|
classification, PAIRING_NA, amount_text, currency, row_time,
|
|
((row["id"], role),), (), res.own_reason,
|
|
)
|
|
role = "outgoing" if res.direction == "outgoing" else "incoming"
|
|
counter_role = "payee" if role == "outgoing" else "payer"
|
|
|
|
if res.counterparty is None:
|
|
classification = (
|
|
CLASSIFICATION_NEEDS_REVIEW
|
|
if _cp_is_ambiguous(res)
|
|
else CLASSIFICATION_UNRESOLVED
|
|
)
|
|
return DerivedDecision(
|
|
classification, PAIRING_NA, amount_text, currency,
|
|
row_time, ((row["id"], role),),
|
|
(_participant(res.own, "payer" if role == "outgoing" else "payee"),),
|
|
res.counterparty_reason,
|
|
)
|
|
|
|
own_participant = _participant(res.own, "payer" if role == "outgoing" else "payee")
|
|
cp_participant = _participant(res.counterparty, counter_role)
|
|
if res.own.company_id == res.counterparty.company_id:
|
|
# Same-company transfers pair when a mirror candidate exists; cash
|
|
# trail is kept but they never enter the intercompany balance.
|
|
hits, decision = _find_candidates(connection, row, res, pool, resolutions)
|
|
if decision == "r2_ambiguous":
|
|
return DerivedDecision(
|
|
CLASSIFICATION_NEEDS_REVIEW, PAIRING_NA, amount_text, currency,
|
|
row_time, ((row["id"], role),), (own_participant, cp_participant),
|
|
"同层多候选,落入人工审核", tuple(_candidate_rows(hits)),
|
|
)
|
|
if decision == "r1_review":
|
|
return DerivedDecision(
|
|
CLASSIFICATION_NEEDS_REVIEW, PAIRING_NA, amount_text, currency,
|
|
row_time, ((row["id"], role),), (own_participant, cp_participant),
|
|
"端点可确认但账号镜像或参考号证据不足,落入人工审核",
|
|
tuple(_candidate_rows(hits)),
|
|
)
|
|
if hits:
|
|
other = next(item for item in pool if item["id"] == hits[0].row_id)
|
|
return _derive_paired(row, res, other, resolutions[other["id"]], hits[0])
|
|
return DerivedDecision(
|
|
CLASSIFICATION_SAME_COMPANY, PAIRING_SINGLE, amount_text, currency,
|
|
row_time, ((row["id"], role),),
|
|
(own_participant, cp_participant), "本方与对方同属一家公司",
|
|
)
|
|
|
|
hits, decision = _find_candidates(connection, row, res, pool, resolutions)
|
|
if decision == "r2_ambiguous":
|
|
candidates = _candidate_rows(hits)
|
|
return DerivedDecision(
|
|
CLASSIFICATION_NEEDS_REVIEW, PAIRING_NA, amount_text, currency,
|
|
row_time, ((row["id"], role),), (own_participant, cp_participant),
|
|
"同层多候选,落入人工审核", tuple(candidates),
|
|
)
|
|
if decision == "r1_review":
|
|
candidates = _candidate_rows(hits)
|
|
return DerivedDecision(
|
|
CLASSIFICATION_NEEDS_REVIEW, PAIRING_NA, amount_text, currency,
|
|
row_time, ((row["id"], role),), (own_participant, cp_participant),
|
|
"端点可确认但账号镜像或参考号证据不足,落入人工审核",
|
|
tuple(candidates),
|
|
)
|
|
if hits:
|
|
other = next(item for item in pool if item["id"] == hits[0].row_id)
|
|
other_res = resolutions[other["id"]]
|
|
return _derive_paired(row, res, other, other_res, hits[0])
|
|
return DerivedDecision(
|
|
CLASSIFICATION_INTERNAL_SINGLE, PAIRING_SINGLE, amount_text, currency,
|
|
row_time, ((row["id"], role),), (own_participant, cp_participant),
|
|
"单边观察,等待另一方到账或人工确认",
|
|
)
|
|
|
|
|
|
def _own_is_conflict(res: RowResolution) -> bool:
|
|
return res.own_reason in (
|
|
"本方账号与上传账户归属公司冲突",
|
|
"上传账户与批次公司不一致",
|
|
)
|
|
|
|
|
|
def _cp_is_ambiguous(res: RowResolution) -> bool:
|
|
return res.counterparty_reason in (
|
|
"对方账号命中多个账号别名,落入人工审核",
|
|
"对方户名别名归属与账号证据冲突,落入人工审核",
|
|
)
|
|
|
|
|
|
def _participant(resolved: Resolved, role: str) -> dict[str, object]:
|
|
return {
|
|
"role": role,
|
|
"company_id": resolved.company_id,
|
|
"bank_account_id": resolved.bank_account_id,
|
|
"resolve_method": resolved.resolve_method,
|
|
"alias_id": resolved.alias_id,
|
|
"mapping_id": resolved.mapping_id,
|
|
"evidence": json.dumps(resolved.evidence, ensure_ascii=False),
|
|
}
|
|
|
|
|
|
def _candidate_rows(hits: list[CandidateHit]) -> list[dict[str, object]]:
|
|
return [
|
|
{
|
|
"source_row_id": hit.row_id,
|
|
"rule_tier": hit.tier,
|
|
"date_diff_days": hit.date_diff_days,
|
|
"account_mirror": 1 if hit.account_mirror != "none" else 0,
|
|
"reference_match": hit.reference_match,
|
|
"summary_match": 1 if hit.summary_match else 0,
|
|
"accepted": None,
|
|
"reason": None,
|
|
}
|
|
for hit in hits
|
|
]
|
|
|
|
|
|
def _derive_paired(
|
|
row: sqlite3.Row,
|
|
res: RowResolution,
|
|
other: sqlite3.Row,
|
|
other_res: RowResolution,
|
|
hit: CandidateHit,
|
|
) -> DerivedDecision:
|
|
row_role = "outgoing" if res.direction == "outgoing" else "incoming"
|
|
other_role = "incoming" if row_role == "outgoing" else "outgoing"
|
|
if row_role == "outgoing":
|
|
payer = _participant(res.own, "payer")
|
|
payee = _participant(other_res.own, "payee")
|
|
effective_at = _parse_datetime(row["transaction_at"]).isoformat()
|
|
else:
|
|
payer = _participant(other_res.own, "payer")
|
|
payee = _participant(res.own, "payee")
|
|
effective_at = _parse_datetime(other["transaction_at"]).isoformat()
|
|
|
|
classification = (
|
|
CLASSIFICATION_SAME_COMPANY
|
|
if payer["company_id"] == payee["company_id"]
|
|
else CLASSIFICATION_INTERCOMPANY
|
|
)
|
|
observations = (
|
|
(row["id"], row_role),
|
|
(other["id"], other_role),
|
|
)
|
|
return DerivedDecision(
|
|
classification, PAIRING_PAIRED,
|
|
str(amount_of_row(row)), str(row["currency"] or "").strip() or None,
|
|
effective_at, observations, (payer, payee), "双边镜像归并",
|
|
(_candidate_row(hit, accepted=1),),
|
|
)
|
|
|
|
|
|
def _candidate_row(hit: CandidateHit, *, accepted: int | None) -> dict[str, object]:
|
|
return {
|
|
"source_row_id": hit.row_id,
|
|
"rule_tier": hit.tier,
|
|
"date_diff_days": hit.date_diff_days,
|
|
"account_mirror": 1 if hit.account_mirror != "none" else 0,
|
|
"reference_match": hit.reference_match,
|
|
"summary_match": 1 if hit.summary_match else 0,
|
|
"accepted": accepted,
|
|
"reason": None,
|
|
}
|
|
|
|
|
|
def _derive_for_row(
|
|
connection: sqlite3.Connection,
|
|
row: sqlite3.Row,
|
|
res: RowResolution,
|
|
pool: list[sqlite3.Row],
|
|
resolutions: dict[int, RowResolution],
|
|
) -> DerivedDecision:
|
|
return _derive(connection, row, res, pool, resolutions)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Current claim helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _current_claim(connection: sqlite3.Connection, row_id: int) -> sqlite3.Row | None:
|
|
return connection.execute(
|
|
"""
|
|
SELECT c.event_id, c.decision_id, d.locked, d.mode, d.classification,
|
|
d.pairing, d.revision, d.rule_version
|
|
FROM transfer_observation_claims c
|
|
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
|
WHERE c.source_row_id = ?
|
|
""",
|
|
(row_id,),
|
|
).fetchone()
|
|
|
|
|
|
def _current_decision_for_event(
|
|
connection: sqlite3.Connection, event_id: int
|
|
) -> sqlite3.Row | None:
|
|
return connection.execute(
|
|
"""
|
|
SELECT d.* FROM current_transfer_decisions c
|
|
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
|
WHERE c.event_id = ?
|
|
""",
|
|
(event_id,),
|
|
).fetchone()
|
|
|
|
|
|
def _decision_observations(
|
|
connection: sqlite3.Connection, decision_id: int
|
|
) -> list[sqlite3.Row]:
|
|
return connection.execute(
|
|
"SELECT * FROM transfer_decision_observations WHERE decision_id = ? ORDER BY id",
|
|
(decision_id,),
|
|
).fetchall()
|
|
|
|
|
|
def _decision_participants(
|
|
connection: sqlite3.Connection, decision_id: int
|
|
) -> list[sqlite3.Row]:
|
|
return connection.execute(
|
|
"SELECT * FROM transfer_decision_participants WHERE decision_id = ? ORDER BY role",
|
|
(decision_id,),
|
|
).fetchall()
|
|
|
|
|
|
def _same_decision(
|
|
connection: sqlite3.Connection,
|
|
decision_id: int,
|
|
derived: DerivedDecision,
|
|
) -> bool:
|
|
"""Idempotency check: derived outcome equals the current decision."""
|
|
current = connection.execute(
|
|
"SELECT * FROM transfer_match_decisions WHERE id = ?", (decision_id,)
|
|
).fetchone()
|
|
if current is None:
|
|
return False
|
|
if current["classification"] != derived.classification:
|
|
return False
|
|
if current["pairing"] != derived.pairing:
|
|
return False
|
|
if (current["amount"] or None) != derived.amount:
|
|
return False
|
|
if (current["currency"] or None) != derived.currency:
|
|
return False
|
|
if (current["effective_at"] or None) != derived.effective_at:
|
|
return False
|
|
|
|
observed = {
|
|
(row["source_row_id"], row["role"])
|
|
for row in _decision_observations(connection, decision_id)
|
|
}
|
|
if observed != set(derived.observations):
|
|
return False
|
|
current_participants = {
|
|
(
|
|
row["role"], row["company_id"], row["bank_account_id"],
|
|
row["resolve_method"], row["alias_id"], row["mapping_id"],
|
|
)
|
|
for row in _decision_participants(connection, decision_id)
|
|
}
|
|
derived_participants = {
|
|
(
|
|
str(p["role"]), p["company_id"], p["bank_account_id"],
|
|
str(p["resolve_method"]), p["alias_id"], p["mapping_id"],
|
|
)
|
|
for p in derived.participants
|
|
}
|
|
return current_participants == derived_participants
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Applying decisions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _apply(
|
|
connection: sqlite3.Connection,
|
|
derived: DerivedDecision,
|
|
*,
|
|
mode: str,
|
|
locked: int,
|
|
reason: str | None,
|
|
actor: sqlite3.Row | None,
|
|
rule_version: str,
|
|
supersede_decision_id: int | None = None,
|
|
idempotency_key: str | None = None,
|
|
) -> dict[str, object]:
|
|
"""Write one decision + projection update inside the open transaction."""
|
|
now = utc_now()
|
|
superseded_events: set[int] = set()
|
|
|
|
claim_events: dict[int, sqlite3.Row] = {}
|
|
for row_id, _role in derived.observations:
|
|
claim = _current_claim(connection, row_id)
|
|
if claim is not None:
|
|
claim_events[row_id] = claim
|
|
|
|
if claim_events:
|
|
event_id = min(item["event_id"] for item in claim_events.values())
|
|
else:
|
|
cursor = connection.execute(
|
|
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
|
|
(now,),
|
|
)
|
|
event_id = int(cursor.lastrowid)
|
|
|
|
previous = _current_decision_for_event(connection, event_id)
|
|
if supersede_decision_id is None and previous is not None:
|
|
supersede_decision_id = previous["id"]
|
|
|
|
revision_row = connection.execute(
|
|
"SELECT COALESCE(MAX(revision), 0) AS m FROM transfer_match_decisions WHERE event_id = ?",
|
|
(event_id,),
|
|
).fetchone()
|
|
revision = int(revision_row["m"]) + 1
|
|
|
|
cursor = connection.execute(
|
|
"""
|
|
INSERT INTO transfer_match_decisions (
|
|
event_id, revision, classification, pairing, amount, currency,
|
|
effective_at, mode, rule_version, locked, reason, idempotency_key,
|
|
actor_user_id, actor_username, supersedes_decision_id, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
event_id, revision, derived.classification, derived.pairing,
|
|
derived.amount, derived.currency, derived.effective_at,
|
|
mode, rule_version, locked, reason, idempotency_key,
|
|
actor["id"] if actor is not None else None,
|
|
actor["username"] if actor is not None else None,
|
|
supersede_decision_id,
|
|
now,
|
|
),
|
|
)
|
|
decision_id = int(cursor.lastrowid)
|
|
|
|
for row_id, role in derived.observations:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO transfer_decision_observations (decision_id, source_row_id, role, created_at)
|
|
VALUES (?, ?, ?, ?)
|
|
""",
|
|
(decision_id, row_id, role, now),
|
|
)
|
|
for participant in derived.participants:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO transfer_decision_participants (
|
|
decision_id, role, company_id, bank_account_id, resolve_method,
|
|
alias_id, mapping_id, evidence, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
decision_id, participant["role"], participant["company_id"],
|
|
participant["bank_account_id"], participant["resolve_method"],
|
|
participant["alias_id"], participant["mapping_id"],
|
|
participant["evidence"], now,
|
|
),
|
|
)
|
|
for candidate in derived.candidates:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO transfer_match_candidates (
|
|
decision_id, source_row_id, rule_tier, date_diff_days,
|
|
account_mirror, reference_match, summary_match, accepted,
|
|
reason, rule_version, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
decision_id, candidate["source_row_id"], candidate["rule_tier"],
|
|
candidate["date_diff_days"], candidate["account_mirror"],
|
|
candidate["reference_match"], candidate["summary_match"],
|
|
candidate["accepted"], candidate["reason"], rule_version, now,
|
|
),
|
|
)
|
|
|
|
# Projection: point every involved observation's claim at the new decision.
|
|
for row_id, _role in derived.observations:
|
|
connection.execute(
|
|
"""
|
|
INSERT OR REPLACE INTO transfer_observation_claims (source_row_id, event_id, decision_id)
|
|
VALUES (?, ?, ?)
|
|
""",
|
|
(row_id, event_id, decision_id),
|
|
)
|
|
for row_id in claim_events:
|
|
if row_id not in {rid for rid, _ in derived.observations}:
|
|
connection.execute(
|
|
"DELETE FROM transfer_observation_claims WHERE source_row_id = ?",
|
|
(row_id,),
|
|
)
|
|
|
|
# Other involved events lose their current pointer and become superseded.
|
|
for other_event in {item["event_id"] for item in claim_events.values()}:
|
|
if other_event != event_id:
|
|
connection.execute(
|
|
"DELETE FROM current_transfer_decisions WHERE event_id = ?",
|
|
(other_event,),
|
|
)
|
|
connection.execute(
|
|
"UPDATE canonical_transfer_events SET lifecycle = 'superseded' WHERE id = ?",
|
|
(other_event,),
|
|
)
|
|
superseded_events.add(other_event)
|
|
connection.execute(
|
|
"""
|
|
INSERT OR REPLACE INTO current_transfer_decisions (event_id, decision_id)
|
|
VALUES (?, ?)
|
|
""",
|
|
(event_id, decision_id),
|
|
)
|
|
|
|
return {
|
|
"event_id": event_id,
|
|
"decision_id": decision_id,
|
|
"revision": revision,
|
|
"superseded_events": sorted(superseded_events),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public reconcile API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def reconcile_rows(
|
|
connection: sqlite3.Connection,
|
|
source_row_ids: list[int],
|
|
*,
|
|
rule_version: str = RULE_VERSION,
|
|
actor: sqlite3.Row | None = None,
|
|
) -> dict[str, object]:
|
|
"""Reconcile confirmed source rows into canonical events, idempotently.
|
|
|
|
Rows claimed by a locked (manual) decision are never touched. Rows whose
|
|
derived outcome already equals their current decision produce zero writes.
|
|
Runs inside the caller's transaction when one is open, otherwise in its
|
|
own ``BEGIN IMMEDIATE`` transaction.
|
|
"""
|
|
began = _ensure_transaction(connection)
|
|
try:
|
|
ids = sorted({int(row_id) for row_id in source_row_ids})
|
|
if not ids:
|
|
return {
|
|
"created_events": 0, "updated_events": 0, "unchanged": 0,
|
|
"skipped_locked": 0, "rows": 0,
|
|
}
|
|
stats = {
|
|
"created_events": 0, "updated_events": 0, "unchanged": 0,
|
|
"skipped_locked": 0, "rows": 0,
|
|
}
|
|
pool = _confirmed_rows(connection)
|
|
pool_by_id = {row["id"]: row for row in pool}
|
|
resolutions = {
|
|
row["id"]: resolve_row(connection, row)
|
|
for row in pool
|
|
}
|
|
input_rows = _load_rows(connection, ids)
|
|
|
|
for row in input_rows:
|
|
stats["rows"] += 1
|
|
claim = _current_claim(connection, row["id"])
|
|
if claim is not None and claim["locked"]:
|
|
stats["skipped_locked"] += 1
|
|
continue
|
|
if row["id"] not in pool_by_id:
|
|
stats["skipped_unconfirmed"] += 1
|
|
continue
|
|
derived = _derive_for_row(connection, row, resolutions[row["id"]], pool, resolutions)
|
|
if claim is not None and _same_decision(connection, claim["decision_id"], derived):
|
|
stats["unchanged"] += 1
|
|
continue
|
|
outcome = _apply(
|
|
connection, derived, mode=MODE_AUTO, locked=0,
|
|
reason=derived.reason, actor=actor, rule_version=rule_version,
|
|
)
|
|
if outcome["revision"] == 1:
|
|
stats["created_events"] += 1
|
|
else:
|
|
stats["updated_events"] += 1
|
|
except Exception:
|
|
if began:
|
|
connection.rollback()
|
|
raise
|
|
else:
|
|
if began:
|
|
connection.commit()
|
|
return stats
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manual decisions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def apply_manual_decision(
|
|
connection: sqlite3.Connection,
|
|
event_id: int,
|
|
action: str,
|
|
*,
|
|
reason: str,
|
|
expected_revision: int | None,
|
|
request_key: str | None,
|
|
actor: sqlite3.Row,
|
|
source_row_ids: list[int] | None = None,
|
|
participant: dict[str, object] | None = None,
|
|
) -> dict[str, object]:
|
|
"""Apply an administrator decision on an event (link_rows/assign_participant
|
|
/mark_external/reverse). Replaces the current decision with a locked manual
|
|
one; corrections must go through ``reverse``, never in-place editing.
|
|
"""
|
|
reason = (reason or "").strip()
|
|
if not reason:
|
|
raise MatchInputError("必须填写操作原因。")
|
|
began = _ensure_transaction(connection)
|
|
try:
|
|
# Idempotency: a replayed request key returns the earlier decision
|
|
# without re-validating the (now changed) revision.
|
|
if request_key:
|
|
existing = connection.execute(
|
|
"SELECT id FROM transfer_match_decisions WHERE idempotency_key = ? AND event_id = ?",
|
|
(request_key, event_id),
|
|
).fetchone()
|
|
if existing is not None:
|
|
if began:
|
|
connection.commit()
|
|
return _decision_payload(connection, event_id, existing["id"])
|
|
|
|
current = _current_decision_for_event(connection, event_id)
|
|
if current is None:
|
|
raise MatchConflictError("该事件当前没有有效决定。")
|
|
if expected_revision is not None and int(expected_revision) != current["revision"]:
|
|
raise MatchConflictError("事件已发生变更,请刷新后重试。")
|
|
|
|
if action == "link_rows":
|
|
derived = _manual_link(
|
|
connection, event_id, current, source_row_ids, reason, actor
|
|
)
|
|
elif action == "assign_participant":
|
|
derived = _manual_assign_participant(
|
|
connection, event_id, current, participant, reason, actor
|
|
)
|
|
elif action == "mark_external":
|
|
derived = _manual_external(connection, event_id, current, reason)
|
|
elif action == "reverse":
|
|
derived = None
|
|
else:
|
|
raise MatchInputError("未知的人工决定类型。")
|
|
|
|
if action == "reverse":
|
|
outcome = _apply_reversal(
|
|
connection, event_id, current, reason, actor, request_key
|
|
)
|
|
else:
|
|
outcome = _apply(
|
|
connection, derived, mode=MODE_MANUAL, locked=1,
|
|
reason=reason, actor=actor,
|
|
rule_version=current["rule_version"] or RULE_VERSION,
|
|
idempotency_key=request_key,
|
|
)
|
|
audit(
|
|
connection,
|
|
f"transfer_{action}",
|
|
actor=actor,
|
|
target=f"transfer_event:{event_id}",
|
|
detail=f"decision:{outcome['decision_id']};reason:{reason}",
|
|
)
|
|
except Exception:
|
|
if began:
|
|
connection.rollback()
|
|
raise
|
|
else:
|
|
if began:
|
|
connection.commit()
|
|
return _decision_payload(connection, event_id, outcome["decision_id"])
|
|
|
|
|
|
def _manual_link(
|
|
connection: sqlite3.Connection,
|
|
event_id: int,
|
|
current: sqlite3.Row,
|
|
source_row_ids: list[int] | None,
|
|
reason: str,
|
|
actor: sqlite3.Row,
|
|
) -> DerivedDecision:
|
|
if not source_row_ids or len(source_row_ids) != 2:
|
|
raise MatchInputError("人工关联必须且只能提供两条源行。")
|
|
rows = _load_rows(connection, [int(row_id) for row_id in source_row_ids])
|
|
if len(rows) != 2:
|
|
raise MatchInputError("存在无效的源行。")
|
|
for row in rows:
|
|
claim = _current_claim(connection, row["id"])
|
|
if claim is not None and claim["decision_id"] != current["id"] and claim["locked"]:
|
|
raise MatchConflictError(f"源行 {row['id']} 已被锁定的人工决定占用。")
|
|
resolutions = {row["id"]: resolve_row(connection, row) for row in rows}
|
|
directions = {row["id"]: resolutions[row["id"]].direction for row in rows}
|
|
if any(value is None for value in directions.values()):
|
|
raise MatchInputError("人工关联的源行金额方向必须一出一入。")
|
|
if directions[rows[0]["id"]] == directions[rows[1]["id"]]:
|
|
raise MatchInputError("人工关联的源行金额方向必须相反。")
|
|
outgoing_row = rows[0] if directions[rows[0]["id"]] == "outgoing" else rows[1]
|
|
incoming_row = rows[1] if outgoing_row is rows[0] else rows[0]
|
|
out_res = resolutions[outgoing_row["id"]]
|
|
in_res = resolutions[incoming_row["id"]]
|
|
if out_res.own is None or in_res.own is None:
|
|
raise MatchInputError("人工关联的源行未能唯一确认本方归属,不能定案。")
|
|
payer = _participant(out_res.own, "payer")
|
|
payee = _participant(in_res.own, "payee")
|
|
classification = (
|
|
CLASSIFICATION_SAME_COMPANY
|
|
if payer["company_id"] == payee["company_id"]
|
|
else CLASSIFICATION_INTERCOMPANY
|
|
)
|
|
amount = amount_of_row(outgoing_row)
|
|
return DerivedDecision(
|
|
classification, PAIRING_PAIRED, str(amount),
|
|
str(outgoing_row["currency"] or "").strip() or None,
|
|
_parse_datetime(outgoing_row["transaction_at"]).isoformat(),
|
|
(
|
|
(outgoing_row["id"], "outgoing"),
|
|
(incoming_row["id"], "incoming"),
|
|
),
|
|
(payer, payee), "管理员人工关联",
|
|
)
|
|
|
|
|
|
def _manual_assign_participant(
|
|
connection: sqlite3.Connection,
|
|
event_id: int,
|
|
current: sqlite3.Row,
|
|
participant: dict[str, object] | None,
|
|
reason: str,
|
|
actor: sqlite3.Row,
|
|
) -> DerivedDecision:
|
|
if not participant or not isinstance(participant, dict):
|
|
raise MatchInputError("assign_participant 必须提供 participant。")
|
|
role = str(participant.get("role") or "")
|
|
if role not in ("payer", "payee"):
|
|
raise MatchInputError("participant.role 必须是 payer 或 payee。")
|
|
try:
|
|
company_id = int(participant["company_id"])
|
|
except (KeyError, TypeError, ValueError):
|
|
raise MatchInputError("participant.company_id 必须是有效的公司 id。") from None
|
|
company = connection.execute(
|
|
"SELECT id FROM companies WHERE id = ?", (company_id,)
|
|
).fetchone()
|
|
if company is None:
|
|
raise MatchInputError("participant.company_id 指向的公司不存在。")
|
|
|
|
observations = _decision_observations(connection, current["id"])
|
|
if len(observations) != 1:
|
|
raise MatchInputError("assign_participant 只适用于单边事件。")
|
|
observation = observations[0]
|
|
row = _load_rows(connection, [observation["source_row_id"]])[0]
|
|
resolved = resolve_row(connection, row)
|
|
if resolved.own is None or resolved.direction is None:
|
|
raise MatchInputError("assign_participant 需要本方归属已确认。")
|
|
own_role = "payer" if resolved.direction == "outgoing" else "payee"
|
|
if role == own_role:
|
|
raise MatchInputError("assign_participant 只能指定对方参与方角色。")
|
|
own_participant = _participant(resolved.own, own_role)
|
|
assigned = {
|
|
"role": role,
|
|
"company_id": company_id,
|
|
"bank_account_id": (
|
|
int(participant["bank_account_id"])
|
|
if participant.get("bank_account_id")
|
|
else None
|
|
),
|
|
"resolve_method": "manual",
|
|
"alias_id": None,
|
|
"mapping_id": None,
|
|
"evidence": json.dumps(
|
|
{"via": "manual", "company_id": company_id}, ensure_ascii=False
|
|
),
|
|
}
|
|
participants = (own_participant, assigned)
|
|
classification = (
|
|
CLASSIFICATION_SAME_COMPANY
|
|
if own_participant["company_id"] == company_id
|
|
else CLASSIFICATION_INTERCOMPANY
|
|
)
|
|
amount = amount_of_row(row)
|
|
return DerivedDecision(
|
|
classification, PAIRING_SINGLE, str(amount),
|
|
str(row["currency"] or "").strip() or None,
|
|
_parse_datetime(row["transaction_at"]).isoformat(),
|
|
((row["id"], observation["role"]),),
|
|
participants, "管理员按证据确认参与方",
|
|
)
|
|
|
|
|
|
def _manual_external(
|
|
connection: sqlite3.Connection, event_id: int, current: sqlite3.Row, reason: str
|
|
) -> DerivedDecision:
|
|
observations = _decision_observations(connection, current["id"])
|
|
participants = _decision_participants(connection, current["id"])
|
|
if len(observations) != 1:
|
|
raise MatchInputError("mark_external 只适用于单边事件。")
|
|
observation = observations[0]
|
|
row = _load_rows(connection, [observation["source_row_id"]])[0]
|
|
amount = amount_of_row(row)
|
|
participant_rows = [
|
|
{
|
|
"role": p["role"], "company_id": p["company_id"],
|
|
"bank_account_id": p["bank_account_id"],
|
|
"resolve_method": p["resolve_method"], "alias_id": p["alias_id"],
|
|
"mapping_id": p["mapping_id"], "evidence": p["evidence"],
|
|
}
|
|
for p in participants
|
|
]
|
|
return DerivedDecision(
|
|
CLASSIFICATION_EXTERNAL, PAIRING_NA, str(amount),
|
|
str(row["currency"] or "").strip() or None,
|
|
_parse_datetime(row["transaction_at"]).isoformat(),
|
|
((row["id"], observation["role"]),),
|
|
tuple(participant_rows), "管理员确认外部交易",
|
|
)
|
|
|
|
|
|
def _apply_reversal(
|
|
connection: sqlite3.Connection,
|
|
event_id: int,
|
|
current: sqlite3.Row,
|
|
reason: str,
|
|
actor: sqlite3.Row | None,
|
|
idempotency_key: str | None,
|
|
) -> dict[str, object]:
|
|
now = utc_now()
|
|
revision_row = connection.execute(
|
|
"SELECT COALESCE(MAX(revision), 0) AS m FROM transfer_match_decisions WHERE event_id = ?",
|
|
(event_id,),
|
|
).fetchone()
|
|
revision = int(revision_row["m"]) + 1
|
|
cursor = connection.execute(
|
|
"""
|
|
INSERT INTO transfer_match_decisions (
|
|
event_id, revision, classification, pairing, amount, currency,
|
|
effective_at, mode, rule_version, locked, reason, idempotency_key,
|
|
actor_user_id, actor_username, supersedes_decision_id, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
event_id, revision, current["classification"], current["pairing"],
|
|
current["amount"], current["currency"], current["effective_at"],
|
|
MODE_REVERSAL, current["rule_version"], reason, idempotency_key,
|
|
actor["id"] if actor is not None else None,
|
|
actor["username"] if actor is not None else None,
|
|
current["id"], now,
|
|
),
|
|
)
|
|
decision_id = int(cursor.lastrowid)
|
|
connection.execute(
|
|
"DELETE FROM transfer_observation_claims WHERE event_id = ?", (event_id,)
|
|
)
|
|
connection.execute(
|
|
"DELETE FROM current_transfer_decisions WHERE event_id = ?", (event_id,)
|
|
)
|
|
return {"decision_id": decision_id, "event_id": event_id}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Projection rebuild and B-44 read contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def rebuild_current_projection(connection: sqlite3.Connection) -> int:
|
|
"""Rebuild current decisions and row claims from the append-only log.
|
|
|
|
For every active event, the current decision is its highest-revision
|
|
decision — unless that decision is a reversal, in which case the event has
|
|
no current pointer and no claims. Returns the number of current decisions
|
|
rebuilt. Intended as a recovery/consistency entry point.
|
|
"""
|
|
connection.execute("DELETE FROM transfer_observation_claims")
|
|
connection.execute("DELETE FROM current_transfer_decisions")
|
|
events = connection.execute(
|
|
"""
|
|
SELECT e.id AS event_id,
|
|
(SELECT d2.id FROM transfer_match_decisions d2
|
|
WHERE d2.event_id = e.id
|
|
ORDER BY d2.revision DESC LIMIT 1) AS latest_id
|
|
FROM canonical_transfer_events e
|
|
WHERE e.lifecycle = 'active'
|
|
"""
|
|
).fetchall()
|
|
rebuilt = 0
|
|
for event in events:
|
|
if event["latest_id"] is None:
|
|
continue
|
|
latest = connection.execute(
|
|
"SELECT mode FROM transfer_match_decisions WHERE id = ?",
|
|
(event["latest_id"],),
|
|
).fetchone()
|
|
if latest is None or latest["mode"] == MODE_REVERSAL:
|
|
continue
|
|
observations = connection.execute(
|
|
"""
|
|
SELECT source_row_id FROM transfer_decision_observations
|
|
WHERE decision_id = ? ORDER BY id
|
|
""",
|
|
(event["latest_id"],),
|
|
).fetchall()
|
|
with connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT OR REPLACE INTO current_transfer_decisions (event_id, decision_id)
|
|
VALUES (?, ?)
|
|
""",
|
|
(event["event_id"], event["latest_id"]),
|
|
)
|
|
for observation in observations:
|
|
connection.execute(
|
|
"""
|
|
INSERT OR REPLACE INTO transfer_observation_claims (source_row_id, event_id, decision_id)
|
|
VALUES (?, ?, ?)
|
|
""",
|
|
(observation["source_row_id"], event["event_id"], event["latest_id"]),
|
|
)
|
|
rebuilt += 1
|
|
return rebuilt
|
|
|
|
|
|
def eligible_intercompany_events(connection: sqlite3.Connection) -> list[sqlite3.Row]:
|
|
return connection.execute(
|
|
"SELECT * FROM eligible_intercompany_events ORDER BY event_id"
|
|
).fetchall()
|
|
|
|
|
|
def unresolved_amounts(
|
|
connection: sqlite3.Connection,
|
|
company_id: int,
|
|
cutoff: str | None = None,
|
|
) -> list[sqlite3.Row]:
|
|
"""Unresolved amounts a company is exposed to as of ``cutoff``.
|
|
|
|
Only current decisions in ``unresolved``/``needs_review``/``internal_single``
|
|
count as unresolved; paired intercompany, same-company and external events
|
|
are resolved classifications and never appear here.
|
|
"""
|
|
cutoff_where = "AND d.effective_at <= ?" if cutoff else ""
|
|
params: list[object] = []
|
|
if cutoff:
|
|
params.append(cutoff)
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT d.id AS decision_id, d.event_id, d.classification, d.amount,
|
|
d.currency, d.effective_at, o.role AS direction
|
|
FROM current_transfer_decisions c
|
|
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
|
JOIN transfer_decision_participants p ON p.decision_id = d.id
|
|
JOIN transfer_decision_observations o ON o.decision_id = d.id
|
|
WHERE d.classification IN ('unresolved', 'needs_review', 'internal_single')
|
|
AND p.company_id = ?
|
|
{cutoff_where}
|
|
ORDER BY d.id
|
|
""",
|
|
(company_id, *params),
|
|
).fetchall()
|
|
return rows
|
|
|
|
|
|
def exposed_status(decision: sqlite3.Row | dict) -> str:
|
|
classification = decision["classification"]
|
|
pairing = decision["pairing"]
|
|
if classification == CLASSIFICATION_INTERCOMPANY and pairing == PAIRING_PAIRED:
|
|
return "matched"
|
|
if classification == CLASSIFICATION_INTERCOMPANY:
|
|
return "confirmed_single"
|
|
if classification == CLASSIFICATION_SAME_COMPANY:
|
|
return "same_company_transfer"
|
|
if classification == CLASSIFICATION_EXTERNAL:
|
|
return "external"
|
|
if classification == CLASSIFICATION_NEEDS_REVIEW:
|
|
return "needs_review"
|
|
if classification == CLASSIFICATION_INTERNAL_SINGLE:
|
|
return "internal_single"
|
|
return "unresolved"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API payload helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _decision_payload(
|
|
connection: sqlite3.Connection, event_id: int, decision_id: int
|
|
) -> dict[str, object]:
|
|
decision = connection.execute(
|
|
"SELECT * FROM transfer_match_decisions WHERE id = ?", (decision_id,)
|
|
).fetchone()
|
|
observations = _decision_observations(connection, decision_id)
|
|
participants = _decision_participants(connection, decision_id)
|
|
return {
|
|
"event_id": event_id,
|
|
"decision_id": decision_id,
|
|
"revision": decision["revision"],
|
|
"classification": decision["classification"],
|
|
"pairing": decision["pairing"],
|
|
"status": exposed_status(decision),
|
|
"amount": decision["amount"],
|
|
"currency": decision["currency"],
|
|
"effective_at": decision["effective_at"],
|
|
"mode": decision["mode"],
|
|
"locked": bool(decision["locked"]),
|
|
"rule_version": decision["rule_version"],
|
|
"source_row_ids": [o["source_row_id"] for o in observations],
|
|
"participants": [
|
|
{
|
|
"role": p["role"], "company_id": p["company_id"],
|
|
"bank_account_id": p["bank_account_id"],
|
|
"resolve_method": p["resolve_method"],
|
|
}
|
|
for p in participants
|
|
],
|
|
}
|