Merge main: Konflikte aufgelöst (OCR entfernt, Anlagevermögen behalten)
This commit is contained in:
commit
aed81022c0
43 changed files with 2127 additions and 423 deletions
|
|
@ -4,9 +4,6 @@ DB_NAME=steuerkram
|
||||||
DB_USER=steuerkram
|
DB_USER=steuerkram
|
||||||
DB_PASS=steuerkram
|
DB_PASS=steuerkram
|
||||||
|
|
||||||
# App
|
|
||||||
APP_SECRET=change-this-to-a-random-string
|
|
||||||
|
|
||||||
# KI-Kontierungsvorschlag (optional)
|
# KI-Kontierungsvorschlag (optional)
|
||||||
# Wenn nichts konfiguriert wird, sucht die App automatisch nach einem lokalen Ollama auf localhost:11434.
|
# Wenn nichts konfiguriert wird, sucht die App automatisch nach einem lokalen Ollama auf localhost:11434.
|
||||||
#
|
#
|
||||||
|
|
@ -14,6 +11,9 @@ APP_SECRET=change-this-to-a-random-string
|
||||||
# OLLAMA_URL=http://localhost:11434
|
# OLLAMA_URL=http://localhost:11434
|
||||||
# OLLAMA_MODEL=llama3.2
|
# OLLAMA_MODEL=llama3.2
|
||||||
#
|
#
|
||||||
|
# Vision-Modell für Beleglesung (multimodales Ollama-Modell, benötigt OLLAMA_URL)
|
||||||
|
# VISION_MODEL=qwen2.5vl:latest
|
||||||
|
#
|
||||||
# Option B: Remote-LLM per OpenAI-kompatibler API (z.B. OpenAI, OpenRouter, LM Studio)
|
# Option B: Remote-LLM per OpenAI-kompatibler API (z.B. OpenAI, OpenRouter, LM Studio)
|
||||||
# LLM_API_URL=https://api.openai.com/v1
|
# LLM_API_URL=https://api.openai.com/v1
|
||||||
# LLM_API_KEY=sk-...
|
# LLM_API_KEY=sk-...
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,10 @@ $this->assertNotEmpty($wert, 'Beschreibung');
|
||||||
| Datei | Wann aktualisieren |
|
| Datei | Wann aktualisieren |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Changes.md` | Bei jedem Feature, Bugfix oder strukturellen Änderung (nicht bei reinen Test- oder Doku-Commits) |
|
| `Changes.md` | Bei jedem Feature, Bugfix oder strukturellen Änderung (nicht bei reinen Test- oder Doku-Commits) |
|
||||||
| `README.md` | Wenn sich der Funktionsumfang, Tech Stack oder die Installationsanleitung ändert |
|
| `README.md` | Wenn sich der Funktionsumfang oder der Tech Stack grundlegend ändert |
|
||||||
| `BERICHTE.md` | Bei jeder Änderung an `EuerCalculator.php` oder `UstvaCalculator.php` (Zuordnungsregeln, neue Kennzahlen, geänderte Bedingungen) |
|
| `docs/funktionen.md` | Wenn ein neues Feature hinzukommt oder ein bestehendes wesentlich geändert wird |
|
||||||
|
| `docs/berechnungslogik.md` | Bei jeder Änderung an `EuerCalculator.php` oder `UstvaCalculator.php` (Zuordnungsregeln, neue Kennzahlen, geänderte Bedingungen) |
|
||||||
|
| `docs/anlagevermögen.md` | Bei Änderungen am Anlagevermögens-Workflow oder den Asset-Modellen |
|
||||||
|
|
||||||
## Datenbankmigrationen
|
## Datenbankmigrationen
|
||||||
|
|
||||||
|
|
|
||||||
25
Changes.md
25
Changes.md
|
|
@ -2,6 +2,14 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-04-15 – KI-Erkennung: automatischer Aufruf aus Belegansicht entfernt
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- `src/Views/belege/detail.php`: KI-Analyse wird nicht mehr automatisch beim Öffnen eines Belegs ausgelöst; stattdessen wird ein Hinweis "KI-Analyse wird im Hintergrund durchgeführt …" mit einem "Jetzt generieren"-Button angezeigt, wenn noch kein Vorschlag vorhanden ist
|
||||||
|
- Hintergrund: Die Analyse läuft bereits beim Nextcloud-Import (`ki-batch.php`); ein doppelter Aufruf aus der UI war überflüssig
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-04-12 – Anlagevermögen: Anzeige-Fix und Daten-Backfill
|
## 2026-04-12 – Anlagevermögen: Anzeige-Fix und Daten-Backfill
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|
@ -23,6 +31,23 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-04-12 – EÜR-Korrekturen: Gewerbesteuer + Bewirtungskonten ohne VSt
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- `migrations/028_euer_4320_bewirtung_ohne_vst.sql`: Gewerbesteuer (4320) von Z. 52 auf Z. 51 (§ 4 Abs. 5b EStG, KZ 165 nicht abzugsfähig)
|
||||||
|
- `migrations/028_euer_4320_bewirtung_ohne_vst.sql`: Konten 4652 (Bewirtung 70 % ohne VSt, Z. 44) und 4657 (Bewirtung 30 % ohne VSt, Z. 51) ergänzt
|
||||||
|
- `docs/kontenrahmen.md`: Dokumentation entsprechend aktualisiert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-04-12 – Klasse-0-Konten als Anlagevermögen klassifiziert
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- `docs/kontenrahmen.md`: Alle 10 Klasse-0-Konten (0027–0480) von `Zahlungskonto` auf `Anlagevermögen` korrigiert
|
||||||
|
- `migrations/027_anlagevermögen_kontenart.sql`: Migration war bereits vorhanden und setzt die DB-Änderung beim nächsten Container-Start um
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-04-12 – Backup: smarte Rotation und Lock-Schutz
|
## 2026-04-12 – Backup: smarte Rotation und Lock-Schutz
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
|
||||||
143
README.md
143
README.md
|
|
@ -1,63 +1,37 @@
|
||||||
# Steuerkram
|
# Steuerkram
|
||||||
|
|
||||||
Self-hosted Buchhaltungs- und Steueranwendung für deutsche Freiberufler und Kleinunternehmer. Entwickelt als datensouveräne Alternative zu SaaS-Diensten.
|
Self-hosted Buchhaltungs- und Steueranwendung für deutsche Freiberufler und Kleinunternehmer. Entwickelt als datensouveräne Alternative zu SaaS-Diensten – mit Unterstützung von [Claude Code](https://claude.ai/code).
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
[](LICENSE.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Funktionsumfang
|
## Funktionsumfang
|
||||||
|
|
||||||
- **Kontenrahmen SKR03** – DATEV-konformer SKR03 mit 157 Systemkonten (Klassen 0, 1, 2, 4, 8); zugeschnitten auf EÜR-Buchhalter ohne Bilanzierung (CRUD)
|
| Bereich | Beschreibung |
|
||||||
- **Belegverwaltung** – Upload von PDF, PNG, TIFF und ZUGFeRD-XML; SHA-256 Integritätsprüfung; automatischer Weißrand-Zuschnitt bei JPEG/PNG via GD
|
|
||||||
- **Buchungen** – EÜR-Buchungsmodell (Einnahme/Ausgabe) mit Kategorie- und Zahlungskonto; GoBD-konforme Trigger verhindern nachträgliche Manipulation
|
|
||||||
- **Kundenverwaltung** – CRUD
|
|
||||||
- **Ausgangsrechnungen** – DIN 5008 Layout, PDF-Export via mPDF
|
|
||||||
- **UStVA** – Kennzahlen-Berechnung aus Buchungen, ELSTER XML-Export
|
|
||||||
- **EÜR** – Jahresauswertung mit PDF-Export
|
|
||||||
- **Anlagevermögen** – Anlagenspiegel für Wirtschaftsgüter: automatische Anlagen-Nummerierung (konfiguriertes Präfix + Jahrgang + laufende Nummer), Verknüpfung mit Anlagevermögenskonten des SKR03; beim Buchen auf ein Anlagevermögenskonto öffnet sich automatisch ein Dialog zur Auswahl (neues Wirtschaftsgut anlegen oder bestehendes verknüpfen); Anlagennummer wird in allen Buchungsübersichten (Beleg-Detail, Journal, UStVA-Drill-down, EÜR-Drill-down) als direkter Link zur Bearbeitungsmaske des Anlageguts angezeigt
|
|
||||||
- **Einstellungen** – Firmendaten, Logo, ELSTER-Steuernummer, Nummernkreis-Präfixe (Belege, Rechnungen, Anlagen)
|
|
||||||
- **Benutzerverwaltung** – Benutzer anlegen, bearbeiten, deaktivieren und löschen; session-basierte Authentifizierung mit 24 h Timeout
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
Dieses Projekt wurden mit Unterstützung von [Claude Code](https://claude.ai/code) entwickelt.
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
| Schicht | Technologie |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Webserver | Apache 2.4 |
|
| **Kontenrahmen SKR03** | DATEV-konform, 157 Konten, mit UStVA-Kennzahlen und EÜR-Zeilennummern |
|
||||||
| Backend | PHP 8.3 (MVC, kein Framework) |
|
| **Belegverwaltung** | PDF, PNG, TIFF, ZUGFeRD-XML; SHA-256 Integritätsprüfung; optionale KI-Kontierung |
|
||||||
| Datenbank | MariaDB 11 |
|
| **Buchungen** | EÜR-Modell (Einnahme/Ausgabe); GoBD-konforme Trigger; Stornobuchungen; Journal |
|
||||||
| PDF-Generierung | mPDF 8.x |
|
| **Anlagevermögen** | Anlagenspiegel; automatische Anlagen-Nummerierung; Buchungsworkflow mit Asset-Verknüpfung |
|
||||||
| Frontend | HTML, CSS, JavaScript (keine Bibliotheken) |
|
| **Ausgangsrechnungen** | DIN 5008, PDF-Export via mPDF |
|
||||||
| Deployment | Docker / Docker Compose |
|
| **UStVA** | Kennzahlen-Berechnung, ELSTER XML-Export, Drill-down |
|
||||||
|
| **EÜR** | Jahresauswertung, PDF-Export, Drill-down |
|
||||||
|
| **Kundenverwaltung** | CRUD |
|
||||||
|
| **Einstellungen** | Firmendaten, Logo, ELSTER-Steuernummer, Nummernkreise |
|
||||||
|
| **Benutzerverwaltung** | Mehrbenutzer, Session-Auth mit 24 h Timeout |
|
||||||
|
|
||||||
## Voraussetzungen
|
---
|
||||||
|
|
||||||
- [Docker](https://docs.docker.com/get-docker/) ≥ 24
|
## Schnellstart
|
||||||
- [Docker Compose](https://docs.docker.com/compose/install/) ≥ 2.20 (in neueren Docker-Versionen enthalten)
|
|
||||||
- Git
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Automatische Installation
|
|
||||||
|
|
||||||
Das Script `install.sh` übernimmt die gesamte Installation: es erkennt die Linux-Distribution, installiert git, curl und Docker, klont das Repository in das gewünschte Verzeichnis und legt die `.env`-Datei an.
|
|
||||||
|
|
||||||
Das Script muss **vor** dem Klonen des Repositories heruntergeladen werden – es erledigt das Klonen selbst:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://codeberg.org/fds2610/Steuerkram/raw/branch/main/install.sh -o install.sh
|
curl -fsSL https://codeberg.org/fds2610/Steuerkram/raw/branch/main/install.sh -o install.sh
|
||||||
sudo bash install.sh
|
sudo bash install.sh
|
||||||
```
|
cd /opt/Steuerkram
|
||||||
|
|
||||||
> Das Script muss als root ausgeführt werden, da es Pakete installiert und ggf. den Docker-Dienst aktiviert. Mit `sudo bash install.sh` funktioniert das ohne dauerhaft als root angemeldet zu sein.
|
|
||||||
|
|
||||||
Nach der Installation die `.env`-Datei im gewählten Installationsverzeichnis anpassen (siehe Hinweis am Ende des Scripts) und anschließend die Container starten:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/Steuerkram # oder das gewählte Verzeichnis
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -353,71 +327,24 @@ Anschließend im Browser unter `http://localhost:8080` (oder dem konfigurierten
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Ohne Systemd-Service (Kurzform)
|
## Dokumentation
|
||||||
|
|
||||||
```bash
|
| Dokument | Inhalt |
|
||||||
bash scripts/backup.sh
|
|---|---|
|
||||||
git pull
|
| [Installation](docs/installation.md) | Docker-Setup, Systemd-Service, Port, Upload-Limit |
|
||||||
docker compose up -d --build
|
| [Konfiguration](docs/konfiguration.md) | Firmendaten, Nummernkreise, KI-Beleganalyse, Nextcloud-Import |
|
||||||
docker compose logs web | grep migrate
|
| [Funktionen](docs/funktionen.md) | Alle Features im Detail |
|
||||||
```
|
| [Anlagevermögen](docs/anlagevermögen.md) | Anlagenspiegel, Buchungsworkflow, Nummernkreis |
|
||||||
|
| [Update & Backup](docs/update-backup.md) | Update-Anleitung, Backup, Datenpersistenz |
|
||||||
|
| [Automatisierung](docs/automatisierung.md) | Automatisierungsagent, Ideenagent, Support |
|
||||||
|
| [Datenbankstruktur](docs/datenbankstruktur.md) | Tabellen, Spalten, Fremdschlüssel, GoBD-Mechanismen |
|
||||||
|
| [Berechnungslogik](docs/berechnungslogik.md) | UStVA-Kennzahlen und EÜR-Zuordnungsregeln |
|
||||||
|
| [Kontenrahmen](docs/kontenrahmen.md) | Vollständige SKR03-Kontenliste |
|
||||||
|
|
||||||
#### Mit Systemd-Service (Kurzform)
|
---
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/backup.sh
|
|
||||||
git pull
|
|
||||||
docker compose build
|
|
||||||
sudo systemctl restart steuerkram
|
|
||||||
journalctl -u steuerkram | grep migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
## GoBD-Hinweise
|
## GoBD-Hinweise
|
||||||
|
|
||||||
- Gebuchte Sätze können nicht geändert oder gelöscht werden (MariaDB `BEFORE UPDATE/DELETE` Trigger).
|
- Gebuchte Sätze können nicht geändert oder gelöscht werden (MariaDB `BEFORE UPDATE/DELETE` Trigger).
|
||||||
- Korrekturen erfolgen ausschließlich über Stornobuchungen.
|
- Korrekturen erfolgen ausschließlich über Stornobuchungen.
|
||||||
- Jeder hochgeladene Beleg erhält einen SHA-256 Hash zur Integritätsprüfung.
|
- Jeder hochgeladene Beleg erhält einen SHA-256 Hash zur Integritätsprüfung.
|
||||||
|
|
||||||
## Kontenrahmen SKR03
|
|
||||||
|
|
||||||
Der enthaltene Kontenrahmen basiert auf dem DATEV SKR03 2026, zugeschnitten auf EÜR-Buchhalter (IST-Versteuerung). Alle Konten sind mit geprüften UStVA-Kennzahlen (ELSTER) und EÜR-Zeilennummern (Anlage EÜR 2024) hinterlegt.
|
|
||||||
|
|
||||||
Vollständige Dokumentation: [`Kontenrahmen.md`](Kontenrahmen.md)
|
|
||||||
|
|
||||||
## Berechnungslogik UStVA und EÜR
|
|
||||||
|
|
||||||
Detaillierte Informationen darüber, welche Buchungsattribute und Konten in welche Positionen der UStVA (ELSTER-Kennzahlen) bzw. der EÜR einfließen, sind in [`BERICHTE.md`](BERICHTE.md) dokumentiert.
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
Issues können im [Codeberg-Repo](https://codeberg.org/fds2610/Steuerkram/issues) gemeldet werden. Mit dem Label **`automate`** werden sie automatisch verarbeitet.
|
|
||||||
|
|
||||||
### Automatisierter Agent
|
|
||||||
|
|
||||||
Ein stündlich per Cron gestarteter Agent (Claude CLI) verarbeitet Issues mit dem Label `automate` nacheinander – mit Lockfile-Schutz gegen Parallelläufe. Mehrere offene Tickets werden in einer Sitzung sequenziell abgearbeitet.
|
|
||||||
|
|
||||||
#### Ablauf je Ticket
|
|
||||||
|
|
||||||
1. Issue-Body und alle Kommentare laden (inkl. Diskussionsergebnisse früherer Rückfragen)
|
|
||||||
2. Relevante Codebase-Dateien lesen
|
|
||||||
3. **Klarheitscheck**: Sind die Anforderungen eindeutig? Falls nicht → Rückfragen als Kommentar posten, `automate`-Label entfernen, warten
|
|
||||||
4. Analyse mit Claude Opus (Ursache, Lösungsplan, Risiken)
|
|
||||||
5. Lösung implementieren
|
|
||||||
6. Tests ausführen (`php tests/run.php`), Fehler beheben
|
|
||||||
7. Commit & Push: `Fix #NR: Kurzbeschreibung`
|
|
||||||
8. Kommentar mit Zusammenfassung im Issue posten
|
|
||||||
9. Labels aktualisieren, Issue offen lassen
|
|
||||||
|
|
||||||
#### Label-Ausgänge
|
|
||||||
|
|
||||||
| Ergebnis | `automate` | gesetztes Label |
|
|
||||||
|---|---|---|
|
|
||||||
| Erfolgreich umgesetzt | entfernt | `testing` |
|
|
||||||
| Unklar / Rückfrage offen | entfernt | *(keins)* |
|
|
||||||
| Zu riskant / abgelehnt | entfernt | `abgelehnt` |
|
|
||||||
|
|
||||||
## Lizenz
|
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
|
||||||
|
|
||||||
Dieses Projekt steht unter der MIT-Lizenz – siehe [LICENSE.md](LICENSE.md) für Details.
|
|
||||||
|
|
|
||||||
69
docs/anlagevermögen.md
Normal file
69
docs/anlagevermögen.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Anlagevermögen
|
||||||
|
|
||||||
|
## Überblick
|
||||||
|
|
||||||
|
Der Bereich **Betriebsvermögen → Anlagevermögen** führt einen Anlagenspiegel für abnutzbare Wirtschaftsgüter. Jedes Wirtschaftsgut ist mit einem Anlagevermögenskonto des SKR03 verknüpft und erhält eine eindeutige Anlagen-Nummer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anlagen-Nummernkreis
|
||||||
|
|
||||||
|
Anlagen-Nummern werden automatisch im Format `{Präfix}-{Jahr}-{laufende Nummer}` vergeben, z.B. `ANL-2026-0001`.
|
||||||
|
|
||||||
|
Der Präfix ist unter **Einstellungen → Nummernkreise → Anlagen-Präfix** konfigurierbar (Standard: `ANL`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anlagenspiegel
|
||||||
|
|
||||||
|
Die Liste unter **Anlagevermögen** zeigt alle erfassten Wirtschaftsgüter mit folgenden Spalten:
|
||||||
|
|
||||||
|
| Spalte | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| Nr. | Anlagen-Nummer (automatisch vergeben) |
|
||||||
|
| Anschaffungsdatum | Datum der Anschaffung |
|
||||||
|
| Anlageart | Verknüpftes SKR03-Anlagevermögenskonto |
|
||||||
|
| Wirtschaftsgut | Bezeichnung |
|
||||||
|
| Anschaffungskosten | Nettobetrag der Anschaffung |
|
||||||
|
| Zugänge | Nachträgliche Anschaffungskosten |
|
||||||
|
| Abgänge | Wertminderungen durch Verkauf/Abgang |
|
||||||
|
| Abschreibungen | AfA des laufenden Wirtschaftsjahres |
|
||||||
|
|
||||||
|
Über das Bleistift-Symbol gelangt man zur Bearbeitungsmaske, über das Papierkorb-Symbol kann ein Wirtschaftsgut gelöscht werden (nur wenn keine Buchungen verknüpft sind).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Buchungsworkflow
|
||||||
|
|
||||||
|
Wird beim Erfassen einer Buchung ein **Anlagevermögenskonto** als Kategorie gewählt, öffnet sich automatisch ein Dialog:
|
||||||
|
|
||||||
|
**Option 1 – Neues Wirtschaftsgut anlegen**
|
||||||
|
Das System legt automatisch ein neues Anlagegut an:
|
||||||
|
- Bezeichnung ← Buchungstext
|
||||||
|
- Anschaffungsdatum ← Buchungsdatum
|
||||||
|
- Anschaffungskosten ← Nettobetrag
|
||||||
|
|
||||||
|
**Option 2 – Bestehendes Wirtschaftsgut auswählen**
|
||||||
|
Es wird die Liste der Wirtschaftsgüter angezeigt, die mit dem gewählten Anlagevermögenskonto verknüpft sind. Nach der Auswahl wird die Buchung mit dem vorhandenen Anlagegut verknüpft.
|
||||||
|
|
||||||
|
In beiden Fällen wird die `asset_id` in der Buchungszeile gespeichert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anzeige in Buchungstabellen
|
||||||
|
|
||||||
|
In allen Buchungsübersichten (Beleg-Detail, Journal, UStVA-Drill-down, EÜR-Drill-down) erscheint eine **Anlage**-Spalte. Sie zeigt die Anlagen-Nummer als direkten Link zur Bearbeitungsmaske des Wirtschaftsguts. Falls für ein älteres Wirtschaftsgut keine Anlagen-Nummer hinterlegt ist, wird stattdessen die Bezeichnung angezeigt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anlagen-Stammdaten bearbeiten
|
||||||
|
|
||||||
|
Folgende Felder können nachträglich angepasst werden:
|
||||||
|
|
||||||
|
- Bezeichnung
|
||||||
|
- Anlageart (SKR03-Konto)
|
||||||
|
- Anschaffungsdatum und -kosten
|
||||||
|
- Zugänge und Abgänge
|
||||||
|
- Abschreibung laufendes Jahr
|
||||||
|
- Nutzungsdauer (in Jahren)
|
||||||
|
- Notizen
|
||||||
50
docs/automatisierung.md
Normal file
50
docs/automatisierung.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Automatisierung & Support
|
||||||
|
|
||||||
|
## Automatisierungsagent
|
||||||
|
|
||||||
|
Issues mit dem Label **`automate`** im [Codeberg-Repo](https://codeberg.org/fds2610/Steuerkram/issues) werden automatisch durch einen stündlich per Cron gestarteten Agenten (Claude CLI) verarbeitet. Mehrere offene Tickets werden in einer Sitzung sequenziell abgearbeitet. Ein Lockfile verhindert parallele Läufe.
|
||||||
|
|
||||||
|
### Ablauf je Ticket
|
||||||
|
|
||||||
|
1. Issue-Body und alle Kommentare laden (inkl. Diskussionsergebnisse früherer Rückfragen)
|
||||||
|
2. Relevante Codebase-Dateien lesen
|
||||||
|
3. **Klarheitscheck**: Sind die Anforderungen eindeutig? Falls nicht → Rückfragen als Kommentar posten, `automate`-Label entfernen, warten
|
||||||
|
4. Analyse (Ursache, Lösungsplan, Risiken)
|
||||||
|
5. Lösung implementieren
|
||||||
|
6. Tests ausführen (`php tests/run.php`), Fehler beheben
|
||||||
|
7. Commit & Push: `Fix #NR: Kurzbeschreibung`
|
||||||
|
8. Kommentar mit Zusammenfassung im Issue posten
|
||||||
|
9. Labels aktualisieren, Issue offen lassen
|
||||||
|
|
||||||
|
### Label-Ausgänge
|
||||||
|
|
||||||
|
| Ergebnis | `automate` | gesetztes Label |
|
||||||
|
|---|---|---|
|
||||||
|
| Erfolgreich umgesetzt | entfernt | `testing` |
|
||||||
|
| Unklar / Rückfrage offen | entfernt | *(keins)* |
|
||||||
|
| Zu riskant / abgelehnt | entfernt | `abgelehnt` |
|
||||||
|
|
||||||
|
### Manuell starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/fds/projects/Steuerkram/.claude/automate-run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ideenagent
|
||||||
|
|
||||||
|
Ein separater Agent sammelt und bewertet Ideen zur Weiterentwicklung. Er läuft ebenfalls per Cron.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/fds/projects/Steuerkram/.claude/ideenagent-run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Issues können im [Codeberg-Repo](https://codeberg.org/fds2610/Steuerkram/issues) gemeldet werden.
|
||||||
|
|
||||||
|
- Mit dem Label **`automate`** werden sie automatisch verarbeitet (siehe oben).
|
||||||
|
- Ohne Label landen sie in der normalen Issue-Queue.
|
||||||
|
|
@ -9,7 +9,7 @@ Dieses Dokument beschreibt, wie Buchungen in die beiden steuerlichen Berichte ei
|
||||||
|
|
||||||
**Datei:** `src/Services/EuerCalculator.php`
|
**Datei:** `src/Services/EuerCalculator.php`
|
||||||
|
|
||||||
Die EÜR basiert auf dem Feld `accounts.kontenart` sowie `accounts.euer_zeile` des Kategorie-Kontos. Das Feld `buchungstyp` wird nicht ausgewertet. Stornobuchungen (`is_reversal = 1`) werden ausgeschlossen.
|
Die EÜR basiert auf dem Feld `accounts.kontenart` sowie `accounts.euer_zeile` des Kategorie-Kontos. Das Feld `buchungstyp` wird nicht ausgewertet. Stornobuchungen (`is_reversal = 1`) werden **nicht** explizit ausgefiltert – sie tragen negative Beträge und heben damit die ursprüngliche Buchung automatisch auf (Summe = 0).
|
||||||
|
|
||||||
### Spaltenauswahl nach Kontenart und euer_zeile
|
### Spaltenauswahl nach Kontenart und euer_zeile
|
||||||
|
|
||||||
284
docs/datenbankstruktur.md
Normal file
284
docs/datenbankstruktur.md
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
# Datenbankstruktur
|
||||||
|
|
||||||
|
MariaDB 11, Engine InnoDB, Zeichensatz `utf8mb4_unicode_ci`. Das Schema wird durch nummerierte Migrations-Dateien in `migrations/` verwaltet und beim Container-Start automatisch eingespielt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
| Tabelle | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `fiscal_years` | Geschäftsjahre |
|
||||||
|
| `accounts` | Kontenrahmen SKR03 |
|
||||||
|
| `company_settings` | Firmendaten und Einstellungen (Singleton) |
|
||||||
|
| `customers` | Kundenstammdaten |
|
||||||
|
| `belege` | Belegdokumente |
|
||||||
|
| `bookings` | Buchungszeilen (EÜR-Journal) |
|
||||||
|
| `invoices` | Ausgangsrechnungen |
|
||||||
|
| `invoice_lines` | Rechnungspositionen |
|
||||||
|
| `users` | Benutzerkonten |
|
||||||
|
| `ustva_submissions` | UStVA-Einreichungen |
|
||||||
|
| `audit_log` | GoBD-Audit-Log |
|
||||||
|
| `schema_migrations` | Protokoll angewendeter Migrationen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabellen im Detail
|
||||||
|
|
||||||
|
### `fiscal_years` – Geschäftsjahre
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INT UNSIGNED PK | |
|
||||||
|
| `year` | SMALLINT UNSIGNED UNIQUE | Jahreszahl (z.B. 2026) |
|
||||||
|
| `start_date` | DATE | Beginn des Geschäftsjahres |
|
||||||
|
| `end_date` | DATE | Ende des Geschäftsjahres |
|
||||||
|
| `locked` | TINYINT(1) | `1` = Jahr abgeschlossen, keine neuen Buchungen möglich |
|
||||||
|
| `created_at` | DATETIME | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `accounts` – Kontenrahmen SKR03
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INT UNSIGNED PK | |
|
||||||
|
| `kontonummer` | VARCHAR(10) UNIQUE | DATEV-Kontonummer |
|
||||||
|
| `bezeichnung` | VARCHAR(255) | Kontobezeichnung |
|
||||||
|
| `kontenart` | ENUM | `Betriebseinnahme`, `Betriebsausgabe`, `Anlagevermögen`, `Zahlungskonto`, `Verbindlichkeit`, `Privat` |
|
||||||
|
| `beschreibung` | TEXT | Erläuterungstext |
|
||||||
|
| `steuercode` | VARCHAR(20) | `USt19`, `USt7`, `VSt19`, `VSt7` oder leer |
|
||||||
|
| `ustva_kz` | VARCHAR(10) | ELSTER-Kennzahl (z.B. `81`, `35`) |
|
||||||
|
| `euer_zeile` | VARCHAR(5) | Zeilennummer Anlage EÜR (z.B. `14`, `51`) |
|
||||||
|
| `is_system` | TINYINT(1) | `1` = SKR03-Standardkonto |
|
||||||
|
| `aktiv` | TINYINT(1) | `0` = deaktiviert |
|
||||||
|
| `created_at` / `updated_at` | DATETIME | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `company_settings` – Firmendaten (Singleton, id=1)
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INT UNSIGNED PK | Immer `1` |
|
||||||
|
| `firma` | VARCHAR(255) | Firmenname |
|
||||||
|
| `inhaber` | VARCHAR(255) | Inhabername |
|
||||||
|
| `strasse`, `plz`, `ort` | VARCHAR | Adresse |
|
||||||
|
| `telefon`, `email`, `website` | VARCHAR | Kontaktdaten |
|
||||||
|
| `ust_id` | VARCHAR(20) | Umsatzsteuer-ID (DE...) |
|
||||||
|
| `steuernummer` | VARCHAR(30) | Steuernummer beim Finanzamt |
|
||||||
|
| `finanzamt` | VARCHAR(255) | Zuständiges Finanzamt |
|
||||||
|
| `iban`, `bic`, `bank_name` | VARCHAR | Bankverbindung |
|
||||||
|
| `logo_path` | VARCHAR(500) | Pfad zum Logo-PNG |
|
||||||
|
| `ustva_steuernummer` | VARCHAR(20) | ELSTER-Format: `FFFBBBBBBUUUUP` |
|
||||||
|
| `ustva_bundesland` | VARCHAR(2) | Bundesland-Kürzel (z.B. `BY`) |
|
||||||
|
| `invoice_nr_prefix` | VARCHAR(10) | Präfix Rechnungsnummer (Standard: `AR`) |
|
||||||
|
| `invoice_nr_next` | INT UNSIGNED | Nächste Rechnungsnummer |
|
||||||
|
| `invoice_nr_year` | SMALLINT UNSIGNED | Jahresstand des Rechnungszählers |
|
||||||
|
| `beleg_nr_prefix` | VARCHAR(10) | Präfix Belegnummer (Standard: `BLG`) |
|
||||||
|
| `beleg_nr_next` | INT UNSIGNED | Nächste Belegnummer |
|
||||||
|
| `anlage_nr_prefix` | VARCHAR(10) | Präfix Anlagen-Nummer (Standard: `ANL`) |
|
||||||
|
| `anlage_nr_next` | INT UNSIGNED | Nächste Anlagen-Nummer |
|
||||||
|
| `payment_days_default` | INT UNSIGNED | Standard-Zahlungsziel in Tagen |
|
||||||
|
| `ki_vorschlag_aktiv` | TINYINT(1) | KI-Kontierungsvorschlag aktiviert |
|
||||||
|
| `nextcloud_url` | VARCHAR(500) | WebDAV-URL der Nextcloud-Instanz |
|
||||||
|
| `nextcloud_user` | VARCHAR(255) | Nextcloud-Benutzername |
|
||||||
|
| `nextcloud_pass` | VARCHAR(500) | Nextcloud-Passwort / App-Passwort |
|
||||||
|
| `nextcloud_folder` | VARCHAR(500) | Import-Quellordner (Standard: `/Belege/Import`) |
|
||||||
|
| `nextcloud_done` | VARCHAR(500) | Zielordner nach erfolgreichem Import |
|
||||||
|
| `updated_at` | DATETIME | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `customers` – Kundenstammdaten
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INT UNSIGNED PK | |
|
||||||
|
| `kundennummer` | VARCHAR(20) UNIQUE | |
|
||||||
|
| `firma` | VARCHAR(255) | Firmenname (optional) |
|
||||||
|
| `anrede`, `vorname`, `nachname` | VARCHAR | Ansprechpartner |
|
||||||
|
| `strasse`, `plz`, `ort`, `land` | VARCHAR | Adresse |
|
||||||
|
| `email`, `telefon` | VARCHAR | Kontakt |
|
||||||
|
| `ust_id` | VARCHAR(20) | USt-ID des Kunden (B2B) |
|
||||||
|
| `steuernummer` | VARCHAR(30) | |
|
||||||
|
| `payment_days` | INT UNSIGNED | Zahlungsziel in Tagen |
|
||||||
|
| `notes` | TEXT | Interne Notizen |
|
||||||
|
| `archived` | TINYINT(1) | `1` = archiviert |
|
||||||
|
| `created_at` / `updated_at` | DATETIME | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `belege` – Belegdokumente
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INT UNSIGNED PK | |
|
||||||
|
| `beleg_nr` | VARCHAR(30) UNIQUE | Automatisch vergeben (z.B. `BLG-2026-0001`) |
|
||||||
|
| `filename` | VARCHAR(500) | Originaldateiname |
|
||||||
|
| `storage_path` | VARCHAR(500) | Pfad unter `public/static/uploads/` |
|
||||||
|
| `mime_type` | VARCHAR(100) | MIME-Typ der Datei |
|
||||||
|
| `file_hash` | VARCHAR(64) | SHA-256 für GoBD-Integritätsprüfung |
|
||||||
|
| `beleg_datum` | DATE | Datum auf dem Dokument |
|
||||||
|
| `rechnungs_nr` | VARCHAR(100) | Rechnungsnummer des Lieferanten |
|
||||||
|
| `betrag_brutto` | DECIMAL(12,2) | Bruttobetrag laut Dokument |
|
||||||
|
| `belegtyp` | ENUM | `Eingangsrechnung`, `Ausgangsrechnung`, `Quittung`, `Kontoauszug`, `Sonstiges` |
|
||||||
|
| `notes` | TEXT | Interne Notizen |
|
||||||
|
| `bewirtungsgrund` | TEXT | Anlass der Bewirtung (§ 4 Abs. 5 Nr. 2 EStG) |
|
||||||
|
| `teilnehmerliste` | TEXT | Teilnehmer der Bewirtung |
|
||||||
|
| `eigenanteil_30` | TINYINT(1) | `1` = 30%-Eigenanteil beim Buchen markiert |
|
||||||
|
| `ki_vorschlag` | JSON | KI-Kontierungsvorschlag |
|
||||||
|
| `ki_vorschlag_at` | TIMESTAMP | Zeitpunkt der KI-Analyse |
|
||||||
|
| `beleg_text` | MEDIUMTEXT | Extrahierter OCR-Text |
|
||||||
|
| `locked` | TINYINT(1) | GoBD: mit Geschäftsjahr gesperrt |
|
||||||
|
| `fiscal_year_id` | INT UNSIGNED FK | → `fiscal_years.id` |
|
||||||
|
| `created_at` / `updated_at` | DATETIME | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `bookings` – Buchungszeilen (EÜR-Journal)
|
||||||
|
|
||||||
|
GoBD-gesichert: MariaDB-Trigger verhindern Änderungen und Löschungen gesperrter Buchungen.
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INT UNSIGNED PK | |
|
||||||
|
| `buchungs_nr` | VARCHAR(30) UNIQUE | Automatisch vergeben |
|
||||||
|
| `beleg_id` | INT UNSIGNED FK | → `belege.id` |
|
||||||
|
| `asset_id` | INT UNSIGNED FK | → `assets.id` (nur bei Anlagevermögen-Buchungen) |
|
||||||
|
| `buchungs_datum` | DATE | |
|
||||||
|
| `buchungstyp` | ENUM | `Einnahme`, `Ausgabe` |
|
||||||
|
| `kategorie_konto_id` | INT UNSIGNED FK | → `accounts.id` (Was?) |
|
||||||
|
| `zahlungskonto_id` | INT UNSIGNED FK | → `accounts.id` (Wie bezahlt?) |
|
||||||
|
| `betrag_netto` | DECIMAL(12,2) | |
|
||||||
|
| `ust_satz` | DECIMAL(5,2) | Steuersatz in % (0, 7, 19) |
|
||||||
|
| `steuerart` | ENUM | `normal`, `ig_lieferung`, `export`, `ig_erwerb_19`, `ig_erwerb_7` |
|
||||||
|
| `betrag_ust` | DECIMAL(12,2) | |
|
||||||
|
| `betrag_brutto` | DECIMAL(12,2) | |
|
||||||
|
| `buchungstext` | VARCHAR(500) | |
|
||||||
|
| `is_reversal` | TINYINT(1) | `1` = Stornobuchung |
|
||||||
|
| `reversed_by` | INT UNSIGNED | ID der zugehörigen Stornobuchung |
|
||||||
|
| `locked` | TINYINT(1) | GoBD: unveränderlich |
|
||||||
|
| `fiscal_year_id` | INT UNSIGNED FK | → `fiscal_years.id` |
|
||||||
|
| `created_at` | DATETIME | |
|
||||||
|
|
||||||
|
> **Hinweis:** Die Spalte `asset_id` existiert nur nach Migration 030 (Feature-Branch `Betriebsvermögen`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `assets` – Anlagegüter (Wirtschaftsgüter)
|
||||||
|
|
||||||
|
> Diese Tabelle existiert nur nach Migration 028 (Feature-Branch `Betriebsvermögen`).
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INT UNSIGNED PK | |
|
||||||
|
| `lfd_nr` | INT UNSIGNED | Laufende Nummer |
|
||||||
|
| `anlage_nr` | VARCHAR(30) | Anlagen-Nummer (z.B. `ANL-2026-0001`) |
|
||||||
|
| `bezeichnung` | VARCHAR(255) | Bezeichnung des Wirtschaftsguts |
|
||||||
|
| `account_id` | INT UNSIGNED FK | → `accounts.id` (Anlageart) |
|
||||||
|
| `anschaffungsdatum` | DATE | |
|
||||||
|
| `anschaffungskosten` | DECIMAL(12,2) | Netto-Anschaffungskosten |
|
||||||
|
| `zugaenge` | DECIMAL(12,2) | Nachträgliche Anschaffungskosten |
|
||||||
|
| `abgaenge` | DECIMAL(12,2) | Wertminderungen |
|
||||||
|
| `abschreibung_jahr` | DECIMAL(12,2) | AfA laufendes Wirtschaftsjahr |
|
||||||
|
| `nutzungsdauer` | INT UNSIGNED | Nutzungsdauer in Jahren |
|
||||||
|
| `notizen` | TEXT | |
|
||||||
|
| `fiscal_year_id` | INT UNSIGNED FK | → `fiscal_years.id` |
|
||||||
|
| `created_at` / `updated_at` | DATETIME | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `invoices` – Ausgangsrechnungen
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INT UNSIGNED PK | |
|
||||||
|
| `rechnungs_nr` | VARCHAR(30) UNIQUE | `NULL` solange Entwurf |
|
||||||
|
| `customer_id` | INT UNSIGNED FK | → `customers.id` |
|
||||||
|
| `betreff` | VARCHAR(500) | |
|
||||||
|
| `leistungszeitraum_von` / `_bis` | DATE | |
|
||||||
|
| `rechnungs_datum` | DATE | |
|
||||||
|
| `faellig_datum` | DATE | |
|
||||||
|
| `status` | ENUM | `Entwurf`, `Gesendet`, `Bezahlt`, `Storniert` |
|
||||||
|
| `betrag_netto` / `_ust` / `_brutto` | DECIMAL(12,2) | Summenspalten |
|
||||||
|
| `beleg_id` | INT UNSIGNED FK | → `belege.id` |
|
||||||
|
| `notes` | TEXT | |
|
||||||
|
| `fiscal_year_id` | INT UNSIGNED FK | → `fiscal_years.id` |
|
||||||
|
| `created_at` / `updated_at` | DATETIME | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `invoice_lines` – Rechnungspositionen
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INT UNSIGNED PK | |
|
||||||
|
| `invoice_id` | INT UNSIGNED FK | → `invoices.id` (CASCADE DELETE) |
|
||||||
|
| `position` | SMALLINT UNSIGNED | Positionsnummer |
|
||||||
|
| `bezeichnung` | VARCHAR(500) | |
|
||||||
|
| `menge` | DECIMAL(10,3) | |
|
||||||
|
| `einheit` | VARCHAR(20) | z.B. `Stk.`, `h` |
|
||||||
|
| `einzelpreis` | DECIMAL(12,2) | Netto-Einzelpreis |
|
||||||
|
| `ust_satz` | DECIMAL(5,2) | |
|
||||||
|
| `betrag_netto` / `_ust` / `_brutto` | DECIMAL(12,2) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `users` – Benutzerkonten
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INT UNSIGNED PK | |
|
||||||
|
| `username` | VARCHAR(64) UNIQUE | |
|
||||||
|
| `password_hash` | VARCHAR(255) | bcrypt |
|
||||||
|
| `active` | TINYINT(1) | `0` = deaktiviert |
|
||||||
|
| `created_at` | DATETIME | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `ustva_submissions` – UStVA-Einreichungen
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INT UNSIGNED PK | |
|
||||||
|
| `year` | SMALLINT UNSIGNED | Meldejahr |
|
||||||
|
| `month` | TINYINT UNSIGNED | Meldemonat (1–12) |
|
||||||
|
| `xml_content` | MEDIUMTEXT | Generiertes ELSTER-XML |
|
||||||
|
| `field_values` | JSON | Kennzahlen als JSON |
|
||||||
|
| `submitted_at` | DATETIME | `NULL` = noch nicht eingereicht |
|
||||||
|
| `status` | ENUM | `Erstellt`, `Eingereicht` |
|
||||||
|
| `created_at` | DATETIME | |
|
||||||
|
|
||||||
|
UNIQUE KEY auf `(year, month)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `audit_log` – GoBD-Audit-Log
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | BIGINT UNSIGNED PK | |
|
||||||
|
| `timestamp` | DATETIME | |
|
||||||
|
| `table_name` | VARCHAR(50) | Betroffene Tabelle |
|
||||||
|
| `record_id` | INT UNSIGNED | Betroffener Datensatz |
|
||||||
|
| `action` | ENUM | `INSERT`, `UPDATE`, `DELETE`, `LOCK` |
|
||||||
|
| `old_values` | JSON | Werte vor der Änderung |
|
||||||
|
| `new_values` | JSON | Werte nach der Änderung |
|
||||||
|
| `ip_address` | VARCHAR(45) | |
|
||||||
|
| `user_agent` | VARCHAR(500) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GoBD-Mechanismen
|
||||||
|
|
||||||
|
- **Trigger `bookings_before_update` / `bookings_before_delete`**: Jeder Versuch, eine gesperrte Buchung (`locked = 1`) zu ändern oder zu löschen, wird mit einem SQL-Fehler abgewiesen.
|
||||||
|
- **`file_hash` in `belege`**: SHA-256-Prüfsumme jedes Belegdokuments; erlaubt nachträgliche Integritätsprüfung.
|
||||||
|
- **`audit_log`**: Protokolliert alle Datenbankänderungen mit alten und neuen Werten.
|
||||||
|
- **Stornobuchungen** statt Löschungen: Korrekturen erzeugen eine Gegenbuchung, die ursprüngliche Buchung bleibt erhalten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migrationen
|
||||||
|
|
||||||
|
Schemaänderungen kommen ausschließlich als nummerierte SQL-Dateien in `migrations/` und werden vom Script `scripts/migrate.php` beim Container-Start automatisch eingespielt. Die Tabelle `schema_migrations` protokolliert, welche Dateien bereits angewendet wurden.
|
||||||
91
docs/funktionen.md
Normal file
91
docs/funktionen.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# Funktionen
|
||||||
|
|
||||||
|
## Kontenrahmen SKR03
|
||||||
|
|
||||||
|
DATEV-konformer SKR03 2026 mit 157 Systemkonten (Klassen 0, 1, 2, 4, 8), zugeschnitten auf EÜR-Buchhalter ohne Bilanzierungspflicht. Alle Konten sind mit geprüften UStVA-Kennzahlen (ELSTER) und EÜR-Zeilennummern hinterlegt.
|
||||||
|
|
||||||
|
Vollständige Kontenliste: [kontenrahmen.md](kontenrahmen.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Belegverwaltung
|
||||||
|
|
||||||
|
- Upload von PDF, PNG, JPEG, TIFF und ZUGFeRD-XML
|
||||||
|
- SHA-256 Integritätsprüfung je Datei
|
||||||
|
- Automatischer Weißrand-Zuschnitt bei JPEG/PNG via GD
|
||||||
|
- KI-gestützter Kontierungsvorschlag (optional, siehe [konfiguration.md](konfiguration.md))
|
||||||
|
- Nextcloud-Import via WebDAV (optional)
|
||||||
|
- Belege können nicht gelöscht werden, solange Buchungen darauf verweisen (GoBD)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Buchungen
|
||||||
|
|
||||||
|
EÜR-Buchungsmodell (Einnahme/Ausgabe) mit Kategorie- und Zahlungskonto.
|
||||||
|
|
||||||
|
- **Steuerarten** für Auslandsumsätze: Inland, steuerfreie ig. Lieferung (Kz 41), steuerfreie Ausfuhr (Kz 44), ig. Erwerb 19 % (Kz 89/91), ig. Erwerb 7 % (Kz 93/94)
|
||||||
|
- **Bewirtungsausgaben**: automatischer Split 70 %/30 % bei Konten 4650, 4651, 4652
|
||||||
|
- **Eigenanteil** (30 %) bei Telefon/Internet (Konto 4940): optional zubuchbar
|
||||||
|
- **Stornobuchungen**: GoBD-konforme Gegenbuchung statt Löschung
|
||||||
|
- **GoBD-Schutz**: MariaDB `BEFORE UPDATE/DELETE`-Trigger verhindern nachträgliche Manipulation gebuchter Sätze
|
||||||
|
- **Journal**: gefilterte Übersicht aller Buchungen mit CSV-Export
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anlagevermögen
|
||||||
|
|
||||||
|
Anlagenspiegel für abnutzbare Wirtschaftsgüter – siehe [anlagevermögen.md](anlagevermögen.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kundenverwaltung
|
||||||
|
|
||||||
|
CRUD für Kundenstammdaten. Kunden werden beim Erstellen von Ausgangsrechnungen ausgewählt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ausgangsrechnungen
|
||||||
|
|
||||||
|
- DIN 5008-konformes Layout
|
||||||
|
- PDF-Export via mPDF
|
||||||
|
- Automatische Rechnungsnummer (konfiguriertes Präfix + Jahrgang + laufende Nummer)
|
||||||
|
- Direkte Verknüpfung mit Belegverwaltung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UStVA (Umsatzsteuervoranmeldung)
|
||||||
|
|
||||||
|
- Monatliche Kennzahlen-Berechnung aus Buchungen (Kz 81, 83, 86, 84, 35, 41, 44, 61, 62, 67, 66, 69)
|
||||||
|
- ELSTER XML-Export
|
||||||
|
- Drill-down: Klick auf eine Kennzahl zeigt alle zugehörigen Buchungszeilen
|
||||||
|
|
||||||
|
Zuordnungsregeln: [berechnungslogik.md](berechnungslogik.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EÜR (Einnahmen-Überschuss-Rechnung)
|
||||||
|
|
||||||
|
- Jahresauswertung: Einnahmen, Ausgaben, Gewinn je Kategorie-Konto
|
||||||
|
- Nicht abzugsfähige Betriebsausgaben (§ 4 Abs. 5 EStG, EÜR-Zeile 51) in separatem Abschnitt
|
||||||
|
- PDF-Export
|
||||||
|
- Drill-down: Klick auf ein Konto zeigt alle zugehörigen Buchungszeilen
|
||||||
|
|
||||||
|
Zuordnungsregeln: [berechnungslogik.md](berechnungslogik.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Einstellungen
|
||||||
|
|
||||||
|
- Firmendaten und Logo (erscheinen auf Rechnungen und im EÜR/UStVA-Export)
|
||||||
|
- ELSTER-Steuernummer
|
||||||
|
- Nummernkreis-Präfixe für Belege, Rechnungen und Anlagen
|
||||||
|
- KI-Kontierungsvorschlag (Ollama oder OpenAI-kompatible API)
|
||||||
|
- Nextcloud-Belegimport (WebDAV-Zugangsdaten)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Benutzerverwaltung
|
||||||
|
|
||||||
|
- Benutzer anlegen, bearbeiten, deaktivieren und löschen
|
||||||
|
- Session-basierte Authentifizierung mit 24 h Timeout
|
||||||
|
- Erste Benutzeranlage über Ersteinrichtungs-Dialog beim ersten Aufruf
|
||||||
139
docs/installation.md
Normal file
139
docs/installation.md
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- [Docker](https://docs.docker.com/get-docker/) ≥ 24
|
||||||
|
- [Docker Compose](https://docs.docker.com/compose/install/) ≥ 2.20 (in neueren Docker-Versionen enthalten)
|
||||||
|
- Git
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Automatische Installation
|
||||||
|
|
||||||
|
Das Script `install.sh` übernimmt die gesamte Installation: es erkennt die Linux-Distribution, installiert git, curl und Docker, klont das Repository in das gewünschte Verzeichnis und legt die `.env`-Datei an.
|
||||||
|
|
||||||
|
Das Script muss **vor** dem Klonen des Repositories heruntergeladen werden – es erledigt das Klonen selbst:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://codeberg.org/fds2610/Steuerkram/raw/branch/main/install.sh -o install.sh
|
||||||
|
sudo bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> Das Script muss als root ausgeführt werden, da es Pakete installiert und ggf. den Docker-Dienst aktiviert.
|
||||||
|
|
||||||
|
Nach der Installation die `.env`-Datei im gewählten Installationsverzeichnis anpassen und die Container starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/Steuerkram # oder das gewählte Verzeichnis
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manuelle Installation
|
||||||
|
|
||||||
|
### 1. Repository klonen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url> steuerkram
|
||||||
|
cd steuerkram
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Konfiguration anpassen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
DB_PASS=sicheres-passwort # Datenbankpasswort – in docker-compose.yml spiegeln
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
- der SKR03-Kontenrahmen eingespielt
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Port ändern
|
||||||
|
|
||||||
|
Der Standard-Port ist `8080`. Um ihn zu ändern, die `ports`-Zeile in `docker-compose.yml` anpassen:
|
||||||
|
|
||||||
|
```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:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
upload_max_filesize = 20M
|
||||||
|
post_max_size = 20M
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Systemd-Service einrichten
|
||||||
|
|
||||||
|
Damit Steuerkram automatisch beim Systemstart startet, 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 woanders liegt, den Pfad anpassen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/systemd/system/steuerkram.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Service aktivieren und starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable steuerkram
|
||||||
|
sudo systemctl start steuerkram
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Status prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status steuerkram
|
||||||
|
journalctl -u steuerkram -f # Live-Log inkl. Migrations-Ausgabe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wichtige 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 |
|
||||||
95
docs/konfiguration.md
Normal file
95
docs/konfiguration.md
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
# Konfiguration
|
||||||
|
|
||||||
|
## Firmendaten hinterlegen
|
||||||
|
|
||||||
|
Nach dem ersten Login unter **Einstellungen** Firmendaten, Logo und ELSTER-Steuernummer eintragen. Diese Angaben erscheinen auf Ausgangsrechnungen und im EÜR/UStVA-Export.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nummernkreise
|
||||||
|
|
||||||
|
Unter **Einstellungen → Nummernkreise** können Präfixe für die automatisch vergebenen Nummern konfiguriert werden:
|
||||||
|
|
||||||
|
| Nummernkreis | Standard | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| Belege | `BLG` | `BLG-2026-0001` |
|
||||||
|
| Ausgangsrechnungen | `RE` | `RE-2026-0001` |
|
||||||
|
| Anlagen | `ANL` | `ANL-2026-0001` |
|
||||||
|
|
||||||
|
Das Format ist immer `{Präfix}-{Jahr}-{laufende Nummer}`. Die laufende Nummer wird automatisch hochgezählt und kann nicht zurückgesetzt werden (GoBD).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KI-Kontierungsvorschlag einrichten
|
||||||
|
|
||||||
|
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 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=qwen2.5vl
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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. Für zuverlässige Ergebnisse wird ein Vision-fähiges Modell empfohlen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nextcloud-Import einrichten
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Zugangsdaten konfigurieren
|
||||||
|
|
||||||
|
Die Zugangsdaten werden in der Steuerkram-UI gepflegt: **Einstellungen → Nextcloud-Belegimport**.
|
||||||
|
|
||||||
|
Hinweis: 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
|
||||||
|
|
||||||
|
Cron-Eintrag auf dem Host anlegen (`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
|
||||||
|
```
|
||||||
|
|
||||||
|
> Das `-T`-Flag ist bei Cron zwingend erforderlich – ohne TTY schlägt `docker compose exec` sonst still fehl.
|
||||||
|
|
||||||
|
Damit `docker compose exec` ohne `sudo` funktioniert, muss der User in der Docker-Gruppe sein:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
# Danach neu einloggen
|
||||||
|
```
|
||||||
|
|
@ -15,7 +15,7 @@ Der kanonische Seed liegt in `migrations/018_seed_kontenrahmen_skr03_final.sql`.
|
||||||
| Spalte | Bedeutung |
|
| Spalte | Bedeutung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `kontonummer` | DATEV-Kontonummer |
|
| `kontonummer` | DATEV-Kontonummer |
|
||||||
| `kontenart` | `Betriebseinnahme`, `Betriebsausgabe`, `Zahlungskonto`, `Privat` |
|
| `kontenart` | `Betriebseinnahme`, `Betriebsausgabe`, `Anlagevermögen`, `Zahlungskonto`, `Privat` |
|
||||||
| `steuercode` | `USt19`, `USt7`, `VSt19`, `VSt7` oder leer |
|
| `steuercode` | `USt19`, `USt7`, `VSt19`, `VSt7` oder leer |
|
||||||
| `ustva_kz` | ELSTER-Kennzahl für die UStVA (z.B. `81`, `66`) |
|
| `ustva_kz` | ELSTER-Kennzahl für die UStVA (z.B. `81`, `66`) |
|
||||||
| `euer_zeile` | Zeilennummer in der Anlage EÜR (z.B. `14`, `52`) |
|
| `euer_zeile` | Zeilennummer in der Anlage EÜR (z.B. `14`, `52`) |
|
||||||
|
|
@ -26,16 +26,16 @@ Der kanonische Seed liegt in `migrations/018_seed_kontenrahmen_skr03_final.sql`.
|
||||||
|
|
||||||
| Kto. | Bezeichnung | Kontenart | Stcode | UStVA | EÜR |
|
| Kto. | Bezeichnung | Kontenart | Stcode | UStVA | EÜR |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| 0027 | EDV-Software | Zahlungskonto | – | – | – |
|
| 0027 | EDV-Software | Anlagevermögen | – | – | – |
|
||||||
| 0030 | Lizenzen an gewerblichen Schutzrechten | Zahlungskonto | – | – | – |
|
| 0030 | Lizenzen an gewerblichen Schutzrechten | Anlagevermögen | – | – | – |
|
||||||
| 0210 | Maschinen | Zahlungskonto | – | – | – |
|
| 0210 | Maschinen | Anlagevermögen | – | – | – |
|
||||||
| 0320 | Pkw | Zahlungskonto | – | – | – |
|
| 0320 | Pkw | Anlagevermögen | – | – | – |
|
||||||
| 0350 | Lkw | Zahlungskonto | – | – | – |
|
| 0350 | Lkw | Anlagevermögen | – | – | – |
|
||||||
| 0400 | Betriebsausstattung | Zahlungskonto | – | – | – |
|
| 0400 | Betriebsausstattung | Anlagevermögen | – | – | – |
|
||||||
| 0410 | Geschäftsausstattung | Zahlungskonto | – | – | – |
|
| 0410 | Geschäftsausstattung | Anlagevermögen | – | – | – |
|
||||||
| 0420 | Büroeinrichtung | Zahlungskonto | – | – | – |
|
| 0420 | Büroeinrichtung | Anlagevermögen | – | – | – |
|
||||||
| 0440 | Werkzeuge | Zahlungskonto | – | – | – |
|
| 0440 | Werkzeuge | Anlagevermögen | – | – | – |
|
||||||
| 0480 | Geringwertige Wirtschaftsgüter | Zahlungskonto | – | – | – |
|
| 0480 | Geringwertige Wirtschaftsgüter | Anlagevermögen | – | – | – |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -181,8 +181,9 @@ Der kanonische Seed liegt in `migrations/018_seed_kontenrahmen_skr03_final.sql`.
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 4650 | Bewirtungskosten (70 % abzugsfähig) VSt. 19% | VSt19 | 66 | 44 |
|
| 4650 | Bewirtungskosten (70 % abzugsfähig) VSt. 19% | VSt19 | 66 | 44 |
|
||||||
| 4651 | Bewirtungskosten (70 % abzugsfähig) VSt. 7% | VSt7 | 66 | 44 |
|
| 4651 | Bewirtungskosten (70 % abzugsfähig) VSt. 7% | VSt7 | 66 | 44 |
|
||||||
|
| 4652 | Bewirtungskosten (70 % abzugsfähig) ohne Vorsteuer | – | – | 44 |
|
||||||
|
|
||||||
> Buchungen auf 4650/4651 werden automatisch 70/30 aufgeteilt: 70 % auf das Buchungskonto, 30 % auf 4654/4656 (Z. 51).
|
> Buchungen auf 4650/4651/4652 werden automatisch 70/30 aufgeteilt: 70 % auf das Buchungskonto, 30 % auf 4654/4656/4657 (Z. 51).
|
||||||
|
|
||||||
### Buchführung / Beratung (EÜR Z. 46)
|
### Buchführung / Beratung (EÜR Z. 46)
|
||||||
|
|
||||||
|
|
@ -224,10 +225,12 @@ Der kanonische Seed liegt in `migrations/018_seed_kontenrahmen_skr03_final.sql`.
|
||||||
| Kto. | Bezeichnung | Stcode | UStVA | EÜR |
|
| Kto. | Bezeichnung | Stcode | UStVA | EÜR |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 4289 | Häusliches Arbeitszimmer (nicht abziehbarer Anteil) | – | – | 51 |
|
| 4289 | Häusliches Arbeitszimmer (nicht abziehbarer Anteil) | – | – | 51 |
|
||||||
|
| 4320 | Gewerbesteuer (§ 4 Abs. 5b EStG) | – | – | 51 |
|
||||||
| 4635 | Geschenke nicht abzugsfähig ohne § 37b EStG | VSt19 | 66 | 51 |
|
| 4635 | Geschenke nicht abzugsfähig ohne § 37b EStG | VSt19 | 66 | 51 |
|
||||||
| 4636 | Geschenke nicht abzugsfähig mit § 37b EStG | VSt19 | 66 | 51 |
|
| 4636 | Geschenke nicht abzugsfähig mit § 37b EStG | VSt19 | 66 | 51 |
|
||||||
| 4654 | Nicht abzugsfähige Bewirtungskosten VSt. 19% | VSt19 | 66 | 51 |
|
| 4654 | Nicht abzugsfähige Bewirtungskosten VSt. 19% | VSt19 | 66 | 51 |
|
||||||
| 4656 | Nicht abzugsfähige Bewirtungskosten VSt. 7% | VSt7 | 66 | 51 |
|
| 4656 | Nicht abzugsfähige Bewirtungskosten VSt. 7% | VSt7 | 66 | 51 |
|
||||||
|
| 4657 | Nicht abzugsfähige Bewirtungskosten ohne Vorsteuer | – | – | 51 |
|
||||||
| 4672 | Reisekosten Unternehmer (nicht abziehbarer Anteil) | – | – | 51 |
|
| 4672 | Reisekosten Unternehmer (nicht abziehbarer Anteil) | – | – | 51 |
|
||||||
|
|
||||||
> Konten mit Z. 51 fließen **nicht** in den EÜR-Gewinn ein, werden aber separat unter KZ 165 ausgewiesen.
|
> Konten mit Z. 51 fließen **nicht** in den EÜR-Gewinn ein, werden aber separat unter KZ 165 ausgewiesen.
|
||||||
|
|
@ -246,7 +249,6 @@ Der kanonische Seed liegt in `migrations/018_seed_kontenrahmen_skr03_final.sql`.
|
||||||
| 4300 | Nicht abziehbare Vorsteuer | – | – | 52 |
|
| 4300 | Nicht abziehbare Vorsteuer | – | – | 52 |
|
||||||
| 4301 | Nicht abziehbare Vorsteuer 7 % | – | – | 52 |
|
| 4301 | Nicht abziehbare Vorsteuer 7 % | – | – | 52 |
|
||||||
| 4306 | Nicht abziehbare Vorsteuer 19 % | – | – | 52 |
|
| 4306 | Nicht abziehbare Vorsteuer 19 % | – | – | 52 |
|
||||||
| 4320 | Gewerbesteuer | – | – | 52 |
|
|
||||||
| 4340 | Sonstige Betriebssteuern | – | – | 52 |
|
| 4340 | Sonstige Betriebssteuern | – | – | 52 |
|
||||||
| 4350 | Verbrauchsteuer (sonstige Steuern) | – | – | 52 |
|
| 4350 | Verbrauchsteuer (sonstige Steuern) | – | – | 52 |
|
||||||
| 4360 | Versicherungen | – | – | 52 |
|
| 4360 | Versicherungen | – | – | 52 |
|
||||||
|
|
@ -358,7 +360,7 @@ Der kanonische Seed liegt in `migrations/018_seed_kontenrahmen_skr03_final.sql`.
|
||||||
| 48 | Miete/Leasing bewegliche Wirtschaftsgüter, Lizenzen |
|
| 48 | Miete/Leasing bewegliche Wirtschaftsgüter, Lizenzen |
|
||||||
| 49 | AfA Sachanlagen und immaterielle Wirtschaftsgüter |
|
| 49 | AfA Sachanlagen und immaterielle Wirtschaftsgüter |
|
||||||
| 50 | Sofortabschreibung GWG (§ 6 Abs. 2 EStG) |
|
| 50 | Sofortabschreibung GWG (§ 6 Abs. 2 EStG) |
|
||||||
| 51 | Nicht abzugsfähige Betriebsausgaben § 4 Abs. 5 EStG (KZ 165) |
|
| 51 | Nicht abzugsfähige Betriebsausgaben § 4 Abs. 5 und Abs. 5b EStG (KZ 165) |
|
||||||
| 52 | Sonstige und übrige Betriebsausgaben |
|
| 52 | Sonstige und übrige Betriebsausgaben |
|
||||||
| 65 | Häusliches Arbeitszimmer (abziehbarer Anteil, § 4 Abs. 5 Nr. 6b EStG) |
|
| 65 | Häusliches Arbeitszimmer (abziehbarer Anteil, § 4 Abs. 5 Nr. 6b EStG) |
|
||||||
| 66 | Tagespauschale häusliche Wohnung (§ 4 Abs. 5 Nr. 6b S. 4 EStG) |
|
| 66 | Tagespauschale häusliche Wohnung (§ 4 Abs. 5 Nr. 6b S. 4 EStG) |
|
||||||
93
docs/update-backup.md
Normal file
93
docs/update-backup.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Update & Backup
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript erstellt einen Dump der Datenbank sowie ein Archiv der Uploads im Verzeichnis `backups/`. Die Rotation erfolgt nach folgendem Schema:
|
||||||
|
|
||||||
|
- 7 Tages-Backups
|
||||||
|
- 4 Wochen-Backups
|
||||||
|
- 12 Monats-Backups
|
||||||
|
|
||||||
|
Ein `flock`-Lock verhindert parallele Läufe bei Verzögerungen.
|
||||||
|
|
||||||
|
### Manuelles Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec db mariadb-dump -u steuerkram -psteuerkram steuerkram \
|
||||||
|
> ~/steuerkram_backup_$(date +%Y%m%d_%H%M).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update
|
||||||
|
|
||||||
|
Für ein vollständiges Update steht `scripts/update.sh` bereit. Es führt alle notwendigen Schritte automatisch aus.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Script erledigt in dieser Reihenfolge:
|
||||||
|
|
||||||
|
1. **Voraussetzungen** – Docker, Docker Compose und Git werden geprüft
|
||||||
|
2. **Backup** – `scripts/backup.sh` wird vor jeder Änderung aufgerufen
|
||||||
|
3. **git pull** – Code wird aktualisiert; geänderte Dateien werden erkannt
|
||||||
|
4. **Image-Rebuild** – Das Docker-Image wird neu gebaut, wenn `Dockerfile`, `composer.json`, `composer.lock`, `php/`, `apache/` oder `docker-entrypoint.sh` geändert wurden
|
||||||
|
5. **Container-Neustart** – `docker compose down --remove-orphans` + `up -d`
|
||||||
|
6. **Healthcheck** – Wartet bis die Datenbank `healthy` meldet (max. 120 s)
|
||||||
|
7. **Migrationen** – `scripts/migrate.php` wird explizit ausgeführt (idempotent)
|
||||||
|
8. **Tests** – Die Test-Suite wird ausgeführt und das Ergebnis angezeigt
|
||||||
|
|
||||||
|
### Optionen
|
||||||
|
|
||||||
|
| Option | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `--no-backup` | Backup überspringen (z. B. wenn gerade erst gesichert) |
|
||||||
|
| `--no-tests` | Tests nicht ausführen (schnellerer Ablauf) |
|
||||||
|
| `--force-rebuild` | Docker-Image immer neu bauen, unabhängig von Dateiänderungen |
|
||||||
|
|
||||||
|
### Migrationen
|
||||||
|
|
||||||
|
Datenbankmigrationen werden von `scripts/migrate.php` verwaltet. Das Script prüft, welche Migrations-Dateien noch nicht angewendet wurden, und führt sie der Reihe nach aus. Es wird sowohl vom Container-Entrypoint als auch von `update.sh` aufgerufen.
|
||||||
|
|
||||||
|
```
|
||||||
|
[migrate] Starte Migrations-Prüfung...
|
||||||
|
[migrate] Führe aus: NNN_beschreibung.sql
|
||||||
|
[migrate] N Migration(en) erfolgreich angewendet.
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrationen können auch manuell ausgelöst werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec web php scripts/migrate.php
|
||||||
|
```
|
||||||
|
|
||||||
|
### Diagnose bei Fehlern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps # Container-Status
|
||||||
|
docker compose logs web # Web-Container-Log (PHP-Fehler, Migrationen)
|
||||||
|
docker compose logs db # Datenbank-Log
|
||||||
|
```
|
||||||
|
|
||||||
|
Falls ein Update fehlschlägt und ein Rollback nötig ist, das letzte Backup einspielen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T db mariadb -u steuerkram -psteuerkram steuerkram \
|
||||||
|
< backups/steuerkram_db_YYYYMMDD_HHMM.sql
|
||||||
|
```
|
||||||
17
migrations/028_euer_4320_bewirtung_ohne_vst.sql
Normal file
17
migrations/028_euer_4320_bewirtung_ohne_vst.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- Migration 028: Gewerbesteuer (4320) als nicht abzugsfähig klassifizieren,
|
||||||
|
-- Bewirtungskonten ohne Vorsteuer (4652, 4657) ergänzen
|
||||||
|
|
||||||
|
-- 4320 Gewerbesteuer: § 4 Abs. 5b EStG → nicht abzugsfähig (KZ 165)
|
||||||
|
UPDATE accounts
|
||||||
|
SET euer_zeile = '51'
|
||||||
|
WHERE kontonummer = '4320';
|
||||||
|
|
||||||
|
-- Bewirtungskonten ohne Vorsteuer (Pendant zu 4650/4654 und 4651/4656)
|
||||||
|
INSERT INTO accounts (kontonummer, bezeichnung, kontenart, steuercode, ustva_kz, euer_zeile, is_system, aktiv)
|
||||||
|
VALUES
|
||||||
|
('4652', 'Bewirtungskosten (70 % abzugsfähig) ohne Vorsteuer', 'Betriebsausgabe', NULL, NULL, '44', 1, 1),
|
||||||
|
('4657', 'Nicht abzugsfähige Bewirtungskosten ohne Vorsteuer', 'Betriebsausgabe', NULL, NULL, '51', 1, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
bezeichnung = VALUES(bezeichnung),
|
||||||
|
kontenart = VALUES(kontenart),
|
||||||
|
euer_zeile = VALUES(euer_zeile);
|
||||||
6
migrations/029_logo_position.sql
Normal file
6
migrations/029_logo_position.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
ALTER TABLE company_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS logo_max_width INT NOT NULL DEFAULT 600,
|
||||||
|
ADD COLUMN IF NOT EXISTS logo_max_height INT NOT NULL DEFAULT 100,
|
||||||
|
ADD COLUMN IF NOT EXISTS logo_position VARCHAR(10) NOT NULL DEFAULT 'center',
|
||||||
|
ADD COLUMN IF NOT EXISTS logo_margin_side INT NOT NULL DEFAULT 75,
|
||||||
|
ADD COLUMN IF NOT EXISTS logo_margin_top INT NOT NULL DEFAULT 6;
|
||||||
|
|
@ -109,8 +109,9 @@ img { max-width: 100%; }
|
||||||
.main-content {
|
.main-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
|
|
@ -121,6 +122,9 @@ img { max-width: 100%; }
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-title {
|
.topbar-title {
|
||||||
|
|
@ -368,6 +372,18 @@ td { padding: .65rem 1rem; vertical-align: middle; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Invoice preview (DIN 5008) ──────────────────────────────── */
|
/* ── Invoice preview (DIN 5008) ──────────────────────────────── */
|
||||||
|
.invoice-preview thead th {
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: normal;
|
||||||
|
background: revert;
|
||||||
|
padding: revert;
|
||||||
|
white-space: revert;
|
||||||
|
color: inherit;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
.invoice-preview {
|
.invoice-preview {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 2cm 2cm 2.5cm;
|
padding: 2cm 2cm 2.5cm;
|
||||||
|
|
@ -379,6 +395,29 @@ td { padding: .65rem 1rem; vertical-align: middle; }
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Adressbereich: DIN 5008-Proportionen (50% = 85mm bei vollem A4-Preview).
|
||||||
|
* Breakpoint: nav (220px) + page-body-padding (45px) + A4-max-width (794px) = 1059px.
|
||||||
|
* Darunter stapeln sich die Spalten vertikal.
|
||||||
|
*/
|
||||||
|
.invoice-address {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50% 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1059px) {
|
||||||
|
.invoice-address {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.invoice-address > div:last-child {
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Modals ──────────────────────────────────────────────────── */
|
/* ── Modals ──────────────────────────────────────────────────── */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,10 @@ set -eo pipefail
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
BACKUP_DIR="$PROJECT_DIR/backups"
|
BACKUP_DIR="$PROJECT_DIR/backups"
|
||||||
LOCK_FILE="/tmp/steuerkram-backup.lock"
|
LOCK_FILE="$BACKUP_DIR/.backup.lock"
|
||||||
|
|
||||||
|
# Backup-Verzeichnis sicherstellen, bevor Lock erworben wird
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
# Verhindert mehrfachen gleichzeitigen Start (z.B. bei Verzögerung durch langen Backup-Lauf)
|
# Verhindert mehrfachen gleichzeitigen Start (z.B. bei Verzögerung durch langen Backup-Lauf)
|
||||||
exec 9>"$LOCK_FILE"
|
exec 9>"$LOCK_FILE"
|
||||||
|
|
@ -24,7 +27,6 @@ if ! flock -n 9; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
|
|
||||||
echo "$(date '+%Y-%m-%d %H:%M:%S') Erstelle Backup $TIMESTAMP..."
|
echo "$(date '+%Y-%m-%d %H:%M:%S') Erstelle Backup $TIMESTAMP..."
|
||||||
|
|
||||||
|
|
|
||||||
104
scripts/ki-batch.php
Normal file
104
scripts/ki-batch.php
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KI-Kontierungsvorschlag – Batch-Verarbeitung (CLI-Einstiegspunkt).
|
||||||
|
*
|
||||||
|
* Verarbeitet EINEN ausstehenden Beleg pro Aufruf (ki_vorschlag IS NULL, locked = 0).
|
||||||
|
* Verhindert so, dass mehrere Vision-Model-Anfragen parallel an Ollama geschickt werden.
|
||||||
|
*
|
||||||
|
* Voraussetzung: ki_vorschlag_aktiv = 1 in den Einstellungen, Ollama erreichbar.
|
||||||
|
*
|
||||||
|
* Aufruf:
|
||||||
|
* docker compose exec web php scripts/ki-batch.php
|
||||||
|
*
|
||||||
|
* Für optionalen eigenen Cronjob (manuelle Uploads ohne Nextcloud):
|
||||||
|
* *\/5 * * * * docker compose -f /opt/steuerkram/docker-compose.yml exec -T web \
|
||||||
|
* php scripts/ki-batch.php >> /var/log/steuerkram-ki.log 2>&1
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
chdir(dirname(__DIR__));
|
||||||
|
|
||||||
|
// DB-Zugangsdaten aus .env laden (für CLI-Kontext ohne Apache-Umgebung)
|
||||||
|
if (file_exists('.env')) {
|
||||||
|
foreach (file('.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||||
|
if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
[$key, $value] = explode('=', $line, 2);
|
||||||
|
$key = trim($key);
|
||||||
|
$value = trim($value, " \t\n\r\0\x0B\"'");
|
||||||
|
if (!array_key_exists($key, $_ENV)) {
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
putenv("{$key}={$value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once 'vendor/autoload.php';
|
||||||
|
|
||||||
|
use Steuerkram\Config;
|
||||||
|
use Steuerkram\Database;
|
||||||
|
use Steuerkram\Models\Beleg;
|
||||||
|
use Steuerkram\Models\Setting;
|
||||||
|
use Steuerkram\Services\KontierungsvorschlagService;
|
||||||
|
|
||||||
|
Config::load();
|
||||||
|
|
||||||
|
$tag = '[ki-batch]';
|
||||||
|
|
||||||
|
// KI-Erkennung in den Einstellungen aktiv?
|
||||||
|
$settings = Setting::get();
|
||||||
|
if (empty($settings['ki_vorschlag_aktiv'])) {
|
||||||
|
echo "{$tag} KI-Erkennung ist in den Einstellungen deaktiviert – nichts zu tun." . PHP_EOL;
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$svc = new KontierungsvorschlagService();
|
||||||
|
|
||||||
|
if (!$svc->isEnabled()) {
|
||||||
|
echo "{$tag} LLM nicht erreichbar – Abbruch." . PHP_EOL;
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionale Beleg-ID als CLI-Argument: php ki-batch.php 123
|
||||||
|
$argId = isset($argv[1]) && ctype_digit((string)$argv[1]) ? (int)$argv[1] : null;
|
||||||
|
|
||||||
|
// Beleg holen: wenn ID angegeben, diesen direkt verarbeiten; sonst ältesten ausstehenden
|
||||||
|
$row = $argId !== null
|
||||||
|
? Database::fetchOne('SELECT id FROM belege WHERE id = ? AND ki_vorschlag IS NULL AND locked = 0', [$argId])
|
||||||
|
: Database::fetchOne('SELECT id FROM belege WHERE ki_vorschlag IS NULL AND locked = 0 ORDER BY id ASC LIMIT 1');
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
echo "{$tag} Kein ausstehender Beleg gefunden." . PHP_EOL;
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$belegId = (int)$row['id'];
|
||||||
|
$beleg = Beleg::find($belegId);
|
||||||
|
|
||||||
|
if (!$beleg) {
|
||||||
|
echo "{$tag} Beleg #{$belegId} nicht ladbar – Abbruch." . PHP_EOL;
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "{$tag} Verarbeite Beleg #{$belegId} ({$beleg['beleg_nr']}) ..." . PHP_EOL;
|
||||||
|
|
||||||
|
$start = microtime(true);
|
||||||
|
$result = $svc->generateForBeleg($beleg);
|
||||||
|
|
||||||
|
if ($result === null) {
|
||||||
|
echo "{$tag} Beleg #{$belegId}: Kein Vorschlag generiert (nicht unterstützter Typ oder LLM-Fehler)." . PHP_EOL;
|
||||||
|
// Leeres Array speichern damit der Beleg nicht erneut verarbeitet wird
|
||||||
|
Beleg::saveKiVorschlag($belegId, []);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Beleg::saveKiVorschlag($belegId, $result);
|
||||||
|
|
||||||
|
$elapsed = round(microtime(true) - $start, 1);
|
||||||
|
$zeilen = count($result);
|
||||||
|
echo "{$tag} Beleg #{$belegId}: {$zeilen} Buchungszeile(n) generiert in {$elapsed}s." . PHP_EOL;
|
||||||
|
|
||||||
|
exit(0);
|
||||||
|
|
@ -41,6 +41,7 @@ if (file_exists('.env')) {
|
||||||
require_once 'vendor/autoload.php';
|
require_once 'vendor/autoload.php';
|
||||||
|
|
||||||
use Steuerkram\Config;
|
use Steuerkram\Config;
|
||||||
|
use Steuerkram\Models\Setting;
|
||||||
use Steuerkram\Services\NextcloudImportService;
|
use Steuerkram\Services\NextcloudImportService;
|
||||||
|
|
||||||
Config::load(); // Database verbindet sich lazy über Config::get()
|
Config::load(); // Database verbindet sich lazy über Config::get()
|
||||||
|
|
@ -64,4 +65,16 @@ echo sprintf(
|
||||||
$result['errors'],
|
$result['errors'],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// KI-Vorschlag für jeden neu importierten Beleg anstoßen (wenn KI-Erkennung aktiv)
|
||||||
|
if (!empty($result['imported_ids'])) {
|
||||||
|
$settings = Setting::get();
|
||||||
|
if (!empty($settings['ki_vorschlag_aktiv'])) {
|
||||||
|
$kiBatch = __DIR__ . '/ki-batch.php';
|
||||||
|
echo '[nextcloud-import] Starte KI-Erkennung für ' . count($result['imported_ids']) . ' importierte(n) Beleg(e) ...' . PHP_EOL;
|
||||||
|
foreach ($result['imported_ids'] as $belegId) {
|
||||||
|
passthru('php ' . escapeshellarg($kiBatch) . ' ' . (int)$belegId . ' 2>&1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
exit($result['errors'] > 0 ? 1 : 0);
|
exit($result['errors'] > 0 ? 1 : 0);
|
||||||
|
|
|
||||||
352
scripts/update.sh
Executable file
352
scripts/update.sh
Executable file
|
|
@ -0,0 +1,352 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Steuerkram – Update-Script
|
||||||
|
#
|
||||||
|
# Spielt alle Arten von Änderungen vollständig ein:
|
||||||
|
# • git pull
|
||||||
|
# • Backup (vor jeder Änderung)
|
||||||
|
# • Composer-Abhängigkeiten
|
||||||
|
# • Docker-Image-Rebuild (bei Änderungen an Dockerfile, composer.json, etc.)
|
||||||
|
# • Datenbank-Migrationen
|
||||||
|
# • Berechtigungen und Upload-Verzeichnisse
|
||||||
|
# • Optionale Testausführung
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./update.sh [--no-backup] [--no-tests] [--force-rebuild]
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Optionen ─────────────────────────────────────────────────────────────────
|
||||||
|
OPT_NO_BACKUP=false
|
||||||
|
OPT_NO_TESTS=false
|
||||||
|
OPT_FORCE_REBUILD=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--no-backup) OPT_NO_BACKUP=true ;;
|
||||||
|
--no-tests) OPT_NO_TESTS=true ;;
|
||||||
|
--force-rebuild) OPT_FORCE_REBUILD=true ;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--no-backup] [--no-tests] [--force-rebuild]"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unbekannte Option: $arg" >&2
|
||||||
|
echo "Usage: $0 [--no-backup] [--no-tests] [--force-rebuild]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Pfade ────────────────────────────────────────────────────────────────────
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# ── Farben ───────────────────────────────────────────────────────────────────
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
else
|
||||||
|
RED='' GREEN='' YELLOW='' CYAN='' BOLD='' NC=''
|
||||||
|
fi
|
||||||
|
|
||||||
|
step() { echo -e "\n${BOLD}${CYAN}══ $* ${NC}"; }
|
||||||
|
ok() { echo -e " ${GREEN}✓${NC} $*"; }
|
||||||
|
warn() { echo -e " ${YELLOW}⚠${NC} $*"; }
|
||||||
|
fail() { echo -e " ${RED}✗${NC} $*" >&2; }
|
||||||
|
info() { echo -e " $*"; }
|
||||||
|
|
||||||
|
# ── Fehlerbehandlung ─────────────────────────────────────────────────────────
|
||||||
|
UPDATE_FAILED=false
|
||||||
|
on_error() {
|
||||||
|
local exit_code=$?
|
||||||
|
local line=$1
|
||||||
|
fail "Fehler in Zeile $line (Exit-Code: $exit_code)"
|
||||||
|
UPDATE_FAILED=true
|
||||||
|
}
|
||||||
|
trap 'on_error $LINENO' ERR
|
||||||
|
|
||||||
|
# ── Startzeit ────────────────────────────────────────────────────────────────
|
||||||
|
START_TIME=$(date +%s)
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo -e "${BOLD} Steuerkram Update – $(date '+%Y-%m-%d %H:%M:%S')${NC}"
|
||||||
|
echo -e "${BOLD}════════════════════════════════════════════════════════${NC}"
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# SCHRITT 1: Voraussetzungen prüfen
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
step "1/8 Voraussetzungen prüfen"
|
||||||
|
|
||||||
|
if ! command -v docker &>/dev/null; then
|
||||||
|
fail "Docker ist nicht installiert oder nicht im PATH."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "Docker gefunden: $(docker --version | head -1)"
|
||||||
|
|
||||||
|
if ! docker info &>/dev/null 2>&1; then
|
||||||
|
fail "Docker-Daemon läuft nicht. Bitte starten: sudo systemctl start docker"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "Docker-Daemon erreichbar"
|
||||||
|
|
||||||
|
if ! docker compose version &>/dev/null 2>&1; then
|
||||||
|
fail "Docker Compose (Plugin) nicht verfügbar."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "Docker Compose gefunden: $(docker compose version)"
|
||||||
|
|
||||||
|
if ! git -C "$PROJECT_DIR" rev-parse --git-dir &>/dev/null; then
|
||||||
|
fail "Kein Git-Repository in $PROJECT_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "Git-Repository gefunden"
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# SCHRITT 2: Backup erstellen
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
step "2/8 Backup erstellen"
|
||||||
|
|
||||||
|
if [[ "$OPT_NO_BACKUP" == "true" ]]; then
|
||||||
|
warn "Backup übersprungen (--no-backup)"
|
||||||
|
else
|
||||||
|
# Prüfen ob Container läuft (Backup braucht laufende DB)
|
||||||
|
if docker compose ps --status running db 2>/dev/null | grep -q "db"; then
|
||||||
|
info "Erstelle Backup vor dem Update..."
|
||||||
|
if bash "$PROJECT_DIR/scripts/backup.sh"; then
|
||||||
|
ok "Backup erfolgreich erstellt"
|
||||||
|
else
|
||||||
|
warn "Backup fehlgeschlagen – Update wird trotzdem fortgesetzt"
|
||||||
|
warn "Prüfe den Backup-Fehler manuell: ./scripts/backup.sh"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Datenbank-Container nicht aktiv – Backup übersprungen"
|
||||||
|
warn "Beim ersten Start ist das normal."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# SCHRITT 3: git pull + Änderungen erkennen
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
step "3/8 Code aktualisieren (git pull)"
|
||||||
|
|
||||||
|
BEFORE_COMMIT=$(git rev-parse HEAD)
|
||||||
|
BEFORE_SHORT=$(git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
# Lokale Änderungen prüfen
|
||||||
|
if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then
|
||||||
|
warn "Ungespeicherte lokale Änderungen vorhanden:"
|
||||||
|
git status --short | head -20 | while read -r line; do info "$line"; done
|
||||||
|
warn "Diese Änderungen bleiben erhalten (kein stash)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
git pull --ff-only 2>&1 | while read -r line; do info "$line"; done
|
||||||
|
|
||||||
|
AFTER_COMMIT=$(git rev-parse HEAD)
|
||||||
|
AFTER_SHORT=$(git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
if [[ "$BEFORE_COMMIT" == "$AFTER_COMMIT" ]]; then
|
||||||
|
ok "Bereits auf dem neuesten Stand ($AFTER_SHORT)"
|
||||||
|
CHANGED_FILES=()
|
||||||
|
else
|
||||||
|
ok "Aktualisiert: $BEFORE_SHORT → $AFTER_SHORT"
|
||||||
|
mapfile -t CHANGED_FILES < <(git diff --name-only "$BEFORE_COMMIT" "$AFTER_COMMIT" 2>/dev/null || true)
|
||||||
|
info "Geänderte Dateien (${#CHANGED_FILES[@]}):"
|
||||||
|
for f in "${CHANGED_FILES[@]}"; do info " $f"; done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Rebuild-Bedarf ermitteln ─────────────────────────────────────────────────
|
||||||
|
NEEDS_REBUILD=false
|
||||||
|
|
||||||
|
if [[ "$OPT_FORCE_REBUILD" == "true" ]]; then
|
||||||
|
NEEDS_REBUILD=true
|
||||||
|
info "Rebuild erzwungen (--force-rebuild)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Dateien, die einen Rebuild des Docker-Images erfordern
|
||||||
|
REBUILD_TRIGGERS=(
|
||||||
|
"Dockerfile"
|
||||||
|
"composer.json"
|
||||||
|
"composer.lock"
|
||||||
|
"php/"
|
||||||
|
"apache/"
|
||||||
|
"docker-entrypoint.sh"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$BEFORE_COMMIT" != "$AFTER_COMMIT" ]]; then
|
||||||
|
for trigger in "${REBUILD_TRIGGERS[@]}"; do
|
||||||
|
for changed in "${CHANGED_FILES[@]}"; do
|
||||||
|
if [[ "$changed" == "$trigger" || "$changed" == ${trigger}* ]]; then
|
||||||
|
NEEDS_REBUILD=true
|
||||||
|
info "Rebuild ausgelöst durch: $changed"
|
||||||
|
break 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# SCHRITT 4: Docker-Image bauen (wenn nötig)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
step "4/8 Docker-Image"
|
||||||
|
|
||||||
|
if [[ "$NEEDS_REBUILD" == "true" ]]; then
|
||||||
|
info "Baue Docker-Image neu..."
|
||||||
|
docker compose build --no-cache web 2>&1 | while read -r line; do
|
||||||
|
# Fortschrittszeilen komprimieren
|
||||||
|
[[ "$line" =~ ^#[0-9]+ ]] && echo -n "." || info "$line"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
ok "Image erfolgreich gebaut"
|
||||||
|
else
|
||||||
|
ok "Kein Rebuild nötig (keine relevanten Änderungen)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# SCHRITT 5: Container neu starten
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
step "5/8 Container neu starten"
|
||||||
|
|
||||||
|
info "Stoppe laufende Container..."
|
||||||
|
docker compose down --remove-orphans 2>&1 | while read -r line; do info "$line"; done
|
||||||
|
|
||||||
|
info "Starte Container..."
|
||||||
|
docker compose up -d 2>&1 | while read -r line; do info "$line"; done
|
||||||
|
|
||||||
|
ok "Container gestartet"
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# SCHRITT 6: Auf Betriebsbereitschaft warten
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
step "6/8 Auf Betriebsbereitschaft warten"
|
||||||
|
|
||||||
|
TIMEOUT=120
|
||||||
|
ELAPSED=0
|
||||||
|
INTERVAL=3
|
||||||
|
|
||||||
|
info "Warte auf Datenbank-Healthcheck (max. ${TIMEOUT}s)..."
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
DB_STATUS=$(docker compose ps --format json db 2>/dev/null \
|
||||||
|
| python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('Health',''))" 2>/dev/null \
|
||||||
|
|| docker compose ps db 2>/dev/null | grep -oP '(healthy|starting|unhealthy)' | head -1 \
|
||||||
|
|| echo "unknown")
|
||||||
|
|
||||||
|
if [[ "$DB_STATUS" == "healthy" ]]; then
|
||||||
|
ok "Datenbank ist bereit (${ELAPSED}s)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $ELAPSED -ge $TIMEOUT ]]; then
|
||||||
|
fail "Timeout: Datenbank nicht bereit nach ${TIMEOUT}s"
|
||||||
|
fail "Status: $DB_STATUS"
|
||||||
|
info "Logs: docker compose logs db"
|
||||||
|
UPDATE_FAILED=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -n "."
|
||||||
|
sleep $INTERVAL
|
||||||
|
ELAPSED=$((ELAPSED + INTERVAL))
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Web-Container kurz warten (Entrypoint läuft noch)
|
||||||
|
info "Warte auf Web-Container..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
WEB_STATUS=$(docker compose ps --format json web 2>/dev/null \
|
||||||
|
| python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('State',''))" 2>/dev/null \
|
||||||
|
|| docker compose ps web 2>/dev/null | grep -oP '(running|exited|restarting)' | head -1 \
|
||||||
|
|| echo "unknown")
|
||||||
|
|
||||||
|
if [[ "$WEB_STATUS" == "running" ]]; then
|
||||||
|
ok "Web-Container läuft"
|
||||||
|
else
|
||||||
|
warn "Web-Container Status: $WEB_STATUS"
|
||||||
|
info "Logs der letzten 20 Zeilen:"
|
||||||
|
docker compose logs --tail=20 web 2>/dev/null | while read -r line; do info "$line"; done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# SCHRITT 7: Migrationen explizit ausführen
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
step "7/8 Datenbank-Migrationen"
|
||||||
|
|
||||||
|
# Der Entrypoint führt Migrationen bereits aus, aber wir führen sie
|
||||||
|
# nochmals explizit aus um sicherzustellen, dass alle eingespielt sind
|
||||||
|
# und um den Status im Log zu sehen.
|
||||||
|
|
||||||
|
info "Führe Migrationen aus (idempotent)..."
|
||||||
|
MIGRATE_OUTPUT=$(docker compose exec -T web php /var/www/html/scripts/migrate.php 2>&1) || {
|
||||||
|
fail "Migrationen fehlgeschlagen"
|
||||||
|
echo "$MIGRATE_OUTPUT" | while read -r line; do info "$line"; done
|
||||||
|
UPDATE_FAILED=true
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "$MIGRATE_OUTPUT" | while read -r line; do info "$line"; done
|
||||||
|
|
||||||
|
if [[ "$UPDATE_FAILED" == "false" ]]; then
|
||||||
|
ok "Migrationen erfolgreich"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# SCHRITT 8: Tests ausführen
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
step "8/8 Tests"
|
||||||
|
|
||||||
|
if [[ "$OPT_NO_TESTS" == "true" ]]; then
|
||||||
|
warn "Tests übersprungen (--no-tests)"
|
||||||
|
else
|
||||||
|
info "Führe Test-Suite aus..."
|
||||||
|
TEST_OUTPUT=$(docker compose exec -T web php /var/www/html/tests/run.php 2>&1) || true
|
||||||
|
TEST_EXIT=${PIPESTATUS[0]:-0}
|
||||||
|
|
||||||
|
echo "$TEST_OUTPUT" | while read -r line; do info "$line"; done
|
||||||
|
|
||||||
|
if echo "$TEST_OUTPUT" | grep -q "FEHLER\|FAILED\|fehlgeschlagen\|failed"; then
|
||||||
|
warn "Einige Tests sind fehlgeschlagen – bitte prüfen"
|
||||||
|
warn "Vollständiger Testlauf: docker compose exec web php tests/run.php"
|
||||||
|
else
|
||||||
|
ok "Alle Tests bestanden"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# ZUSAMMENFASSUNG
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
END_TIME=$(date +%s)
|
||||||
|
DURATION=$((END_TIME - START_TIME))
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}════════════════════════════════════════════════════════${NC}"
|
||||||
|
|
||||||
|
if [[ "$UPDATE_FAILED" == "true" ]]; then
|
||||||
|
echo -e "${RED}${BOLD} Update mit Fehlern abgeschlossen (${DURATION}s)${NC}"
|
||||||
|
echo -e "${BOLD}════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " Diagnose:"
|
||||||
|
echo " docker compose logs web"
|
||||||
|
echo " docker compose logs db"
|
||||||
|
echo " docker compose ps"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}${BOLD} Update erfolgreich (${DURATION}s)${NC}"
|
||||||
|
echo -e "${BOLD}════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
if [[ "$BEFORE_COMMIT" != "$AFTER_COMMIT" ]]; then
|
||||||
|
echo -e " ${CYAN}$BEFORE_SHORT${NC} → ${GREEN}$AFTER_SHORT${NC}"
|
||||||
|
else
|
||||||
|
echo -e " Commit: ${GREEN}$AFTER_SHORT${NC} (keine neuen Commits)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo " Anwendung: http://localhost:8080"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
@ -15,7 +15,6 @@ class Config
|
||||||
'db_name' => $_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: 'steuerkram',
|
'db_name' => $_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: 'steuerkram',
|
||||||
'db_user' => $_ENV['DB_USER'] ?? getenv('DB_USER') ?: 'steuerkram',
|
'db_user' => $_ENV['DB_USER'] ?? getenv('DB_USER') ?: 'steuerkram',
|
||||||
'db_pass' => $_ENV['DB_PASS'] ?? getenv('DB_PASS') ?: 'steuerkram',
|
'db_pass' => $_ENV['DB_PASS'] ?? getenv('DB_PASS') ?: 'steuerkram',
|
||||||
'app_secret' => $_ENV['APP_SECRET'] ?? getenv('APP_SECRET') ?: 'dev-secret',
|
|
||||||
'upload_dir' => dirname(__DIR__) . '/public/static/uploads',
|
'upload_dir' => dirname(__DIR__) . '/public/static/uploads',
|
||||||
'base_url' => '',
|
'base_url' => '',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,8 @@ class BelegController
|
||||||
$kiVorschlag = $beleg['ki_vorschlag'] !== null
|
$kiVorschlag = $beleg['ki_vorschlag'] !== null
|
||||||
? json_decode($beleg['ki_vorschlag'], true)
|
? json_decode($beleg['ki_vorschlag'], true)
|
||||||
: null;
|
: null;
|
||||||
|
// KI-Sektion anzeigen wenn Ergebnis vorhanden ODER LLM gerade verfügbar
|
||||||
|
$kiSektion = $kiAktiv || $kiVorschlag !== null;
|
||||||
|
|
||||||
$view = new View();
|
$view = new View();
|
||||||
$view->render('layout/base.php', [
|
$view->render('layout/base.php', [
|
||||||
|
|
@ -218,6 +220,7 @@ class BelegController
|
||||||
'accounts' => $accounts,
|
'accounts' => $accounts,
|
||||||
'assets' => $assets,
|
'assets' => $assets,
|
||||||
'kiAktiv' => $kiAktiv,
|
'kiAktiv' => $kiAktiv,
|
||||||
|
'kiSektion' => $kiSektion,
|
||||||
'kiVorschlag' => $kiVorschlag,
|
'kiVorschlag' => $kiVorschlag,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class SettingsController
|
||||||
$view = new View();
|
$view = new View();
|
||||||
$view->render('layout/base.php', [
|
$view->render('layout/base.php', [
|
||||||
'pageTitle' => 'Einstellungen',
|
'pageTitle' => 'Einstellungen',
|
||||||
|
'topbarActions' => '<button type="submit" form="settings-form" class="btn btn-primary">Einstellungen speichern</button>',
|
||||||
'content' => $this->renderView('settings/form', [
|
'content' => $this->renderView('settings/form', [
|
||||||
'settings' => $settings,
|
'settings' => $settings,
|
||||||
'errors' => [],
|
'errors' => [],
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ class Setting
|
||||||
|
|
||||||
public static function update(array $data): void
|
public static function update(array $data): void
|
||||||
{
|
{
|
||||||
|
$logoPosition = in_array($data['logo_position'] ?? '', ['center', 'left', 'right'])
|
||||||
|
? $data['logo_position']
|
||||||
|
: 'center';
|
||||||
|
|
||||||
Database::execute(
|
Database::execute(
|
||||||
'UPDATE company_settings SET
|
'UPDATE company_settings SET
|
||||||
firma=?, inhaber=?, strasse=?, plz=?, ort=?,
|
firma=?, inhaber=?, strasse=?, plz=?, ort=?,
|
||||||
|
|
@ -29,9 +33,11 @@ class Setting
|
||||||
iban=?, bic=?, bank_name=?,
|
iban=?, bic=?, bank_name=?,
|
||||||
ustva_steuernummer=?, ustva_bundesland=?,
|
ustva_steuernummer=?, ustva_bundesland=?,
|
||||||
invoice_nr_prefix=?, beleg_nr_prefix=?, anlage_nr_prefix=?,
|
invoice_nr_prefix=?, beleg_nr_prefix=?, anlage_nr_prefix=?,
|
||||||
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?, ocr_aktiv=?,
|
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?,
|
||||||
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
|
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
|
||||||
nextcloud_folder=?, nextcloud_done=?
|
nextcloud_folder=?, nextcloud_done=?,
|
||||||
|
logo_max_width=?, logo_max_height=?,
|
||||||
|
logo_position=?, logo_margin_side=?, logo_margin_top=?
|
||||||
WHERE id=1',
|
WHERE id=1',
|
||||||
[
|
[
|
||||||
$data['firma'] ?? '',
|
$data['firma'] ?? '',
|
||||||
|
|
@ -56,12 +62,16 @@ class Setting
|
||||||
(int)($data['payment_days_default'] ?? 14),
|
(int)($data['payment_days_default'] ?? 14),
|
||||||
isset($data['test_mode']) ? 1 : 0,
|
isset($data['test_mode']) ? 1 : 0,
|
||||||
isset($data['ki_vorschlag_aktiv']) ? 1 : 0,
|
isset($data['ki_vorschlag_aktiv']) ? 1 : 0,
|
||||||
isset($data['ocr_aktiv']) ? 1 : 0,
|
|
||||||
$data['nextcloud_url'] ?: null,
|
$data['nextcloud_url'] ?: null,
|
||||||
$data['nextcloud_user'] ?: null,
|
$data['nextcloud_user'] ?: null,
|
||||||
$data['nextcloud_pass'] ?: null,
|
$data['nextcloud_pass'] ?: null,
|
||||||
$data['nextcloud_folder'] ?: '/Belege/Import',
|
$data['nextcloud_folder'] ?: '/Belege/Import',
|
||||||
$data['nextcloud_done'] ?: null,
|
$data['nextcloud_done'] ?: null,
|
||||||
|
max(50, (int)($data['logo_max_width'] ?? 600)),
|
||||||
|
max(20, (int)($data['logo_max_height'] ?? 100)),
|
||||||
|
$logoPosition,
|
||||||
|
max(0, (int)($data['logo_margin_side'] ?? 75)),
|
||||||
|
max(0, (int)($data['logo_margin_top'] ?? 6)),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,6 @@ class KontierungsvorschlagService
|
||||||
return (bool)($s['ki_vorschlag_aktiv'] ?? 0) && $this->llm->isAvailable();
|
return (bool)($s['ki_vorschlag_aktiv'] ?? 0) && $this->llm->isAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isOcrEnabled(): bool
|
|
||||||
{
|
|
||||||
$s = Setting::get();
|
|
||||||
return (bool)($s['ocr_aktiv'] ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function extractText(array $beleg): string
|
public function extractText(array $beleg): string
|
||||||
{
|
{
|
||||||
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||||
|
|
@ -38,14 +32,33 @@ class KontierungsvorschlagService
|
||||||
$mime = strtolower($beleg['mime_type'] ?? '');
|
$mime = strtolower($beleg['mime_type'] ?? '');
|
||||||
|
|
||||||
if ($mime === 'application/pdf') {
|
if ($mime === 'application/pdf') {
|
||||||
|
// 1. eRechnung-XML (ZUGFeRD / Factur-X / XRechnung) — strukturiert, fehlerarm
|
||||||
|
$xml = $this->extractErechnungXml($absPath);
|
||||||
|
if ($xml !== '') return $xml;
|
||||||
|
|
||||||
|
// 2. Vision-Modell (qwen2.5vl o.ä.)
|
||||||
|
if ($this->llm->isVisionAvailable()) {
|
||||||
|
$vision = $this->extractPdfVision($absPath);
|
||||||
|
if ($vision !== '') return $vision;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback: pdftotext
|
||||||
return $this->extractPdfText($absPath);
|
return $this->extractPdfText($absPath);
|
||||||
} elseif (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) {
|
}
|
||||||
return $this->isOcrEnabled() ? $this->extractImageText($absPath) : '';
|
|
||||||
|
if (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) {
|
||||||
|
if (!$this->llm->isVisionAvailable()) return '';
|
||||||
|
return $this->extractImageVision($absPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen Kontierungsvorschlag für den Beleg.
|
||||||
|
* Gibt ein Array von Buchungszeilen zurück (eine je MwSt-Satz),
|
||||||
|
* oder null bei Fehler / nicht unterstütztem MIME-Typ.
|
||||||
|
*/
|
||||||
public function generateForBeleg(array $beleg): ?array
|
public function generateForBeleg(array $beleg): ?array
|
||||||
{
|
{
|
||||||
$text = $this->extractText($beleg);
|
$text = $this->extractText($beleg);
|
||||||
|
|
@ -82,20 +95,20 @@ class KontierungsvorschlagService
|
||||||
));
|
));
|
||||||
|
|
||||||
$beleginhalt = trim($text) !== ''
|
$beleginhalt = trim($text) !== ''
|
||||||
? "Belegtext:\n" . mb_substr(trim($text), 0, 2000)
|
? "Belegtext:\n" . mb_substr(trim($text), 0, 3000)
|
||||||
: '[Kein Text extrahierbar]';
|
: '[Kein Text extrahierbar]';
|
||||||
|
|
||||||
$settings = Setting::get();
|
$settings = Setting::get();
|
||||||
$eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? ''));
|
$eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? ''));
|
||||||
$eigenerHinweis = $eigenerName !== ''
|
$eigenerHinweis = $eigenerName !== ''
|
||||||
? "\n- supplier_name: NICHT \"{$eigenerName}\" (das ist der Rechnungsempfaenger, nicht der Lieferant). Bei Ausgabe = Aussteller/Verkaeuer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar."
|
? "\n- supplier_name: NICHT \"{$eigenerName}\" (das ist der Rechnungsempfaenger, nicht der Lieferant). Bei Ausgabe = Aussteller/Verkaeufer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar."
|
||||||
: "\n- supplier_name: Bei Ausgabe = Aussteller/Verkaeuer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar.";
|
: "\n- supplier_name: Bei Ausgabe = Aussteller/Verkaeufer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar.";
|
||||||
|
|
||||||
$prompt = <<<PROMPT
|
$prompt = <<<PROMPT
|
||||||
Du bist ein Buchhalter (SKR03-Kontenrahmen, Deutschland). Analysiere den folgenden Beleg und erstelle einen Kontierungsvorschlag.
|
Du bist ein Buchhalter (SKR03-Kontenrahmen, Deutschland). Analysiere den folgenden Beleg und erstelle einen Kontierungsvorschlag.
|
||||||
|
|
||||||
Antworte AUSSCHLIESSLICH mit einem einzigen JSON-Objekt, ohne erklaerenden Text davor oder danach:
|
Antworte AUSSCHLIESSLICH mit einem JSON-Array. Eine Zeile je MwSt-Satz (7% und 19% = zwei Eintraege). Nur ein Eintrag wenn ein einziger Steuersatz. Kein erklaerende Text davor oder danach:
|
||||||
{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":19,"betrag_netto":33.64,"buchungstext":"Rechnung 2026-001","supplier_name":"Mustermann GmbH"}
|
[{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":7,"betrag_netto":138.10,"buchungstext":"Rechnung 2026-001","supplier_name":"Mustermann GmbH"},{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":19,"betrag_netto":26.24,"buchungstext":"Rechnung 2026-001","supplier_name":"Mustermann GmbH"}]
|
||||||
|
|
||||||
Erlaubte Werte:
|
Erlaubte Werte:
|
||||||
- buchungstyp: "Ausgabe" oder "Einnahme"
|
- buchungstyp: "Ausgabe" oder "Einnahme"
|
||||||
|
|
@ -117,43 +130,222 @@ PROMPT;
|
||||||
return $this->parseAndValidate($response, $allAccounts);
|
return $this->parseAndValidate($response, $allAccounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Text-Extraktion
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrahiert eingebettete eRechnung-XML (ZUGFeRD / Factur-X / XRechnung).
|
||||||
|
* Gibt XML-Inhalt (max. 12.000 Zeichen) zurück, oder '' wenn keiner gefunden.
|
||||||
|
*/
|
||||||
|
private function extractErechnungXml(string $path): string
|
||||||
|
{
|
||||||
|
// pdfdetach ist Teil von poppler-utils (zusammen mit pdftotext installiert)
|
||||||
|
$safe = escapeshellarg($path);
|
||||||
|
exec("pdfdetach -list {$safe} 2>/dev/null", $listOutput, $rc);
|
||||||
|
if ($rc !== 0 || empty($listOutput)) return '';
|
||||||
|
|
||||||
|
$hasXml = false;
|
||||||
|
foreach ($listOutput as $line) {
|
||||||
|
if (stripos($line, '.xml') !== false) {
|
||||||
|
$hasXml = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$hasXml) return '';
|
||||||
|
|
||||||
|
$tmpDir = sys_get_temp_dir() . '/erechnung_' . uniqid();
|
||||||
|
$safeTmp = escapeshellarg($tmpDir);
|
||||||
|
mkdir($tmpDir, 0700, true);
|
||||||
|
|
||||||
|
exec("pdfdetach -saveall -o {$safeTmp} {$safe} 2>/dev/null");
|
||||||
|
|
||||||
|
$xmlContent = '';
|
||||||
|
$candidates = [
|
||||||
|
"$tmpDir/factur-x.xml",
|
||||||
|
"$tmpDir/ZUGFeRD-invoice.xml",
|
||||||
|
"$tmpDir/zugferd-invoice.xml",
|
||||||
|
"$tmpDir/xrechnung.xml",
|
||||||
|
];
|
||||||
|
// Bekannte Namen zuerst, dann alle .xml-Dateien im tmpDir
|
||||||
|
foreach (array_merge($candidates, glob("$tmpDir/*.xml") ?: [], glob("$tmpDir/*.XML") ?: []) as $f) {
|
||||||
|
if (file_exists($f)) {
|
||||||
|
$xmlContent = file_get_contents($f);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
foreach (glob("$tmpDir/*") ?: [] as $f) {
|
||||||
|
@unlink($f);
|
||||||
|
}
|
||||||
|
@rmdir($tmpDir);
|
||||||
|
|
||||||
|
return mb_substr($xmlContent, 0, 12000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konvertiert PDF-Seiten zu Bildern (pdftoppm) und schickt sie ans Vision-Modell.
|
||||||
|
* Fallback auf pdftotext wenn Vision nicht verfügbar oder fehlschlägt.
|
||||||
|
*/
|
||||||
|
private function extractPdfVision(string $path): string
|
||||||
|
{
|
||||||
|
$tmpDir = sys_get_temp_dir() . '/vision_' . uniqid();
|
||||||
|
$safeTmp = escapeshellarg($tmpDir . '/page');
|
||||||
|
$safe = escapeshellarg($path);
|
||||||
|
mkdir($tmpDir, 0700, true);
|
||||||
|
|
||||||
|
// pdftoppm ist Teil von poppler-utils; auf 800px Breite begrenzen (Ollama-Limit)
|
||||||
|
exec("pdftoppm -jpeg -scale-to-x 800 -scale-to-y -1 {$safe} {$tmpDir}/page 2>/dev/null", $out, $rc);
|
||||||
|
|
||||||
|
$images = glob("$tmpDir/page*.jpg") ?: glob("$tmpDir/page*.jpeg") ?: [];
|
||||||
|
sort($images);
|
||||||
|
|
||||||
|
if ($rc !== 0 || empty($images)) {
|
||||||
|
@rmdir($tmpDir);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$base64 = [];
|
||||||
|
foreach ($images as $img) {
|
||||||
|
$base64[] = base64_encode(file_get_contents($img));
|
||||||
|
@unlink($img);
|
||||||
|
}
|
||||||
|
@rmdir($tmpDir);
|
||||||
|
|
||||||
|
$prompt = 'Extrahiere den vollstaendigen Text dieses Belegs. '
|
||||||
|
. 'Behalte die Tabellenstruktur bei (Spalten mit Leerzeichen ausrichten). '
|
||||||
|
. 'Antworte nur mit dem extrahierten Text, ohne Kommentar oder Erklaerung.';
|
||||||
|
|
||||||
|
return $this->llm->generateVision($prompt, $base64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt ein Bild (JPEG/PNG/TIFF) ans Vision-Modell.
|
||||||
|
* Skaliert auf max. 800px Breite damit der Payload nicht zu groß wird.
|
||||||
|
*/
|
||||||
|
private function extractImageVision(string $path): string
|
||||||
|
{
|
||||||
|
$base64 = $this->resizeImageBase64($path, 800);
|
||||||
|
if ($base64 === '') return '';
|
||||||
|
|
||||||
|
$prompt = 'Extrahiere den vollstaendigen Text dieses Belegs. '
|
||||||
|
. 'Behalte die Tabellenstruktur bei. '
|
||||||
|
. 'Antworte nur mit dem extrahierten Text, ohne Kommentar oder Erklaerung.';
|
||||||
|
|
||||||
|
return $this->llm->generateVision($prompt, [$base64]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest ein Bild, skaliert es auf maxWidth Pixel Breite und gibt es als base64-JPEG zurück.
|
||||||
|
*/
|
||||||
|
private function resizeImageBase64(string $path, int $maxWidth): string
|
||||||
|
{
|
||||||
|
// pdftoppm erzeugt JPEG; ImageCropService verwendet GD — GD ist im Container verfügbar
|
||||||
|
$info = @getimagesize($path);
|
||||||
|
if (!$info) return '';
|
||||||
|
|
||||||
|
[$origW, $origH, $type] = $info;
|
||||||
|
|
||||||
|
$src = match ($type) {
|
||||||
|
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
|
||||||
|
IMAGETYPE_PNG => @imagecreatefrompng($path),
|
||||||
|
IMAGETYPE_TIFF_II, IMAGETYPE_TIFF_MM => null, // GD unterstützt kein TIFF
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!$src) {
|
||||||
|
// Kein GD-Support (z. B. TIFF) → direkt senden, auch wenn groß
|
||||||
|
return base64_encode(file_get_contents($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($origW <= $maxWidth) {
|
||||||
|
// Bereits klein genug → direkt als JPEG-Base64
|
||||||
|
ob_start();
|
||||||
|
imagejpeg($src, null, 85);
|
||||||
|
$jpeg = ob_get_clean();
|
||||||
|
imagedestroy($src);
|
||||||
|
return base64_encode($jpeg);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scale = $maxWidth / $origW;
|
||||||
|
$newW = $maxWidth;
|
||||||
|
$newH = (int)round($origH * $scale);
|
||||||
|
$dst = imagecreatetruecolor($newW, $newH);
|
||||||
|
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
|
||||||
|
imagedestroy($src);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
imagejpeg($dst, null, 85);
|
||||||
|
$jpeg = ob_get_clean();
|
||||||
|
imagedestroy($dst);
|
||||||
|
|
||||||
|
return base64_encode($jpeg);
|
||||||
|
}
|
||||||
|
|
||||||
private function extractPdfText(string $path): string
|
private function extractPdfText(string $path): string
|
||||||
{
|
{
|
||||||
$safe = escapeshellarg($path);
|
$safe = escapeshellarg($path);
|
||||||
$output = [];
|
$output = [];
|
||||||
exec("pdftotext -l 1 {$safe} -", $output, $returnCode);
|
exec("pdftotext {$safe} -", $output, $returnCode);
|
||||||
return $returnCode === 0 ? implode("\n", $output) : '';
|
return $returnCode === 0 ? implode("\n", $output) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function extractImageText(string $path): string
|
// -------------------------------------------------------------------------
|
||||||
{
|
// JSON-Parsing & Validierung
|
||||||
$tmpBase = sys_get_temp_dir() . '/ocr_' . uniqid();
|
// -------------------------------------------------------------------------
|
||||||
$safe = escapeshellarg($path);
|
|
||||||
$safeTmp = escapeshellarg($tmpBase);
|
|
||||||
exec("tesseract {$safe} {$safeTmp} -l deu+eng quiet 2>/dev/null", $out, $rc);
|
|
||||||
$txtFile = $tmpBase . '.txt';
|
|
||||||
if ($rc === 0 && file_exists($txtFile)) {
|
|
||||||
$text = file_get_contents($txtFile);
|
|
||||||
unlink($txtFile);
|
|
||||||
return $text ?: '';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst die LLM-Antwort als JSON-Array von Buchungszeilen.
|
||||||
|
* Akzeptiert auch ein einzelnes JSON-Objekt (Backward-Compat) und wickelt es in ein Array.
|
||||||
|
* Gibt validiertes Array zurück, oder null bei Parse-/Validierungsfehler.
|
||||||
|
*/
|
||||||
private function parseAndValidate(string $response, array $accounts): ?array
|
private function parseAndValidate(string $response, array $accounts): ?array
|
||||||
{
|
{
|
||||||
// JSON aus Antwort extrahieren (Modell könnte Prosa voranstellen)
|
// Array-Form: [{ ... }, { ... }]
|
||||||
if (!preg_match('/\{[^{}]*\}/s', $response, $m)) return null;
|
if (preg_match('/\[.*\]/s', $response, $m)) {
|
||||||
|
|
||||||
$data = json_decode($m[0], true);
|
$data = json_decode($m[0], true);
|
||||||
if (!is_array($data)) return null;
|
if (is_array($data) && isset($data[0]) && is_array($data[0])) {
|
||||||
|
return $this->validateLines($data, $accounts);
|
||||||
$required = ['buchungstyp', 'kategorie_konto_id', 'zahlungskonto_id', 'ust_satz', 'betrag_netto'];
|
}
|
||||||
foreach ($required as $field) {
|
|
||||||
if (!isset($data[$field])) return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Werte normalisieren
|
// Fallback: einzelnes Objekt { ... } → in Array einwickeln
|
||||||
|
if (preg_match('/\{[^{}]*\}/s', $response, $m)) {
|
||||||
|
$data = json_decode($m[0], true);
|
||||||
|
if (is_array($data)) {
|
||||||
|
return $this->validateLines([$data], $accounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert und normalisiert ein Array von Buchungszeilen.
|
||||||
|
* Gibt nur gültige Zeilen zurück; null wenn keine einzige valide ist.
|
||||||
|
*/
|
||||||
|
private function validateLines(array $lines, array $accounts): ?array
|
||||||
|
{
|
||||||
|
$kategorieIds = array_column(array_filter($accounts,
|
||||||
|
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
|
||||||
|
), 'id');
|
||||||
|
$zahlungsIds = array_column(array_filter($accounts,
|
||||||
|
fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true)
|
||||||
|
), 'id');
|
||||||
|
|
||||||
|
$valid = [];
|
||||||
|
foreach ($lines as $data) {
|
||||||
|
if (!is_array($data)) continue;
|
||||||
|
|
||||||
|
$required = ['buchungstyp', 'kategorie_konto_id', 'zahlungskonto_id', 'ust_satz', 'betrag_netto'];
|
||||||
|
$ok = true;
|
||||||
|
foreach ($required as $field) {
|
||||||
|
if (!isset($data[$field])) { $ok = false; break; }
|
||||||
|
}
|
||||||
|
if (!$ok) continue;
|
||||||
|
|
||||||
|
// Normalisieren
|
||||||
$data['buchungstyp'] = in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) ? $data['buchungstyp'] : 'Ausgabe';
|
$data['buchungstyp'] = in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) ? $data['buchungstyp'] : 'Ausgabe';
|
||||||
$data['kategorie_konto_id'] = (int)$data['kategorie_konto_id'];
|
$data['kategorie_konto_id'] = (int)$data['kategorie_konto_id'];
|
||||||
$data['zahlungskonto_id'] = (int)$data['zahlungskonto_id'];
|
$data['zahlungskonto_id'] = (int)$data['zahlungskonto_id'];
|
||||||
|
|
@ -162,15 +354,9 @@ PROMPT;
|
||||||
$data['buchungstext'] = (string)($data['buchungstext'] ?? '');
|
$data['buchungstext'] = (string)($data['buchungstext'] ?? '');
|
||||||
$data['supplier_name'] = trim((string)($data['supplier_name'] ?? ''));
|
$data['supplier_name'] = trim((string)($data['supplier_name'] ?? ''));
|
||||||
|
|
||||||
// IDs gegen Kontenliste validieren (mit Kontenartenprüfung)
|
// Konto-IDs validieren
|
||||||
$kategorieIds = array_column(array_filter($accounts,
|
if (!in_array($data['kategorie_konto_id'], $kategorieIds, false)) continue;
|
||||||
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
|
if (!in_array($data['zahlungskonto_id'], $zahlungsIds, false)) continue;
|
||||||
), 'id');
|
|
||||||
$zahlungsIds = array_column(array_filter($accounts,
|
|
||||||
fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true)
|
|
||||||
), 'id');
|
|
||||||
if (!in_array($data['kategorie_konto_id'], $kategorieIds, false)) return null;
|
|
||||||
if (!in_array($data['zahlungskonto_id'], $zahlungsIds, false)) return null;
|
|
||||||
|
|
||||||
// Lieferant gegen vorhandene Suppliers prüfen
|
// Lieferant gegen vorhandene Suppliers prüfen
|
||||||
if ($data['supplier_name'] !== '') {
|
if ($data['supplier_name'] !== '') {
|
||||||
|
|
@ -180,6 +366,9 @@ PROMPT;
|
||||||
$data['supplier_found'] = null;
|
$data['supplier_found'] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
$valid[] = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return empty($valid) ? null : $valid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class LlmService
|
||||||
private ?string $mode = null; // 'ollama' | 'openai' | null
|
private ?string $mode = null; // 'ollama' | 'openai' | null
|
||||||
private string $url = '';
|
private string $url = '';
|
||||||
private string $model = '';
|
private string $model = '';
|
||||||
|
private string $visionModel = '';
|
||||||
private ?string $apiKey = null;
|
private ?string $apiKey = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
|
|
@ -30,6 +31,11 @@ class LlmService
|
||||||
return $this->mode !== null;
|
return $this->mode !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isVisionAvailable(): bool
|
||||||
|
{
|
||||||
|
return $this->mode === 'ollama';
|
||||||
|
}
|
||||||
|
|
||||||
public function getProviderLabel(): ?string
|
public function getProviderLabel(): ?string
|
||||||
{
|
{
|
||||||
return match($this->mode) {
|
return match($this->mode) {
|
||||||
|
|
@ -48,6 +54,45 @@ class LlmService
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet Bilder (base64-kodiert) zusammen mit einem Prompt an das Vision-Modell.
|
||||||
|
* Nur im Ollama-Modus verfügbar. Gibt '' zurück wenn Vision nicht verfügbar.
|
||||||
|
*
|
||||||
|
* @param string $prompt
|
||||||
|
* @param string[] $base64Images Array von base64-kodierten Bildern (JPEG/PNG)
|
||||||
|
*/
|
||||||
|
public function generateVision(string $prompt, array $base64Images): string
|
||||||
|
{
|
||||||
|
if ($this->mode !== 'ollama' || empty($base64Images)) return '';
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'model' => $this->visionModel,
|
||||||
|
'messages' => [[
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => $prompt,
|
||||||
|
'images' => $base64Images,
|
||||||
|
]],
|
||||||
|
'stream' => false,
|
||||||
|
'options' => ['temperature' => 0.1],
|
||||||
|
];
|
||||||
|
|
||||||
|
$ch = curl_init($this->url . '/api/chat');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 300,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||||
|
]);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($err || !$response) return '';
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
return $data['message']['content'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
private function detect(): void
|
private function detect(): void
|
||||||
{
|
{
|
||||||
$ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? '');
|
$ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? '');
|
||||||
|
|
@ -58,6 +103,7 @@ class LlmService
|
||||||
$this->mode = 'ollama';
|
$this->mode = 'ollama';
|
||||||
$this->url = rtrim($ollamaUrl, '/');
|
$this->url = rtrim($ollamaUrl, '/');
|
||||||
$this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2');
|
$this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2');
|
||||||
|
$this->visionModel = getenv('VISION_MODEL') ?: ($_ENV['VISION_MODEL'] ?? 'qwen2.5vl:latest');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class NextcloudImportService
|
||||||
*/
|
*/
|
||||||
public function import(): array
|
public function import(): array
|
||||||
{
|
{
|
||||||
$result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => []];
|
$result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [], 'imported_ids' => []];
|
||||||
|
|
||||||
if (!$this->baseUrl || !$this->user || !$this->pass) {
|
if (!$this->baseUrl || !$this->user || !$this->pass) {
|
||||||
$result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.';
|
$result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.';
|
||||||
|
|
@ -262,7 +262,7 @@ class NextcloudImportService
|
||||||
|
|
||||||
// In Datenbank anlegen
|
// In Datenbank anlegen
|
||||||
$today = date('Y-m-d');
|
$today = date('Y-m-d');
|
||||||
Beleg::create([
|
$belegId = Beleg::create([
|
||||||
'beleg_nr' => $belegNr,
|
'beleg_nr' => $belegNr,
|
||||||
'filename' => $filename,
|
'filename' => $filename,
|
||||||
'storage_path' => $storagePath,
|
'storage_path' => $storagePath,
|
||||||
|
|
@ -280,6 +280,7 @@ class NextcloudImportService
|
||||||
|
|
||||||
$result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}";
|
$result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}";
|
||||||
$result['imported']++;
|
$result['imported']++;
|
||||||
|
$result['imported_ids'][] = $belegId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ declare(strict_types=1);
|
||||||
namespace Steuerkram\Services;
|
namespace Steuerkram\Services;
|
||||||
|
|
||||||
use Mpdf\Mpdf;
|
use Mpdf\Mpdf;
|
||||||
use Mpdf\Config\ConfigVariables;
|
|
||||||
use Mpdf\Config\FontVariables;
|
|
||||||
|
|
||||||
class PdfInvoiceService
|
class PdfInvoiceService
|
||||||
{
|
{
|
||||||
|
|
@ -19,8 +17,8 @@ class PdfInvoiceService
|
||||||
'format' => 'A4',
|
'format' => 'A4',
|
||||||
'margin_top' => 15,
|
'margin_top' => 15,
|
||||||
'margin_bottom' => 25,
|
'margin_bottom' => 25,
|
||||||
'margin_left' => 20,
|
'margin_left' => 25, // DIN 5008: 25mm links
|
||||||
'margin_right' => 20,
|
'margin_right' => 15, // DIN 5008: 15mm rechts
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$mpdf->SetTitle('Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'));
|
$mpdf->SetTitle('Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'));
|
||||||
|
|
@ -31,28 +29,45 @@ class PdfInvoiceService
|
||||||
$mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY);
|
$mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY);
|
||||||
|
|
||||||
$filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
|
$filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
|
||||||
$mpdf->Output($filename, 'D'); // D = Download
|
$mpdf->Output($filename, 'D');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildHtml(array $inv, array $lines, array $settings): string
|
private function buildHtml(array $inv, array $lines, array $settings): string
|
||||||
{
|
{
|
||||||
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
|
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
|
||||||
$fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
|
$fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
|
||||||
$fmtNum = fn($v) => number_format((float)$v, 3, ',', '.');
|
$fmtNum = fn($v) => number_format((float)$v, 1, ',', '.');
|
||||||
$fmtDate = fn($v) => $v ? date('d.m.Y', strtotime($v)) : '–';
|
$fmtDate = fn($v) => $v ? date('d.m.Y', strtotime($v)) : '–';
|
||||||
|
|
||||||
// Customer address
|
// Kundenname
|
||||||
$customerName = !empty($inv['firma']) ? $inv['firma'] : trim(($inv['anrede'] ?? '') . ' ' . ($inv['vorname'] ?? '') . ' ' . $inv['nachname']);
|
$customerName = !empty($inv['firma'])
|
||||||
|
? $inv['firma']
|
||||||
|
: trim(($inv['anrede'] ?? '') . ' ' . ($inv['vorname'] ?? '') . ' ' . $inv['nachname']);
|
||||||
|
|
||||||
// Logo
|
// Logo
|
||||||
$logoHtml = '';
|
$logoHtml = '';
|
||||||
if (!empty($settings['logo_path'])) {
|
if (!empty($settings['logo_path'])) {
|
||||||
$logoPath = dirname(__DIR__, 2) . '/public/' . $settings['logo_path'];
|
$logoPath = dirname(__DIR__, 2) . '/public/' . $settings['logo_path'];
|
||||||
if (file_exists($logoPath)) {
|
if (file_exists($logoPath)) {
|
||||||
$logoHtml = '<img src="' . $h($settings['logo_path']) . '" alt="Logo" class="logo">';
|
$maxW = max(50, (int)($settings['logo_max_width'] ?? 600));
|
||||||
|
$maxH = max(20, (int)($settings['logo_max_height'] ?? 100));
|
||||||
|
$logoHtml = '<img src="' . $h($settings['logo_path']) . '" alt="Logo"'
|
||||||
|
. ' style="max-width:' . $maxW . 'px;max-height:' . $maxH . 'px">';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$logoPosition = $settings['logo_position'] ?? 'center';
|
||||||
|
$logoMarginSide = max(0, (int)($settings['logo_margin_side'] ?? 75));
|
||||||
|
$logoMarginTop = max(0, (int)($settings['logo_margin_top'] ?? 6));
|
||||||
|
|
||||||
|
if ($logoPosition === 'left') {
|
||||||
|
$logoWrapStyle = "text-align:left;margin-left:{$logoMarginSide}px;margin-top:{$logoMarginTop}px";
|
||||||
|
} elseif ($logoPosition === 'right') {
|
||||||
|
$logoWrapStyle = "text-align:right;margin-right:{$logoMarginSide}px;margin-top:{$logoMarginTop}px";
|
||||||
|
} else {
|
||||||
|
$logoWrapStyle = 'text-align:center';
|
||||||
|
}
|
||||||
|
|
||||||
// Leistungszeitraum
|
// Leistungszeitraum
|
||||||
$lz = '';
|
$lz = '';
|
||||||
if (!empty($inv['leistungszeitraum_von'])) {
|
if (!empty($inv['leistungszeitraum_von'])) {
|
||||||
|
|
@ -62,7 +77,12 @@ class PdfInvoiceService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build positions table
|
// Fälligkeitsdatum
|
||||||
|
$fälligText = !empty($inv['faellig_datum'])
|
||||||
|
? ' Fällig am: <strong>' . $fmtDate($inv['faellig_datum']) . '</strong>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Positionszeilen
|
||||||
$posRows = '';
|
$posRows = '';
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$posRows .= '<tr>
|
$posRows .= '<tr>
|
||||||
|
|
@ -78,26 +98,31 @@ class PdfInvoiceService
|
||||||
|
|
||||||
$payDays = $inv['payment_days'] ?? 14;
|
$payDays = $inv['payment_days'] ?? 14;
|
||||||
|
|
||||||
|
// Empfänger-Land nur wenn nicht DE
|
||||||
|
$land = ($inv['land'] ?? 'DE') !== 'DE' ? '<br>' . $h($inv['land']) : '';
|
||||||
|
|
||||||
return <<<HTML
|
return <<<HTML
|
||||||
<div class="invoice-wrap">
|
<div class="invoice-wrap">
|
||||||
|
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="logo-wrap">{$logoHtml}</div>
|
<div class="logo-wrap" style="{$logoWrapStyle}">{$logoHtml}</div>
|
||||||
|
|
||||||
<!-- Adressbereich (DIN 5008) -->
|
<!-- Adressbereich DIN 5008 Typ A (Anschriftfeld 85mm × 45mm) -->
|
||||||
<div class="address-block">
|
<table class="address-block">
|
||||||
<div class="address-left">
|
<tr>
|
||||||
|
<!-- Linke Spalte: Anschriftfeld 85mm (Kuvertfenster) -->
|
||||||
|
<td class="address-left">
|
||||||
<div class="return-address">{$h($settings['firma'] ?? '')} · {$h($settings['strasse'] ?? '')} · {$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}</div>
|
<div class="return-address">{$h($settings['firma'] ?? '')} · {$h($settings['strasse'] ?? '')} · {$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}</div>
|
||||||
<div class="recipient">
|
<div class="recipient">
|
||||||
{$h($customerName)}<br>
|
{$h($customerName)}<br>
|
||||||
{$h($inv['strasse'])}<br>
|
{$h($inv['strasse'])}<br>
|
||||||
{$h($inv['plz'])} {$h($inv['ort'])}<br>
|
{$h($inv['plz'])} {$h($inv['ort'])}{$land}
|
||||||
{$h($inv['land'] !== 'DE' ? $inv['land'] : '')}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
<div class="address-right">
|
<!-- Rechte Spalte: Absenderinformationen -->
|
||||||
|
<td class="address-right">
|
||||||
<table class="sender-table">
|
<table class="sender-table">
|
||||||
<tr><td>{$h($settings['firma'] ?? '')}</td></tr>
|
<tr><td><strong>{$h($settings['firma'] ?? '')}</strong></td></tr>
|
||||||
<tr><td>{$h($settings['inhaber'] ?? '')}</td></tr>
|
<tr><td>{$h($settings['inhaber'] ?? '')}</td></tr>
|
||||||
<tr><td>{$h($settings['strasse'] ?? '')}</td></tr>
|
<tr><td>{$h($settings['strasse'] ?? '')}</td></tr>
|
||||||
<tr><td>{$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}</td></tr>
|
<tr><td>{$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}</td></tr>
|
||||||
|
|
@ -106,11 +131,13 @@ class PdfInvoiceService
|
||||||
<tr><td>{$h($settings['email'] ?? '')}</td></tr>
|
<tr><td>{$h($settings['email'] ?? '')}</td></tr>
|
||||||
<tr><td>USt-ID: {$h($settings['ust_id'] ?? '')}</td></tr>
|
<tr><td>USt-ID: {$h($settings['ust_id'] ?? '')}</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<!-- Meta-Felder -->
|
<!-- Meta-Felder -->
|
||||||
<table class="meta-table">
|
<table class="meta-table">
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Betreff</th>
|
<th>Betreff</th>
|
||||||
<th>Rechnungsnummer</th>
|
<th>Rechnungsnummer</th>
|
||||||
|
|
@ -118,13 +145,16 @@ class PdfInvoiceService
|
||||||
<th>Leistungszeitraum</th>
|
<th>Leistungszeitraum</th>
|
||||||
<th>Datum</th>
|
<th>Datum</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{$h($inv['betreff'])}</td>
|
<td>{$h($inv['betreff'])}</td>
|
||||||
<td>{$h($inv['rechnungs_nr'] ?? 'Entwurf')}</td>
|
<td><strong>{$h($inv['rechnungs_nr'] ?? 'Entwurf')}</strong></td>
|
||||||
<td>{$h($inv['kundennummer'])}</td>
|
<td>{$h($inv['kundennummer'])}</td>
|
||||||
<td>{$h($lz)}</td>
|
<td>{$h($lz ?: '–')}</td>
|
||||||
<td>{$fmtDate($inv['rechnungs_datum'])}</td>
|
<td>{$fmtDate($inv['rechnungs_datum'])}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Positionen -->
|
<!-- Positionen -->
|
||||||
|
|
@ -145,16 +175,19 @@ class PdfInvoiceService
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Summen -->
|
<!-- Summen (rechtsbündig) -->
|
||||||
<table class="sum-table">
|
<table class="sum-table" align="right">
|
||||||
<tr><td>Nettobetrag:</td> <td class="sum-val">{$fmt($inv['betrag_netto'])}</td></tr>
|
<tr><td>Nettobetrag:</td> <td class="sum-val">{$fmt($inv['betrag_netto'])}</td></tr>
|
||||||
<tr><td>zzgl. Umsatzsteuer:</td><td class="sum-val">{$fmt($inv['betrag_ust'])}</td></tr>
|
<tr><td>zzgl. Umsatzsteuer:</td><td class="sum-val">{$fmt($inv['betrag_ust'])}</td></tr>
|
||||||
<tr class="sum-total"><td><strong>Rechnungsbetrag:</strong></td><td class="sum-val"><strong>{$fmt($inv['betrag_brutto'])}</strong></td></tr>
|
<tr class="sum-total">
|
||||||
|
<td><strong>Rechnungsbetrag:</strong></td>
|
||||||
|
<td class="sum-val"><strong>{$fmt($inv['betrag_brutto'])}</strong></td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Zahlungshinweis -->
|
<!-- Zahlungshinweis -->
|
||||||
<p class="payment-note">
|
<p class="payment-note">
|
||||||
Bitte überweisen Sie den Rechnungsbetrag innerhalb {$h($payDays)} Tagen auf das unten genannte Konto.
|
Bitte überweisen Sie den Rechnungsbetrag innerhalb {$h($payDays)} Tagen auf das unten genannte Konto.{$fälligText}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -197,48 +230,60 @@ HTML;
|
||||||
return <<<CSS
|
return <<<CSS
|
||||||
body { font-family: DejaVu Sans, sans-serif; font-size: 10pt; color: #000; }
|
body { font-family: DejaVu Sans, sans-serif; font-size: 10pt; color: #000; }
|
||||||
.invoice-wrap { width: 100%; }
|
.invoice-wrap { width: 100%; }
|
||||||
.logo-wrap { text-align: center; margin-bottom: 10mm; }
|
|
||||||
.logo { max-height: 25mm; max-width: 80mm; }
|
/* Logo */
|
||||||
|
.logo-wrap { margin-bottom: 8mm; }
|
||||||
|
|
||||||
/* DIN 5008 Adressbereich */
|
/* DIN 5008 Adressbereich */
|
||||||
.address-block { display: flex; justify-content: space-between; margin-bottom: 10mm; }
|
.address-block { width: 100%; border-collapse: collapse; margin-bottom: 8mm; }
|
||||||
.address-left { width: 85mm; }
|
.address-left { width: 85mm; vertical-align: top; padding: 0; }
|
||||||
.return-address { font-size: 7pt; color: #555; border-bottom: 0.3pt solid #aaa; padding-bottom: 1mm; margin-bottom: 2mm; }
|
.address-right { vertical-align: top; padding: 0 0 0 5mm; }
|
||||||
.recipient { font-size: 10pt; line-height: 1.4; min-height: 27.3mm; }
|
|
||||||
.address-right { width: 75mm; text-align: left; }
|
.return-address {
|
||||||
.sender-table td { font-size: 9pt; padding: 0.5mm 0; }
|
font-size: 7pt; color: #555;
|
||||||
|
border-bottom: 0.3pt solid #aaa;
|
||||||
|
padding-bottom: 1mm; margin-bottom: 2mm;
|
||||||
|
}
|
||||||
|
.recipient { font-size: 10pt; line-height: 1.5; min-height: 27.3mm; }
|
||||||
|
|
||||||
|
/* Absendertabelle: auf Inhaltsbreite schrumpfen, rechtsbündig */
|
||||||
|
.sender-table { border-collapse: collapse; font-size: 9pt; margin-left: auto; }
|
||||||
|
.sender-table td { padding: 0.4mm 0; white-space: nowrap; }
|
||||||
|
|
||||||
/* Meta-Felder */
|
/* Meta-Felder */
|
||||||
.meta-table { width: 100%; border-collapse: collapse; margin-bottom: 8mm; font-size: 9pt; }
|
.meta-table { width: 100%; table-layout: fixed; border-collapse: collapse; margin-bottom: 8mm; font-size: 9pt; }
|
||||||
.meta-table th { background: #f0f0f0; padding: 2mm 2mm; text-align: left; font-size: 8pt; color: #555; border: 0.5pt solid #ccc; }
|
.meta-table th { background: #f0f0f0; padding: 2mm; text-align: left; font-size: 8pt; color: #555; border: 0.5pt solid #ccc; }
|
||||||
.meta-table td { padding: 2mm; border: 0.5pt solid #ccc; }
|
.meta-table td { padding: 2mm; border: 0.5pt solid #ccc; }
|
||||||
|
|
||||||
/* Positionen */
|
/* Positionen */
|
||||||
.pos-table { width: 100%; border-collapse: collapse; margin-bottom: 5mm; font-size: 9pt; }
|
.pos-table { width: 100%; table-layout: fixed; border-collapse: collapse; margin-bottom: 5mm; font-size: 9pt; }
|
||||||
.pos-table thead th { background: #1e293b; color: #fff; padding: 2mm; text-align: left; }
|
.pos-table thead th { background: #f0f0f0; color: #000; padding: 2mm; }
|
||||||
.pos-table tbody tr:nth-child(even) { background: #f8f8f8; }
|
.pos-table tbody tr:nth-child(even) { background: #f8f8f8; }
|
||||||
.pos-table tbody td { padding: 1.5mm 2mm; border-bottom: 0.3pt solid #e0e0e0; }
|
.pos-table tbody td { padding: 1.5mm 2mm; border-bottom: 0.3pt solid #e0e0e0; }
|
||||||
.pos-nr { width: 10mm; }
|
.pos-nr { width: 8%; }
|
||||||
.pos-bez { width: 55mm; }
|
.pos-bez { /* nimmt den Rest */ }
|
||||||
.pos-menge { width: 18mm; text-align: right; }
|
.pos-menge { width: 11%; text-align: right; }
|
||||||
.pos-einheit { width: 15mm; }
|
.pos-einheit { width: 10%; }
|
||||||
.pos-ust { width: 12mm; text-align: right; }
|
.pos-ust { width: 8%; text-align: right; }
|
||||||
.pos-preis { width: 20mm; text-align: right; }
|
.pos-preis { width: 15%; text-align: right; }
|
||||||
.pos-gesamt { width: 22mm; text-align: right; }
|
.pos-gesamt { width: 16%; text-align: right; }
|
||||||
|
|
||||||
/* Summen */
|
/* Summen */
|
||||||
.sum-table { float: right; width: 70mm; border-collapse: collapse; margin-bottom: 8mm; font-size: 9.5pt; }
|
.sum-table { border-collapse: collapse; margin-bottom: 8mm; font-size: 9pt; }
|
||||||
.sum-table td { padding: 1.5mm 2mm; }
|
.sum-table td { padding: 1.5mm 2mm; }
|
||||||
.sum-table .sum-val { text-align: right; border-top: 0.3pt solid #ccc; }
|
.sum-table .sum-val { text-align: right; border-top: 0.3pt solid #ccc; }
|
||||||
.sum-table .sum-total td { border-top: 1pt solid #000; font-size: 10.5pt; }
|
.sum-table .sum-total td { border-top: 1pt solid #000; }
|
||||||
|
|
||||||
/* Zahlungshinweis */
|
/* Zahlungshinweis */
|
||||||
.payment-note { clear: both; margin-top: 5mm; font-size: 9pt; padding: 3mm; background: #f8f8f8; border-left: 2pt solid #2563eb; }
|
.payment-note { margin-top: 5mm; font-size: 9pt; padding: 3mm; background: #f8f8f8; border-left: 2pt solid #2563eb; }
|
||||||
|
|
||||||
/* Fußzeile */
|
/* Fußzeile */
|
||||||
.footer-table { width: 100%; border-collapse: collapse; font-size: 7.5pt; border-top: 0.5pt solid #ccc; padding-top: 2mm; }
|
.footer-table { width: 100%; border-collapse: collapse; font-size: 7.5pt; border-top: 0.5pt solid #ccc; }
|
||||||
.footer-table td { width: 25%; padding: 1mm 2mm; vertical-align: top; }
|
.footer-table td { padding: 1mm 2mm; vertical-align: top; }
|
||||||
.page-nr { text-align: right; }
|
.footer-table td:nth-child(1) { width: 35%; }
|
||||||
|
.footer-table td:nth-child(2) { width: 25%; }
|
||||||
|
.footer-table td:nth-child(3) { width: 28%; }
|
||||||
|
.page-nr { width: 12%; text-align: right; }
|
||||||
CSS;
|
CSS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,7 @@ $suppliers = $suppliers ?? [];
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KI-Kontierungsvorschlag -->
|
<!-- KI-Kontierungsvorschlag -->
|
||||||
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?>
|
<?php if (!$beleg['locked'] && ($kiSektion ?? false)): ?>
|
||||||
<div id="ki-badge" style="margin-bottom:1rem"></div>
|
<div id="ki-badge" style="margin-bottom:1rem"></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|
@ -821,9 +821,10 @@ function updateNettoAnzeige() {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?>
|
<?php if (!$beleg['locked'] && ($kiSektion ?? false)): ?>
|
||||||
<script>
|
<script>
|
||||||
const _belegId = <?= (int)$beleg['id'] ?>;
|
const _belegId = <?= (int)$beleg['id'] ?>;
|
||||||
|
const _kiAktiv = <?= json_encode((bool)($kiAktiv ?? false)) ?>;
|
||||||
const _kiVorschlag = <?= json_encode($kiVorschlag ?? null) ?>;
|
const _kiVorschlag = <?= json_encode($kiVorschlag ?? null) ?>;
|
||||||
|
|
||||||
function _selectById(selectEl, id) {
|
function _selectById(selectEl, id) {
|
||||||
|
|
@ -871,7 +872,9 @@ function _applyKiVorschlag(v) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _showKiBadgeDone(v) {
|
function _showKiBadgeDone(arr) {
|
||||||
|
// arr ist immer ein Array (bereits normalisiert)
|
||||||
|
const v = arr[0];
|
||||||
const katEl = document.getElementById('kategorie_konto_id');
|
const katEl = document.getElementById('kategorie_konto_id');
|
||||||
const zhlEl = document.getElementById('zahlungskonto_id');
|
const zhlEl = document.getElementById('zahlungskonto_id');
|
||||||
const katOpt = katEl ? katEl.querySelector('option[value="' + v.kategorie_konto_id + '"]') : null;
|
const katOpt = katEl ? katEl.querySelector('option[value="' + v.kategorie_konto_id + '"]') : null;
|
||||||
|
|
@ -881,18 +884,44 @@ function _showKiBadgeDone(v) {
|
||||||
const supTxt = v.supplier_name
|
const supTxt = v.supplier_name
|
||||||
? ' · Lieferant: ' + v.supplier_name + (v.supplier_found === false ? ' <span style="color:var(--color-warning);font-weight:600">(neu – bitte prüfen)</span>' : '')
|
? ' · Lieferant: ' + v.supplier_name + (v.supplier_found === false ? ' <span style="color:var(--color-warning);font-weight:600">(neu – bitte prüfen)</span>' : '')
|
||||||
: '';
|
: '';
|
||||||
const details =
|
|
||||||
|
// Erste Zeile (bereits ins Formular übertragen)
|
||||||
|
let details =
|
||||||
(v.buchungstyp ? v.buchungstyp + ' · ' : '') +
|
(v.buchungstyp ? v.buchungstyp + ' · ' : '') +
|
||||||
'USt ' + v.ust_satz + ' % · ' +
|
|
||||||
'Kategorie: ' + katTxt + ' · ' +
|
'Kategorie: ' + katTxt + ' · ' +
|
||||||
'Zahlung: ' + zhlTxt + supTxt;
|
'Zahlung: ' + zhlTxt + supTxt;
|
||||||
|
|
||||||
|
// Alle Buchungszeilen mit Beträgen anzeigen
|
||||||
|
const fmt = n => parseFloat(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||||
|
let zeilen = '';
|
||||||
|
arr.forEach((z, i) => {
|
||||||
|
const netto = parseFloat(z.betrag_netto) || 0;
|
||||||
|
const ust = Math.round(netto * z.ust_satz / 100 * 100) / 100;
|
||||||
|
const brutto = Math.round((netto + ust) * 100) / 100;
|
||||||
|
const hinweis = (i > 0) ? ' <span style="color:var(--color-muted);font-style:italic">(bitte als weitere Buchung erfassen)</span>' : '';
|
||||||
|
zeilen += '<br><span style="font-size:.85em">Zeile ' + (i + 1) + ': '
|
||||||
|
+ 'Netto ' + fmt(netto) + ' € | USt ' + z.ust_satz + '% ' + fmt(ust) + ' € | Brutto ' + fmt(brutto) + ' €'
|
||||||
|
+ hinweis + '</span>';
|
||||||
|
});
|
||||||
|
|
||||||
|
const neuBtn = _kiAktiv
|
||||||
|
? '<button class="btn btn-secondary btn-sm" style="flex-shrink:0;margin-top:.1rem" onclick="_startKiAnalyse()">Neu generieren</button>'
|
||||||
|
: '';
|
||||||
document.getElementById('ki-badge').innerHTML =
|
document.getElementById('ki-badge').innerHTML =
|
||||||
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:center;gap:1rem">' +
|
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem">' +
|
||||||
'<div><strong>KI-Vorschlag:</strong> <span style="font-size:.9em">' + details + '</span></div>' +
|
'<div><strong>KI-Vorschlag:</strong> <span style="font-size:.9em">' + details + '</span>' + zeilen + '</div>' +
|
||||||
'<button class="btn btn-secondary btn-sm" style="flex-shrink:0" onclick="_startKiAnalyse()">Neu generieren</button>' +
|
neuBtn +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _showKiBadgeNoResult() {
|
||||||
|
const neuBtn = _kiAktiv
|
||||||
|
? ' <button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button>'
|
||||||
|
: '';
|
||||||
|
document.getElementById('ki-badge').innerHTML =
|
||||||
|
'<div class="flash flash-warning">Kein Kontierungsvorschlag generiert.' + neuBtn + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
function _showKiBadgeLoading() {
|
function _showKiBadgeLoading() {
|
||||||
document.getElementById('ki-badge').innerHTML =
|
document.getElementById('ki-badge').innerHTML =
|
||||||
'<div class="flash flash-info" style="opacity:.65">Analysiere Beleg … bitte warten.</div>';
|
'<div class="flash flash-info" style="opacity:.65">Analysiere Beleg … bitte warten.</div>';
|
||||||
|
|
@ -900,36 +929,47 @@ function _showKiBadgeLoading() {
|
||||||
|
|
||||||
function _showKiBadgeError() {
|
function _showKiBadgeError() {
|
||||||
document.getElementById('ki-badge').innerHTML =
|
document.getElementById('ki-badge').innerHTML =
|
||||||
'<div class="flash flash-warning">Kein Kontierungsvorschlag generiert. ' +
|
'<div class="flash flash-warning">Fehler bei der KI-Analyse. ' +
|
||||||
'<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button></div>';
|
'<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalisiert _kiVorschlag zu einem Array (Objekt oder Array aus DB möglich).
|
||||||
|
// Gibt null zurück wenn kein Ergebnis vorhanden.
|
||||||
|
// Gibt leeres Array [] zurück wenn verarbeitet aber kein Ergebnis erzeugt.
|
||||||
|
function _normalizeKiArr(raw) {
|
||||||
|
if (raw === null || raw === undefined) return null;
|
||||||
|
const arr = Array.isArray(raw) ? raw : [raw];
|
||||||
|
// Leeres Array = "verarbeitet, kein Ergebnis"
|
||||||
|
if (arr.length === 0) return [];
|
||||||
|
// Mindestens erstes Element muss gültig sein
|
||||||
|
return (arr[0] && arr[0].kategorie_konto_id) ? arr : [];
|
||||||
|
}
|
||||||
|
|
||||||
async function _startKiAnalyse() {
|
async function _startKiAnalyse() {
|
||||||
await fetch('/belege/' + _belegId + '/vorschlag/reset', {method: 'POST'});
|
await fetch('/belege/' + _belegId + '/vorschlag/reset', {method: 'POST'});
|
||||||
_showKiBadgeLoading();
|
_showKiBadgeLoading();
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'});
|
const r = await fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'});
|
||||||
const v = await r.json();
|
const raw = await r.json();
|
||||||
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); }
|
const arr = _normalizeKiArr(raw);
|
||||||
else _showKiBadgeError();
|
if (arr && arr.length > 0) { _applyKiVorschlag(arr[0]); _showKiBadgeDone(arr); }
|
||||||
|
else _showKiBadgeNoResult();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_showKiBadgeError();
|
_showKiBadgeError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
if (_kiVorschlag && _kiVorschlag.kategorie_konto_id) {
|
const arr = _normalizeKiArr(_kiVorschlag);
|
||||||
_applyKiVorschlag(_kiVorschlag);
|
if (arr === null) {
|
||||||
_showKiBadgeDone(_kiVorschlag);
|
// Noch nicht verarbeitet – nichts anzeigen
|
||||||
|
} else if (arr.length === 0) {
|
||||||
|
// Verarbeitet, aber kein Ergebnis
|
||||||
|
_showKiBadgeNoResult();
|
||||||
} else {
|
} else {
|
||||||
_showKiBadgeLoading();
|
// Vorschlag vorhanden → ins Formular übernehmen und anzeigen
|
||||||
fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'})
|
_applyKiVorschlag(arr[0]);
|
||||||
.then(r => r.json())
|
_showKiBadgeDone(arr);
|
||||||
.then(v => {
|
|
||||||
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); }
|
|
||||||
else _showKiBadgeError();
|
|
||||||
})
|
|
||||||
.catch(() => _showKiBadgeError());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,15 @@ if (!empty($invoice['leistungszeitraum_von'])) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Leistungszeitraum mit <br> für die Vorschau-Tabelle (Datumsangaben untereinander)
|
||||||
|
$lzHtml = '';
|
||||||
|
if (!empty($invoice['leistungszeitraum_von'])) {
|
||||||
|
$lzHtml = $h(View::formatDate($invoice['leistungszeitraum_von']));
|
||||||
|
if (!empty($invoice['leistungszeitraum_bis']) && $invoice['leistungszeitraum_bis'] !== $invoice['leistungszeitraum_von']) {
|
||||||
|
$lzHtml .= '<br>' . $h(View::formatDate($invoice['leistungszeitraum_bis']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$badgeClass = match($invoice['status']) {
|
$badgeClass = match($invoice['status']) {
|
||||||
'Bezahlt' => 'badge-green',
|
'Bezahlt' => 'badge-green',
|
||||||
'Storniert' => 'badge-red',
|
'Storniert' => 'badge-red',
|
||||||
|
|
@ -56,13 +65,28 @@ $badgeClass = match($invoice['status']) {
|
||||||
|
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<?php if (!empty($settings['logo_path'])): ?>
|
<?php if (!empty($settings['logo_path'])): ?>
|
||||||
<div style="text-align:center;margin-bottom:1.5rem">
|
<?php
|
||||||
<img src="/<?= $h($settings['logo_path']) ?>" alt="Logo" style="max-height:60px;max-width:250px">
|
$logoMaxW = max(50, (int)($settings['logo_max_width'] ?? 600));
|
||||||
|
$logoMaxH = max(20, (int)($settings['logo_max_height'] ?? 100));
|
||||||
|
$logoPos = $settings['logo_position'] ?? 'center';
|
||||||
|
$logoMSide = max(0, (int)($settings['logo_margin_side'] ?? 75));
|
||||||
|
$logoMTop = max(0, (int)($settings['logo_margin_top'] ?? 6));
|
||||||
|
if ($logoPos === 'left') {
|
||||||
|
$logoWrapStyle = "text-align:left;margin-left:{$logoMSide}px;margin-top:{$logoMTop}px;margin-bottom:1.5rem";
|
||||||
|
} elseif ($logoPos === 'right') {
|
||||||
|
$logoWrapStyle = "text-align:right;margin-right:{$logoMSide}px;margin-top:{$logoMTop}px;margin-bottom:1.5rem";
|
||||||
|
} else {
|
||||||
|
$logoWrapStyle = 'text-align:center;margin-bottom:1.5rem';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div style="<?= $logoWrapStyle ?>">
|
||||||
|
<img src="/<?= $h($settings['logo_path']) ?>" alt="Logo"
|
||||||
|
style="max-width:<?= $logoMaxW ?>px;max-height:<?= $logoMaxH ?>px">
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Adressbereich -->
|
<!-- Adressbereich (DIN 5008: 50% = 85mm bei vollem A4-Preview) -->
|
||||||
<div style="display:grid;grid-template-columns:100mm 1fr;gap:1rem;margin-bottom:1.5rem">
|
<div class="invoice-address" style="margin-bottom:1.5rem">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:8pt;color:#666;border-bottom:1px solid #ccc;padding-bottom:2px;margin-bottom:4px">
|
<div style="font-size:8pt;color:#666;border-bottom:1px solid #ccc;padding-bottom:2px;margin-bottom:4px">
|
||||||
<?= $h($settings['firma'] ?? '') ?> · <?= $h($settings['strasse'] ?? '') ?> · <?= $h(($settings['plz'] ?? '') . ' ' . ($settings['ort'] ?? '')) ?>
|
<?= $h($settings['firma'] ?? '') ?> · <?= $h($settings['strasse'] ?? '') ?> · <?= $h(($settings['plz'] ?? '') . ' ' . ($settings['ort'] ?? '')) ?>
|
||||||
|
|
@ -74,7 +98,8 @@ $badgeClass = match($invoice['status']) {
|
||||||
<?php if ($invoice['land'] !== 'DE'): ?><br><?= $h($invoice['land']) ?><?php endif; ?>
|
<?php if ($invoice['land'] !== 'DE'): ?><br><?= $h($invoice['land']) ?><?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:left;font-size:.85rem;line-height:1.7">
|
<div style="display:flex;justify-content:flex-end;font-size:.85rem;line-height:1.7">
|
||||||
|
<div style="text-align:left">
|
||||||
<strong><?= $h($settings['firma'] ?? '') ?></strong><br>
|
<strong><?= $h($settings['firma'] ?? '') ?></strong><br>
|
||||||
<?= $h($settings['inhaber'] ?? '') ?><br>
|
<?= $h($settings['inhaber'] ?? '') ?><br>
|
||||||
<?= $h($settings['strasse'] ?? '') ?><br>
|
<?= $h($settings['strasse'] ?? '') ?><br>
|
||||||
|
|
@ -84,40 +109,41 @@ $badgeClass = match($invoice['status']) {
|
||||||
USt-ID: <?= $h($settings['ust_id'] ?? '') ?>
|
USt-ID: <?= $h($settings['ust_id'] ?? '') ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Meta-Tabelle -->
|
<!-- Meta-Tabelle -->
|
||||||
<table style="width:100%;border-collapse:collapse;margin-bottom:1.5rem;font-size:.9rem">
|
<table style="width:100%;border-collapse:collapse;margin-bottom:1.5rem;font-size:.9rem">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background:#f0f0f0">
|
<tr style="background:#f0f0f0">
|
||||||
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Betreff</th>
|
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-weight:normal;white-space:nowrap">Betreff</th>
|
||||||
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Rechnungsnummer</th>
|
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-weight:normal;white-space:nowrap">Rechnungsnummer</th>
|
||||||
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Kundennummer</th>
|
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-weight:normal;white-space:nowrap">Kundennummer</th>
|
||||||
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Leistungszeitraum</th>
|
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-weight:normal;white-space:nowrap">Leistungszeitraum</th>
|
||||||
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Datum</th>
|
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-weight:normal;white-space:nowrap;width:100%">Datum</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:.4rem .5rem;border:1px solid #ddd"><?= $h($invoice['betreff']) ?></td>
|
<td style="padding:.4rem .5rem;border:1px solid #ddd;white-space:nowrap"><?= $h($invoice['betreff']) ?></td>
|
||||||
<td style="padding:.4rem .5rem;border:1px solid #ddd;font-weight:700"><?= $h($invoice['rechnungs_nr'] ?? 'Entwurf') ?></td>
|
<td style="padding:.4rem .5rem;border:1px solid #ddd;white-space:nowrap;font-weight:700"><?= $h($invoice['rechnungs_nr'] ?? 'Entwurf') ?></td>
|
||||||
<td style="padding:.4rem .5rem;border:1px solid #ddd"><?= $h($invoice['kundennummer']) ?></td>
|
<td style="padding:.4rem .5rem;border:1px solid #ddd;white-space:nowrap"><?= $h($invoice['kundennummer']) ?></td>
|
||||||
<td style="padding:.4rem .5rem;border:1px solid #ddd"><?= $h($lz ?: '–') ?></td>
|
<td style="padding:.4rem .5rem;border:1px solid #ddd"><?= $lzHtml ?: '–' ?></td>
|
||||||
<td style="padding:.4rem .5rem;border:1px solid #ddd"><?= View::formatDate($invoice['rechnungs_datum']) ?></td>
|
<td style="padding:.4rem .5rem;border:1px solid #ddd;white-space:nowrap"><?= $h(View::formatDate($invoice['rechnungs_datum'])) ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Positionen -->
|
<!-- Positionen -->
|
||||||
<table style="width:100%;border-collapse:collapse;margin-bottom:1.5rem;font-size:.9rem">
|
<table style="width:100%;table-layout:fixed;border-collapse:collapse;margin-bottom:1.5rem;font-size:.9rem">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background:#1e293b;color:#fff">
|
<tr style="background:#f0f0f0">
|
||||||
<th style="padding:.5rem;text-align:left;width:40px">Pos.</th>
|
<th style="padding:.5rem;text-align:left;width:6%">Pos.</th>
|
||||||
<th style="padding:.5rem;text-align:left">Bezeichnung</th>
|
<th style="padding:.5rem;text-align:left">Bezeichnung</th>
|
||||||
<th style="padding:.5rem;text-align:right;width:70px">Menge</th>
|
<th style="padding:.5rem;text-align:right;width:11%">Menge</th>
|
||||||
<th style="padding:.5rem;text-align:left;width:70px">Einheit</th>
|
<th style="padding:.5rem;text-align:left;width:10%">Einheit</th>
|
||||||
<th style="padding:.5rem;text-align:right;width:60px">USt.</th>
|
<th style="padding:.5rem;text-align:right;width:8%">USt.</th>
|
||||||
<th style="padding:.5rem;text-align:right;width:100px">Einzelpreis</th>
|
<th style="padding:.5rem;text-align:right;width:15%">Einzelpreis</th>
|
||||||
<th style="padding:.5rem;text-align:right;width:110px">Gesamt</th>
|
<th style="padding:.5rem;text-align:right;width:16%">Gesamt</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -125,7 +151,7 @@ $badgeClass = match($invoice['status']) {
|
||||||
<tr style="border-bottom:1px solid #eee">
|
<tr style="border-bottom:1px solid #eee">
|
||||||
<td style="padding:.4rem .5rem"><?= $h($line['position']) ?></td>
|
<td style="padding:.4rem .5rem"><?= $h($line['position']) ?></td>
|
||||||
<td style="padding:.4rem .5rem"><?= $h($line['bezeichnung']) ?></td>
|
<td style="padding:.4rem .5rem"><?= $h($line['bezeichnung']) ?></td>
|
||||||
<td style="padding:.4rem .5rem;text-align:right"><?= number_format((float)$line['menge'], 3, ',', '.') ?></td>
|
<td style="padding:.4rem .5rem;text-align:right"><?= number_format((float)$line['menge'], 1, ',', '.') ?></td>
|
||||||
<td style="padding:.4rem .5rem"><?= $h($line['einheit']) ?></td>
|
<td style="padding:.4rem .5rem"><?= $h($line['einheit']) ?></td>
|
||||||
<td style="padding:.4rem .5rem;text-align:right"><?= number_format((float)$line['ust_satz'], 0) ?> %</td>
|
<td style="padding:.4rem .5rem;text-align:right"><?= number_format((float)$line['ust_satz'], 0) ?> %</td>
|
||||||
<td style="padding:.4rem .5rem;text-align:right"><?= $fmt($line['einzelpreis']) ?></td>
|
<td style="padding:.4rem .5rem;text-align:right"><?= $fmt($line['einzelpreis']) ?></td>
|
||||||
|
|
@ -154,20 +180,20 @@ $badgeClass = match($invoice['status']) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fußzeile -->
|
<!-- Fußzeile -->
|
||||||
<div style="border-top:1px solid #ccc;padding-top:.75rem;display:grid;grid-template-columns:1fr 1fr 1fr 80px;gap:1rem;font-size:.75rem;color:#555">
|
<div style="border-top:1px solid #ccc;padding-top:.75rem;display:grid;grid-template-columns:auto auto auto 1fr;gap:1rem;font-size:.75rem;color:#555">
|
||||||
<div>
|
<div style="min-width:0">
|
||||||
<strong><?= $h($settings['firma'] ?? '') ?></strong><br>
|
<strong><?= $h($settings['firma'] ?? '') ?></strong><br>
|
||||||
<?= $h($settings['inhaber'] ?? '') ?><br>
|
<?= $h($settings['inhaber'] ?? '') ?><br>
|
||||||
<?= $h($settings['strasse'] ?? '') ?><br>
|
<?= $h($settings['strasse'] ?? '') ?><br>
|
||||||
<?= $h(($settings['plz'] ?? '') . ' ' . ($settings['ort'] ?? '')) ?><br>
|
<?= $h(($settings['plz'] ?? '') . ' ' . ($settings['ort'] ?? '')) ?><br>
|
||||||
USt-ID: <?= $h($settings['ust_id'] ?? '') ?>
|
USt-ID: <?= $h($settings['ust_id'] ?? '') ?>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style="min-width:0">
|
||||||
Tel: <?= $h($settings['telefon'] ?? '') ?><br>
|
Tel: <?= $h($settings['telefon'] ?? '') ?><br>
|
||||||
E-Mail: <?= $h($settings['email'] ?? '') ?><br>
|
E-Mail: <?= $h($settings['email'] ?? '') ?><br>
|
||||||
Web: <?= $h($settings['website'] ?? '') ?>
|
Web: <?= $h($settings['website'] ?? '') ?>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style="min-width:0">
|
||||||
<?= $h($settings['bank_name'] ?? '') ?><br>
|
<?= $h($settings['bank_name'] ?? '') ?><br>
|
||||||
IBAN: <?= $h($settings['iban'] ?? '') ?><br>
|
IBAN: <?= $h($settings['iban'] ?? '') ?><br>
|
||||||
BIC: <?= $h($settings['bic'] ?? '') ?>
|
BIC: <?= $h($settings['bic'] ?? '') ?>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ function navActive(string $prefix, string $current): string {
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-brand"><img src="/static/favicon/web-app-manifest-512x512.png" alt="" style="height:1.1em;width:auto;vertical-align:middle;margin-right:.4em;" />Steuer<span>kram</span></div>
|
<div class="sidebar-brand"><a href="https://codeberg.org/fds2610/Steuerkram" target="_blank" rel="noopener noreferrer" style="display:inline-flex;align-items:center;color:inherit;text-decoration:none;"><img src="/static/favicon/web-app-manifest-512x512.png" alt="" style="height:1.1em;width:auto;vertical-align:middle;margin-right:.4em;" />Steuer<span>kram</span></a></div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<div class="sidebar-label">Übersicht</div>
|
<div class="sidebar-label">Übersicht</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||||
|
|
||||||
<form method="post" action="/settings">
|
<form id="settings-form" method="post" action="/settings">
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start">
|
||||||
|
|
||||||
<!-- Firmendaten -->
|
<!-- Firmendaten -->
|
||||||
|
|
@ -85,8 +85,6 @@
|
||||||
|
|
||||||
<!-- Bankdaten + Logo + Nummernkreise -->
|
<!-- Bankdaten + Logo + Nummernkreise -->
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" class="btn btn-primary btn-lg w-full" style="margin-bottom:1rem">Einstellungen speichern</button>
|
|
||||||
|
|
||||||
<!-- Betriebsmodus -->
|
<!-- Betriebsmodus -->
|
||||||
<div class="card mb-2" style="border:2px solid <?= ($settings['test_mode'] ?? 1) ? 'var(--color-warning,#f59e0b)' : 'var(--color-success,#22c55e)' ?>">
|
<div class="card mb-2" style="border:2px solid <?= ($settings['test_mode'] ?? 1) ? 'var(--color-warning,#f59e0b)' : 'var(--color-success,#22c55e)' ?>">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
|
@ -138,7 +136,7 @@
|
||||||
<span style="font-weight:600">Automatischen Kontierungsvorschlag erzeugen</span>
|
<span style="font-weight:600">Automatischen Kontierungsvorschlag erzeugen</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-muted text-sm" style="margin:.5rem 0 .75rem 1.75rem">
|
<p class="text-muted text-sm" style="margin:.5rem 0 .75rem 1.75rem">
|
||||||
Beim ersten Öffnen eines Belegs wird ein LLM befragt und das Buchungsformular vorausgefüllt.
|
Beim automatischen Nextcloud-Import wird für jeden Beleg ein Kontierungsvorschlag per LLM erzeugt.
|
||||||
Konfiguration via <code>.env</code> (lokales Ollama oder Remote-API).
|
Konfiguration via <code>.env</code> (lokales Ollama oder Remote-API).
|
||||||
</p>
|
</p>
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -155,23 +153,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OCR-Texterkennung -->
|
|
||||||
<div class="card mb-2">
|
|
||||||
<div class="card-header"><div class="card-title">OCR-Texterkennung</div></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;user-select:none">
|
|
||||||
<input type="checkbox" name="ocr_aktiv" value="1"
|
|
||||||
<?= !empty($settings['ocr_aktiv']) ? 'checked' : '' ?>>
|
|
||||||
<span style="font-weight:600">Automatische Texterkennung bei Bildbelegen aktivieren</span>
|
|
||||||
</label>
|
|
||||||
<p class="text-muted text-sm" style="margin:.5rem 0 0 1.75rem">
|
|
||||||
Bei Belegen im Bildformat (JPEG, PNG, TIFF) wird Tesseract OCR eingesetzt, um den Belegtext
|
|
||||||
zu extrahieren. Dieser Text wird – sofern der KI-Schalter aktiv ist – an das LLM übergeben.
|
|
||||||
PDF-Belege werden unabhängig davon per <code>pdftotext</code> ausgelesen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nextcloud-Import -->
|
<!-- Nextcloud-Import -->
|
||||||
<div class="card mb-2">
|
<div class="card mb-2">
|
||||||
<div class="card-header"><div class="card-title">Nextcloud-Belegimport</div></div>
|
<div class="card-header"><div class="card-title">Nextcloud-Belegimport</div></div>
|
||||||
|
|
@ -239,28 +220,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-2">
|
|
||||||
<div class="card-header"><div class="card-title">Logo</div></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<?php if (!empty($settings['logo_path'])): ?>
|
|
||||||
<div style="text-align:center;margin-bottom:1rem">
|
|
||||||
<img src="/<?= $h($settings['logo_path']) ?>" alt="Logo" style="max-height:80px;max-width:200px">
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/settings/logo/delete" style="margin-bottom:1rem">
|
|
||||||
<button type="submit" class="btn btn-danger btn-sm w-full">Logo löschen</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
<form method="post" action="/settings/logo" enctype="multipart/form-data">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Logo hochladen (PNG, JPG)</label>
|
|
||||||
<input type="file" name="logo" accept=".png,.jpg,.jpeg">
|
|
||||||
<div class="form-hint">Empfohlen: transparentes PNG, mind. 300px breit</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-secondary">Logo hochladen</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card mb-2">
|
<div class="card mb-2">
|
||||||
<div class="card-header"><div class="card-title">Nummernkreise</div></div>
|
<div class="card-header"><div class="card-title">Nummernkreise</div></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
@ -290,6 +249,82 @@
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-top:0">
|
||||||
|
<div></div>
|
||||||
|
<div>
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><div class="card-title">Logo</div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php if (!empty($settings['logo_path'])): ?>
|
||||||
|
<div style="text-align:center;margin-bottom:1rem">
|
||||||
|
<img src="/<?= $h($settings['logo_path']) ?>" alt="Logo" style="max-height:80px;max-width:400px">
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="post" action="/settings/logo" enctype="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Logo hochladen (PNG, JPG)</label>
|
||||||
|
<input type="file" name="logo" accept=".png,.jpg,.jpeg">
|
||||||
|
<div class="form-hint">Empfohlen: transparentes PNG, mind. 300px breit</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
|
||||||
|
<button type="submit" class="btn btn-secondary">Hochladen</button>
|
||||||
|
<?php if (!empty($settings['logo_path'])): ?>
|
||||||
|
<button type="submit" form="delete-logo-form" class="btn btn-danger btn-sm">Löschen</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<?php if (!empty($settings['logo_path'])): ?>
|
||||||
|
<form id="delete-logo-form" method="post" action="/settings/logo/delete"></form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div style="margin-top:1rem;border-top:1px solid var(--color-border,#e5e7eb);padding-top:1rem">
|
||||||
|
<div class="form-hint" style="margin-bottom:.75rem">Darstellung in der Rechnung</div>
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Max-Breite</label>
|
||||||
|
<input type="number" name="logo_max_width" form="settings-form"
|
||||||
|
value="<?= $h($settings['logo_max_width'] ?? 600) ?>" min="50" max="2000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Max-Höhe</label>
|
||||||
|
<input type="number" name="logo_max_height" form="settings-form"
|
||||||
|
value="<?= $h($settings['logo_max_height'] ?? 100) ?>" min="20" max="500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Position</label>
|
||||||
|
<div style="display:flex;gap:1.5rem;margin-top:.35rem">
|
||||||
|
<?php foreach (['center' => 'Zentriert', 'left' => 'Links', 'right' => 'Rechts'] as $val => $label): ?>
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer">
|
||||||
|
<input type="radio" name="logo_position" value="<?= $val ?>" form="settings-form"
|
||||||
|
<?= ($settings['logo_position'] ?? 'center') === $val ? 'checked' : '' ?>
|
||||||
|
onchange="logoPositionChanged()">
|
||||||
|
<?= $label ?>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php $showMargins = in_array($settings['logo_position'] ?? 'center', ['left', 'right']); ?>
|
||||||
|
<div id="logo-margin-fields" style="display:<?= $showMargins ? 'block' : 'none' ?>">
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Rand Seite</label>
|
||||||
|
<input type="number" name="logo_margin_side" form="settings-form"
|
||||||
|
value="<?= $h($settings['logo_margin_side'] ?? 75) ?>" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Rand oben</label>
|
||||||
|
<input type="number" name="logo_margin_top" form="settings-form"
|
||||||
|
value="<?= $h($settings['logo_margin_top'] ?? 6) ?>" min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function updateModeLabel(cb) {
|
function updateModeLabel(cb) {
|
||||||
const track = document.getElementById('toggle_track');
|
const track = document.getElementById('toggle_track');
|
||||||
|
|
@ -316,4 +351,10 @@ function updateModeLabel(cb) {
|
||||||
cb.closest('.card').querySelector('.badge').textContent = 'Produktiv';
|
cb.closest('.card').querySelector('.badge').textContent = 'Produktiv';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logoPositionChanged() {
|
||||||
|
const pos = document.querySelector('input[name="logo_position"]:checked')?.value;
|
||||||
|
document.getElementById('logo-margin-fields').style.display =
|
||||||
|
(pos === 'left' || pos === 'right') ? 'block' : 'none';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ class BelegBuchungTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', $belegNr . 'buch'),
|
'file_hash' => hash('sha256', $belegNr . 'buch'),
|
||||||
'beleg_datum' => $datum,
|
'beleg_datum' => $datum,
|
||||||
'eingangs_datum' => $datum,
|
|
||||||
'rechnungs_nr' => 'RE-BUCH-001',
|
'rechnungs_nr' => 'RE-BUCH-001',
|
||||||
'betrag_brutto' => 59.50,
|
'betrag_brutto' => 59.50,
|
||||||
'belegtyp' => 'Eingangsrechnung',
|
'belegtyp' => 'Eingangsrechnung',
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,6 @@ class InvoiceBuchungTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', $nr),
|
'file_hash' => hash('sha256', $nr),
|
||||||
'beleg_datum' => $invoice['rechnungs_datum'],
|
'beleg_datum' => $invoice['rechnungs_datum'],
|
||||||
'eingangs_datum' => $invoice['rechnungs_datum'],
|
|
||||||
'rechnungs_nr' => $nr,
|
'rechnungs_nr' => $nr,
|
||||||
'betrag_brutto' => $invoice['betrag_brutto'],
|
'betrag_brutto' => $invoice['betrag_brutto'],
|
||||||
'belegtyp' => 'Ausgangsrechnung',
|
'belegtyp' => 'Ausgangsrechnung',
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,6 @@ class TaxSummenTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', 'test-ar'),
|
'file_hash' => hash('sha256', 'test-ar'),
|
||||||
'beleg_datum' => $datum,
|
'beleg_datum' => $datum,
|
||||||
'eingangs_datum' => $datum,
|
|
||||||
'belegtyp' => 'Ausgangsrechnung',
|
'belegtyp' => 'Ausgangsrechnung',
|
||||||
]);
|
]);
|
||||||
\Steuerkram\Models\Booking::create([
|
\Steuerkram\Models\Booking::create([
|
||||||
|
|
@ -89,7 +88,6 @@ class TaxSummenTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', 'test-er'),
|
'file_hash' => hash('sha256', 'test-er'),
|
||||||
'beleg_datum' => $datum,
|
'beleg_datum' => $datum,
|
||||||
'eingangs_datum' => $datum,
|
|
||||||
'belegtyp' => 'Eingangsrechnung',
|
'belegtyp' => 'Eingangsrechnung',
|
||||||
]);
|
]);
|
||||||
\Steuerkram\Models\Booking::create([
|
\Steuerkram\Models\Booking::create([
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ class EuerBuchungsTest extends TestCase
|
||||||
{
|
{
|
||||||
$datum = date('Y-m-d');
|
$datum = date('Y-m-d');
|
||||||
|
|
||||||
|
// EÜR-Snapshot vor allen Testbuchungen
|
||||||
|
$euerVorher = (new \Steuerkram\Services\EuerCalculator())->calculate((int)date('Y'));
|
||||||
|
$einnahmenVorher = (float)$euerVorher['total_einnahmen'];
|
||||||
|
$gewinnVorher = (float)$euerVorher['gewinn'];
|
||||||
|
|
||||||
// ── Kontenarten prüfen ───────────────────────────────────────
|
// ── Kontenarten prüfen ───────────────────────────────────────
|
||||||
$kontenarten = \Steuerkram\Database::fetchAll(
|
$kontenarten = \Steuerkram\Database::fetchAll(
|
||||||
"SELECT DISTINCT kontenart FROM accounts ORDER BY kontenart"
|
"SELECT DISTINCT kontenart FROM accounts ORDER BY kontenart"
|
||||||
|
|
@ -59,7 +64,6 @@ class EuerBuchungsTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', 'einnahme-' . uniqid()),
|
'file_hash' => hash('sha256', 'einnahme-' . uniqid()),
|
||||||
'beleg_datum' => $datum,
|
'beleg_datum' => $datum,
|
||||||
'eingangs_datum' => $datum,
|
|
||||||
'belegtyp' => 'Ausgangsrechnung',
|
'belegtyp' => 'Ausgangsrechnung',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -90,7 +94,6 @@ class EuerBuchungsTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', 'ausgabe-' . uniqid()),
|
'file_hash' => hash('sha256', 'ausgabe-' . uniqid()),
|
||||||
'beleg_datum' => $datum,
|
'beleg_datum' => $datum,
|
||||||
'eingangs_datum' => $datum,
|
|
||||||
'belegtyp' => 'Eingangsrechnung',
|
'belegtyp' => 'Eingangsrechnung',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -113,6 +116,11 @@ class EuerBuchungsTest extends TestCase
|
||||||
$stornoId = \Steuerkram\Models\Booking::reverse($ausgabeBuchungId, 'Testgrund');
|
$stornoId = \Steuerkram\Models\Booking::reverse($ausgabeBuchungId, 'Testgrund');
|
||||||
$storno = \Steuerkram\Models\Booking::find($stornoId);
|
$storno = \Steuerkram\Models\Booking::find($stornoId);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
-300.00,
|
||||||
|
round((float)$storno['betrag_netto'], 2),
|
||||||
|
'Storno betrag_netto ist korrekt negiert'
|
||||||
|
);
|
||||||
$this->assertEquals('Ausgabe', $storno['buchungstyp'], 'Storno behält buchungstyp der Originalbuchung');
|
$this->assertEquals('Ausgabe', $storno['buchungstyp'], 'Storno behält buchungstyp der Originalbuchung');
|
||||||
$this->assertEquals(1, (int)$storno['is_reversal'], 'Storno-Flag gesetzt');
|
$this->assertEquals(1, (int)$storno['is_reversal'], 'Storno-Flag gesetzt');
|
||||||
$this->assertEquals($ausgabeId, (int)$storno['kategorie_konto_id'], 'Storno behält Kategorie-Konto');
|
$this->assertEquals($ausgabeId, (int)$storno['kategorie_konto_id'], 'Storno behält Kategorie-Konto');
|
||||||
|
|
@ -123,16 +131,17 @@ class EuerBuchungsTest extends TestCase
|
||||||
$this->assertEquals($stornoId, (int)$origNeu['reversed_by'], 'Original verweist auf Storno-Buchung');
|
$this->assertEquals($stornoId, (int)$origNeu['reversed_by'], 'Original verweist auf Storno-Buchung');
|
||||||
|
|
||||||
// ── EÜR-Berechnung ───────────────────────────────────────────
|
// ── EÜR-Berechnung ───────────────────────────────────────────
|
||||||
$euer = (new \Steuerkram\Services\EuerCalculator())->calculate((int)date('Y'));
|
// Nach Einnahme (1000 €) + stornierter Ausgabe: Delta muss +1000 € betragen
|
||||||
|
$euerNachher = (new \Steuerkram\Services\EuerCalculator())->calculate((int)date('Y'));
|
||||||
|
|
||||||
$this->assert(
|
$this->assert(
|
||||||
(float)$euer['total_einnahmen'] >= 1000.00,
|
(float)$euerNachher['total_einnahmen'] >= $einnahmenVorher + 1000.00,
|
||||||
'EÜR: Einnahmen enthalten die 1000 € Einnahme-Buchung'
|
'EÜR: Einnahmen enthalten die 1000 € Einnahme-Buchung'
|
||||||
);
|
);
|
||||||
// Ausgabe wurde storniert → sollte nicht in den Ausgaben erscheinen (is_reversal=0 Filter)
|
// Ausgabe wurde storniert → Netto-Effekt auf Gewinn: +1000 €
|
||||||
$this->assert(
|
$this->assert(
|
||||||
(float)$euer['gewinn'] > 0,
|
round((float)$euerNachher['gewinn'] - $gewinnVorher, 2) >= 1000.00,
|
||||||
'EÜR: Gewinn ist positiv (Einnahmen > Ausgaben nach Storno)'
|
'EÜR: Gewinn ist um ≥ 1000 € gestiegen (Einnahme + stornierte Ausgabe)'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ class UstvaAuslandTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', 'test-igl'),
|
'file_hash' => hash('sha256', 'test-igl'),
|
||||||
'beleg_datum' => $datum,
|
'beleg_datum' => $datum,
|
||||||
'eingangs_datum' => $datum,
|
|
||||||
'belegtyp' => 'Ausgangsrechnung',
|
'belegtyp' => 'Ausgangsrechnung',
|
||||||
]);
|
]);
|
||||||
\Steuerkram\Models\Booking::create([
|
\Steuerkram\Models\Booking::create([
|
||||||
|
|
@ -67,7 +66,6 @@ class UstvaAuslandTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', 'test-exp'),
|
'file_hash' => hash('sha256', 'test-exp'),
|
||||||
'beleg_datum' => $datum,
|
'beleg_datum' => $datum,
|
||||||
'eingangs_datum' => $datum,
|
|
||||||
'belegtyp' => 'Ausgangsrechnung',
|
'belegtyp' => 'Ausgangsrechnung',
|
||||||
]);
|
]);
|
||||||
\Steuerkram\Models\Booking::create([
|
\Steuerkram\Models\Booking::create([
|
||||||
|
|
@ -90,7 +88,6 @@ class UstvaAuslandTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', 'test-ige19'),
|
'file_hash' => hash('sha256', 'test-ige19'),
|
||||||
'beleg_datum' => $datum,
|
'beleg_datum' => $datum,
|
||||||
'eingangs_datum' => $datum,
|
|
||||||
'belegtyp' => 'Eingangsrechnung',
|
'belegtyp' => 'Eingangsrechnung',
|
||||||
]);
|
]);
|
||||||
\Steuerkram\Models\Booking::create([
|
\Steuerkram\Models\Booking::create([
|
||||||
|
|
@ -113,7 +110,6 @@ class UstvaAuslandTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', 'test-ige7'),
|
'file_hash' => hash('sha256', 'test-ige7'),
|
||||||
'beleg_datum' => $datum,
|
'beleg_datum' => $datum,
|
||||||
'eingangs_datum' => $datum,
|
|
||||||
'belegtyp' => 'Eingangsrechnung',
|
'belegtyp' => 'Eingangsrechnung',
|
||||||
]);
|
]);
|
||||||
\Steuerkram\Models\Booking::create([
|
\Steuerkram\Models\Booking::create([
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,6 @@ class LieferantTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', $belegNr),
|
'file_hash' => hash('sha256', $belegNr),
|
||||||
'beleg_datum' => $datum,
|
'beleg_datum' => $datum,
|
||||||
'eingangs_datum' => $datum,
|
|
||||||
'rechnungs_nr' => 'RE-SUP-001',
|
'rechnungs_nr' => 'RE-SUP-001',
|
||||||
'betrag_brutto' => 59.50,
|
'betrag_brutto' => 59.50,
|
||||||
'belegtyp' => 'Eingangsrechnung',
|
'belegtyp' => 'Eingangsrechnung',
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,6 @@ class InvoiceToReportsTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', $rechnungsNr),
|
'file_hash' => hash('sha256', $rechnungsNr),
|
||||||
'beleg_datum' => $invoice['rechnungs_datum'],
|
'beleg_datum' => $invoice['rechnungs_datum'],
|
||||||
'eingangs_datum' => $invoice['rechnungs_datum'],
|
|
||||||
'rechnungs_nr' => $rechnungsNr,
|
'rechnungs_nr' => $rechnungsNr,
|
||||||
'betrag_brutto' => $invoice['betrag_brutto'],
|
'betrag_brutto' => $invoice['betrag_brutto'],
|
||||||
'belegtyp' => 'Ausgangsrechnung',
|
'belegtyp' => 'Ausgangsrechnung',
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@ class IstVersteuerungTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', $nr),
|
'file_hash' => hash('sha256', $nr),
|
||||||
'beleg_datum' => $rechnungsDatum,
|
'beleg_datum' => $rechnungsDatum,
|
||||||
'eingangs_datum' => $rechnungsDatum,
|
|
||||||
'rechnungs_nr' => $nr,
|
'rechnungs_nr' => $nr,
|
||||||
'betrag_brutto' => $invoice['betrag_brutto'],
|
'betrag_brutto' => $invoice['betrag_brutto'],
|
||||||
'belegtyp' => 'Ausgangsrechnung',
|
'belegtyp' => 'Ausgangsrechnung',
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ class BelegGebuechtStatusTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => sha1('test'),
|
'file_hash' => sha1('test'),
|
||||||
'beleg_datum' => date('Y-m-d'),
|
'beleg_datum' => date('Y-m-d'),
|
||||||
'eingangs_datum'=> date('Y-m-d'),
|
|
||||||
'belegtyp' => 'Eingangsrechnung',
|
'belegtyp' => 'Eingangsrechnung',
|
||||||
'betrag_brutto' => 119.00,
|
'betrag_brutto' => 119.00,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ class NichtAbzugsfaehigEuerTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', 'kz165ein' . uniqid()),
|
'file_hash' => hash('sha256', 'kz165ein' . uniqid()),
|
||||||
'beleg_datum' => $datum,
|
'beleg_datum' => $datum,
|
||||||
'eingangs_datum' => $datum,
|
|
||||||
'belegtyp' => 'Ausgangsrechnung',
|
'belegtyp' => 'Ausgangsrechnung',
|
||||||
]);
|
]);
|
||||||
\Steuerkram\Models\Booking::create([
|
\Steuerkram\Models\Booking::create([
|
||||||
|
|
@ -62,7 +61,6 @@ class NichtAbzugsfaehigEuerTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', 'kz165aus' . uniqid()),
|
'file_hash' => hash('sha256', 'kz165aus' . uniqid()),
|
||||||
'beleg_datum' => $datum,
|
'beleg_datum' => $datum,
|
||||||
'eingangs_datum' => $datum,
|
|
||||||
'belegtyp' => 'Eingangsrechnung',
|
'belegtyp' => 'Eingangsrechnung',
|
||||||
]);
|
]);
|
||||||
\Steuerkram\Models\Booking::create([
|
\Steuerkram\Models\Booking::create([
|
||||||
|
|
@ -84,7 +82,6 @@ class NichtAbzugsfaehigEuerTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', 'kz165na' . uniqid()),
|
'file_hash' => hash('sha256', 'kz165na' . uniqid()),
|
||||||
'beleg_datum' => $datum,
|
'beleg_datum' => $datum,
|
||||||
'eingangs_datum' => $datum,
|
|
||||||
'belegtyp' => 'Eingangsrechnung',
|
'belegtyp' => 'Eingangsrechnung',
|
||||||
]);
|
]);
|
||||||
\Steuerkram\Models\Booking::create([
|
\Steuerkram\Models\Booking::create([
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ class StornoBuchungTest extends TestCase
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', 'storno-test-' . uniqid()),
|
'file_hash' => hash('sha256', 'storno-test-' . uniqid()),
|
||||||
'beleg_datum' => $datum,
|
'beleg_datum' => $datum,
|
||||||
'eingangs_datum' => $datum,
|
|
||||||
'belegtyp' => 'Ausgangsrechnung',
|
'belegtyp' => 'Ausgangsrechnung',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue