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:
@@ -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