Merge pull request 'Betriebsvermögen / Anlagevermögen' (#44) from fds2610-bot/Steuerkram:Betriebsvermögen into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/44
Abschreibungen folgen noch.
This commit is contained in:
fds2610 2026-04-15 20:37:54 +02:00
commit 6fbe281175
32 changed files with 1732 additions and 141 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,35 @@
--- ---
## 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 – Anlagevermögen: Anzeige-Fix und Daten-Backfill
### Fix
- Buchungstabellen (Beleg-Detail, Journal, UStVA-Drill-down, EÜR-Drill-down): Anlage-Spalte zeigt jetzt auch die Bezeichnung als Fallback, wenn `anlage_nr` leer ist
- `migrations/032_anlage_nr_backfill.sql`: Anlage-Nummern für bestehende Anlagegüter ohne `anlage_nr` werden nachgeneriert
---
## 2026-04-12 – Anlagevermögen (Wirtschaftsgüter)
### Neu
- Navigation: neuer Abschnitt „Betriebsvermögen" mit Menüpunkt „Anlagevermögen"
- `migrations/028_assets_anlagevermoegen.sql`: neue Tabelle `assets` für Wirtschaftsgüter
- `src/Models/Asset.php`: CRUD-Model mit automatischer laufender Nummer und Geschäftsjahrzuordnung
- `src/Controllers/AssetController.php`: Liste, Anlegen, Bearbeiten, Löschen
- `src/Views/assets/list.php`: Anlagenspiegel-Tabelle mit Bleistift- und Papierkorb-Aktionen
- `src/Views/assets/form.php`: Formular für Wirtschaftsgut (Bezeichnung, Anschaffungsdatum, Kosten, Zugänge, Abgänge, AfA, Nutzungsdauer, Notizen)
- `tests/20_AnlagevermoegensTest.php`: 14 Tests für CRUD-Operationen
---
## 2026-04-12 – EÜR-Korrekturen: Gewerbesteuer + Bewirtungskonten ohne VSt ## 2026-04-12 – EÜR-Korrekturen: Gewerbesteuer + Bewirtungskonten ohne VSt
### Geändert ### Geändert

290
README.md
View file

@ -35,7 +35,295 @@ cd /opt/Steuerkram
docker compose up -d docker compose up -d
``` ```
Anwendung aufrufen: `http://localhost:8080` ---
### Manuelle Installation
#### 1. Repository klonen
```bash
git clone <repository-url> steuerkram
cd steuerkram
```
#### 2. Konfiguration anpassen
Kopiere `.env.example` nach `.env` und passe die Werte an:
```bash
cp .env.example .env
```
```dotenv
DB_PASS=sicheres-passwort # Datenbankpasswort – in docker-compose.yml spiegeln
APP_SECRET=zufaelliger-string # Zufälligen langen String eintragen (für Sessions)
```
Außerdem in `docker-compose.yml` das `MARIADB_PASSWORD` im `db`-Service auf denselben Wert wie `DB_PASS` setzen.
#### 3. Container starten
```bash
docker compose up -d
```
Beim ersten Start werden automatisch:
- die Datenbank initialisiert (`migrations/001_initial_schema.sql`)
- der SKR03-Kontenrahmen eingespielt (`migrations/002_seed_skr03.sql`)
- das Docker-Image mit PHP-Abhängigkeiten (mPDF) gebaut
#### 4. Anwendung aufrufen
```
http://localhost:8080
```
Beim ersten Aufruf erscheint die **Ersteinrichtung**: dort den ersten Benutzer-Account anlegen. Danach ist die Anwendung passwortgeschützt.
Weitere Benutzer können nach dem Login unter **Benutzer** in der Seitenleiste verwaltet werden.
#### 5. KI-Beleganalyse einrichten (optional)
Die KI-gestützte Belegerkennung ist standardmäßig deaktiviert. Sie kann unter **Einstellungen → KI-Kontierungsvorschlag** aktiviert werden, sobald ein LLM erreichbar ist.
**Option A – Lokales Ollama** (kein API-Schlüssel nötig, aber hoher Ressourcenbedarf):
```bash
ollama pull mistral # oder llama3.2 etc.
```
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
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=mistral
```
**Option B – Remote-API** (OpenAI-kompatibel, z. B. OpenAI, OpenRouter, LM Studio):
```dotenv
LLM_API_URL=https://api.openai.com/v1
LLM_API_KEY=sk-...
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.
#### 6. Firmendaten hinterlegen
Nach dem ersten Login unter **Einstellungen** Firmendaten, Logo und ELSTER-Steuernummer eintragen. Diese Angaben erscheinen auf Ausgangsrechnungen und im EÜR-Export.
## Port ändern
Der Standard-Port ist `8080`. Um ihn zu ändern, passe in `docker-compose.yml` die `ports`-Zeile an:
```yaml
ports:
- "9090:80" # Beispiel: Port 9090 auf dem Host
```
## Upload-Limit anpassen
Die maximale Dateigröße für Beleganhänge ist auf **20 MB** voreingestellt (`php/uploads.ini`). Um das Limit zu ändern, die Werte in dieser Datei anpassen und den Container neu bauen:
```ini
upload_max_filesize = 20M
post_max_size = 20M
```
```bash
docker compose up -d --build
```
## Belege automatisch aus Nextcloud importieren
Belege können automatisch aus einem Nextcloud-Ordner via WebDAV importiert werden. Das CLI-Skript `scripts/nextcloud-import.php` liest alle unterstützten Dateien (PDF, JPEG, PNG, TIFF, XML) aus dem konfigurierten Ordner, legt sie als Belege an und verschiebt sie anschließend in einen Erledigte-Ordner (oder löscht sie, wenn kein Ziel-Ordner konfiguriert ist).
### Konfiguration
Die Zugangsdaten werden in der Steuerkram-UI gepflegt: **Einstellungen → Nextcloud-Belegimport**.
Empfehlung: Ein Nextcloud-App-Passwort verwenden (Nextcloud → Einstellungen → Sicherheit → App-Passwörter).
### Manueller Aufruf
```bash
docker compose exec web php scripts/nextcloud-import.php
```
### Automatisch per Cron (auf dem Host)
Für den automatischen Abruf muss ein Cron-Eintrag auf dem Host angelegt werden. Dabei gibt es zwei Varianten:
**Option 1: Root-Crontab** (`sudo crontab -e`)
```cron
*/15 * * * * docker compose -f /opt/steuerkram/docker-compose.yml exec -T web \
php scripts/nextcloud-import.php >> /var/log/steuerkram-import.log 2>&1
```
Kein `sudo` im Befehl nötig. Nachteil: Root-Crons sind schwerer zu überblicken.
**Option 2: User-Crontab** (`crontab -e`) – empfohlen
Damit `docker compose exec` ohne `sudo` funktioniert, muss der User in der Docker-Gruppe sein:
```bash
sudo usermod -aG docker $USER
# Danach neu einloggen
```
Anschließend in der User-Crontab (ohne `sudo`):
```cron
*/15 * * * * docker compose -f /home/fds/projects/Steuerkram/docker-compose.yml exec -T web \
php scripts/nextcloud-import.php >> /var/log/steuerkram-import.log 2>&1
```
> **Hinweis:** Das `-T`-Flag ist bei Cron zwingend erforderlich – ohne TTY schlägt `docker compose exec` sonst still fehl.
>
> **Empfehlung:** User-Crontab + Docker-Gruppe. Das ist konsistent mit dem bestehenden Setup (alle anderen Jobs laufen ebenfalls in der User-Crontab).
Das Skript gibt beim Start eine Zusammenfassung aus (importiert / übersprungen / Fehler) und beendet sich mit Exit-Code 1 bei Fehlern.
## Datenpersistenz
| Pfad | Inhalt |
|---|---|
| `./data/mariadb/` | MariaDB-Datenbankdateien |
| Docker Volume `uploads` | Hochgeladene Belege |
Beide Pfade liegen außerhalb des Containers und bleiben bei `docker compose down` erhalten.
## Systemd-Service einrichten
Damit Steuerkram automatisch beim Systemstart startet und als Dienst verwaltet werden kann, liegt im Repository die Datei `steuerkram.service`.
#### 1. Service-Datei installieren
```bash
sudo cp steuerkram.service /etc/systemd/system/steuerkram.service
```
Die Datei setzt `WorkingDirectory=/opt/Steuerkram` voraus. Falls das Repository in ein anderes Verzeichnis geklont wurde, den Pfad in der Service-Datei anpassen:
```bash
sudo nano /etc/systemd/system/steuerkram.service
# WorkingDirectory= auf das tatsächliche Installationsverzeichnis setzen
```
#### 2. Service aktivieren und starten
```bash
sudo systemctl daemon-reload
sudo systemctl enable steuerkram # automatisch beim Systemstart
sudo systemctl start steuerkram
```
#### 3. Status prüfen
```bash
sudo systemctl status steuerkram
journalctl -u steuerkram -f # Live-Log inkl. Migrations-Ausgabe
```
#### Wichtige Service-Befehle
| Befehl | Wirkung |
|---|---|
| `sudo systemctl start steuerkram` | Container starten |
| `sudo systemctl stop steuerkram` | Container stoppen |
| `sudo systemctl restart steuerkram` | Neustart (z.B. nach Update) |
| `sudo systemctl status steuerkram` | Aktueller Status |
## Backup
```bash
bash scripts/backup.sh
```
Das Skript erstellt ein Dump der Datenbank sowie ein Archiv der Uploads im Verzeichnis `backups/`.
## Update
Seit dem 11.04.2026 bin ich mit meiner Software für mich selber produktiv. Ab diesem Zeitpunkt sollten keine breaking changes mehr vorkommen.
Datenbankmigrationen werden **automatisch** beim nächsten Container-Start eingespielt – kein manueller Eingriff nötig. Das Script `scripts/migrate.php` prüft beim Start, welche Migrations-Dateien noch nicht angewendet wurden, und führt sie der Reihe nach aus.
### Vollständige Update-Anleitung
Die folgenden Schritte sichern einen vollständigen und sicheren Update-Ablauf. Alle Befehle im Steuerkram-Verzeichnis ausführen.
#### Schritt 1 – Datenbank sichern
Vor jedem Update ein Backup erstellen:
```bash
bash scripts/backup.sh
```
Das Backup landet in `backups/`. Alternativ manuell:
```bash
docker compose exec db mariadb-dump -u steuerkram -psteuerkram steuerkram \
> ~/steuerkram_backup_$(date +%Y%m%d_%H%M).sql
```
#### Schritt 2 – Aktuellen Code holen
```bash
git pull
```
Prüfen, ob neue Migrations-Dateien enthalten sind:
```bash
git log --oneline -5
```
#### Schritt 3 – Container neu bauen und starten
```bash
docker compose up -d --build
```
Dieser Befehl baut das PHP-Image neu (falls sich `Dockerfile` oder `composer.json` geändert haben) und startet alle Container. Der Web-Container führt beim Start automatisch `scripts/migrate.php` aus.
#### Schritt 4 – Migrationen prüfen
```bash
docker compose logs web | grep migrate
```
Erwartete Ausgabe bei erfolgreicher Migration:
```
[migrate] Starte Migrations-Prüfung...
[migrate] Führe aus: NNN_beschreibung.sql
[migrate] N Migration(en) erfolgreich angewendet.
```
Oder wenn alles bereits aktuell ist:
```
[migrate] Datenbank ist aktuell – keine Migrationen notwendig.
```
Falls die Ausgabe einen Fehler enthält, das Backup aus Schritt 1 einspielen:
```bash
docker compose exec -T db mariadb -u steuerkram -psteuerkram steuerkram \
< ~/steuerkram_backup_YYYYMMDD_HHMM.sql
```
#### Schritt 5 – Anwendung prüfen
```bash
docker compose ps # alle Container müssen "running" sein
docker compose logs web # keine PHP-Fehler oder Exceptions
```
Anschließend im Browser unter `http://localhost:8080` (oder dem konfigurierten Port) einloggen und stichprobenartig Belege und Buchungen prüfen.
--- ---

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

@ -0,0 +1,20 @@
-- Migration 028: Tabelle für Anlagevermögen (Wirtschaftsgüter)
CREATE TABLE IF NOT EXISTS assets (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
lfd_nr INT UNSIGNED NOT NULL COMMENT 'Laufende Nummer im Anlagenspiegel',
bezeichnung VARCHAR(255) NOT NULL COMMENT 'Bezeichnung des Wirtschaftsguts',
anschaffungsdatum DATE NOT NULL,
anschaffungskosten DECIMAL(12,2) NOT NULL DEFAULT 0.00,
zugaenge DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 'Zugänge im laufenden Wirtschaftsjahr',
abgaenge DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 'Abgänge im laufenden Wirtschaftsjahr',
abschreibung_jahr DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 'Abschreibung des laufenden Wirtschaftsjahres',
nutzungsdauer SMALLINT UNSIGNED NULL COMMENT 'Nutzungsdauer in Jahren (für AfA-Berechnung)',
notizen TEXT NULL,
fiscal_year_id INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_assets_fiscal_year (fiscal_year_id),
KEY idx_assets_lfd_nr (lfd_nr)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Anlagevermögen / Wirtschaftsgüter (Anlagenspiegel)';

View file

@ -0,0 +1,6 @@
-- Migration 029: Anlageart (Kontonummer) zu assets hinzufügen
ALTER TABLE assets
ADD COLUMN account_id INT UNSIGNED NULL
COMMENT 'FK auf accounts – Anlagevermögenskonto (Anlageart)'
AFTER bezeichnung,
ADD KEY idx_assets_account (account_id);

View file

@ -0,0 +1,6 @@
-- Migration 030: asset_id in bookings – Verknüpfung zur Anlagegut-Tabelle
ALTER TABLE bookings
ADD COLUMN asset_id INT UNSIGNED NULL
COMMENT 'FK auf assets – bei Buchungen auf Anlagevermögenskonten'
AFTER beleg_id,
ADD KEY idx_bookings_asset (asset_id);

View file

@ -0,0 +1,13 @@
-- Migration 031: Anlagen-Nummernkreis mit konfiguriertem Präfix
ALTER TABLE company_settings
ADD COLUMN anlage_nr_prefix VARCHAR(10) NOT NULL DEFAULT 'ANL'
COMMENT 'Präfix für Anlagen-Nummern (z.B. ANL)'
AFTER beleg_nr_next,
ADD COLUMN anlage_nr_next INT UNSIGNED NOT NULL DEFAULT 1
COMMENT 'Nächste laufende Nummer für Anlagegüter'
AFTER anlage_nr_prefix;
ALTER TABLE assets
ADD COLUMN anlage_nr VARCHAR(30) NOT NULL DEFAULT ''
COMMENT 'Formatierte Anlagen-Nummer (z.B. ANL-2026-0001)'
AFTER lfd_nr;

View file

@ -0,0 +1,23 @@
-- Migration 032: Anlage-Nummern für bestehende Anlagegüter ohne Nummer nachgenerieren
-- Weist assets mit leerem anlage_nr Nummern im konfigurierten Format zu.
CREATE TEMPORARY TABLE IF NOT EXISTS _anlage_backfill_tmp AS
SELECT
a.id,
YEAR(COALESCE(a.anschaffungsdatum, NOW())) AS asset_year,
cs.anlage_nr_prefix AS prefix,
cs.anlage_nr_next + (ROW_NUMBER() OVER (ORDER BY a.id) - 1) AS seq_nr
FROM assets a
CROSS JOIN company_settings cs
WHERE a.anlage_nr = '' AND cs.id = 1;
UPDATE assets a
JOIN _anlage_backfill_tmp t ON a.id = t.id
SET a.anlage_nr = CONCAT(t.prefix, '-', t.asset_year, '-', LPAD(t.seq_nr, 4, '0'))
WHERE a.anlage_nr = '';
UPDATE company_settings
SET anlage_nr_next = anlage_nr_next + (SELECT COUNT(*) FROM _anlage_backfill_tmp)
WHERE id = 1 AND (SELECT COUNT(*) FROM _anlage_backfill_tmp) > 0;
DROP TEMPORARY TABLE IF EXISTS _anlage_backfill_tmp;

View file

@ -19,6 +19,7 @@ use Steuerkram\Controllers\UstvaController;
use Steuerkram\Controllers\EuerController; use Steuerkram\Controllers\EuerController;
use Steuerkram\Controllers\SettingsController; use Steuerkram\Controllers\SettingsController;
use Steuerkram\Controllers\LoginController; use Steuerkram\Controllers\LoginController;
use Steuerkram\Controllers\AssetController;
use Steuerkram\Controllers\UserAdminController; use Steuerkram\Controllers\UserAdminController;
Config::load(); Config::load();
@ -97,6 +98,14 @@ $router->post('/invoices/{id}/cancel', fn($p) => (new InvoiceController())->canc
$router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p)); $router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p));
$router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p)); $router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p));
// ── Anlagevermögen ───────────────────────────────────────────
$router->get('/assets', fn($p) => (new AssetController())->index($p));
$router->get('/assets/new', fn($p) => (new AssetController())->create($p));
$router->post('/assets', fn($p) => (new AssetController())->store($p));
$router->get('/assets/{id}/edit', fn($p) => (new AssetController())->edit($p));
$router->post('/assets/{id}', fn($p) => (new AssetController())->update($p));
$router->post('/assets/{id}/delete', fn($p) => (new AssetController())->destroy($p));
// ── UStVA ──────────────────────────────────────────────────── // ── UStVA ────────────────────────────────────────────────────
$router->get('/tax/ustva', fn($p) => (new UstvaController())->index($p)); $router->get('/tax/ustva', fn($p) => (new UstvaController())->index($p));
$router->get('/tax/ustva/{year}/{month}', fn($p) => (new UstvaController())->show($p)); $router->get('/tax/ustva/{year}/{month}', fn($p) => (new UstvaController())->show($p));

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

