v1.17+v1.18: Beleg-Auto-Zuordnung (Zaehlernummer/Adresse) + eigener OCR-Container (Tesseract)
This commit is contained in:
@@ -69,6 +69,19 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- npm_default
|
- 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:
|
networks:
|
||||||
npm_default:
|
npm_default:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -189,6 +189,31 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
|
|
||||||
## 8. Verlauf / offene Punkte
|
## 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.
|
**Erledigt 04.10. (v1.16):** SparkReceipt-Schnittstelle für gescannte Belege.
|
||||||
SparkReceipt bietet Webhooks (Doku: sparkreceipt.com/integrations/webhooks)
|
SparkReceipt bietet Webhooks (Doku: sparkreceipt.com/integrations/webhooks)
|
||||||
– POST auf eine URL bei document created/updated/deleted, Standard-Verzögerung
|
– POST auf eine URL bei document created/updated/deleted, Standard-Verzögerung
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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")}
|
||||||
@@ -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
|
||||||
@@ -183,6 +183,11 @@ CREATE TABLE IF NOT EXISTS belege (
|
|||||||
erfasst_am TEXT NOT NULL,
|
erfasst_am TEXT NOT NULL,
|
||||||
UNIQUE (quelle, externe_id)
|
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() {
|
async function init() {
|
||||||
|
|||||||
+113
-5
@@ -23,6 +23,8 @@ const N8N_TICKET_WEBHOOK = process.env.N8N_TICKET_WEBHOOK || ''
|
|||||||
const KI_API_TOKEN = process.env.KI_API_TOKEN || ''
|
const KI_API_TOKEN = process.env.KI_API_TOKEN || ''
|
||||||
// Beleg-Import aus SparkReceipt (leer = Webhook-Endpunkt deaktiviert)
|
// Beleg-Import aus SparkReceipt (leer = Webhook-Endpunkt deaktiviert)
|
||||||
const SPARKRECEIPT_WEBHOOK_TOKEN = process.env.SPARKRECEIPT_WEBHOOK_TOKEN || ''
|
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)
|
// PDF-Ablage für das Dokumentenmodul (im Container per Volume persistiert)
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads')
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads')
|
||||||
const UPLOAD_MAX_BYTES = 25 * 1024 * 1024 // 25 MB pro Datei
|
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,
|
betrag: b.betrag === null ? null : Number(b.betrag), waehrung: b.waehrung,
|
||||||
datum: b.datum, jahr: b.jahr, kategorie: b.kategorie, notiz: b.notiz,
|
datum: b.datum, jahr: b.jahr, kategorie: b.kategorie, notiz: b.notiz,
|
||||||
dateiUrl: b.datei_url, zugeordnet: b.zugeordnet, erfasstAm: b.erfasst_am,
|
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')
|
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) => {
|
app.post('/api/belege/webhook', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!SPARKRECEIPT_WEBHOOK_TOKEN) return res.status(503).json({ fehler: 'Beleg-Import ist nicht konfiguriert.' })
|
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)
|
const f = belegFelder(req.body)
|
||||||
if (!f.externeId) return res.status(400).json({ fehler: 'Keine Dokument-ID im Event.' })
|
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.' })
|
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(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO belege (id, quelle, externe_id, haendler, betrag, waehrung, datum, jahr, kategorie, notiz, datei_url, erfasst_am)
|
`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)
|
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
|
ON CONFLICT (quelle, externe_id) DO UPDATE SET
|
||||||
haendler = EXCLUDED.haendler, betrag = EXCLUDED.betrag, waehrung = EXCLUDED.waehrung,
|
haendler = EXCLUDED.haendler, betrag = EXCLUDED.betrag, waehrung = EXCLUDED.waehrung,
|
||||||
datum = EXCLUDED.datum, jahr = EXCLUDED.jahr, kategorie = EXCLUDED.kategorie,
|
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 *`,
|
RETURNING *`,
|
||||||
[`b-${Date.now()}`, f.externeId, f.haendler, f.betrag, f.waehrung, f.datum, f.jahr,
|
[`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) {
|
} catch (e) {
|
||||||
console.error('[belege] Webhook-Fehler:', e)
|
console.error('[belege] Webhook-Fehler:', e)
|
||||||
res.status(500).json({ fehler: 'Interner Serverfehler.' })
|
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) }
|
} 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) ────────────────
|
// ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────────
|
||||||
function pruefeHaus(b) {
|
function pruefeHaus(b) {
|
||||||
const fehler = []
|
const fehler = []
|
||||||
|
|||||||
+56
-5
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -42,7 +42,7 @@ const zahl = (s: string) => Number(s.replace(',', '.'))
|
|||||||
|
|
||||||
function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }: {
|
function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }: {
|
||||||
position?: KostenItem
|
position?: KostenItem
|
||||||
vorbefuellung?: { betrag?: string; jahr?: string; notiz?: string }
|
vorbefuellung?: { betrag?: string; jahr?: string; notiz?: string; unitId?: string | null; building?: string }
|
||||||
onGespeichert?: () => void
|
onGespeichert?: () => void
|
||||||
triggerLabel?: string
|
triggerLabel?: string
|
||||||
}) {
|
}) {
|
||||||
@@ -54,8 +54,8 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }:
|
|||||||
const [betrag, setBetrag] = useState(
|
const [betrag, setBetrag] = useState(
|
||||||
position ? String(position.betrag).replace('.', ',') : (vorbefuellung?.betrag ?? ''),
|
position ? String(position.betrag).replace('.', ',') : (vorbefuellung?.betrag ?? ''),
|
||||||
)
|
)
|
||||||
const [einheit, setEinheit] = useState(position?.unitId ?? '__haus') // '__haus' = ganzes Haus
|
const [einheit, setEinheit] = useState(position?.unitId ?? vorbefuellung?.unitId ?? '__haus') // '__haus' = ganzes Haus
|
||||||
const [haus, setHaus] = useState(position?.building ?? buildings[0]?.name ?? '')
|
const [haus, setHaus] = useState(position?.building ?? vorbefuellung?.building ?? buildings[0]?.name ?? '')
|
||||||
const [umlegbar, setUmlegbar] = useState(position?.umlegbar ?? true)
|
const [umlegbar, setUmlegbar] = useState(position?.umlegbar ?? true)
|
||||||
const [notiz, setNotiz] = useState(position?.notiz ?? vorbefuellung?.notiz ?? '')
|
const [notiz, setNotiz] = useState(position?.notiz ?? vorbefuellung?.notiz ?? '')
|
||||||
const [sende, setSende] = useState(false)
|
const [sende, setSende] = useState(false)
|
||||||
@@ -195,8 +195,9 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }:
|
|||||||
|
|
||||||
/** Eingegangene SparkReceipt-Belege (nur Verwaltung). */
|
/** Eingegangene SparkReceipt-Belege (nur Verwaltung). */
|
||||||
function BelegeListe() {
|
function BelegeListe() {
|
||||||
const { belege, setBelegZugeordnet } = useStore()
|
const { belege, setBelegZugeordnet, analysiereBeleg, units } = useStore()
|
||||||
const [nurOffene, setNurOffene] = useState(false)
|
const [nurOffene, setNurOffene] = useState(false)
|
||||||
|
const [analysiere, setAnalysiere] = useState<string | null>(null)
|
||||||
|
|
||||||
const zeilen = useMemo(
|
const zeilen = useMemo(
|
||||||
() => (nurOffene ? belege.filter((b) => !b.zugeordnet) : belege),
|
() => (nurOffene ? belege.filter((b) => !b.zugeordnet) : belege),
|
||||||
@@ -204,6 +205,24 @@ function BelegeListe() {
|
|||||||
)
|
)
|
||||||
const offen = belege.filter((b) => !b.zugeordnet).length
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-2xl border border-teal-200 bg-teal-50/60 p-4 text-sm text-slate-700">
|
<div className="rounded-2xl border border-teal-200 bg-teal-50/60 p-4 text-sm text-slate-700">
|
||||||
@@ -212,6 +231,9 @@ function BelegeListe() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
Gescannte Belege aus dem SparkReceipt-Cloudarchiv landen automatisch in dieser Liste.
|
Gescannte Belege aus dem SparkReceipt-Cloudarchiv landen automatisch in dieser Liste.
|
||||||
|
Die Zuordnung zur Wohneinheit erfolgt primär über die <strong>Stromzählernummer</strong>,
|
||||||
|
sekundär über die <strong>Adresse</strong> – zuerst im übertragenen Text, sonst per OCR-Analyse
|
||||||
|
des Belegs (Button „Analysieren").
|
||||||
In SparkReceipt unter <em>Integrationen → Webhooks</em> diese URL hinterlegen
|
In SparkReceipt unter <em>Integrationen → Webhooks</em> diese URL hinterlegen
|
||||||
(Token liegt in der Server-Konfiguration <code>portal.env</code>):
|
(Token liegt in der Server-Konfiguration <code>portal.env</code>):
|
||||||
</p>
|
</p>
|
||||||
@@ -252,6 +274,7 @@ function BelegeListe() {
|
|||||||
<TableHead>Händler</TableHead>
|
<TableHead>Händler</TableHead>
|
||||||
<TableHead>Datum</TableHead>
|
<TableHead>Datum</TableHead>
|
||||||
<TableHead>Kategorie (SparkReceipt)</TableHead>
|
<TableHead>Kategorie (SparkReceipt)</TableHead>
|
||||||
|
<TableHead>Zuordnung</TableHead>
|
||||||
<TableHead className="text-right">Betrag</TableHead>
|
<TableHead className="text-right">Betrag</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="text-right">Aktion</TableHead>
|
<TableHead className="text-right">Aktion</TableHead>
|
||||||
@@ -269,6 +292,19 @@ function BelegeListe() {
|
|||||||
{b.jahr && <span className="text-xs text-slate-400"> · {b.jahr}</span>}
|
{b.jahr && <span className="text-xs text-slate-400"> · {b.jahr}</span>}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-slate-500">{b.kategorie || '–'}</TableCell>
|
<TableCell className="text-slate-500">{b.kategorie || '–'}</TableCell>
|
||||||
|
<TableCell className="text-slate-500">
|
||||||
|
{(() => {
|
||||||
|
const { ziel, weg } = zuordnungLabel(b)
|
||||||
|
return ziel ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-medium text-slate-700">{ziel}</div>
|
||||||
|
{weg && <div className="text-xs text-slate-400">erkannt per {weg}</div>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-400">nicht zugeordnet</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right font-semibold">
|
<TableCell className="text-right font-semibold">
|
||||||
{b.betrag === null ? '–' : `${b.betrag.toLocaleString('de-DE', { minimumFractionDigits: 2 })} ${b.waehrung}`}
|
{b.betrag === null ? '–' : `${b.betrag.toLocaleString('de-DE', { minimumFractionDigits: 2 })} ${b.waehrung}`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -283,6 +319,19 @@ function BelegeListe() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{b.dateiUrl && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
disabled={analysiere === b.id}
|
||||||
|
onClick={() => analysieren(b.id)}
|
||||||
|
title="Beleg per OCR analysieren und Zuordnung aktualisieren"
|
||||||
|
>
|
||||||
|
<ScanSearch className="h-3.5 w-3.5" />
|
||||||
|
{analysiere === b.id ? 'Analysiere …' : 'Analysieren'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{!b.zugeordnet && (
|
{!b.zugeordnet && (
|
||||||
<KostenDialog
|
<KostenDialog
|
||||||
triggerLabel="Übernehmen"
|
triggerLabel="Übernehmen"
|
||||||
@@ -290,6 +339,8 @@ function BelegeListe() {
|
|||||||
betrag: b.betrag === null ? '' : String(b.betrag).replace('.', ','),
|
betrag: b.betrag === null ? '' : String(b.betrag).replace('.', ','),
|
||||||
jahr: b.jahr ? String(b.jahr) : String(new Date().getFullYear()),
|
jahr: b.jahr ? String(b.jahr) : String(new Date().getFullYear()),
|
||||||
notiz: `SparkReceipt ${b.haendler ? `· ${b.haendler} ` : ''}(#${b.externeId})`,
|
notiz: `SparkReceipt ${b.haendler ? `· ${b.haendler} ` : ''}(#${b.externeId})`,
|
||||||
|
unitId: b.unitId,
|
||||||
|
building: b.gebaeude ?? undefined,
|
||||||
}}
|
}}
|
||||||
onGespeichert={() => setBelegZugeordnet(b.id, true)}
|
onGespeichert={() => setBelegZugeordnet(b.id, true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ interface Store extends Bootstrap {
|
|||||||
deleteKosten: (id: string) => Promise<string | null>
|
deleteKosten: (id: string) => Promise<string | null>
|
||||||
// ── Belege (SparkReceipt-Import; Verwaltung ordnet zu) ──
|
// ── Belege (SparkReceipt-Import; Verwaltung ordnet zu) ──
|
||||||
setBelegZugeordnet: (id: string, zugeordnet: boolean) => Promise<string | null>
|
setBelegZugeordnet: (id: string, zugeordnet: boolean) => Promise<string | null>
|
||||||
|
/** Beleg erneut per OCR analysieren und Zuordnung aktualisieren. */
|
||||||
|
analysiereBeleg: (id: string) => Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
const StoreContext = createContext<Store | null>(null)
|
const StoreContext = createContext<Store | null>(null)
|
||||||
@@ -460,6 +462,15 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|||||||
return fehlerNachricht(e)
|
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])
|
}), [daten, bereit, me, role, meineEinheit, ladeAlles])
|
||||||
|
|
||||||
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
||||||
|
|||||||
@@ -149,4 +149,7 @@ export interface Beleg {
|
|||||||
dateiUrl: string
|
dateiUrl: string
|
||||||
zugeordnet: boolean // bereits als Kostenposition übernommen
|
zugeordnet: boolean // bereits als Kostenposition übernommen
|
||||||
erfasstAm: string
|
erfasstAm: string
|
||||||
|
unitId: string | null // Auto-/Manuell-Zuordnung zur Wohneinheit
|
||||||
|
gebaeude: string | null // zugeordnetes Haus
|
||||||
|
zuordnung: 'zaehlernummer' | 'adresse' | 'manuell' | null // Zuordnungsweg
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user