diff --git a/docker-compose.yml b/docker-compose.yml index 3aab49b..2c09d89 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,19 @@ services: networks: - npm_default + # OCR-Dienst für die Beleg-Analyse (Tesseract Deutsch); nur intern erreichbar + mp-ocr: + build: ./server/ocr + container_name: mp-ocr + restart: unless-stopped + healthcheck: + test: ['CMD-SHELL', "python -c \"import urllib.request;urllib.request.urlopen('http://localhost:8081/healthz')\""] + interval: 30s + timeout: 5s + retries: 5 + networks: + - npm_default + networks: npm_default: external: true diff --git a/kimi.md b/kimi.md index 3a1e4cb..51de1d5 100644 --- a/kimi.md +++ b/kimi.md @@ -189,6 +189,31 @@ tar -czf mp-dist.tar.gz -C dist . ## 8. Verlauf / offene Punkte +**Erledigt 04.10. (v1.17 + v1.18):** Auto-Zuordnung der Belege + eigener +OCR-Container. ZUORDNUNGSREGEL (Vorgabe des Nutzers): PRIMÄR Stromzählernummer, +SEKUNDÄR Adresse. belege-Tabelle um unit_id/gebaude/zuordnung/rohtext erweitert +(ALTER ... ADD COLUMN IF NOT EXISTS). `belegAutoZuordnung(text)`: Stromzähler +aus meters (längste Nummer zuerst) als Substring im Volltext, dann Hausname/ +Adresse aus buildings; WICHTIG: Umlaut-Normalisierung (ß↔ss, Umlaute↔Ae/Oe/Ue) +auf beiden Seiten – sonst findet „Hauptstrasse" nicht „Hauptstraße". +Webhook prüft zuerst den Event-Volltext (eventVolltext = rekursiv alle +String-/Zahlenwerte); ohne Treffer + datei_url → belegAnalysieren() asynchron +nach dem Response (fire-and-forget). `zuordnung='manuell'` wird bei Updates/ +OCR NIEMALS überschrieben (CASE-Expressions im ON CONFLICT und im UPDATE). +E2E verifiziert: Zählernummer→rw-4, Adresse→Hauptstraße 12, Testdaten weg. +OCR-CONTAINER mp-ocr (v1.18): server/ocr/ (FastAPI + Tesseract deu + PyMuPDF; +PDF→200dpi-PNG→pytesseract; Bilder direkt; /ocr {url} und /ocr/upload; +data:-URLs werden lokal dekodiert (httpx kann keine data:-URLs); 20 MB Limit). +Compose-Service ohne öffentlichen Port, nur im npm_default-Netz; deploy.sh +baut ihn serverseitig mit (`compose up -d --build`). mp-api: OCR_URL env +(default http://mp-ocr:8081), belegAnalysieren(id) (OCR-Text → Zuordnung → +rohtext ersetzen; Fallback rohtext), POST /api/belege/:id/analysieren +(adminOnly). Frontend: Belege-Tabelle mit Zuordnungs-Spalte (Ziel + „erkannt +per Zählernummer/Adresse/Manuell"), „Analysieren"-Button pro Beleg +(analysiereBeleg im Store), KostenDialog-Vorbefüllung um unitId/building +erweitert. E2E verifiziert: OCR über data-URL-PDF (reportlab-Test-PDF mit +„Zaehler-Nr: ST-0815-4711") → hs-7 zugeordnet; mp-ocr healthy. + **Erledigt 04.10. (v1.16):** SparkReceipt-Schnittstelle für gescannte Belege. SparkReceipt bietet Webhooks (Doku: sparkreceipt.com/integrations/webhooks) – POST auf eine URL bei document created/updated/deleted, Standard-Verzögerung diff --git a/server/ocr/Dockerfile b/server/ocr/Dockerfile new file mode 100644 index 0000000..bc82642 --- /dev/null +++ b/server/ocr/Dockerfile @@ -0,0 +1,21 @@ +# Hinterstein Mieterportal – OCR-Dienst (Tesseract Deutsch) +# Analysiert Belege (PDF/Bild) für die Auto-Zuordnung (Zählernummer/Adresse). +# Nur intern im Docker-Netz (mp-api → mp-ocr:8081), kein öffentlicher Port. + +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + tesseract-ocr tesseract-ocr-deu \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py ./ + +ENV PORT=8081 +EXPOSE 8081 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ + CMD python -c "import urllib.request;urllib.request.urlopen('http://localhost:8081/healthz')" || exit 1 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8081"] diff --git a/server/ocr/main.py b/server/ocr/main.py new file mode 100644 index 0000000..09e6b0f --- /dev/null +++ b/server/ocr/main.py @@ -0,0 +1,103 @@ +"""mp-ocr – OCR-Dienst für das Mieterportal (Beleg-Analyse). + +Nimmt eine Datei-URL (oder Upload) entgegen, lädt den Beleg (PDF/Bild), +extrahiert den Text mit Tesseract (Deutsch) und liefert ihn zurück. +Nur intern im Docker-Netz erreichbar (mp-api ruft ihn auf). +""" + +import base64 +import binascii +import io +import urllib.parse + +import fitz # PyMuPDF +import httpx +import pytesseract +from fastapi import FastAPI, HTTPException, UploadFile +from PIL import Image +from pydantic import BaseModel + +MAX_DATEI_BYTES = 20 * 1024 * 1024 # 20 MB +DOWNLOAD_TIMEOUT = 20.0 + +app = FastAPI(title="mp-ocr", docs_url=None, redoc_url=None) + + +class Anfrage(BaseModel): + url: str + + +@app.get("/healthz") +def healthz(): + return {"ok": True, "dienst": "mp-ocr"} + + +def bild_text(img: Image.Image) -> str: + return pytesseract.image_to_string(img, lang="deu").strip() + + +def pdf_text(daten: bytes) -> str: + teile = [] + with fitz.open(stream=daten, filetype="pdf") as doc: + for seite in doc: + pix = seite.get_pixmap(dpi=200) + img = Image.open(io.BytesIO(pix.tobytes("png"))) + teile.append(bild_text(img)) + return "\n".join(t for t in teile if t).strip() + + +def datei_text(daten: bytes, dateiname: str) -> str: + if dateiname.lower().endswith(".pdf") or daten[:5] == b"%PDF-": + return pdf_text(daten) + try: + with Image.open(io.BytesIO(daten)) as img: + return bild_text(img.convert("RGB")) + except Exception: + raise HTTPException(400, "Dateiformat nicht unterstützt (PDF/JPG/PNG erwartet).") + + +def datenVonDataUrl(url: str) -> tuple[bytes, str]: + """data:application/pdf;base64,…. → (bytes, name).""" + kopf, _, payload = url.partition(",") + if not payload: + raise HTTPException(400, "Leere data-URL.") + typ = kopf[5:].split(";")[0] or "application/octet-stream" + if ";base64" in kopf: + try: + return base64.b64decode(urllib.parse.unquote(payload)), "beleg-data" + except (binascii.Error, ValueError): + raise HTTPException(400, "Ungültige base64-data-URL.") + return urllib.parse.unquote_to_bytes(payload), "beleg-data" + + +@app.post("/ocr") +async def ocr_url(anfrage: Anfrage): + """Beleg von einer URL herunterladen und per OCR auswerten.""" + url = anfrage.url + if url.startswith("data:"): + daten, name = datenVonDataUrl(url) + if len(daten) > MAX_DATEI_BYTES: + raise HTTPException(413, "Datei größer als 20 MB.") + return {"text": datei_text(daten, name)} + if not url.startswith(("http://", "https://")): + raise HTTPException(400, "Nur http(s)- oder data-URLs erlaubt.") + try: + async with httpx.AsyncClient(timeout=DOWNLOAD_TIMEOUT, follow_redirects=True) as client: + resp = await client.get(url) + except Exception as e: + raise HTTPException(502, f"Download fehlgeschlagen: {e}") + if resp.status_code != 200: + raise HTTPException(502, f"Download: HTTP {resp.status_code}") + if len(resp.content) > MAX_DATEI_BYTES: + raise HTTPException(413, "Datei größer als 20 MB.") + name = url.split("?")[0].rsplit("/", 1)[-1] or "beleg" + return {"text": datei_text(resp.content, name)} + + +@app.post("/ocr/upload") +async def ocr_upload(datei: UploadFile): + """Beleg direkt als Upload (multipart) auswerten.""" + daten = await datei.read(MAX_DATEI_BYTES + 1) + if len(daten) > MAX_DATEI_BYTES: + raise HTTPException(413, "Datei größer als 20 MB.") + return {"text": datei_text(daten, datei.filename or "beleg")} diff --git a/server/ocr/requirements.txt b/server/ocr/requirements.txt new file mode 100644 index 0000000..70b112f --- /dev/null +++ b/server/ocr/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.115.6 +uvicorn==0.32.1 +httpx==0.28.1 +Pillow==11.0.0 +PyMuPDF==1.24.14 +pytesseract==0.3.13 +python-multipart==0.0.20 diff --git a/server/src/db.js b/server/src/db.js index 32981c0..a737610 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -183,6 +183,11 @@ CREATE TABLE IF NOT EXISTS belege ( erfasst_am TEXT NOT NULL, UNIQUE (quelle, externe_id) ); +-- Auto-Zuordnung: primär Stromzählernummer, sekundär Adresse (v1.17) +ALTER TABLE belege ADD COLUMN IF NOT EXISTS unit_id TEXT REFERENCES units(id); +ALTER TABLE belege ADD COLUMN IF NOT EXISTS gebaude TEXT; +ALTER TABLE belege ADD COLUMN IF NOT EXISTS zuordnung TEXT; -- zaehlernummer | adresse | manuell +ALTER TABLE belege ADD COLUMN IF NOT EXISTS rohtext TEXT; -- Volltext des Events für die Zuordnung ` async function init() { diff --git a/server/src/index.js b/server/src/index.js index aadfd56..b316fa7 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -23,6 +23,8 @@ const N8N_TICKET_WEBHOOK = process.env.N8N_TICKET_WEBHOOK || '' const KI_API_TOKEN = process.env.KI_API_TOKEN || '' // Beleg-Import aus SparkReceipt (leer = Webhook-Endpunkt deaktiviert) const SPARKRECEIPT_WEBHOOK_TOKEN = process.env.SPARKRECEIPT_WEBHOOK_TOKEN || '' +// OCR-Dienst für die Beleg-Analyse (interner Container mp-ocr) +const OCR_URL = process.env.OCR_URL || 'http://mp-ocr:8081' // PDF-Ablage für das Dokumentenmodul (im Container per Volume persistiert) const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads') const UPLOAD_MAX_BYTES = 25 * 1024 * 1024 // 25 MB pro Datei @@ -189,6 +191,7 @@ const mapBeleg = (b) => ({ betrag: b.betrag === null ? null : Number(b.betrag), waehrung: b.waehrung, datum: b.datum, jahr: b.jahr, kategorie: b.kategorie, notiz: b.notiz, dateiUrl: b.datei_url, zugeordnet: b.zugeordnet, erfasstAm: b.erfasst_am, + unitId: b.unit_id ?? null, gebaeude: b.gebaude ?? null, zuordnung: b.zuordnung ?? null, }) const deutschesDatum = () => new Date().toLocaleDateString('de-DE') @@ -751,6 +754,87 @@ function belegFelder(body) { } } +/** Volltext eines Webhook-Events (alle String-/Zahlenwerte rekursiv). */ +function eventVolltext(wert, teile = []) { + if (wert === null || wert === undefined) return teile + if (typeof wert === 'string' || typeof wert === 'number') { teile.push(String(wert)); return teile } + if (Array.isArray(wert)) { for (const x of wert) eventVolltext(x, teile); return teile } + if (typeof wert === 'object') { for (const x of Object.values(wert)) eventVolltext(x, teile); return teile } + return teile +} + +/** Auto-Zuordnung eines Belegs: PRIMÄR Stromzählernummer im Volltext, + * SEKUNDÄR Adresse/Hausname. Liefert { unitId?, gebaeude?, zuordnung? }. */ +async function belegAutoZuordnung(text) { + const fund = {} + // Umlaut-Vereinheitlichung: ß↔ss, Umlaute↔Ae/Oe/Ue (OCR & Tippfehler) + const normalisieren = (s) => String(s).toLowerCase() + .replace(/ß/g, 'ss').replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue') + const nadel = normalisieren(text) + // Primär: Stromzählernummern (längste Nummer zuerst = spezifischste Treffer) + const { rows: zaehler } = await pool.query( + `SELECT nummer, unit_id, building FROM meters WHERE art = 'Strom' ORDER BY length(nummer) DESC`, + ) + for (const z of zaehler) { + if (z.nummer && nadel.includes(normalisieren(z.nummer))) { + fund.unitId = z.unit_id + fund.gebaeude = z.building + fund.zuordnung = 'zaehlernummer' + return fund + } + } + // Sekundär: Hausname/Adresse im Volltext + const { rows: haeuser } = await pool.query('SELECT name, adresse FROM buildings') + for (const h of haeuser) { + const name = normalisieren((h.name || '').trim()) + const adr = normalisieren((h.adresse || '').trim()) + if ((name.length >= 5 && nadel.includes(name)) || (adr.length >= 5 && nadel.includes(adr))) { + fund.gebaeude = h.name + fund.zuordnung = 'adresse' + return fund + } + } + return fund +} + +/** Beleg nachimportieren/analysieren: datei_url per OCR auswerten (oder den + * gespeicherten Volltext erneut zuordnen) und die Wohnung/Haus-Zuordnung + * aktualisieren. Eine manuelle Zuordnung ('manuell') wird nie überschrieben. */ +async function belegAnalysieren(id) { + const { rows } = await pool.query('SELECT * FROM belege WHERE id = $1', [id]) + if (!rows.length) return null + const beleg = rows[0] + let text = '' + if (beleg.datei_url) { + try { + const resp = await fetch(`${OCR_URL}/ocr`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: beleg.datei_url }), + signal: AbortSignal.timeout(45000), + }) + if (resp.ok) text = String((await resp.json()).text || '') + else console.warn(`[ocr] HTTP ${resp.status} für Beleg ${id}`) + } catch (e) { + console.warn(`[ocr] Analyse fehlgeschlagen für Beleg ${id}:`, e.message) + } + } + if (!text.trim()) text = beleg.rohtext || '' // Fallback: Webhook-Volltext erneut prüfen + if (!text.trim()) return mapBeleg(beleg) + const auto = await belegAutoZuordnung(text) + const { rows: aktualisiert } = await pool.query( + `UPDATE belege SET + unit_id = CASE WHEN zuordnung = 'manuell' THEN unit_id ELSE $1 END, + gebaude = CASE WHEN zuordnung = 'manuell' THEN gebaude ELSE $2 END, + zuordnung = CASE WHEN zuordnung = 'manuell' THEN zuordnung ELSE $3 END, + rohtext = $4 + WHERE id = $5 RETURNING *`, + [auto.unitId ?? null, auto.gebaeude ?? null, auto.zuordnung ?? null, + text.slice(0, 8000), id], + ) + return mapBeleg(aktualisiert[0]) +} + app.post('/api/belege/webhook', async (req, res) => { try { if (!SPARKRECEIPT_WEBHOOK_TOKEN) return res.status(503).json({ fehler: 'Beleg-Import ist nicht konfiguriert.' }) @@ -762,18 +846,33 @@ app.post('/api/belege/webhook', async (req, res) => { const f = belegFelder(req.body) if (!f.externeId) return res.status(400).json({ fehler: 'Keine Dokument-ID im Event.' }) if (f.betrag !== null && !Number.isFinite(f.betrag)) return res.status(400).json({ fehler: 'Betrag ungültig.' }) + // Auto-Zuordnung: Volltext des Events gegen Zählernummern/Adressen prüfen + const volltext = eventVolltext(req.body).join(' \n ') + const auto = await belegAutoZuordnung(volltext) const { rows } = await pool.query( - `INSERT INTO belege (id, quelle, externe_id, haendler, betrag, waehrung, datum, jahr, kategorie, notiz, datei_url, erfasst_am) - VALUES ($1,'sparkreceipt',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + `INSERT INTO belege (id, quelle, externe_id, haendler, betrag, waehrung, datum, jahr, kategorie, notiz, datei_url, erfasst_am, unit_id, gebaude, zuordnung, rohtext) + VALUES ($1,'sparkreceipt',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT (quelle, externe_id) DO UPDATE SET haendler = EXCLUDED.haendler, betrag = EXCLUDED.betrag, waehrung = EXCLUDED.waehrung, datum = EXCLUDED.datum, jahr = EXCLUDED.jahr, kategorie = EXCLUDED.kategorie, - notiz = EXCLUDED.notiz, datei_url = EXCLUDED.datei_url + notiz = EXCLUDED.notiz, datei_url = EXCLUDED.datei_url, + unit_id = CASE WHEN belege.zuordnung = 'manuell' THEN belege.unit_id ELSE EXCLUDED.unit_id END, + gebaude = CASE WHEN belege.zuordnung = 'manuell' THEN belege.gebaude ELSE EXCLUDED.gebaude END, + zuordnung = CASE WHEN belege.zuordnung = 'manuell' THEN belege.zuordnung ELSE EXCLUDED.zuordnung END, + rohtext = EXCLUDED.rohtext RETURNING *`, [`b-${Date.now()}`, f.externeId, f.haendler, f.betrag, f.waehrung, f.datum, f.jahr, - f.kategorie, f.notiz, f.dateiUrl, deutschesDatumZeit()], + f.kategorie, f.notiz, f.dateiUrl, deutschesDatumZeit(), + auto.unitId ?? null, auto.gebaeude ?? null, auto.zuordnung ?? null, + volltext.slice(0, 8000)], ) - res.json({ ok: true, beleg: mapBeleg(rows[0]), event: event || 'unknown' }) + const beleg = mapBeleg(rows[0]) + res.json({ ok: true, beleg, event: event || 'unknown' }) + // Keine Zuordnung im Webhook-Text, aber eine Datei vorhanden → + // OCR-Analyse asynchron nachschieben (blockiert den Webhook nicht) + if (!beleg.zuordnung && beleg.dateiUrl) { + belegAnalysieren(beleg.id).catch((e) => console.warn('[belege] Nachanalyse fehlgeschlagen:', e.message)) + } } catch (e) { console.error('[belege] Webhook-Fehler:', e) res.status(500).json({ fehler: 'Interner Serverfehler.' }) @@ -792,6 +891,15 @@ app.patch('/api/belege/:id', auth, adminOnly, async (req, res, next) => { } catch (e) { next(e) } }) +// Beleg erneut analysieren (OCR über datei_url) und Zuordnung aktualisieren +app.post('/api/belege/:id/analysieren', auth, adminOnly, async (req, res, next) => { + try { + const beleg = await belegAnalysieren(req.params.id) + if (!beleg) return res.status(404).json({ fehler: 'Beleg nicht gefunden.' }) + res.json({ beleg }) + } catch (e) { next(e) } +}) + // ── Bestand: Häuser & Wohnungen (Verwaltung) ──────────────── function pruefeHaus(b) { const fehler = [] diff --git a/src/pages/Kosten.tsx b/src/pages/Kosten.tsx index 98a3535..8eb1c07 100644 --- a/src/pages/Kosten.tsx +++ b/src/pages/Kosten.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { Calculator, Receipt, ReceiptText, PiggyBank, Plus, Pencil, Trash2, Coins, FileScan, Check, ExternalLink } from 'lucide-react' +import { Calculator, Receipt, ReceiptText, PiggyBank, Plus, Pencil, Trash2, Coins, FileScan, Check, ExternalLink, ScanSearch } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' @@ -42,7 +42,7 @@ const zahl = (s: string) => Number(s.replace(',', '.')) function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }: { position?: KostenItem - vorbefuellung?: { betrag?: string; jahr?: string; notiz?: string } + vorbefuellung?: { betrag?: string; jahr?: string; notiz?: string; unitId?: string | null; building?: string } onGespeichert?: () => void triggerLabel?: string }) { @@ -54,8 +54,8 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }: const [betrag, setBetrag] = useState( position ? String(position.betrag).replace('.', ',') : (vorbefuellung?.betrag ?? ''), ) - const [einheit, setEinheit] = useState(position?.unitId ?? '__haus') // '__haus' = ganzes Haus - const [haus, setHaus] = useState(position?.building ?? buildings[0]?.name ?? '') + const [einheit, setEinheit] = useState(position?.unitId ?? vorbefuellung?.unitId ?? '__haus') // '__haus' = ganzes Haus + const [haus, setHaus] = useState(position?.building ?? vorbefuellung?.building ?? buildings[0]?.name ?? '') const [umlegbar, setUmlegbar] = useState(position?.umlegbar ?? true) const [notiz, setNotiz] = useState(position?.notiz ?? vorbefuellung?.notiz ?? '') const [sende, setSende] = useState(false) @@ -195,8 +195,9 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }: /** Eingegangene SparkReceipt-Belege (nur Verwaltung). */ function BelegeListe() { - const { belege, setBelegZugeordnet } = useStore() + const { belege, setBelegZugeordnet, analysiereBeleg, units } = useStore() const [nurOffene, setNurOffene] = useState(false) + const [analysiere, setAnalysiere] = useState(null) const zeilen = useMemo( () => (nurOffene ? belege.filter((b) => !b.zugeordnet) : belege), @@ -204,6 +205,24 @@ function BelegeListe() { ) const offen = belege.filter((b) => !b.zugeordnet).length + const zuordnungLabel = (b: Beleg) => { + const whg = b.unitId ? (() => { + const u = units.find((x) => x.id === b.unitId) + return u ? `Whg. ${u.number}` : b.unitId + })() : null + const ziel = whg ? `${b.gebaeude ?? ''}, ${whg}` : (b.gebaeude ?? null) + const weg = b.zuordnung === 'zaehlernummer' ? 'Zählernummer' + : b.zuordnung === 'adresse' ? 'Adresse' + : b.zuordnung === 'manuell' ? 'Manuell' : null + return { ziel, weg } + } + + const analysieren = async (id: string) => { + setAnalysiere(id) + await analysiereBeleg(id) + setAnalysiere(null) + } + return (
@@ -212,6 +231,9 @@ function BelegeListe() {

Gescannte Belege aus dem SparkReceipt-Cloudarchiv landen automatisch in dieser Liste. + Die Zuordnung zur Wohneinheit erfolgt primär über die Stromzählernummer, + sekundär über die Adresse – zuerst im übertragenen Text, sonst per OCR-Analyse + des Belegs (Button „Analysieren"). In SparkReceipt unter Integrationen → Webhooks diese URL hinterlegen (Token liegt in der Server-Konfiguration portal.env):

@@ -252,6 +274,7 @@ function BelegeListe() { Händler Datum Kategorie (SparkReceipt) + Zuordnung Betrag Status Aktion @@ -269,6 +292,19 @@ function BelegeListe() { {b.jahr && · {b.jahr}} {b.kategorie || '–'} + + {(() => { + const { ziel, weg } = zuordnungLabel(b) + return ziel ? ( + <> +
{ziel}
+ {weg &&
erkannt per {weg}
} + + ) : ( + nicht zugeordnet + ) + })()} +
{b.betrag === null ? '–' : `${b.betrag.toLocaleString('de-DE', { minimumFractionDigits: 2 })} ${b.waehrung}`} @@ -283,6 +319,19 @@ function BelegeListe() {
+ {b.dateiUrl && ( + + )} {!b.zugeordnet && ( setBelegZugeordnet(b.id, true)} /> diff --git a/src/store/store.tsx b/src/store/store.tsx index 8a66fd4..ecd1b79 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -162,6 +162,8 @@ interface Store extends Bootstrap { deleteKosten: (id: string) => Promise // ── Belege (SparkReceipt-Import; Verwaltung ordnet zu) ── setBelegZugeordnet: (id: string, zugeordnet: boolean) => Promise + /** Beleg erneut per OCR analysieren und Zuordnung aktualisieren. */ + analysiereBeleg: (id: string) => Promise } const StoreContext = createContext(null) @@ -460,6 +462,15 @@ export function StoreProvider({ children }: { children: ReactNode }) { return fehlerNachricht(e) } }, + async analysiereBeleg(id) { + try { + const { beleg } = await apiPost<{ beleg: Beleg }>(`/api/belege/${id}/analysieren`) + setDaten((x) => ({ ...x, belege: x.belege.map((b) => (b.id === id ? beleg : b)) })) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, }), [daten, bereit, me, role, meineEinheit, ladeAlles]) return {children} diff --git a/src/types/index.ts b/src/types/index.ts index 5dfb658..686d356 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -149,4 +149,7 @@ export interface Beleg { dateiUrl: string zugeordnet: boolean // bereits als Kostenposition übernommen erfasstAm: string + unitId: string | null // Auto-/Manuell-Zuordnung zur Wohneinheit + gebaeude: string | null // zugeordnetes Haus + zuordnung: 'zaehlernummer' | 'adresse' | 'manuell' | null // Zuordnungsweg }