Merge fix/update-script-ordner: Update-Script, OCR-Entfernung, KI-Refactor

This commit is contained in:
fds2610-bot 2026-04-15 20:21:12 +02:00
commit 017195f4f2
17 changed files with 538 additions and 129 deletions

View file

@ -11,6 +11,9 @@ DB_PASS=steuerkram
# OLLAMA_URL=http://localhost:11434 # OLLAMA_URL=http://localhost:11434
# OLLAMA_MODEL=llama3.2 # 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) # Option B: Remote-LLM per OpenAI-kompatibler API (z.B. OpenAI, OpenRouter, LM Studio)
# LLM_API_URL=https://api.openai.com/v1 # LLM_API_URL=https://api.openai.com/v1
# LLM_API_KEY=sk-... # LLM_API_KEY=sk-...

View file

@ -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 ## 2026-04-12 – EÜR-Korrekturen: Gewerbesteuer + Bewirtungskonten ohne VSt
### Geändert ### Geändert

View file

@ -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 ### Option A – Lokales Ollama
Kein API-Schlüssel nötig, aber hoher Ressourcenbedarf: Kein API-Schlüssel nötig, aber hoher Ressourcenbedarf:
```bash ```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: 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 ```dotenv
OLLAMA_URL=http://localhost:11434 OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=mistral OLLAMA_MODEL=qwen2.5vl
``` ```
### Option B – Remote-API (OpenAI-kompatibel) ### Option B – Remote-API (OpenAI-kompatibel)
@ -49,7 +56,7 @@ LLM_API_KEY=sk-...
LLM_MODEL=gpt-4o-mini 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**. 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 ### Manueller Aufruf

View file

@ -36,10 +36,10 @@ Beide Pfade liegen außerhalb des Containers und bleiben bei `docker compose dow
## Update ## 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 ```bash
./update.sh ./scripts/update.sh
``` ```
Das Script erledigt in dieser Reihenfolge: Das Script erledigt in dieser Reihenfolge:

View file

@ -14,7 +14,10 @@ set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BACKUP_DIR="$PROJECT_DIR/backups" 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) # Verhindert mehrfachen gleichzeitigen Start (z.B. bei Verzögerung durch langen Backup-Lauf)
exec 9>"$LOCK_FILE" exec 9>"$LOCK_FILE"
@ -24,7 +27,6 @@ if ! flock -n 9; then
fi fi
TIMESTAMP=$(date +%Y%m%d_%H%M%S) TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"
echo "$(date '+%Y-%m-%d %H:%M:%S') Erstelle Backup $TIMESTAMP..." echo "$(date '+%Y-%m-%d %H:%M:%S') Erstelle Backup $TIMESTAMP..."

104
scripts/ki-batch.php Normal file
View file

