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:
parent
15f501db6f
commit
b8ef04033c
12 changed files with 542 additions and 11 deletions
12
.env.example
12
.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
|
||||
|
|
|
|||
|
|
@ -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/*
|
||||
|
|
|
|||
11
migrations/011_beleg_ki_vorschlag.sql
Normal file
11
migrations/011_beleg_ki_vorschlag.sql
Normal 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;
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,6 +144,12 @@ 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'],
|
||||
|
|
@ -152,6 +159,8 @@ class BelegController
|
|||
'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'];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
130
src/Services/KontierungsvorschlagService.php
Normal file
130
src/Services/KontierungsvorschlagService.php
Normal 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
158
src/Services/LlmService.php
Normal 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'] ?? '';
|
||||
}
|
||||
}
|
||||
|
|
@ -247,6 +247,11 @@ $suppliers = $suppliers ?? [];
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KI-Kontierungsvorschlag -->
|
||||
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?>
|
||||
<div id="ki-badge" style="margin-bottom:1rem"></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Neue Buchungszeile erfassen -->
|
||||
<?php if (!$beleg['locked']): ?>
|
||||
<?php
|
||||
|
|
@ -410,11 +415,12 @@ $suppliers = $suppliers ?? [];
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="eigenanteil_group" style="margin-bottom:.75rem;opacity:.35;pointer-events:none">
|
||||
<label style="display:flex;align-items:center;gap:.5rem;cursor:not-allowed" title="Nur bei Telefon/Internet 19% wählbar">
|
||||
<input type="checkbox" id="eigenanteil_30_cb" name="eigenanteil_30" value="1" disabled title="Nur bei Telefon/Internet 19% wählbar">
|
||||
30% Eigenanteil buchen
|
||||
</label>
|
||||
<div class="form-group" title="Nur bei Telefon/Internet 19% wählbar" style="margin-bottom:.75rem">
|
||||
<span id="eigenanteil_group"
|
||||
style="display:inline-flex;align-items:center;gap:.5rem;opacity:.35;pointer-events:none">
|
||||
<input type="checkbox" id="eigenanteil_30_cb" name="eigenanteil_30" value="1" disabled>
|
||||
<span>30% Eigenanteil buchen</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Buchung erfassen</button>
|
||||
|
|
@ -497,3 +503,73 @@ function updateSteuerartVisibility() {
|
|||
updateEigenanteilState();
|
||||
}
|
||||
</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; ?>
|
||||
|
|
|
|||
|
|
@ -128,6 +128,33 @@
|
|||
</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-header"><div class="card-title">Bankdaten</div></div>
|
||||
<div class="card-body">
|
||||
|
|
|
|||
73
tests/11_KontierungsvorschlagTest.php
Normal file
73
tests/11_KontierungsvorschlagTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue