"""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, timedelta, timezone 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 def _business_today() -> str: """Shanghai business date (the default reversal effective date).""" return datetime.now(timezone(timedelta(hours=8))).date().isoformat() # --------------------------------------------------------------------------- # 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) now = utc_now() began = False if not connection.in_transaction: connection.execute("BEGIN IMMEDIATE") began = True try: # Re-check inside the write transaction: concurrent identical submits # serialize here, so a replay is found before any INSERT. 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: if began: connection.commit() return _record_payload(connection, existing["id"], idempotent_replay=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, ), ) except sqlite3.IntegrityError: # A concurrent identical submit won the race and committed first; # surface the existing record idempotently instead of a UNIQUE 500. if began: connection.rollback() 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) raise 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, effective_at: 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) with an independent business effective date — explicit ``effective_at`` or the approval business day. 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, effective_at=effective_at, ) _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, effective_at: object = None, ) -> 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 _is_manual_creation_source(revision, record["id"]): # This record created the event (approve_new): it added the economic # impact, so reversing it must always produce an equal-amount reversal # event — even when other manual evidence was later linked onto the # same event. The original impact must not survive in balances. if effective_at is not None and str(effective_at).strip(): effective_at = _validate_date(effective_at, "冲销生效日") else: effective_at = _business_today() create_reversal( connection, event_id, source_kind="manual", source_revision_token=str(record["id"]), effective_at=effective_at, reason=reason, actor=actor, idempotency_key=request_key, rule_version="manual-record-v1", ) else: # The manual was linked evidence (approve_link) on an event it never # created: it added no second impact, so reversing detaches the claim # and the underlying economic 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 _is_manual_creation_source(revision: sqlite3.Row, manual_record_id: int) -> bool: """True when ``manual_record_id`` created the event via ``approve_new``. The creation source is recorded in the event's immutable revision ``evidence_json``; a linked evidence record is never the creation source and carries no second economic impact. """ if revision["source_kind"] != "manual": return False try: evidence = json.loads(revision["evidence_json"] or "{}") except (TypeError, ValueError): return False return evidence.get("manual_record_id") == manual_record_id 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, u.username AS submitted_by_username 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 LEFT JOIN users u ON u.id = m.submitted_by {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]: evidence = json.loads(row["evidence_json"] or "{}") if row["evidence_json"] else {} 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"], "submitted_by_username": row["submitted_by_username"] if "submitted_by_username" in row.keys() else None, "attachment_name": evidence.get("attachment_name"), "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"], }