diff --git a/.env.example b/.env.example index 03ee8f8..9c0fad0 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,15 @@ DB_PASS=steuerkram # App APP_SECRET=change-this-to-a-random-string + +# KI-Kontierungsvorschlag (optional) +# Wenn nichts konfiguriert wird, sucht die App automatisch nach einem lokalen Ollama auf localhost:11434. +# +# Option A: Lokales Ollama mit expliziter URL +# OLLAMA_URL=http://localhost:11434 +# OLLAMA_MODEL=llama3.2 +# +# 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-... +# LLM_MODEL=gpt-4o-mini diff --git a/Dockerfile b/Dockerfile index 995de3c..547bf72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y \ zip \ unzip \ curl \ + poppler-utils \ && docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install pdo pdo_mysql zip gd \ && apt-get clean && rm -rf /var/lib/apt/lists/* diff --git a/migrations/011_beleg_ki_vorschlag.sql b/migrations/011_beleg_ki_vorschlag.sql new file mode 100644 index 0000000..4e57440 --- /dev/null +++ b/migrations/011_beleg_ki_vorschlag.sql @@ -0,0 +1,11 @@ +-- KI-Kontierungsvorschlag: Spalten in belege +ALTER TABLE belege + ADD COLUMN IF NOT EXISTS ki_vorschlag JSON NULL, + ADD COLUMN IF NOT EXISTS ki_vorschlag_at TIMESTAMP NULL; + +-- KI-Einstellung in company_settings +ALTER TABLE company_settings + ADD COLUMN IF NOT EXISTS ki_vorschlag_aktiv TINYINT(1) NOT NULL DEFAULT 0 + COMMENT '1 = automatischer Kontierungsvorschlag per LLM aktiv'; + +UPDATE company_settings SET ki_vorschlag_aktiv = 0 WHERE id = 1; diff --git a/public/index.php b/public/index.php index a3c4031..344e4f0 100644 --- a/public/index.php +++ b/public/index.php @@ -51,6 +51,8 @@ $router->get('/belege/{id}', fn($p) => (new BelegController())->show($p)); $router->get('/belege/{id}/file', fn($p) => (new BelegController())->serveFile($p)); $router->post('/belege/{id}/delete', fn($p) => (new BelegController())->destroy($p)); $router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($p)); +$router->post('/belege/{id}/vorschlag/generate', fn($p) => (new BelegController())->generateVorschlag($p)); +$router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController())->resetVorschlag($p)); // ── Buchungen ──────────────────────────────────────────────── $router->get('/journal', fn($p) => (new BookingController())->journal($p)); diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index 18a843b..6325251 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -10,6 +10,7 @@ use Steuerkram\Models\Account; use Steuerkram\Models\Supplier; use Steuerkram\Config; use Steuerkram\View; +use Steuerkram\Services\KontierungsvorschlagService; class BelegController { @@ -143,15 +144,23 @@ class BelegController $accounts = Account::all(['aktiv' => 1]); $suppliers = Supplier::all(); + $svc = new KontierungsvorschlagService(); + $kiAktiv = $svc->isEnabled(); + $kiVorschlag = $beleg['ki_vorschlag'] !== null + ? json_decode($beleg['ki_vorschlag'], true) + : null; + $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Beleg ' . $beleg['beleg_nr'], 'content' => $this->renderView('belege/detail', [ - 'beleg' => $beleg, - 'suppliers' => $suppliers, - 'bookings' => $bookings, - 'integrity' => $integrity, - 'accounts' => $accounts, + 'beleg' => $beleg, + 'suppliers' => $suppliers, + 'bookings' => $bookings, + 'integrity' => $integrity, + 'accounts' => $accounts, + 'kiAktiv' => $kiAktiv, + 'kiVorschlag' => $kiVorschlag, ]), ]); } @@ -210,6 +219,29 @@ class BelegController View::redirect("/belege/{$id}", 'Metadaten gespeichert.'); } + public function generateVorschlag(array $params): void + { + header('Content-Type: application/json'); + $id = (int)$params['id']; + $beleg = Beleg::find($id); + if (!$beleg) { + echo json_encode(['error' => 'Beleg nicht gefunden']); + return; + } + + $svc = new KontierungsvorschlagService(); + $vorschlag = $svc->generateForBeleg($beleg); + Beleg::saveKiVorschlag($id, $vorschlag); + echo json_encode($vorschlag ?? ['error' => 'Kein Vorschlag generiert']); + } + + public function resetVorschlag(array $params): void + { + header('Content-Type: application/json'); + Beleg::saveKiVorschlag((int)$params['id'], null); + echo json_encode(['ok' => true]); + } + public function destroy(array $params): void { $id = (int)$params['id']; diff --git a/src/Models/Beleg.php b/src/Models/Beleg.php index 31164f8..5ab34ae 100644 --- a/src/Models/Beleg.php +++ b/src/Models/Beleg.php @@ -209,6 +209,14 @@ class Beleg return $nr; } + public static function saveKiVorschlag(int $id, ?array $vorschlag): void + { + Database::execute( + 'UPDATE belege SET ki_vorschlag = ?, ki_vorschlag_at = NOW() WHERE id = ?', + [$vorschlag !== null ? json_encode($vorschlag) : null, $id] + ); + } + public static function verifyIntegrity(int $id): bool { $beleg = self::find($id); diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 8715727..675373f 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -29,7 +29,7 @@ class Setting iban=?, bic=?, bank_name=?, ustva_steuernummer=?, ustva_bundesland=?, invoice_nr_prefix=?, beleg_nr_prefix=?, - payment_days_default=?, test_mode=? + payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=? WHERE id=1', [ $data['firma'] ?? '', @@ -52,6 +52,7 @@ class Setting $data['beleg_nr_prefix'] ?? 'BLG', (int)($data['payment_days_default'] ?? 14), isset($data['test_mode']) ? 1 : 0, + isset($data['ki_vorschlag_aktiv']) ? 1 : 0, ] ); } diff --git a/src/Services/KontierungsvorschlagService.php b/src/Services/KontierungsvorschlagService.php new file mode 100644 index 0000000..1e8c564 --- /dev/null +++ b/src/Services/KontierungsvorschlagService.php @@ -0,0 +1,130 @@ +llm = new LlmService(); + } + + public function isEnabled(): bool + { + $s = Setting::get(); + return (bool)($s['ki_vorschlag_aktiv'] ?? 0) && $this->llm->isAvailable(); + } + + public function generateForBeleg(array $beleg): ?array + { + $absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; + if (!file_exists($absPath)) return null; + + // Inhalt extrahieren + $base64Image = null; + $mime = strtolower($beleg['mime_type'] ?? ''); + + if ($mime === 'application/pdf') { + $text = $this->extractPdfText($absPath); + } elseif (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) { + $base64Image = base64_encode(file_get_contents($absPath)); + $text = null; + } else { + return null; + } + + // Konten laden + $allAccounts = Account::all(['aktiv' => 1]); + $kategorieKonten = array_filter( + $allAccounts, + fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true) + ); + $zahlungsKonten = array_filter( + $allAccounts, + fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true) + ); + + // Kontenliste für Prompt aufbauen + $kategorieList = implode("\n", array_map( + fn($a) => " {$a['id']} – {$a['kontonummer']} – {$a['bezeichnung']} ({$a['kontenart']})", + $kategorieKonten + )); + $zahlungsList = implode("\n", array_map( + fn($a) => " {$a['id']} – {$a['kontonummer']} – {$a['bezeichnung']}", + $zahlungsKonten + )); + + $beleginhalt = $text !== null && trim($text) !== '' + ? "Belegtext:\n" . mb_substr(trim($text), 0, 3000) + : '[Bilddatei – Inhalt wird direkt analysiert]'; + + $prompt = <<llm->generate($prompt, $base64Image); + if ($response === '') return null; + + return $this->parseAndValidate($response, $allAccounts); + } + + private function extractPdfText(string $path): string + { + $safe = escapeshellarg($path); + $output = []; + exec("pdftotext -l 1 {$safe} -", $output, $returnCode); + return $returnCode === 0 ? implode("\n", $output) : ''; + } + + 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; + } + + // 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'] ?? ''); + + // IDs gegen Kontenliste validieren + $accountIds = array_column($accounts, 'id'); + if (!in_array($data['kategorie_konto_id'], $accountIds, false)) return null; + if (!in_array($data['zahlungskonto_id'], $accountIds, false)) return null; + + return $data; + } +} diff --git a/src/Services/LlmService.php b/src/Services/LlmService.php new file mode 100644 index 0000000..99ed773 --- /dev/null +++ b/src/Services/LlmService.php @@ -0,0 +1,158 @@ +detect(); + } + + public function isAvailable(): bool + { + return $this->mode !== null; + } + + public function getProviderLabel(): ?string + { + return match($this->mode) { + 'ollama' => 'Ollama (' . $this->url . ')', + 'openai' => 'Remote-API (' . $this->url . ')', + default => null, + }; + } + + public function generate(string $prompt, ?string $base64Image = null): string + { + return match($this->mode) { + 'ollama' => $this->callOllama($prompt, $base64Image), + 'openai' => $this->callOpenAi($prompt, $base64Image), + default => '', + }; + } + + private function detect(): void + { + $ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? ''); + $apiUrl = getenv('LLM_API_URL') ?: ($_ENV['LLM_API_URL'] ?? ''); + $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'); + return; + } + + if ($apiUrl !== '' && $apiKey !== '') { + $this->mode = 'openai'; + $this->url = rtrim($apiUrl, '/'); + $this->model = getenv('LLM_MODEL') ?: ($_ENV['LLM_MODEL'] ?? 'gpt-4o-mini'); + $this->apiKey = $apiKey; + return; + } + + // Auto-Discovery: lokales Ollama auf Port 11434 + if ($this->probeOllama('http://localhost:11434')) { + $this->mode = 'ollama'; + $this->url = 'http://localhost:11434'; + $this->model = 'llama3.2'; + } + } + + private function probeOllama(string $baseUrl): bool + { + $ch = curl_init($baseUrl . '/api/tags'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 2, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_NOBODY => false, + ]); + curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_errno($ch); + curl_close($ch); + return $err === 0 && $code >= 200 && $code < 300; + } + + private function callOllama(string $prompt, ?string $base64Image): string + { + $payload = [ + 'model' => $this->model, + 'prompt' => $prompt, + 'stream' => false, + ]; + if ($base64Image !== null) { + $payload['images'] = [$base64Image]; + } + + $ch = curl_init($this->url . '/api/generate'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 120, + 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 ''; + return json_decode($response, true)['response'] ?? ''; + } + + private function callOpenAi(string $prompt, ?string $base64Image): string + { + $content = []; + if ($base64Image !== null) { + $content[] = [ + 'type' => 'image_url', + 'image_url' => ['url' => 'data:image/jpeg;base64,' . $base64Image], + ]; + } + $content[] = ['type' => 'text', 'text' => $prompt]; + + $payload = [ + 'model' => $this->model, + 'messages' => [['role' => 'user', 'content' => $content]], + ]; + + $ch = curl_init($this->url . '/chat/completions'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 120, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $this->apiKey, + ], + 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['choices'][0]['message']['content'] ?? ''; + } +} diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index eba0818..610de12 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -247,6 +247,11 @@ $suppliers = $suppliers ?? []; + + +
+ + -
- +
+ + + 30% Eigenanteil buchen +
@@ -497,3 +503,73 @@ function updateSteuerartVisibility() { updateEigenanteilState(); } + + + + diff --git a/src/Views/settings/form.php b/src/Views/settings/form.php index 8893cfe..6a1b3f0 100644 --- a/src/Views/settings/form.php +++ b/src/Views/settings/form.php @@ -128,6 +128,33 @@
+ +
+
KI-Kontierungsvorschlag
+
+ +

