diff --git a/.env.example b/.env.example index 7dcf14c..6b5bed4 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,9 @@ DB_PASS=steuerkram # OLLAMA_URL=http://localhost:11434 # OLLAMA_MODEL=llama3.2 # +# Vision-Modell für Beleglesung (multimodales Ollama-Modell, benötigt OLLAMA_URL) +# VISION_MODEL=qwen2.5vl:latest +# # Option B: Remote-LLM per OpenAI-kompatibler API (z.B. OpenAI, OpenRouter, LM Studio) # LLM_API_URL=https://api.openai.com/v1 # LLM_API_KEY=sk-... diff --git a/Changes.md b/Changes.md index 68048db..092b62a 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,14 @@ --- +## 2026-04-15 – KI-Erkennung: automatischer Aufruf aus Belegansicht entfernt + +### Geändert +- `src/Views/belege/detail.php`: KI-Analyse wird nicht mehr automatisch beim Öffnen eines Belegs ausgelöst; stattdessen wird ein Hinweis "KI-Analyse wird im Hintergrund durchgeführt …" mit einem "Jetzt generieren"-Button angezeigt, wenn noch kein Vorschlag vorhanden ist +- Hintergrund: Die Analyse läuft bereits beim Nextcloud-Import (`ki-batch.php`); ein doppelter Aufruf aus der UI war überflüssig + +--- + ## 2026-04-12 – EÜR-Korrekturen: Gewerbesteuer + Bewirtungskonten ohne VSt ### Geändert diff --git a/docs/konfiguration.md b/docs/konfiguration.md index d73d895..ff89fd0 100644 --- a/docs/konfiguration.md +++ b/docs/konfiguration.md @@ -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. --- @@ -61,7 +68,7 @@ Belege können automatisch aus einem Nextcloud-Ordner via WebDAV importiert werd Die Zugangsdaten werden in der Steuerkram-UI gepflegt: **Einstellungen → Nextcloud-Belegimport**. -Empfehlung: Ein Nextcloud-App-Passwort verwenden (Nextcloud → Einstellungen → Sicherheit → App-Passwörter). +Hinweis: Ein Nextcloud-App-Passwort verwenden (Nextcloud → Einstellungen → Sicherheit → App-Passwörter). ### Manueller Aufruf diff --git a/docs/update-backup.md b/docs/update-backup.md index 6a500ca..3e45610 100644 --- a/docs/update-backup.md +++ b/docs/update-backup.md @@ -36,10 +36,10 @@ Beide Pfade liegen außerhalb des Containers und bleiben bei `docker compose dow ## Update -Für ein vollständiges Update steht `update.sh` im Projektwurzelverzeichnis bereit. Es führt alle notwendigen Schritte automatisch aus. +Für ein vollständiges Update steht `scripts/update.sh` bereit. Es führt alle notwendigen Schritte automatisch aus. ```bash -./update.sh +./scripts/update.sh ``` Das Script erledigt in dieser Reihenfolge: diff --git a/scripts/backup.sh b/scripts/backup.sh index b84a5ab..518a467 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -14,7 +14,10 @@ set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" BACKUP_DIR="$PROJECT_DIR/backups" -LOCK_FILE="/tmp/steuerkram-backup.lock" +LOCK_FILE="$BACKUP_DIR/.backup.lock" + +# Backup-Verzeichnis sicherstellen, bevor Lock erworben wird +mkdir -p "$BACKUP_DIR" # Verhindert mehrfachen gleichzeitigen Start (z.B. bei Verzögerung durch langen Backup-Lauf) exec 9>"$LOCK_FILE" @@ -24,7 +27,6 @@ if ! flock -n 9; then fi TIMESTAMP=$(date +%Y%m%d_%H%M%S) -mkdir -p "$BACKUP_DIR" echo "$(date '+%Y-%m-%d %H:%M:%S') Erstelle Backup $TIMESTAMP..." diff --git a/scripts/ki-batch.php b/scripts/ki-batch.php new file mode 100644 index 0000000..114b454 --- /dev/null +++ b/scripts/ki-batch.php @@ -0,0 +1,104 @@ +> /var/log/steuerkram-ki.log 2>&1 + */ + +declare(strict_types=1); + +chdir(dirname(__DIR__)); + +// DB-Zugangsdaten aus .env laden (für CLI-Kontext ohne Apache-Umgebung) +if (file_exists('.env')) { + foreach (file('.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $key = trim($key); + $value = trim($value, " \t\n\r\0\x0B\"'"); + if (!array_key_exists($key, $_ENV)) { + $_ENV[$key] = $value; + putenv("{$key}={$value}"); + } + } +} + +require_once 'vendor/autoload.php'; + +use Steuerkram\Config; +use Steuerkram\Database; +use Steuerkram\Models\Beleg; +use Steuerkram\Models\Setting; +use Steuerkram\Services\KontierungsvorschlagService; + +Config::load(); + +$tag = '[ki-batch]'; + +// KI-Erkennung in den Einstellungen aktiv? +$settings = Setting::get(); +if (empty($settings['ki_vorschlag_aktiv'])) { + echo "{$tag} KI-Erkennung ist in den Einstellungen deaktiviert – nichts zu tun." . PHP_EOL; + exit(0); +} + +$svc = new KontierungsvorschlagService(); + +if (!$svc->isEnabled()) { + echo "{$tag} LLM nicht erreichbar – Abbruch." . PHP_EOL; + exit(1); +} + +// Optionale Beleg-ID als CLI-Argument: php ki-batch.php 123 +$argId = isset($argv[1]) && ctype_digit((string)$argv[1]) ? (int)$argv[1] : null; + +// Beleg holen: wenn ID angegeben, diesen direkt verarbeiten; sonst ältesten ausstehenden +$row = $argId !== null + ? Database::fetchOne('SELECT id FROM belege WHERE id = ? AND ki_vorschlag IS NULL AND locked = 0', [$argId]) + : Database::fetchOne('SELECT id FROM belege WHERE ki_vorschlag IS NULL AND locked = 0 ORDER BY id ASC LIMIT 1'); + +if (!$row) { + echo "{$tag} Kein ausstehender Beleg gefunden." . PHP_EOL; + exit(0); +} + +$belegId = (int)$row['id']; +$beleg = Beleg::find($belegId); + +if (!$beleg) { + echo "{$tag} Beleg #{$belegId} nicht ladbar – Abbruch." . PHP_EOL; + exit(1); +} + +echo "{$tag} Verarbeite Beleg #{$belegId} ({$beleg['beleg_nr']}) ..." . PHP_EOL; + +$start = microtime(true); +$result = $svc->generateForBeleg($beleg); + +if ($result === null) { + echo "{$tag} Beleg #{$belegId}: Kein Vorschlag generiert (nicht unterstützter Typ oder LLM-Fehler)." . PHP_EOL; + // Leeres Array speichern damit der Beleg nicht erneut verarbeitet wird + Beleg::saveKiVorschlag($belegId, []); + exit(0); +} + +Beleg::saveKiVorschlag($belegId, $result); + +$elapsed = round(microtime(true) - $start, 1); +$zeilen = count($result); +echo "{$tag} Beleg #{$belegId}: {$zeilen} Buchungszeile(n) generiert in {$elapsed}s." . PHP_EOL; + +exit(0); diff --git a/scripts/nextcloud-import.php b/scripts/nextcloud-import.php index 89964d8..11c7107 100644 --- a/scripts/nextcloud-import.php +++ b/scripts/nextcloud-import.php @@ -41,6 +41,7 @@ if (file_exists('.env')) { require_once 'vendor/autoload.php'; use Steuerkram\Config; +use Steuerkram\Models\Setting; use Steuerkram\Services\NextcloudImportService; Config::load(); // Database verbindet sich lazy über Config::get() @@ -64,4 +65,16 @@ echo sprintf( $result['errors'], ); +// KI-Vorschlag für jeden neu importierten Beleg anstoßen (wenn KI-Erkennung aktiv) +if (!empty($result['imported_ids'])) { + $settings = Setting::get(); + if (!empty($settings['ki_vorschlag_aktiv'])) { + $kiBatch = __DIR__ . '/ki-batch.php'; + echo '[nextcloud-import] Starte KI-Erkennung für ' . count($result['imported_ids']) . ' importierte(n) Beleg(e) ...' . PHP_EOL; + foreach ($result['imported_ids'] as $belegId) { + passthru('php ' . escapeshellarg($kiBatch) . ' ' . (int)$belegId . ' 2>&1'); + } + } +} + exit($result['errors'] > 0 ? 1 : 0); diff --git a/update.sh b/scripts/update.sh similarity index 99% rename from update.sh rename to scripts/update.sh index ad693db..982e42e 100755 --- a/update.sh +++ b/scripts/update.sh @@ -41,7 +41,7 @@ done # ── Pfade ──────────────────────────────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$SCRIPT_DIR" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" cd "$PROJECT_DIR" # ── Farben ─────────────────────────────────────────────────────────────────── diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index 8a4448d..9e52d80 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -204,6 +204,8 @@ class BelegController $kiVorschlag = $beleg['ki_vorschlag'] !== null ? json_decode($beleg['ki_vorschlag'], true) : null; + // KI-Sektion anzeigen wenn Ergebnis vorhanden ODER LLM gerade verfügbar + $kiSektion = $kiAktiv || $kiVorschlag !== null; $view = new View(); $view->render('layout/base.php', [ @@ -215,6 +217,7 @@ class BelegController 'integrity' => $integrity, 'accounts' => $accounts, 'kiAktiv' => $kiAktiv, + 'kiSektion' => $kiSektion, 'kiVorschlag' => $kiVorschlag, ]), ]); diff --git a/src/Models/Setting.php b/src/Models/Setting.php index c112139..e7e3356 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -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, diff --git a/src/Services/KontierungsvorschlagService.php b/src/Services/KontierungsvorschlagService.php index 944c132..27a6dc7 100644 --- a/src/Services/KontierungsvorschlagService.php +++ b/src/Services/KontierungsvorschlagService.php @@ -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 = <<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; } } diff --git a/src/Services/LlmService.php b/src/Services/LlmService.php index eb34f28..22b9007 100644 --- a/src/Services/LlmService.php +++ b/src/Services/LlmService.php @@ -15,10 +15,11 @@ namespace Steuerkram\Services; */ class LlmService { - private ?string $mode = null; // 'ollama' | 'openai' | null - private string $url = ''; - private string $model = ''; - private ?string $apiKey = null; + private ?string $mode = null; // 'ollama' | 'openai' | null + private string $url = ''; + private string $model = ''; + private string $visionModel = ''; + private ?string $apiKey = null; public function __construct() { @@ -30,6 +31,11 @@ class LlmService return $this->mode !== null; } + public function isVisionAvailable(): bool + { + return $this->mode === 'ollama'; + } + public function getProviderLabel(): ?string { return match($this->mode) { @@ -48,6 +54,45 @@ class LlmService }; } + /** + * Sendet Bilder (base64-kodiert) zusammen mit einem Prompt an das Vision-Modell. + * Nur im Ollama-Modus verfügbar. Gibt '' zurück wenn Vision nicht verfügbar. + * + * @param string $prompt + * @param string[] $base64Images Array von base64-kodierten Bildern (JPEG/PNG) + */ + public function generateVision(string $prompt, array $base64Images): string + { + if ($this->mode !== 'ollama' || empty($base64Images)) return ''; + + $payload = [ + 'model' => $this->visionModel, + 'messages' => [[ + 'role' => 'user', + 'content' => $prompt, + 'images' => $base64Images, + ]], + 'stream' => false, + 'options' => ['temperature' => 0.1], + ]; + + $ch = curl_init($this->url . '/api/chat'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 300, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_POSTFIELDS => json_encode($payload), + ]); + $response = curl_exec($ch); + $err = curl_error($ch); + curl_close($ch); + + if ($err || !$response) return ''; + $data = json_decode($response, true); + return $data['message']['content'] ?? ''; + } + private function detect(): void { $ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? ''); @@ -55,9 +100,10 @@ class LlmService $apiKey = getenv('LLM_API_KEY') ?: ($_ENV['LLM_API_KEY'] ?? ''); if ($ollamaUrl !== '') { - $this->mode = 'ollama'; - $this->url = rtrim($ollamaUrl, '/'); - $this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2'); + $this->mode = 'ollama'; + $this->url = rtrim($ollamaUrl, '/'); + $this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2'); + $this->visionModel = getenv('VISION_MODEL') ?: ($_ENV['VISION_MODEL'] ?? 'qwen2.5vl:latest'); return; } diff --git a/src/Services/NextcloudImportService.php b/src/Services/NextcloudImportService.php index 5d48d9b..6c87641 100644 --- a/src/Services/NextcloudImportService.php +++ b/src/Services/NextcloudImportService.php @@ -69,7 +69,7 @@ class NextcloudImportService */ public function import(): array { - $result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => []]; + $result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [], 'imported_ids' => []]; if (!$this->baseUrl || !$this->user || !$this->pass) { $result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.'; @@ -262,7 +262,7 @@ class NextcloudImportService // In Datenbank anlegen $today = date('Y-m-d'); - Beleg::create([ + $belegId = Beleg::create([ 'beleg_nr' => $belegNr, 'filename' => $filename, 'storage_path' => $storagePath, @@ -280,6 +280,7 @@ class NextcloudImportService $result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}"; $result['imported']++; + $result['imported_ids'][] = $belegId; } /** diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index a37633e..1cda943 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -271,7 +271,7 @@ $suppliers = $suppliers ?? []; - +
@@ -630,9 +630,10 @@ function updateNettoAnzeige() { } - + diff --git a/src/Views/layout/nav.php b/src/Views/layout/nav.php index ba4433a..3003041 100644 --- a/src/Views/layout/nav.php +++ b/src/Views/layout/nav.php @@ -5,7 +5,7 @@ function navActive(string $prefix, string $current): string { } ?>