Files
caiwuzongzhang/src/bank_importer/parser.py
T

457 lines
16 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.
from __future__ import annotations
from datetime import date, datetime, time
from decimal import Decimal, InvalidOperation
from itertools import groupby
from pathlib import Path
import re
from typing import Any, Iterable
from .models import NormalizedTransaction, SheetResult, StatementBatch
from .reader import RawSheet, read_workbook
from .templates import BankTemplate, TEMPLATES, normalize_header
class StatementParseError(RuntimeError):
pass
class UnknownTemplateError(StatementParseError):
pass
class AmbiguousTemplateError(StatementParseError):
pass
SCAN_LIMIT = 50
def detect_header(
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]):
normalized = [normalize_header(value) for value in row]
for template in TEMPLATES:
column_map: dict[str, int] = {}
for field, aliases in template.columns.items():
for column_index, header in enumerate(normalized):
if header and header in aliases:
column_map[field] = column_index
break
if all(field in column_map for field in template.required):
candidates.append((len(column_map), template, row_index, column_map))
if not candidates:
inspected = min(len(rows), scan_limit)
candidate_rows = _header_candidate_rows(rows[:scan_limit])
detail = f";候选表头:{''.join(candidate_rows)}" if candidate_rows else ""
raise UnknownTemplateError(
f"未识别到受支持的银行表头(已扫描前 {inspected} 行){detail}。"
)
candidates.sort(key=lambda item: item[0], reverse=True)
best_score = candidates[0][0]
best = [candidate for candidate in candidates if candidate[0] == best_score]
identities = {(item[1].template_id, item[2]) for item in best}
if len(identities) > 1:
matches = ", ".join(f"{item[1].bank_name}@{item[2] + 1}" for item in best)
raise AmbiguousTemplateError(f"Ambiguous bank header signatures: {matches}")
_, template, row_index, column_map = best[0]
return template, row_index, column_map
def parse_statement(path: str | Path) -> tuple[StatementBatch, ...]:
batches = tuple(result.batch for result in analyze_workbook(path) if result.batch)
if not 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, ...]:
root = Path(path)
files = sorted(
file
for file in root.rglob("*")
if file.is_file()
and file.suffix.lower() in {".xls", ".xlsx"}
and not file.name.startswith("~$")
and file.stat().st_size > 0
)
return tuple(batch for file in files for batch in parse_statement(file))
def _header_candidate_rows(
rows: tuple[tuple[Any, ...], ...], limit: int = 3
) -> 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
for aliases in template.columns.values()
for alias in aliases
}
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 not values:
continue
matched = sum(normalize_header(value) in known_headers for value in values)
candidates.append((matched, len(values), row_index, values))
candidates.sort(key=lambda item: (item[0], item[1]), reverse=True)
summaries: list[str] = []
for _, _, row_index, values in candidates[:limit]:
preview = "、".join(value[:24] for value in values[:8])
if len(values) > 8:
preview += "……"
summaries.append(f"第 {row_index + 1} 行「{preview}」")
return tuple(summaries)
def _parse_sheet(source: Path, sheet: RawSheet) -> StatementBatch:
template, header_index, columns = detect_header(sheet.rows)
metadata = _extract_metadata(sheet.rows[:header_index])
transactions: list[NormalizedTransaction] = []
warnings: list[str] = []
for row_index, row in enumerate(sheet.rows[header_index + 1 :], header_index + 2):
transaction_at = _transaction_datetime(row, columns)
if transaction_at is None:
continue
income = _decimal(_value(row, columns.get("income")))
expense = _decimal(_value(row, columns.get("expense")))
if income == 0 and expense == 0:
continue
if income > 0 and expense > 0:
warnings.append(f"Row {row_index} has both income and expense.")
transactions.append(
NormalizedTransaction(
source_file=source,
sheet_name=sheet.name,
source_row=row_index,
transaction_at=transaction_at,
income=income,
expense=expense,
balance=_optional_decimal(_value(row, columns.get("balance"))),
own_account=_optional_text(_value(row, columns.get("own_account"))),
own_name=_optional_text(_value(row, columns.get("own_name"))),
counterparty_account=_optional_text(
_value(row, columns.get("counterparty_account"))
),
counterparty_name=_optional_text(
_value(row, columns.get("counterparty_name"))
),
counterparty_bank=_optional_text(
_value(row, columns.get("counterparty_bank"))
),
summary=_optional_text(_value(row, columns.get("summary"))),
purpose=_optional_text(_value(row, columns.get("purpose"))),
reference=_optional_text(_value(row, columns.get("reference"))),
currency=_optional_text(_value(row, columns.get("currency"))),
)
)
if not transactions:
raise StatementParseError(
f"{source.name}/{sheet.name}: header found but no transaction rows parsed."
)
own_account = metadata.get("own_account") or _first_value(
transaction.own_account for transaction in transactions
)
own_name = metadata.get("own_name") or _first_value(
transaction.own_name for transaction in transactions
)
warnings.extend(_balance_warnings(transactions))
return StatementBatch(
source_file=source,
sheet_name=sheet.name,
bank_name=template.bank_name,
template_id=template.template_id,
header_row=header_index + 1,
own_account=own_account if isinstance(own_account, str) else None,
own_name=own_name if isinstance(own_name, str) else None,
period_start=metadata.get("period_start")
if isinstance(metadata.get("period_start"), date)
else None,
period_end=metadata.get("period_end")
if isinstance(metadata.get("period_end"), date)
else None,
transactions=tuple(transactions),
warnings=tuple(warnings),
template_version=template.version,
)
def _extract_metadata(rows: tuple[tuple[Any, ...], ...]) -> dict[str, str | date]:
result: dict[str, str | date] = {}
labels = {
"账号": "own_account",
"账/卡号": "own_account",
"户名": "own_name",
"账户名称": "own_name",
"起始日期": "period_start",
"开始日期": "period_start",
"截止日期": "period_end",
"结束日期": "period_end",
}
for row in rows:
for index, value in enumerate(row):
text = _text(value)
if not text:
continue
key_text, inline_value = _split_label(text)
key = labels.get(key_text)
if key:
candidate: Any = inline_value or _next_nonempty(row, index + 1)
if key.startswith("period_"):
parsed = _date_value(candidate)
if parsed:
result[key] = parsed
elif candidate is not None:
result[key] = _text(candidate)
if "起止日期" in text:
dates = _dates_in_text(text)
if len(dates) >= 2:
result["period_start"], result["period_end"] = dates[:2]
return result
def _transaction_datetime(row: tuple[Any, ...], columns: dict[str, int]) -> datetime | None:
if "transaction_at" in columns:
return _datetime_value(_value(row, columns["transaction_at"]))
transaction_date = _date_value(_value(row, columns.get("transaction_date")))
if transaction_date is None:
return None
transaction_time = _time_value(_value(row, columns.get("transaction_time")))
return datetime.combine(transaction_date, transaction_time or time.min)
def _datetime_value(value: Any) -> datetime | None:
if isinstance(value, datetime):
return value
if isinstance(value, date):
return datetime.combine(value, time.min)
text = _text(value)
if not text:
return None
normalized = text.replace("年", "-").replace("月", "-").replace("日", " ")
normalized = " ".join(normalized.split())
for pattern in (
"%Y-%m-%d %H:%M:%S",
"%Y/%m/%d %H:%M:%S",
"%Y%m%d %H:%M:%S",
"%Y-%m-%d",
"%Y/%m/%d",
"%Y%m%d",
):
try:
return datetime.strptime(normalized, pattern)
except ValueError:
continue
return None
def _date_value(value: Any) -> date | None:
parsed = _datetime_value(value)
return parsed.date() if parsed else None
def _time_value(value: Any) -> time | None:
if isinstance(value, datetime):
return value.time()
if isinstance(value, time):
return value
text = _text(value)
if not text:
return None
for pattern in ("%H:%M:%S", "%H:%M"):
try:
return datetime.strptime(text, pattern).time()
except ValueError:
continue
return None
def _dates_in_text(value: str) -> list[date]:
date_strings = re.findall(
r"20\d{2}(?:年|-|/)?\d{1,2}(?:月|-|/)?\d{1,2}日?", value
)
return [parsed for item in date_strings if (parsed := _date_value(item))]
def _decimal(value: Any) -> Decimal:
if value is None:
return Decimal("0")
if isinstance(value, Decimal):
return value
if isinstance(value, (int, float)):
return Decimal(str(value))
text = _text(value)
if not text:
return Decimal("0")
text = text.replace(",", "").replace("¥", "").replace("¥", "")
text = text.removesuffix("元")
negative = text.startswith("(") and text.endswith(")")
if negative:
text = text[1:-1]
try:
amount = Decimal(text)
except InvalidOperation as exc:
raise StatementParseError(f"Invalid amount: {value!r}") from exc
return -amount if negative else amount
def _optional_decimal(value: Any) -> Decimal | None:
if value is None or _text(value) == "":
return None
return _decimal(value)
def _balance_warnings(
transactions: list[NormalizedTransaction],
) -> list[str]:
warnings: list[str] = []
if len(transactions) < 2:
return warnings
descending_source = transactions[0].transaction_at > transactions[-1].transaction_at
ordered = sorted(transactions, key=lambda item: item.transaction_at)
groups = [
list(items)
for _, items in groupby(ordered, key=lambda item: item.transaction_at)
]
def source_endpoint(group: list[NormalizedTransaction]) -> NormalizedTransaction:
key = lambda item: item.source_row
return min(group, key=key) if descending_source else max(group, key=key)
previous_balance = source_endpoint(groups[0]).balance
for group in groups[1:]:
if previous_balance is None:
previous_balance = source_endpoint(group).balance
continue
expected = previous_balance + sum(
(item.income - item.expense for item in group), Decimal("0")
)
matching_balance = next(
(
item.balance
for item in group
if item.balance is not None
and abs(expected - item.balance) <= Decimal("0.01")
),
None,
)
if matching_balance is not None:
previous_balance = matching_balance
continue
rows = ",".join(str(item.source_row) for item in group)
warnings.append(f"Rows {rows} do not reconcile by balance.")
previous_balance = source_endpoint(group).balance
return warnings
def _value(row: tuple[Any, ...], column: int | None) -> Any:
if column is None or column >= len(row):
return None
return row[column]
def _text(value: Any) -> str:
return "" if value is None else str(value).strip()
def _optional_text(value: Any) -> str | None:
text = _text(value)
return text or None
def _split_label(value: str) -> tuple[str, str]:
parts = re.split(r"[:]", value, maxsplit=1)
key = parts[0].strip()
return key, parts[1].strip() if len(parts) == 2 else ""
def _next_nonempty(row: tuple[Any, ...], start: int) -> Any:
for value in row[start:]:
if _text(value):
return value
return None
def _first_value(values: Iterable[str | None]) -> str | None:
return next((value for value in values if value), None)