Merge fix/update-script-ordner: Update-Script, OCR-Entfernung, KI-Refactor
This commit is contained in:
commit
017195f4f2
17 changed files with 538 additions and 129 deletions
|
|
@ -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-...
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
104
scripts/ki-batch.php
Normal 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);
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -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,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue