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:
commit
6fbe281175
32 changed files with 1732 additions and 141 deletions
|
|
@ -11,6 +11,9 @@ DB_PASS=steuerkram
|
||||||
# OLLAMA_URL=http://localhost:11434
|
# OLLAMA_URL=http://localhost:11434
|
||||||
# OLLAMA_MODEL=llama3.2
|
# OLLAMA_MODEL=llama3.2
|
||||||
#
|
#
|
||||||
|
# Vision-Modell für Beleglesung (multimodales Ollama-Modell, benötigt OLLAMA_URL)
|
||||||
|
# VISION_MODEL=qwen2.5vl:latest
|
||||||
|
#
|
||||||
# Option B: Remote-LLM per OpenAI-kompatibler API (z.B. OpenAI, OpenRouter, LM Studio)
|
# Option B: Remote-LLM per OpenAI-kompatibler API (z.B. OpenAI, OpenRouter, LM Studio)
|
||||||
# LLM_API_URL=https://api.openai.com/v1
|
# LLM_API_URL=https://api.openai.com/v1
|
||||||
# LLM_API_KEY=sk-...
|
# LLM_API_KEY=sk-...
|
||||||
|
|
|
||||||
29
Changes.md
29
Changes.md
|
|
@ -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
290
README.md
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
20
migrations/028_assets_anlagevermoegen.sql
Normal file
20
migrations/028_assets_anlagevermoegen.sql
Normal 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)';
|
||||||
6
migrations/029_assets_anlageart.sql
Normal file
6
migrations/029_assets_anlageart.sql
Normal 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);
|
||||||
6
migrations/030_bookings_asset_id.sql
Normal file
6
migrations/030_bookings_asset_id.sql
Normal 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);
|
||||||
13
migrations/031_anlage_nr.sql
Normal file
13
migrations/031_anlage_nr.sql
Normal 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;
|
||||||
23
migrations/032_anlage_nr_backfill.sql
Normal file
23
migrations/032_anlage_nr_backfill.sql
Normal 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;
|
||||||
|
|
@ -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
104
scripts/ki-batch.php
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KI-Kontierungsvorschlag – Batch-Verarbeitung (CLI-Einstiegspunkt).
|
||||||
|
*
|
||||||
|
* Verarbeitet EINEN ausstehenden Beleg pro Aufruf (ki_vorschlag IS NULL, locked = 0).
|
||||||
|
* Verhindert so, dass mehrere Vision-Model-Anfragen parallel an Ollama geschickt werden.
|
||||||
|
*
|
||||||
|
* Voraussetzung: ki_vorschlag_aktiv = 1 in den Einstellungen, Ollama erreichbar.
|
||||||
|
*
|
||||||
|
* Aufruf:
|
||||||
|
* docker compose exec web php scripts/ki-batch.php
|
||||||
|
*
|
||||||
|
* Für optionalen eigenen Cronjob (manuelle Uploads ohne Nextcloud):
|
||||||
|
* *\/5 * * * * docker compose -f /opt/steuerkram/docker-compose.yml exec -T web \
|
||||||
|
* php scripts/ki-batch.php >> /var/log/steuerkram-ki.log 2>&1
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
chdir(dirname(__DIR__));
|
||||||
|
|
||||||
|
// DB-Zugangsdaten aus .env laden (für CLI-Kontext ohne Apache-Umgebung)
|
||||||
|
if (file_exists('.env')) {
|
||||||
|
foreach (file('.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||||
|
if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
[$key, $value] = explode('=', $line, 2);
|
||||||
|
$key = trim($key);
|
||||||
|
$value = trim($value, " \t\n\r\0\x0B\"'");
|
||||||
|
if (!array_key_exists($key, $_ENV)) {
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
putenv("{$key}={$value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once 'vendor/autoload.php';
|
||||||
|
|
||||||
|
use Steuerkram\Config;
|
||||||
|
use Steuerkram\Database;
|
||||||
|
use Steuerkram\Models\Beleg;
|
||||||
|
use Steuerkram\Models\Setting;
|
||||||
|
use Steuerkram\Services\KontierungsvorschlagService;
|
||||||
|
|
||||||
|
Config::load();
|
||||||
|
|
||||||
|
$tag = '[ki-batch]';
|
||||||
|
|
||||||
|
// KI-Erkennung in den Einstellungen aktiv?
|
||||||
|
$settings = Setting::get();
|
||||||
|
if (empty($settings['ki_vorschlag_aktiv'])) {
|
||||||
|
echo "{$tag} KI-Erkennung ist in den Einstellungen deaktiviert – nichts zu tun." . PHP_EOL;
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$svc = new KontierungsvorschlagService();
|
||||||
|
|
||||||
|
if (!$svc->isEnabled()) {
|
||||||
|
echo "{$tag} LLM nicht erreichbar – Abbruch." . PHP_EOL;
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionale Beleg-ID als CLI-Argument: php ki-batch.php 123
|
||||||
|
$argId = isset($argv[1]) && ctype_digit((string)$argv[1]) ? (int)$argv[1] : null;
|
||||||
|
|
||||||
|
// Beleg holen: wenn ID angegeben, diesen direkt verarbeiten; sonst ältesten ausstehenden
|
||||||
|
$row = $argId !== null
|
||||||
|
? Database::fetchOne('SELECT id FROM belege WHERE id = ? AND ki_vorschlag IS NULL AND locked = 0', [$argId])
|
||||||
|
: Database::fetchOne('SELECT id FROM belege WHERE ki_vorschlag IS NULL AND locked = 0 ORDER BY id ASC LIMIT 1');
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
echo "{$tag} Kein ausstehender Beleg gefunden." . PHP_EOL;
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$belegId = (int)$row['id'];
|
||||||
|
$beleg = Beleg::find($belegId);
|
||||||
|
|
||||||
|
if (!$beleg) {
|
||||||
|
echo "{$tag} Beleg #{$belegId} nicht ladbar – Abbruch." . PHP_EOL;
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "{$tag} Verarbeite Beleg #{$belegId} ({$beleg['beleg_nr']}) ..." . PHP_EOL;
|
||||||
|
|
||||||
|
$start = microtime(true);
|
||||||
|
$result = $svc->generateForBeleg($beleg);
|
||||||
|
|
||||||
|
if ($result === null) {
|
||||||
|
echo "{$tag} Beleg #{$belegId}: Kein Vorschlag generiert (nicht unterstützter Typ oder LLM-Fehler)." . PHP_EOL;
|
||||||
|
// Leeres Array speichern damit der Beleg nicht erneut verarbeitet wird
|
||||||
|
Beleg::saveKiVorschlag($belegId, []);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Beleg::saveKiVorschlag($belegId, $result);
|
||||||
|
|
||||||
|
$elapsed = round(microtime(true) - $start, 1);
|
||||||
|
$zeilen = count($result);
|
||||||
|
echo "{$tag} Beleg #{$belegId}: {$zeilen} Buchungszeile(n) generiert in {$elapsed}s." . PHP_EOL;
|
||||||
|
|
||||||
|
exit(0);
|
||||||
|
|
@ -41,6 +41,7 @@ if (file_exists('.env')) {
|
||||||
require_once 'vendor/autoload.php';
|
require_once 'vendor/autoload.php';
|
||||||
|
|
||||||
use Steuerkram\Config;
|
use Steuerkram\Config;
|
||||||
|
use Steuerkram\Models\Setting;
|
||||||
use Steuerkram\Services\NextcloudImportService;
|
use Steuerkram\Services\NextcloudImportService;
|
||||||
|
|
||||||
Config::load(); // Database verbindet sich lazy über Config::get()
|
Config::load(); // Database verbindet sich lazy über Config::get()
|
||||||
|
|
@ -64,4 +65,16 @@ echo sprintf(
|
||||||
$result['errors'],
|
$result['errors'],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// KI-Vorschlag für jeden neu importierten Beleg anstoßen (wenn KI-Erkennung aktiv)
|
||||||
|
if (!empty($result['imported_ids'])) {
|
||||||
|
$settings = Setting::get();
|
||||||
|
if (!empty($settings['ki_vorschlag_aktiv'])) {
|
||||||
|
$kiBatch = __DIR__ . '/ki-batch.php';
|
||||||
|
echo '[nextcloud-import] Starte KI-Erkennung für ' . count($result['imported_ids']) . ' importierte(n) Beleg(e) ...' . PHP_EOL;
|
||||||
|
foreach ($result['imported_ids'] as $belegId) {
|
||||||
|
passthru('php ' . escapeshellarg($kiBatch) . ' ' . (int)$belegId . ' 2>&1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
exit($result['errors'] > 0 ? 1 : 0);
|
exit($result['errors'] > 0 ? 1 : 0);
|
||||||
|
|
|
||||||
159
src/Controllers/AssetController.php
Normal file
159
src/Controllers/AssetController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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
155
src/Models/Asset.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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,43 +130,222 @@ 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)) return null;
|
if (is_array($data) && isset($data[0]) && is_array($data[0])) {
|
||||||
|
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
|
||||||
|
if (preg_match('/\{[^{}]*\}/s', $response, $m)) {
|
||||||
|
$data = json_decode($m[0], true);
|
||||||
|
if (is_array($data)) {
|
||||||
|
return $this->validateLines([$data], $accounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
|
||||||
|
), 'id');
|
||||||
|
$zahlungsIds = array_column(array_filter($accounts,
|
||||||
|
fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true)
|
||||||
|
), 'id');
|
||||||
|
|
||||||
|
$valid = [];
|
||||||
|
foreach ($lines as $data) {
|
||||||
|
if (!is_array($data)) continue;
|
||||||
|
|
||||||
|
$required = ['buchungstyp', 'kategorie_konto_id', 'zahlungskonto_id', 'ust_satz', 'betrag_netto'];
|
||||||
|
$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['buchungstyp'] = in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) ? $data['buchungstyp'] : 'Ausgabe';
|
||||||
$data['kategorie_konto_id'] = (int)$data['kategorie_konto_id'];
|
$data['kategorie_konto_id'] = (int)$data['kategorie_konto_id'];
|
||||||
$data['zahlungskonto_id'] = (int)$data['zahlungskonto_id'];
|
$data['zahlungskonto_id'] = (int)$data['zahlungskonto_id'];
|
||||||
|
|
@ -162,15 +354,9 @@ PROMPT;
|
||||||
$data['buchungstext'] = (string)($data['buchungstext'] ?? '');
|
$data['buchungstext'] = (string)($data['buchungstext'] ?? '');
|
||||||
$data['supplier_name'] = trim((string)($data['supplier_name'] ?? ''));
|
$data['supplier_name'] = trim((string)($data['supplier_name'] ?? ''));
|
||||||
|
|
||||||
// IDs gegen Kontenliste validieren (mit Kontenartenprüfung)
|
// Konto-IDs validieren
|
||||||
$kategorieIds = array_column(array_filter($accounts,
|
if (!in_array($data['kategorie_konto_id'], $kategorieIds, false)) continue;
|
||||||
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
|
if (!in_array($data['zahlungskonto_id'], $zahlungsIds, false)) continue;
|
||||||
), 'id');
|
|
||||||
$zahlungsIds = array_column(array_filter($accounts,
|
|
||||||
fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true)
|
|
||||||
), '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
|
// Lieferant gegen vorhandene Suppliers prüfen
|
||||||
if ($data['supplier_name'] !== '') {
|
if ($data['supplier_name'] !== '') {
|
||||||
|
|
@ -180,6 +366,9 @@ PROMPT;
|
||||||
$data['supplier_found'] = null;
|
$data['supplier_found'] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
$valid[] = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return empty($valid) ? null : $valid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ 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 $visionModel = '';
|
||||||
private ?string $apiKey = null;
|
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'] ?? '');
|
||||||
|
|
@ -58,6 +103,7 @@ class LlmService
|
||||||
$this->mode = 'ollama';
|
$this->mode = 'ollama';
|
||||||
$this->url = rtrim($ollamaUrl, '/');
|
$this->url = rtrim($ollamaUrl, '/');
|
||||||
$this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2');
|
$this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2');
|
||||||
|
$this->visionModel = getenv('VISION_MODEL') ?: ($_ENV['VISION_MODEL'] ?? 'qwen2.5vl:latest');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class NextcloudImportService
|
||||||
*/
|
*/
|
||||||
public function import(): array
|
public function import(): array
|
||||||
{
|
{
|
||||||
$result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => []];
|
$result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [], 'imported_ids' => []];
|
||||||
|
|
||||||
if (!$this->baseUrl || !$this->user || !$this->pass) {
|
if (!$this->baseUrl || !$this->user || !$this->pass) {
|
||||||
$result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.';
|
$result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.';
|
||||||
|
|
@ -262,7 +262,7 @@ class NextcloudImportService
|
||||||
|
|
||||||
// In Datenbank anlegen
|
// In Datenbank anlegen
|
||||||
$today = date('Y-m-d');
|
$today = date('Y-m-d');
|
||||||
Beleg::create([
|
$belegId = Beleg::create([
|
||||||
'beleg_nr' => $belegNr,
|
'beleg_nr' => $belegNr,
|
||||||
'filename' => $filename,
|
'filename' => $filename,
|
||||||
'storage_path' => $storagePath,
|
'storage_path' => $storagePath,
|
||||||
|
|
@ -280,6 +280,7 @@ class NextcloudImportService
|
||||||
|
|
||||||
$result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}";
|
$result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}";
|
||||||
$result['imported']++;
|
$result['imported']++;
|
||||||
|
$result['imported_ids'][] = $belegId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
94
src/Views/assets/form.php
Normal 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
65
src/Views/assets/list.php
Normal 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 "<?= $h($a['bezeichnung']) ?>" 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>
|
||||||
|
|
@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
69
tests/20_AnlagevermoegensTest.php
Normal file
69
tests/20_AnlagevermoegensTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue