B-42: 导入功能加固——逐工作表确认、流式上传与诊断证据

This commit is contained in:
腾讯WorkBuddy
2026-08-17 01:00:19 +08:00
parent 7f1a93f6a6
commit 486842963e
15 changed files with 2019 additions and 183 deletions
+78 -22
View File
@@ -7,7 +7,7 @@ from pathlib import Path
import re
from typing import Any, Iterable
from .models import NormalizedTransaction, StatementBatch
from .models import NormalizedTransaction, SheetResult, StatementBatch
from .reader import RawSheet, read_workbook
from .templates import BankTemplate, TEMPLATES, normalize_header
@@ -24,8 +24,11 @@ class AmbiguousTemplateError(StatementParseError):
pass
SCAN_LIMIT = 50
def detect_header(
rows: tuple[tuple[Any, ...], ...], scan_limit: int = 50
rows: tuple[tuple[Any, ...], ...], scan_limit: int = SCAN_LIMIT
) -> tuple[BankTemplate, int, dict[str, int]]:
candidates: list[tuple[int, BankTemplate, int, dict[str, int]]] = []
for row_index, row in enumerate(rows[:scan_limit]):
@@ -42,8 +45,8 @@ def detect_header(
if not candidates:
inspected = min(len(rows), scan_limit)
candidate_rows = _header_candidate_summary(rows[:scan_limit])
detail = f";候选表头:{candidate_rows}" if candidate_rows else ""
candidate_rows = _header_candidate_rows(rows[:scan_limit])
detail = f";候选表头:{''.join(candidate_rows)}" if candidate_rows else ""
raise UnknownTemplateError(
f"未识别到受支持的银行表头(已扫描前 {inspected} 行){detail}"
)
@@ -60,21 +63,69 @@ def detect_header(
def parse_statement(path: str | Path) -> tuple[StatementBatch, ...]:
source = Path(path)
batches: list[StatementBatch] = []
errors: list[str] = []
for sheet in read_workbook(source):
if not any(any(_text(value) for value in row) for row in sheet.rows):
continue
try:
batches.append(_parse_sheet(source, sheet))
except UnknownTemplateError as exc:
errors.append(f"{sheet.name}: {exc}")
batches = tuple(result.batch for result in analyze_workbook(path) if result.batch)
if not batches:
detail = "; ".join(errors) or "Workbook contains no readable worksheets."
raise UnknownTemplateError(f"{source.name}: {detail}")
return tuple(batches)
raise UnknownTemplateError(f"{Path(path).name}: 工作簿中没有可解析的工作表。")
return batches
def analyze_workbook(path: str | Path) -> tuple[SheetResult, ...]:
"""Parse every worksheet into an independent result.
Each worksheet yields exactly one :class:`SheetResult` whose ``outcome``
is ``parsed``, ``exception`` or ``ignored``. Unreadable workbooks raise
``CorruptWorkbookError``; a workbook that reads but contains no parsable
sheet still returns one result per sheet so the UI can surface the
filename / sheet / scanned range / candidate headers evidence.
"""
source = Path(path)
return tuple(_analyze_sheet(source, sheet) for sheet in read_workbook(source))
def _analyze_sheet(source: Path, sheet: RawSheet) -> SheetResult:
rows = sheet.rows
scanned = min(len(rows), SCAN_LIMIT)
if not rows:
return SheetResult(
sheet_name=sheet.name,
outcome="ignored",
message=f"工作表「{sheet.name}」为空,已跳过。",
scanned_rows=0,
)
if not any(any(_text(value) for value in row) for row in rows):
return SheetResult(
sheet_name=sheet.name,
outcome="ignored",
message=f"工作表「{sheet.name}」无有效内容,已跳过。",
scanned_rows=scanned,
)
try:
batch = _parse_sheet(source, sheet)
except UnknownTemplateError as exc:
return SheetResult(
sheet_name=sheet.name,
outcome="exception",
message=_clean_sheet_message(str(exc), source),
scanned_rows=scanned,
candidate_headers=_header_candidate_rows(rows),
)
except (AmbiguousTemplateError, StatementParseError) as exc:
return SheetResult(
sheet_name=sheet.name,
outcome="exception",
message=_clean_sheet_message(str(exc), source),
scanned_rows=scanned,
)
return SheetResult(
sheet_name=sheet.name,
outcome="parsed",
scanned_rows=scanned,
batch=batch,
)
def _clean_sheet_message(message: str, source: Path) -> str:
return message.replace(str(source), source.name).replace(source.name, "本文件")
def parse_directory(path: str | Path) -> tuple[StatementBatch, ...]:
@@ -90,9 +141,14 @@ def parse_directory(path: str | Path) -> tuple[StatementBatch, ...]:
return tuple(batch for file in files for batch in parse_statement(file))
def _header_candidate_summary(
def _header_candidate_rows(
rows: tuple[tuple[Any, ...], ...], limit: int = 3
) -> str:
) -> tuple[str, ...]:
"""Preview up to ``limit`` plausible header rows.
Every non-empty row counts as a candidate, including single-cell rows, so
an ambiguous workbook always has explicit scan evidence for the UI.
"""
known_headers = {
alias
for template in TEMPLATES
@@ -102,7 +158,7 @@ def _header_candidate_summary(
candidates: list[tuple[int, int, int, tuple[str, ...]]] = []
for row_index, row in enumerate(rows):
values = tuple(_text(value) for value in row if _text(value))
if len(values) < 2:
if not values:
continue
matched = sum(normalize_header(value) in known_headers for value in values)
candidates.append((matched, len(values), row_index, values))
@@ -114,7 +170,7 @@ def _header_candidate_summary(
if len(values) > 8:
preview += "……"
summaries.append(f"{row_index + 1} 行「{preview}")
return "".join(summaries)
return tuple(summaries)
def _parse_sheet(source: Path, sheet: RawSheet) -> StatementBatch: