v1.17+v1.18: Beleg-Auto-Zuordnung (Zaehlernummer/Adresse) + eigener OCR-Container (Tesseract)

This commit is contained in:
2026-10-04 13:05:55 +02:00
parent 4eab719550
commit c0cadd7451
10 changed files with 357 additions and 10 deletions
+21
View File
@@ -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"]
+103
View File
@@ -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")}
+7
View File
@@ -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
+5
View File
@@ -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() {
+113 -5
View File
@@ -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 = []