Merge main: Konflikte aufgelöst (OCR entfernt, Anlagevermögen behalten)

This commit is contained in:
fds2610-bot 2026-04-15 20:35:18 +02:00
commit aed81022c0
43 changed files with 2127 additions and 423 deletions

View file

@ -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-...

View file

@ -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

View file

@ -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
View file

@ -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).
![PHP](https://img.shields.io/badge/PHP-777BB4?logo=php&logoColor=white)
![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.
![PHP](https://img.shields.io/badge/PHP-777BB4?logo=php&logoColor=white)
![HTML](https://img.shields.io/badge/HTML-E34F26?logo=html5&logoColor=white)
![CSS](https://img.shields.io/badge/CSS-1572B6?logo=css3&logoColor=white)
![JavaScript](https://img.shields.io/badge/JavaScript-F7DF1E?logo=javascript&logoColor=black)
| 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
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
View 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
View 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.

View file

@ -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
View 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
View 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
View 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
View 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
```

View file

@ -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
View 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
```

View 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);

View 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;

View file

@ -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;

View file

@ -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
View file

@ -0,0 +1,104 @@
<?php
/**
* KI-Kontierungsvorschlag – Batch-Verarbeitung (CLI-Einstiegspunkt).
*
* Verarbeitet EINEN ausstehenden Beleg pro Aufruf (ki_vorschlag IS NULL, locked = 0).
* Verhindert so, dass mehrere Vision-Model-Anfragen parallel an Ollama geschickt werden.
*
* Voraussetzung: ki_vorschlag_aktiv = 1 in den Einstellungen, Ollama erreichbar.
*
* Aufruf:
* docker compose exec web php scripts/ki-batch.php
*
* Für optionalen eigenen Cronjob (manuelle Uploads ohne Nextcloud):
* *\/5 * * * * docker compose -f /opt/steuerkram/docker-compose.yml exec -T web \
* php scripts/ki-batch.php >> /var/log/steuerkram-ki.log 2>&1
*/
declare(strict_types=1);
chdir(dirname(__DIR__));
// DB-Zugangsdaten aus .env laden (für CLI-Kontext ohne Apache-Umgebung)
if (file_exists('.env')) {
foreach (file('.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value, " \t\n\r\0\x0B\"'");
if (!array_key_exists($key, $_ENV)) {
$_ENV[$key] = $value;
putenv("{$key}={$value}");
}
}
}
require_once 'vendor/autoload.php';
use Steuerkram\Config;
use Steuerkram\Database;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Setting;
use Steuerkram\Services\KontierungsvorschlagService;
Config::load();
$tag = '[ki-batch]';
// KI-Erkennung in den Einstellungen aktiv?
$settings = Setting::get();
if (empty($settings['ki_vorschlag_aktiv'])) {
echo "{$tag} KI-Erkennung ist in den Einstellungen deaktiviert – nichts zu tun." . PHP_EOL;
exit(0);
}
$svc = new KontierungsvorschlagService();
if (!$svc->isEnabled()) {
echo "{$tag} LLM nicht erreichbar – Abbruch." . PHP_EOL;
exit(1);
}
// Optionale Beleg-ID als CLI-Argument: php ki-batch.php 123
$argId = isset($argv[1]) && ctype_digit((string)$argv[1]) ? (int)$argv[1] : null;
// Beleg holen: wenn ID angegeben, diesen direkt verarbeiten; sonst ältesten ausstehenden
$row = $argId !== null
? Database::fetchOne('SELECT id FROM belege WHERE id = ? AND ki_vorschlag IS NULL AND locked = 0', [$argId])
: Database::fetchOne('SELECT id FROM belege WHERE ki_vorschlag IS NULL AND locked = 0 ORDER BY id ASC LIMIT 1');
if (!$row) {
echo "{$tag} Kein ausstehender Beleg gefunden." . PHP_EOL;
exit(0);
}
$belegId = (int)$row['id'];
$beleg = Beleg::find($belegId);
if (!$beleg) {
echo "{$tag} Beleg #{$belegId} nicht ladbar – Abbruch." . PHP_EOL;
exit(1);
}
echo "{$tag} Verarbeite Beleg #{$belegId} ({$beleg['beleg_nr']}) ..." . PHP_EOL;
$start = microtime(true);
$result = $svc->generateForBeleg($beleg);
if ($result === null) {
echo "{$tag} Beleg #{$belegId}: Kein Vorschlag generiert (nicht unterstützter Typ oder LLM-Fehler)." . PHP_EOL;
// Leeres Array speichern damit der Beleg nicht erneut verarbeitet wird
Beleg::saveKiVorschlag($belegId, []);
exit(0);
}
Beleg::saveKiVorschlag($belegId, $result);
$elapsed = round(microtime(true) - $start, 1);
$zeilen = count($result);
echo "{$tag} Beleg #{$belegId}: {$zeilen} Buchungszeile(n) generiert in {$elapsed}s." . PHP_EOL;
exit(0);

View file

@ -41,6 +41,7 @@ if (file_exists('.env')) {
require_once 'vendor/autoload.php'; require_once 'vendor/autoload.php';
use Steuerkram\Config; use Steuerkram\Config;
use Steuerkram\Models\Setting;
use Steuerkram\Services\NextcloudImportService; use Steuerkram\Services\NextcloudImportService;
Config::load(); // Database verbindet sich lazy über Config::get() Config::load(); // Database verbindet sich lazy über Config::get()
@ -64,4 +65,16 @@ echo sprintf(
$result['errors'], $result['errors'],
); );
// KI-Vorschlag für jeden neu importierten Beleg anstoßen (wenn KI-Erkennung aktiv)
if (!empty($result['imported_ids'])) {
$settings = Setting::get();
if (!empty($settings['ki_vorschlag_aktiv'])) {
$kiBatch = __DIR__ . '/ki-batch.php';
echo '[nextcloud-import] Starte KI-Erkennung für ' . count($result['imported_ids']) . ' importierte(n) Beleg(e) ...' . PHP_EOL;
foreach ($result['imported_ids'] as $belegId) {
passthru('php ' . escapeshellarg($kiBatch) . ' ' . (int)$belegId . ' 2>&1');
}
}
}
exit($result['errors'] > 0 ? 1 : 0); exit($result['errors'] > 0 ? 1 : 0);

352
scripts/update.sh Executable file
View 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

View file

@ -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' => '',
]; ];

View file

@ -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,
]), ]),
]); ]);

View file

@ -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' => [],

View file

@ -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)),
] ]
); );
} }

View file

@ -24,12 +24,6 @@ class KontierungsvorschlagService
return (bool)($s['ki_vorschlag_aktiv'] ?? 0) && $this->llm->isAvailable(); return (bool)($s['ki_vorschlag_aktiv'] ?? 0) && $this->llm->isAvailable();
} }
public function isOcrEnabled(): bool
{
$s = Setting::get();
return (bool)($s['ocr_aktiv'] ?? 1);
}
public function extractText(array $beleg): string public function extractText(array $beleg): string
{ {
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; $absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
@ -38,14 +32,33 @@ class KontierungsvorschlagService
$mime = strtolower($beleg['mime_type'] ?? ''); $mime = strtolower($beleg['mime_type'] ?? '');
if ($mime === 'application/pdf') { if ($mime === 'application/pdf') {
// 1. eRechnung-XML (ZUGFeRD / Factur-X / XRechnung) — strukturiert, fehlerarm
$xml = $this->extractErechnungXml($absPath);
if ($xml !== '') return $xml;
// 2. Vision-Modell (qwen2.5vl o.ä.)
if ($this->llm->isVisionAvailable()) {
$vision = $this->extractPdfVision($absPath);
if ($vision !== '') return $vision;
}
// 3. Fallback: pdftotext
return $this->extractPdfText($absPath); return $this->extractPdfText($absPath);
} elseif (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) { }
return $this->isOcrEnabled() ? $this->extractImageText($absPath) : '';
if (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) {
if (!$this->llm->isVisionAvailable()) return '';
return $this->extractImageVision($absPath);
} }
return ''; return '';
} }
/**
* Erstellt einen Kontierungsvorschlag für den Beleg.
* Gibt ein Array von Buchungszeilen zurück (eine je MwSt-Satz),
* oder null bei Fehler / nicht unterstütztem MIME-Typ.
*/
public function generateForBeleg(array $beleg): ?array public function generateForBeleg(array $beleg): ?array
{ {
$text = $this->extractText($beleg); $text = $this->extractText($beleg);
@ -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;
} }
} }

View file

@ -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;
} }

View file

@ -69,7 +69,7 @@ class NextcloudImportService
*/ */
public function import(): array public function import(): array
{ {
$result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => []]; $result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [], 'imported_ids' => []];
if (!$this->baseUrl || !$this->user || !$this->pass) { if (!$this->baseUrl || !$this->user || !$this->pass) {
$result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.'; $result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.';
@ -262,7 +262,7 @@ class NextcloudImportService
// In Datenbank anlegen // In Datenbank anlegen
$today = date('Y-m-d'); $today = date('Y-m-d');
Beleg::create([ $belegId = Beleg::create([
'beleg_nr' => $belegNr, 'beleg_nr' => $belegNr,
'filename' => $filename, 'filename' => $filename,
'storage_path' => $storagePath, 'storage_path' => $storagePath,
@ -280,6 +280,7 @@ class NextcloudImportService
$result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}"; $result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}";
$result['imported']++; $result['imported']++;
$result['imported_ids'][] = $belegId;
} }
/** /**

View file

@ -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;
} }
} }

View file

@ -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>

View file

@ -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'] ?? '') ?>

View file

@ -5,7 +5,7 @@ function navActive(string $prefix, string $current): string {
} }
?> ?>
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-brand"><img src="/static/favicon/web-app-manifest-512x512.png" alt="" style="height:1.1em;width:auto;vertical-align:middle;margin-right:.4em;" />Steuer<span>kram</span></div> <div class="sidebar-brand"><a href="https://codeberg.org/fds2610/Steuerkram" target="_blank" rel="noopener noreferrer" style="display:inline-flex;align-items:center;color:inherit;text-decoration:none;"><img src="/static/favicon/web-app-manifest-512x512.png" alt="" style="height:1.1em;width:auto;vertical-align:middle;margin-right:.4em;" />Steuer<span>kram</span></a></div>
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-label">Übersicht</div> <div class="sidebar-label">Übersicht</div>

View file

@ -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>

View file

@ -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',

View file

@ -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',

View file

@ -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([

View file

@ -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)'
); );
} }
} }

View file

@ -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([

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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,
]); ]);

View file

@ -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([

View file

@ -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',
]); ]);