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
+56 -5
View File
@@ -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<string | null>(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 (
<div className="space-y-4">
<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>
<p className="mt-1">
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
(Token liegt in der Server-Konfiguration <code>portal.env</code>):
</p>
@@ -252,6 +274,7 @@ function BelegeListe() {
<TableHead>Händler</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Kategorie (SparkReceipt)</TableHead>
<TableHead>Zuordnung</TableHead>
<TableHead className="text-right">Betrag</TableHead>
<TableHead>Status</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>}
</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">
{b.betrag === null ? '–' : `${b.betrag.toLocaleString('de-DE', { minimumFractionDigits: 2 })} ${b.waehrung}`}
</TableCell>
@@ -283,6 +319,19 @@ function BelegeListe() {
</TableCell>
<TableCell className="text-right">
<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 && (
<KostenDialog
triggerLabel="Übernehmen"
@@ -290,6 +339,8 @@ function BelegeListe() {
betrag: b.betrag === null ? '' : String(b.betrag).replace('.', ','),
jahr: b.jahr ? String(b.jahr) : String(new Date().getFullYear()),
notiz: `SparkReceipt ${b.haendler ? `· ${b.haendler} ` : ''}(#${b.externeId})`,
unitId: b.unitId,
building: b.gebaeude ?? undefined,
}}
onGespeichert={() => setBelegZugeordnet(b.id, true)}
/>
+11
View File
@@ -162,6 +162,8 @@ interface Store extends Bootstrap {
deleteKosten: (id: string) => Promise<string | null>
// ── Belege (SparkReceipt-Import; Verwaltung ordnet zu) ──
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)
@@ -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 <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
+3
View File
@@ -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
}