B-44: intercompany ledger events, subject review and drill-down evidence
- migration 6: manual_records, ledger_event_revisions chain, current projections, source claims, subject suggestions, eligible_position_events - ledger_events.py: bank-event reconciliation, reversal/adjustment/reopen, append-only revision chain and rebuildable current projection - subjects.py: fixed subject mirror, draft suggestion dictionary, explicit administrator subject confirmation with expected_revision + idempotency - manual_records.py: submit, approve new/link, return/exception/reverse, candidate hints, idempotent replay and concurrency-safe claims - positions.py: Decimal aggregation, both-perspective conservation asserts, cutoff window, unresolved gross buckets, keyset pagination, evidence visibility (visible/masked/missing) - server.py: admin + company intercompany APIs with tenant isolation (404 on cross-tenant reads, 403 on company writes) and auto reconcile wiring - admin/company portals: balance directory, pair drill-down drawer, evidence drawer, subject/manual audit queue, company balance summary - tests: ledger events, subjects, manual records, positions, HTTP API and migration persistence (233 total, all green)
This commit is contained in:
@@ -0,0 +1,778 @@
|
||||
"""Manual evidence records, administrator approval and audit-safe reversal.
|
||||
|
||||
Manual records are immutable submitted facts. Only an approved record becomes
|
||||
a canonical ledger event (``approve_new``) or joins one (``approve_link``);
|
||||
returned/exception/pending records never affect a balance and never leak to
|
||||
the counterparty. Approved facts change only through a ``reverse`` decision
|
||||
that creates an opposite new event (or detaches a linked claim) — the original
|
||||
is never edited. Idempotency keys and UNIQUE claims prevent double counting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from .db import utc_now
|
||||
from .ledger_events import (
|
||||
LedgerConflictError,
|
||||
LedgerInputError,
|
||||
create_event,
|
||||
create_reversal,
|
||||
current_revision,
|
||||
manual_source_claim,
|
||||
)
|
||||
from .subjects import SUBJECTS
|
||||
|
||||
MANUAL_STATES = ("pending", "approved", "returned", "exception", "reversed")
|
||||
FUNDING_SOURCES = ("approved_bank_account", "personal_transit", "other")
|
||||
DATE_KEYS = ("occurred_at",)
|
||||
|
||||
|
||||
class ManualConflictError(ValueError):
|
||||
"""A claim/idempotency/revision conflict (mapped to HTTP 409)."""
|
||||
|
||||
|
||||
class ManualInputError(ValueError):
|
||||
"""Invalid manual record input (mapped to HTTP 400/422)."""
|
||||
|
||||
|
||||
def _parse_amount(amount: object) -> Decimal:
|
||||
try:
|
||||
value = Decimal(str(amount))
|
||||
except InvalidOperation:
|
||||
raise ManualInputError("金额不是有效的十进制数。") from None
|
||||
if not value.is_finite() or value <= 0:
|
||||
raise ManualInputError("金额必须大于零。")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_date(value: object, field: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if len(text) < 10:
|
||||
raise ManualInputError(f"{field}必须是 YYYY-MM-DD 或完整时间。")
|
||||
try:
|
||||
datetime.fromisoformat(text[:10])
|
||||
except ValueError:
|
||||
raise ManualInputError(f"{field}必须是 YYYY-MM-DD 或完整时间。") from None
|
||||
return text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Submit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def submit(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int,
|
||||
counterparty_company_id: int,
|
||||
occurred_at: str,
|
||||
direction: str,
|
||||
amount: str,
|
||||
currency: str,
|
||||
funding_source: str,
|
||||
requested_subject: str,
|
||||
request_key: str,
|
||||
actor: sqlite3.Row,
|
||||
bank_account_id: object = None,
|
||||
personal_transit_mapping_id: object = None,
|
||||
related_source_row_id: object = None,
|
||||
summary: object = None,
|
||||
reason: object = None,
|
||||
evidence: object = None,
|
||||
supersedes_record_id: object = None,
|
||||
) -> dict[str, object]:
|
||||
"""Submit one manual record for review. Idempotent on ``(company_id, request_key)``."""
|
||||
request_key = str(request_key or "").strip()
|
||||
if not request_key:
|
||||
raise ManualInputError("必须提供提交幂等键 request_key。")
|
||||
if int(company_id) == int(counterparty_company_id):
|
||||
raise ManualInputError("对方公司不能与本公司相同。")
|
||||
if direction not in ("outgoing", "incoming"):
|
||||
raise ManualInputError("方向必须是 outgoing 或 incoming。")
|
||||
if funding_source not in FUNDING_SOURCES:
|
||||
raise ManualInputError(f"资金来源必须是:{'、'.join(FUNDING_SOURCES)}。")
|
||||
if requested_subject not in SUBJECTS:
|
||||
raise ManualInputError("科目必须是应收/应付/其他应收/其他应付之一。")
|
||||
_validate_date(occurred_at, "业务日期")
|
||||
currency = str(currency or "").strip()
|
||||
if not currency:
|
||||
raise ManualInputError("币种不能为空。")
|
||||
amount = str(_parse_amount(amount))
|
||||
|
||||
for label, raw in (
|
||||
("company_id", company_id), ("counterparty_company_id", counterparty_company_id),
|
||||
):
|
||||
row = connection.execute("SELECT id FROM companies WHERE id = ?", (int(raw),)).fetchone()
|
||||
if row is None:
|
||||
raise ManualInputError(f"{label} 指向的公司不存在。")
|
||||
|
||||
bank_account_id = _resolve_account_ref(
|
||||
connection, bank_account_id, company_id, "银行账户"
|
||||
)
|
||||
mapping_id = _resolve_account_ref(
|
||||
connection, personal_transit_mapping_id, company_id, "个人过账映射"
|
||||
)
|
||||
related_row = None
|
||||
if related_source_row_id not in (None, ""):
|
||||
related_row = connection.execute(
|
||||
"SELECT r.id, b.company_id FROM source_rows r "
|
||||
"JOIN sheet_batches s ON s.id = r.sheet_batch_id "
|
||||
"JOIN import_batches b ON b.id = s.import_batch_id "
|
||||
"WHERE r.id = ?",
|
||||
(int(related_source_row_id),),
|
||||
).fetchone()
|
||||
if related_row is None:
|
||||
raise ManualInputError("关联银行源行不存在。")
|
||||
|
||||
if funding_source == "approved_bank_account" and bank_account_id is None:
|
||||
raise ManualInputError("资金来源为已批准账户时必须指定银行账户。")
|
||||
if funding_source == "personal_transit" and mapping_id is None:
|
||||
raise ManualInputError("资金来源为个人过账时必须指定个人过账映射。")
|
||||
|
||||
supersedes_id = None
|
||||
if supersedes_record_id not in (None, ""):
|
||||
parent = connection.execute(
|
||||
"SELECT id, company_id FROM manual_records WHERE id = ?",
|
||||
(int(supersedes_record_id),),
|
||||
).fetchone()
|
||||
if parent is None or parent["company_id"] != int(company_id):
|
||||
raise ManualInputError("supersedes_record_id 无效。")
|
||||
supersedes_id = int(supersedes_record_id)
|
||||
|
||||
existing = connection.execute(
|
||||
"SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?",
|
||||
(int(company_id), request_key),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return _record_payload(connection, existing["id"], idempotent_replay=True)
|
||||
|
||||
now = utc_now()
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
try:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO manual_records (
|
||||
company_id, counterparty_company_id, occurred_at, direction,
|
||||
amount, amount_scale, currency, funding_source, bank_account_id,
|
||||
personal_transit_mapping_id, related_source_row_id,
|
||||
requested_subject, summary, reason, evidence_json, request_key,
|
||||
supersedes_record_id, submitted_by, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(company_id), int(counterparty_company_id), occurred_at, direction,
|
||||
amount, _scale_of(amount), currency, funding_source, bank_account_id,
|
||||
mapping_id, related_row["id"] if related_row is not None else None,
|
||||
requested_subject, str(summary or "") or None,
|
||||
str(reason or "") or None,
|
||||
json.dumps(evidence, ensure_ascii=False) if evidence else None,
|
||||
request_key, supersedes_id,
|
||||
actor["id"], now,
|
||||
),
|
||||
)
|
||||
record_id = int(cursor.lastrowid)
|
||||
_append_decision(
|
||||
connection, record_id, state="pending", action="submit",
|
||||
reason=str(reason or "") or None, actor=actor,
|
||||
)
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _record_payload(connection, record_id)
|
||||
|
||||
|
||||
def _resolve_account_ref(connection, raw, company_id: int, label: str) -> int | None:
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
row = connection.execute(
|
||||
"SELECT id, company_id FROM bank_accounts WHERE id = ?", (int(raw),)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ManualInputError(f"{label}不存在。")
|
||||
if row["company_id"] != int(company_id):
|
||||
raise ManualInputError(f"{label}必须属于提交公司。")
|
||||
return int(raw)
|
||||
|
||||
|
||||
def _scale_of(amount: str) -> int:
|
||||
exponent = Decimal(amount).as_tuple().exponent
|
||||
return max(0, -int(exponent))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decisions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _append_decision(
|
||||
connection: sqlite3.Connection,
|
||||
record_id: int,
|
||||
*,
|
||||
state: str,
|
||||
action: str,
|
||||
reason: str | None,
|
||||
actor: sqlite3.Row,
|
||||
idempotency_key: str | None = None,
|
||||
supersedes_decision_id: int | None = None,
|
||||
) -> int:
|
||||
row = connection.execute(
|
||||
"SELECT COALESCE(MAX(revision), 0) AS m FROM manual_record_decisions WHERE record_id = ?",
|
||||
(record_id,),
|
||||
).fetchone()
|
||||
revision = int(row["m"]) + 1
|
||||
now = utc_now()
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO manual_record_decisions (
|
||||
record_id, revision, state, action, reason, actor_user_id,
|
||||
actor_username, idempotency_key, supersedes_decision_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
record_id, revision, state, action, reason,
|
||||
actor["id"], actor["username"], idempotency_key,
|
||||
supersedes_decision_id, now,
|
||||
),
|
||||
)
|
||||
decision_id = int(cursor.lastrowid)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO current_manual_record_decisions (record_id, decision_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(record_id, decision_id),
|
||||
)
|
||||
return decision_id
|
||||
|
||||
|
||||
def _current_decision(connection: sqlite3.Connection, record_id: int) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT d.* FROM current_manual_record_decisions c
|
||||
JOIN manual_record_decisions d ON d.id = c.decision_id
|
||||
WHERE c.record_id = ?
|
||||
""",
|
||||
(record_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def decide(
|
||||
connection: sqlite3.Connection,
|
||||
record_id: int,
|
||||
action: str,
|
||||
*,
|
||||
reason: str,
|
||||
expected_decision_id: int | None,
|
||||
request_key: str | None,
|
||||
actor: sqlite3.Row,
|
||||
subject_code: object = None,
|
||||
target_ledger_event_id: object = None,
|
||||
) -> dict[str, object]:
|
||||
"""Apply an administrator decision to a manual record.
|
||||
|
||||
``approve_new`` creates a confirmed ledger event; ``approve_link`` joins an
|
||||
existing ledger event without adding a second economic impact; ``return``
|
||||
and ``exception`` never produce a balance; ``reverse`` creates an opposite
|
||||
reversal event (or detaches a linked claim). Replays return the earlier
|
||||
outcome via ``idempotency_key``.
|
||||
"""
|
||||
reason = (reason or "").strip()
|
||||
if not reason:
|
||||
raise ManualInputError("必须填写审核原因。")
|
||||
if action not in ("approve_new", "approve_link", "return", "exception", "reverse"):
|
||||
raise ManualInputError("未知的审核决定类型。")
|
||||
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
try:
|
||||
record = connection.execute(
|
||||
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
|
||||
).fetchone()
|
||||
if record is None:
|
||||
raise ManualConflictError("手工记录不存在。")
|
||||
if request_key:
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM manual_record_decisions WHERE record_id = ? AND idempotency_key = ?",
|
||||
(record_id, request_key),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _decision_payload(connection, record_id, existing["id"])
|
||||
|
||||
current = _current_decision(connection, record_id)
|
||||
if current is None:
|
||||
raise ManualConflictError("该记录没有当前状态。")
|
||||
if expected_decision_id is not None and int(expected_decision_id) != current["id"]:
|
||||
raise ManualConflictError("记录已发生变更,请刷新后重试。")
|
||||
|
||||
if action == "approve_new":
|
||||
outcome = _approve_new(
|
||||
connection, record, current, actor, subject_code, reason, request_key
|
||||
)
|
||||
elif action == "approve_link":
|
||||
outcome = _approve_link(
|
||||
connection, record, current, actor, target_ledger_event_id, reason,
|
||||
request_key,
|
||||
)
|
||||
elif action == "return":
|
||||
if current["state"] != "pending":
|
||||
raise ManualConflictError("只有待复核的记录可以退回。")
|
||||
decision_id = _append_decision(
|
||||
connection, record_id, state="returned", action=action,
|
||||
reason=reason, actor=actor, idempotency_key=request_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
outcome = {"decision_id": decision_id, "ledger_event_id": None}
|
||||
elif action == "exception":
|
||||
if current["state"] != "pending":
|
||||
raise ManualConflictError("只有待复核的记录可以转为异常。")
|
||||
decision_id = _append_decision(
|
||||
connection, record_id, state="exception", action=action,
|
||||
reason=reason, actor=actor, idempotency_key=request_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
outcome = {"decision_id": decision_id, "ledger_event_id": None}
|
||||
else: # reverse
|
||||
if current["state"] != "approved":
|
||||
raise ManualConflictError("只有已批准记录可以冲销。")
|
||||
outcome = _reverse(connection, record, current, actor, request_key, reason)
|
||||
|
||||
_store_audit(connection, record, current, action, outcome, reason, actor)
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return _decision_payload(connection, record_id, outcome["decision_id"])
|
||||
|
||||
|
||||
def _approve_new(
|
||||
connection: sqlite3.Connection,
|
||||
record: sqlite3.Row,
|
||||
current: sqlite3.Row,
|
||||
actor: sqlite3.Row,
|
||||
subject_code: object,
|
||||
reason: str,
|
||||
idempotency_key: str | None,
|
||||
) -> dict[str, object]:
|
||||
subject = str(subject_code or record["requested_subject"] or "")
|
||||
if subject not in SUBJECTS:
|
||||
raise ManualInputError("科目必须是应收/应付/其他应收/其他应付之一。")
|
||||
if record["direction"] == "outgoing":
|
||||
payer, payee = record["company_id"], record["counterparty_company_id"]
|
||||
else:
|
||||
payer, payee = record["counterparty_company_id"], record["company_id"]
|
||||
event_id, _revision_id = create_event(
|
||||
connection,
|
||||
state="confirmed",
|
||||
effective_at=record["occurred_at"],
|
||||
amount=record["amount"],
|
||||
currency=record["currency"],
|
||||
payer_company_id=payer,
|
||||
payee_company_id=payee,
|
||||
perspective_company_id=record["company_id"],
|
||||
subject_code=subject,
|
||||
source_kind="manual",
|
||||
source_revision_token=None,
|
||||
posting_kind="normal",
|
||||
rule_version="manual-record-v1",
|
||||
evidence_json=json.dumps({"manual_record_id": record["id"]}, ensure_ascii=False),
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
)
|
||||
decision_id = _append_decision(
|
||||
connection, record["id"], state="approved", action="approve_new",
|
||||
reason=reason, actor=actor,
|
||||
idempotency_key=idempotency_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_manual_sources (manual_record_id, ledger_event_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(record["id"], event_id),
|
||||
)
|
||||
return {"decision_id": decision_id, "ledger_event_id": event_id}
|
||||
|
||||
|
||||
def _approve_link(
|
||||
connection: sqlite3.Connection,
|
||||
record: sqlite3.Row,
|
||||
current: sqlite3.Row,
|
||||
actor: sqlite3.Row,
|
||||
target_ledger_event_id: object,
|
||||
reason: str,
|
||||
idempotency_key: str | None,
|
||||
) -> dict[str, object]:
|
||||
if target_ledger_event_id in (None, ""):
|
||||
raise ManualInputError("approve_link 必须指定目标往来事件。")
|
||||
target = connection.execute(
|
||||
"SELECT id, lifecycle FROM ledger_events WHERE id = ?",
|
||||
(int(target_ledger_event_id),),
|
||||
).fetchone()
|
||||
if target is None or target["lifecycle"] != "active":
|
||||
raise ManualConflictError("目标往来事件不存在。")
|
||||
if manual_source_claim(connection, record["id"]) is not None:
|
||||
raise ManualConflictError("该手工记录已关联往来事件。")
|
||||
decision_id = _append_decision(
|
||||
connection, record["id"], state="approved", action="approve_link",
|
||||
reason=reason, actor=actor,
|
||||
idempotency_key=idempotency_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO ledger_event_manual_sources (manual_record_id, ledger_event_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(record["id"], int(target_ledger_event_id)),
|
||||
)
|
||||
return {"decision_id": decision_id, "ledger_event_id": int(target_ledger_event_id)}
|
||||
|
||||
|
||||
def _reverse(
|
||||
connection: sqlite3.Connection,
|
||||
record: sqlite3.Row,
|
||||
current: sqlite3.Row,
|
||||
actor: sqlite3.Row,
|
||||
request_key: str | None,
|
||||
reason: str,
|
||||
) -> dict[str, object]:
|
||||
claim = manual_source_claim(connection, record["id"])
|
||||
if claim is None:
|
||||
raise ManualConflictError("该记录尚未关联往来事件,无法冲销。")
|
||||
event_id = claim["ledger_event_id"]
|
||||
revision = current_revision(connection, event_id)
|
||||
if revision is None:
|
||||
raise ManualConflictError("关联的往来事件没有当前修订。")
|
||||
|
||||
if revision["source_kind"] == "manual" and not _event_has_other_sources(
|
||||
connection, event_id, record["id"]
|
||||
):
|
||||
# The manual record is the only economic source: reverse it with a new
|
||||
# opposite event at an independent effective date.
|
||||
create_reversal(
|
||||
connection,
|
||||
event_id,
|
||||
source_kind="manual",
|
||||
source_revision_token=str(record["id"]),
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
idempotency_key=request_key,
|
||||
rule_version="manual-record-v1",
|
||||
)
|
||||
else:
|
||||
# The manual was linked evidence on a bank event: it never added a
|
||||
# second impact, so reversing detaches the claim without a reversal
|
||||
# event; the bank impact stays.
|
||||
connection.execute(
|
||||
"DELETE FROM ledger_event_manual_sources WHERE manual_record_id = ?",
|
||||
(record["id"],),
|
||||
)
|
||||
decision_id = _append_decision(
|
||||
connection, record["id"], state="reversed", action="reverse",
|
||||
reason=reason, actor=actor, idempotency_key=request_key,
|
||||
supersedes_decision_id=current["id"],
|
||||
)
|
||||
return {"decision_id": decision_id, "ledger_event_id": None}
|
||||
|
||||
|
||||
def _event_has_other_sources(connection, event_id: int, manual_record_id: int) -> bool:
|
||||
bank = connection.execute(
|
||||
"SELECT 1 FROM ledger_event_bank_sources WHERE ledger_event_id = ? LIMIT 1",
|
||||
(event_id,),
|
||||
).fetchone()
|
||||
other_manual = connection.execute(
|
||||
"SELECT 1 FROM ledger_event_manual_sources "
|
||||
"WHERE ledger_event_id = ? AND manual_record_id != ? LIMIT 1",
|
||||
(event_id, manual_record_id),
|
||||
).fetchone()
|
||||
return bank is not None or other_manual is not None
|
||||
|
||||
|
||||
def _store_audit(
|
||||
connection, record, current, action, outcome, reason, actor
|
||||
) -> None:
|
||||
from .auth import audit
|
||||
|
||||
audit(
|
||||
connection,
|
||||
f"manual_{action}",
|
||||
actor=actor,
|
||||
target=f"manual_record:{record['id']}",
|
||||
detail=(
|
||||
f"decision:{outcome['decision_id']};"
|
||||
f"ledger_event:{outcome.get('ledger_event_id')};reason:{reason}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Candidates and queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_candidates(connection: sqlite3.Connection, record_id: int) -> list[dict[str, object]]:
|
||||
"""Deterministic hints shown before approval; never auto-merged."""
|
||||
record = connection.execute(
|
||||
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
|
||||
).fetchone()
|
||||
if record is None:
|
||||
return []
|
||||
wanted_direction = "incoming" if record["direction"] == "outgoing" else "outgoing"
|
||||
date_prefix = str(record["occurred_at"])[:10]
|
||||
candidates: list[dict[str, object]] = []
|
||||
|
||||
bank_rows = connection.execute(
|
||||
"""
|
||||
SELECT e.event_id, e.amount, e.currency, e.effective_at, e.pairing,
|
||||
e.payer_company_id, e.payee_company_id, e.decision_id
|
||||
FROM eligible_intercompany_events e
|
||||
WHERE (e.payer_company_id = ? AND e.payee_company_id = ?)
|
||||
OR (e.payer_company_id = ? AND e.payee_company_id = ?)
|
||||
ORDER BY e.event_id
|
||||
""",
|
||||
(
|
||||
record["company_id"], record["counterparty_company_id"],
|
||||
record["counterparty_company_id"], record["company_id"],
|
||||
),
|
||||
).fetchall()
|
||||
for row in bank_rows:
|
||||
if row["amount"] != record["amount"] or row["currency"] != record["currency"]:
|
||||
continue
|
||||
event_direction = (
|
||||
"outgoing" if row["payer_company_id"] == record["company_id"] else "incoming"
|
||||
)
|
||||
if event_direction != wanted_direction:
|
||||
continue
|
||||
candidates.append(
|
||||
{
|
||||
"kind": "bank_event",
|
||||
"ledger_event_id": _ledger_event_of_bank(connection, row["event_id"]),
|
||||
"bank_event_id": row["event_id"],
|
||||
"decision_id": row["decision_id"],
|
||||
"amount": row["amount"],
|
||||
"currency": row["currency"],
|
||||
"effective_at": row["effective_at"],
|
||||
"pairing": row["pairing"],
|
||||
"hint": "已存在匹配的银行规范事件,建议关联",
|
||||
}
|
||||
)
|
||||
|
||||
manual_rows = connection.execute(
|
||||
"""
|
||||
SELECT m.id, m.company_id, m.counterparty_company_id, m.direction,
|
||||
m.amount, m.currency, m.occurred_at, d.state
|
||||
FROM manual_records m
|
||||
JOIN current_manual_record_decisions c ON c.record_id = m.id
|
||||
JOIN manual_record_decisions d ON d.id = c.decision_id
|
||||
WHERE m.id != ? AND m.amount = ? AND m.currency = ?
|
||||
AND (
|
||||
(m.company_id = ? AND m.counterparty_company_id = ?)
|
||||
OR (m.company_id = ? AND m.counterparty_company_id = ?)
|
||||
)
|
||||
ORDER BY m.id
|
||||
""",
|
||||
(
|
||||
record["id"], record["amount"], record["currency"],
|
||||
record["company_id"], record["counterparty_company_id"],
|
||||
record["counterparty_company_id"], record["company_id"],
|
||||
),
|
||||
).fetchall()
|
||||
for row in manual_rows:
|
||||
if row["direction"] != wanted_direction:
|
||||
continue
|
||||
if row["state"] not in ("approved", "pending"):
|
||||
continue
|
||||
candidates.append(
|
||||
{
|
||||
"kind": "manual_record",
|
||||
"ledger_event_id": None,
|
||||
"manual_record_id": row["id"],
|
||||
"amount": row["amount"],
|
||||
"currency": row["currency"],
|
||||
"occurred_at": row["occurred_at"],
|
||||
"state": row["state"],
|
||||
"hint": "存在方向相反的同额手工记录,建议核对后关联",
|
||||
}
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def _ledger_event_of_bank(connection, bank_event_id: int) -> int | None:
|
||||
claim = connection.execute(
|
||||
"SELECT ledger_event_id FROM ledger_event_bank_sources WHERE bank_event_id = ?",
|
||||
(bank_event_id,),
|
||||
).fetchone()
|
||||
return claim["ledger_event_id"] if claim is not None else None
|
||||
|
||||
|
||||
def list_records(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
company_id: int | None = None,
|
||||
state: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[sqlite3.Row]:
|
||||
conditions: list[str] = []
|
||||
params: list[object] = []
|
||||
if company_id is not None:
|
||||
conditions.append("(m.company_id = ? OR m.counterparty_company_id = ?)")
|
||||
params.extend([company_id, company_id])
|
||||
if state is not None:
|
||||
if state not in MANUAL_STATES:
|
||||
raise ManualInputError("无效的记录状态。")
|
||||
conditions.append("d.state = ?")
|
||||
params.append(state)
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
return connection.execute(
|
||||
f"""
|
||||
SELECT m.*, d.id AS decision_id, d.state AS state, d.revision AS decision_revision,
|
||||
d.action AS action, d.reason AS decision_reason,
|
||||
d.actor_username AS decision_actor, d.created_at AS decision_at,
|
||||
c.name AS company_name, cc.name AS counterparty_company_name
|
||||
FROM manual_records m
|
||||
JOIN current_manual_record_decisions c ON c.record_id = m.id
|
||||
JOIN manual_record_decisions d ON d.id = c.decision_id
|
||||
LEFT JOIN companies c ON c.id = m.company_id
|
||||
LEFT JOIN companies cc ON cc.id = m.counterparty_company_id
|
||||
{where}
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(*params, max(1, int(limit))),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def rebuild_current_manual_projection(connection: sqlite3.Connection) -> int:
|
||||
began = False
|
||||
if not connection.in_transaction:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
began = True
|
||||
try:
|
||||
connection.execute("DELETE FROM current_manual_record_decisions")
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT m.id AS record_id,
|
||||
(SELECT d2.id FROM manual_record_decisions d2
|
||||
WHERE d2.record_id = m.id
|
||||
ORDER BY d2.revision DESC LIMIT 1) AS latest_id
|
||||
FROM manual_records m
|
||||
"""
|
||||
).fetchall()
|
||||
rebuilt = 0
|
||||
for row in rows:
|
||||
if row["latest_id"] is None:
|
||||
continue
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO current_manual_record_decisions (record_id, decision_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(row["record_id"], row["latest_id"]),
|
||||
)
|
||||
rebuilt += 1
|
||||
except Exception:
|
||||
if began:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
if began:
|
||||
connection.commit()
|
||||
return rebuilt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payloads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _record_payload(connection: sqlite3.Connection, record_id: int, *, idempotent_replay: bool = False) -> dict[str, object]:
|
||||
rows = list_records(connection, limit=1000)
|
||||
row = next((item for item in rows if item["id"] == record_id), None)
|
||||
if row is None:
|
||||
raise ManualInputError("手工记录不存在。")
|
||||
payload = _row_payload(connection, row)
|
||||
if idempotent_replay:
|
||||
payload["idempotent_replay"] = True
|
||||
return payload
|
||||
|
||||
|
||||
def _row_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, object]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"company_id": row["company_id"],
|
||||
"company_name": row["company_name"],
|
||||
"counterparty_company_id": row["counterparty_company_id"],
|
||||
"counterparty_company_name": row["counterparty_company_name"],
|
||||
"occurred_at": row["occurred_at"],
|
||||
"direction": row["direction"],
|
||||
"amount": row["amount"],
|
||||
"currency": row["currency"],
|
||||
"funding_source": row["funding_source"],
|
||||
"bank_account_id": row["bank_account_id"],
|
||||
"personal_transit_mapping_id": row["personal_transit_mapping_id"],
|
||||
"related_source_row_id": row["related_source_row_id"],
|
||||
"requested_subject": row["requested_subject"],
|
||||
"summary": row["summary"],
|
||||
"reason": row["reason"],
|
||||
"request_key": row["request_key"],
|
||||
"supersedes_record_id": row["supersedes_record_id"],
|
||||
"submitted_by": row["submitted_by"],
|
||||
"created_at": row["created_at"],
|
||||
"state": row["state"],
|
||||
"decision_id": row["decision_id"],
|
||||
"decision_revision": row["decision_revision"],
|
||||
"decision_action": row["action"],
|
||||
"decision_reason": row["decision_reason"],
|
||||
"decision_actor": row["decision_actor"],
|
||||
"decision_at": row["decision_at"],
|
||||
"candidates": find_candidates(connection, row["id"]),
|
||||
}
|
||||
|
||||
|
||||
def _decision_payload(connection: sqlite3.Connection, record_id: int, decision_id: int) -> dict[str, object]:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT d.* FROM manual_record_decisions d
|
||||
WHERE d.id = ?
|
||||
""",
|
||||
(decision_id,),
|
||||
).fetchone()
|
||||
record = connection.execute(
|
||||
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
|
||||
).fetchone()
|
||||
claim = manual_source_claim(connection, record_id)
|
||||
return {
|
||||
"record_id": record_id,
|
||||
"decision_id": decision_id,
|
||||
"revision": row["revision"],
|
||||
"state": row["state"],
|
||||
"action": row["action"],
|
||||
"reason": row["reason"],
|
||||
"actor_username": row["actor_username"],
|
||||
"created_at": row["created_at"],
|
||||
"ledger_event_id": claim["ledger_event_id"] if claim is not None else None,
|
||||
"requested_subject": record["requested_subject"],
|
||||
"amount": record["amount"],
|
||||
"currency": record["currency"],
|
||||
"occurred_at": record["occurred_at"],
|
||||
}
|
||||
Reference in New Issue
Block a user