Refactor: OCR/Tesseract-Fallback entfernt, KI-Doku aktualisiert
This commit is contained in:
parent
6ab1bc3ca4
commit
b51124fd0a
4 changed files with 262 additions and 84 deletions
|
|
@ -20,23 +20,30 @@ Das Format ist immer `{Präfix}-{Jahr}-{laufende Nummer}`. Die laufende Nummer w
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## KI-Beleganalyse einrichten
|
## KI-Kontierungsvorschlag einrichten
|
||||||
|
|
||||||
Die KI-gestützte Belegerkennung ist standardmäßig deaktiviert. Sie kann unter **Einstellungen → KI-Kontierungsvorschlag** aktiviert werden, sobald ein LLM erreichbar ist.
|
Der KI-Kontierungsvorschlag analysiert Belege automatisch beim Nextcloud-Import und füllt das Buchungsformular mit einem Vorschlag vor. Die Funktion ist standardmäßig deaktiviert und kann unter **Einstellungen → KI-Kontierungsvorschlag** aktiviert werden.
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
- Nextcloud-Import muss konfiguriert und aktiv sein (siehe unten)
|
||||||
|
- Ein erreichbares LLM (lokal via Ollama oder Remote-API)
|
||||||
|
- Für Bildbelege (JPEG, PNG, TIFF): ein Vision-fähiges Modell (z.B. `qwen2.5vl`, `llava`); ohne Vision-Unterstützung werden Bildbelege ohne Textextraktion verarbeitet
|
||||||
|
- Für PDF-Belege: `pdftotext` und `pdftoppm` (Teil von `poppler-utils`, im Docker-Image enthalten)
|
||||||
|
|
||||||
### Option A – Lokales Ollama
|
### Option A – Lokales Ollama
|
||||||
|
|
||||||
Kein API-Schlüssel nötig, aber hoher Ressourcenbedarf:
|
Kein API-Schlüssel nötig, aber hoher Ressourcenbedarf:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ollama pull mistral # oder llama3.2 etc.
|
ollama pull qwen2.5vl # Vision-fähig (empfohlen), alternativ mistral o.ä.
|
||||||
```
|
```
|
||||||
|
|
||||||
In `.env` ist keine weitere Konfiguration nötig – die App erkennt Ollama auf `localhost:11434` automatisch. Modell und URL können optional überschrieben werden:
|
In `.env` ist keine weitere Konfiguration nötig – die App erkennt Ollama auf `localhost:11434` automatisch. Modell und URL können optional überschrieben werden:
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
OLLAMA_URL=http://localhost:11434
|
OLLAMA_URL=http://localhost:11434
|
||||||
OLLAMA_MODEL=mistral
|
OLLAMA_MODEL=qwen2.5vl
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option B – Remote-API (OpenAI-kompatibel)
|
### Option B – Remote-API (OpenAI-kompatibel)
|
||||||
|
|
@ -49,7 +56,7 @@ LLM_API_KEY=sk-...
|
||||||
LLM_MODEL=gpt-4o-mini
|
LLM_MODEL=gpt-4o-mini
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Hinweis:** Die Ergebnisqualität hängt stark vom gewählten Modell ab. Tests mit `mistral` (lokal via Ollama) lieferten bisher mäßige Ergebnisse.
|
> **Hinweis:** Die Ergebnisqualität hängt stark vom gewählten Modell ab. Für zuverlässige Ergebnisse wird ein Vision-fähiges Modell empfohlen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class Setting
|
||||||
iban=?, bic=?, bank_name=?,
|
iban=?, bic=?, bank_name=?,
|
||||||
ustva_steuernummer=?, ustva_bundesland=?,
|
ustva_steuernummer=?, ustva_bundesland=?,
|
||||||
invoice_nr_prefix=?, beleg_nr_prefix=?,
|
invoice_nr_prefix=?, beleg_nr_prefix=?,
|
||||||
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?, ocr_aktiv=?,
|
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?,
|
||||||
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
|
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
|
||||||
nextcloud_folder=?, nextcloud_done=?,
|
nextcloud_folder=?, nextcloud_done=?,
|
||||||
logo_max_width=?, logo_max_height=?,
|
logo_max_width=?, logo_max_height=?,
|
||||||
|
|
@ -61,7 +61,6 @@ class Setting
|
||||||
(int)($data['payment_days_default'] ?? 14),
|
(int)($data['payment_days_default'] ?? 14),
|
||||||
isset($data['test_mode']) ? 1 : 0,
|
isset($data['test_mode']) ? 1 : 0,
|
||||||
isset($data['ki_vorschlag_aktiv']) ? 1 : 0,
|
isset($data['ki_vorschlag_aktiv']) ? 1 : 0,
|
||||||
isset($data['ocr_aktiv']) ? 1 : 0,
|
|
||||||
$data['nextcloud_url'] ?: null,
|
$data['nextcloud_url'] ?: null,
|
||||||
$data['nextcloud_user'] ?: null,
|
$data['nextcloud_user'] ?: null,
|
||||||
$data['nextcloud_pass'] ?: null,
|
$data['nextcloud_pass'] ?: null,
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,6 @@ class KontierungsvorschlagService
|
||||||
return (bool)($s['ki_vorschlag_aktiv'] ?? 0) && $this->llm->isAvailable();
|
return (bool)($s['ki_vorschlag_aktiv'] ?? 0) && $this->llm->isAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isOcrEnabled(): bool
|
|
||||||
{
|
|
||||||
$s = Setting::get();
|
|
||||||
return (bool)($s['ocr_aktiv'] ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function extractText(array $beleg): string
|
public function extractText(array $beleg): string
|
||||||
{
|
{
|
||||||
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||||
|
|
@ -38,14 +32,33 @@ class KontierungsvorschlagService
|
||||||
$mime = strtolower($beleg['mime_type'] ?? '');
|
$mime = strtolower($beleg['mime_type'] ?? '');
|
||||||
|
|
||||||
if ($mime === 'application/pdf') {
|
if ($mime === 'application/pdf') {
|
||||||
|
// 1. eRechnung-XML (ZUGFeRD / Factur-X / XRechnung) — strukturiert, fehlerarm
|
||||||
|
$xml = $this->extractErechnungXml($absPath);
|
||||||
|
if ($xml !== '') return $xml;
|
||||||
|
|
||||||
|
// 2. Vision-Modell (qwen2.5vl o.ä.)
|
||||||
|
if ($this->llm->isVisionAvailable()) {
|
||||||
|
$vision = $this->extractPdfVision($absPath);
|
||||||
|
if ($vision !== '') return $vision;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback: pdftotext
|
||||||
return $this->extractPdfText($absPath);
|
return $this->extractPdfText($absPath);
|
||||||
} elseif (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) {
|
}
|
||||||
return $this->isOcrEnabled() ? $this->extractImageText($absPath) : '';
|
|
||||||
|
if (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) {
|
||||||
|
if (!$this->llm->isVisionAvailable()) return '';
|
||||||
|
return $this->extractImageVision($absPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen Kontierungsvorschlag für den Beleg.
|
||||||
|
* Gibt ein Array von Buchungszeilen zurück (eine je MwSt-Satz),
|
||||||
|
* oder null bei Fehler / nicht unterstütztem MIME-Typ.
|
||||||
|
*/
|
||||||
public function generateForBeleg(array $beleg): ?array
|
public function generateForBeleg(array $beleg): ?array
|
||||||
{
|
{
|
||||||
$text = $this->extractText($beleg);
|
$text = $this->extractText($beleg);
|
||||||
|
|
@ -82,20 +95,20 @@ class KontierungsvorschlagService
|
||||||
));
|
));
|
||||||
|
|
||||||
$beleginhalt = trim($text) !== ''
|
$beleginhalt = trim($text) !== ''
|
||||||
? "Belegtext:\n" . mb_substr(trim($text), 0, 2000)
|
? "Belegtext:\n" . mb_substr(trim($text), 0, 3000)
|
||||||
: '[Kein Text extrahierbar]';
|
: '[Kein Text extrahierbar]';
|
||||||
|
|
||||||
$settings = Setting::get();
|
$settings = Setting::get();
|
||||||
$eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? ''));
|
$eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? ''));
|
||||||
$eigenerHinweis = $eigenerName !== ''
|
$eigenerHinweis = $eigenerName !== ''
|
||||||
? "\n- supplier_name: NICHT \"{$eigenerName}\" (das ist der Rechnungsempfaenger, nicht der Lieferant). Bei Ausgabe = Aussteller/Verkaeuer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar."
|
? "\n- supplier_name: NICHT \"{$eigenerName}\" (das ist der Rechnungsempfaenger, nicht der Lieferant). Bei Ausgabe = Aussteller/Verkaeufer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar."
|
||||||
: "\n- supplier_name: Bei Ausgabe = Aussteller/Verkaeuer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar.";
|
: "\n- supplier_name: Bei Ausgabe = Aussteller/Verkaeufer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar.";
|
||||||
|
|
||||||
$prompt = <<<PROMPT
|
$prompt = <<<PROMPT
|
||||||
Du bist ein Buchhalter (SKR03-Kontenrahmen, Deutschland). Analysiere den folgenden Beleg und erstelle einen Kontierungsvorschlag.
|
Du bist ein Buchhalter (SKR03-Kontenrahmen, Deutschland). Analysiere den folgenden Beleg und erstelle einen Kontierungsvorschlag.
|
||||||
|
|
||||||
Antworte AUSSCHLIESSLICH mit einem einzigen JSON-Objekt, ohne erklaerenden Text davor oder danach:
|
Antworte AUSSCHLIESSLICH mit einem JSON-Array. Eine Zeile je MwSt-Satz (7% und 19% = zwei Eintraege). Nur ein Eintrag wenn ein einziger Steuersatz. Kein erklaerende Text davor oder danach:
|
||||||
{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":19,"betrag_netto":33.64,"buchungstext":"Rechnung 2026-001","supplier_name":"Mustermann GmbH"}
|
[{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":7,"betrag_netto":138.10,"buchungstext":"Rechnung 2026-001","supplier_name":"Mustermann GmbH"},{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":19,"betrag_netto":26.24,"buchungstext":"Rechnung 2026-001","supplier_name":"Mustermann GmbH"}]
|
||||||
|
|
||||||
Erlaubte Werte:
|
Erlaubte Werte:
|
||||||
- buchungstyp: "Ausgabe" oder "Einnahme"
|
- buchungstyp: "Ausgabe" oder "Einnahme"
|
||||||
|
|
@ -117,43 +130,222 @@ PROMPT;
|
||||||
return $this->parseAndValidate($response, $allAccounts);
|
return $this->parseAndValidate($response, $allAccounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Text-Extraktion
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrahiert eingebettete eRechnung-XML (ZUGFeRD / Factur-X / XRechnung).
|
||||||
|
* Gibt XML-Inhalt (max. 12.000 Zeichen) zurück, oder '' wenn keiner gefunden.
|
||||||
|
*/
|
||||||
|
private function extractErechnungXml(string $path): string
|
||||||
|
{
|
||||||
|
// pdfdetach ist Teil von poppler-utils (zusammen mit pdftotext installiert)
|
||||||
|
$safe = escapeshellarg($path);
|
||||||
|
exec("pdfdetach -list {$safe} 2>/dev/null", $listOutput, $rc);
|
||||||
|
if ($rc !== 0 || empty($listOutput)) return '';
|
||||||
|
|
||||||
|
$hasXml = false;
|
||||||
|
foreach ($listOutput as $line) {
|
||||||
|
if (stripos($line, '.xml') !== false) {
|
||||||
|
$hasXml = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$hasXml) return '';
|
||||||
|
|
||||||
|
$tmpDir = sys_get_temp_dir() . '/erechnung_' . uniqid();
|
||||||
|
$safeTmp = escapeshellarg($tmpDir);
|
||||||
|
mkdir($tmpDir, 0700, true);
|
||||||
|
|
||||||
|
exec("pdfdetach -saveall -o {$safeTmp} {$safe} 2>/dev/null");
|
||||||
|
|
||||||
|
$xmlContent = '';
|
||||||
|
$candidates = [
|
||||||
|
"$tmpDir/factur-x.xml",
|
||||||
|
"$tmpDir/ZUGFeRD-invoice.xml",
|
||||||
|
"$tmpDir/zugferd-invoice.xml",
|
||||||
|
"$tmpDir/xrechnung.xml",
|
||||||
|
];
|
||||||
|
// Bekannte Namen zuerst, dann alle .xml-Dateien im tmpDir
|
||||||
|
foreach (array_merge($candidates, glob("$tmpDir/*.xml") ?: [], glob("$tmpDir/*.XML") ?: []) as $f) {
|
||||||
|
if (file_exists($f)) {
|
||||||
|
$xmlContent = file_get_contents($f);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
foreach (glob("$tmpDir/*") ?: [] as $f) {
|
||||||
|
@unlink($f);
|
||||||
|
}
|
||||||
|
@rmdir($tmpDir);
|
||||||
|
|
||||||
|
return mb_substr($xmlContent, 0, 12000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konvertiert PDF-Seiten zu Bildern (pdftoppm) und schickt sie ans Vision-Modell.
|
||||||
|
* Fallback auf pdftotext wenn Vision nicht verfügbar oder fehlschlägt.
|
||||||
|
*/
|
||||||
|
private function extractPdfVision(string $path): string
|
||||||
|
{
|
||||||
|
$tmpDir = sys_get_temp_dir() . '/vision_' . uniqid();
|
||||||
|
$safeTmp = escapeshellarg($tmpDir . '/page');
|
||||||
|
$safe = escapeshellarg($path);
|
||||||
|
mkdir($tmpDir, 0700, true);
|
||||||
|
|
||||||
|
// pdftoppm ist Teil von poppler-utils; auf 800px Breite begrenzen (Ollama-Limit)
|
||||||
|
exec("pdftoppm -jpeg -scale-to-x 800 -scale-to-y -1 {$safe} {$tmpDir}/page 2>/dev/null", $out, $rc);
|
||||||
|
|
||||||
|
$images = glob("$tmpDir/page*.jpg") ?: glob("$tmpDir/page*.jpeg") ?: [];
|
||||||
|
sort($images);
|
||||||
|
|
||||||
|
if ($rc !== 0 || empty($images)) {
|
||||||
|
@rmdir($tmpDir);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$base64 = [];
|
||||||
|
foreach ($images as $img) {
|
||||||
|
$base64[] = base64_encode(file_get_contents($img));
|
||||||
|
@unlink($img);
|
||||||
|
}
|
||||||
|
@rmdir($tmpDir);
|
||||||
|
|
||||||
|
$prompt = 'Extrahiere den vollstaendigen Text dieses Belegs. '
|
||||||
|
. 'Behalte die Tabellenstruktur bei (Spalten mit Leerzeichen ausrichten). '
|
||||||
|
. 'Antworte nur mit dem extrahierten Text, ohne Kommentar oder Erklaerung.';
|
||||||
|
|
||||||
|
return $this->llm->generateVision($prompt, $base64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt ein Bild (JPEG/PNG/TIFF) ans Vision-Modell.
|
||||||
|
* Skaliert auf max. 800px Breite damit der Payload nicht zu groß wird.
|
||||||
|
*/
|
||||||
|
private function extractImageVision(string $path): string
|
||||||
|
{
|
||||||
|
$base64 = $this->resizeImageBase64($path, 800);
|
||||||
|
if ($base64 === '') return '';
|
||||||
|
|
||||||
|
$prompt = 'Extrahiere den vollstaendigen Text dieses Belegs. '
|
||||||
|
. 'Behalte die Tabellenstruktur bei. '
|
||||||
|
. 'Antworte nur mit dem extrahierten Text, ohne Kommentar oder Erklaerung.';
|
||||||
|
|
||||||
|
return $this->llm->generateVision($prompt, [$base64]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest ein Bild, skaliert es auf maxWidth Pixel Breite und gibt es als base64-JPEG zurück.
|
||||||
|
*/
|
||||||
|
private function resizeImageBase64(string $path, int $maxWidth): string
|
||||||
|
{
|
||||||
|
// pdftoppm erzeugt JPEG; ImageCropService verwendet GD — GD ist im Container verfügbar
|
||||||
|
$info = @getimagesize($path);
|
||||||
|
if (!$info) return '';
|
||||||
|
|
||||||
|
[$origW, $origH, $type] = $info;
|
||||||
|
|
||||||
|
$src = match ($type) {
|
||||||
|
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
|
||||||
|
IMAGETYPE_PNG => @imagecreatefrompng($path),
|
||||||
|
IMAGETYPE_TIFF_II, IMAGETYPE_TIFF_MM => null, // GD unterstützt kein TIFF
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!$src) {
|
||||||
|
// Kein GD-Support (z. B. TIFF) → direkt senden, auch wenn groß
|
||||||
|
return base64_encode(file_get_contents($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($origW <= $maxWidth) {
|
||||||
|
// Bereits klein genug → direkt als JPEG-Base64
|
||||||
|
ob_start();
|
||||||
|
imagejpeg($src, null, 85);
|
||||||
|
$jpeg = ob_get_clean();
|
||||||
|
imagedestroy($src);
|
||||||
|
return base64_encode($jpeg);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scale = $maxWidth / $origW;
|
||||||
|
$newW = $maxWidth;
|
||||||
|
$newH = (int)round($origH * $scale);
|
||||||
|
$dst = imagecreatetruecolor($newW, $newH);
|
||||||
|
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
|
||||||
|
imagedestroy($src);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
imagejpeg($dst, null, 85);
|
||||||
|
$jpeg = ob_get_clean();
|
||||||
|
imagedestroy($dst);
|
||||||
|
|
||||||
|
return base64_encode($jpeg);
|
||||||
|
}
|
||||||
|
|
||||||
private function extractPdfText(string $path): string
|
private function extractPdfText(string $path): string
|
||||||
{
|
{
|
||||||
$safe = escapeshellarg($path);
|
$safe = escapeshellarg($path);
|
||||||
$output = [];
|
$output = [];
|
||||||
exec("pdftotext -l 1 {$safe} -", $output, $returnCode);
|
exec("pdftotext {$safe} -", $output, $returnCode);
|
||||||
return $returnCode === 0 ? implode("\n", $output) : '';
|
return $returnCode === 0 ? implode("\n", $output) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function extractImageText(string $path): string
|
// -------------------------------------------------------------------------
|
||||||
{
|
// JSON-Parsing & Validierung
|
||||||
$tmpBase = sys_get_temp_dir() . '/ocr_' . uniqid();
|
// -------------------------------------------------------------------------
|
||||||
$safe = escapeshellarg($path);
|
|
||||||
$safeTmp = escapeshellarg($tmpBase);
|
|
||||||
exec("tesseract {$safe} {$safeTmp} -l deu+eng quiet 2>/dev/null", $out, $rc);
|
|
||||||
$txtFile = $tmpBase . '.txt';
|
|
||||||
if ($rc === 0 && file_exists($txtFile)) {
|
|
||||||
$text = file_get_contents($txtFile);
|
|
||||||
unlink($txtFile);
|
|
||||||
return $text ?: '';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst die LLM-Antwort als JSON-Array von Buchungszeilen.
|
||||||
|
* Akzeptiert auch ein einzelnes JSON-Objekt (Backward-Compat) und wickelt es in ein Array.
|
||||||
|
* Gibt validiertes Array zurück, oder null bei Parse-/Validierungsfehler.
|
||||||
|
*/
|
||||||
private function parseAndValidate(string $response, array $accounts): ?array
|
private function parseAndValidate(string $response, array $accounts): ?array
|
||||||
{
|
{
|
||||||
// JSON aus Antwort extrahieren (Modell könnte Prosa voranstellen)
|
// Array-Form: [{ ... }, { ... }]
|
||||||
if (!preg_match('/\{[^{}]*\}/s', $response, $m)) return null;
|
if (preg_match('/\[.*\]/s', $response, $m)) {
|
||||||
|
|
||||||
$data = json_decode($m[0], true);
|
$data = json_decode($m[0], true);
|
||||||
if (!is_array($data)) return null;
|
if (is_array($data) && isset($data[0]) && is_array($data[0])) {
|
||||||
|
return $this->validateLines($data, $accounts);
|
||||||
$required = ['buchungstyp', 'kategorie_konto_id', 'zahlungskonto_id', 'ust_satz', 'betrag_netto'];
|
}
|
||||||
foreach ($required as $field) {
|
|
||||||
if (!isset($data[$field])) return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Werte normalisieren
|
// Fallback: einzelnes Objekt { ... } → in Array einwickeln
|
||||||
|
if (preg_match('/\{[^{}]*\}/s', $response, $m)) {
|
||||||
|
$data = json_decode($m[0], true);
|
||||||
|
if (is_array($data)) {
|
||||||
|
return $this->validateLines([$data], $accounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert und normalisiert ein Array von Buchungszeilen.
|
||||||
|
* Gibt nur gültige Zeilen zurück; null wenn keine einzige valide ist.
|
||||||
|
*/
|
||||||
|
private function validateLines(array $lines, array $accounts): ?array
|
||||||
|
{
|
||||||
|
$kategorieIds = array_column(array_filter($accounts,
|
||||||
|
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
|
||||||
|
), 'id');
|
||||||
|
$zahlungsIds = array_column(array_filter($accounts,
|
||||||
|
fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true)
|
||||||
|
), 'id');
|
||||||
|
|
||||||
|
$valid = [];
|
||||||
|
foreach ($lines as $data) {
|
||||||
|
if (!is_array($data)) continue;
|
||||||
|
|
||||||
|
$required = ['buchungstyp', 'kategorie_konto_id', 'zahlungskonto_id', 'ust_satz', 'betrag_netto'];
|
||||||
|
$ok = true;
|
||||||
|
foreach ($required as $field) {
|
||||||
|
if (!isset($data[$field])) { $ok = false; break; }
|
||||||
|
}
|
||||||
|
if (!$ok) continue;
|
||||||
|
|
||||||
|
// Normalisieren
|
||||||
$data['buchungstyp'] = in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) ? $data['buchungstyp'] : 'Ausgabe';
|
$data['buchungstyp'] = in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) ? $data['buchungstyp'] : 'Ausgabe';
|
||||||
$data['kategorie_konto_id'] = (int)$data['kategorie_konto_id'];
|
$data['kategorie_konto_id'] = (int)$data['kategorie_konto_id'];
|
||||||
$data['zahlungskonto_id'] = (int)$data['zahlungskonto_id'];
|
$data['zahlungskonto_id'] = (int)$data['zahlungskonto_id'];
|
||||||
|
|
@ -162,15 +354,9 @@ PROMPT;
|
||||||
$data['buchungstext'] = (string)($data['buchungstext'] ?? '');
|
$data['buchungstext'] = (string)($data['buchungstext'] ?? '');
|
||||||
$data['supplier_name'] = trim((string)($data['supplier_name'] ?? ''));
|
$data['supplier_name'] = trim((string)($data['supplier_name'] ?? ''));
|
||||||
|
|
||||||
// IDs gegen Kontenliste validieren (mit Kontenartenprüfung)
|
// Konto-IDs validieren
|
||||||
$kategorieIds = array_column(array_filter($accounts,
|
if (!in_array($data['kategorie_konto_id'], $kategorieIds, false)) continue;
|
||||||
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
|
if (!in_array($data['zahlungskonto_id'], $zahlungsIds, false)) continue;
|
||||||
), 'id');
|
|
||||||
$zahlungsIds = array_column(array_filter($accounts,
|
|
||||||
fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true)
|
|
||||||
), 'id');
|
|
||||||
if (!in_array($data['kategorie_konto_id'], $kategorieIds, false)) return null;
|
|
||||||
if (!in_array($data['zahlungskonto_id'], $zahlungsIds, false)) return null;
|
|
||||||
|
|
||||||
// Lieferant gegen vorhandene Suppliers prüfen
|
// Lieferant gegen vorhandene Suppliers prüfen
|
||||||
if ($data['supplier_name'] !== '') {
|
if ($data['supplier_name'] !== '') {
|
||||||
|
|
@ -180,6 +366,9 @@ PROMPT;
|
||||||
$data['supplier_found'] = null;
|
$data['supplier_found'] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
$valid[] = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return empty($valid) ? null : $valid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@
|
||||||
<span style="font-weight:600">Automatischen Kontierungsvorschlag erzeugen</span>
|
<span style="font-weight:600">Automatischen Kontierungsvorschlag erzeugen</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-muted text-sm" style="margin:.5rem 0 .75rem 1.75rem">
|
<p class="text-muted text-sm" style="margin:.5rem 0 .75rem 1.75rem">
|
||||||
Beim ersten Öffnen eines Belegs wird ein LLM befragt und das Buchungsformular vorausgefüllt.
|
Beim automatischen Nextcloud-Import wird für jeden Beleg ein Kontierungsvorschlag per LLM erzeugt.
|
||||||
Konfiguration via <code>.env</code> (lokales Ollama oder Remote-API).
|
Konfiguration via <code>.env</code> (lokales Ollama oder Remote-API).
|
||||||
</p>
|
</p>
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -153,23 +153,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OCR-Texterkennung -->
|
|
||||||
<div class="card mb-2">
|
|
||||||
<div class="card-header"><div class="card-title">OCR-Texterkennung</div></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;user-select:none">
|
|
||||||
<input type="checkbox" name="ocr_aktiv" value="1"
|
|
||||||
<?= !empty($settings['ocr_aktiv']) ? 'checked' : '' ?>>
|
|
||||||
<span style="font-weight:600">Automatische Texterkennung bei Bildbelegen aktivieren</span>
|
|
||||||
</label>
|
|
||||||
<p class="text-muted text-sm" style="margin:.5rem 0 0 1.75rem">
|
|
||||||
Bei Belegen im Bildformat (JPEG, PNG, TIFF) wird Tesseract OCR eingesetzt, um den Belegtext
|
|
||||||
zu extrahieren. Dieser Text wird – sofern der KI-Schalter aktiv ist – an das LLM übergeben.
|
|
||||||
PDF-Belege werden unabhängig davon per <code>pdftotext</code> ausgelesen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nextcloud-Import -->
|
<!-- Nextcloud-Import -->
|
||||||
<div class="card mb-2">
|
<div class="card mb-2">
|
||||||
<div class="card-header"><div class="card-title">Nextcloud-Belegimport</div></div>
|
<div class="card-header"><div class="card-title">Nextcloud-Belegimport</div></div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue