diff --git a/.env.example b/.env.example index 9c0fad0..6b5bed4 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. # @@ -14,6 +11,9 @@ APP_SECRET=change-this-to-a-random-string # 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/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/Changes.md b/Changes.md index 5ef0494..b13f9e7 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 – Anlagevermögen: Anzeige-Fix und Daten-Backfill ### Fix @@ -23,6 +31,23 @@ --- +## 2026-04-12 – EÜR-Korrekturen: Gewerbesteuer + Bewirtungskonten ohne VSt + +### Geändert +- `migrations/028_euer_4320_bewirtung_ohne_vst.sql`: Gewerbesteuer (4320) von Z. 52 auf Z. 51 (§ 4 Abs. 5b EStG, KZ 165 nicht abzugsfähig) +- `migrations/028_euer_4320_bewirtung_ohne_vst.sql`: Konten 4652 (Bewirtung 70 % ohne VSt, Z. 44) und 4657 (Bewirtung 30 % ohne VSt, Z. 51) ergänzt +- `docs/kontenrahmen.md`: Dokumentation entsprechend aktualisiert + +--- + +## 2026-04-12 – Klasse-0-Konten als Anlagevermögen klassifiziert + +### Geändert +- `docs/kontenrahmen.md`: Alle 10 Klasse-0-Konten (0027–0480) von `Zahlungskonto` auf `Anlagevermögen` korrigiert +- `migrations/027_anlagevermögen_kontenart.sql`: Migration war bereits vorhanden und setzt die DB-Änderung beim nächsten Container-Start um + +--- + ## 2026-04-12 – Backup: smarte Rotation und Lock-Schutz ### Geändert diff --git a/README.md b/README.md index e4eb7d1..66fbe92 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,37 @@ # 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 -- **Anlagevermögen** – Anlagenspiegel für Wirtschaftsgüter: automatische Anlagen-Nummerierung (konfiguriertes Präfix + Jahrgang + laufende Nummer), Verknüpfung mit Anlagevermögenskonten des SKR03; beim Buchen auf ein Anlagevermögenskonto öffnet sich automatisch ein Dialog zur Auswahl (neues Wirtschaftsgut anlegen oder bestehendes verknüpfen); Anlagennummer wird in allen Buchungsübersichten (Beleg-Detail, Journal, UStVA-Drill-down, EÜR-Drill-down) als direkter Link zur Bearbeitungsmaske des Anlageguts angezeigt -- **Einstellungen** – Firmendaten, Logo, ELSTER-Steuernummer, Nummernkreis-Präfixe (Belege, Rechnungen, Anlagen) -- **Benutzerverwaltung** – Benutzer anlegen, bearbeiten, deaktivieren und löschen; session-basierte Authentifizierung mit 24 h Timeout - -## Tech Stack - -Dieses Projekt wurden mit Unterstützung von [Claude Code](https://claude.ai/code) entwickelt. - -![PHP](https://img.shields.io/badge/PHP-777BB4?logo=php&logoColor=white) -![HTML](https://img.shields.io/badge/HTML-E34F26?logo=html5&logoColor=white) -![CSS](https://img.shields.io/badge/CSS-1572B6?logo=css3&logoColor=white) -![JavaScript](https://img.shields.io/badge/JavaScript-F7DF1E?logo=javascript&logoColor=black) - -| Schicht | Technologie | +| 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 ``` @@ -353,71 +327,24 @@ Anschließend im Browser unter `http://localhost:8080` (oder dem konfigurierten --- -#### 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 | +| [Datenbankstruktur](docs/datenbankstruktur.md) | Tabellen, Spalten, Fremdschlüssel, GoBD-Mechanismen | +| [Berechnungslogik](docs/berechnungslogik.md) | UStVA-Kennzahlen und EÜR-Zuordnungsregeln | +| [Kontenrahmen](docs/kontenrahmen.md) | Vollständige SKR03-Kontenliste | -#### Mit Systemd-Service (Kurzform) - -```bash -bash scripts/backup.sh -git pull -docker compose build -sudo systemctl restart steuerkram -journalctl -u steuerkram | grep migrate -``` +--- ## GoBD-Hinweise - 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 96% rename from BERICHTE.md rename to docs/berechnungslogik.md index b84bb83..9d4cd07 100644 --- a/BERICHTE.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/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. 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..f4555c9 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,139 @@ +# Installation + +## Voraussetzungen + +- [Docker](https://docs.docker.com/get-docker/) ≥ 24 +- [Docker Compose](https://docs.docker.com/compose/install/) ≥ 2.20 (in neueren Docker-Versionen enthalten) +- Git + +--- + +## Automatische Installation + +Das Script `install.sh` übernimmt die gesamte Installation: es erkennt die Linux-Distribution, installiert git, curl und Docker, klont das Repository in das gewünschte Verzeichnis und legt die `.env`-Datei an. + +Das Script muss **vor** dem Klonen des Repositories heruntergeladen werden – es erledigt das Klonen selbst: + +```bash +curl -fsSL https://codeberg.org/fds2610/Steuerkram/raw/branch/main/install.sh -o install.sh +sudo bash install.sh +``` + +> Das Script muss als root ausgeführt werden, da es Pakete installiert und ggf. den Docker-Dienst aktiviert. + +Nach der Installation die `.env`-Datei im gewählten Installationsverzeichnis anpassen und die Container starten: + +```bash +cd /opt/Steuerkram # oder das gewählte Verzeichnis +docker compose up -d +``` + +--- + +## Manuelle Installation + +### 1. Repository klonen + +```bash +git clone steuerkram +cd steuerkram +``` + +### 2. Konfiguration anpassen + +```bash +cp .env.example .env +``` + +```dotenv +DB_PASS=sicheres-passwort # Datenbankpasswort – in docker-compose.yml spiegeln +``` + +Außerdem in `docker-compose.yml` das `MARIADB_PASSWORD` im `db`-Service auf denselben Wert wie `DB_PASS` setzen. + +### 3. Container starten + +```bash +docker compose up -d +``` + +Beim ersten Start werden automatisch: +- die Datenbank initialisiert +- der SKR03-Kontenrahmen eingespielt +- das Docker-Image mit PHP-Abhängigkeiten (mPDF) gebaut + +### 4. Anwendung aufrufen + +``` +http://localhost:8080 +``` + +Beim ersten Aufruf erscheint die **Ersteinrichtung**: dort den ersten Benutzer-Account anlegen. Danach ist die Anwendung passwortgeschützt. + +--- + +## Port ändern + +Der Standard-Port ist `8080`. Um ihn zu ändern, die `ports`-Zeile in `docker-compose.yml` anpassen: + +```yaml +ports: + - "9090:80" # Beispiel: Port 9090 auf dem Host +``` + +--- + +## Upload-Limit anpassen + +Die maximale Dateigröße für Beleganhänge ist auf **20 MB** voreingestellt (`php/uploads.ini`). Um das Limit zu ändern: + +```ini +upload_max_filesize = 20M +post_max_size = 20M +``` + +```bash +docker compose up -d --build +``` + +--- + +## Systemd-Service einrichten + +Damit Steuerkram automatisch beim Systemstart startet, liegt im Repository die Datei `steuerkram.service`. + +### 1. Service-Datei installieren + +```bash +sudo cp steuerkram.service /etc/systemd/system/steuerkram.service +``` + +Die Datei setzt `WorkingDirectory=/opt/Steuerkram` voraus. Falls das Repository woanders liegt, den Pfad anpassen: + +```bash +sudo nano /etc/systemd/system/steuerkram.service +``` + +### 2. Service aktivieren und starten + +```bash +sudo systemctl daemon-reload +sudo systemctl enable steuerkram +sudo systemctl start steuerkram +``` + +### 3. Status prüfen + +```bash +sudo systemctl status steuerkram +journalctl -u steuerkram -f # Live-Log inkl. Migrations-Ausgabe +``` + +### Wichtige Befehle + +| Befehl | Wirkung | +|---|---| +| `sudo systemctl start steuerkram` | Container starten | +| `sudo systemctl stop steuerkram` | Container stoppen | +| `sudo systemctl restart steuerkram` | Neustart (z.B. nach Update) | +| `sudo systemctl status steuerkram` | Aktueller Status | diff --git a/docs/konfiguration.md b/docs/konfiguration.md new file mode 100644 index 0000000..ff89fd0 --- /dev/null +++ b/docs/konfiguration.md @@ -0,0 +1,95 @@ +# Konfiguration + +## Firmendaten hinterlegen + +Nach dem ersten Login unter **Einstellungen** Firmendaten, Logo und ELSTER-Steuernummer eintragen. Diese Angaben erscheinen auf Ausgangsrechnungen und im EÜR/UStVA-Export. + +--- + +## Nummernkreise + +Unter **Einstellungen → Nummernkreise** können Präfixe für die automatisch vergebenen Nummern konfiguriert werden: + +| Nummernkreis | Standard | Beispiel | +|---|---|---| +| Belege | `BLG` | `BLG-2026-0001` | +| Ausgangsrechnungen | `RE` | `RE-2026-0001` | +| Anlagen | `ANL` | `ANL-2026-0001` | + +Das Format ist immer `{Präfix}-{Jahr}-{laufende Nummer}`. Die laufende Nummer wird automatisch hochgezählt und kann nicht zurückgesetzt werden (GoBD). + +--- + +## KI-Kontierungsvorschlag einrichten + +Der KI-Kontierungsvorschlag analysiert Belege automatisch beim Nextcloud-Import und füllt das Buchungsformular mit einem Vorschlag vor. Die Funktion ist standardmäßig deaktiviert und kann unter **Einstellungen → KI-Kontierungsvorschlag** aktiviert werden. + +### Voraussetzungen + +- Nextcloud-Import muss konfiguriert und aktiv sein (siehe unten) +- Ein erreichbares LLM (lokal via Ollama oder Remote-API) +- Für Bildbelege (JPEG, PNG, TIFF): ein Vision-fähiges Modell (z.B. `qwen2.5vl`, `llava`); ohne Vision-Unterstützung werden Bildbelege ohne Textextraktion verarbeitet +- Für PDF-Belege: `pdftotext` und `pdftoppm` (Teil von `poppler-utils`, im Docker-Image enthalten) + +### Option A – Lokales Ollama + +Kein API-Schlüssel nötig, aber hoher Ressourcenbedarf: + +```bash +ollama pull qwen2.5vl # Vision-fähig (empfohlen), alternativ mistral o.ä. +``` + +In `.env` ist keine weitere Konfiguration nötig – die App erkennt Ollama auf `localhost:11434` automatisch. Modell und URL können optional überschrieben werden: + +```dotenv +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=qwen2.5vl +``` + +### Option B – Remote-API (OpenAI-kompatibel) + +z.B. OpenAI, OpenRouter, LM Studio: + +```dotenv +LLM_API_URL=https://api.openai.com/v1 +LLM_API_KEY=sk-... +LLM_MODEL=gpt-4o-mini +``` + +> **Hinweis:** Die Ergebnisqualität hängt stark vom gewählten Modell ab. Für zuverlässige Ergebnisse wird ein Vision-fähiges Modell empfohlen. + +--- + +## Nextcloud-Import einrichten + +Belege können automatisch aus einem Nextcloud-Ordner via WebDAV importiert werden. Das CLI-Skript `scripts/nextcloud-import.php` liest alle unterstützten Dateien (PDF, JPEG, PNG, TIFF, XML) aus dem konfigurierten Ordner, legt sie als Belege an und verschiebt sie anschließend in einen Erledigte-Ordner. + +### Zugangsdaten konfigurieren + +Die Zugangsdaten werden in der Steuerkram-UI gepflegt: **Einstellungen → Nextcloud-Belegimport**. + +Hinweis: Ein Nextcloud-App-Passwort verwenden (Nextcloud → Einstellungen → Sicherheit → App-Passwörter). + +### Manueller Aufruf + +```bash +docker compose exec web php scripts/nextcloud-import.php +``` + +### Automatisch per Cron + +Cron-Eintrag auf dem Host anlegen (`crontab -e`): + +```cron +*/15 * * * * docker compose -f /opt/Steuerkram/docker-compose.yml exec -T web \ + php scripts/nextcloud-import.php >> /var/log/steuerkram-import.log 2>&1 +``` + +> Das `-T`-Flag ist bei Cron zwingend erforderlich – ohne TTY schlägt `docker compose exec` sonst still fehl. + +Damit `docker compose exec` ohne `sudo` funktioniert, muss der User in der Docker-Gruppe sein: + +```bash +sudo usermod -aG docker $USER +# Danach neu einloggen +``` diff --git a/Kontenrahmen.md b/docs/kontenrahmen.md similarity index 92% rename from Kontenrahmen.md rename to docs/kontenrahmen.md index 06ac265..ab575ac 100644 --- a/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 | – | – | – | --- @@ -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/docs/update-backup.md b/docs/update-backup.md new file mode 100644 index 0000000..3e45610 --- /dev/null +++ b/docs/update-backup.md @@ -0,0 +1,93 @@ +# Update & Backup + +## Backup + +```bash +bash scripts/backup.sh +``` + +Das Skript erstellt einen Dump der Datenbank sowie ein Archiv der Uploads im Verzeichnis `backups/`. Die Rotation erfolgt nach folgendem Schema: + +- 7 Tages-Backups +- 4 Wochen-Backups +- 12 Monats-Backups + +Ein `flock`-Lock verhindert parallele Läufe bei Verzögerungen. + +### Manuelles Backup + +```bash +docker compose exec db mariadb-dump -u steuerkram -psteuerkram steuerkram \ + > ~/steuerkram_backup_$(date +%Y%m%d_%H%M).sql +``` + +--- + +## Datenpersistenz + +| Pfad | Inhalt | +|---|---| +| `./data/mariadb/` | MariaDB-Datenbankdateien | +| Docker Volume `uploads` | Hochgeladene Belege | + +Beide Pfade liegen außerhalb des Containers und bleiben bei `docker compose down` erhalten. + +--- + +## Update + +Für ein vollständiges Update steht `scripts/update.sh` bereit. Es führt alle notwendigen Schritte automatisch aus. + +```bash +./scripts/update.sh +``` + +Das Script erledigt in dieser Reihenfolge: + +1. **Voraussetzungen** – Docker, Docker Compose und Git werden geprüft +2. **Backup** – `scripts/backup.sh` wird vor jeder Änderung aufgerufen +3. **git pull** – Code wird aktualisiert; geänderte Dateien werden erkannt +4. **Image-Rebuild** – Das Docker-Image wird neu gebaut, wenn `Dockerfile`, `composer.json`, `composer.lock`, `php/`, `apache/` oder `docker-entrypoint.sh` geändert wurden +5. **Container-Neustart** – `docker compose down --remove-orphans` + `up -d` +6. **Healthcheck** – Wartet bis die Datenbank `healthy` meldet (max. 120 s) +7. **Migrationen** – `scripts/migrate.php` wird explizit ausgeführt (idempotent) +8. **Tests** – Die Test-Suite wird ausgeführt und das Ergebnis angezeigt + +### Optionen + +| Option | Bedeutung | +|---|---| +| `--no-backup` | Backup überspringen (z. B. wenn gerade erst gesichert) | +| `--no-tests` | Tests nicht ausführen (schnellerer Ablauf) | +| `--force-rebuild` | Docker-Image immer neu bauen, unabhängig von Dateiänderungen | + +### Migrationen + +Datenbankmigrationen werden von `scripts/migrate.php` verwaltet. Das Script prüft, welche Migrations-Dateien noch nicht angewendet wurden, und führt sie der Reihe nach aus. Es wird sowohl vom Container-Entrypoint als auch von `update.sh` aufgerufen. + +``` +[migrate] Starte Migrations-Prüfung... +[migrate] Führe aus: NNN_beschreibung.sql +[migrate] N Migration(en) erfolgreich angewendet. +``` + +Migrationen können auch manuell ausgelöst werden: + +```bash +docker compose exec web php scripts/migrate.php +``` + +### Diagnose bei Fehlern + +```bash +docker compose ps # Container-Status +docker compose logs web # Web-Container-Log (PHP-Fehler, Migrationen) +docker compose logs db # Datenbank-Log +``` + +Falls ein Update fehlschlägt und ein Rollback nötig ist, das letzte Backup einspielen: + +```bash +docker compose exec -T db mariadb -u steuerkram -psteuerkram steuerkram \ + < backups/steuerkram_db_YYYYMMDD_HHMM.sql +``` 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); 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/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/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/scripts/update.sh b/scripts/update.sh new file mode 100755 index 0000000..982e42e --- /dev/null +++ b/scripts/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="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_DIR" + +# ── Farben ─────────────────────────────────────────────────────────────────── +if [[ -t 1 ]]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + CYAN='\033[0;36m' + BOLD='\033[1m' + NC='\033[0m' +else + RED='' GREEN='' YELLOW='' CYAN='' BOLD='' NC='' +fi + +step() { echo -e "\n${BOLD}${CYAN}══ $* ${NC}"; } +ok() { echo -e " ${GREEN}✓${NC} $*"; } +warn() { echo -e " ${YELLOW}⚠${NC} $*"; } +fail() { echo -e " ${RED}✗${NC} $*" >&2; } +info() { echo -e " $*"; } + +# ── Fehlerbehandlung ───────────────────────────────────────────────────────── +UPDATE_FAILED=false +on_error() { + local exit_code=$? + local line=$1 + fail "Fehler in Zeile $line (Exit-Code: $exit_code)" + UPDATE_FAILED=true +} +trap 'on_error $LINENO' ERR + +# ── Startzeit ──────────────────────────────────────────────────────────────── +START_TIME=$(date +%s) +echo "" +echo -e "${BOLD}════════════════════════════════════════════════════════${NC}" +echo -e "${BOLD} Steuerkram Update – $(date '+%Y-%m-%d %H:%M:%S')${NC}" +echo -e "${BOLD}════════════════════════════════════════════════════════${NC}" + +# ════════════════════════════════════════════════════════════════════════════ +# SCHRITT 1: Voraussetzungen prüfen +# ════════════════════════════════════════════════════════════════════════════ +step "1/8 Voraussetzungen prüfen" + +if ! command -v docker &>/dev/null; then + fail "Docker ist nicht installiert oder nicht im PATH." + exit 1 +fi +ok "Docker gefunden: $(docker --version | head -1)" + +if ! docker info &>/dev/null 2>&1; then + fail "Docker-Daemon läuft nicht. Bitte starten: sudo systemctl start docker" + exit 1 +fi +ok "Docker-Daemon erreichbar" + +if ! docker compose version &>/dev/null 2>&1; then + fail "Docker Compose (Plugin) nicht verfügbar." + exit 1 +fi +ok "Docker Compose gefunden: $(docker compose version)" + +if ! git -C "$PROJECT_DIR" rev-parse --git-dir &>/dev/null; then + fail "Kein Git-Repository in $PROJECT_DIR" + exit 1 +fi +ok "Git-Repository gefunden" + +# ════════════════════════════════════════════════════════════════════════════ +# SCHRITT 2: Backup erstellen +# ════════════════════════════════════════════════════════════════════════════ +step "2/8 Backup erstellen" + +if [[ "$OPT_NO_BACKUP" == "true" ]]; then + warn "Backup übersprungen (--no-backup)" +else + # Prüfen ob Container läuft (Backup braucht laufende DB) + if docker compose ps --status running db 2>/dev/null | grep -q "db"; then + info "Erstelle Backup vor dem Update..." + if bash "$PROJECT_DIR/scripts/backup.sh"; then + ok "Backup erfolgreich erstellt" + else + warn "Backup fehlgeschlagen – Update wird trotzdem fortgesetzt" + warn "Prüfe den Backup-Fehler manuell: ./scripts/backup.sh" + fi + else + warn "Datenbank-Container nicht aktiv – Backup übersprungen" + warn "Beim ersten Start ist das normal." + fi +fi + +# ════════════════════════════════════════════════════════════════════════════ +# SCHRITT 3: git pull + Änderungen erkennen +# ════════════════════════════════════════════════════════════════════════════ +step "3/8 Code aktualisieren (git pull)" + +BEFORE_COMMIT=$(git rev-parse HEAD) +BEFORE_SHORT=$(git rev-parse --short HEAD) + +# Lokale Änderungen prüfen +if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then + warn "Ungespeicherte lokale Änderungen vorhanden:" + git status --short | head -20 | while read -r line; do info "$line"; done + warn "Diese Änderungen bleiben erhalten (kein stash)." +fi + +git pull --ff-only 2>&1 | while read -r line; do info "$line"; done + +AFTER_COMMIT=$(git rev-parse HEAD) +AFTER_SHORT=$(git rev-parse --short HEAD) + +if [[ "$BEFORE_COMMIT" == "$AFTER_COMMIT" ]]; then + ok "Bereits auf dem neuesten Stand ($AFTER_SHORT)" + CHANGED_FILES=() +else + ok "Aktualisiert: $BEFORE_SHORT → $AFTER_SHORT" + mapfile -t CHANGED_FILES < <(git diff --name-only "$BEFORE_COMMIT" "$AFTER_COMMIT" 2>/dev/null || true) + info "Geänderte Dateien (${#CHANGED_FILES[@]}):" + for f in "${CHANGED_FILES[@]}"; do info " $f"; done +fi + +# ── Rebuild-Bedarf ermitteln ───────────────────────────────────────────────── +NEEDS_REBUILD=false + +if [[ "$OPT_FORCE_REBUILD" == "true" ]]; then + NEEDS_REBUILD=true + info "Rebuild erzwungen (--force-rebuild)" +fi + +# Dateien, die einen Rebuild des Docker-Images erfordern +REBUILD_TRIGGERS=( + "Dockerfile" + "composer.json" + "composer.lock" + "php/" + "apache/" + "docker-entrypoint.sh" +) + +if [[ "$BEFORE_COMMIT" != "$AFTER_COMMIT" ]]; then + for trigger in "${REBUILD_TRIGGERS[@]}"; do + for changed in "${CHANGED_FILES[@]}"; do + if [[ "$changed" == "$trigger" || "$changed" == ${trigger}* ]]; then + NEEDS_REBUILD=true + info "Rebuild ausgelöst durch: $changed" + break 2 + fi + done + done +fi + +# ════════════════════════════════════════════════════════════════════════════ +# SCHRITT 4: Docker-Image bauen (wenn nötig) +# ════════════════════════════════════════════════════════════════════════════ +step "4/8 Docker-Image" + +if [[ "$NEEDS_REBUILD" == "true" ]]; then + info "Baue Docker-Image neu..." + docker compose build --no-cache web 2>&1 | while read -r line; do + # Fortschrittszeilen komprimieren + [[ "$line" =~ ^#[0-9]+ ]] && echo -n "." || info "$line" + done + echo "" + ok "Image erfolgreich gebaut" +else + ok "Kein Rebuild nötig (keine relevanten Änderungen)" +fi + +# ════════════════════════════════════════════════════════════════════════════ +# SCHRITT 5: Container neu starten +# ════════════════════════════════════════════════════════════════════════════ +step "5/8 Container neu starten" + +info "Stoppe laufende Container..." +docker compose down --remove-orphans 2>&1 | while read -r line; do info "$line"; done + +info "Starte Container..." +docker compose up -d 2>&1 | while read -r line; do info "$line"; done + +ok "Container gestartet" + +# ════════════════════════════════════════════════════════════════════════════ +# SCHRITT 6: Auf Betriebsbereitschaft warten +# ════════════════════════════════════════════════════════════════════════════ +step "6/8 Auf Betriebsbereitschaft warten" + +TIMEOUT=120 +ELAPSED=0 +INTERVAL=3 + +info "Warte auf Datenbank-Healthcheck (max. ${TIMEOUT}s)..." + +while true; do + DB_STATUS=$(docker compose ps --format json db 2>/dev/null \ + | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('Health',''))" 2>/dev/null \ + || docker compose ps db 2>/dev/null | grep -oP '(healthy|starting|unhealthy)' | head -1 \ + || echo "unknown") + + if [[ "$DB_STATUS" == "healthy" ]]; then + ok "Datenbank ist bereit (${ELAPSED}s)" + break + fi + + if [[ $ELAPSED -ge $TIMEOUT ]]; then + fail "Timeout: Datenbank nicht bereit nach ${TIMEOUT}s" + fail "Status: $DB_STATUS" + info "Logs: docker compose logs db" + UPDATE_FAILED=true + break + fi + + echo -n "." + sleep $INTERVAL + ELAPSED=$((ELAPSED + INTERVAL)) +done +echo "" + +# Web-Container kurz warten (Entrypoint läuft noch) +info "Warte auf Web-Container..." +sleep 5 + +WEB_STATUS=$(docker compose ps --format json web 2>/dev/null \ + | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('State',''))" 2>/dev/null \ + || docker compose ps web 2>/dev/null | grep -oP '(running|exited|restarting)' | head -1 \ + || echo "unknown") + +if [[ "$WEB_STATUS" == "running" ]]; then + ok "Web-Container läuft" +else + warn "Web-Container Status: $WEB_STATUS" + info "Logs der letzten 20 Zeilen:" + docker compose logs --tail=20 web 2>/dev/null | while read -r line; do info "$line"; done +fi + +# ════════════════════════════════════════════════════════════════════════════ +# SCHRITT 7: Migrationen explizit ausführen +# ════════════════════════════════════════════════════════════════════════════ +step "7/8 Datenbank-Migrationen" + +# Der Entrypoint führt Migrationen bereits aus, aber wir führen sie +# nochmals explizit aus um sicherzustellen, dass alle eingespielt sind +# und um den Status im Log zu sehen. + +info "Führe Migrationen aus (idempotent)..." +MIGRATE_OUTPUT=$(docker compose exec -T web php /var/www/html/scripts/migrate.php 2>&1) || { + fail "Migrationen fehlgeschlagen" + echo "$MIGRATE_OUTPUT" | while read -r line; do info "$line"; done + UPDATE_FAILED=true +} + +echo "$MIGRATE_OUTPUT" | while read -r line; do info "$line"; done + +if [[ "$UPDATE_FAILED" == "false" ]]; then + ok "Migrationen erfolgreich" +fi + +# ════════════════════════════════════════════════════════════════════════════ +# SCHRITT 8: Tests ausführen +# ════════════════════════════════════════════════════════════════════════════ +step "8/8 Tests" + +if [[ "$OPT_NO_TESTS" == "true" ]]; then + warn "Tests übersprungen (--no-tests)" +else + info "Führe Test-Suite aus..." + TEST_OUTPUT=$(docker compose exec -T web php /var/www/html/tests/run.php 2>&1) || true + TEST_EXIT=${PIPESTATUS[0]:-0} + + echo "$TEST_OUTPUT" | while read -r line; do info "$line"; done + + if echo "$TEST_OUTPUT" | grep -q "FEHLER\|FAILED\|fehlgeschlagen\|failed"; then + warn "Einige Tests sind fehlgeschlagen – bitte prüfen" + warn "Vollständiger Testlauf: docker compose exec web php tests/run.php" + else + ok "Alle Tests bestanden" + fi +fi + +# ════════════════════════════════════════════════════════════════════════════ +# ZUSAMMENFASSUNG +# ════════════════════════════════════════════════════════════════════════════ +END_TIME=$(date +%s) +DURATION=$((END_TIME - START_TIME)) + +echo "" +echo -e "${BOLD}════════════════════════════════════════════════════════${NC}" + +if [[ "$UPDATE_FAILED" == "true" ]]; then + echo -e "${RED}${BOLD} Update mit Fehlern abgeschlossen (${DURATION}s)${NC}" + echo -e "${BOLD}════════════════════════════════════════════════════════${NC}" + echo "" + echo " Diagnose:" + echo " docker compose logs web" + echo " docker compose logs db" + echo " docker compose ps" + echo "" + exit 1 +else + echo -e "${GREEN}${BOLD} Update erfolgreich (${DURATION}s)${NC}" + echo -e "${BOLD}════════════════════════════════════════════════════════${NC}" + echo "" + if [[ "$BEFORE_COMMIT" != "$AFTER_COMMIT" ]]; then + echo -e " ${CYAN}$BEFORE_SHORT${NC} → ${GREEN}$AFTER_SHORT${NC}" + else + echo -e " Commit: ${GREEN}$AFTER_SHORT${NC} (keine neuen Commits)" + fi + echo "" + echo " Anwendung: http://localhost:8080" + echo "" +fi 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' => '', ]; diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index 9a70468..6e84374 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -206,6 +206,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', [ @@ -218,6 +220,7 @@ class BelegController 'accounts' => $accounts, 'assets' => $assets, 'kiAktiv' => $kiAktiv, + 'kiSektion' => $kiSektion, 'kiVorschlag' => $kiVorschlag, ]), ]); 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 7db02ba..e610d29 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=?, @@ -29,9 +33,11 @@ class Setting iban=?, bic=?, bank_name=?, ustva_steuernummer=?, ustva_bundesland=?, invoice_nr_prefix=?, beleg_nr_prefix=?, anlage_nr_prefix=?, - payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?, ocr_aktiv=?, + payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?, nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?, - nextcloud_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'] ?? '', @@ -56,12 +62,16 @@ 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, $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/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/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/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 << - +
@@ -821,9 +821,10 @@ function updateNettoAnzeige() { } - + diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index 5b40d86..046ca38 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -15,6 +15,15 @@ if (!empty($invoice['leistungszeitraum_von'])) { } } +// Leistungszeitraum mit
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/layout/nav.php b/src/Views/layout/nav.php index 2831e11..4d5e3c7 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
%