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
+3
View File
@@ -198,4 +198,7 @@ export const api = {
importCommit: (body) => request("/imports/commit", { method: "POST", body }),
importBatches: () => request("/imports/batches"),
undoImportBatch: (id) => request(`/imports/batches/${id}`, { method: "DELETE" }),
// PDF/Bild-Beleg
documentPreview: (formData) => request("/documents/preview", { method: "POST", formData }),
};
+11
View File
@@ -560,3 +560,14 @@ tr.row-invalid {
tr.row-dup {
opacity: 0.55;
}
.doc-text {
max-height: 240px;
overflow: auto;
font-size: 0.78rem;
white-space: pre-wrap;
color: var(--muted);
background: var(--surface-2);
padding: var(--sp-3);
border-radius: var(--radius-sm);
margin-top: var(--sp-2);
}
+7 -2
View File
@@ -197,13 +197,18 @@
"targetAccount": "Zielkonto"
},
"import": {
"title": "CSV-Import",
"title": "Import",
"modeCsv": "CSV",
"modeDoc": "PDF · Bild",
"readDoc": "Beleg auslesen",
"docHint": "PDF-/Bild-Erkennung ist Best-Effort — bitte jede Zeile prüfen und ggf. korrigieren.",
"extractedText": "Extrahierter Text (zur Kontrolle)",
"step1": "1 · Konto & Datei",
"step2": "2 · Format",
"step3": "3 · Spalten zuordnen",
"step4": "4 · Prüfen & buchen",
"account": "Zielkonto",
"file": "CSV-Datei",
"file": "Datei",
"preset": "Bank-Vorlage (vorgefertigt)",
"template": "Eigene Vorlage",
"templateManual": "Manuell einstellen",
+7 -2
View File
@@ -197,13 +197,18 @@
"targetAccount": "Target account"
},
"import": {
"title": "CSV import",
"title": "Import",
"modeCsv": "CSV",
"modeDoc": "PDF · Image",
"readDoc": "Read document",
"docHint": "PDF/image recognition is best-effort — please check and fix every row.",
"extractedText": "Extracted text (for reference)",
"step1": "1 · Account & file",
"step2": "2 · Format",
"step3": "3 · Map columns",
"step4": "4 · Review & import",
"account": "Target account",
"file": "CSV file",
"file": "File",
"preset": "Bank preset",
"template": "My template",
"templateManual": "Configure manually",
+49 -5
View File
@@ -41,11 +41,13 @@ export default function Import() {
const [presets, setPresets] = useState([]);
const [batches, setBatches] = useState([]);
const [mode, setMode] = useState("csv"); // "csv" | "doc"
const [accountId, setAccountId] = useState("");
const [file, setFile] = useState(null);
const [config, setConfig] = useState(defaultConfig());
const [raw, setRaw] = useState(null);
const [rows, setRows] = useState(null);
const [docText, setDocText] = useState("");
const [busy, setBusy] = useState(false);
const [saveName, setSaveName] = useState("");
@@ -117,6 +119,19 @@ export default function Import() {
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
}
async function loadDoc() {
if (!file || !accountId) { toast(t("import.needFileAccount"), "error"); return; }
setBusy(true);
try {
const fd = new FormData();
fd.append("file", file);
fd.append("account_id", accountId);
const res = await api.documentPreview(fd);
setRows(res.rows.map((r) => ({ ...r })));
setDocText(res.text || "");
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
}
async function commit() {
setBusy(true);
try {
@@ -168,7 +183,15 @@ export default function Import() {
return (
<div>
<h1>{t("import.title")}</h1>
<div className="page-head">
<h1>{t("import.title")}</h1>
<div className="seg" style={{ width: 240, marginBottom: 0 }}>
<button type="button" className={`seg-btn${mode === "csv" ? " active" : ""}`}
onClick={() => { setMode("csv"); setRows(null); setRaw(null); setDocText(""); }}>{t("import.modeCsv")}</button>
<button type="button" className={`seg-btn${mode === "doc" ? " active" : ""}`}
onClick={() => { setMode("doc"); setRows(null); setRaw(null); setDocText(""); }}>{t("import.modeDoc")}</button>
</div>
</div>
{/* Schritt 1: Konto + Datei */}
<div className="card">
@@ -182,11 +205,20 @@ export default function Import() {
</label>
<label className="grow">
<span>{t("import.file")}</span>
<input ref={fileRef} type="file" accept=".csv,text/csv,text/plain"
onChange={(e) => { setFile(e.target.files?.[0] || null); setRaw(null); setRows(null); }} />
<input ref={fileRef} type="file"
accept={mode === "doc" ? ".pdf,application/pdf,image/*" : ".csv,text/csv,text/plain"}
onChange={(e) => { setFile(e.target.files?.[0] || null); setRaw(null); setRows(null); setDocText(""); }} />
</label>
</div>
<div className="row">
{mode === "doc" && (
<>
<button className="btn primary" onClick={loadDoc} disabled={busy || !file}>
<Icon name="search" size={15} /> {t("import.readDoc")}
</button>
<p className="muted small" style={{ marginTop: "var(--sp-2)" }}>{t("import.docHint")}</p>
</>
)}
<div className="row" style={{ display: mode === "csv" ? undefined : "none" }}>
{presets.length > 0 && (
<label className="grow">
<span>{t("import.preset")}</span>
@@ -208,7 +240,8 @@ export default function Import() {
</div>
</div>
{/* Schritt 2: Format */}
{/* Schritt 2: Format (nur CSV) */}
{mode === "csv" && (
<div className="card">
<h2>{t("import.step2")}</h2>
<div className="row">
@@ -266,6 +299,17 @@ export default function Import() {
<Icon name="search" size={15} /> {t("import.readFile")}
</button>
</div>
)}
{/* Extrahierter Text (PDF/Bild) */}
{mode === "doc" && docText && (
<div className="card">
<details>
<summary className="muted">{t("import.extractedText")}</summary>
<pre className="doc-text">{docText}</pre>
</details>
</div>
)}
{/* Schritt 3: Spaltenzuordnung */}
{raw && (