Feature: KI-Kontierungsvorschlag via lokales Ollama oder Remote-LLM

- LlmService: Auto-Discovery lokales Ollama + OpenAI-kompatibler Remote-API-Modus
- KontierungsvorschlagService: PDF-Textextraktion (pdftotext) + Vision für Bilder
- Async-Badge in Beleg-Detailansicht: "Analysiere..." → Formular vorausgefüllt
- Einstellung in Settings (default: aus) mit Provider-Status-Anzeige
- .env.example: Beispielkonfiguration für beide Provider-Typen (auskommentiert)
- Dockerfile: poppler-utils für pdftotext
- Migration 011: ki_vorschlag/ki_vorschlag_at in belege, ki_vorschlag_aktiv in settings
- Test 11: saveKiVorschlag, JSON-Extraktion, isEnabled-Default
This commit is contained in:
fds2610 2026-03-30 06:13:56 +02:00
parent 15f501db6f
commit b8ef04033c
12 changed files with 542 additions and 11 deletions

View file

@ -6,3 +6,15 @@ DB_PASS=steuerkram
# App # App
APP_SECRET=change-this-to-a-random-string 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

View file

@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y \
zip \ zip \
unzip \ unzip \
curl \ curl \
poppler-utils \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install pdo pdo_mysql zip gd \ && docker-php-ext-install pdo pdo_mysql zip gd \
&& apt-get clean && rm -rf /var/lib/apt/lists/* && apt-get clean && rm -rf /var/lib/apt/lists/*

View file

@ -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;

View file

@ -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->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}/delete', fn($p) => (new BelegController())->destroy($p));
$router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($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 ──────────────────────────────────────────────── // ── Buchungen ────────────────────────────────────────────────
$router->get('/journal', fn($p) => (new BookingController())->journal($p)); $router->get('/journal', fn($p) => (new BookingController())->journal($p));

View file

@ -10,6 +10,7 @@ use Steuerkram\Models\Account;
use Steuerkram\Models\Supplier; use Steuerkram\Models\Supplier;
use Steuerkram\Config; use Steuerkram\Config;
use Steuerkram\View; use Steuerkram\View;
use Steuerkram\Services\KontierungsvorschlagService;
class BelegController class BelegController
{ {
@ -143,6 +144,12 @@ class BelegController
$accounts = Account::all(['aktiv' => 1]); $accounts = Account::all(['aktiv' => 1]);
$suppliers = Supplier::all(); $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 = new View();
$view->render('layout/base.php', [ $view->render('layout/base.php', [
'pageTitle' => 'Beleg ' . $beleg['beleg_nr'], 'pageTitle' => 'Beleg ' . $beleg['beleg_nr'],
@ -152,6 +159,8 @@ class BelegController
'bookings' => $bookings, 'bookings' => $bookings,
'integrity' => $integrity, 'integrity' => $integrity,
'accounts' => $accounts, 'accounts' => $accounts,
'kiAktiv' => $kiAktiv,
'kiVorschlag' => $kiVorschlag,
]), ]),
]); ]);
} }
@ -210,6 +219,29 @@ class BelegController
View::redirect("/belege/{$id}", 'Metadaten gespeichert.'); 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 public function destroy(array $params): void
{ {
$id = (int)$params['id']; $id = (int)$params['id'];

View file

@ -209,6 +209,14 @@ class Beleg
return $nr; 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 public static function verifyIntegrity(int $id): bool
{ {
$beleg = self::find($id); $beleg = self::find($id);

View file

@ -29,7 +29,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=? payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?
WHERE id=1', WHERE id=1',
[ [
$data['firma'] ?? '', $data['firma'] ?? '',
@ -52,6 +52,7 @@ class Setting
$data['beleg_nr_prefix'] ?? 'BLG', $data['beleg_nr_prefix'] ?? 'BLG',
(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,
] ]
); );
} }

View file

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Models\Account;
use Steuerkram\Models\Setting;
class KontierungsvorschlagService
{
private LlmService $llm;
public function __construct()
{
$this->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 = <<<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"}
Erlaubte Werte:
- buchungstyp: "Ausgabe" oder "Einnahme"
- ust_satz: 0, 7 oder 19
- betrag_netto: Dezimalzahl mit Punkt als Trennzeichen
Verfuegbare Kategorie-Konten:
$kategorieList
Verfuegbare Zahlungskonten:
$zahlungsList
$beleginhalt
PROMPT;
$response = $this->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;
}
}

158
src/Services/LlmService.php Normal file
View file

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
/**
* Einheitlicher LLM-Client für lokales Ollama und OpenAI-kompatible Remote-APIs.
*
* Provider-Erkennung (Reihenfolge):
* 1. OLLAMA_URL in Umgebungsvariablen → Ollama-Modus
* 2. LLM_API_URL + LLM_API_KEY in Umgebungsvariablen → OpenAI-kompatibler Modus
* 3. Auto-Discovery: HEAD-Request auf http://localhost:11434 → Ollama-Modus
* 4. Kein Provider gefunden → isAvailable() = false
*/
class LlmService
{
private ?string $mode = null; // 'ollama' | 'openai' | null
private string $url = '';
private string $model = '';
private ?string $apiKey = null;
public function __construct()
{
$this->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'] ?? '';
}
}

View file

@ -247,6 +247,11 @@ $suppliers = $suppliers ?? [];
</div> </div>
</div> </div>
<!-- KI-Kontierungsvorschlag -->
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?>
<div id="ki-badge" style="margin-bottom:1rem"></div>
<?php endif; ?>
<!-- Neue Buchungszeile erfassen --> <!-- Neue Buchungszeile erfassen -->
<?php if (!$beleg['locked']): ?> <?php if (!$beleg['locked']): ?>
<?php <?php
@ -410,11 +415,12 @@ $suppliers = $suppliers ?? [];
</div> </div>
</div> </div>
<div class="form-group" id="eigenanteil_group" style="margin-bottom:.75rem;opacity:.35;pointer-events:none"> <div class="form-group" title="Nur bei Telefon/Internet 19% wählbar" style="margin-bottom:.75rem">
<label style="display:flex;align-items:center;gap:.5rem;cursor:not-allowed" title="Nur bei Telefon/Internet 19% wählbar"> <span id="eigenanteil_group"
<input type="checkbox" id="eigenanteil_30_cb" name="eigenanteil_30" value="1" disabled title="Nur bei Telefon/Internet 19% wählbar"> style="display:inline-flex;align-items:center;gap:.5rem;opacity:.35;pointer-events:none">
30% Eigenanteil buchen <input type="checkbox" id="eigenanteil_30_cb" name="eigenanteil_30" value="1" disabled>
</label> <span>30% Eigenanteil buchen</span>
</span>
</div> </div>
<button type="submit" class="btn btn-primary">Buchung erfassen</button> <button type="submit" class="btn btn-primary">Buchung erfassen</button>
@ -497,3 +503,73 @@ function updateSteuerartVisibility() {
updateEigenanteilState(); updateEigenanteilState();
} }
</script> </script>
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?>
<script>
const _belegId = <?= (int)$beleg['id'] ?>;
const _kiVorschlag = <?= json_encode($kiVorschlag ?? null) ?>;
function _applyKiVorschlag(v) {
const btRadio = document.querySelector('input[name="buchungstyp"][value="' + v.buchungstyp + '"]');
if (btRadio) { btRadio.checked = true; filterKategorieKonten(v.buchungstyp); }
const ustEl = document.getElementById('ust_satz_select');
if (ustEl) { ustEl.value = String(v.ust_satz); updateSteuerartVisibility(); }
const katEl = document.getElementById('kategorie_konto_id');
if (katEl) { katEl.value = String(v.kategorie_konto_id); updateEigenanteilState(); }
const zhlEl = document.getElementById('zahlungskonto_id');
if (zhlEl) zhlEl.value = String(v.zahlungskonto_id);
const netEl = document.querySelector('input[name="betrag_netto"]');
if (netEl && v.betrag_netto) netEl.value = v.betrag_netto;
const txtEl = document.querySelector('input[name="buchungstext"]');
if (txtEl && v.buchungstext) txtEl.value = v.buchungstext;
}
function _showKiBadgeDone() {
document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:center">' +
'<span>KI-Kontierungsvorschlag – Formular vorausgefüllt. Bitte prüfen und ggf. anpassen.</span>' +
'<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Neu generieren</button>' +
'</div>';
}
function _showKiBadgeLoading() {
document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-info" style="opacity:.65">Analysiere Beleg … bitte warten.</div>';
}
function _showKiBadgeError() {
document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-warning">Kein Kontierungsvorschlag generiert. ' +
'<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button></div>';
}
async function _startKiAnalyse() {
await fetch('/belege/' + _belegId + '/vorschlag/reset', {method: 'POST'});
_showKiBadgeLoading();
try {
const r = await fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'});
const v = await r.json();
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(); }
else _showKiBadgeError();
} catch (e) {
_showKiBadgeError();
}
}
document.addEventListener('DOMContentLoaded', function () {
if (_kiVorschlag && _kiVorschlag.kategorie_konto_id) {
_applyKiVorschlag(_kiVorschlag);
_showKiBadgeDone();
} else {
_showKiBadgeLoading();
fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'})
.then(r => r.json())
.then(v => {
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(); }
else _showKiBadgeError();
})
.catch(() => _showKiBadgeError());
}
});
</script>
<?php endif; ?>

View file

@ -128,6 +128,33 @@
</div> </div>
</div> </div>
<!-- KI-Kontierungsvorschlag -->
<div class="card mb-2">
<div class="card-header"><div class="card-title">KI-Kontierungsvorschlag</div></div>
<div class="card-body">
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;user-select:none">
<input type="checkbox" name="ki_vorschlag_aktiv" value="1"
<?= !empty($settings['ki_vorschlag_aktiv']) ? 'checked' : '' ?>>
<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.
Konfiguration via <code>.env</code> (lokales Ollama oder Remote-API).
</p>
<?php
$llm = new \Steuerkram\Services\LlmService();
$providerLabel = $llm->getProviderLabel();
?>
<p class="text-sm" style="margin:0 0 0 1.75rem;color:<?= $providerLabel ? 'var(--color-success,#16a34a)' : 'var(--color-muted,#6b7280)' ?>">
<?php if ($providerLabel): ?>
✓ Provider erkannt: <?= $h($providerLabel) ?>
<?php else: ?>
— Kein LLM-Provider konfiguriert oder erreichbar (Auto-Discovery localhost:11434 fehlgeschlagen)
<?php endif; ?>
</p>
</div>
</div>
<div class="card mb-2"> <div class="card mb-2">
<div class="card-header"><div class="card-title">Bankdaten</div></div> <div class="card-header"><div class="card-title">Bankdaten</div></div>
<div class="card-body"> <div class="card-body">

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use Steuerkram\Database;
use Steuerkram\Models\Beleg;
use Steuerkram\Services\KontierungsvorschlagService;
class KontierungsvorschlagTest extends TestCase
{
protected string $testName = 'Test 11: KI-Kontierungsvorschlag';
public function run(): void
{
// 1. Einstellung ist standardmäßig deaktiviert
$svc = new KontierungsvorschlagService();
$this->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');
}
}