@ -0,0 +1,104 @@
<?php
/**
* KI-Kontierungsvorschlag – Batch-Verarbeitung (CLI-Einstiegspunkt).
*
* Verarbeitet EINEN ausstehenden Beleg pro Aufruf (ki_vorschlag IS NULL, locked = 0).
* Verhindert so, dass mehrere Vision-Model-Anfragen parallel an Ollama geschickt werden.
*
* Voraussetzung: ki_vorschlag_aktiv = 1 in den Einstellungen, Ollama erreichbar.
*
* Aufruf:
* docker compose exec web php scripts/ki-batch.php
*
* Für optionalen eigenen Cronjob (manuelle Uploads ohne Nextcloud):
* *\/5 * * * * docker compose -f /opt/steuerkram/docker-compose.yml exec -T web \
* php scripts/ki-batch.php >> /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);

View file

@ -41,6 +41,7 @@ if (file_exists('.env')) {
require_once 'vendor/autoload.php'; require_once 'vendor/autoload.php';
use Steuerkram\Config; use Steuerkram\Config;
use Steuerkram\Models\Setting;
use Steuerkram\Services\NextcloudImportService; use Steuerkram\Services\NextcloudImportService;
Config::load(); // Database verbindet sich lazy über Config::get() Config::load(); // Database verbindet sich lazy über Config::get()
@ -64,4 +65,16 @@ echo sprintf(
$result['errors'], $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); exit($result['errors'] > 0 ? 1 : 0);

View file

@ -41,7 +41,7 @@ done
# ── Pfade ──────────────────────────────────────────────────────────────────── # ── Pfade ────────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$SCRIPT_DIR" PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR" cd "$PROJECT_DIR"
# ── Farben ─────────────────────────────────────────────────────────────────── # ── Farben ───────────────────────────────────────────────────────────────────

View file

@ -204,6 +204,8 @@ class BelegController
$kiVorschlag = $beleg['ki_vorschlag'] !== null $kiVorschlag = $beleg['ki_vorschlag'] !== null
? json_decode($beleg['ki_vorschlag'], true) ? json_decode($beleg['ki_vorschlag'], true)
: null; : null;
// KI-Sektion anzeigen wenn Ergebnis vorhanden ODER LLM gerade verfügbar
$kiSektion = $kiAktiv || $kiVorschlag !== null;
$view = new View(); $view = new View();
$view->render('layout/base.php', [ $view->render('layout/base.php', [
@ -215,6 +217,7 @@ class BelegController
'integrity' => $integrity, 'integrity' => $integrity,
'accounts' => $accounts, 'accounts' => $accounts,
'kiAktiv' => $kiAktiv, 'kiAktiv' => $kiAktiv,
'kiSektion' => $kiSektion,
'kiVorschlag' => $kiVorschlag, 'kiVorschlag' => $kiVorschlag,
]), ]),
]); ]);

View file

@ -33,7 +33,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=?, ki_vorschlag_aktiv=?, ocr_aktiv=?, payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?,
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?, nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
nextcloud_folder=?, nextcloud_done=?, nextcloud_folder=?, nextcloud_done=?,
logo_max_width=?, logo_max_height=?, logo_max_width=?, logo_max_height=?,
@ -61,7 +61,6 @@ class Setting
(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, isset($data['ki_vorschlag_aktiv']) ? 1 : 0,
isset($data['ocr_aktiv']) ? 1 : 0,
$data['nextcloud_url'] ?: null, $data['nextcloud_url'] ?: null,
$data['nextcloud_user'] ?: null, $data['nextcloud_user'] ?: null,
$data['nextcloud_pass'] ?: null, $data['nextcloud_pass'] ?: null,

View file

@ -24,12 +24,6 @@ class KontierungsvorschlagService
return (bool)($s['ki_vorschlag_aktiv'] ?? 0) && $this->llm->isAvailable(); 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 public function extractText(array $beleg): string
{ {
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; $absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
@ -38,14 +32,33 @@ class KontierungsvorschlagService
$mime = strtolower($beleg['mime_type'] ?? ''); $mime = strtolower($beleg['mime_type'] ?? '');
if ($mime === 'application/pdf') { 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); 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 ''; 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 public function generateForBeleg(array $beleg): ?array
{ {
$text = $this->extractText($beleg); $text = $this->extractText($beleg);
@ -61,7 +74,7 @@ class KontierungsvorschlagService
} }
// Konten laden (max. 50 relevante Konten für kurzen Prompt) // 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( $kategorieKonten = array_slice(array_values(array_filter(
$allAccounts, $allAccounts,
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true) fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
@ -82,20 +95,20 @@ class KontierungsvorschlagService
)); ));
$beleginhalt = trim($text) !== '' $beleginhalt = trim($text) !== ''
? "Belegtext:\n" . mb_substr(trim($text), 0, 2000) ? "Belegtext:\n" . mb_substr(trim($text), 0, 3000)
: '[Kein Text extrahierbar]'; : '[Kein Text extrahierbar]';
$settings = Setting::get(); $settings = Setting::get();
$eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? '')); $eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? ''));
$eigenerHinweis = $eigenerName !== '' $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: 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/Verkaeuer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar."; : "\n- supplier_name: Bei Ausgabe = Aussteller/Verkaeufer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar.";
$prompt = <<<PROMPT $prompt = <<<PROMPT
Du bist ein Buchhalter (SKR03-Kontenrahmen, Deutschland). Analysiere den folgenden Beleg und erstelle einen Kontierungsvorschlag. 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: Antworte AUSSCHLIESSLICH mit einem JSON-Array. Eine Zeile je MwSt-Satz (7% und 19% = zwei Eintraege). Nur ein Eintrag wenn ein einziger Steuersatz. Kein erklaerende Text davor oder danach:
{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":19,"betrag_netto":33.64,"buchungstext":"Rechnung 2026-001","supplier_name":"Mustermann GmbH"} [{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":7,"betrag_netto":138.10,"buchungstext":"Rechnung 2026-001","supplier_name":"Mustermann GmbH"},{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":19,"betrag_netto":26.24,"buchungstext":"Rechnung 2026-001","supplier_name":"Mustermann GmbH"}]
Erlaubte Werte: Erlaubte Werte:
- buchungstyp: "Ausgabe" oder "Einnahme" - buchungstyp: "Ausgabe" oder "Einnahme"
@ -117,69 +130,245 @@ PROMPT;
return $this->parseAndValidate($response, $allAccounts); return $this->parseAndValidate($response, $allAccounts);
} }
// -------------------------------------------------------------------------
// Text-Extraktion
// -------------------------------------------------------------------------
/**
* Extrahiert eingebettete eRechnung-XML (ZUGFeRD / Factur-X / XRechnung).
* Gibt XML-Inhalt (max. 12.000 Zeichen) zurück, oder '' wenn keiner gefunden.
*/
private function extractErechnungXml(string $path): string
{
// pdfdetach ist Teil von poppler-utils (zusammen mit pdftotext installiert)
$safe = escapeshellarg($path);
exec("pdfdetach -list {$safe} 2>/dev/null", $listOutput, $rc);
if ($rc !== 0 || empty($listOutput)) return '';
$hasXml = false;
foreach ($listOutput as $line) {
if (stripos($line, '.xml') !== false) {
$hasXml = true;
break;
}
}
if (!$hasXml) return '';
$tmpDir = sys_get_temp_dir() . '/erechnung_' . uniqid();
$safeTmp = escapeshellarg($tmpDir);
mkdir($tmpDir, 0700, true);
exec("pdfdetach -saveall -o {$safeTmp} {$safe} 2>/dev/null");
$xmlContent = '';
$candidates = [
"$tmpDir/factur-x.xml",
"$tmpDir/ZUGFeRD-invoice.xml",
"$tmpDir/zugferd-invoice.xml",
"$tmpDir/xrechnung.xml",
];
// Bekannte Namen zuerst, dann alle .xml-Dateien im tmpDir
foreach (array_merge($candidates, glob("$tmpDir/*.xml") ?: [], glob("$tmpDir/*.XML") ?: []) as $f) {
if (file_exists($f)) {
$xmlContent = file_get_contents($f);
break;
}
}
// Cleanup
foreach (glob("$tmpDir/*") ?: [] as $f) {
@unlink($f);
}
@rmdir($tmpDir);
return mb_substr($xmlContent, 0, 12000);
}
/**
* Konvertiert PDF-Seiten zu Bildern (pdftoppm) und schickt sie ans Vision-Modell.
* Fallback auf pdftotext wenn Vision nicht verfügbar oder fehlschlägt.
*/
private function extractPdfVision(string $path): string
{
$tmpDir = sys_get_temp_dir() . '/vision_' . uniqid();
$safeTmp = escapeshellarg($tmpDir . '/page');
$safe = escapeshellarg($path);
mkdir($tmpDir, 0700, true);
// pdftoppm ist Teil von poppler-utils; auf 800px Breite begrenzen (Ollama-Limit)
exec("pdftoppm -jpeg -scale-to-x 800 -scale-to-y -1 {$safe} {$tmpDir}/page 2>/dev/null", $out, $rc);
$images = glob("$tmpDir/page*.jpg") ?: glob("$tmpDir/page*.jpeg") ?: [];
sort($images);
if ($rc !== 0 || empty($images)) {
@rmdir($tmpDir);
return '';
}
$base64 = [];
foreach ($images as $img) {
$base64[] = base64_encode(file_get_contents($img));
@unlink($img);
}
@rmdir($tmpDir);
$prompt = 'Extrahiere den vollstaendigen Text dieses Belegs. '
. 'Behalte die Tabellenstruktur bei (Spalten mit Leerzeichen ausrichten). '
. 'Antworte nur mit dem extrahierten Text, ohne Kommentar oder Erklaerung.';
return $this->llm->generateVision($prompt, $base64);
}
/**
* Schickt ein Bild (JPEG/PNG/TIFF) ans Vision-Modell.
* Skaliert auf max. 800px Breite damit der Payload nicht zu groß wird.
*/
private function extractImageVision(string $path): string
{
$base64 = $this->resizeImageBase64($path, 800);
if ($base64 === '') return '';
$prompt = 'Extrahiere den vollstaendigen Text dieses Belegs. '
. 'Behalte die Tabellenstruktur bei. '
. 'Antworte nur mit dem extrahierten Text, ohne Kommentar oder Erklaerung.';
return $this->llm->generateVision($prompt, [$base64]);
}
/**
* Liest ein Bild, skaliert es auf maxWidth Pixel Breite und gibt es als base64-JPEG zurück.
*/
private function resizeImageBase64(string $path, int $maxWidth): string
{
// pdftoppm erzeugt JPEG; ImageCropService verwendet GD — GD ist im Container verfügbar
$info = @getimagesize($path);
if (!$info) return '';
[$origW, $origH, $type] = $info;
$src = match ($type) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
IMAGETYPE_PNG => @imagecreatefrompng($path),
IMAGETYPE_TIFF_II, IMAGETYPE_TIFF_MM => null, // GD unterstützt kein TIFF
default => null,
};
if (!$src) {
// Kein GD-Support (z. B. TIFF) → direkt senden, auch wenn groß
return base64_encode(file_get_contents($path));
}
if ($origW <= $maxWidth) {
// Bereits klein genug → direkt als JPEG-Base64
ob_start();
imagejpeg($src, null, 85);
$jpeg = ob_get_clean();
imagedestroy($src);
return base64_encode($jpeg);
}
$scale = $maxWidth / $origW;
$newW = $maxWidth;
$newH = (int)round($origH * $scale);
$dst = imagecreatetruecolor($newW, $newH);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
imagedestroy($src);
ob_start();
imagejpeg($dst, null, 85);
$jpeg = ob_get_clean();
imagedestroy($dst);
return base64_encode($jpeg);
}
private function extractPdfText(string $path): string private function extractPdfText(string $path): string
{ {
$safe = escapeshellarg($path); $safe = escapeshellarg($path);
$output = []; $output = [];
exec("pdftotext -l 1 {$safe} -", $output, $returnCode); exec("pdftotext {$safe} -", $output, $returnCode);
return $returnCode === 0 ? implode("\n", $output) : ''; return $returnCode === 0 ? implode("\n", $output) : '';
} }
private function extractImageText(string $path): string // -------------------------------------------------------------------------
{ // JSON-Parsing & Validierung
$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 '';
}
/**
* 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 private function parseAndValidate(string $response, array $accounts): ?array
{ {
// JSON aus Antwort extrahieren (Modell könnte Prosa voranstellen) // Array-Form: [{ ... }, { ... }]
if (!preg_match('/\{[^{}]*\}/s', $response, $m)) return null; if (preg_match('/\[.*\]/s', $response, $m)) {
$data = json_decode($m[0], true);
$data = json_decode($m[0], true); if (is_array($data) && isset($data[0]) && is_array($data[0])) {
if (!is_array($data)) return null; return $this->validateLines($data, $accounts);
}
$required = ['buchungstyp', 'kategorie_konto_id', 'zahlungskonto_id', 'ust_satz', 'betrag_netto'];
foreach ($required as $field) {
if (!isset($data[$field])) return null;
} }
// Werte normalisieren // Fallback: einzelnes Objekt { ... } → in Array einwickeln
$data['buchungstyp'] = in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) ? $data['buchungstyp'] : 'Ausgabe'; if (preg_match('/\{[^{}]*\}/s', $response, $m)) {
$data['kategorie_konto_id'] = (int)$data['kategorie_konto_id']; $data = json_decode($m[0], true);
$data['zahlungskonto_id'] = (int)$data['zahlungskonto_id']; if (is_array($data)) {
$data['ust_satz'] = in_array((int)$data['ust_satz'], [0, 7, 19], true) ? (int)$data['ust_satz'] : 0; return $this->validateLines([$data], $accounts);
$data['betrag_netto'] = round((float)$data['betrag_netto'], 2); }
$data['buchungstext'] = (string)($data['buchungstext'] ?? ''); }
$data['supplier_name'] = trim((string)($data['supplier_name'] ?? ''));
// 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, $kategorieIds = array_column(array_filter($accounts,
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true) fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
), 'id'); ), 'id');
$zahlungsIds = array_column(array_filter($accounts, $zahlungsIds = array_column(array_filter($accounts,
fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true) fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true)
), 'id'); ), '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 $valid = [];
if ($data['supplier_name'] !== '') { foreach ($lines as $data) {
$existing = Supplier::findByName($data['supplier_name']); if (!is_array($data)) continue;
$data['supplier_found'] = $existing !== false;
} else { $required = ['buchungstyp', 'kategorie_konto_id', 'zahlungskonto_id', 'ust_satz', 'betrag_netto'];
$data['supplier_found'] = null; $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;
} }
} }

View file

@ -15,10 +15,11 @@ namespace Steuerkram\Services;
*/ */
class LlmService class LlmService
{ {
private ?string $mode = null; // 'ollama' | 'openai' | null private ?string $mode = null; // 'ollama' | 'openai' | null
private string $url = ''; private string $url = '';
private string $model = ''; private string $model = '';
private ?string $apiKey = null; private string $visionModel = '';
private ?string $apiKey = null;
public function __construct() public function __construct()
{ {
@ -30,6 +31,11 @@ class LlmService
return $this->mode !== null; return $this->mode !== null;
} }
public function isVisionAvailable(): bool
{
return $this->mode === 'ollama';
}
public function getProviderLabel(): ?string public function getProviderLabel(): ?string
{ {
return match($this->mode) { 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 private function detect(): void
{ {
$ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? ''); $ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? '');
@ -55,9 +100,10 @@ class LlmService
$apiKey = getenv('LLM_API_KEY') ?: ($_ENV['LLM_API_KEY'] ?? ''); $apiKey = getenv('LLM_API_KEY') ?: ($_ENV['LLM_API_KEY'] ?? '');
if ($ollamaUrl !== '') { if ($ollamaUrl !== '') {
$this->mode = 'ollama'; $this->mode = 'ollama';
$this->url = rtrim($ollamaUrl, '/'); $this->url = rtrim($ollamaUrl, '/');
$this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2'); $this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2');
$this->visionModel = getenv('VISION_MODEL') ?: ($_ENV['VISION_MODEL'] ?? 'qwen2.5vl:latest');
return; return;
} }

View file

@ -69,7 +69,7 @@ class NextcloudImportService
*/ */
public function import(): array 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) { if (!$this->baseUrl || !$this->user || !$this->pass) {
$result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.'; $result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.';
@ -262,7 +262,7 @@ class NextcloudImportService
// In Datenbank anlegen // In Datenbank anlegen
$today = date('Y-m-d'); $today = date('Y-m-d');
Beleg::create([ $belegId = Beleg::create([
'beleg_nr' => $belegNr, 'beleg_nr' => $belegNr,
'filename' => $filename, 'filename' => $filename,
'storage_path' => $storagePath, 'storage_path' => $storagePath,
@ -280,6 +280,7 @@ class NextcloudImportService
$result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}"; $result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}";
$result['imported']++; $result['imported']++;
$result['imported_ids'][] = $belegId;
} }
/** /**

View file

@ -271,7 +271,7 @@ $suppliers = $suppliers ?? [];
</div> </div>
<!-- KI-Kontierungsvorschlag --> <!-- KI-Kontierungsvorschlag -->
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?> <?php if (!$beleg['locked'] && ($kiSektion ?? false)): ?>
<div id="ki-badge" style="margin-bottom:1rem"></div> <div id="ki-badge" style="margin-bottom:1rem"></div>
<?php endif; ?> <?php endif; ?>
@ -630,9 +630,10 @@ function updateNettoAnzeige() {
} }
</script> </script>
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?> <?php if (!$beleg['locked'] && ($kiSektion ?? false)): ?>
<script> <script>
const _belegId = <?= (int)$beleg['id'] ?>; const _belegId = <?= (int)$beleg['id'] ?>;
const _kiAktiv = <?= json_encode((bool)($kiAktiv ?? false)) ?>;
const _kiVorschlag = <?= json_encode($kiVorschlag ?? null) ?>; const _kiVorschlag = <?= json_encode($kiVorschlag ?? null) ?>;
function _selectById(selectEl, id) { function _selectById(selectEl, id) {
@ -680,7 +681,9 @@ function _applyKiVorschlag(v) {
} }
} }
function _showKiBadgeDone(v) { function _showKiBadgeDone(arr) {
// arr ist immer ein Array (bereits normalisiert)
const v = arr[0];
const katEl = document.getElementById('kategorie_konto_id'); const katEl = document.getElementById('kategorie_konto_id');
const zhlEl = document.getElementById('zahlungskonto_id'); const zhlEl = document.getElementById('zahlungskonto_id');
const katOpt = katEl ? katEl.querySelector('option[value="' + v.kategorie_konto_id + '"]') : null; const katOpt = katEl ? katEl.querySelector('option[value="' + v.kategorie_konto_id + '"]') : null;
@ -690,18 +693,44 @@ function _showKiBadgeDone(v) {
const supTxt = v.supplier_name const supTxt = v.supplier_name
? ' · Lieferant: ' + v.supplier_name + (v.supplier_found === false ? ' <span style="color:var(--color-warning);font-weight:600">(neu – bitte prüfen)</span>' : '') ? ' · Lieferant: ' + v.supplier_name + (v.supplier_found === false ? ' <span style="color:var(--color-warning);font-weight:600">(neu – bitte prüfen)</span>' : '')
: ''; : '';
const details =
// Erste Zeile (bereits ins Formular übertragen)
let details =
(v.buchungstyp ? v.buchungstyp + ' · ' : '') + (v.buchungstyp ? v.buchungstyp + ' · ' : '') +
'USt ' + v.ust_satz + ' % · ' +
'Kategorie: ' + katTxt + ' · ' + 'Kategorie: ' + katTxt + ' · ' +
'Zahlung: ' + zhlTxt + supTxt; 'Zahlung: ' + zhlTxt + supTxt;
// Alle Buchungszeilen mit Beträgen anzeigen
const fmt = n => parseFloat(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
let zeilen = '';
arr.forEach((z, i) => {
const netto = parseFloat(z.betrag_netto) || 0;
const ust = Math.round(netto * z.ust_satz / 100 * 100) / 100;
const brutto = Math.round((netto + ust) * 100) / 100;
const hinweis = (i > 0) ? ' <span style="color:var(--color-muted);font-style:italic">(bitte als weitere Buchung erfassen)</span>' : '';
zeilen += '<br><span style="font-size:.85em">Zeile ' + (i + 1) + ': '
+ 'Netto ' + fmt(netto) + ' € | USt ' + z.ust_satz + '% ' + fmt(ust) + ' € | Brutto ' + fmt(brutto) + ' €'
+ hinweis + '</span>';
});
const neuBtn = _kiAktiv
? '<button class="btn btn-secondary btn-sm" style="flex-shrink:0;margin-top:.1rem" onclick="_startKiAnalyse()">Neu generieren</button>'
: '';
document.getElementById('ki-badge').innerHTML = document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:center;gap:1rem">' + '<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem">' +
'<div><strong>KI-Vorschlag:</strong> <span style="font-size:.9em">' + details + '</span></div>' + '<div><strong>KI-Vorschlag:</strong> <span style="font-size:.9em">' + details + '</span>' + zeilen + '</div>' +
'<button class="btn btn-secondary btn-sm" style="flex-shrink:0" onclick="_startKiAnalyse()">Neu generieren</button>' + neuBtn +
'</div>'; '</div>';
} }
function _showKiBadgeNoResult() {
const neuBtn = _kiAktiv
? ' <button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button>'
: '';
document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-warning">Kein Kontierungsvorschlag generiert.' + neuBtn + '</div>';
}
function _showKiBadgeLoading() { function _showKiBadgeLoading() {
document.getElementById('ki-badge').innerHTML = document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-info" style="opacity:.65">Analysiere Beleg … bitte warten.</div>'; '<div class="flash flash-info" style="opacity:.65">Analysiere Beleg … bitte warten.</div>';
@ -709,36 +738,47 @@ function _showKiBadgeLoading() {
function _showKiBadgeError() { function _showKiBadgeError() {
document.getElementById('ki-badge').innerHTML = document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-warning">Kein Kontierungsvorschlag generiert. ' + '<div class="flash flash-warning">Fehler bei der KI-Analyse. ' +
'<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button></div>'; '<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button></div>';
} }
// Normalisiert _kiVorschlag zu einem Array (Objekt oder Array aus DB möglich).
// Gibt null zurück wenn kein Ergebnis vorhanden.
// Gibt leeres Array [] zurück wenn verarbeitet aber kein Ergebnis erzeugt.
function _normalizeKiArr(raw) {
if (raw === null || raw === undefined) return null;
const arr = Array.isArray(raw) ? raw : [raw];
// Leeres Array = "verarbeitet, kein Ergebnis"
if (arr.length === 0) return [];
// Mindestens erstes Element muss gültig sein
return (arr[0] && arr[0].kategorie_konto_id) ? arr : [];
}
async function _startKiAnalyse() { async function _startKiAnalyse() {
await fetch('/belege/' + _belegId + '/vorschlag/reset', {method: 'POST'}); await fetch('/belege/' + _belegId + '/vorschlag/reset', {method: 'POST'});
_showKiBadgeLoading(); _showKiBadgeLoading();
try { try {
const r = await fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'}); const r = await fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'});
const v = await r.json(); const raw = await r.json();
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); } const arr = _normalizeKiArr(raw);
else _showKiBadgeError(); if (arr && arr.length > 0) { _applyKiVorschlag(arr[0]); _showKiBadgeDone(arr); }
else _showKiBadgeNoResult();
} catch (e) { } catch (e) {
_showKiBadgeError(); _showKiBadgeError();
} }
} }
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
if (_kiVorschlag && _kiVorschlag.kategorie_konto_id) { const arr = _normalizeKiArr(_kiVorschlag);
_applyKiVorschlag(_kiVorschlag); if (arr === null) {
_showKiBadgeDone(_kiVorschlag); // Noch nicht verarbeitet – nichts anzeigen
} else if (arr.length === 0) {
// Verarbeitet, aber kein Ergebnis
_showKiBadgeNoResult();
} else { } else {
_showKiBadgeLoading(); // Vorschlag vorhanden → ins Formular übernehmen und anzeigen
fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'}) _applyKiVorschlag(arr[0]);
.then(r => r.json()) _showKiBadgeDone(arr);
.then(v => {
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); }
else _showKiBadgeError();
})
.catch(() => _showKiBadgeError());
} }
}); });
</script> </script>

View file

@ -5,7 +5,7 @@ function navActive(string $prefix, string $current): string {
} }
?> ?>
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-brand"><img src="/static/favicon/web-app-manifest-512x512.png" alt="" style="height:1.1em;width:auto;vertical-align:middle;margin-right:.4em;" />Steuer<span>kram</span></div> <div class="sidebar-brand"><a href="https://codeberg.org/fds2610/Steuerkram" target="_blank" rel="noopener noreferrer" style="display:inline-flex;align-items:center;color:inherit;text-decoration:none;"><img src="/static/favicon/web-app-manifest-512x512.png" alt="" style="height:1.1em;width:auto;vertical-align:middle;margin-right:.4em;" />Steuer<span>kram</span></a></div>
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-label">Übersicht</div> <div class="sidebar-label">Übersicht</div>

View file

@ -136,7 +136,7 @@
<span style="font-weight:600">Automatischen Kontierungsvorschlag erzeugen</span> <span style="font-weight:600">Automatischen Kontierungsvorschlag erzeugen</span>
</label> </label>
<p class="text-muted text-sm" style="margin:.5rem 0 .75rem 1.75rem"> <p class="text-muted text-sm" style="margin:.5rem 0 .75rem 1.75rem">
Beim ersten Öffnen eines Belegs wird ein LLM befragt und das Buchungsformular vorausgefüllt. Beim automatischen Nextcloud-Import wird für jeden Beleg ein Kontierungsvorschlag per LLM erzeugt.
Konfiguration via <code>.env</code> (lokales Ollama oder Remote-API). Konfiguration via <code>.env</code> (lokales Ollama oder Remote-API).
</p> </p>
<?php <?php
@ -153,23 +153,6 @@
</div> </div>
</div> </div>
<!-- OCR-Texterkennung -->
<div class="card mb-2">
<div class="card-header"><div class="card-title">OCR-Texterkennung</div></div>
<div class="card-body">
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;user-select:none">
<input type="checkbox" name="ocr_aktiv" value="1"
<?= !empty($settings['ocr_aktiv']) ? 'checked' : '' ?>>
<span style="font-weight:600">Automatische Texterkennung bei Bildbelegen aktivieren</span>
</label>
<p class="text-muted text-sm" style="margin:.5rem 0 0 1.75rem">
Bei Belegen im Bildformat (JPEG, PNG, TIFF) wird Tesseract OCR eingesetzt, um den Belegtext
zu extrahieren. Dieser Text wird – sofern der KI-Schalter aktiv ist – an das LLM übergeben.
PDF-Belege werden unabhängig davon per <code>pdftotext</code> ausgelesen.
</p>
</div>
</div>
<!-- Nextcloud-Import --> <!-- Nextcloud-Import -->
<div class="card mb-2"> <div class="card mb-2">
<div class="card-header"><div class="card-title">Nextcloud-Belegimport</div></div> <div class="card-header"><div class="card-title">Nextcloud-Belegimport</div></div>

View file

@ -17,6 +17,11 @@ class EuerBuchungsTest extends TestCase
{ {
$datum = date('Y-m-d'); $datum = date('Y-m-d');
// EÜR-Snapshot vor allen Testbuchungen
$euerVorher = (new \Steuerkram\Services\EuerCalculator())->calculate((int)date('Y'));
$einnahmenVorher = (float)$euerVorher['total_einnahmen'];
$gewinnVorher = (float)$euerVorher['gewinn'];
// ── Kontenarten prüfen ─────────────────────────────────────── // ── Kontenarten prüfen ───────────────────────────────────────
$kontenarten = \Steuerkram\Database::fetchAll( $kontenarten = \Steuerkram\Database::fetchAll(
"SELECT DISTINCT kontenart FROM accounts ORDER BY kontenart" "SELECT DISTINCT kontenart FROM accounts ORDER BY kontenart"
@ -111,6 +116,11 @@ class EuerBuchungsTest extends TestCase
$stornoId = \Steuerkram\Models\Booking::reverse($ausgabeBuchungId, 'Testgrund'); $stornoId = \Steuerkram\Models\Booking::reverse($ausgabeBuchungId, 'Testgrund');
$storno = \Steuerkram\Models\Booking::find($stornoId); $storno = \Steuerkram\Models\Booking::find($stornoId);
$this->assertEquals(
-300.00,
round((float)$storno['betrag_netto'], 2),
'Storno betrag_netto ist korrekt negiert'
);
$this->assertEquals('Ausgabe', $storno['buchungstyp'], 'Storno behält buchungstyp der Originalbuchung'); $this->assertEquals('Ausgabe', $storno['buchungstyp'], 'Storno behält buchungstyp der Originalbuchung');
$this->assertEquals(1, (int)$storno['is_reversal'], 'Storno-Flag gesetzt'); $this->assertEquals(1, (int)$storno['is_reversal'], 'Storno-Flag gesetzt');
$this->assertEquals($ausgabeId, (int)$storno['kategorie_konto_id'], 'Storno behält Kategorie-Konto'); $this->assertEquals($ausgabeId, (int)$storno['kategorie_konto_id'], 'Storno behält Kategorie-Konto');
@ -121,16 +131,17 @@ class EuerBuchungsTest extends TestCase
$this->assertEquals($stornoId, (int)$origNeu['reversed_by'], 'Original verweist auf Storno-Buchung'); $this->assertEquals($stornoId, (int)$origNeu['reversed_by'], 'Original verweist auf Storno-Buchung');
// ── EÜR-Berechnung ─────────────────────────────────────────── // ── EÜR-Berechnung ───────────────────────────────────────────
$euer = (new \Steuerkram\Services\EuerCalculator())->calculate((int)date('Y')); // Nach Einnahme (1000 €) + stornierter Ausgabe: Delta muss +1000 € betragen
$euerNachher = (new \Steuerkram\Services\EuerCalculator())->calculate((int)date('Y'));
$this->assert( $this->assert(
(float)$euer['total_einnahmen'] >= 1000.00, (float)$euerNachher['total_einnahmen'] >= $einnahmenVorher + 1000.00,
'EÜR: Einnahmen enthalten die 1000 € Einnahme-Buchung' 'EÜR: Einnahmen enthalten die 1000 € Einnahme-Buchung'
); );
// Ausgabe wurde storniert → sollte nicht in den Ausgaben erscheinen (is_reversal=0 Filter) // Ausgabe wurde storniert → Netto-Effekt auf Gewinn: +1000 €
$this->assert( $this->assert(
(float)$euer['gewinn'] > 0, round((float)$euerNachher['gewinn'] - $gewinnVorher, 2) >= 1000.00,
'EÜR: Gewinn ist positiv (Einnahmen > Ausgaben nach Storno)' 'EÜR: Gewinn ist um ≥ 1000 € gestiegen (Einnahme + stornierte Ausgabe)'
); );
} }
} }