+ Beim ersten Öffnen eines Belegs wird ein LLM befragt und das Buchungsformular vorausgefüllt. + Konfiguration via .env (lokales Ollama oder Remote-API). +

+ getProviderLabel(); + ?> +

+ + ✓ Provider erkannt: + + — Kein LLM-Provider konfiguriert oder erreichbar (Auto-Discovery localhost:11434 fehlgeschlagen) + +

+
+
+
Bankdaten
diff --git a/tests/11_KontierungsvorschlagTest.php b/tests/11_KontierungsvorschlagTest.php new file mode 100644 index 0000000..3b0df11 --- /dev/null +++ b/tests/11_KontierungsvorschlagTest.php @@ -0,0 +1,73 @@ +assert(!$svc->isEnabled(), 'ki_vorschlag_aktiv sollte per Default 0 sein'); + + // 2. saveKiVorschlag speichert und liest korrekt zurück + $belegId = $this->createTestBeleg(); + + $vorschlag = [ + 'buchungstyp' => 'Ausgabe', + 'kategorie_konto_id' => 42, + 'zahlungskonto_id' => 10, + 'ust_satz' => 19, + 'betrag_netto' => 33.64, + 'buchungstext' => 'Testrechnung', + ]; + Beleg::saveKiVorschlag($belegId, $vorschlag); + + $beleg = Beleg::find($belegId); + $this->assertNotEmpty($beleg['ki_vorschlag'], 'ki_vorschlag sollte gespeichert sein'); + $this->assertNotEmpty($beleg['ki_vorschlag_at'], 'ki_vorschlag_at sollte gesetzt sein'); + + $decoded = json_decode($beleg['ki_vorschlag'], true); + $this->assertEquals('Ausgabe', $decoded['buchungstyp'], 'buchungstyp korrekt gespeichert'); + $this->assertEquals(19, $decoded['ust_satz'], 'ust_satz korrekt gespeichert'); + $this->assertEquals(33.64, $decoded['betrag_netto'], 'betrag_netto korrekt gespeichert'); + + // 3. saveKiVorschlag(null) löscht den Vorschlag + Beleg::saveKiVorschlag($belegId, null); + $beleg = Beleg::find($belegId); + $this->assert($beleg['ki_vorschlag'] === null, 'ki_vorschlag sollte nach Reset null sein'); + + // 4. JSON-Extraktion: Prosa vor JSON wird korrekt extrahiert + $this->testJsonExtraktion(); + } + + private function createTestBeleg(): int + { + Database::execute( + "INSERT INTO belege (beleg_nr, filename, storage_path, mime_type, file_hash, eingangs_datum, belegtyp) + VALUES ('TEST-KI-0001', 'test.pdf', 'static/uploads/test/test.pdf', 'application/pdf', + SHA2('test', 256), CURDATE(), 'Eingangsrechnung')" + ); + return (int)Database::lastInsertId(); + } + + private function testJsonExtraktion(): void + { + // Simuliert die Regex-Extraktion aus einer LLM-Antwort mit vorangehendem Text + $antwort = "Hier ist mein Kontierungsvorschlag:\n{\"buchungstyp\":\"Ausgabe\",\"kategorie_konto_id\":5,\"zahlungskonto_id\":2,\"ust_satz\":19,\"betrag_netto\":100.00,\"buchungstext\":\"Test\"}"; + preg_match('/\{[^{}]*\}/s', $antwort, $m); + $data = json_decode($m[0] ?? '{}', true); + $this->assertEquals('Ausgabe', $data['buchungstyp'] ?? '', 'JSON-Extraktion aus LLM-Antwort mit Prosa'); + $this->assertEquals(19, $data['ust_satz'] ?? -1, 'ust_satz aus extrahiertem JSON'); + + // Antwort ohne JSON + preg_match('/\{[^{}]*\}/s', 'Keine Angabe möglich.', $m2); + $this->assert(empty($m2), 'Keine JSON-Extraktion ohne JSON-Block'); + } +}