@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Asset;
use Steuerkram\View;
class AssetController
{
public function index(array $params): void
{
$assets = Asset::all();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Anlagevermögen',
'content' => $this->renderView('assets/list', [
'assets' => $assets,
]),
'topbarActions' => '<a href="/assets/new" class="btn btn-primary">+ Wirtschaftsgut anlegen</a>',
]);
}
public function create(array $params): void
{
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Wirtschaftsgut anlegen',
'content' => $this->renderView('assets/form', [
'asset' => [],
'errors' => [],
'action' => '/assets',
'anlagearten' => Asset::anlagearten(),
]),
]);
}
public function store(array $params): void
{
$data = $this->extractPost();
$errors = $this->validate($data);
if ($errors) {
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Wirtschaftsgut anlegen',
'content' => $this->renderView('assets/form', [
'asset' => $data,
'errors' => $errors,
'action' => '/assets',
'anlagearten' => Asset::anlagearten(),
]),
]);
return;
}
Asset::create($data);
View::redirect('/assets', 'Wirtschaftsgut erfolgreich angelegt.');
}
public function edit(array $params): void
{
$asset = Asset::find((int)$params['id']);
if (!$asset) { http_response_code(404); return; }
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Wirtschaftsgut bearbeiten',
'content' => $this->renderView('assets/form', [
'asset' => $asset,
'errors' => [],
'action' => '/assets/' . $asset['id'],
'anlagearten' => Asset::anlagearten(),
]),
]);
}
public function update(array $params): void
{
$id = (int)$params['id'];
$asset = Asset::find($id);
if (!$asset) { http_response_code(404); return; }
$data = $this->extractPost();
$errors = $this->validate($data);
if ($errors) {
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Wirtschaftsgut bearbeiten',
'content' => $this->renderView('assets/form', [
'asset' => array_merge($asset, $data),
'errors' => $errors,
'action' => '/assets/' . $id,
'anlagearten' => Asset::anlagearten(),
]),
]);
return;
}
Asset::update($id, $data);
View::redirect('/assets', 'Wirtschaftsgut aktualisiert.');
}
public function destroy(array $params): void
{
if (!Asset::delete((int)$params['id'])) {
View::redirect('/assets', 'Wirtschaftsgut konnte nicht gelöscht werden.', 'error');
return;
}
View::redirect('/assets', 'Wirtschaftsgut gelöscht.');
}
// ── Private helpers ──────────────────────────────────────────
private function extractPost(): array
{
return [
'bezeichnung' => trim($_POST['bezeichnung'] ?? ''),
'account_id' => trim($_POST['account_id'] ?? ''),
'anschaffungsdatum' => trim($_POST['anschaffungsdatum'] ?? ''),
'anschaffungskosten' => $_POST['anschaffungskosten'] ?? '0',
'zugaenge' => $_POST['zugaenge'] ?? '0',
'abgaenge' => $_POST['abgaenge'] ?? '0',
'abschreibung_jahr' => $_POST['abschreibung_jahr'] ?? '0',
'nutzungsdauer' => trim($_POST['nutzungsdauer'] ?? ''),
'notizen' => trim($_POST['notizen'] ?? ''),
];
}
private function validate(array $data): array
{
$errors = [];
if ($data['bezeichnung'] === '') {
$errors['bezeichnung'] = 'Pflichtfeld';
}
if ($data['anschaffungsdatum'] === '') {
$errors['anschaffungsdatum'] = 'Pflichtfeld';
} elseif (!strtotime($data['anschaffungsdatum'])) {
$errors['anschaffungsdatum'] = 'Ungültiges Datum';
}
if (!is_numeric($data['anschaffungskosten']) || (float)$data['anschaffungskosten'] < 0) {
$errors['anschaffungskosten'] = 'Muss eine positive Zahl sein';
}
return $errors;
}
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
}

