Merge pull request 'fix/update-script-ordner & Überarbeitung KI Buchungsvorschlag' (#1) from fix/update-script-ordner into main

Reviewed-on: https://codeberg.org/fds2610-bot/Steuerkram/pulls/1
This commit is contained in:
fds2610-bot 2026-04-15 20:13:52 +02:00
commit 70940cc473
8 changed files with 285 additions and 94 deletions

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.
--- ---

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..."

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

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

@ -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)'
); );
} }
} }