- 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)
672 lines
24 KiB
Python
672 lines
24 KiB
Python
"""Immutable statement import pipeline with per-worksheet review lifecycle.
|
||
|
||
Every uploaded file is hashed (SHA-256) and written once to content-addressed
|
||
storage before parsing. A repeated upload of identical bytes never creates a
|
||
second set of facts: it records a ``duplicate`` batch that points at the
|
||
original batch. Parse failures keep the batch and its diagnostics as an
|
||
``exception`` batch without producing any confirmed source rows.
|
||
|
||
Each worksheet is parsed independently into a :class:`SheetResult`
|
||
(``parsed`` / ``exception`` / ``ignored``) that is persisted in
|
||
``sheet_reviews`` as immutable evidence. Whether a parsed sheet may take part
|
||
in later matching and calculation is a separate, auditable human decision
|
||
(``review_status``): only ``confirmed`` sheets participate.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import shutil
|
||
import sqlite3
|
||
import tempfile
|
||
|
||
from . import auth
|
||
from . import ledger_events
|
||
from . import matching
|
||
from .db import utc_now
|
||
from .models import SheetResult, StatementBatch
|
||
from .parser import StatementParseError, analyze_workbook
|
||
from .reader import CorruptWorkbookError
|
||
|
||
|
||
class SheetReviewError(ValueError):
|
||
"""A review decision cannot be applied to the worksheet (mapped to 409)."""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ImportResult:
|
||
batch_id: int
|
||
status: str # parsed | duplicate | exception
|
||
sha256: str
|
||
source_file_id: int
|
||
batches: tuple[StatementBatch, ...] = ()
|
||
sheets: tuple[SheetResult, ...] = ()
|
||
message: str | None = None
|
||
duplicate_same_company: bool = False # only meaningful when status == 'duplicate'
|
||
|
||
|
||
def import_statement(
|
||
connection: sqlite3.Connection,
|
||
storage_dir: str | Path,
|
||
original_filename: str,
|
||
content: bytes,
|
||
company_id: int | None = None,
|
||
upload_bank_account_id: int | None = None,
|
||
) -> ImportResult:
|
||
sha256 = hashlib.sha256(content).hexdigest()
|
||
existing_file = connection.execute(
|
||
"SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
|
||
).fetchone()
|
||
|
||
if existing_file is not None:
|
||
return _record_duplicate(connection, existing_file["id"], sha256, company_id)
|
||
|
||
stored_path = _store_immutable(Path(storage_dir), original_filename, content, sha256)
|
||
try:
|
||
source_file_id, batch_id = _create_batch_records(
|
||
connection, sha256, original_filename, len(content), stored_path,
|
||
company_id, upload_bank_account_id,
|
||
)
|
||
except sqlite3.IntegrityError:
|
||
# Lost a concurrent-insert race on the sha256 UNIQUE constraint: the
|
||
# identical content was already persisted by another request, so this
|
||
# upload is a duplicate. The content-addressed file already exists.
|
||
existing = connection.execute(
|
||
"SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
|
||
).fetchone()
|
||
if existing is not None:
|
||
return _record_duplicate(connection, existing["id"], sha256, company_id)
|
||
raise
|
||
return _parse_and_persist(
|
||
connection, stored_path, sha256, original_filename, source_file_id, batch_id
|
||
)
|
||
|
||
|
||
def import_statement_path(
|
||
connection: sqlite3.Connection,
|
||
storage_dir: str | Path,
|
||
original_filename: str,
|
||
upload_path: str | Path,
|
||
company_id: int | None = None,
|
||
upload_bank_account_id: int | None = None,
|
||
) -> ImportResult:
|
||
"""Import from an already-downloaded upload file (streaming-friendly).
|
||
|
||
``upload_path`` names the temp file the multipart handler streamed to
|
||
disk; it is hashed incrementally and either discarded (duplicate) or
|
||
published into the content-addressed store. The upload file itself is
|
||
never modified. The approved ``upload_bank_account_id`` used at upload time
|
||
is persisted on the batch so ownership can be resolved later even when the
|
||
source rows carry no own account.
|
||
"""
|
||
source = Path(upload_path)
|
||
sha256 = _file_sha256(source)
|
||
existing_file = connection.execute(
|
||
"SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
|
||
).fetchone()
|
||
|
||
if existing_file is not None:
|
||
source.unlink(missing_ok=True)
|
||
return _record_duplicate(connection, existing_file["id"], sha256, company_id)
|
||
|
||
stored_path = _publish_immutable(Path(storage_dir), original_filename, source, sha256)
|
||
size = stored_path.stat().st_size
|
||
try:
|
||
source_file_id, batch_id = _create_batch_records(
|
||
connection, sha256, original_filename, size, stored_path,
|
||
company_id, upload_bank_account_id,
|
||
)
|
||
except sqlite3.IntegrityError:
|
||
# Lost a concurrent-insert race on the sha256 UNIQUE constraint: the
|
||
# identical content was already persisted by another request, so this
|
||
# upload is a duplicate. The content-addressed file already exists.
|
||
existing = connection.execute(
|
||
"SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
|
||
).fetchone()
|
||
if existing is not None:
|
||
return _record_duplicate(connection, existing["id"], sha256, company_id)
|
||
raise
|
||
return _parse_and_persist(
|
||
connection, stored_path, sha256, original_filename, source_file_id, batch_id
|
||
)
|
||
|
||
|
||
def _create_batch_records(
|
||
connection: sqlite3.Connection,
|
||
sha256: str,
|
||
original_filename: str,
|
||
size_bytes: int,
|
||
stored_path: Path,
|
||
company_id: int | None,
|
||
upload_bank_account_id: int | None,
|
||
) -> tuple[int, int]:
|
||
now = utc_now()
|
||
with connection:
|
||
cursor = connection.execute(
|
||
"""
|
||
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
""",
|
||
(sha256, original_filename, size_bytes, str(stored_path), now),
|
||
)
|
||
source_file_id = cursor.lastrowid
|
||
cursor = connection.execute(
|
||
"""
|
||
INSERT INTO import_batches (source_file_id, status, company_id, upload_bank_account_id, created_at, updated_at)
|
||
VALUES (?, 'parsing', ?, ?, ?, ?)
|
||
""",
|
||
(source_file_id, company_id, upload_bank_account_id, now, now),
|
||
)
|
||
batch_id = cursor.lastrowid
|
||
return source_file_id, batch_id
|
||
|
||
|
||
def _parse_and_persist(
|
||
connection: sqlite3.Connection,
|
||
stored_path: Path,
|
||
sha256: str,
|
||
original_filename: str,
|
||
source_file_id: int,
|
||
batch_id: int,
|
||
) -> ImportResult:
|
||
try:
|
||
sheets = analyze_workbook(stored_path)
|
||
except CorruptWorkbookError as exc:
|
||
message = _clean_message(str(exc), stored_path, original_filename)
|
||
with connection:
|
||
_insert_exception(
|
||
connection, batch_id, "parse", message, original_filename,
|
||
diagnostics={"original_filename": original_filename},
|
||
)
|
||
_set_batch_status(connection, batch_id, "exception")
|
||
return ImportResult(batch_id, "exception", sha256, source_file_id, message=message)
|
||
except Exception as exc:
|
||
message = f"文件解析失败,请检查文件是否完整。({type(exc).__name__})"
|
||
with connection:
|
||
_insert_exception(
|
||
connection, batch_id, "internal", message, original_filename,
|
||
diagnostics={"original_filename": original_filename},
|
||
)
|
||
_set_batch_status(connection, batch_id, "failed")
|
||
raise
|
||
|
||
with connection:
|
||
parsed_batches: list[StatementBatch] = []
|
||
for sheet in sheets:
|
||
if sheet.outcome == "parsed" and sheet.batch is not None:
|
||
sheet_batch_id = _insert_sheet_batch(connection, batch_id, sheet.batch)
|
||
_insert_sheet_review(connection, batch_id, sheet, sheet_batch_id)
|
||
parsed_batches.append(sheet.batch)
|
||
else:
|
||
_insert_sheet_review(connection, batch_id, sheet, None)
|
||
if not parsed_batches:
|
||
message = _whole_file_message(original_filename, sheets)
|
||
with connection:
|
||
_insert_exception(
|
||
connection, batch_id, "parse", message, original_filename,
|
||
diagnostics=_exception_diagnostics(original_filename, sheets),
|
||
)
|
||
_set_batch_status(connection, batch_id, "exception")
|
||
return ImportResult(
|
||
batch_id, "exception", sha256, source_file_id,
|
||
message=message, sheets=sheets,
|
||
)
|
||
_set_batch_status(connection, batch_id, "parsed")
|
||
return ImportResult(
|
||
batch_id, "parsed", sha256, source_file_id,
|
||
batches=tuple(parsed_batches), sheets=sheets,
|
||
)
|
||
|
||
|
||
def _record_duplicate(
|
||
connection: sqlite3.Connection,
|
||
source_file_id: int,
|
||
sha256: str,
|
||
company_id: int | None = None,
|
||
) -> ImportResult:
|
||
original = connection.execute(
|
||
"""
|
||
SELECT id, company_id FROM import_batches
|
||
WHERE source_file_id = ? AND status = 'parsed'
|
||
ORDER BY id LIMIT 1
|
||
""",
|
||
(source_file_id,),
|
||
).fetchone()
|
||
if original is None:
|
||
original = connection.execute(
|
||
"""
|
||
SELECT id, company_id FROM import_batches
|
||
WHERE source_file_id = ? AND status != 'duplicate'
|
||
ORDER BY id LIMIT 1
|
||
""",
|
||
(source_file_id,),
|
||
).fetchone()
|
||
now = utc_now()
|
||
with connection:
|
||
cursor = connection.execute(
|
||
"""
|
||
INSERT INTO import_batches
|
||
(source_file_id, status, duplicate_of_id, company_id, diagnostics, created_at, updated_at)
|
||
VALUES (?, 'duplicate', ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
source_file_id,
|
||
original["id"],
|
||
company_id,
|
||
json.dumps({"note": "内容哈希相同,复用已有批次,不产生第二份事实。"}, ensure_ascii=False),
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
# A duplicate is only "same company" when the uploader belongs to the
|
||
# same tenant that owns the original batch. Cross-company duplicates
|
||
# must stay opaque: the caller gets the new duplicate batch id (which
|
||
# belongs to its own company) but never the other company's batch id.
|
||
same_company = original["company_id"] == company_id
|
||
if same_company:
|
||
batch_id = original["id"]
|
||
else:
|
||
batch_id = int(cursor.lastrowid)
|
||
return ImportResult(
|
||
batch_id,
|
||
"duplicate",
|
||
sha256,
|
||
source_file_id,
|
||
message="相同内容的文件已导入,本次按重复上传处理。",
|
||
duplicate_same_company=same_company,
|
||
)
|
||
|
||
|
||
def _store_immutable(
|
||
storage_dir: Path, original_filename: str, content: bytes, sha256: str
|
||
) -> Path:
|
||
suffix = Path(original_filename).suffix.lower()
|
||
target_dir = storage_dir / sha256[:2]
|
||
target_dir.mkdir(parents=True, exist_ok=True)
|
||
target = target_dir / f"{sha256}{suffix}"
|
||
# Publish via a unique temporary file + hard link: the content-addressed
|
||
# target either appears complete or not at all, and is never overwritten.
|
||
# The temp name is unique per writer, so concurrent uploads of the same
|
||
# bytes cannot corrupt each other's staging file (B-43 baseline fix).
|
||
fd, temp_path = tempfile.mkstemp(prefix=f".{sha256}.", suffix=".tmp", dir=target_dir)
|
||
try:
|
||
with os.fdopen(fd, "wb") as handle:
|
||
handle.write(content)
|
||
try:
|
||
os.link(temp_path, target)
|
||
except FileExistsError:
|
||
# Content-addressed name means identical bytes; never overwrite.
|
||
pass
|
||
finally:
|
||
try:
|
||
os.unlink(temp_path)
|
||
except FileNotFoundError:
|
||
pass
|
||
return target
|
||
|
||
|
||
def _publish_immutable(
|
||
storage_dir: Path, original_filename: str, upload_path: Path, sha256: str
|
||
) -> Path:
|
||
suffix = Path(original_filename).suffix.lower()
|
||
target_dir = storage_dir / sha256[:2]
|
||
target_dir.mkdir(parents=True, exist_ok=True)
|
||
target = target_dir / f"{sha256}{suffix}"
|
||
fd, temp_path = tempfile.mkstemp(prefix=f".{sha256}.", suffix=".tmp", dir=target_dir)
|
||
try:
|
||
with os.fdopen(fd, "wb") as handle:
|
||
# Copy keeps the same-filesystem guarantee even if the upload temp
|
||
# lives elsewhere; the hard link then publishes atomically. The
|
||
# staging file is unique per writer, so two concurrent uploads of
|
||
# the same bytes never corrupt each other's copy.
|
||
with upload_path.open("rb") as source:
|
||
shutil.copyfileobj(source, handle, length=1024 * 1024)
|
||
try:
|
||
os.link(temp_path, target)
|
||
except FileExistsError:
|
||
pass
|
||
finally:
|
||
try:
|
||
os.unlink(temp_path)
|
||
except FileNotFoundError:
|
||
pass
|
||
upload_path.unlink(missing_ok=True)
|
||
return target
|
||
|
||
|
||
def _file_sha256(path: Path) -> str:
|
||
digest = hashlib.sha256()
|
||
with path.open("rb") as handle:
|
||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||
digest.update(chunk)
|
||
return digest.hexdigest()
|
||
|
||
|
||
def _insert_sheet_batch(
|
||
connection: sqlite3.Connection, batch_id: int, batch: StatementBatch
|
||
) -> int:
|
||
now = utc_now()
|
||
cursor = connection.execute(
|
||
"""
|
||
INSERT INTO sheet_batches (
|
||
import_batch_id, sheet_name, bank_name, template_id, template_version,
|
||
header_row, own_account, own_name, period_start, period_end,
|
||
transaction_count, warnings, created_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
batch_id,
|
||
batch.sheet_name,
|
||
batch.bank_name,
|
||
batch.template_id,
|
||
batch.template_version,
|
||
batch.header_row,
|
||
batch.own_account,
|
||
batch.own_name,
|
||
batch.period_start.isoformat() if batch.period_start else None,
|
||
batch.period_end.isoformat() if batch.period_end else None,
|
||
len(batch.transactions),
|
||
json.dumps(list(batch.warnings), ensure_ascii=False),
|
||
now,
|
||
),
|
||
)
|
||
sheet_batch_id = cursor.lastrowid
|
||
for transaction in batch.transactions:
|
||
connection.execute(
|
||
"""
|
||
INSERT INTO source_rows (
|
||
sheet_batch_id, source_row, transaction_at, income, expense, balance,
|
||
own_account, own_name, counterparty_account, counterparty_name,
|
||
counterparty_bank, summary, purpose, reference, currency, created_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
sheet_batch_id,
|
||
transaction.source_row,
|
||
transaction.transaction_at.isoformat(),
|
||
str(transaction.income),
|
||
str(transaction.expense),
|
||
str(transaction.balance) if transaction.balance is not None else None,
|
||
transaction.own_account,
|
||
transaction.own_name,
|
||
transaction.counterparty_account,
|
||
transaction.counterparty_name,
|
||
transaction.counterparty_bank,
|
||
transaction.summary,
|
||
transaction.purpose,
|
||
transaction.reference,
|
||
transaction.currency,
|
||
now,
|
||
),
|
||
)
|
||
return sheet_batch_id
|
||
|
||
|
||
def _insert_sheet_review(
|
||
connection: sqlite3.Connection,
|
||
batch_id: int,
|
||
sheet: SheetResult,
|
||
sheet_batch_id: int | None,
|
||
) -> None:
|
||
# Parse-side ignored sheets are terminal: there is nothing to confirm, so
|
||
# their review lifecycle is closed with a machine reason.
|
||
if sheet.outcome == "ignored":
|
||
review_status = "ignored"
|
||
review_reason = "空表或无有效内容,自动忽略。"
|
||
else:
|
||
review_status = "pending"
|
||
review_reason = None
|
||
connection.execute(
|
||
"""
|
||
INSERT INTO sheet_reviews (
|
||
import_batch_id, sheet_name, outcome, message, scanned_rows,
|
||
candidate_headers, sheet_batch_id, review_status, review_reason, created_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
batch_id,
|
||
sheet.sheet_name,
|
||
sheet.outcome,
|
||
sheet.message,
|
||
sheet.scanned_rows,
|
||
json.dumps(list(sheet.candidate_headers), ensure_ascii=False)
|
||
if sheet.candidate_headers
|
||
else None,
|
||
sheet_batch_id,
|
||
review_status,
|
||
review_reason,
|
||
utc_now(),
|
||
),
|
||
)
|
||
|
||
|
||
def _insert_exception(
|
||
connection: sqlite3.Connection,
|
||
batch_id: int,
|
||
stage: str,
|
||
message: str,
|
||
original_filename: str,
|
||
*,
|
||
diagnostics: dict[str, object] | None = None,
|
||
) -> None:
|
||
payload = {"original_filename": original_filename}
|
||
if diagnostics:
|
||
payload.update(diagnostics)
|
||
connection.execute(
|
||
"""
|
||
INSERT INTO import_exceptions (import_batch_id, stage, message, diagnostics, created_at)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
batch_id,
|
||
stage,
|
||
message,
|
||
json.dumps(payload, ensure_ascii=False),
|
||
utc_now(),
|
||
),
|
||
)
|
||
|
||
|
||
def _set_batch_status(connection: sqlite3.Connection, batch_id: int, status: str) -> None:
|
||
connection.execute(
|
||
"UPDATE import_batches SET status = ?, updated_at = ? WHERE id = ?",
|
||
(status, utc_now(), batch_id),
|
||
)
|
||
|
||
|
||
def _whole_file_message(original_filename: str, sheets: tuple[SheetResult, ...]) -> str:
|
||
detail = ";".join(
|
||
f"{sheet.sheet_name}:{sheet.message}" for sheet in sheets if sheet.message
|
||
)
|
||
return f"文件「{original_filename}」没有可确认的工作表。{detail}"
|
||
|
||
|
||
def _exception_diagnostics(
|
||
original_filename: str, sheets: tuple[SheetResult, ...]
|
||
) -> dict[str, object]:
|
||
return {
|
||
"original_filename": original_filename,
|
||
"sheets": [
|
||
{
|
||
"sheet_name": sheet.sheet_name,
|
||
"outcome": sheet.outcome,
|
||
"message": sheet.message,
|
||
"scanned_rows": sheet.scanned_rows,
|
||
"candidate_headers": list(sheet.candidate_headers),
|
||
}
|
||
for sheet in sheets
|
||
],
|
||
}
|
||
|
||
|
||
def _clean_message(message: str, stored_path: Path, original_filename: str) -> str:
|
||
return message.replace(str(stored_path), original_filename).replace(
|
||
stored_path.name, original_filename
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Per-sheet review lifecycle
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def sheet_review_rows(connection: sqlite3.Connection, batch_id: int) -> list[sqlite3.Row]:
|
||
return connection.execute(
|
||
"""
|
||
SELECT r.id, r.sheet_name, r.outcome, r.message, r.scanned_rows,
|
||
r.candidate_headers, r.review_status, r.review_reason,
|
||
r.reviewed_by, r.reviewed_at,
|
||
s.bank_name, s.template_id, s.header_row, s.own_account, s.own_name,
|
||
s.period_start, s.period_end, s.transaction_count, s.warnings
|
||
FROM sheet_reviews r
|
||
LEFT JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
||
WHERE r.import_batch_id = ?
|
||
ORDER BY r.id
|
||
""",
|
||
(batch_id,),
|
||
).fetchall()
|
||
|
||
|
||
def scoped_batch(
|
||
connection: sqlite3.Connection, batch_id: int, company_id: int | None
|
||
) -> sqlite3.Row | None:
|
||
"""Load a batch, scoping company users to their own tenant.
|
||
|
||
``company_id is None`` (admin) sees any batch; a company user only sees
|
||
batches owned by that company. Returns None when out of scope so callers
|
||
can answer 404 without leaking the batch's existence.
|
||
"""
|
||
if company_id is None:
|
||
return connection.execute(
|
||
"SELECT id, company_id FROM import_batches WHERE id = ?", (batch_id,)
|
||
).fetchone()
|
||
return connection.execute(
|
||
"SELECT id, company_id FROM import_batches WHERE id = ? AND company_id = ?",
|
||
(batch_id, company_id),
|
||
).fetchone()
|
||
|
||
|
||
def review_sheets(
|
||
connection: sqlite3.Connection,
|
||
batch_id: int,
|
||
sheet_names: list[str],
|
||
decision: str,
|
||
actor: sqlite3.Row | None,
|
||
company_id: int | None,
|
||
reason: str | None = None,
|
||
) -> dict[str, object]:
|
||
"""Apply ``confirm`` or ``ignore`` to whole worksheets atomically.
|
||
|
||
Multi-sheet writes run in one transaction; a failure rolls back every
|
||
sheet in the request. Idempotent repeats of the same decision succeed
|
||
without touching the audit log; changing a settled decision is a
|
||
conflict. Returns ``{"updated": [...], "already": [...]}``.
|
||
"""
|
||
if decision not in ("confirm", "ignore"):
|
||
raise ValueError("decision 必须是 confirm 或 ignore。")
|
||
names = [str(name).strip() for name in sheet_names]
|
||
if not names:
|
||
raise ValueError("必须至少指定一个工作表。")
|
||
reason = (reason or "").strip() or None
|
||
if decision == "ignore" and not reason:
|
||
raise ValueError("忽略工作表必须填写原因。")
|
||
|
||
batch = scoped_batch(connection, batch_id, company_id)
|
||
if batch is None:
|
||
raise LookupError("批次不存在。")
|
||
|
||
placeholders = ",".join("?" for _ in names)
|
||
rows = connection.execute(
|
||
f"""
|
||
SELECT * FROM sheet_reviews
|
||
WHERE import_batch_id = ? AND sheet_name IN ({placeholders})
|
||
""",
|
||
(batch_id, *names),
|
||
).fetchall()
|
||
found = {row["sheet_name"] for row in rows}
|
||
missing = [name for name in names if name not in found]
|
||
if missing:
|
||
raise ValueError(f"工作表不存在或不属于该批次:{'、'.join(missing)}")
|
||
|
||
target = "confirmed" if decision == "confirm" else "ignored"
|
||
now = utc_now()
|
||
updated: list[str] = []
|
||
already: list[str] = []
|
||
matching_result: dict[str, object] | None = None
|
||
# BEGIN IMMEDIATE serializes concurrent confirmations: the write lock is
|
||
# taken before the read, so two cashiers confirming matching worksheets can
|
||
# never derive their match decisions from stale snapshots.
|
||
began = False
|
||
if not connection.in_transaction:
|
||
connection.execute("BEGIN IMMEDIATE")
|
||
began = True
|
||
try:
|
||
for row in rows:
|
||
if row["review_status"] == target:
|
||
already.append(row["sheet_name"])
|
||
continue
|
||
if decision == "confirm" and row["outcome"] != "parsed":
|
||
raise SheetReviewError(
|
||
f"工作表「{row['sheet_name']}」没有可确认的交易,只能忽略或保留待处理。"
|
||
)
|
||
if row["review_status"] != "pending":
|
||
raise SheetReviewError(
|
||
f"工作表「{row['sheet_name']}」已处理,不能更改决定。"
|
||
)
|
||
cursor = connection.execute(
|
||
"""
|
||
UPDATE sheet_reviews
|
||
SET review_status = ?, review_reason = ?, reviewed_by = ?, reviewed_at = ?
|
||
WHERE id = ? AND review_status = 'pending'
|
||
""",
|
||
(target, reason, actor["id"] if actor is not None else None, now, row["id"]),
|
||
)
|
||
if cursor.rowcount:
|
||
updated.append(row["sheet_name"])
|
||
|
||
# Confirming a worksheet and reconciling its rows must succeed or fail
|
||
# together: any matching failure rolls the confirmation back, so there
|
||
# is never a "confirmed but unmatched" half state.
|
||
if updated and decision == "confirm":
|
||
placeholders = ",".join("?" for _ in updated)
|
||
confirmed_rows = connection.execute(
|
||
f"""
|
||
SELECT r.id FROM source_rows r
|
||
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
||
JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id
|
||
WHERE rv.sheet_name IN ({placeholders})
|
||
AND rv.import_batch_id = ?
|
||
ORDER BY r.id
|
||
""",
|
||
(*updated, batch_id),
|
||
).fetchall()
|
||
matching_result = matching.reconcile_rows(
|
||
connection,
|
||
[item["id"] for item in confirmed_rows],
|
||
actor=actor,
|
||
)
|
||
ledger_events.reconcile_bank_events(connection, actor=actor)
|
||
if began:
|
||
connection.commit()
|
||
except Exception:
|
||
if began:
|
||
connection.rollback()
|
||
raise
|
||
|
||
if updated:
|
||
auth.audit(
|
||
connection,
|
||
f"sheet_{decision}",
|
||
actor=actor,
|
||
target=f"batch:{batch_id}",
|
||
detail=f"sheets:{','.join(updated)}" + (f";reason:{reason}" if reason else ""),
|
||
)
|
||
payload: dict[str, object] = {"updated": updated, "already": already}
|
||
if matching_result is not None:
|
||
payload["matching"] = matching_result
|
||
return payload
|