View file

@ -7,6 +7,7 @@ namespace Steuerkram\Controllers;
use Steuerkram\Models\Beleg; use Steuerkram\Models\Beleg;
use Steuerkram\Models\Booking; use Steuerkram\Models\Booking;
use Steuerkram\Models\Account; use Steuerkram\Models\Account;
use Steuerkram\Models\Asset;
use Steuerkram\Models\Supplier; use Steuerkram\Models\Supplier;
use Steuerkram\Config; use Steuerkram\Config;
use Steuerkram\View; use Steuerkram\View;
@ -198,12 +199,15 @@ class BelegController
$integrity = Beleg::verifyIntegrity((int)$params['id']); $integrity = Beleg::verifyIntegrity((int)$params['id']);
$accounts = Account::all(['aktiv' => 1]); $accounts = Account::all(['aktiv' => 1]);
$suppliers = Supplier::all(); $suppliers = Supplier::all();
$assets = Asset::all();
$svc = new KontierungsvorschlagService(); $svc = new KontierungsvorschlagService();
$kiAktiv = $svc->isEnabled(); $kiAktiv = $svc->isEnabled();
$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', [
@ -214,7 +218,9 @@ class BelegController
'bookings' => $bookings, 'bookings' => $bookings,
'integrity' => $integrity, 'integrity' => $integrity,
'accounts' => $accounts, 'accounts' => $accounts,
'assets' => $assets,
'kiAktiv' => $kiAktiv, 'kiAktiv' => $kiAktiv,
'kiSektion' => $kiSektion,
'kiVorschlag' => $kiVorschlag, 'kiVorschlag' => $kiVorschlag,
]), ]),
]); ]);

View file

