feat: Phase 5 – PDF/Bild-Beleg-Import (lokal, Tesseract-OCR)

Backend:
- services/ocr: PDF-Text via PyMuPDF, OCR (Tesseract) für Scans/Bilder,
  heuristischer Parser (Datum + Betrag je Zeile, CH/DE/US-Beträge).
- routers/documents: /documents/preview (Upload -> Text -> Kandidaten ->
  gemeinsame Prüf-/Buchungslogik des CSV-Imports). csv_import refaktoriert:
  detect_rows() von CSV und Beleg gemeinsam genutzt.
- requirements: PyMuPDF, pytesseract, pillow; install.sh installiert tesseract-ocr
  (+ deu). pytest: Heuristik-Parser und PDF-Vorschau.

Frontend:
- Import-Seite: CSV/PDF-Umschalter; PDF-Modus mit Beleg-Auslesen, extrahiertem
  Text zur Kontrolle und Best-Effort-Hinweis; gleiche Prüfansicht + Buchen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 20:25:32 +02:00
co-authored by Claude Opus 4.8
parent ddf4f1f5ed
commit 7461fddb7a
13 changed files with 353 additions and 41 deletions
+55
View File
@@ -0,0 +1,55 @@
import io
import pytest
fitz = pytest.importorskip("fitz")
def _account(admin):
r = admin.post("/api/accounts", json={"name": "Beleg", "type": "girokonto", "currency": "EUR"})
assert r.status_code == 201
return r.json()
def _make_pdf() -> bytes:
doc = fitz.open()
page = doc.new_page()
page.insert_text((50, 72), "15.03.2026 REWE Supermarkt Zuerich -50.00")
page.insert_text((50, 96), "25.03.2026 Gehalt Firma AG 3000.00")
page.insert_text((50, 120), "Kein Datum hier, nur Text 12.34")
return doc.tobytes()
def test_parse_transactions_heuristic():
from app.services import ocr
text = (
"Kontoauszug März 2026\n"
"15.03.2026 REWE Supermarkt -50,00\n"
"2026-03-25 Gehalt 3'000.00\n"
"Zwischensumme\n"
)
rows = ocr.parse_transactions(text)
assert len(rows) == 2
assert rows[0]["date"] == "2026-03-15"
assert rows[0]["amount_cents"] == -5000
assert "REWE" in rows[0]["payee"]
assert rows[1]["date"] == "2026-03-25"
assert rows[1]["amount_cents"] == 300000 # Schweizer Tausender-Apostroph
def test_pdf_document_preview(admin):
acc = _account(admin)
pdf = _make_pdf()
r = admin.post(
"/api/documents/preview",
files={"file": ("beleg.pdf", io.BytesIO(pdf), "application/pdf")},
data={"account_id": str(acc["id"])},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["count"] == 2
rows = body["rows"]
kinds = {(row["type"], row["amount_cents"]) for row in rows}
assert ("expense", -5000) in kinds
assert ("income", 300000) in kinds