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
|
||||
|
||||
Kein API-Schlüssel nötig, aber hoher Ressourcenbedarf:
|
||||
|
||||
```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:
|
||||
|
||||
```dotenv
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=mistral
|
||||
OLLAMA_MODEL=qwen2.5vl
|
||||
```
|
||||
|
||||
### Option B – Remote-API (OpenAI-kompatibel)
|
||||
|
|
@ -49,7 +56,7 @@ LLM_API_KEY=sk-...
|
|||
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=?,
|
||||
ustva_steuernummer=?, ustva_bundesland=?,
|
||||
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_folder=?, nextcloud_done=?,
|
||||
logo_max_width=?, logo_max_height=?,
|
||||
|
|
@ -61,7 +61,6 @@ class Setting
|
|||
(int)($data['payment_days_default'] ?? 14),
|
||||
isset($data['test_mode']) ? 1 : 0,
|
||||
isset($data['ki_vorschlag_aktiv']) ? 1 : 0,
|
||||
isset($data['ocr_aktiv']) ? 1 : 0,
|
||||
$data['nextcloud_url'] ?: null,
|
||||
$data['nextcloud_user'] ?: null,
|
||||
$data['nextcloud_pass'] ?: null,
|
||||
|
|
|
|||
|
|
@ -24,12 +24,6 @@ class KontierungsvorschlagService
|
|||
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
|
||||
{
|
||||
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||
|
|
@ -38,14 +32,33 @@ class KontierungsvorschlagService
|
|||
$mime = strtolower($beleg['mime_type'] ?? '');
|
||||
|
||||
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);
|
||||
} 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 '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$text = $this->extractText($beleg);
|
||||
|
|
@ -61,7 +74,7 @@ class KontierungsvorschlagService
|
|||
}
|
||||
|
||||
// Konten laden (max. 50 relevante Konten für kurzen Prompt)
|
||||
$allAccounts = Account::all(['aktiv' => 1]);
|
||||
$allAccounts = Account::all(['aktiv' => 1]);
|
||||
$kategorieKonten = array_slice(array_values(array_filter(
|
||||
$allAccounts,
|
||||
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
|
||||
|
|
@ -82,20 +95,20 @@ class KontierungsvorschlagService
|
|||
));
|
||||
|
||||
$beleginhalt = trim($text) !== ''
|
||||
? "Belegtext:\n" . mb_substr(trim($text), 0, 2000)
|
||||
? "Belegtext:\n" . mb_substr(trim($text), 0, 3000)
|
||||
: '[Kein Text extrahierbar]';
|
||||
|
||||
$settings = Setting::get();
|
||||
$eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? ''));
|
||||
$settings = Setting::get();
|
||||
$eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? ''));
|
||||
$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: 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/Verkaeufer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar.";
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
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:
|
||||
{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":19,"betrag_netto":33.64,"buchungstext":"Rechnung 2026-001","supplier_name":"Mustermann GmbH"}
|
||||
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":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:
|
||||
- buchungstyp: "Ausgabe" oder "Einnahme"
|
||||
|
|
@ -117,69 +130,245 @@ PROMPT;
|
|||
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
|
||||
{
|
||||
$safe = escapeshellarg($path);
|
||||
$safe = escapeshellarg($path);
|
||||
$output = [];
|
||||
exec("pdftotext -l 1 {$safe} -", $output, $returnCode);
|
||||
exec("pdftotext {$safe} -", $output, $returnCode);
|
||||
return $returnCode === 0 ? implode("\n", $output) : '';
|
||||
}
|
||||
|
||||
private function extractImageText(string $path): string
|
||||
{
|
||||
$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 '';
|
||||
}
|
||||
// -------------------------------------------------------------------------
|
||||
// JSON-Parsing & Validierung
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
// JSON aus Antwort extrahieren (Modell könnte Prosa voranstellen)
|
||||
if (!preg_match('/\{[^{}]*\}/s', $response, $m)) return null;
|
||||
|
||||
$data = json_decode($m[0], true);
|
||||
if (!is_array($data)) return null;
|
||||
|
||||
$required = ['buchungstyp', 'kategorie_konto_id', 'zahlungskonto_id', 'ust_satz', 'betrag_netto'];
|
||||
foreach ($required as $field) {
|
||||
if (!isset($data[$field])) return null;
|
||||
// Array-Form: [{ ... }, { ... }]
|
||||
if (preg_match('/\[.*\]/s', $response, $m)) {
|
||||
$data = json_decode($m[0], true);
|
||||
if (is_array($data) && isset($data[0]) && is_array($data[0])) {
|
||||
return $this->validateLines($data, $accounts);
|
||||
}
|
||||
}
|
||||
|
||||
// Werte normalisieren
|
||||
$data['buchungstyp'] = in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) ? $data['buchungstyp'] : 'Ausgabe';
|
||||
$data['kategorie_konto_id'] = (int)$data['kategorie_konto_id'];
|
||||
$data['zahlungskonto_id'] = (int)$data['zahlungskonto_id'];
|
||||
$data['ust_satz'] = in_array((int)$data['ust_satz'], [0, 7, 19], true) ? (int)$data['ust_satz'] : 0;
|
||||
$data['betrag_netto'] = round((float)$data['betrag_netto'], 2);
|
||||
$data['buchungstext'] = (string)($data['buchungstext'] ?? '');
|
||||
$data['supplier_name'] = trim((string)($data['supplier_name'] ?? ''));
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// IDs gegen Kontenliste validieren (mit Kontenartenprüfung)
|
||||
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');
|
||||
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
|
||||
if ($data['supplier_name'] !== '') {
|
||||
$existing = Supplier::findByName($data['supplier_name']);
|
||||
$data['supplier_found'] = $existing !== false;
|
||||
} else {
|
||||
$data['supplier_found'] = null;
|
||||
$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['kategorie_konto_id'] = (int)$data['kategorie_konto_id'];
|
||||
$data['zahlungskonto_id'] = (int)$data['zahlungskonto_id'];
|
||||
$data['ust_satz'] = in_array((int)$data['ust_satz'], [0, 7, 19], true) ? (int)$data['ust_satz'] : 0;
|
||||
$data['betrag_netto'] = round((float)$data['betrag_netto'], 2);
|
||||
$data['buchungstext'] = (string)($data['buchungstext'] ?? '');
|
||||
$data['supplier_name'] = trim((string)($data['supplier_name'] ?? ''));
|
||||
|
||||
// Konto-IDs validieren
|
||||
if (!in_array($data['kategorie_konto_id'], $kategorieIds, false)) continue;
|
||||
if (!in_array($data['zahlungskonto_id'], $zahlungsIds, false)) continue;
|
||||
|
||||
// Lieferant gegen vorhandene Suppliers prüfen
|
||||
if ($data['supplier_name'] !== '') {
|
||||
$existing = Supplier::findByName($data['supplier_name']);
|
||||
$data['supplier_found'] = $existing !== false;
|
||||
} else {
|
||||
$data['supplier_found'] = null;
|
||||
}
|
||||
|
||||
$valid[] = $data;
|
||||
}
|
||||
|
||||
return $data;
|
||||
return empty($valid) ? null : $valid;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@
|
|||
<span style="font-weight:600">Automatischen Kontierungsvorschlag erzeugen</span>
|
||||
</label>
|
||||
<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).
|
||||
</p>
|
||||
<?php
|
||||
|
|
@ -153,23 +153,6 @@
|
|||
</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 -->
|
||||
<div class="card mb-2">
|
||||
<div class="card-header"><div class="card-title">Nextcloud-Belegimport</div></div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue