"""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