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)