From 4f9d51ff05daca185c3d9fca90763224cdfedb49 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 12 Apr 2026 10:11:36 +0200 Subject: [PATCH 01/14] Fix: Tests an aktuelle DB-Schema angepasst (eingangs_datum entfernt, bewirtungsfelder auf belege, steuercode-Filter) --- tests/01_BelegUploadTest.php | 26 ++++++------ tests/11_KontierungsvorschlagTest.php | 4 +- tests/16_BewirtungssplitTest.php | 60 +++++++++++++-------------- tests/17_AutomatikkontenUstTest.php | 17 ++++---- 4 files changed, 52 insertions(+), 55 deletions(-) diff --git a/tests/01_BelegUploadTest.php b/tests/01_BelegUploadTest.php index 8a88d53..3f5f294 100644 --- a/tests/01_BelegUploadTest.php +++ b/tests/01_BelegUploadTest.php @@ -18,17 +18,16 @@ class BelegUploadTest extends TestCase $datum = date('Y-m-d'); $id = \Steuerkram\Models\Beleg::create([ - 'beleg_nr' => $belegNr, - 'filename' => 'test-rechnung.pdf', - 'storage_path' => 'static/uploads/test/test-rechnung.pdf', - 'mime_type' => 'application/pdf', - 'file_hash' => hash('sha256', $belegNr), - 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, - 'rechnungs_nr' => 'RE-TEST-001', - 'betrag_brutto' => 119.00, - 'belegtyp' => 'Eingangsrechnung', - 'notes' => 'Automatisch erstellt (Test)', + 'beleg_nr' => $belegNr, + 'filename' => 'test-rechnung.pdf', + 'storage_path' => 'static/uploads/test/test-rechnung.pdf', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', $belegNr), + 'beleg_datum' => $datum, + 'rechnungs_nr' => 'RE-TEST-001', + 'betrag_brutto' => 119.00, + 'belegtyp' => 'Eingangsrechnung', + 'notes' => 'Automatisch erstellt (Test)', ]); $this->assertGreaterThan(0, $id, 'Beleg wurde in der DB angelegt (ID > 0)'); @@ -51,7 +50,8 @@ class BelegUploadTest extends TestCase $this->assert($found, 'Beleg erscheint in Beleg::all() (Belegübersicht)'); $this->assertGreaterThan(0, $result['total'], 'Belegübersicht enthält mindestens 1 Eintrag'); - // Betrag korrekt gespeichert - $this->assertEquals(119.00, (float)($beleg['betrag_brutto'] ?? 0), 'Betrag brutto korrekt gespeichert'); + // Betrag korrekt gespeichert (direkt aus belege, nicht die berechnete Buchungssumme) + $raw = \Steuerkram\Database::fetchOne('SELECT betrag_brutto FROM belege WHERE id = ?', [$id]); + $this->assertEquals(119.00, (float)($raw['betrag_brutto'] ?? 0), 'Betrag brutto korrekt gespeichert'); } } diff --git a/tests/11_KontierungsvorschlagTest.php b/tests/11_KontierungsvorschlagTest.php index 3b0df11..0d44082 100644 --- a/tests/11_KontierungsvorschlagTest.php +++ b/tests/11_KontierungsvorschlagTest.php @@ -50,9 +50,9 @@ class KontierungsvorschlagTest extends TestCase private function createTestBeleg(): int { Database::execute( - "INSERT INTO belege (beleg_nr, filename, storage_path, mime_type, file_hash, eingangs_datum, belegtyp) + "INSERT INTO belege (beleg_nr, filename, storage_path, mime_type, file_hash, belegtyp) VALUES ('TEST-KI-0001', 'test.pdf', 'static/uploads/test/test.pdf', 'application/pdf', - SHA2('test', 256), CURDATE(), 'Eingangsrechnung')" + SHA2('test', 256), 'Eingangsrechnung')" ); return (int)Database::lastInsertId(); } diff --git a/tests/16_BewirtungssplitTest.php b/tests/16_BewirtungssplitTest.php index 6d3bbcd..172aa6f 100644 --- a/tests/16_BewirtungssplitTest.php +++ b/tests/16_BewirtungssplitTest.php @@ -31,15 +31,18 @@ class BewirtungssplitTest extends TestCase // ── Test 1: Split bei Konto 4650 ───────────────────────────── $beleg1 = \Steuerkram\Models\Beleg::create([ - 'beleg_nr' => 'TEST-BEW-4650-' . uniqid(), - 'filename' => 'test.pdf', - 'storage_path' => '', - 'mime_type' => 'application/pdf', - 'file_hash' => hash('sha256', 'bew4650' . uniqid()), - 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, - 'belegtyp' => 'Eingangsrechnung', + 'beleg_nr' => 'TEST-BEW-4650-' . uniqid(), + 'filename' => 'test.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'bew4650' . uniqid()), + 'beleg_datum' => $datum, + 'belegtyp' => 'Eingangsrechnung', ]); + \Steuerkram\Database::execute( + 'UPDATE belege SET bewirtungsgrund = ?, teilnehmerliste = ? WHERE id = ?', + ['Kundengespräch', 'Max Mustermann, Erika Mustermann', $beleg1] + ); // Bewirtungssplit direkt über Booking::create + zweite Buchung simulieren // (wie BookingController::bucheBewirtungssplit() es tut) @@ -57,8 +60,6 @@ class BewirtungssplitTest extends TestCase 'betrag_netto' => $netto70, 'ust_satz' => $ustSatz, 'buchungstext' => 'Bewirtung Test 70%', - 'bewirtungsgrund' => 'Kundengespräch', - 'teilnehmerliste' => 'Max Mustermann, Erika Mustermann', ]); $id1b = \Steuerkram\Models\Booking::create([ @@ -70,8 +71,6 @@ class BewirtungssplitTest extends TestCase 'betrag_netto' => $netto30, 'ust_satz' => $ustSatz, 'buchungstext' => 'Bewirtung Test 30%', - 'bewirtungsgrund' => 'Kundengespräch', - 'teilnehmerliste' => 'Max Mustermann, Erika Mustermann', ]); $b1a = \Steuerkram\Models\Booking::find($id1a); @@ -95,20 +94,20 @@ class BewirtungssplitTest extends TestCase 'Gesamt-Vorsteuer korrekt (100% des Nettobetrags)' ); - // Bewirtungsfelder gespeichert - $this->assertEquals('Kundengespräch', $b1a['bewirtungsgrund'], 'Bewirtungsgrund gespeichert'); - $this->assertEquals('Max Mustermann, Erika Mustermann', $b1a['teilnehmerliste'], 'Teilnehmerliste gespeichert'); + // Bewirtungsfelder auf dem Beleg gespeichert (seit Migration 024 in belege, nicht bookings) + $belegDaten = \Steuerkram\Models\Beleg::find($beleg1); + $this->assertEquals('Kundengespräch', $belegDaten['bewirtungsgrund'], 'Bewirtungsgrund gespeichert'); + $this->assertEquals('Max Mustermann, Erika Mustermann', $belegDaten['teilnehmerliste'], 'Teilnehmerliste gespeichert'); // ── Test 2: Split bei Konto 4651 ───────────────────────────── $beleg2 = \Steuerkram\Models\Beleg::create([ - 'beleg_nr' => 'TEST-BEW-4651-' . uniqid(), - 'filename' => 'test2.pdf', - 'storage_path' => '', - 'mime_type' => 'application/pdf', - 'file_hash' => hash('sha256', 'bew4651' . uniqid()), - 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, - 'belegtyp' => 'Eingangsrechnung', + 'beleg_nr' => 'TEST-BEW-4651-' . uniqid(), + 'filename' => 'test2.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'bew4651' . uniqid()), + 'beleg_datum' => $datum, + 'belegtyp' => 'Eingangsrechnung', ]); $id2a = \Steuerkram\Models\Booking::create([ @@ -149,14 +148,13 @@ class BewirtungssplitTest extends TestCase $bankCheck = $this->findAccountId('4600'); if ($bankCheck) { $beleg3 = \Steuerkram\Models\Beleg::create([ - 'beleg_nr' => 'TEST-BEW-NORMAL-' . uniqid(), - 'filename' => 'test3.pdf', - 'storage_path' => '', - 'mime_type' => 'application/pdf', - 'file_hash' => hash('sha256', 'normal' . uniqid()), - 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, - 'belegtyp' => 'Eingangsrechnung', + 'beleg_nr' => 'TEST-BEW-NORMAL-' . uniqid(), + 'filename' => 'test3.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'normal' . uniqid()), + 'beleg_datum' => $datum, + 'belegtyp' => 'Eingangsrechnung', ]); $idNormal = \Steuerkram\Models\Booking::create([ diff --git a/tests/17_AutomatikkontenUstTest.php b/tests/17_AutomatikkontenUstTest.php index 8a7077b..9cdf35c 100644 --- a/tests/17_AutomatikkontenUstTest.php +++ b/tests/17_AutomatikkontenUstTest.php @@ -37,7 +37,7 @@ class AutomatikkontenUstTest extends TestCase $mapping = ['VSt19' => 19, 'USt19' => 19, 'VSt7' => 7, 'USt7' => 7]; $kontenMitSteuercode = \Steuerkram\Database::fetchAll( - "SELECT kontonummer, steuercode FROM accounts WHERE steuercode IS NOT NULL LIMIT 20" + "SELECT kontonummer, steuercode FROM accounts WHERE steuercode IS NOT NULL AND steuercode != '' LIMIT 20" ); $this->assertNotEmpty($kontenMitSteuercode, 'Konten mit Steuercode vorhanden'); @@ -60,14 +60,13 @@ class AutomatikkontenUstTest extends TestCase } $belegId = \Steuerkram\Models\Beleg::create([ - 'beleg_nr' => 'TEST-AUTOUST-' . uniqid(), - 'filename' => 'test.pdf', - 'storage_path' => '', - 'mime_type' => 'application/pdf', - 'file_hash' => hash('sha256', 'autoust' . uniqid()), - 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, - 'belegtyp' => 'Eingangsrechnung', + 'beleg_nr' => 'TEST-AUTOUST-' . uniqid(), + 'filename' => 'test.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'autoust' . uniqid()), + 'beleg_datum' => $datum, + 'belegtyp' => 'Eingangsrechnung', ]); $bookingId = \Steuerkram\Models\Booking::create([ From efdca6fbc39c0239cf84374b57afd77f317310dd Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 12 Apr 2026 19:20:15 +0200 Subject: [PATCH 02/14] Docs: README verschlankt, docs/-Verzeichnis mit Unterseiten je Bereich --- CLAUDE.md | 6 +- README.md | 405 ++---------------------- docs/anlagevermögen.md | 69 ++++ docs/automatisierung.md | 50 +++ BERICHTE.md => docs/berechnungslogik.md | 0 docs/funktionen.md | 91 ++++++ docs/installation.md | 140 ++++++++ docs/konfiguration.md | 88 +++++ Kontenrahmen.md => docs/kontenrahmen.md | 0 docs/update-backup.md | 106 +++++++ 10 files changed, 583 insertions(+), 372 deletions(-) create mode 100644 docs/anlagevermögen.md create mode 100644 docs/automatisierung.md rename BERICHTE.md => docs/berechnungslogik.md (100%) create mode 100644 docs/funktionen.md create mode 100644 docs/installation.md create mode 100644 docs/konfiguration.md rename Kontenrahmen.md => docs/kontenrahmen.md (100%) create mode 100644 docs/update-backup.md diff --git a/CLAUDE.md b/CLAUDE.md index 7ee6bb9..9930a72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,8 +42,10 @@ $this->assertNotEmpty($wert, 'Beschreibung'); | Datei | Wann aktualisieren | |---|---| | `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 | -| `BERICHTE.md` | Bei jeder Änderung an `EuerCalculator.php` oder `UstvaCalculator.php` (Zuordnungsregeln, neue Kennzahlen, geänderte Bedingungen) | +| `README.md` | Wenn sich der Funktionsumfang oder der Tech Stack grundlegend ändert | +| `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 diff --git a/README.md b/README.md index 63cbf1f..7c0f9b1 100644 --- a/README.md +++ b/README.md @@ -1,396 +1,61 @@ # 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 -- **Kontenrahmen SKR03** – DATEV-konformer SKR03 mit 157 Systemkonten (Klassen 0, 1, 2, 4, 8); zugeschnitten auf EÜR-Buchhalter ohne Bilanzierung (CRUD) -- **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 -- **Einstellungen** – Firmendaten, Logo, ELSTER-Steuernummer -- **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 | +| Bereich | Beschreibung | |---|---| -| Webserver | Apache 2.4 | -| Backend | PHP 8.3 (MVC, kein Framework) | -| Datenbank | MariaDB 11 | -| PDF-Generierung | mPDF 8.x | -| Frontend | HTML, CSS, JavaScript (keine Bibliotheken) | -| Deployment | Docker / Docker Compose | +| **Kontenrahmen SKR03** | DATEV-konform, 157 Konten, mit UStVA-Kennzahlen und EÜR-Zeilennummern | +| **Belegverwaltung** | PDF, PNG, TIFF, ZUGFeRD-XML; SHA-256 Integritätsprüfung; optionale KI-Kontierung | +| **Buchungen** | EÜR-Modell (Einnahme/Ausgabe); GoBD-konforme Trigger; Stornobuchungen; Journal | +| **Anlagevermögen** | Anlagenspiegel; automatische Anlagen-Nummerierung; Buchungsworkflow mit Asset-Verknüpfung | +| **Ausgangsrechnungen** | DIN 5008, PDF-Export via mPDF | +| **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 -- [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: +## Schnellstart ```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. 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 +cd /opt/Steuerkram docker compose up -d ``` ---- - -### Manuelle Installation - -#### 1. Repository klonen - -```bash -git clone steuerkram -cd steuerkram -``` - -#### 2. Konfiguration anpassen - -Kopiere `.env.example` nach `.env` und passe die Werte an: - -```bash -cp .env.example .env -``` - -```dotenv -DB_PASS=sicheres-passwort # Datenbankpasswort – in docker-compose.yml spiegeln -APP_SECRET=zufaelliger-string # Zufälligen langen String eintragen (für Sessions) -``` - -Außerdem in `docker-compose.yml` das `MARIADB_PASSWORD` im `db`-Service auf denselben Wert wie `DB_PASS` setzen. - -#### 3. Container starten - -```bash -docker compose up -d -``` - -Beim ersten Start werden automatisch: -- die Datenbank initialisiert (`migrations/001_initial_schema.sql`) -- der SKR03-Kontenrahmen eingespielt (`migrations/002_seed_skr03.sql`) -- das Docker-Image mit PHP-Abhängigkeiten (mPDF) gebaut - -#### 4. Anwendung aufrufen - -``` -http://localhost:8080 -``` - -Beim ersten Aufruf erscheint die **Ersteinrichtung**: dort den ersten Benutzer-Account anlegen. Danach ist die Anwendung passwortgeschützt. - -Weitere Benutzer können nach dem Login unter **Benutzer** in der Seitenleiste verwaltet werden. - -#### 5. KI-Beleganalyse einrichten (optional) - -Die KI-gestützte Belegerkennung ist standardmäßig deaktiviert. Sie kann unter **Einstellungen → KI-Kontierungsvorschlag** aktiviert werden, sobald ein LLM erreichbar ist. - -**Option A – Lokales Ollama** (kein API-Schlüssel nötig, aber hoher Ressourcenbedarf): - -```bash -ollama pull mistral # oder llama3.2 etc. -``` - -In `.env` ist keine weitere Konfiguration nötig – die App erkennt Ollama auf `localhost:11434` automatisch. Modell und URL können optional überschrieben werden: - -```dotenv -OLLAMA_URL=http://localhost:11434 -OLLAMA_MODEL=mistral -``` - -**Option B – Remote-API** (OpenAI-kompatibel, z. B. OpenAI, OpenRouter, LM Studio): - -```dotenv -LLM_API_URL=https://api.openai.com/v1 -LLM_API_KEY=sk-... -LLM_MODEL=gpt-4o-mini -``` - -> **Hinweis:** Die Ergebnisqualität hängt stark vom gewählten Modell ab. Tests mit `mistral` (lokal via Ollama) lieferten bisher mäßige Ergebnisse. - -#### 6. Firmendaten hinterlegen - -Nach dem ersten Login unter **Einstellungen** Firmendaten, Logo und ELSTER-Steuernummer eintragen. Diese Angaben erscheinen auf Ausgangsrechnungen und im EÜR-Export. - -## Port ändern - -Der Standard-Port ist `8080`. Um ihn zu ändern, passe in `docker-compose.yml` die `ports`-Zeile an: - -```yaml -ports: - - "9090:80" # Beispiel: Port 9090 auf dem Host -``` - -## Upload-Limit anpassen - -Die maximale Dateigröße für Beleganhänge ist auf **20 MB** voreingestellt (`php/uploads.ini`). Um das Limit zu ändern, die Werte in dieser Datei anpassen und den Container neu bauen: - -```ini -upload_max_filesize = 20M -post_max_size = 20M -``` - -```bash -docker compose up -d --build -``` - -## Belege automatisch aus Nextcloud importieren - -Belege können automatisch aus einem Nextcloud-Ordner via WebDAV importiert werden. Das CLI-Skript `scripts/nextcloud-import.php` liest alle unterstützten Dateien (PDF, JPEG, PNG, TIFF, XML) aus dem konfigurierten Ordner, legt sie als Belege an und verschiebt sie anschließend in einen Erledigte-Ordner (oder löscht sie, wenn kein Ziel-Ordner konfiguriert ist). - -### Konfiguration - -Die Zugangsdaten werden in der Steuerkram-UI gepflegt: **Einstellungen → Nextcloud-Belegimport**. - -Empfehlung: Ein Nextcloud-App-Passwort verwenden (Nextcloud → Einstellungen → Sicherheit → App-Passwörter). - -### Manueller Aufruf - -```bash -docker compose exec web php scripts/nextcloud-import.php -``` - -### Automatisch per Cron (auf dem Host) - -```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 Skript gibt beim Start eine Zusammenfassung aus (importiert / übersprungen / Fehler) und beendet sich mit Exit-Code 1 bei Fehlern. - -## Datenpersistenz - -| Pfad | Inhalt | -|---|---| -| `./data/mariadb/` | MariaDB-Datenbankdateien | -| Docker Volume `uploads` | Hochgeladene Belege | - -Beide Pfade liegen außerhalb des Containers und bleiben bei `docker compose down` erhalten. - -## Systemd-Service einrichten - -Damit Steuerkram automatisch beim Systemstart startet und als Dienst verwaltet werden kann, liegt im Repository die Datei `steuerkram.service`. - -#### 1. Service-Datei installieren - -```bash -sudo cp steuerkram.service /etc/systemd/system/steuerkram.service -``` - -Die Datei setzt `WorkingDirectory=/opt/Steuerkram` voraus. Falls das Repository in ein anderes Verzeichnis geklont wurde, den Pfad in der Service-Datei anpassen: - -```bash -sudo nano /etc/systemd/system/steuerkram.service -# WorkingDirectory= auf das tatsächliche Installationsverzeichnis setzen -``` - -#### 2. Service aktivieren und starten - -```bash -sudo systemctl daemon-reload -sudo systemctl enable steuerkram # automatisch beim Systemstart -sudo systemctl start steuerkram -``` - -#### 3. Status prüfen - -```bash -sudo systemctl status steuerkram -journalctl -u steuerkram -f # Live-Log inkl. Migrations-Ausgabe -``` - -#### Wichtige Service-Befehle - -| Befehl | Wirkung | -|---|---| -| `sudo systemctl start steuerkram` | Container starten | -| `sudo systemctl stop steuerkram` | Container stoppen | -| `sudo systemctl restart steuerkram` | Neustart (z.B. nach Update) | -| `sudo systemctl status steuerkram` | Aktueller Status | - -## Backup - -```bash -bash scripts/backup.sh -``` - -Das Skript erstellt ein Dump der Datenbank sowie ein Archiv der Uploads im Verzeichnis `backups/`. - -## Update - -Seit dem 11.04.2026 bin ich mit meiner Software für mich selber produktiv. Ab diesem Zeitpunkt sollten keine breaking changes mehr vorkommen. - -Datenbankmigrationen werden **automatisch** beim nächsten Container-Start eingespielt – kein manueller Eingriff nötig. Das Script `scripts/migrate.php` prüft beim Start, welche Migrations-Dateien noch nicht angewendet wurden, und führt sie der Reihe nach aus. - -### Vollständige Update-Anleitung - -Die folgenden Schritte sichern einen vollständigen und sicheren Update-Ablauf. Alle Befehle im Steuerkram-Verzeichnis ausführen. - -#### Schritt 1 – Datenbank sichern - -Vor jedem Update ein Backup erstellen: - -```bash -bash scripts/backup.sh -``` - -Das Backup landet in `backups/`. Alternativ manuell: - -```bash -docker compose exec db mariadb-dump -u steuerkram -psteuerkram steuerkram \ - > ~/steuerkram_backup_$(date +%Y%m%d_%H%M).sql -``` - -#### Schritt 2 – Aktuellen Code holen - -```bash -git pull -``` - -Prüfen, ob neue Migrations-Dateien enthalten sind: - -```bash -git log --oneline -5 -``` - -#### Schritt 3 – Container neu bauen und starten - -```bash -docker compose up -d --build -``` - -Dieser Befehl baut das PHP-Image neu (falls sich `Dockerfile` oder `composer.json` geändert haben) und startet alle Container. Der Web-Container führt beim Start automatisch `scripts/migrate.php` aus. - -#### Schritt 4 – Migrationen prüfen - -```bash -docker compose logs web | grep migrate -``` - -Erwartete Ausgabe bei erfolgreicher Migration: - -``` -[migrate] Starte Migrations-Prüfung... -[migrate] Führe aus: NNN_beschreibung.sql -[migrate] N Migration(en) erfolgreich angewendet. -``` - -Oder wenn alles bereits aktuell ist: - -``` -[migrate] Datenbank ist aktuell – keine Migrationen notwendig. -``` - -Falls die Ausgabe einen Fehler enthält, das Backup aus Schritt 1 einspielen: - -```bash -docker compose exec -T db mariadb -u steuerkram -psteuerkram steuerkram \ - < ~/steuerkram_backup_YYYYMMDD_HHMM.sql -``` - -#### Schritt 5 – Anwendung prüfen - -```bash -docker compose ps # alle Container müssen "running" sein -docker compose logs web # keine PHP-Fehler oder Exceptions -``` - -Anschließend im Browser unter `http://localhost:8080` (oder dem konfigurierten Port) einloggen und stichprobenartig Belege und Buchungen prüfen. +Anwendung aufrufen: `http://localhost:8080` --- -#### Ohne Systemd-Service (Kurzform) +## Dokumentation -```bash -bash scripts/backup.sh -git pull -docker compose up -d --build -docker compose logs web | grep migrate -``` +| Dokument | Inhalt | +|---|---| +| [Installation](docs/installation.md) | Docker-Setup, Systemd-Service, Port, Upload-Limit | +| [Konfiguration](docs/konfiguration.md) | Firmendaten, Nummernkreise, KI-Beleganalyse, Nextcloud-Import | +| [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 | +| [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 - Gebuchte Sätze können nicht geändert oder gelöscht werden (MariaDB `BEFORE UPDATE/DELETE` Trigger). - Korrekturen erfolgen ausschließlich über Stornobuchungen. - 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. diff --git a/docs/anlagevermögen.md b/docs/anlagevermögen.md new file mode 100644 index 0000000..e196242 --- /dev/null +++ b/docs/anlagevermögen.md @@ -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 diff --git a/docs/automatisierung.md b/docs/automatisierung.md new file mode 100644 index 0000000..995543c --- /dev/null +++ b/docs/automatisierung.md @@ -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. diff --git a/BERICHTE.md b/docs/berechnungslogik.md similarity index 100% rename from BERICHTE.md rename to docs/berechnungslogik.md diff --git a/docs/funktionen.md b/docs/funktionen.md new file mode 100644 index 0000000..35e30ca --- /dev/null +++ b/docs/funktionen.md @@ -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 diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..53416a8 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,140 @@ +# 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 steuerkram +cd steuerkram +``` + +### 2. Konfiguration anpassen + +```bash +cp .env.example .env +``` + +```dotenv +DB_PASS=sicheres-passwort # Datenbankpasswort – in docker-compose.yml spiegeln +APP_SECRET=zufaelliger-string # Zufälligen langen String eintragen (für Sessions) +``` + +Außerdem in `docker-compose.yml` das `MARIADB_PASSWORD` im `db`-Service auf denselben Wert wie `DB_PASS` setzen. + +### 3. Container starten + +```bash +docker compose up -d +``` + +Beim ersten Start werden automatisch: +- die Datenbank initialisiert +- 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 | diff --git a/docs/konfiguration.md b/docs/konfiguration.md new file mode 100644 index 0000000..d73d895 --- /dev/null +++ b/docs/konfiguration.md @@ -0,0 +1,88 @@ +# 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-Beleganalyse einrichten + +Die KI-gestützte Belegerkennung ist standardmäßig deaktiviert. Sie kann unter **Einstellungen → KI-Kontierungsvorschlag** aktiviert werden, sobald ein LLM erreichbar ist. + +### Option A – Lokales Ollama + +Kein API-Schlüssel nötig, aber hoher Ressourcenbedarf: + +```bash +ollama pull mistral # oder llama3.2 etc. +``` + +In `.env` ist keine weitere Konfiguration nötig – die App erkennt Ollama auf `localhost:11434` automatisch. Modell und URL können optional überschrieben werden: + +```dotenv +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=mistral +``` + +### Option B – Remote-API (OpenAI-kompatibel) + +z.B. OpenAI, OpenRouter, LM Studio: + +```dotenv +LLM_API_URL=https://api.openai.com/v1 +LLM_API_KEY=sk-... +LLM_MODEL=gpt-4o-mini +``` + +> **Hinweis:** Die Ergebnisqualität hängt stark vom gewählten Modell ab. Tests mit `mistral` (lokal via Ollama) lieferten bisher mäßige Ergebnisse. + +--- + +## 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**. + +Empfehlung: Ein Nextcloud-App-Passwort verwenden (Nextcloud → Einstellungen → Sicherheit → App-Passwörter). + +### Manueller Aufruf + +```bash +docker compose exec web php scripts/nextcloud-import.php +``` + +### Automatisch per Cron + +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 +``` diff --git a/Kontenrahmen.md b/docs/kontenrahmen.md similarity index 100% rename from Kontenrahmen.md rename to docs/kontenrahmen.md diff --git a/docs/update-backup.md b/docs/update-backup.md new file mode 100644 index 0000000..3d7078a --- /dev/null +++ b/docs/update-backup.md @@ -0,0 +1,106 @@ +# 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 + +Datenbankmigrationen werden **automatisch** beim nächsten Container-Start eingespielt. Das Script `scripts/migrate.php` prüft beim Start, welche Migrations-Dateien noch nicht angewendet wurden, und führt sie der Reihe nach aus. + +### Vollständige Update-Anleitung + +#### Schritt 1 – Datenbank sichern + +```bash +bash scripts/backup.sh +``` + +#### Schritt 2 – Aktuellen Code holen + +```bash +git pull +``` + +#### Schritt 3 – Container neu bauen und starten + +```bash +docker compose up -d --build +``` + +Dieser Befehl baut das PHP-Image neu (falls sich `Dockerfile` oder `composer.json` geändert haben) und startet alle Container. Der Web-Container führt beim Start automatisch `scripts/migrate.php` aus. + +#### Schritt 4 – Migrationen prüfen + +```bash +docker compose logs web | grep migrate +``` + +Erwartete Ausgabe: + +``` +[migrate] Starte Migrations-Prüfung... +[migrate] Führe aus: NNN_beschreibung.sql +[migrate] N Migration(en) erfolgreich angewendet. +``` + +Falls ein Fehler auftritt, das Backup aus Schritt 1 einspielen: + +```bash +docker compose exec -T db mariadb -u steuerkram -psteuerkram steuerkram \ + < ~/steuerkram_backup_YYYYMMDD_HHMM.sql +``` + +#### Schritt 5 – Anwendung prüfen + +```bash +docker compose ps # alle Container müssen "running" sein +docker compose logs web # keine PHP-Fehler oder Exceptions +``` + +--- + +### Kurzform ohne Systemd + +```bash +bash scripts/backup.sh && git pull && docker compose up -d --build +docker compose logs web | grep migrate +``` + +### Kurzform mit Systemd + +```bash +bash scripts/backup.sh && git pull && docker compose build +sudo systemctl restart steuerkram +journalctl -u steuerkram | grep migrate +``` From adaf39ebbc01aa6f64392aed003df2e86350a336 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 12 Apr 2026 19:36:22 +0200 Subject: [PATCH 03/14] =?UTF-8?q?Cleanup:=20APP=5FSECRET=20entfernt=20?= =?UTF-8?q?=E2=80=93=20war=20nie=20implementiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 --- docs/installation.md | 1 - src/Config.php | 1 - 3 files changed, 5 deletions(-) diff --git a/.env.example b/.env.example index 9c0fad0..7dcf14c 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,6 @@ DB_NAME=steuerkram DB_USER=steuerkram DB_PASS=steuerkram -# App -APP_SECRET=change-this-to-a-random-string - # KI-Kontierungsvorschlag (optional) # Wenn nichts konfiguriert wird, sucht die App automatisch nach einem lokalen Ollama auf localhost:11434. # diff --git a/docs/installation.md b/docs/installation.md index 53416a8..f4555c9 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -47,7 +47,6 @@ cp .env.example .env ```dotenv DB_PASS=sicheres-passwort # Datenbankpasswort – in docker-compose.yml spiegeln -APP_SECRET=zufaelliger-string # Zufälligen langen String eintragen (für Sessions) ``` Außerdem in `docker-compose.yml` das `MARIADB_PASSWORD` im `db`-Service auf denselben Wert wie `DB_PASS` setzen. diff --git a/src/Config.php b/src/Config.php index 42020c7..d6c5c76 100644 --- a/src/Config.php +++ b/src/Config.php @@ -15,7 +15,6 @@ class Config 'db_name' => $_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: 'steuerkram', 'db_user' => $_ENV['DB_USER'] ?? getenv('DB_USER') ?: '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', 'base_url' => '', ]; From 7e2af89748713d419a8c0b8fa18a8f6a083a9adf Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 12 Apr 2026 19:55:51 +0200 Subject: [PATCH 04/14] Docs: Datenbankstruktur dokumentiert --- README.md | 1 + docs/datenbankstruktur.md | 284 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 docs/datenbankstruktur.md diff --git a/README.md b/README.md index 7c0f9b1..98a04a9 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Anwendung aufrufen: `http://localhost:8080` | [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 | diff --git a/docs/datenbankstruktur.md b/docs/datenbankstruktur.md new file mode 100644 index 0000000..b823d68 --- /dev/null +++ b/docs/datenbankstruktur.md @@ -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. From 110dd5ade46fa6260432c3498eea86d5fd3d2dfe Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 12 Apr 2026 21:50:32 +0200 Subject: [PATCH 05/14] =?UTF-8?q?Docs:=20Klasse-0-Konten=20als=20Anlagever?= =?UTF-8?q?m=C3=B6gen=20klassifiziert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Changes.md | 8 ++++++++ docs/kontenrahmen.md | 22 +++++++++++----------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/Changes.md b/Changes.md index d3aba7c..61374be 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,14 @@ --- +## 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 ### Geändert diff --git a/docs/kontenrahmen.md b/docs/kontenrahmen.md index 06ac265..4455d0c 100644 --- a/docs/kontenrahmen.md +++ b/docs/kontenrahmen.md @@ -15,7 +15,7 @@ Der kanonische Seed liegt in `migrations/018_seed_kontenrahmen_skr03_final.sql`. | Spalte | Bedeutung | |---|---| | `kontonummer` | DATEV-Kontonummer | -| `kontenart` | `Betriebseinnahme`, `Betriebsausgabe`, `Zahlungskonto`, `Privat` | +| `kontenart` | `Betriebseinnahme`, `Betriebsausgabe`, `Anlagevermögen`, `Zahlungskonto`, `Privat` | | `steuercode` | `USt19`, `USt7`, `VSt19`, `VSt7` oder leer | | `ustva_kz` | ELSTER-Kennzahl für die UStVA (z.B. `81`, `66`) | | `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 | |---|---|---|---|---|---| -| 0027 | EDV-Software | Zahlungskonto | – | – | – | -| 0030 | Lizenzen an gewerblichen Schutzrechten | Zahlungskonto | – | – | – | -| 0210 | Maschinen | Zahlungskonto | – | – | – | -| 0320 | Pkw | Zahlungskonto | – | – | – | -| 0350 | Lkw | Zahlungskonto | – | – | – | -| 0400 | Betriebsausstattung | Zahlungskonto | – | – | – | -| 0410 | Geschäftsausstattung | Zahlungskonto | – | – | – | -| 0420 | Büroeinrichtung | Zahlungskonto | – | – | – | -| 0440 | Werkzeuge | Zahlungskonto | – | – | – | -| 0480 | Geringwertige Wirtschaftsgüter | Zahlungskonto | – | – | – | +| 0027 | EDV-Software | Anlagevermögen | – | – | – | +| 0030 | Lizenzen an gewerblichen Schutzrechten | Anlagevermögen | – | – | – | +| 0210 | Maschinen | Anlagevermögen | – | – | – | +| 0320 | Pkw | Anlagevermögen | – | – | – | +| 0350 | Lkw | Anlagevermögen | – | – | – | +| 0400 | Betriebsausstattung | Anlagevermögen | – | – | – | +| 0410 | Geschäftsausstattung | Anlagevermögen | – | – | – | +| 0420 | Büroeinrichtung | Anlagevermögen | – | – | – | +| 0440 | Werkzeuge | Anlagevermögen | – | – | – | +| 0480 | Geringwertige Wirtschaftsgüter | Anlagevermögen | – | – | – | --- From 5eca18a918129fad1b931bef3b7d05f73da11dcd Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 12 Apr 2026 22:24:17 +0200 Subject: [PATCH 06/14] =?UTF-8?q?Fix:=20Gewerbesteuer=20(4320)=20in=20KZ?= =?UTF-8?q?=20165,=20Bewirtungskonten=204652/4657=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Changes.md | 9 +++++++++ docs/kontenrahmen.md | 8 +++++--- migrations/028_euer_4320_bewirtung_ohne_vst.sql | 17 +++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 migrations/028_euer_4320_bewirtung_ohne_vst.sql diff --git a/Changes.md b/Changes.md index 61374be..68048db 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,15 @@ --- +## 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 diff --git a/docs/kontenrahmen.md b/docs/kontenrahmen.md index 4455d0c..ab575ac 100644 --- a/docs/kontenrahmen.md +++ b/docs/kontenrahmen.md @@ -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 | | 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) @@ -224,10 +225,12 @@ Der kanonische Seed liegt in `migrations/018_seed_kontenrahmen_skr03_final.sql`. | Kto. | Bezeichnung | Stcode | UStVA | EÜR | |---|---|---|---|---| | 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 | | 4636 | Geschenke nicht abzugsfähig mit § 37b EStG | VSt19 | 66 | 51 | | 4654 | Nicht abzugsfähige Bewirtungskosten VSt. 19% | VSt19 | 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 | > 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 | | 4301 | Nicht abziehbare Vorsteuer 7 % | – | – | 52 | | 4306 | Nicht abziehbare Vorsteuer 19 % | – | – | 52 | -| 4320 | Gewerbesteuer | – | – | 52 | | 4340 | Sonstige Betriebssteuern | – | – | 52 | | 4350 | Verbrauchsteuer (sonstige Steuern) | – | – | 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 | | 49 | AfA Sachanlagen und immaterielle Wirtschaftsgüter | | 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 | | 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) | diff --git a/migrations/028_euer_4320_bewirtung_ohne_vst.sql b/migrations/028_euer_4320_bewirtung_ohne_vst.sql new file mode 100644 index 0000000..5c6a244 --- /dev/null +++ b/migrations/028_euer_4320_bewirtung_ohne_vst.sql @@ -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); From 56b1cebfac8122e43fb74201a35f3754456ce4d3 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 12 Apr 2026 22:58:08 +0200 Subject: [PATCH 07/14] =?UTF-8?q?Cleanup:=20eingangs=5Fdatum=20aus=20Tests?= =?UTF-8?q?=20entfernt,=20Storno-Doku=20pr=C3=A4zisiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/berechnungslogik.md | 2 +- tests/02_BelegBuchungTest.php | 1 - tests/04_InvoiceBuchungTest.php | 1 - tests/05_TaxSummenTest.php | 2 -- tests/06_EuerBuchungsTest.php | 2 -- tests/07_UstvaAuslandTest.php | 4 ---- tests/08_LieferantTest.php | 1 - tests/09_InvoiceToReportsTest.php | 1 - tests/10_IstVersteuerungTest.php | 1 - tests/12_BelegGebuechtStatusTest.php | 1 - tests/15_NichtAbzugsfaehigEuerTest.php | 3 --- tests/18_StornoBuchungTest.php | 1 - 12 files changed, 1 insertion(+), 19 deletions(-) diff --git a/docs/berechnungslogik.md b/docs/berechnungslogik.md index b84bb83..9d4cd07 100644 --- a/docs/berechnungslogik.md +++ b/docs/berechnungslogik.md @@ -9,7 +9,7 @@ Dieses Dokument beschreibt, wie Buchungen in die beiden steuerlichen Berichte ei **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 diff --git a/tests/02_BelegBuchungTest.php b/tests/02_BelegBuchungTest.php index 7e82bcc..e8c6bd0 100644 --- a/tests/02_BelegBuchungTest.php +++ b/tests/02_BelegBuchungTest.php @@ -47,7 +47,6 @@ class BelegBuchungTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', $belegNr . 'buch'), 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, 'rechnungs_nr' => 'RE-BUCH-001', 'betrag_brutto' => 59.50, 'belegtyp' => 'Eingangsrechnung', diff --git a/tests/04_InvoiceBuchungTest.php b/tests/04_InvoiceBuchungTest.php index 56d3b1f..bad1962 100644 --- a/tests/04_InvoiceBuchungTest.php +++ b/tests/04_InvoiceBuchungTest.php @@ -74,7 +74,6 @@ class InvoiceBuchungTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', $nr), 'beleg_datum' => $invoice['rechnungs_datum'], - 'eingangs_datum' => $invoice['rechnungs_datum'], 'rechnungs_nr' => $nr, 'betrag_brutto' => $invoice['betrag_brutto'], 'belegtyp' => 'Ausgangsrechnung', diff --git a/tests/05_TaxSummenTest.php b/tests/05_TaxSummenTest.php index 766d8f5..ef35c1c 100644 --- a/tests/05_TaxSummenTest.php +++ b/tests/05_TaxSummenTest.php @@ -66,7 +66,6 @@ class TaxSummenTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', 'test-ar'), 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, 'belegtyp' => 'Ausgangsrechnung', ]); \Steuerkram\Models\Booking::create([ @@ -89,7 +88,6 @@ class TaxSummenTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', 'test-er'), 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, 'belegtyp' => 'Eingangsrechnung', ]); \Steuerkram\Models\Booking::create([ diff --git a/tests/06_EuerBuchungsTest.php b/tests/06_EuerBuchungsTest.php index 7e9c684..5d027a4 100644 --- a/tests/06_EuerBuchungsTest.php +++ b/tests/06_EuerBuchungsTest.php @@ -59,7 +59,6 @@ class EuerBuchungsTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', 'einnahme-' . uniqid()), 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, 'belegtyp' => 'Ausgangsrechnung', ]); @@ -90,7 +89,6 @@ class EuerBuchungsTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', 'ausgabe-' . uniqid()), 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, 'belegtyp' => 'Eingangsrechnung', ]); diff --git a/tests/07_UstvaAuslandTest.php b/tests/07_UstvaAuslandTest.php index a604c2a..92e6ef3 100644 --- a/tests/07_UstvaAuslandTest.php +++ b/tests/07_UstvaAuslandTest.php @@ -44,7 +44,6 @@ class UstvaAuslandTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', 'test-igl'), 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, 'belegtyp' => 'Ausgangsrechnung', ]); \Steuerkram\Models\Booking::create([ @@ -67,7 +66,6 @@ class UstvaAuslandTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', 'test-exp'), 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, 'belegtyp' => 'Ausgangsrechnung', ]); \Steuerkram\Models\Booking::create([ @@ -90,7 +88,6 @@ class UstvaAuslandTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', 'test-ige19'), 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, 'belegtyp' => 'Eingangsrechnung', ]); \Steuerkram\Models\Booking::create([ @@ -113,7 +110,6 @@ class UstvaAuslandTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', 'test-ige7'), 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, 'belegtyp' => 'Eingangsrechnung', ]); \Steuerkram\Models\Booking::create([ diff --git a/tests/08_LieferantTest.php b/tests/08_LieferantTest.php index fa99656..868d445 100644 --- a/tests/08_LieferantTest.php +++ b/tests/08_LieferantTest.php @@ -63,7 +63,6 @@ class LieferantTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', $belegNr), 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, 'rechnungs_nr' => 'RE-SUP-001', 'betrag_brutto' => 59.50, 'belegtyp' => 'Eingangsrechnung', diff --git a/tests/09_InvoiceToReportsTest.php b/tests/09_InvoiceToReportsTest.php index c0e9c20..72a344f 100644 --- a/tests/09_InvoiceToReportsTest.php +++ b/tests/09_InvoiceToReportsTest.php @@ -73,7 +73,6 @@ class InvoiceToReportsTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', $rechnungsNr), 'beleg_datum' => $invoice['rechnungs_datum'], - 'eingangs_datum' => $invoice['rechnungs_datum'], 'rechnungs_nr' => $rechnungsNr, 'betrag_brutto' => $invoice['betrag_brutto'], 'belegtyp' => 'Ausgangsrechnung', diff --git a/tests/10_IstVersteuerungTest.php b/tests/10_IstVersteuerungTest.php index 782d04f..83895bb 100644 --- a/tests/10_IstVersteuerungTest.php +++ b/tests/10_IstVersteuerungTest.php @@ -76,7 +76,6 @@ class IstVersteuerungTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', $nr), 'beleg_datum' => $rechnungsDatum, - 'eingangs_datum' => $rechnungsDatum, 'rechnungs_nr' => $nr, 'betrag_brutto' => $invoice['betrag_brutto'], 'belegtyp' => 'Ausgangsrechnung', diff --git a/tests/12_BelegGebuechtStatusTest.php b/tests/12_BelegGebuechtStatusTest.php index 6e3860e..3d6e617 100644 --- a/tests/12_BelegGebuechtStatusTest.php +++ b/tests/12_BelegGebuechtStatusTest.php @@ -28,7 +28,6 @@ class BelegGebuechtStatusTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => sha1('test'), 'beleg_datum' => date('Y-m-d'), - 'eingangs_datum'=> date('Y-m-d'), 'belegtyp' => 'Eingangsrechnung', 'betrag_brutto' => 119.00, ]); diff --git a/tests/15_NichtAbzugsfaehigEuerTest.php b/tests/15_NichtAbzugsfaehigEuerTest.php index b9f6a6d..0a7d4df 100644 --- a/tests/15_NichtAbzugsfaehigEuerTest.php +++ b/tests/15_NichtAbzugsfaehigEuerTest.php @@ -40,7 +40,6 @@ class NichtAbzugsfaehigEuerTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', 'kz165ein' . uniqid()), 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, 'belegtyp' => 'Ausgangsrechnung', ]); \Steuerkram\Models\Booking::create([ @@ -62,7 +61,6 @@ class NichtAbzugsfaehigEuerTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', 'kz165aus' . uniqid()), 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, 'belegtyp' => 'Eingangsrechnung', ]); \Steuerkram\Models\Booking::create([ @@ -84,7 +82,6 @@ class NichtAbzugsfaehigEuerTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', 'kz165na' . uniqid()), 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, 'belegtyp' => 'Eingangsrechnung', ]); \Steuerkram\Models\Booking::create([ diff --git a/tests/18_StornoBuchungTest.php b/tests/18_StornoBuchungTest.php index 0575033..ec56e4a 100644 --- a/tests/18_StornoBuchungTest.php +++ b/tests/18_StornoBuchungTest.php @@ -34,7 +34,6 @@ class StornoBuchungTest extends TestCase 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', 'storno-test-' . uniqid()), 'beleg_datum' => $datum, - 'eingangs_datum' => $datum, 'belegtyp' => 'Ausgangsrechnung', ]); From d8cba3e1f5845caf246ea5c215e77286dc64c41c Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 13 Apr 2026 15:32:57 +0200 Subject: [PATCH 08/14] =?UTF-8?q?Logo-Upload=20und=20Rechnungserstellung?= =?UTF-8?q?=20=C3=BCberarbeitet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/029_logo_position.sql | 6 + public/static/css/app.css | 43 ++++- src/Controllers/SettingsController.php | 5 +- src/Models/Setting.php | 13 +- src/Services/PdfInvoiceService.php | 227 +++++++++++++++---------- src/Views/invoices/show.php | 96 +++++++---- src/Views/settings/form.php | 108 +++++++++--- 7 files changed, 342 insertions(+), 156 deletions(-) create mode 100644 migrations/029_logo_position.sql diff --git a/migrations/029_logo_position.sql b/migrations/029_logo_position.sql new file mode 100644 index 0000000..2960f1f --- /dev/null +++ b/migrations/029_logo_position.sql @@ -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; diff --git a/public/static/css/app.css b/public/static/css/app.css index a28181f..4e00b26 100644 --- a/public/static/css/app.css +++ b/public/static/css/app.css @@ -109,8 +109,9 @@ img { max-width: 100%; } .main-content { display: flex; flex-direction: column; - min-height: 100vh; - overflow: hidden; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; } .topbar { @@ -121,6 +122,9 @@ img { max-width: 100%; } align-items: center; justify-content: space-between; gap: 1rem; + position: sticky; + top: 0; + z-index: 10; } .topbar-title { @@ -368,6 +372,18 @@ td { padding: .65rem 1rem; vertical-align: middle; } } /* ── 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 { background: #fff; padding: 2cm 2cm 2.5cm; @@ -379,6 +395,29 @@ td { padding: .65rem 1rem; vertical-align: middle; } 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 ──────────────────────────────────────────────────── */ .modal-overlay { display: none; diff --git a/src/Controllers/SettingsController.php b/src/Controllers/SettingsController.php index bc18b53..e262a5e 100644 --- a/src/Controllers/SettingsController.php +++ b/src/Controllers/SettingsController.php @@ -15,8 +15,9 @@ class SettingsController $view = new View(); $view->render('layout/base.php', [ - 'pageTitle' => 'Einstellungen', - 'content' => $this->renderView('settings/form', [ + 'pageTitle' => 'Einstellungen', + 'topbarActions' => '', + 'content' => $this->renderView('settings/form', [ 'settings' => $settings, 'errors' => [], ]), diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 903e4c2..c112139 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -21,6 +21,10 @@ class Setting public static function update(array $data): void { + $logoPosition = in_array($data['logo_position'] ?? '', ['center', 'left', 'right']) + ? $data['logo_position'] + : 'center'; + Database::execute( 'UPDATE company_settings SET firma=?, inhaber=?, strasse=?, plz=?, ort=?, @@ -31,7 +35,9 @@ class Setting invoice_nr_prefix=?, beleg_nr_prefix=?, payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?, ocr_aktiv=?, 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', [ $data['firma'] ?? '', @@ -61,6 +67,11 @@ class Setting $data['nextcloud_pass'] ?: null, $data['nextcloud_folder'] ?: '/Belege/Import', $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)), ] ); } diff --git a/src/Services/PdfInvoiceService.php b/src/Services/PdfInvoiceService.php index 13d3e60..718c311 100644 --- a/src/Services/PdfInvoiceService.php +++ b/src/Services/PdfInvoiceService.php @@ -5,8 +5,6 @@ declare(strict_types=1); namespace Steuerkram\Services; use Mpdf\Mpdf; -use Mpdf\Config\ConfigVariables; -use Mpdf\Config\FontVariables; class PdfInvoiceService { @@ -15,12 +13,12 @@ class PdfInvoiceService $html = $this->buildHtml($invoice, $lines, $settings); $mpdf = new Mpdf([ - 'mode' => 'utf-8', - 'format' => 'A4', - 'margin_top' => 15, + 'mode' => 'utf-8', + 'format' => 'A4', + 'margin_top' => 15, 'margin_bottom' => 25, - 'margin_left' => 20, - 'margin_right' => 20, + 'margin_left' => 25, // DIN 5008: 25mm links + 'margin_right' => 15, // DIN 5008: 15mm rechts ]); $mpdf->SetTitle('Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf')); @@ -31,28 +29,45 @@ class PdfInvoiceService $mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY); $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 { - $h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); - $fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €'; - $fmtNum = fn($v) => number_format((float)$v, 3, ',', '.'); + $h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); + $fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €'; + $fmtNum = fn($v) => number_format((float)$v, 1, ',', '.'); $fmtDate = fn($v) => $v ? date('d.m.Y', strtotime($v)) : '–'; - // Customer address - $customerName = !empty($inv['firma']) ? $inv['firma'] : trim(($inv['anrede'] ?? '') . ' ' . ($inv['vorname'] ?? '') . ' ' . $inv['nachname']); + // Kundenname + $customerName = !empty($inv['firma']) + ? $inv['firma'] + : trim(($inv['anrede'] ?? '') . ' ' . ($inv['vorname'] ?? '') . ' ' . $inv['nachname']); // Logo $logoHtml = ''; if (!empty($settings['logo_path'])) { $logoPath = dirname(__DIR__, 2) . '/public/' . $settings['logo_path']; if (file_exists($logoPath)) { - $logoHtml = ''; + $maxW = max(50, (int)($settings['logo_max_width'] ?? 600)); + $maxH = max(20, (int)($settings['logo_max_height'] ?? 100)); + $logoHtml = 'Logo'; } } + $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 $lz = ''; if (!empty($inv['leistungszeitraum_von'])) { @@ -62,69 +77,84 @@ class PdfInvoiceService } } - // Build positions table + // Fälligkeitsdatum + $fälligText = !empty($inv['faellig_datum']) + ? ' Fällig am: ' . $fmtDate($inv['faellig_datum']) . '' + : ''; + + // Positionszeilen $posRows = ''; foreach ($lines as $line) { $posRows .= ' - ' . $h($line['position']) . ' - ' . $h($line['bezeichnung']) . ' - ' . $fmtNum($line['menge']) . ' - ' . $h($line['einheit']) . ' - ' . $h(number_format((float)$line['ust_satz'], 0)) . ' % - ' . $fmt($line['einzelpreis']) . ' - ' . $fmt($line['betrag_brutto']) . ' + ' . $h($line['position']) . ' + ' . $h($line['bezeichnung']) . ' + ' . $fmtNum($line['menge']) . ' + ' . $h($line['einheit']) . ' + ' . $h(number_format((float)$line['ust_satz'], 0)) . ' % + ' . $fmt($line['einzelpreis']) . ' + ' . $fmt($line['betrag_brutto']) . ' '; } $payDays = $inv['payment_days'] ?? 14; + // Empfänger-Land nur wenn nicht DE + $land = ($inv['land'] ?? 'DE') !== 'DE' ? '
' . $h($inv['land']) : ''; + return << -
{$logoHtml}
+
{$logoHtml}
- -
-
-
{$h($settings['firma'] ?? '')} · {$h($settings['strasse'] ?? '')} · {$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}
-
- {$h($customerName)}
- {$h($inv['strasse'])}
- {$h($inv['plz'])} {$h($inv['ort'])}
- {$h($inv['land'] !== 'DE' ? $inv['land'] : '')} -
-
-
- - - - - - - - - -
{$h($settings['firma'] ?? '')}
{$h($settings['inhaber'] ?? '')}
{$h($settings['strasse'] ?? '')}
{$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}
 
{$h($settings['telefon'] ?? '')}
{$h($settings['email'] ?? '')}
USt-ID: {$h($settings['ust_id'] ?? '')}
-
-
+ + + + + + + + +
+
{$h($settings['firma'] ?? '')} · {$h($settings['strasse'] ?? '')} · {$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}
+
+ {$h($customerName)}
+ {$h($inv['strasse'])}
+ {$h($inv['plz'])} {$h($inv['ort'])}{$land} +
+
+ + + + + + + + + +
{$h($settings['firma'] ?? '')}
{$h($settings['inhaber'] ?? '')}
{$h($settings['strasse'] ?? '')}
{$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}
 
{$h($settings['telefon'] ?? '')}
{$h($settings['email'] ?? '')}
USt-ID: {$h($settings['ust_id'] ?? '')}
+
- - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +
BetreffRechnungsnummerKundennummerLeistungszeitraumDatum
{$h($inv['betreff'])}{$h($inv['rechnungs_nr'] ?? 'Entwurf')}{$h($inv['kundennummer'])}{$h($lz)}{$fmtDate($inv['rechnungs_datum'])}
BetreffRechnungsnummerKundennummerLeistungszeitraumDatum
{$h($inv['betreff'])}{$h($inv['rechnungs_nr'] ?? 'Entwurf')}{$h($inv['kundennummer'])}{$h($lz ?: '–')}{$fmtDate($inv['rechnungs_datum'])}
@@ -145,16 +175,19 @@ class PdfInvoiceService - - - + +
Nettobetrag:{$fmt($inv['betrag_netto'])}
+ - + + + +
Nettobetrag: {$fmt($inv['betrag_netto'])}
zzgl. Umsatzsteuer:{$fmt($inv['betrag_ust'])}
Rechnungsbetrag:{$fmt($inv['betrag_brutto'])}
Rechnungsbetrag:{$fmt($inv['betrag_brutto'])}

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

