Files
caiwuzongzhang/src/bank_importer/importing.py
T
腾讯WorkBuddy 85293b79df 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)
2026-08-19 11:58:25 +08:00

672 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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