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:
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user