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_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)
|
||||
# LLM_API_URL=https://api.openai.com/v1
|
||||
# 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
|
||||
|
||||
### Geändert
|
||||
|
|
|
|||
290
README.md
290
README.md
|
|
@ -35,7 +35,295 @@ cd /opt/Steuerkram
|
|||
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
|
||||
|
||||
Kein API-Schlüssel nötig, aber hoher Ressourcenbedarf:
|
||||
|
||||
```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:
|
||||
|
||||
```dotenv
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=mistral
|
||||
OLLAMA_MODEL=qwen2.5vl
|
||||
```
|
||||
|
||||
### Option B – Remote-API (OpenAI-kompatibel)
|
||||
|
|
@ -49,7 +56,7 @@ 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.
|
||||
> **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**.
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
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\SettingsController;
|
||||
use Steuerkram\Controllers\LoginController;
|
||||
use Steuerkram\Controllers\AssetController;
|
||||
use Steuerkram\Controllers\UserAdminController;
|
||||
|
||||
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/{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 ────────────────────────────────────────────────────
|
||||
$router->get('/tax/ustva', fn($p) => (new UstvaController())->index($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';
|
||||
|
||||
use Steuerkram\Config;
|
||||
use Steuerkram\Models\Setting;
|
||||
use Steuerkram\Services\NextcloudImportService;
|
||||
|
||||
Config::load(); // Database verbindet sich lazy über Config::get()
|
||||
|
|
@ -64,4 +65,16 @@ echo sprintf(
|
|||
$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);
|
||||
|
|
|
|||
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\Booking;
|
||||
use Steuerkram\Models\Account;
|
||||
use Steuerkram\Models\Asset;
|
||||
use Steuerkram\Models\Supplier;
|
||||
use Steuerkram\Config;
|
||||
use Steuerkram\View;
|
||||
|
|
@ -198,12 +199,15 @@ class BelegController
|
|||
$integrity = Beleg::verifyIntegrity((int)$params['id']);
|
||||
$accounts = Account::all(['aktiv' => 1]);
|
||||
$suppliers = Supplier::all();
|
||||
$assets = Asset::all();
|
||||
|
||||
$svc = new KontierungsvorschlagService();
|
||||
$kiAktiv = $svc->isEnabled();
|
||||
$kiVorschlag = $beleg['ki_vorschlag'] !== null
|
||||
? json_decode($beleg['ki_vorschlag'], true)
|
||||
: null;
|
||||
// KI-Sektion anzeigen wenn Ergebnis vorhanden ODER LLM gerade verfügbar
|
||||
$kiSektion = $kiAktiv || $kiVorschlag !== null;
|
||||
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
|
|
@ -214,7 +218,9 @@ class BelegController
|
|||
'bookings' => $bookings,
|
||||
'integrity' => $integrity,
|
||||
'accounts' => $accounts,
|
||||
'assets' => $assets,
|
||||
'kiAktiv' => $kiAktiv,
|
||||
'kiSektion' => $kiSektion,
|
||||
'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(
|
||||
'SELECT kontonummer FROM accounts WHERE id = ?',
|
||||
'SELECT id, kontonummer, kontenart FROM accounts WHERE id = ?',
|
||||
[(int)$data['kategorie_konto_id']]
|
||||
);
|
||||
$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)) {
|
||||
$this->bucheBewirtungssplit($data, $kontonummer);
|
||||
} else {
|
||||
|
|
@ -180,6 +195,7 @@ class BookingController
|
|||
$bookings = \Steuerkram\Models\Booking::forBeleg($belegId);
|
||||
$accounts = \Steuerkram\Models\Account::all(['aktiv' => 1]);
|
||||
$integrity = \Steuerkram\Models\Beleg::verifyIntegrity($belegId);
|
||||
$assets = \Steuerkram\Models\Asset::all();
|
||||
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
|
|
@ -189,6 +205,7 @@ class BookingController
|
|||
'bookings' => $bookings,
|
||||
'integrity' => $integrity,
|
||||
'accounts' => $accounts,
|
||||
'assets' => $assets,
|
||||
'formErrors' => $errors,
|
||||
'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(
|
||||
'SELECT bk.*,
|
||||
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
|
||||
JOIN accounts k ON bk.kategorie_konto_id = k.id
|
||||
JOIN accounts z ON bk.zahlungskonto_id = z.id
|
||||
LEFT JOIN assets a ON bk.asset_id = a.id
|
||||
WHERE bk.beleg_id = ?
|
||||
ORDER BY bk.buchungs_datum, bk.id',
|
||||
[$belegId]
|
||||
|
|
@ -50,12 +52,14 @@ class Booking
|
|||
$sql = 'SELECT bk.*,
|
||||
k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_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
|
||||
JOIN accounts k ON bk.kategorie_konto_id = k.id
|
||||
JOIN accounts z ON bk.zahlungskonto_id = z.id
|
||||
JOIN belege bl ON bk.beleg_id = bl.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) . '
|
||||
ORDER BY bk.buchungs_datum DESC, bk.id DESC
|
||||
LIMIT ? OFFSET ?';
|
||||
|
|
@ -101,15 +105,18 @@ class Booking
|
|||
$steuerart = 'normal';
|
||||
}
|
||||
|
||||
$assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null;
|
||||
|
||||
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,
|
||||
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto,
|
||||
buchungstext, fiscal_year_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
$nr,
|
||||
(int)$data['beleg_id'],
|
||||
$assetId,
|
||||
$data['buchungs_datum'],
|
||||
$data['buchungstyp'],
|
||||
(int)$data['kategorie_konto_id'],
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ class Setting
|
|||
ust_id=?, steuernummer=?, finanzamt=?,
|
||||
iban=?, bic=?, bank_name=?,
|
||||
ustva_steuernummer=?, ustva_bundesland=?,
|
||||
invoice_nr_prefix=?, beleg_nr_prefix=?,
|
||||
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?, ocr_aktiv=?,
|
||||
invoice_nr_prefix=?, beleg_nr_prefix=?, anlage_nr_prefix=?,
|
||||
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?,
|
||||
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
|
||||
nextcloud_folder=?, nextcloud_done=?,
|
||||
logo_max_width=?, logo_max_height=?,
|
||||
|
|
@ -58,10 +58,10 @@ class Setting
|
|||
$data['ustva_bundesland'] ?? null,
|
||||
$data['invoice_nr_prefix'] ?? 'AR',
|
||||
$data['beleg_nr_prefix'] ?? 'BLG',
|
||||
$data['anlage_nr_prefix'] ?? 'ANL',
|
||||
(int)($data['payment_days_default'] ?? 14),
|
||||
isset($data['test_mode']) ? 1 : 0,
|
||||
isset($data['ki_vorschlag_aktiv']) ? 1 : 0,
|
||||
isset($data['ocr_aktiv']) ? 1 : 0,
|
||||
$data['nextcloud_url'] ?: null,
|
||||
$data['nextcloud_user'] ?: null,
|
||||
$data['nextcloud_pass'] ?: null,
|
||||
|
|
|
|||
|
|
@ -80,14 +80,16 @@ class EuerCalculator
|
|||
return Database::fetchAll(
|
||||
'SELECT bk.id, bk.buchungs_datum, bk.buchungstyp, bk.ust_satz,
|
||||
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,
|
||||
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
|
||||
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
||||
LEFT JOIN belege bl ON bk.beleg_id = bl.id
|
||||
JOIN accounts k ON bk.kategorie_konto_id = k.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 = ?
|
||||
ORDER BY bk.buchungs_datum, bk.id',
|
||||
[$year, $kontoId]
|
||||
|
|
|
|||
|
|
@ -24,12 +24,6 @@ class KontierungsvorschlagService
|
|||
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
|
||||
{
|
||||
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||
|
|
@ -38,14 +32,33 @@ class KontierungsvorschlagService
|
|||
$mime = strtolower($beleg['mime_type'] ?? '');
|
||||
|
||||
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);
|
||||
} 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 '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$text = $this->extractText($beleg);
|
||||
|
|
@ -82,20 +95,20 @@ class KontierungsvorschlagService
|
|||
));
|
||||
|
||||
$beleginhalt = trim($text) !== ''
|
||||
? "Belegtext:\n" . mb_substr(trim($text), 0, 2000)
|
||||
? "Belegtext:\n" . mb_substr(trim($text), 0, 3000)
|
||||
: '[Kein Text extrahierbar]';
|
||||
|
||||
$settings = Setting::get();
|
||||
$eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? ''));
|
||||
$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: 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/Verkaeufer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar.";
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
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:
|
||||
{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":19,"betrag_netto":33.64,"buchungstext":"Rechnung 2026-001","supplier_name":"Mustermann GmbH"}
|
||||
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":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:
|
||||
- buchungstyp: "Ausgabe" oder "Einnahme"
|
||||
|
|
@ -117,43 +130,222 @@ PROMPT;
|
|||
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
|
||||
{
|
||||
$safe = escapeshellarg($path);
|
||||
$output = [];
|
||||
exec("pdftotext -l 1 {$safe} -", $output, $returnCode);
|
||||
exec("pdftotext {$safe} -", $output, $returnCode);
|
||||
return $returnCode === 0 ? implode("\n", $output) : '';
|
||||
}
|
||||
|
||||
private function extractImageText(string $path): string
|
||||
{
|
||||
$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 '';
|
||||
}
|
||||
// -------------------------------------------------------------------------
|
||||
// JSON-Parsing & Validierung
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
// JSON aus Antwort extrahieren (Modell könnte Prosa voranstellen)
|
||||
if (!preg_match('/\{[^{}]*\}/s', $response, $m)) return null;
|
||||
|
||||
// Array-Form: [{ ... }, { ... }]
|
||||
if (preg_match('/\[.*\]/s', $response, $m)) {
|
||||
$data = json_decode($m[0], true);
|
||||
if (!is_array($data)) return null;
|
||||
|
||||
$required = ['buchungstyp', 'kategorie_konto_id', 'zahlungskonto_id', 'ust_satz', 'betrag_netto'];
|
||||
foreach ($required as $field) {
|
||||
if (!isset($data[$field])) return null;
|
||||
if (is_array($data) && isset($data[0]) && is_array($data[0])) {
|
||||
return $this->validateLines($data, $accounts);
|
||||
}
|
||||
}
|
||||
|
||||
// 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['kategorie_konto_id'] = (int)$data['kategorie_konto_id'];
|
||||
$data['zahlungskonto_id'] = (int)$data['zahlungskonto_id'];
|
||||
|
|
@ -162,15 +354,9 @@ PROMPT;
|
|||
$data['buchungstext'] = (string)($data['buchungstext'] ?? '');
|
||||
$data['supplier_name'] = trim((string)($data['supplier_name'] ?? ''));
|
||||
|
||||
// IDs gegen Kontenliste validieren (mit Kontenartenprüfung)
|
||||
$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');
|
||||
if (!in_array($data['kategorie_konto_id'], $kategorieIds, false)) return null;
|
||||
if (!in_array($data['zahlungskonto_id'], $zahlungsIds, false)) return null;
|
||||
// Konto-IDs validieren
|
||||
if (!in_array($data['kategorie_konto_id'], $kategorieIds, false)) continue;
|
||||
if (!in_array($data['zahlungskonto_id'], $zahlungsIds, false)) continue;
|
||||
|
||||
// Lieferant gegen vorhandene Suppliers prüfen
|
||||
if ($data['supplier_name'] !== '') {
|
||||
|
|
@ -180,6 +366,9 @@ PROMPT;
|
|||
$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 $url = '';
|
||||
private string $model = '';
|
||||
private string $visionModel = '';
|
||||
private ?string $apiKey = null;
|
||||
|
||||
public function __construct()
|
||||
|
|
@ -30,6 +31,11 @@ class LlmService
|
|||
return $this->mode !== null;
|
||||
}
|
||||
|
||||
public function isVisionAvailable(): bool
|
||||
{
|
||||
return $this->mode === 'ollama';
|
||||
}
|
||||
|
||||
public function getProviderLabel(): ?string
|
||||
{
|
||||
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
|
||||
{
|
||||
$ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? '');
|
||||
|
|
@ -58,6 +103,7 @@ class LlmService
|
|||
$this->mode = 'ollama';
|
||||
$this->url = rtrim($ollamaUrl, '/');
|
||||
$this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2');
|
||||
$this->visionModel = getenv('VISION_MODEL') ?: ($_ENV['VISION_MODEL'] ?? 'qwen2.5vl:latest');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class NextcloudImportService
|
|||
*/
|
||||
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) {
|
||||
$result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.';
|
||||
|
|
@ -262,7 +262,7 @@ class NextcloudImportService
|
|||
|
||||
// In Datenbank anlegen
|
||||
$today = date('Y-m-d');
|
||||
Beleg::create([
|
||||
$belegId = Beleg::create([
|
||||
'beleg_nr' => $belegNr,
|
||||
'filename' => $filename,
|
||||
'storage_path' => $storagePath,
|
||||
|
|
@ -280,6 +280,7 @@ class NextcloudImportService
|
|||
|
||||
$result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}";
|
||||
$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,
|
||||
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,
|
||||
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
|
||||
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
||||
LEFT JOIN belege bl ON bk.beleg_id = bl.id
|
||||
JOIN accounts k ON bk.kategorie_konto_id = k.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) = ?';
|
||||
|
||||
$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">Brutto</th>
|
||||
<th>Text</th>
|
||||
<th>Anlage</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -222,6 +223,15 @@ $suppliers = $suppliers ?? [];
|
|||
<span class="badge badge-red">Storniert</span>
|
||||
<?php endif; ?>
|
||||
</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>
|
||||
<?php if (!$bk['locked'] && !$isReversal && !$isReversed): ?>
|
||||
<div style="display:flex;gap:.35rem">
|
||||
|
|
@ -263,7 +273,7 @@ $suppliers = $suppliers ?? [];
|
|||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?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; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -271,7 +281,7 @@ $suppliers = $suppliers ?? [];
|
|||
</div>
|
||||
|
||||
<!-- KI-Kontierungsvorschlag -->
|
||||
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?>
|
||||
<?php if (!$beleg['locked'] && ($kiSektion ?? false)): ?>
|
||||
<div id="ki-badge" style="margin-bottom:1rem"></div>
|
||||
<?php endif; ?>
|
||||
|
||||
|
|
@ -380,7 +390,7 @@ $suppliers = $suppliers ?? [];
|
|||
data-target="kategorie_konto_id" autocomplete="off" style="margin-bottom:.3rem"
|
||||
value="<?= $h($formData['kategorie_kontonummer'] ?? '') ?>">
|
||||
<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>
|
||||
<?php foreach (['Betriebsausgabe', 'Betriebseinnahme', 'Anlagevermögen', 'Privat', 'Verbindlichkeit'] as $art): ?>
|
||||
<?php if (empty($kontenGruppen[$art])) continue; ?>
|
||||
|
|
@ -477,6 +487,9 @@ $suppliers = $suppliers ?? [];
|
|||
</label>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -488,6 +501,184 @@ $suppliers = $suppliers ?? [];
|
|||
</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>
|
||||
function filterKategorieKonten(buchungstyp) {
|
||||
const select = document.getElementById('kategorie_konto_id');
|
||||
|
|
@ -630,9 +821,10 @@ function updateNettoAnzeige() {
|
|||
}
|
||||
</script>
|
||||
|
||||
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?>
|
||||
<?php if (!$beleg['locked'] && ($kiSektion ?? false)): ?>
|
||||
<script>
|
||||
const _belegId = <?= (int)$beleg['id'] ?>;
|
||||
const _kiAktiv = <?= json_encode((bool)($kiAktiv ?? false)) ?>;
|
||||
const _kiVorschlag = <?= json_encode($kiVorschlag ?? null) ?>;
|
||||
|
||||
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 zhlEl = document.getElementById('zahlungskonto_id');
|
||||
const katOpt = katEl ? katEl.querySelector('option[value="' + v.kategorie_konto_id + '"]') : null;
|
||||
|
|
@ -690,18 +884,44 @@ function _showKiBadgeDone(v) {
|
|||
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>' : '')
|
||||
: '';
|
||||
const details =
|
||||
|
||||
// Erste Zeile (bereits ins Formular übertragen)
|
||||
let details =
|
||||
(v.buchungstyp ? v.buchungstyp + ' · ' : '') +
|
||||
'USt ' + v.ust_satz + ' % · ' +
|
||||
'Kategorie: ' + katTxt + ' · ' +
|
||||
'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 =
|
||||
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:center;gap:1rem">' +
|
||||
'<div><strong>KI-Vorschlag:</strong> <span style="font-size:.9em">' + details + '</span></div>' +
|
||||
'<button class="btn btn-secondary btn-sm" style="flex-shrink:0" onclick="_startKiAnalyse()">Neu generieren</button>' +
|
||||
'<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>' + zeilen + '</div>' +
|
||||
neuBtn +
|
||||
'</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() {
|
||||
document.getElementById('ki-badge').innerHTML =
|
||||
'<div class="flash flash-info" style="opacity:.65">Analysiere Beleg … bitte warten.</div>';
|
||||
|
|
@ -709,36 +929,47 @@ function _showKiBadgeLoading() {
|
|||
|
||||
function _showKiBadgeError() {
|
||||
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>';
|
||||
}
|
||||
|
||||
// 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() {
|
||||
await fetch('/belege/' + _belegId + '/vorschlag/reset', {method: 'POST'});
|
||||
_showKiBadgeLoading();
|
||||
try {
|
||||
const r = await fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'});
|
||||
const v = await r.json();
|
||||
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); }
|
||||
else _showKiBadgeError();
|
||||
const raw = await r.json();
|
||||
const arr = _normalizeKiArr(raw);
|
||||
if (arr && arr.length > 0) { _applyKiVorschlag(arr[0]); _showKiBadgeDone(arr); }
|
||||
else _showKiBadgeNoResult();
|
||||
} catch (e) {
|
||||
_showKiBadgeError();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (_kiVorschlag && _kiVorschlag.kategorie_konto_id) {
|
||||
_applyKiVorschlag(_kiVorschlag);
|
||||
_showKiBadgeDone(_kiVorschlag);
|
||||
const arr = _normalizeKiArr(_kiVorschlag);
|
||||
if (arr === null) {
|
||||
// Noch nicht verarbeitet – nichts anzeigen
|
||||
} else if (arr.length === 0) {
|
||||
// Verarbeitet, aber kein Ergebnis
|
||||
_showKiBadgeNoResult();
|
||||
} else {
|
||||
_showKiBadgeLoading();
|
||||
fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
.then(v => {
|
||||
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); }
|
||||
else _showKiBadgeError();
|
||||
})
|
||||
.catch(() => _showKiBadgeError());
|
||||
// Vorschlag vorhanden → ins Formular übernehmen und anzeigen
|
||||
_applyKiVorschlag(arr[0]);
|
||||
_showKiBadgeDone(arr);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@
|
|||
<th class="td-right">USt (%)</th>
|
||||
<th class="td-right">Brutto</th>
|
||||
<th>Buchungstext</th>
|
||||
<th>Anlage</th>
|
||||
<?php if ($testMode): ?><th></th><?php endif; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -84,6 +85,15 @@
|
|||
<span class="badge badge-red">Storniert</span>
|
||||
<?php endif; ?>
|
||||
</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): ?>
|
||||
<td>
|
||||
<form method="post" action="/bookings/<?= $h($bk['id']) ?>/delete"
|
||||
|
|
@ -95,7 +105,7 @@
|
|||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?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; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ function navActive(string $prefix, string $current): string {
|
|||
}
|
||||
?>
|
||||
<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-label">Übersicht</div>
|
||||
|
|
@ -49,6 +49,16 @@ function navActive(string $prefix, string $current): string {
|
|||
</nav>
|
||||
</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-label">Steuerauswertungen</div>
|
||||
<nav>
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@
|
|||
<span style="font-weight:600">Automatischen Kontierungsvorschlag erzeugen</span>
|
||||
</label>
|
||||
<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).
|
||||
</p>
|
||||
<?php
|
||||
|
|
@ -153,23 +153,6 @@
|
|||
</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 -->
|
||||
<div class="card mb-2">
|
||||
<div class="card-header"><div class="card-title">Nextcloud-Belegimport</div></div>
|
||||
|
|
@ -249,6 +232,10 @@
|
|||
<label>Beleg-Präfix</label>
|
||||
<input type="text" name="beleg_nr_prefix" value="<?= $h($settings['beleg_nr_prefix'] ?? 'BLG') ?>" maxlength="10">
|
||||
</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 class="form-group">
|
||||
<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">USt</th>
|
||||
<th class="td-right">Brutto</th>
|
||||
<th>Anlage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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($ust) ?></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>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<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($sumUst) ?></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"><?= $kz === '69' ? 'USt-Beitrag' : 'USt' ?></th>
|
||||
<th class="td-right">Brutto</th>
|
||||
<th>Anlage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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($ust) ?></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>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<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($sumUst) ?></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