Files
bin/finance/app/services/importer.py
wlfb df6615c8f7 fix: Fehler-Feedback Kreditzuordnung, robustes PDF-Fehlerhandling
Kreditzuordnung im Szenario prueft jetzt response.ok, zeigt bei Fehlschlag
eine deutsche Meldung und macht die Checkbox-Aenderung rueckgaengig statt
stillschweigend neu zu laden. process_pdf faengt zusaetzlich zu ParserError
auch unerwartete Parser-Exceptions (z.B. pdfminer bei strukturell kaputten
PDFs) ab und legt dafuer ein sauberes error-Statement an statt eines
unbehandelten 500; die Datei bleibt dabei im Posteingang.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:14:43 +02:00

119 lines
3.8 KiB
Python

from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.tables import Account, Statement, Transaction
from app.parsers.base import ParserError
from app.parsers.registry import parse_pdf
from app.parsers.validate import balance_difference, dedup_hash
from app.services.categorize import apply_rules
def _find_or_create_account(session: Session, bank: str, iban: str | None, filename: str) -> Account:
if not iban:
iban = f"UNBEKANNT-{filename}"
acc = session.execute(select(Account).where(Account.iban == iban)).scalar()
if acc is not None:
return acc
acc = Account(bank=bank, iban=iban, name=iban, type="giro")
session.add(acc)
session.flush()
return acc
def _error_statement(session: Session, filename: str, message: str) -> Statement:
stmt = Statement(filename=filename, bank="unbekannt", account_id=None,
status="error", error_message=message)
session.add(stmt)
session.commit()
session.refresh(stmt)
return stmt
def process_pdf(session: Session, path: Path) -> Statement:
settings = get_settings()
filename = path.name
try:
parsed = parse_pdf(path)
except ParserError as exc:
return _error_statement(session, filename, str(exc))
except Exception as exc:
# Fremdbibliotheken (pdfplumber/pdfminer) werfen bei strukturell
# kaputten PDFs eigene Exception-Typen statt ParserError (z.B.
# PdfminerException "No /Root object!"). Ohne diesen Fang wuerde ein
# kaputtes Upload zu einem unbehandelten 500 fuehren statt zu einem
# sauberen Fehler-Import wie bei "Bank nicht erkannt". Die Datei
# bleibt dabei unangetastet im Posteingang.
return _error_statement(session, filename, f"PDF nicht lesbar: {exc}")
account = _find_or_create_account(session, parsed.bank, parsed.iban, filename)
stmt = Statement(
filename=filename,
bank=parsed.bank,
account_id=account.id,
period_start=parsed.period_start,
period_end=parsed.period_end,
opening_balance=parsed.opening_balance,
closing_balance=parsed.closing_balance,
status="draft",
)
session.add(stmt)
session.flush()
diff = balance_difference(parsed)
if diff != 0:
stmt.status = "error"
stmt.error_message = f"Saldo-Differenz {diff} EUR"
session.commit()
session.refresh(stmt)
return stmt
drafts = []
for t in parsed.transactions:
h = dedup_hash(account.id, t.booking_date, t.amount, t.purpose)
is_duplicate = session.execute(
select(Transaction).where(
Transaction.dedup_hash == h,
Transaction.status == "confirmed",
)
).scalar() is not None
tx = Transaction(
account_id=account.id,
statement_id=stmt.id,
booking_date=t.booking_date,
value_date=t.value_date,
amount=t.amount,
purpose=t.purpose,
counterparty=t.counterparty,
status="draft",
dedup_hash=h,
is_duplicate=is_duplicate,
)
session.add(tx)
drafts.append(tx)
session.flush()
apply_rules(session, drafts)
stmt.status = "draft"
# Invariant: a committed draft implies the PDF left the inbox. Move the
# file before committing so a failed move can never leave a committed
# draft with the PDF still sitting in the inbox.
uploads_dir = settings.uploads_dir
uploads_dir.mkdir(parents=True, exist_ok=True)
try:
path.replace(uploads_dir / filename)
except OSError:
session.rollback()
raise
session.commit()
session.refresh(stmt)
return stmt