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
+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 && (