feat: CSV-Import ueber bestehende Pipeline mit dreiwertiger Saldo-Pruefung

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:45:53 +02:00
parent e2d713e960
commit ea4858c4de
7 changed files with 354 additions and 50 deletions

View File

@@ -11,7 +11,7 @@ from app.auth import require_auth
from app.config import get_settings
from app.db import get_session
from app.models.tables import Statement, Transaction
from app.services.importer import process_pdf
from app.services.importer import process_file
router = APIRouter(prefix="/api/imports", tags=["imports"],
dependencies=[Depends(require_auth)])
@@ -50,22 +50,22 @@ class TransactionOut(BaseModel):
class PreviewOut(BaseModel):
statement: StatementOut
transactions: list[TransactionOut]
balance_ok: bool
balance_ok: bool | None
duplicates: int
@router.post("/upload", response_model=StatementOut, status_code=201)
def upload(file: UploadFile, session: Session = Depends(get_session)):
safe_name = Path(file.filename or "upload.pdf").name
if not safe_name or not safe_name.lower().endswith(".pdf"):
raise HTTPException(400, "Nur PDF-Dateien")
if not safe_name or not safe_name.lower().endswith((".pdf", ".csv")):
raise HTTPException(400, "Nur PDF- oder CSV-Dateien")
settings = get_settings()
inbox_dir = settings.inbox_dir
inbox_dir.mkdir(parents=True, exist_ok=True)
dest = inbox_dir / safe_name
with dest.open("wb") as f:
f.write(file.file.read())
stmt = process_pdf(session, dest)
stmt = process_file(session, dest)
return StatementOut.model_validate(stmt)
@@ -74,9 +74,10 @@ def scan_inbox(session: Session = Depends(get_session)):
settings = get_settings()
inbox_dir = settings.inbox_dir
inbox_dir.mkdir(parents=True, exist_ok=True)
paths = sorted(inbox_dir.glob("*.pdf")) + sorted(inbox_dir.glob("*.csv"))
results = []
for path in sorted(inbox_dir.glob("*.pdf")):
stmt = process_pdf(session, path)
for path in paths:
stmt = process_file(session, path)
results.append(StatementOut.model_validate(stmt))
return results
@@ -96,11 +97,17 @@ def preview(statement_id: int, session: Session = Depends(get_session)):
select(Transaction).where(Transaction.statement_id == statement_id)
.order_by(Transaction.booking_date, Transaction.id)
).scalars().all()
balance_ok = (
stmt.opening_balance is not None and stmt.closing_balance is not None
and (stmt.opening_balance + sum((t.amount for t in txs), Decimal("0"))
- stmt.closing_balance) == 0
)
# Dreiwertig (Ausbaustufe 3 Task 3): manche CSV-Formate (z.B. HVB, DKB)
# liefern keine Saldodaten - None statt False, damit die Vorschau nicht
# faelschlich einen Saldo-Fehler anzeigt, wo schlicht keine Pruefung
# moeglich ist.
if stmt.opening_balance is None or stmt.closing_balance is None:
balance_ok = None
else:
balance_ok = (
stmt.opening_balance + sum((t.amount for t in txs), Decimal("0"))
- stmt.closing_balance
) == 0
duplicates = sum(1 for t in txs if t.is_duplicate)
return PreviewOut(
statement=StatementOut.model_validate(stmt),

View File

@@ -1,3 +1,5 @@
from datetime import date
from decimal import Decimal
from pathlib import Path
from sqlalchemy import select
@@ -5,7 +7,8 @@ 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.base import ParsedStatement, ParserError
from app.parsers.csv_formats import parse_csv
from app.parsers.registry import parse_pdf
from app.parsers.validate import balance_difference, dedup_hash
from app.services.categorize import apply_rules
@@ -14,6 +17,17 @@ 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}"
if bank == "hvb_csv":
# HVB-CSV traegt nur die Kontonummer (keine IBAN). Konto-Zuordnung
# ueber "endet auf Kontonummer" gegen alle bestehenden IBANs -
# dokumentierte Einschraenkung (siehe Formatreferenz Task 2/3).
for existing in session.execute(select(Account)).scalars():
if existing.iban.endswith(iban):
return existing
acc = Account(bank=bank, iban=iban, name=iban, type="giro")
session.add(acc)
session.flush()
return acc
acc = session.execute(select(Account).where(Account.iban == iban)).scalar()
if acc is not None:
return acc
@@ -23,6 +37,20 @@ def _find_or_create_account(session: Session, bank: str, iban: str | None, filen
return acc
def _apply_anchor_autofill(account: Account, anchor: tuple[date, Decimal] | None) -> None:
"""CSV-Anker-Regel (Ausbaustufe 3 Task 3): der Datei-Kontostand wird nur
uebernommen, wenn er neuer ist als ein evtl. vorhandener Anker (oder noch
keiner existiert). Ein neuerer, manuell per PATCH gesetzter Anker bleibt
unangetastet - so entkommt kein aelterer CSV-Export einem bewusst
gesetzten aktuellen Stand."""
if anchor is None:
return
anchor_date, anchor_balance = anchor
if account.anchor_date is None or anchor_date >= account.anchor_date:
account.anchor_date = anchor_date
account.anchor_balance = anchor_balance
def _error_statement(session: Session, filename: str, message: str) -> Statement:
stmt = Statement(filename=filename, bank="unbekannt", account_id=None,
status="error", error_message=message)
@@ -32,23 +60,18 @@ def _error_statement(session: Session, filename: str, message: str) -> Statement
return stmt
def process_pdf(session: Session, path: Path) -> Statement:
def _finish_statement(session: Session, path: Path, parsed: ParsedStatement,
check_balance: bool,
anchor: tuple[date, Decimal] | None) -> Statement:
"""Gemeinsamer Abschluss fuer PDF- und CSV-Importe, sobald ein
ParsedStatement vorliegt: Konto zuordnen, Statement+Transaktionen als
Entwurf anlegen, optional den Saldo pruefen, Datei aus dem Posteingang
verschieben. `check_balance=False` (CSV-Formate ohne Saldodaten, z.B.
HVB/DKB) laesst den Entwurf OHNE Fehler stehen statt einer
Saldo-Differenz-Pruefung, die auf None-Feldern crashen wuerde."""
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(
@@ -64,13 +87,14 @@ def process_pdf(session: Session, path: Path) -> Statement:
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
if check_balance:
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:
@@ -100,10 +124,11 @@ def process_pdf(session: Session, path: Path) -> Statement:
apply_rules(session, drafts)
stmt.status = "draft"
_apply_anchor_autofill(account, anchor)
# 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.
# Invariant: a committed draft implies the source file left the inbox.
# Move the file before committing so a failed move can never leave a
# committed draft with the file still sitting in the inbox.
uploads_dir = settings.uploads_dir
uploads_dir.mkdir(parents=True, exist_ok=True)
try:
@@ -116,3 +141,46 @@ def process_pdf(session: Session, path: Path) -> Statement:
session.refresh(stmt)
return stmt
def process_pdf(session: Session, path: Path) -> Statement:
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}")
return _finish_statement(session, path, parsed, check_balance=True, anchor=None)
def process_csv(session: Session, path: Path) -> Statement:
filename = path.name
try:
parsed_csv = parse_csv(path)
except ParserError as exc:
return _error_statement(session, filename, str(exc))
except Exception as exc:
# Analog zu process_pdf: eine strukturell kaputte/unlesbare CSV darf
# nicht als unbehandelter 500 durchschlagen.
return _error_statement(session, filename, f"CSV nicht lesbar: {exc}")
return _finish_statement(session, path, parsed_csv.statement,
check_balance=parsed_csv.balance_checkable,
anchor=parsed_csv.anchor)
def process_file(session: Session, path: Path) -> Statement:
"""Dateiendungs-Weiche fuer die Import-Pipeline (Ausbaustufe 3 Task 3):
.csv laeuft ueber den CSV-Formatparser, alles andere weiterhin ueber den
PDF-Parser."""
if path.suffix.lower() == ".csv":
return process_csv(session, path)
return process_pdf(session, path)

View File

@@ -18,8 +18,10 @@
</table>
<p>
Saldo-Status:
{% if balance_ok %}
<span class="badge badge-ok">Saldo stimmt</span>
{% if balance_ok is none %}
<span class="badge">Saldo-Prüfung: nicht verfügbar (Format ohne Saldodaten)</span>
{% elif balance_ok %}
<span class="badge badge-ok">Saldo plausibel</span>
{% else %}
<span class="badge badge-error">Saldo-Differenz bitte prüfen</span>
{% endif %}

View File

@@ -16,15 +16,24 @@
Über die Übersicht umbenennbar (z. B. "DKB Giro").
</li>
<li>
<strong>Kontoauszug-Import:</strong> PDF in die Drop-Zone auf der
Import-Seite ziehen (oder in die Inbox
<strong>Kontoauszug-Import:</strong> primärer Weg ist der
Kontoumsatz-CSV-Export der Bank (DKB, VR-Bank, HypoVereinsbank) in die
Drop-Zone auf der Import-Seite ziehen (oder in die Inbox
<code>~/.local/share/finance_pod/data/inbox/</code> legen und "Inbox
scannen"). Jeder Import wird geprüft (Anfangssaldo + Buchungen = Endsaldo)
und landet als Entwurf: erst nach Kontrolle der Vorschau "Übernehmen"
klicken. Duplikate werden erkannt und nicht doppelt übernommen. Bestätigte
Importe lassen sich als Ganzes zurückrollen (Auszug + alle seine
Buchungen), z. B. um einen Auszug mit einem verbesserten Parser neu zu
importieren; einzelne Buchungen bleiben unlöschbar.
scannen"). PDF-Kontoauszüge funktionieren weiterhin als Fallback, falls
kein CSV-Export vorliegt. Jeder Import landet als Entwurf: erst nach
Kontrolle der Vorschau "Übernehmen" klicken. Der Saldo-Status in der
Vorschau ist dreiwertig "Saldo plausibel" (Anfangssaldo + Buchungen =
Endsaldo bzw. lückenlose Saldo-Kette), eine rote Saldo-Differenz, oder
"Saldo-Prüfung nicht verfügbar", wenn das Format (z. B. HVB-CSV) keine
Saldodaten liefert dort lohnt ein Blick in die Zeilenzahl und
Stichproben vor dem Übernehmen. CSV-Importe mit Datei-Kontostand (DKB)
oder lückenloser Saldo-Kette (VR) aktualisieren beim Import automatisch
den Konto-Saldo-Anker, sofern er dadurch nicht älter würde als ein bereits
vorhandener. Duplikate werden erkannt und nicht doppelt übernommen.
Bestätigte Importe lassen sich als Ganzes zurückrollen (Auszug + alle
seine Buchungen), z. B. um einen Auszug mit einem verbesserten Parser neu
zu importieren; einzelne Buchungen bleiben unlöschbar.
</li>
<li>
<strong>Kategorien &amp; Regeln:</strong> Buchungen lassen sich kategorisieren;

View File

@@ -4,14 +4,14 @@
<h1>Import</h1>
<div id="dropzone">
Kontoauszug-PDF hier ablegen oder klicken zum Hochladen
<input type="file" id="file-input" accept="application/pdf" style="display:none">
Kontoumsatz-CSV oder Kontoauszug-PDF hier ablegen oder klicken zum Hochladen
<input type="file" id="file-input" accept=".csv,.pdf,application/pdf,text/csv" style="display:none">
</div>
<button type="button" hx-post="/api/imports/scan-inbox" hx-swap="none"
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
Inbox scannen
</button>
<p class="muted">Durchsucht <code>~/.local/share/finance_pod/data/inbox/</code> nach neuen PDF-Kontoauszügen.</p>
<p class="muted">Durchsucht <code>~/.local/share/finance_pod/data/inbox/</code> nach neuen CSV- oder PDF-Kontoauszügen.</p>
<p id="upload-message"></p>
<h2>Importe</h2>