B-42: 导入功能加固——逐工作表确认、流式上传与诊断证据
This commit is contained in:
+78
-22
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user