@ -137,13 +137,28 @@ class BookingController
] ]
); );
// Bewirtungskonto-Split: 4650→4654, 4651→4656, 4652→4657 // Anlagevermögen: neues Asset anlegen oder bestehendes verknüpfen
$konto = \Steuerkram\Database::fetchOne( $konto = \Steuerkram\Database::fetchOne(
'SELECT kontonummer FROM accounts WHERE id = ?', 'SELECT id, kontonummer, kontenart FROM accounts WHERE id = ?',
[(int)$data['kategorie_konto_id']] [(int)$data['kategorie_konto_id']]
); );
$kontonummer = $konto['kontonummer'] ?? ''; $kontonummer = $konto['kontonummer'] ?? '';
if (($konto['kontenart'] ?? '') === 'Anlagevermögen') {
$assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null;
if ($assetId === null) {
// Neues Anlagegut anlegen
$assetId = \Steuerkram\Models\Asset::create([
'bezeichnung' => $data['buchungstext'],
'account_id' => (int)$data['kategorie_konto_id'],
'anschaffungsdatum' => $data['buchungs_datum'],
'anschaffungskosten' => (float)$data['betrag_netto'],
]);
}
$data['asset_id'] = $assetId;
}
// Bewirtungskonto-Split: 4650→4654, 4651→4656, 4652→4657
if (in_array($kontonummer, ['4650', '4651', '4652'], true)) { if (in_array($kontonummer, ['4650', '4651', '4652'], true)) {
$this->bucheBewirtungssplit($data, $kontonummer); $this->bucheBewirtungssplit($data, $kontonummer);
} else { } else {
@ -180,6 +195,7 @@ class BookingController
$bookings = \Steuerkram\Models\Booking::forBeleg($belegId); $bookings = \Steuerkram\Models\Booking::forBeleg($belegId);
$accounts = \Steuerkram\Models\Account::all(['aktiv' => 1]); $accounts = \Steuerkram\Models\Account::all(['aktiv' => 1]);
$integrity = \Steuerkram\Models\Beleg::verifyIntegrity($belegId); $integrity = \Steuerkram\Models\Beleg::verifyIntegrity($belegId);
$assets = \Steuerkram\Models\Asset::all();
$view = new View(); $view = new View();
$view->render('layout/base.php', [ $view->render('layout/base.php', [
@ -189,6 +205,7 @@ class BookingController
'bookings' => $bookings, 'bookings' => $bookings,
'integrity' => $integrity, 'integrity' => $integrity,
'accounts' => $accounts, 'accounts' => $accounts,
'assets' => $assets,
'formErrors' => $errors, 'formErrors' => $errors,
'formData' => $formData, 'formData' => $formData,
]), ]),

155
src/Models/Asset.php Normal file
View file

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class Asset
{
public static function all(?int $fiscalYearId = null): array
{
$where = '1=1';
$params = [];
if ($fiscalYearId !== null) {
$where = 'a.fiscal_year_id = ?';
$params[] = $fiscalYearId;
}
return Database::fetchAll(
"SELECT a.*, ac.kontonummer AS anlageart_kontonummer, ac.bezeichnung AS anlageart_bezeichnung
FROM assets a
LEFT JOIN accounts ac ON a.account_id = ac.id
WHERE $where
ORDER BY a.lfd_nr ASC, a.id ASC",
$params
);
}
public static function find(int $id): array|false
{
return Database::fetchOne(
'SELECT a.*, ac.kontonummer AS anlageart_kontonummer, ac.bezeichnung AS anlageart_bezeichnung
FROM assets a
LEFT JOIN accounts ac ON a.account_id = ac.id
WHERE a.id = ?',
[$id]
);
}
public static function anlagearten(): array
{
return Database::fetchAll(
"SELECT id, kontonummer, bezeichnung FROM accounts
WHERE kontenart = 'Anlagevermögen'
ORDER BY kontonummer ASC"
);
}
public static function nextLfdNr(): int
{
$row = Database::fetchOne('SELECT COALESCE(MAX(lfd_nr), 0) + 1 AS next FROM assets');
return (int)($row['next'] ?? 1);
}
public static function generateAnlageNr(): string
{
$settings = Database::fetchOne(
'SELECT anlage_nr_prefix, anlage_nr_next FROM company_settings WHERE id=1'
);
$prefix = $settings['anlage_nr_prefix'] ?? 'ANL';
$next = (int)($settings['anlage_nr_next'] ?? 1);
$year = date('Y');
$nr = sprintf('%s-%s-%04d', $prefix, $year, $next);
Database::execute(
'UPDATE company_settings SET anlage_nr_next = anlage_nr_next + 1 WHERE id=1'
);
return $nr;
}
public static function create(array $data): int
{
$fyId = null;
if (!empty($data['anschaffungsdatum'])) {
$year = (int)date('Y', strtotime($data['anschaffungsdatum']));
$fy = FiscalYear::findOrCreateByYear($year);
$fyId = $fy['id'];
}
$anlageNr = $data['anlage_nr'] ?? self::generateAnlageNr();
Database::execute(
'INSERT INTO assets
(lfd_nr, anlage_nr, bezeichnung, account_id, anschaffungsdatum, anschaffungskosten,
zugaenge, abgaenge, abschreibung_jahr, nutzungsdauer, notizen, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
$data['lfd_nr'] ?? self::nextLfdNr(),
$anlageNr,
$data['bezeichnung'],
isset($data['account_id']) && $data['account_id'] !== '' ? (int)$data['account_id'] : null,
$data['anschaffungsdatum'],
$data['anschaffungskosten'] ?? 0,
$data['zugaenge'] ?? 0,
$data['abgaenge'] ?? 0,
$data['abschreibung_jahr'] ?? 0,
isset($data['nutzungsdauer']) && $data['nutzungsdauer'] !== '' ? (int)$data['nutzungsdauer'] : null,
$data['notizen'] ?? null,
$fyId,
]
);
return (int)Database::lastInsertId();
}
public static function update(int $id, array $data): void
{
$fyId = null;
if (!empty($data['anschaffungsdatum'])) {
$year = (int)date('Y', strtotime($data['anschaffungsdatum']));
$fy = FiscalYear::findOrCreateByYear($year);
$fyId = $fy['id'];
}
Database::execute(
'UPDATE assets SET
bezeichnung = ?,
account_id = ?,
anschaffungsdatum = ?,
anschaffungskosten = ?,
zugaenge = ?,
abgaenge = ?,
abschreibung_jahr = ?,
nutzungsdauer = ?,
notizen = ?,
fiscal_year_id = COALESCE(?, fiscal_year_id),
updated_at = NOW()
WHERE id = ?',
[
$data['bezeichnung'],
isset($data['account_id']) && $data['account_id'] !== '' ? (int)$data['account_id'] : null,
$data['anschaffungsdatum'],
$data['anschaffungskosten'] ?? 0,
$data['zugaenge'] ?? 0,
$data['abgaenge'] ?? 0,
$data['abschreibung_jahr'] ?? 0,
isset($data['nutzungsdauer']) && $data['nutzungsdauer'] !== '' ? (int)$data['nutzungsdauer'] : null,
$data['notizen'] ?? null,
$fyId,
$id,
]
);
}
public static function delete(int $id): bool
{
$asset = self::find($id);
if (!$asset) return false;
Database::execute('DELETE FROM assets WHERE id = ?', [$id]);
return true;
}
}

View file

@ -13,10 +13,12 @@ class Booking
return Database::fetchAll( return Database::fetchAll(
'SELECT bk.*, 'SELECT bk.*,
k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name, k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name,
z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name,
a.anlage_nr, a.bezeichnung as anlage_bezeichnung
FROM bookings bk FROM bookings bk
JOIN accounts k ON bk.kategorie_konto_id = k.id JOIN accounts k ON bk.kategorie_konto_id = k.id
JOIN accounts z ON bk.zahlungskonto_id = z.id JOIN accounts z ON bk.zahlungskonto_id = z.id
LEFT JOIN assets a ON bk.asset_id = a.id
WHERE bk.beleg_id = ? WHERE bk.beleg_id = ?
ORDER BY bk.buchungs_datum, bk.id', ORDER BY bk.buchungs_datum, bk.id',
[$belegId] [$belegId]
@ -50,12 +52,14 @@ class Booking
$sql = 'SELECT bk.*, $sql = 'SELECT bk.*,
k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name, k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name,
z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name, z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name,
bl.beleg_nr, fy.year as fiscal_year_nr bl.beleg_nr, fy.year as fiscal_year_nr,
a.anlage_nr, a.bezeichnung as anlage_bezeichnung
FROM bookings bk FROM bookings bk
JOIN accounts k ON bk.kategorie_konto_id = k.id JOIN accounts k ON bk.kategorie_konto_id = k.id
JOIN accounts z ON bk.zahlungskonto_id = z.id JOIN accounts z ON bk.zahlungskonto_id = z.id
JOIN belege bl ON bk.beleg_id = bl.id JOIN belege bl ON bk.beleg_id = bl.id
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
LEFT JOIN assets a ON bk.asset_id = a.id
WHERE ' . implode(' AND ', $where) . ' WHERE ' . implode(' AND ', $where) . '
ORDER BY bk.buchungs_datum DESC, bk.id DESC ORDER BY bk.buchungs_datum DESC, bk.id DESC
LIMIT ? OFFSET ?'; LIMIT ? OFFSET ?';
@ -101,15 +105,18 @@ class Booking
$steuerart = 'normal'; $steuerart = 'normal';
} }
$assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null;
Database::execute( Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp, 'INSERT INTO bookings (buchungs_nr, beleg_id, asset_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id, kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto,
buchungstext, fiscal_year_id) buchungstext, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ [
$nr, $nr,
(int)$data['beleg_id'], (int)$data['beleg_id'],
$assetId,
$data['buchungs_datum'], $data['buchungs_datum'],
$data['buchungstyp'], $data['buchungstyp'],
(int)$data['kategorie_konto_id'], (int)$data['kategorie_konto_id'],

View file

@ -32,8 +32,8 @@ class Setting
ust_id=?, steuernummer=?, finanzamt=?, ust_id=?, steuernummer=?, finanzamt=?,
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=?, anlage_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=?,
@ -58,10 +58,10 @@ class Setting
$data['ustva_bundesland'] ?? null, $data['ustva_bundesland'] ?? null,
$data['invoice_nr_prefix'] ?? 'AR', $data['invoice_nr_prefix'] ?? 'AR',
$data['beleg_nr_prefix'] ?? 'BLG', $data['beleg_nr_prefix'] ?? 'BLG',
$data['anlage_nr_prefix'] ?? 'ANL',
(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

@ -80,14 +80,16 @@ class EuerCalculator
return Database::fetchAll( return Database::fetchAll(
'SELECT bk.id, bk.buchungs_datum, bk.buchungstyp, bk.ust_satz, 'SELECT bk.id, bk.buchungs_datum, bk.buchungstyp, bk.ust_satz,
bk.betrag_netto, bk.betrag_ust, bk.betrag_brutto, bk.buchungstext, bk.betrag_netto, bk.betrag_ust, bk.betrag_brutto, bk.buchungstext,
bk.is_reversal, bk.beleg_id, bl.beleg_nr, bk.is_reversal, bk.beleg_id, bk.asset_id, bl.beleg_nr,
k.kontonummer AS kategorie_nr, k.bezeichnung AS kategorie_name, k.kontonummer AS kategorie_nr, k.bezeichnung AS kategorie_name,
z.kontonummer AS zahlungs_nr, z.bezeichnung AS zahlungs_name z.kontonummer AS zahlungs_nr, z.bezeichnung AS zahlungs_name,
a.anlage_nr, a.bezeichnung AS anlage_bezeichnung
FROM bookings bk FROM bookings bk
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
LEFT JOIN belege bl ON bk.beleg_id = bl.id LEFT JOIN belege bl ON bk.beleg_id = bl.id
JOIN accounts k ON bk.kategorie_konto_id = k.id JOIN accounts k ON bk.kategorie_konto_id = k.id
JOIN accounts z ON bk.zahlungskonto_id = z.id JOIN accounts z ON bk.zahlungskonto_id = z.id
LEFT JOIN assets a ON bk.asset_id = a.id
WHERE fy.year = ? AND bk.kategorie_konto_id = ? WHERE fy.year = ? AND bk.kategorie_konto_id = ?
ORDER BY bk.buchungs_datum, bk.id', ORDER BY bk.buchungs_datum, bk.id',
[$year, $kontoId] [$year, $kontoId]

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

@ -198,14 +198,16 @@ class UstvaCalculator
$select = 'SELECT bk.id, bk.buchungs_datum, bk.buchungstyp, bk.steuerart, bk.ust_satz, $select = 'SELECT bk.id, bk.buchungs_datum, bk.buchungstyp, bk.steuerart, bk.ust_satz,
bk.betrag_netto, bk.betrag_ust, bk.betrag_brutto, bk.buchungstext, bk.betrag_netto, bk.betrag_ust, bk.betrag_brutto, bk.buchungstext,
bk.is_reversal, bk.beleg_id, bl.beleg_nr, bk.is_reversal, bk.beleg_id, bk.asset_id, bl.beleg_nr,
k.kontonummer AS kategorie_nr, k.bezeichnung AS kategorie_name, k.kontonummer AS kategorie_nr, k.bezeichnung AS kategorie_name,
z.kontonummer AS zahlungs_nr, z.bezeichnung AS zahlungs_name z.kontonummer AS zahlungs_nr, z.bezeichnung AS zahlungs_name,
a.anlage_nr, a.bezeichnung AS anlage_bezeichnung
FROM bookings bk FROM bookings bk
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
LEFT JOIN belege bl ON bk.beleg_id = bl.id LEFT JOIN belege bl ON bk.beleg_id = bl.id
JOIN accounts k ON bk.kategorie_konto_id = k.id JOIN accounts k ON bk.kategorie_konto_id = k.id
JOIN accounts z ON bk.zahlungskonto_id = z.id JOIN accounts z ON bk.zahlungskonto_id = z.id
LEFT JOIN assets a ON bk.asset_id = a.id
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?'; WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?';
$parts = []; $parts = [];

94
src/Views/assets/form.php Normal file
View file

@ -0,0 +1,94 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card" style="max-width:600px">
<div class="card-body">
<form method="post" action="<?= $h($action) ?>">
<div class="form-group">
<label for="account_id">Anlageart (Konto)</label>
<select id="account_id" name="account_id">
<option value="">– bitte wählen –</option>
<?php foreach ($anlagearten as $kto): ?>
<option value="<?= $h($kto['id']) ?>"
<?= (string)($asset['account_id'] ?? '') === (string)$kto['id'] ? 'selected' : '' ?>>
<?= $h($kto['kontonummer']) ?> – <?= $h($kto['bezeichnung']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="bezeichnung">Bezeichnung des Wirtschaftsguts *</label>
<input type="text" id="bezeichnung" name="bezeichnung"
value="<?= $h($asset['bezeichnung'] ?? '') ?>"
class="<?= isset($errors['bezeichnung']) ? 'input-error' : '' ?>"
required autofocus>
<?php if (isset($errors['bezeichnung'])): ?>
<div class="error-text"><?= $h($errors['bezeichnung']) ?></div>
<?php endif; ?>
</div>
<div class="form-group">
<label for="anschaffungsdatum">Anschaffungsdatum *</label>
<input type="date" id="anschaffungsdatum" name="anschaffungsdatum"
value="<?= $h($asset['anschaffungsdatum'] ?? '') ?>"
class="<?= isset($errors['anschaffungsdatum']) ? 'input-error' : '' ?>"
required>
<?php if (isset($errors['anschaffungsdatum'])): ?>
<div class="error-text"><?= $h($errors['anschaffungsdatum']) ?></div>
<?php endif; ?>
</div>
<div class="form-group">
<label for="anschaffungskosten">Anschaffungskosten (€) *</label>
<input type="number" id="anschaffungskosten" name="anschaffungskosten"
value="<?= $h($asset['anschaffungskosten'] ?? '0') ?>"
class="<?= isset($errors['anschaffungskosten']) ? 'input-error' : '' ?>"
min="0" step="0.01" required>
<?php if (isset($errors['anschaffungskosten'])): ?>
<div class="error-text"><?= $h($errors['anschaffungskosten']) ?></div>
<?php endif; ?>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group">
<label for="zugaenge">Zugänge (€)</label>
<input type="number" id="zugaenge" name="zugaenge"
value="<?= $h($asset['zugaenge'] ?? '0') ?>"
min="0" step="0.01">
</div>
<div class="form-group">
<label for="abgaenge">Abgänge (€)</label>
<input type="number" id="abgaenge" name="abgaenge"
value="<?= $h($asset['abgaenge'] ?? '0') ?>"
min="0" step="0.01">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group">
<label for="abschreibung_jahr">Abschreibung lfd. Jahr (€)</label>
<input type="number" id="abschreibung_jahr" name="abschreibung_jahr"
value="<?= $h($asset['abschreibung_jahr'] ?? '0') ?>"
min="0" step="0.01">
</div>
<div class="form-group">
<label for="nutzungsdauer">Nutzungsdauer (Jahre)</label>
<input type="number" id="nutzungsdauer" name="nutzungsdauer"
value="<?= $h($asset['nutzungsdauer'] ?? '') ?>"
min="1" step="1" placeholder="optional">
</div>
</div>
<div class="form-group">
<label for="notizen">Notizen</label>
<textarea id="notizen" name="notizen" rows="3"><?= $h($asset['notizen'] ?? '') ?></textarea>
</div>
<div style="display:flex;gap:.75rem;margin-top:1rem">
<button type="submit" class="btn btn-primary">Speichern</button>
<a href="/assets" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>

65
src/Views/assets/list.php Normal file
View file

@ -0,0 +1,65 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:4rem">Nr.</th>
<th>Anschaffungsdatum</th>
<th>Anlageart</th>
<th>Wirtschaftsgut</th>
<th class="td-num">Anschaffungskosten</th>
<th class="td-num">Zugänge</th>
<th class="td-num">Abgänge</th>
<th class="td-num">Abschreibungen</th>
<th style="width:6rem"></th>
</tr>
</thead>
<tbody>
<?php foreach ($assets as $a): ?>
<tr>
<td class="td-mono text-sm"><?= $h($a['anlage_nr'] ?: $a['lfd_nr']) ?></td>
<td class="td-muted text-sm"><?= View::formatDate($a['anschaffungsdatum']) ?></td>
<td class="td-muted text-sm">
<?php if ($a['anlageart_kontonummer']): ?>
<?= $h($a['anlageart_kontonummer']) ?> <?= $h($a['anlageart_bezeichnung']) ?>
<?php else: ?>
<span class="text-muted">–</span>
<?php endif; ?>
</td>
<td><?= $h($a['bezeichnung']) ?></td>
<td class="td-num"><?= number_format((float)$a['anschaffungskosten'], 2, ',', '.') ?> €</td>
<td class="td-num"><?= number_format((float)$a['zugaenge'], 2, ',', '.') ?> €</td>
<td class="td-num"><?= number_format((float)$a['abgaenge'], 2, ',', '.') ?> €</td>
<td class="td-num"><?= number_format((float)$a['abschreibung_jahr'], 2, ',', '.') ?> €</td>
<td>
<div style="display:flex;gap:.4rem;justify-content:flex-end">
<a href="/assets/<?= $h($a['id']) ?>/edit"
class="btn btn-secondary btn-sm"
title="Bearbeiten">
<!-- Bleistift -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</a>
<form method="post" action="/assets/<?= $h($a['id']) ?>/delete" style="margin:0"
data-confirm="Wirtschaftsgut &quot;<?= $h($a['bezeichnung']) ?>&quot; wirklich löschen?">
<button type="submit" class="btn btn-danger btn-sm" title="Löschen">
<!-- Papierkorb -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($assets)): ?>
<tr>
<td colspan="9" class="text-center text-muted" style="padding:2rem">
Noch keine Wirtschaftsgüter angelegt.
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>

View file

@ -197,6 +197,7 @@ $suppliers = $suppliers ?? [];
<th class="td-right">USt</th> <th class="td-right">USt</th>
<th class="td-right">Brutto</th> <th class="td-right">Brutto</th>
<th>Text</th> <th>Text</th>
<th>Anlage</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -222,6 +223,15 @@ $suppliers = $suppliers ?? [];
<span class="badge badge-red">Storniert</span> <span class="badge badge-red">Storniert</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td class="td-mono text-sm">
<?php if (!empty($bk['anlage_nr'])): ?>
<a href="/assets/<?= (int)$bk['asset_id'] ?>/edit" title="<?= $h($bk['anlage_bezeichnung']) ?>"><?= $h($bk['anlage_nr']) ?></a>
<?php elseif (!empty($bk['anlage_bezeichnung'])): ?>
<a href="/assets/<?= (int)$bk['asset_id'] ?>/edit"><?= $h($bk['anlage_bezeichnung']) ?></a>
<?php else: ?>
<span class="text-muted">–</span>
<?php endif; ?>
</td>
<td> <td>
<?php if (!$bk['locked'] && !$isReversal && !$isReversed): ?> <?php if (!$bk['locked'] && !$isReversal && !$isReversed): ?>
<div style="display:flex;gap:.35rem"> <div style="display:flex;gap:.35rem">
@ -263,7 +273,7 @@ $suppliers = $suppliers ?? [];
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($bookings)): ?> <?php if (empty($bookings)): ?>
<tr><td colspan="10" class="text-center text-muted" style="padding:1rem">Noch keine Buchungen.</td></tr> <tr><td colspan="11" class="text-center text-muted" style="padding:1rem">Noch keine Buchungen.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
@ -271,7 +281,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; ?>
@ -380,7 +390,7 @@ $suppliers = $suppliers ?? [];
data-target="kategorie_konto_id" autocomplete="off" style="margin-bottom:.3rem" data-target="kategorie_konto_id" autocomplete="off" style="margin-bottom:.3rem"
value="<?= $h($formData['kategorie_kontonummer'] ?? '') ?>"> value="<?= $h($formData['kategorie_kontonummer'] ?? '') ?>">
<select id="kategorie_konto_id" name="kategorie_konto_id" size="6" <select id="kategorie_konto_id" name="kategorie_konto_id" size="6"
onchange="showKontoArt('kategorie_konto_id','kategorie_konto_art',this);updateEigenanteilState();updateBewirtungsState();updateAutoUst()"> onchange="showKontoArt('kategorie_konto_id','kategorie_konto_art',this);updateEigenanteilState();updateBewirtungsState();updateAutoUst();checkAnlagevermoegen()">
<option value="">– Konto wählen –</option> <option value="">– Konto wählen –</option>
<?php foreach (['Betriebsausgabe', 'Betriebseinnahme', 'Anlagevermögen', 'Privat', 'Verbindlichkeit'] as $art): ?> <?php foreach (['Betriebsausgabe', 'Betriebseinnahme', 'Anlagevermögen', 'Privat', 'Verbindlichkeit'] as $art): ?>
<?php if (empty($kontenGruppen[$art])) continue; ?> <?php if (empty($kontenGruppen[$art])) continue; ?>
@ -477,6 +487,9 @@ $suppliers = $suppliers ?? [];
</label> </label>
</div> </div>
<!-- hidden: wird bei Auswahl eines bestehenden Anlageguts gesetzt -->
<input type="hidden" name="asset_id" id="asset_id_hidden" value="">
<button type="submit" class="btn btn-primary">Buchung erfassen</button> <button type="submit" class="btn btn-primary">Buchung erfassen</button>
</form> </form>
</div> </div>
@ -488,6 +501,184 @@ $suppliers = $suppliers ?? [];
</div> </div>
</div> </div>
<!-- ── Modal: Anlagevermögen ──────────────────────────────────── -->
<div id="modal-anlagevermoegen" class="modal-overlay" role="dialog" aria-modal="true">
<div class="modal" style="max-width:640px;width:100%">
<div class="modal-header">
<h3 class="modal-title">Anlagevermögen buchen</h3>
</div>
<div class="modal-body">
<p style="margin-bottom:1rem">Das gewählte Konto gehört zum Anlagevermögen. Soll ein neues Anlagegut angelegt oder ein bestehendes ausgewählt werden?</p>
<div style="display:flex;flex-direction:column;gap:.6rem;margin-bottom:1.25rem">
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer">
<input type="radio" name="anlage_wahl" id="anlage_radio_neu" value="neu" checked>
<span>Neues Anlagegut anlegen</span>
</label>
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer">
<input type="radio" name="anlage_wahl" id="anlage_radio_vorhanden" value="vorhanden">
<span>Bestehendes Anlagegut auswählen</span>
</label>
</div>
<!-- Liste bestehender Anlagegüter (nur bei "vorhanden") -->
<div id="anlage_liste_wrap" style="display:none">
<p id="anlage_liste_leer" class="text-muted text-sm" style="display:none">
Für dieses Konto sind noch keine Anlagegüter vorhanden.
</p>
<div class="table-wrap" id="anlage_liste_table_wrap">
<table>
<thead>
<tr>
<th style="width:3rem">Nr.</th>
<th>Wirtschaftsgut</th>
<th>Anschaffungsdatum</th>
<th class="td-num">Anschaffungskosten</th>
<th style="width:2.5rem"></th>
</tr>
</thead>
<tbody id="anlage_liste_tbody"></tbody>
</table>
</div>
</div>
</div>
<div class="modal-footer" style="display:flex;justify-content:flex-end;gap:.75rem;padding:1rem 1.5rem;border-top:1px solid var(--color-border)">
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button type="button" class="btn btn-primary" id="anlage_modal_ok">Weiter</button>
</div>
</div>
</div>
<?php
// Assets als JSON für JS vorbereiten
$assetsJson = json_encode(array_map(fn($a) => [
'id' => (int)$a['id'],
'lfd_nr' => (int)$a['lfd_nr'],
'bezeichnung' => $a['bezeichnung'],
'anschaffungsdatum' => $a['anschaffungsdatum'],
'anschaffungskosten' => (float)$a['anschaffungskosten'],
'account_id' => $a['account_id'] !== null ? (int)$a['account_id'] : null,
], $assets ?? []), JSON_HEX_TAG);
?>
<script>
const _assets = <?= $assetsJson ?>;
// Formatierung Datum de-DE
function _fmtDate(str) {
if (!str) return '–';
const [y, m, d] = str.split('-');
return d + '.' + m + '.' + y;
}
function _fmtEur(n) {
return n.toLocaleString('de-DE', {minimumFractionDigits:2, maximumFractionDigits:2}) + ' €';
}
// Wird aufgerufen wenn Kategorie-Konto geändert wird
function checkAnlagevermoegen() {
const sel = document.getElementById('kategorie_konto_id');
if (!sel) return;
const opt = sel.options[sel.selectedIndex];
if (!opt || opt.value === '') return;
const art = opt.getAttribute('data-art') || '';
if (art !== 'Anlagevermögen') {
// Kein AV-Konto – Asset-Auswahl zurücksetzen
document.getElementById('asset_id_hidden').value = '';
return;
}
// AV-Konto gewählt → Modal öffnen
const accountId = parseInt(opt.value, 10);
_openAnlageModal(accountId);
}
function _openAnlageModal(accountId) {
// Radio auf "neu" zurücksetzen
document.getElementById('anlage_radio_neu').checked = true;
document.getElementById('anlage_liste_wrap').style.display = 'none';
document.getElementById('asset_id_hidden').value = '';
// Liste für dieses Konto befüllen
const passend = _assets.filter(a => a.account_id === accountId);
_renderAnlageListe(passend);
document.getElementById('modal-anlagevermoegen').classList.add('open');
}
function _renderAnlageListe(list) {
const tbody = document.getElementById('anlage_liste_tbody');
const leer = document.getElementById('anlage_liste_leer');
const table = document.getElementById('anlage_liste_table_wrap');
tbody.innerHTML = '';
if (list.length === 0) {
leer.style.display = '';
table.style.display = 'none';
return;
}
leer.style.display = 'none';
table.style.display = '';
list.forEach(a => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.dataset.assetId = a.id;
tr.innerHTML =
'<td class="td-mono text-sm">' + a.lfd_nr + '</td>' +
'<td>' + _esc(a.bezeichnung) + '</td>' +
'<td class="td-muted text-sm">' + _fmtDate(a.anschaffungsdatum) + '</td>' +
'<td class="td-num">' + _fmtEur(a.anschaffungskosten) + '</td>' +
'<td><input type="radio" name="anlage_auswahl" value="' + a.id + '"></td>';
tbody.appendChild(tr);
// Klick auf Zeile wählt Radio
tr.addEventListener('click', () => {
const radio = tr.querySelector('input[type="radio"]');
if (radio) radio.checked = true;
tbody.querySelectorAll('tr').forEach(r => r.classList.remove('row-selected'));
tr.classList.add('row-selected');
});
});
}
function _esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Radio-Wahl: neu / vorhanden
document.addEventListener('change', e => {
if (e.target.name !== 'anlage_wahl') return;
const wrap = document.getElementById('anlage_liste_wrap');
wrap.style.display = e.target.value === 'vorhanden' ? '' : 'none';
if (e.target.value === 'neu') {
document.getElementById('asset_id_hidden').value = '';
document.querySelectorAll('input[name="anlage_auswahl"]').forEach(r => r.checked = false);
document.querySelectorAll('#anlage_liste_tbody tr').forEach(r => r.classList.remove('row-selected'));
}
});
// OK-Button
document.getElementById('anlage_modal_ok').addEventListener('click', () => {
const wahl = document.querySelector('input[name="anlage_wahl"]:checked')?.value;
if (wahl === 'vorhanden') {
const gewählt = document.querySelector('input[name="anlage_auswahl"]:checked');
if (!gewählt) {
alert('Bitte ein Anlagegut aus der Liste auswählen.');
return;
}
document.getElementById('asset_id_hidden').value = gewählt.value;
} else {
// "neu" – asset_id bleibt leer, Backend legt neues Gut an
document.getElementById('asset_id_hidden').value = '';
}
document.getElementById('modal-anlagevermoegen').classList.remove('open');
});
</script>
<script> <script>
function filterKategorieKonten(buchungstyp) { function filterKategorieKonten(buchungstyp) {
const select = document.getElementById('kategorie_konto_id'); const select = document.getElementById('kategorie_konto_id');
@ -630,9 +821,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 +872,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 +884,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 +929,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

@ -49,6 +49,7 @@
<th class="td-right">USt (%)</th> <th class="td-right">USt (%)</th>
<th class="td-right">Brutto</th> <th class="td-right">Brutto</th>
<th>Buchungstext</th> <th>Buchungstext</th>
<th>Anlage</th>
<?php if ($testMode): ?><th></th><?php endif; ?> <?php if ($testMode): ?><th></th><?php endif; ?>
</tr> </tr>
</thead> </thead>
@ -84,6 +85,15 @@
<span class="badge badge-red">Storniert</span> <span class="badge badge-red">Storniert</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td class="td-mono text-sm">
<?php if (!empty($bk['anlage_nr'])): ?>
<a href="/assets/<?= (int)$bk['asset_id'] ?>/edit" title="<?= $h($bk['anlage_bezeichnung']) ?>"><?= $h($bk['anlage_nr']) ?></a>
<?php elseif (!empty($bk['anlage_bezeichnung'])): ?>
<a href="/assets/<?= (int)$bk['asset_id'] ?>/edit"><?= $h($bk['anlage_bezeichnung']) ?></a>
<?php else: ?>
<span class="text-muted">–</span>
<?php endif; ?>
</td>
<?php if ($testMode): ?> <?php if ($testMode): ?>
<td> <td>
<form method="post" action="/bookings/<?= $h($bk['id']) ?>/delete" <form method="post" action="/bookings/<?= $h($bk['id']) ?>/delete"
@ -95,7 +105,7 @@
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($rows)): ?> <?php if (empty($rows)): ?>
<tr><td colspan="<?= $testMode ? 11 : 10 ?>" class="text-center text-muted" style="padding:2rem">Keine Buchungen gefunden.</td></tr> <tr><td colspan="<?= $testMode ? 12 : 11 ?>" class="text-center text-muted" style="padding:2rem">Keine Buchungen gefunden.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>

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>
@ -49,6 +49,16 @@ function navActive(string $prefix, string $current): string {
</nav> </nav>
</div> </div>
<div class="sidebar-section">
<div class="sidebar-label">Betriebsvermögen</div>
<nav>
<a href="/assets" class="<?= navActive('/assets', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/></svg>
Anlagevermögen
</a>
</nav>
</div>
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-label">Steuerauswertungen</div> <div class="sidebar-label">Steuerauswertungen</div>
<nav> <nav>

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>
@ -249,6 +232,10 @@
<label>Beleg-Präfix</label> <label>Beleg-Präfix</label>
<input type="text" name="beleg_nr_prefix" value="<?= $h($settings['beleg_nr_prefix'] ?? 'BLG') ?>" maxlength="10"> <input type="text" name="beleg_nr_prefix" value="<?= $h($settings['beleg_nr_prefix'] ?? 'BLG') ?>" maxlength="10">
</div> </div>
<div class="form-group">
<label>Anlagen-Präfix</label>
<input type="text" name="anlage_nr_prefix" value="<?= $h($settings['anlage_nr_prefix'] ?? 'ANL') ?>" maxlength="10">
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Standard-Zahlungsziel (Tage)</label> <label>Standard-Zahlungsziel (Tage)</label>

View file

@ -31,6 +31,7 @@ $fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
<th class="td-right">Netto</th> <th class="td-right">Netto</th>
<th class="td-right">USt</th> <th class="td-right">USt</th>
<th class="td-right">Brutto</th> <th class="td-right">Brutto</th>
<th>Anlage</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -62,12 +63,21 @@ $fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
<td class="td-right td-mono"><?= $fmt($netto) ?></td> <td class="td-right td-mono"><?= $fmt($netto) ?></td>
<td class="td-right td-mono"><?= $fmt($ust) ?></td> <td class="td-right td-mono"><?= $fmt($ust) ?></td>
<td class="td-right td-mono"><?= $fmt($brutto) ?></td> <td class="td-right td-mono"><?= $fmt($brutto) ?></td>
<td class="td-mono text-sm">
<?php if (!empty($b['anlage_nr'])): ?>
<a href="/assets/<?= (int)$b['asset_id'] ?>/edit" title="<?= $h($b['anlage_bezeichnung']) ?>"><?= $h($b['anlage_nr']) ?></a>
<?php elseif (!empty($b['anlage_bezeichnung'])): ?>
<a href="/assets/<?= (int)$b['asset_id'] ?>/edit"><?= $h($b['anlage_bezeichnung']) ?></a>
<?php else: ?>
<span class="text-muted">–</span>
<?php endif; ?>
</td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
<tfoot> <tfoot>
<tr style="font-weight:700;border-top:2px solid #000"> <tr style="font-weight:700;border-top:2px solid #000">
<td colspan="4">Summe (<?= count($bookings) ?> Buchungen)</td> <td colspan="5">Summe (<?= count($bookings) ?> Buchungen)</td>
<td class="td-right td-mono"><?= $fmt($sumNetto) ?></td> <td class="td-right td-mono"><?= $fmt($sumNetto) ?></td>
<td class="td-right td-mono"><?= $fmt($sumUst) ?></td> <td class="td-right td-mono"><?= $fmt($sumUst) ?></td>
<td class="td-right td-mono"><?= $fmt($sumBrutto) ?></td> <td class="td-right td-mono"><?= $fmt($sumBrutto) ?></td>

View file

@ -51,6 +51,7 @@ $signedBetrag = fn(array $b, string $field): float =>
<th class="td-right">Netto</th> <th class="td-right">Netto</th>
<th class="td-right"><?= $kz === '69' ? 'USt-Beitrag' : 'USt' ?></th> <th class="td-right"><?= $kz === '69' ? 'USt-Beitrag' : 'USt' ?></th>
<th class="td-right">Brutto</th> <th class="td-right">Brutto</th>
<th>Anlage</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -81,12 +82,21 @@ $signedBetrag = fn(array $b, string $field): float =>
<td class="td-right td-mono"><?= $fmtKz($netto) ?></td> <td class="td-right td-mono"><?= $fmtKz($netto) ?></td>
<td class="td-right td-mono"><?= $fmtKz($ust) ?></td> <td class="td-right td-mono"><?= $fmtKz($ust) ?></td>
<td class="td-right td-mono"><?= $fmtKz($brutto) ?></td> <td class="td-right td-mono"><?= $fmtKz($brutto) ?></td>
<td class="td-mono text-sm">
<?php if (!empty($b['anlage_nr'])): ?>
<a href="/assets/<?= (int)$b['asset_id'] ?>/edit" title="<?= $h($b['anlage_bezeichnung']) ?>"><?= $h($b['anlage_nr']) ?></a>
<?php elseif (!empty($b['anlage_bezeichnung'])): ?>
<a href="/assets/<?= (int)$b['asset_id'] ?>/edit"><?= $h($b['anlage_bezeichnung']) ?></a>
<?php else: ?>
<span class="text-muted">–</span>
<?php endif; ?>
</td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
<tfoot> <tfoot>
<tr style="font-weight:700;border-top:2px solid #000"> <tr style="font-weight:700;border-top:2px solid #000">
<td colspan="5">Summe (<?= count($bookings) ?> Buchungen)</td> <td colspan="6">Summe (<?= count($bookings) ?> Buchungen)</td>
<td class="td-right td-mono"><?= $fmtKz($sumNetto) ?></td> <td class="td-right td-mono"><?= $fmtKz($sumNetto) ?></td>
<td class="td-right td-mono"><?= $fmtKz($sumUst) ?></td> <td class="td-right td-mono"><?= $fmtKz($sumUst) ?></td>
<td class="td-right td-mono"><?= $fmtKz($sumBrutto) ?></td> <td class="td-right td-mono"><?= $fmtKz($sumBrutto) ?></td>

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\Asset;
/**
* Test 20: Anlagevermögen – CRUD und Listenfunktion.
*/
class AnlagevermoegensTest extends TestCase
{
protected string $testName = 'Test 20: Anlagevermögen (Assets)';
public function run(): void
{
// ── Anlegen ───────────────────────────────────────────────────
$id = Asset::create([
'bezeichnung' => 'Test-Laptop',
'anschaffungsdatum' => '2026-01-15',
'anschaffungskosten' => 1500.00,
'zugaenge' => 0,
'abgaenge' => 0,
'abschreibung_jahr' => 300.00,
'nutzungsdauer' => 5,
'notizen' => 'Testnotiz',
]);
$this->assertGreaterThan(0, $id, 'Asset wurde angelegt (ID > 0)');
// ── find() ────────────────────────────────────────────────────
$asset = Asset::find($id);
$this->assertNotEmpty($asset, 'Asset::find() liefert einen Datensatz');
$this->assertEquals('Test-Laptop', $asset['bezeichnung'], 'Bezeichnung korrekt');
$this->assertEquals('2026-01-15', $asset['anschaffungsdatum'], 'Anschaffungsdatum korrekt');
$this->assertEquals(1500.00, (float)$asset['anschaffungskosten'], 'Anschaffungskosten korrekt');
$this->assertEquals(300.00, (float)$asset['abschreibung_jahr'], 'Abschreibung korrekt');
$this->assertEquals(5, (int)$asset['nutzungsdauer'], 'Nutzungsdauer korrekt');
// ── Lfd. Nr. und Anlage-Nr. werden automatisch vergeben ──────
$this->assertGreaterThan(0, (int)$asset['lfd_nr'], 'Lfd. Nr. > 0');
$this->assertNotEmpty($asset['anlage_nr'], 'Anlage-Nr. wurde generiert');
$this->assert(str_contains($asset['anlage_nr'], '-'), 'Anlage-Nr. enthält Präfix-Trenner');
// ── all() enthält das neue Asset ─────────────────────────────
$all = Asset::all();
$found = array_filter($all, fn($a) => (int)$a['id'] === $id);
$this->assert(!empty($found), 'Asset erscheint in Asset::all()');
// ── update() ──────────────────────────────────────────────────
Asset::update($id, [
'bezeichnung' => 'Test-Laptop (aktualisiert)',
'anschaffungsdatum' => '2026-01-15',
'anschaffungskosten' => 1500.00,
'zugaenge' => 200.00,
'abgaenge' => 0,
'abschreibung_jahr' => 340.00,
'nutzungsdauer' => 5,
'notizen' => '',
]);
$updated = Asset::find($id);
$this->assertEquals('Test-Laptop (aktualisiert)', $updated['bezeichnung'], 'Bezeichnung nach Update korrekt');
$this->assertEquals(200.00, (float)$updated['zugaenge'], 'Zugänge nach Update korrekt');
$this->assertEquals(340.00, (float)$updated['abschreibung_jahr'], 'Abschreibung nach Update korrekt');
// ── delete() ──────────────────────────────────────────────────
$deleted = Asset::delete($id);
$this->assert($deleted, 'Asset::delete() gibt true zurück');
$this->assert(Asset::find($id) === false, 'Gelöschtes Asset nicht mehr auffindbar');
}
}