@@ -197,48 +230,60 @@ HTML; return << 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 .= '
' . $h(View::formatDate($invoice['leistungszeitraum_bis'])); + } +} + $badgeClass = match($invoice['status']) { 'Bezahlt' => 'badge-green', 'Storniert' => 'badge-red', @@ -56,13 +65,28 @@ $badgeClass = match($invoice['status']) { -
- Logo + +
+ Logo
- -
+ +
· · @@ -74,14 +98,16 @@ $badgeClass = match($invoice['status']) {
-
-
-
-
-
-
-
- USt-ID: +
+
+
+
+
+
+
+
+ USt-ID: +
@@ -89,35 +115,35 @@ $badgeClass = match($invoice['status']) { - - - - - + + + + + - - - - - + + + + +
BetreffRechnungsnummerKundennummerLeistungszeitraumDatumBetreffRechnungsnummerKundennummerLeistungszeitraumDatum
- +
- - + + - - - - - + + + + + @@ -125,7 +151,7 @@ $badgeClass = match($invoice['status']) { - + @@ -154,20 +180,20 @@ $badgeClass = match($invoice['status']) { -
-
+
+




USt-ID:
-
+
Tel:
E-Mail:
Web:
-
+

IBAN:
BIC: diff --git a/src/Views/settings/form.php b/src/Views/settings/form.php index 679f2d4..d61b970 100644 --- a/src/Views/settings/form.php +++ b/src/Views/settings/form.php @@ -1,6 +1,6 @@ View::h($v); ?> -
+
@@ -85,8 +85,6 @@
- -
@@ -239,28 +237,6 @@
-
-
Logo
-
- -
- Logo -
- - - - -
-
- - -
Empfohlen: transparentes PNG, mind. 300px breit
-
- - -
-
-
Nummernkreise
@@ -286,6 +262,82 @@
+
+
+
+
+
Logo
+
+ +
+ Logo +
+ +
+
+ + +
Empfohlen: transparentes PNG, mind. 300px breit
+
+
+ + + + +
+ + +
+ + +
+
Darstellung in der Rechnung
+
+
+ + +
+
+ + +
+
+
+ +
+ 'Zentriert', 'left' => 'Links', 'right' => 'Rechts'] as $val => $label): ?> + + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+ From 909f9e3ca35ca73795a32781209d0d4350c945b3 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 13 Apr 2026 16:01:00 +0200 Subject: [PATCH 09/14] Feat: update.sh Script + Doku aktualisiert --- docs/update-backup.md | 83 +++++----- update.sh | 352 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 387 insertions(+), 48 deletions(-) create mode 100755 update.sh diff --git a/docs/update-backup.md b/docs/update-backup.md index 3d7078a..6a500ca 100644 --- a/docs/update-backup.md +++ b/docs/update-backup.md @@ -36,37 +36,34 @@ Beide Pfade liegen außerhalb des Containers und bleiben bei `docker compose dow ## Update -Datenbankmigrationen werden **automatisch** beim nächsten Container-Start eingespielt. Das Script `scripts/migrate.php` prüft beim Start, welche Migrations-Dateien noch nicht angewendet wurden, und führt sie der Reihe nach aus. - -### Vollständige Update-Anleitung - -#### Schritt 1 – Datenbank sichern +Für ein vollständiges Update steht `update.sh` im Projektwurzelverzeichnis bereit. Es führt alle notwendigen Schritte automatisch aus. ```bash -bash scripts/backup.sh +./update.sh ``` -#### Schritt 2 – Aktuellen Code holen +Das Script erledigt in dieser Reihenfolge: -```bash -git pull -``` +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 -#### Schritt 3 – Container neu bauen und starten +### Optionen -```bash -docker compose up -d --build -``` +| 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 | -Dieser Befehl baut das PHP-Image neu (falls sich `Dockerfile` oder `composer.json` geändert haben) und startet alle Container. Der Web-Container führt beim Start automatisch `scripts/migrate.php` aus. +### Migrationen -#### Schritt 4 – Migrationen prüfen - -```bash -docker compose logs web | grep migrate -``` - -Erwartete Ausgabe: +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... @@ -74,33 +71,23 @@ Erwartete Ausgabe: [migrate] N Migration(en) erfolgreich angewendet. ``` -Falls ein Fehler auftritt, das Backup aus Schritt 1 einspielen: +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 \ - < ~/steuerkram_backup_YYYYMMDD_HHMM.sql -``` - -#### Schritt 5 – Anwendung prüfen - -```bash -docker compose ps # alle Container müssen "running" sein -docker compose logs web # keine PHP-Fehler oder Exceptions -``` - ---- - -### Kurzform ohne Systemd - -```bash -bash scripts/backup.sh && git pull && docker compose up -d --build -docker compose logs web | grep migrate -``` - -### Kurzform mit Systemd - -```bash -bash scripts/backup.sh && git pull && docker compose build -sudo systemctl restart steuerkram -journalctl -u steuerkram | grep migrate + < backups/steuerkram_db_YYYYMMDD_HHMM.sql ``` diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..ad693db --- /dev/null +++ b/update.sh @@ -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="$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 From 1bf15c8937665b0d96fe1c310ea0f52421a2402b Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 13 Apr 2026 16:04:51 +0200 Subject: [PATCH 10/14] Fix: Update Script lag im falschen Ordner --- docs/update-backup.md | 4 ++-- update.sh => scripts/update.sh | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename update.sh => scripts/update.sh (100%) diff --git a/docs/update-backup.md b/docs/update-backup.md index 6a500ca..3e45610 100644 --- a/docs/update-backup.md +++ b/docs/update-backup.md @@ -36,10 +36,10 @@ Beide Pfade liegen außerhalb des Containers und bleiben bei `docker compose dow ## Update -Für ein vollständiges Update steht `update.sh` im Projektwurzelverzeichnis bereit. Es führt alle notwendigen Schritte automatisch aus. +Für ein vollständiges Update steht `scripts/update.sh` bereit. Es führt alle notwendigen Schritte automatisch aus. ```bash -./update.sh +./scripts/update.sh ``` Das Script erledigt in dieser Reihenfolge: diff --git a/update.sh b/scripts/update.sh similarity index 100% rename from update.sh rename to scripts/update.sh From 90d4f977e8e4ba20b436dc21c008e97cebd58b15 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 13 Apr 2026 16:14:49 +0200 Subject: [PATCH 11/14] Fix: Update Script Fehler behoben --- scripts/update.sh | 2 +- tests/06_EuerBuchungsTest.php | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/update.sh b/scripts/update.sh index ad693db..982e42e 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -41,7 +41,7 @@ done # ── Pfade ──────────────────────────────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$SCRIPT_DIR" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" cd "$PROJECT_DIR" # ── Farben ─────────────────────────────────────────────────────────────────── diff --git a/tests/06_EuerBuchungsTest.php b/tests/06_EuerBuchungsTest.php index 5d027a4..4da04ed 100644 --- a/tests/06_EuerBuchungsTest.php +++ b/tests/06_EuerBuchungsTest.php @@ -17,6 +17,11 @@ class EuerBuchungsTest extends TestCase { $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 = \Steuerkram\Database::fetchAll( "SELECT DISTINCT kontenart FROM accounts ORDER BY kontenart" @@ -121,16 +126,17 @@ class EuerBuchungsTest extends TestCase $this->assertEquals($stornoId, (int)$origNeu['reversed_by'], 'Original verweist auf Storno-Buchung'); // ── 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( - (float)$euer['total_einnahmen'] >= 1000.00, + (float)$euerNachher['total_einnahmen'] >= $einnahmenVorher + 1000.00, '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( - (float)$euer['gewinn'] > 0, - 'EÜR: Gewinn ist positiv (Einnahmen > Ausgaben nach Storno)' + (float)$euerNachher['gewinn'] >= $gewinnVorher + 1000.00, + 'EÜR: Gewinn ist um ≥ 1000 € gestiegen (Einnahme + stornierte Ausgabe)' ); } } From 6ab1bc3ca44dd6cc987d0e6b1e849bc9154f788d Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 13 Apr 2026 16:29:28 +0200 Subject: [PATCH 12/14] Fix: Update Script Fehler 2 behoben --- scripts/backup.sh | 6 ++++-- tests/06_EuerBuchungsTest.php | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/backup.sh b/scripts/backup.sh index b84a5ab..518a467 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -14,7 +14,10 @@ set -eo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" 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) exec 9>"$LOCK_FILE" @@ -24,7 +27,6 @@ if ! flock -n 9; then fi TIMESTAMP=$(date +%Y%m%d_%H%M%S) -mkdir -p "$BACKUP_DIR" echo "$(date '+%Y-%m-%d %H:%M:%S') Erstelle Backup $TIMESTAMP..." diff --git a/tests/06_EuerBuchungsTest.php b/tests/06_EuerBuchungsTest.php index 4da04ed..ddf6f35 100644 --- a/tests/06_EuerBuchungsTest.php +++ b/tests/06_EuerBuchungsTest.php @@ -116,6 +116,11 @@ class EuerBuchungsTest extends TestCase $stornoId = \Steuerkram\Models\Booking::reverse($ausgabeBuchungId, 'Testgrund'); $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(1, (int)$storno['is_reversal'], 'Storno-Flag gesetzt'); $this->assertEquals($ausgabeId, (int)$storno['kategorie_konto_id'], 'Storno behält Kategorie-Konto'); @@ -135,7 +140,7 @@ class EuerBuchungsTest extends TestCase ); // Ausgabe wurde storniert → Netto-Effekt auf Gewinn: +1000 € $this->assert( - (float)$euerNachher['gewinn'] >= $gewinnVorher + 1000.00, + round((float)$euerNachher['gewinn'] - $gewinnVorher, 2) >= 1000.00, 'EÜR: Gewinn ist um ≥ 1000 € gestiegen (Einnahme + stornierte Ausgabe)' ); } From b51124fd0a695fa2495e2c2a59142c4c4bba838c Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 15 Apr 2026 19:59:01 +0200 Subject: [PATCH 13/14] Refactor: OCR/Tesseract-Fallback entfernt, KI-Doku aktualisiert --- docs/konfiguration.md | 17 +- src/Models/Setting.php | 3 +- src/Services/KontierungsvorschlagService.php | 307 +++++++++++++++---- src/Views/settings/form.php | 19 +- 4 files changed, 262 insertions(+), 84 deletions(-) diff --git a/docs/konfiguration.md b/docs/konfiguration.md index d73d895..77bc1cf 100644 --- a/docs/konfiguration.md +++ b/docs/konfiguration.md @@ -20,23 +20,30 @@ Das Format ist immer `{Präfix}-{Jahr}-{laufende Nummer}`. Die laufende Nummer w --- -## KI-Beleganalyse einrichten +## KI-Kontierungsvorschlag einrichten -Die KI-gestützte Belegerkennung ist standardmäßig deaktiviert. Sie kann unter **Einstellungen → KI-Kontierungsvorschlag** aktiviert werden, sobald ein LLM erreichbar ist. +Der KI-Kontierungsvorschlag analysiert Belege automatisch beim Nextcloud-Import und füllt das Buchungsformular mit einem Vorschlag vor. Die Funktion ist standardmäßig deaktiviert und kann unter **Einstellungen → KI-Kontierungsvorschlag** aktiviert werden. + +### Voraussetzungen + +- Nextcloud-Import muss konfiguriert und aktiv sein (siehe unten) +- Ein erreichbares LLM (lokal via Ollama oder Remote-API) +- Für Bildbelege (JPEG, PNG, TIFF): ein Vision-fähiges Modell (z.B. `qwen2.5vl`, `llava`); ohne Vision-Unterstützung werden Bildbelege ohne Textextraktion verarbeitet +- Für PDF-Belege: `pdftotext` und `pdftoppm` (Teil von `poppler-utils`, im Docker-Image enthalten) ### Option A – Lokales Ollama Kein API-Schlüssel nötig, aber hoher Ressourcenbedarf: ```bash -ollama pull mistral # oder llama3.2 etc. +ollama pull qwen2.5vl # Vision-fähig (empfohlen), alternativ mistral o.ä. ``` In `.env` ist keine weitere Konfiguration nötig – die App erkennt Ollama auf `localhost:11434` automatisch. Modell und URL können optional überschrieben werden: ```dotenv OLLAMA_URL=http://localhost:11434 -OLLAMA_MODEL=mistral +OLLAMA_MODEL=qwen2.5vl ``` ### Option B – Remote-API (OpenAI-kompatibel) @@ -49,7 +56,7 @@ LLM_API_KEY=sk-... LLM_MODEL=gpt-4o-mini ``` -> **Hinweis:** Die Ergebnisqualität hängt stark vom gewählten Modell ab. Tests mit `mistral` (lokal via Ollama) lieferten bisher mäßige Ergebnisse. +> **Hinweis:** Die Ergebnisqualität hängt stark vom gewählten Modell ab. Für zuverlässige Ergebnisse wird ein Vision-fähiges Modell empfohlen. --- diff --git a/src/Models/Setting.php b/src/Models/Setting.php index c112139..e7e3356 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -33,7 +33,7 @@ class Setting iban=?, bic=?, bank_name=?, ustva_steuernummer=?, ustva_bundesland=?, invoice_nr_prefix=?, beleg_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_folder=?, nextcloud_done=?, logo_max_width=?, logo_max_height=?, @@ -61,7 +61,6 @@ class Setting (int)($data['payment_days_default'] ?? 14), isset($data['test_mode']) ? 1 : 0, isset($data['ki_vorschlag_aktiv']) ? 1 : 0, - isset($data['ocr_aktiv']) ? 1 : 0, $data['nextcloud_url'] ?: null, $data['nextcloud_user'] ?: null, $data['nextcloud_pass'] ?: null, diff --git a/src/Services/KontierungsvorschlagService.php b/src/Services/KontierungsvorschlagService.php index 944c132..27a6dc7 100644 --- a/src/Services/KontierungsvorschlagService.php +++ b/src/Services/KontierungsvorschlagService.php @@ -24,12 +24,6 @@ class KontierungsvorschlagService return (bool)($s['ki_vorschlag_aktiv'] ?? 0) && $this->llm->isAvailable(); } - public function isOcrEnabled(): bool - { - $s = Setting::get(); - return (bool)($s['ocr_aktiv'] ?? 1); - } - public function extractText(array $beleg): string { $absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; @@ -38,14 +32,33 @@ class KontierungsvorschlagService $mime = strtolower($beleg['mime_type'] ?? ''); if ($mime === 'application/pdf') { + // 1. eRechnung-XML (ZUGFeRD / Factur-X / XRechnung) — strukturiert, fehlerarm + $xml = $this->extractErechnungXml($absPath); + if ($xml !== '') return $xml; + + // 2. Vision-Modell (qwen2.5vl o.ä.) + if ($this->llm->isVisionAvailable()) { + $vision = $this->extractPdfVision($absPath); + if ($vision !== '') return $vision; + } + + // 3. Fallback: pdftotext return $this->extractPdfText($absPath); - } elseif (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) { - return $this->isOcrEnabled() ? $this->extractImageText($absPath) : ''; + } + + if (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) { + if (!$this->llm->isVisionAvailable()) return ''; + return $this->extractImageVision($absPath); } return ''; } + /** + * Erstellt einen Kontierungsvorschlag für den Beleg. + * Gibt ein Array von Buchungszeilen zurück (eine je MwSt-Satz), + * oder null bei Fehler / nicht unterstütztem MIME-Typ. + */ public function generateForBeleg(array $beleg): ?array { $text = $this->extractText($beleg); @@ -61,7 +74,7 @@ class KontierungsvorschlagService } // Konten laden (max. 50 relevante Konten für kurzen Prompt) - $allAccounts = Account::all(['aktiv' => 1]); + $allAccounts = Account::all(['aktiv' => 1]); $kategorieKonten = array_slice(array_values(array_filter( $allAccounts, fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true) @@ -82,20 +95,20 @@ class KontierungsvorschlagService )); $beleginhalt = trim($text) !== '' - ? "Belegtext:\n" . mb_substr(trim($text), 0, 2000) + ? "Belegtext:\n" . mb_substr(trim($text), 0, 3000) : '[Kein Text extrahierbar]'; - $settings = Setting::get(); - $eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? '')); + $settings = Setting::get(); + $eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? '')); $eigenerHinweis = $eigenerName !== '' - ? "\n- supplier_name: NICHT \"{$eigenerName}\" (das ist der Rechnungsempfaenger, nicht der Lieferant). Bei Ausgabe = Aussteller/Verkaeuer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar." - : "\n- supplier_name: Bei Ausgabe = Aussteller/Verkaeuer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar."; + ? "\n- supplier_name: NICHT \"{$eigenerName}\" (das ist der Rechnungsempfaenger, nicht der Lieferant). Bei Ausgabe = Aussteller/Verkaeufer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar." + : "\n- supplier_name: Bei Ausgabe = Aussteller/Verkaeufer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar."; $prompt = <<parseAndValidate($response, $allAccounts); } + // ------------------------------------------------------------------------- + // Text-Extraktion + // ------------------------------------------------------------------------- + + /** + * Extrahiert eingebettete eRechnung-XML (ZUGFeRD / Factur-X / XRechnung). + * Gibt XML-Inhalt (max. 12.000 Zeichen) zurück, oder '' wenn keiner gefunden. + */ + private function extractErechnungXml(string $path): string + { + // pdfdetach ist Teil von poppler-utils (zusammen mit pdftotext installiert) + $safe = escapeshellarg($path); + exec("pdfdetach -list {$safe} 2>/dev/null", $listOutput, $rc); + if ($rc !== 0 || empty($listOutput)) return ''; + + $hasXml = false; + foreach ($listOutput as $line) { + if (stripos($line, '.xml') !== false) { + $hasXml = true; + break; + } + } + if (!$hasXml) return ''; + + $tmpDir = sys_get_temp_dir() . '/erechnung_' . uniqid(); + $safeTmp = escapeshellarg($tmpDir); + mkdir($tmpDir, 0700, true); + + exec("pdfdetach -saveall -o {$safeTmp} {$safe} 2>/dev/null"); + + $xmlContent = ''; + $candidates = [ + "$tmpDir/factur-x.xml", + "$tmpDir/ZUGFeRD-invoice.xml", + "$tmpDir/zugferd-invoice.xml", + "$tmpDir/xrechnung.xml", + ]; + // Bekannte Namen zuerst, dann alle .xml-Dateien im tmpDir + foreach (array_merge($candidates, glob("$tmpDir/*.xml") ?: [], glob("$tmpDir/*.XML") ?: []) as $f) { + if (file_exists($f)) { + $xmlContent = file_get_contents($f); + break; + } + } + + // Cleanup + foreach (glob("$tmpDir/*") ?: [] as $f) { + @unlink($f); + } + @rmdir($tmpDir); + + return mb_substr($xmlContent, 0, 12000); + } + + /** + * Konvertiert PDF-Seiten zu Bildern (pdftoppm) und schickt sie ans Vision-Modell. + * Fallback auf pdftotext wenn Vision nicht verfügbar oder fehlschlägt. + */ + private function extractPdfVision(string $path): string + { + $tmpDir = sys_get_temp_dir() . '/vision_' . uniqid(); + $safeTmp = escapeshellarg($tmpDir . '/page'); + $safe = escapeshellarg($path); + mkdir($tmpDir, 0700, true); + + // pdftoppm ist Teil von poppler-utils; auf 800px Breite begrenzen (Ollama-Limit) + exec("pdftoppm -jpeg -scale-to-x 800 -scale-to-y -1 {$safe} {$tmpDir}/page 2>/dev/null", $out, $rc); + + $images = glob("$tmpDir/page*.jpg") ?: glob("$tmpDir/page*.jpeg") ?: []; + sort($images); + + if ($rc !== 0 || empty($images)) { + @rmdir($tmpDir); + return ''; + } + + $base64 = []; + foreach ($images as $img) { + $base64[] = base64_encode(file_get_contents($img)); + @unlink($img); + } + @rmdir($tmpDir); + + $prompt = 'Extrahiere den vollstaendigen Text dieses Belegs. ' + . 'Behalte die Tabellenstruktur bei (Spalten mit Leerzeichen ausrichten). ' + . 'Antworte nur mit dem extrahierten Text, ohne Kommentar oder Erklaerung.'; + + return $this->llm->generateVision($prompt, $base64); + } + + /** + * Schickt ein Bild (JPEG/PNG/TIFF) ans Vision-Modell. + * Skaliert auf max. 800px Breite damit der Payload nicht zu groß wird. + */ + private function extractImageVision(string $path): string + { + $base64 = $this->resizeImageBase64($path, 800); + if ($base64 === '') return ''; + + $prompt = 'Extrahiere den vollstaendigen Text dieses Belegs. ' + . 'Behalte die Tabellenstruktur bei. ' + . 'Antworte nur mit dem extrahierten Text, ohne Kommentar oder Erklaerung.'; + + return $this->llm->generateVision($prompt, [$base64]); + } + + /** + * Liest ein Bild, skaliert es auf maxWidth Pixel Breite und gibt es als base64-JPEG zurück. + */ + private function resizeImageBase64(string $path, int $maxWidth): string + { + // pdftoppm erzeugt JPEG; ImageCropService verwendet GD — GD ist im Container verfügbar + $info = @getimagesize($path); + if (!$info) return ''; + + [$origW, $origH, $type] = $info; + + $src = match ($type) { + IMAGETYPE_JPEG => @imagecreatefromjpeg($path), + IMAGETYPE_PNG => @imagecreatefrompng($path), + IMAGETYPE_TIFF_II, IMAGETYPE_TIFF_MM => null, // GD unterstützt kein TIFF + default => null, + }; + + if (!$src) { + // Kein GD-Support (z. B. TIFF) → direkt senden, auch wenn groß + return base64_encode(file_get_contents($path)); + } + + if ($origW <= $maxWidth) { + // Bereits klein genug → direkt als JPEG-Base64 + ob_start(); + imagejpeg($src, null, 85); + $jpeg = ob_get_clean(); + imagedestroy($src); + return base64_encode($jpeg); + } + + $scale = $maxWidth / $origW; + $newW = $maxWidth; + $newH = (int)round($origH * $scale); + $dst = imagecreatetruecolor($newW, $newH); + imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $origW, $origH); + imagedestroy($src); + + ob_start(); + imagejpeg($dst, null, 85); + $jpeg = ob_get_clean(); + imagedestroy($dst); + + return base64_encode($jpeg); + } + private function extractPdfText(string $path): string { - $safe = escapeshellarg($path); + $safe = escapeshellarg($path); $output = []; - exec("pdftotext -l 1 {$safe} -", $output, $returnCode); + exec("pdftotext {$safe} -", $output, $returnCode); return $returnCode === 0 ? implode("\n", $output) : ''; } - private function extractImageText(string $path): string - { - $tmpBase = sys_get_temp_dir() . '/ocr_' . uniqid(); - $safe = escapeshellarg($path); - $safeTmp = escapeshellarg($tmpBase); - exec("tesseract {$safe} {$safeTmp} -l deu+eng quiet 2>/dev/null", $out, $rc); - $txtFile = $tmpBase . '.txt'; - if ($rc === 0 && file_exists($txtFile)) { - $text = file_get_contents($txtFile); - unlink($txtFile); - return $text ?: ''; - } - return ''; - } + // ------------------------------------------------------------------------- + // JSON-Parsing & Validierung + // ------------------------------------------------------------------------- + /** + * Parst die LLM-Antwort als JSON-Array von Buchungszeilen. + * Akzeptiert auch ein einzelnes JSON-Objekt (Backward-Compat) und wickelt es in ein Array. + * Gibt validiertes Array zurück, oder null bei Parse-/Validierungsfehler. + */ private function parseAndValidate(string $response, array $accounts): ?array { - // JSON aus Antwort extrahieren (Modell könnte Prosa voranstellen) - if (!preg_match('/\{[^{}]*\}/s', $response, $m)) return null; - - $data = json_decode($m[0], true); - if (!is_array($data)) return null; - - $required = ['buchungstyp', 'kategorie_konto_id', 'zahlungskonto_id', 'ust_satz', 'betrag_netto']; - foreach ($required as $field) { - if (!isset($data[$field])) return null; + // Array-Form: [{ ... }, { ... }] + if (preg_match('/\[.*\]/s', $response, $m)) { + $data = json_decode($m[0], true); + if (is_array($data) && isset($data[0]) && is_array($data[0])) { + return $this->validateLines($data, $accounts); + } } - // Werte normalisieren - $data['buchungstyp'] = in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) ? $data['buchungstyp'] : 'Ausgabe'; - $data['kategorie_konto_id'] = (int)$data['kategorie_konto_id']; - $data['zahlungskonto_id'] = (int)$data['zahlungskonto_id']; - $data['ust_satz'] = in_array((int)$data['ust_satz'], [0, 7, 19], true) ? (int)$data['ust_satz'] : 0; - $data['betrag_netto'] = round((float)$data['betrag_netto'], 2); - $data['buchungstext'] = (string)($data['buchungstext'] ?? ''); - $data['supplier_name'] = trim((string)($data['supplier_name'] ?? '')); + // 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); + } + } - // IDs gegen Kontenliste validieren (mit Kontenartenprüfung) + return null; + } + + /** + * Validiert und normalisiert ein Array von Buchungszeilen. + * Gibt nur gültige Zeilen zurück; null wenn keine einzige valide ist. + */ + private function validateLines(array $lines, array $accounts): ?array + { $kategorieIds = array_column(array_filter($accounts, fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true) ), 'id'); $zahlungsIds = array_column(array_filter($accounts, fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true) ), 'id'); - if (!in_array($data['kategorie_konto_id'], $kategorieIds, false)) return null; - if (!in_array($data['zahlungskonto_id'], $zahlungsIds, false)) return null; - // Lieferant gegen vorhandene Suppliers prüfen - if ($data['supplier_name'] !== '') { - $existing = Supplier::findByName($data['supplier_name']); - $data['supplier_found'] = $existing !== false; - } else { - $data['supplier_found'] = null; + $valid = []; + foreach ($lines as $data) { + if (!is_array($data)) continue; + + $required = ['buchungstyp', 'kategorie_konto_id', 'zahlungskonto_id', 'ust_satz', 'betrag_netto']; + $ok = true; + foreach ($required as $field) { + if (!isset($data[$field])) { $ok = false; break; } + } + if (!$ok) continue; + + // Normalisieren + $data['buchungstyp'] = in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) ? $data['buchungstyp'] : 'Ausgabe'; + $data['kategorie_konto_id'] = (int)$data['kategorie_konto_id']; + $data['zahlungskonto_id'] = (int)$data['zahlungskonto_id']; + $data['ust_satz'] = in_array((int)$data['ust_satz'], [0, 7, 19], true) ? (int)$data['ust_satz'] : 0; + $data['betrag_netto'] = round((float)$data['betrag_netto'], 2); + $data['buchungstext'] = (string)($data['buchungstext'] ?? ''); + $data['supplier_name'] = trim((string)($data['supplier_name'] ?? '')); + + // Konto-IDs validieren + if (!in_array($data['kategorie_konto_id'], $kategorieIds, false)) continue; + if (!in_array($data['zahlungskonto_id'], $zahlungsIds, false)) continue; + + // Lieferant gegen vorhandene Suppliers prüfen + if ($data['supplier_name'] !== '') { + $existing = Supplier::findByName($data['supplier_name']); + $data['supplier_found'] = $existing !== false; + } else { + $data['supplier_found'] = null; + } + + $valid[] = $data; } - return $data; + return empty($valid) ? null : $valid; } } diff --git a/src/Views/settings/form.php b/src/Views/settings/form.php index d61b970..9ebd281 100644 --- a/src/Views/settings/form.php +++ b/src/Views/settings/form.php @@ -136,7 +136,7 @@ Automatischen Kontierungsvorschlag erzeugen

- 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 .env (lokales Ollama oder Remote-API).

- -
-
OCR-Texterkennung
-
- -

- 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 pdftotext ausgelesen. -

-
-
-
Nextcloud-Belegimport
From b9b88defb86d8b480d64af47aef57226d28f75d9 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 15 Apr 2026 20:21:09 +0200 Subject: [PATCH 14/14] =?UTF-8?q?Refactor:=20KI-Analyse=20nicht=20mehr=20a?= =?UTF-8?q?utomatisch=20beim=20=C3=96ffnen=20eines=20Belegs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + Changes.md | 8 ++ docs/konfiguration.md | 2 +- scripts/ki-batch.php | 104 ++++++++++++++++++++++++ scripts/nextcloud-import.php | 13 +++ src/Controllers/BelegController.php | 3 + src/Services/LlmService.php | 60 ++++++++++++-- src/Services/NextcloudImportService.php | 5 +- src/Views/belege/detail.php | 88 ++++++++++++++------ src/Views/layout/nav.php | 2 +- 10 files changed, 253 insertions(+), 35 deletions(-) create mode 100644 scripts/ki-batch.php diff --git a/.env.example b/.env.example index 7dcf14c..6b5bed4 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,9 @@ DB_PASS=steuerkram # OLLAMA_URL=http://localhost:11434 # OLLAMA_MODEL=llama3.2 # +# Vision-Modell für Beleglesung (multimodales Ollama-Modell, benötigt OLLAMA_URL) +# VISION_MODEL=qwen2.5vl:latest +# # Option B: Remote-LLM per OpenAI-kompatibler API (z.B. OpenAI, OpenRouter, LM Studio) # LLM_API_URL=https://api.openai.com/v1 # LLM_API_KEY=sk-... diff --git a/Changes.md b/Changes.md index 68048db..092b62a 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,14 @@ --- +## 2026-04-15 – KI-Erkennung: automatischer Aufruf aus Belegansicht entfernt + +### Geändert +- `src/Views/belege/detail.php`: KI-Analyse wird nicht mehr automatisch beim Öffnen eines Belegs ausgelöst; stattdessen wird ein Hinweis "KI-Analyse wird im Hintergrund durchgeführt …" mit einem "Jetzt generieren"-Button angezeigt, wenn noch kein Vorschlag vorhanden ist +- Hintergrund: Die Analyse läuft bereits beim Nextcloud-Import (`ki-batch.php`); ein doppelter Aufruf aus der UI war überflüssig + +--- + ## 2026-04-12 – EÜR-Korrekturen: Gewerbesteuer + Bewirtungskonten ohne VSt ### Geändert diff --git a/docs/konfiguration.md b/docs/konfiguration.md index 77bc1cf..ff89fd0 100644 --- a/docs/konfiguration.md +++ b/docs/konfiguration.md @@ -68,7 +68,7 @@ Belege können automatisch aus einem Nextcloud-Ordner via WebDAV importiert werd Die Zugangsdaten werden in der Steuerkram-UI gepflegt: **Einstellungen → Nextcloud-Belegimport**. -Empfehlung: Ein Nextcloud-App-Passwort verwenden (Nextcloud → Einstellungen → Sicherheit → App-Passwörter). +Hinweis: Ein Nextcloud-App-Passwort verwenden (Nextcloud → Einstellungen → Sicherheit → App-Passwörter). ### Manueller Aufruf diff --git a/scripts/ki-batch.php b/scripts/ki-batch.php new file mode 100644 index 0000000..114b454 --- /dev/null +++ b/scripts/ki-batch.php @@ -0,0 +1,104 @@ +> /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); diff --git a/scripts/nextcloud-import.php b/scripts/nextcloud-import.php index 89964d8..11c7107 100644 --- a/scripts/nextcloud-import.php +++ b/scripts/nextcloud-import.php @@ -41,6 +41,7 @@ if (file_exists('.env')) { require_once 'vendor/autoload.php'; use Steuerkram\Config; +use Steuerkram\Models\Setting; use Steuerkram\Services\NextcloudImportService; Config::load(); // Database verbindet sich lazy über Config::get() @@ -64,4 +65,16 @@ echo sprintf( $result['errors'], ); +// KI-Vorschlag für jeden neu importierten Beleg anstoßen (wenn KI-Erkennung aktiv) +if (!empty($result['imported_ids'])) { + $settings = Setting::get(); + if (!empty($settings['ki_vorschlag_aktiv'])) { + $kiBatch = __DIR__ . '/ki-batch.php'; + echo '[nextcloud-import] Starte KI-Erkennung für ' . count($result['imported_ids']) . ' importierte(n) Beleg(e) ...' . PHP_EOL; + foreach ($result['imported_ids'] as $belegId) { + passthru('php ' . escapeshellarg($kiBatch) . ' ' . (int)$belegId . ' 2>&1'); + } + } +} + exit($result['errors'] > 0 ? 1 : 0); diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index 8a4448d..9e52d80 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -204,6 +204,8 @@ class BelegController $kiVorschlag = $beleg['ki_vorschlag'] !== null ? json_decode($beleg['ki_vorschlag'], true) : null; + // KI-Sektion anzeigen wenn Ergebnis vorhanden ODER LLM gerade verfügbar + $kiSektion = $kiAktiv || $kiVorschlag !== null; $view = new View(); $view->render('layout/base.php', [ @@ -215,6 +217,7 @@ class BelegController 'integrity' => $integrity, 'accounts' => $accounts, 'kiAktiv' => $kiAktiv, + 'kiSektion' => $kiSektion, 'kiVorschlag' => $kiVorschlag, ]), ]); diff --git a/src/Services/LlmService.php b/src/Services/LlmService.php index eb34f28..22b9007 100644 --- a/src/Services/LlmService.php +++ b/src/Services/LlmService.php @@ -15,10 +15,11 @@ namespace Steuerkram\Services; */ class LlmService { - private ?string $mode = null; // 'ollama' | 'openai' | null - private string $url = ''; - private string $model = ''; - private ?string $apiKey = null; + private ?string $mode = null; // 'ollama' | 'openai' | null + private string $url = ''; + private string $model = ''; + private string $visionModel = ''; + private ?string $apiKey = null; public function __construct() { @@ -30,6 +31,11 @@ class LlmService return $this->mode !== null; } + public function isVisionAvailable(): bool + { + return $this->mode === 'ollama'; + } + public function getProviderLabel(): ?string { return match($this->mode) { @@ -48,6 +54,45 @@ class LlmService }; } + /** + * Sendet Bilder (base64-kodiert) zusammen mit einem Prompt an das Vision-Modell. + * Nur im Ollama-Modus verfügbar. Gibt '' zurück wenn Vision nicht verfügbar. + * + * @param string $prompt + * @param string[] $base64Images Array von base64-kodierten Bildern (JPEG/PNG) + */ + public function generateVision(string $prompt, array $base64Images): string + { + if ($this->mode !== 'ollama' || empty($base64Images)) return ''; + + $payload = [ + 'model' => $this->visionModel, + 'messages' => [[ + 'role' => 'user', + 'content' => $prompt, + 'images' => $base64Images, + ]], + 'stream' => false, + 'options' => ['temperature' => 0.1], + ]; + + $ch = curl_init($this->url . '/api/chat'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 300, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_POSTFIELDS => json_encode($payload), + ]); + $response = curl_exec($ch); + $err = curl_error($ch); + curl_close($ch); + + if ($err || !$response) return ''; + $data = json_decode($response, true); + return $data['message']['content'] ?? ''; + } + private function detect(): void { $ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? ''); @@ -55,9 +100,10 @@ class LlmService $apiKey = getenv('LLM_API_KEY') ?: ($_ENV['LLM_API_KEY'] ?? ''); if ($ollamaUrl !== '') { - $this->mode = 'ollama'; - $this->url = rtrim($ollamaUrl, '/'); - $this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2'); + $this->mode = 'ollama'; + $this->url = rtrim($ollamaUrl, '/'); + $this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2'); + $this->visionModel = getenv('VISION_MODEL') ?: ($_ENV['VISION_MODEL'] ?? 'qwen2.5vl:latest'); return; } diff --git a/src/Services/NextcloudImportService.php b/src/Services/NextcloudImportService.php index 5d48d9b..6c87641 100644 --- a/src/Services/NextcloudImportService.php +++ b/src/Services/NextcloudImportService.php @@ -69,7 +69,7 @@ class NextcloudImportService */ public function import(): array { - $result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => []]; + $result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [], 'imported_ids' => []]; if (!$this->baseUrl || !$this->user || !$this->pass) { $result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.'; @@ -262,7 +262,7 @@ class NextcloudImportService // In Datenbank anlegen $today = date('Y-m-d'); - Beleg::create([ + $belegId = Beleg::create([ 'beleg_nr' => $belegNr, 'filename' => $filename, 'storage_path' => $storagePath, @@ -280,6 +280,7 @@ class NextcloudImportService $result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}"; $result['imported']++; + $result['imported_ids'][] = $belegId; } /** diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index a37633e..1cda943 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -271,7 +271,7 @@ $suppliers = $suppliers ?? [];
- +
@@ -630,9 +630,10 @@ function updateNettoAnzeige() { } - + diff --git a/src/Views/layout/nav.php b/src/Views/layout/nav.php index ba4433a..3003041 100644 --- a/src/Views/layout/nav.php +++ b/src/Views/layout/nav.php @@ -5,7 +5,7 @@ function navActive(string $prefix, string $current): string { } ?>
Pos.
Pos. BezeichnungMengeEinheitUSt.EinzelpreisGesamtMengeEinheitUSt.EinzelpreisGesamt
%