diff --git a/Changes.md b/Changes.md index 4029892..2527bbe 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,56 @@ # Änderungen +## Phase 1 – Stammdaten und QIF-Stammdatenimport (09.08.2026) + +**Datenmodell** +- Migration `002_stammdaten.sql`: `kontengruppen`, `konten`, `kategorien`, `klassen` +- Alle Tabellen führen `benutzer_id`; `Mandant::id()` liefert die Kennung aus der Session + oder – im CLI – vom ersten aktiven Benutzer + +**Konten** +- Kontenübersicht nach Kontengruppen gebündelt, mit Gruppensummen und Gesamtsumme +- Die drei Saldospalten des FinanzManagers (Online-Saldo, aktueller und erwarteter Saldo) + sind angelegt und werden bis Phase 2 aus dem Anfangssaldo gespeist +- Kontoarten des FinanzManagers samt Zuordnung zu den QIF-Typen +- Kontenpflege mit Anfangssaldo, Kreditlimit, IBAN/BIC, Kontengruppe, aktiv/inaktiv +- Kontengruppen anlegen, umbenennen, sortieren, löschen – die Konten bleiben erhalten + +**Kategorien und Klassen** +- Hierarchische Kategorien mit `:` als Trenner; der vollständige Pfad wird mitgeführt und + beim Umbenennen oder Verschieben für den ganzen Teilbaum neu geschrieben +- Art (Einnahme/Ausgabe) gilt für den ganzen Ast und vererbt sich an Unterkategorien +- Verschieben, Zusammenführen und Löschen; Verschieben in den eigenen Teilbaum wird + abgewiesen +- Register *Alle / Einnahmen / Ausgaben / Umbuchungskonten* wie im Original +- Klassen als flache zweite Auswertungsdimension, in der Liste direkt bearbeitbar + +**QIF-Import** +- `QifParser`: zustandsbehaftetes, sequenzielles Lesen; ISO-8859-1 nach UTF-8, US-Beträge, + Datumsformat aus der Kopfzeile, Mehrfachfelder für spätere Splitzeilen +- `QifStammdatenImport` für Klassen, Kategorien und Konten – idempotent, mit Trockenlauf + und Protokoll; fehlende Zwischenstufen im Kategoriepfad entstehen selbsttätig +- Dialog unter *Verwaltung → QIF-Import* mit Upload, Trockenlauf und Übernahme +- `scripts/qif-import.php` für dieselbe Aufgabe auf der Kommandozeile +- Geprüft am echten Export: 17 Klassen, 105 Kategorien, 37 Konten + +**Tests** +- `06_KategorienTest` – Hierarchie, Pfadpflege, Artvererbung, Verschieben, Zusammenführen +- `07_KontenTest` – Kontoarten, Gruppierung und Summen, Betragseingabe +- `08_QifImportTest` – Parser, Zeichensatz, Beträge und Datum, Import, Trockenlauf, + Prüfung der Dateinamen +- E2E `stammdaten.spec.js` – Konto, Kategorie mit Unterkategorie, Register, Klasse, + QIF-Upload mit Trockenlauf + +## Korrektur – Monatsnamen auf Deutsch (09.08.2026) + +- Der Einstellungsdialog zeigte unter *Buchhaltung* englische Monatsnamen, weil + `date('F')` sprachlich nicht umschaltbar ist. +- Monatsnamen liegen jetzt zentral als `View::MONATE` und `View::MONATE_KURZ` vor, + abrufbar über `View::monat()`. Budgetplaner und Auswertungen greifen später darauf zu. +- `05_DarstellungTest` prüft Monatsnamen, Betrags- und Datumsformat gegen eine feste + Sollliste – der Vergleich gegen `date('F')` allein würde April, August, September und + November nicht auffallen lassen. + ## Phase 0 – Projektgerüst (09.08.2026) Grundgerüst der Anwendung. Fachliche Module folgen ab Phase 1. diff --git a/PLAN.md b/PLAN.md index c4dce20..3ec8749 100644 --- a/PLAN.md +++ b/PLAN.md @@ -179,7 +179,7 @@ und Protokoll (importiert / übersprungen / fehlerhaft je Zeile). Nach dem Impor ## 5. Phasen -### Phase 0 — Projektgerüst +### Phase 0 — Projektgerüst *(erledigt)* * Repository auf Codeberg (Upstream persönlicher Account, Fork BOT-Account, PR-Workflow gemäß globaler Regeln), `.gitignore`, `LICENSE`, `README.md`, `CLAUDE.md`, `Changes.md`. * Docker Compose mit nginx (Port 8000) + php-fpm + MariaDB, `docker-entrypoint.sh`, @@ -194,17 +194,18 @@ und Protokoll (importiert / übersprungen / fehlerhaft je Zeile). Nach dem Impor Feature mit UI einen E2E-Test. * *Ergebnis:* Login funktioniert, leere Anwendung ist erreichbar, `./scripts/run-tests.sh` läuft grün. -### Phase 1 — Stammdaten und QIF-Stammdatenimport +### Phase 1 — Stammdaten und QIF-Stammdatenimport *(erledigt)* * Migrationen für `konten`, `kontengruppen`, `kategorien`, `klassen`. * Verwaltung Kategorien (Baumansicht mit Registern *Alle / Einnahmen / Ausgaben / Umbuchungskonten*, Anlegen, Bearbeiten, Verschieben, Ersetzen, Zusammenführen). * Verwaltung Klassen, Kontengruppen, Konten (Anlegen/Bearbeiten, Kontoarten, Anfangssaldo). * **Kontenübersicht** mit Gruppierung, Spalten Online-Saldo / Aktueller Saldo / Erwarteter Saldo, Summenzeile. -* `src/Services/QifParser.php` + `QifImporter.php`, zunächst für `!Type:Class`, - `!Type:Cat`, `!Account`. +* `src/Services/QifParser.php` + `QifStammdatenImport.php`, zunächst für `!Type:Class`, + `!Type:Cat`, `!Account` — mit Trockenlauf, Protokoll und `scripts/qif-import.php` für + die Kommandozeile. * *Ergebnis:* 17 Klassen, 105 Kategorien und 37 Konten sind aus dem Export übernommen und - in der Oberfläche gepflegt. + in der Oberfläche gepflegt. Einzelheiten in `docs/stammdaten.md` und `docs/qif-import.md`. ### Phase 2 — Buchungen * Migrationen `buchungen`, `buchung_splits`, `buchungsadressen`. diff --git a/README.md b/README.md index cb27823..4b2f048 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ Haushalt wird beides parallel genutzt. ## Stand -Phase 0 (Grundgerüst) ist gebaut: Anmeldung, zentraler Einstellungsdialog, Debug-Log, -Migrationsmechanik, Test-Harness. Die fachlichen Module folgen nach `PLAN.md`. +Phase 0 (Grundgerüst) und Phase 1 (Stammdaten) sind gebaut: Anmeldung, zentraler +Einstellungsdialog, Debug-Log, Migrationsmechanik, Test-Harness sowie Konten, +Kontengruppen, Kategorien, Klassen und der QIF-Stammdatenimport. Buchungen folgen in +Phase 2 nach `PLAN.md`. ## Technik @@ -62,8 +64,20 @@ verschlüsselt und nicht auswertbar. Der Importer liest Klassen, Kategorien, Kon Buchungen samt Splits sowie Wertpapierbuchungen. Einzelheiten zum Format und den Fallstricken stehen in `PLAN.md`, Kapitel 4. +Klassen, Kategorien und Konten lassen sich bereits übernehmen – im Dialog unter +*Verwaltung → QIF-Import* oder auf der Kommandozeile: + +```bash +docker compose exec -u www-data php php scripts/qif-import.php data/import/Export.QIF +docker compose exec -u www-data php php scripts/qif-import.php --uebernehmen data/import/Export.QIF +``` + +Ohne `--uebernehmen` läuft nur der Trockenlauf. + ## Dokumentation - `PLAN.md` – Projektplan mit allen Phasen - `CLAUDE.md` – Entwicklungsregeln - `Changes.md` – Änderungsprotokoll +- `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen +- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs diff --git a/docs/qif-import.md b/docs/qif-import.md new file mode 100644 index 0000000..52ae015 --- /dev/null +++ b/docs/qif-import.md @@ -0,0 +1,102 @@ +# QIF-Import + +Der Lexware FinanzManager exportiert erweitertes Quicken-QIF. Die `.QDF`-Datei ist +verschlüsselt und damit keine Migrationsquelle – der QIF-Export ist der einzige Weg. + +Stand: Klassen, Kategorien und Konten werden übernommen (Phase 1). Buchungen, Splits, +Umbuchungen und Wertpapierbuchungen folgen in Phase 2 und 6. + +## Aufbau der Datei + +``` +!Option:MDY Datumsformat für die gesamte Datei +!Type:Class Klassen +!Type:Cat Kategorien +!Option:AutoSwitch Beginn der Kontenliste +!Account ein Konto je Eintrag +!Clear:AutoSwitch Ende der Kontenliste +!Account / !Type:… ab hier abwechselnd Konto und dessen Buchungen +``` + +Ein Eintrag endet mit `^`. Jede Zeile beginnt mit einem Feldbuchstaben, der Rest ist der +Wert. Felder dürfen mehrfach auftreten (Splitzeilen, Adresszeilen), deshalb hält +`QifParser` jeden Eintrag als Liste von Paaren und bietet `feld()`, `felder()` und +`hatFeld()`. + +**Die Datei ist zustandsbehaftet.** Der Kontobezug einer Buchung steckt allein darin, +welcher `!Account`-Abschnitt zuletzt kam – nach Sektionstypen gruppiert gelesen wäre er +verloren. Der Parser liefert daher eine flache Liste in Dateireihenfolge, jeder Eintrag mit +Sektionstyp und aktuellem Konto. + +Jeder Kontoeintrag setzt zugleich das aktuelle Konto. In der Kontenliste folgen mehrere +aufeinander, ohne dass Buchungen dazwischenstehen; das schadet nicht und deckt zugleich die +zweite Schreibweise ab, bei der ein einzelner `!Account` den Kontowechsel bedeutet. + +## Felder der Stammdaten + +| Sektion | Feld | Bedeutung | +|---|---|---| +| `!Type:Class` | `N` / `D` | Name, Beschreibung | +| `!Type:Cat` | `N` | vollständiger Pfad mit `:` | +| | `D` | Beschreibung | +| | `I` / `E` | Einnahme bzw. Ausgabe (Marker ohne Wert) | +| | `T` | steuerrelevant (Marker ohne Wert) | +| | `R` | Kennziffer des Steuerformulars | +| `!Account` | `N` / `T` | Name, Kontotyp (siehe `docs/stammdaten.md`) | +| | `D` | Beschreibung; `Kontonr. 12345` wird als Kontonummer übernommen | +| | `L` | Kreditlimit im US-Format | + +## Fallstricke + +1. **Zeichensatz ISO-8859-1, Zeilenenden CRLF.** `QifParser::nachUtf8()` prüft erst, ob + schon UTF-8 vorliegt – eine zweite Konvertierung zerstörte die Umlaute. +2. **Beträge im US-Format**: `-1,241.04`. Das Komma ist Tausendertrenner. +3. **Datum laut Kopfzeile**, meist `MDY` mit zweistelligem Jahr: `D3.18.26` ist der + 18.03.2026. Jahreszahlen ab 70 gelten als 19xx. +4. **Kategorie und Klasse in einer Zeile**: `L400 - Leben:B-Essen alleine/Privat`. +5. **Unterkategorien ohne genannte Elternkategorie.** Der Import legt fehlende + Zwischenstufen selbst an (`Kategorie::findenOderAnlegenNachPfad()`). +6. **Konten stehen zweimal in der Datei** – einmal in der Kontenliste, einmal als Kopf + ihrer Buchungen. Der zweite Treffer ist keine Dublette. +7. **Umbuchungen erscheinen doppelt**, einmal je beteiligtem Konto, und müssen in Phase 2 + zu einer Buchung zusammengeführt werden. +8. **Anfangssalden fehlen im Export** und werden je Konto nachgetragen. + +## Arbeitsweise des Importeurs + +`QifStammdatenImport` ist **idempotent**: Was es schon gibt, wird gezählt und übersprungen, +nie doppelt angelegt. Ein zweiter Lauf derselben Datei ändert nichts. + +Der echte Lauf liegt in einer Transaktion – ein Abbruch mitten in der Kategorieliste +hinterließe sonst einen halben Baum. Der **Trockenlauf** schreibt gar nicht, sondern zählt +mit einer Menge bereits bekannter Pfade mit; auch die Zwischenstufen, die der Import selbst +anlegen würde, erscheinen dort in der Vorschau. + +## Bedienung + +Im Dialog unter *Verwaltung → QIF-Import*: Datei hochladen, Trockenlauf, dann übernehmen. +Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit derselbe Export +erst geprüft und dann übernommen werden kann. + +Auf der Kommandozeile: + +```bash +docker compose exec -u www-data php php scripts/qif-import.php data/import/Export.QIF +docker compose exec -u www-data php php scripts/qif-import.php --uebernehmen data/import/Export.QIF +``` + +Ohne `--uebernehmen` läuft nur der Trockenlauf. `--benutzer=NAME` wählt den Mandanten, +sonst gilt der erste aktive Benutzer. + +## Prüfung gegen den echten Export + +Der Trockenlauf gegen `Export-Migration-Beispiel.QIF` liefert die Zahlen der Voranalyse: + +``` +Datei enthält Class: 17 · Cat: 105 · Account: 73 · Bank: 572 · Cash: 61 · + Oth T: 31 · Oth A: 154 · Invst: 16 · Memorized: 579 + +Klassen 17 neu +Kategorien 105 neu +Konten 37 neu, 36 vorhanden (die Zweitnennung je Konto) +``` diff --git a/docs/stammdaten.md b/docs/stammdaten.md new file mode 100644 index 0000000..02082fc --- /dev/null +++ b/docs/stammdaten.md @@ -0,0 +1,76 @@ +# Stammdaten + +Konten, Kontengruppen, Kategorien und Klassen – die Grundlage aller Buchungen und +Auswertungen. Die Begriffe entsprechen dem Lexware FinanzManager. + +## Kontengruppen und Konten + +Kontengruppen gliedern die Kontenübersicht (etwa „Anlage“). Konten ohne Gruppe erscheinen +unter **Nicht gruppiert** – das ist keine eigene Gruppe, sondern `kontengruppe_id IS NULL`. +Wird eine Gruppe gelöscht, bleiben ihre Konten erhalten und rutschen dorthin +(`ON DELETE SET NULL`). + +Kontoarten (`Konto::ARTEN`) und ihre Herkunft aus dem QIF-Export (`Konto::QIF_ARTEN`): + +| Kontoart | Bezeichnung | QIF-Typ | +|---|---|---| +| `bank` | Bank | `Bank` | +| `kreditkarte` | Kreditkarte | `CCard` | +| `bar` | Bar | `Cash` | +| `sparkonto` | Tagesgeld/Sparkonto | `Oth T` | +| `vermoegen` | Vermögen | `Oth A` | +| `verbindlichkeit` | Verbindlichkeit | `Oth L` | +| `rente` | Rentenversicherung | `Oth P` | +| `sonstiges` | Sonstiges Vermögen | `Oth E`, `Oth F` | +| `depot` | Depot | `Port`, `Invst` | + +Die **Kontenübersicht** (`Konto::uebersicht()`) bündelt nach Gruppe, bildet je Gruppe eine +Summe und darunter die Gesamtsumme. Sie führt schon jetzt die drei Saldospalten des +FinanzManagers: + +* **Online-Saldo** – Stand des letzten Bankabrufs, bleibt bis Phase 5 leer +* **Aktueller Saldo** – gebuchte Umsätze bis heute +* **Erwarteter Saldo** – einschließlich künftig datierter Buchungen + +Solange es keine Buchungen gibt (bis Phase 2), entsprechen alle drei dem Anfangssaldo. +Die Werte entstehen zentral in `Konto::mitSalden()`; dort kommen in Phase 2 die Buchungen +dazu, ohne dass die Aufrufer sich ändern. + +**Anfangssalden** stehen nicht im QIF-Export. Sie werden nach dem Import je Konto im +Kontoformular nachgetragen. + +## Kategorien + +Hierarchisch, Trenner ist `:` wie im FinanzManager. `name` hält den Blattnamen, `pfad` den +vollständigen Namen (`400 - Leben:B-Essen alleine`). Der Pfad steht redundant in der +Tabelle, weil praktisch jede Anzeige, jede Auswahl und der gesamte Import darüber laufen; +Umbenennen und Verschieben schreiben die Pfade des ganzen Teilbaums neu +(`Kategorie::pfadeNeuAufbauen()`). + +Regeln: + +* Die **Art** (Einnahme/Ausgabe) gilt für den ganzen Ast. Unterkategorien erben sie, und + ein Wechsel an der Wurzel zieht alle Nachfahren mit. +* Ein Name darf keinen Doppelpunkt enthalten – der trennt die Ebenen. +* Eine Kategorie kann nicht unter ihren eigenen Teilbaum verschoben werden. +* **Zusammenführen** hängt die Unterkategorien der Quelle unter das Ziel und löscht die + Quelle. Ab Phase 2 wandern dort auch die Buchungen mit. +* **Löschen** nimmt die Unterkategorien mit (`ON DELETE CASCADE`). Wer eine Kategorie nur + aus den Auswahllisten nehmen will, setzt sie auf inaktiv. + +Die Oberfläche hat die vier Register des Originals: *Alle*, *Einnahmen*, *Ausgaben*, +*Umbuchungskonten*. Umbuchungskonten sind keine Datensätze – dort stehen die Konten, weil +sie in der Kategorieauswahl einer Buchung an derselben Stelle erscheinen (in eckigen +Klammern). + +## Klassen + +Flach, ohne Hierarchie – die zweite Auswertungsdimension neben der Kategorie (Ehemann, +Ehefrau, Haus 1, Projekt …). Im QIF hängen sie mit `/` an der Kategorie: +`400 - Leben:B-Essen alleine/Privat`. `Kategorie::zerlegeQifAngabe()` trennt beides. + +## Mandantenfähigkeit + +Alle Stammdatentabellen führen `benutzer_id`. Die Oberfläche ist einbenutzerig; die +Kennung liefert `Mandant::id()` – aus der Session, im CLI ersatzweise der erste aktive +Benutzer. `Mandant::setze()` gibt sie in Skripten und Tests fest vor. diff --git a/migrations/002_stammdaten.sql b/migrations/002_stammdaten.sql new file mode 100644 index 0000000..4213c08 --- /dev/null +++ b/migrations/002_stammdaten.sql @@ -0,0 +1,88 @@ +-- Phase 1: Stammdaten – Kontengruppen, Konten, Kategorien, Klassen. +-- +-- Alle Kernobjekte führen benutzer_id, auch wenn die Oberfläche vorerst +-- einbenutzerig ist (siehe PLAN.md, Kapitel 3). + +CREATE TABLE IF NOT EXISTS kontengruppen ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + benutzer_id INT UNSIGNED NOT NULL, + name VARCHAR(128) NOT NULL, + beschreibung VARCHAR(255) NOT NULL DEFAULT '', + sortierung INT NOT NULL DEFAULT 0, + erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_kontengruppe (benutzer_id, name), + CONSTRAINT fk_kontengruppe_benutzer FOREIGN KEY (benutzer_id) + REFERENCES benutzer (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- kontoart entspricht den Kontoarten des FinanzManagers; die Zuordnung zu den +-- QIF-Typen (Bank, CCard, Oth A …) steht in Konto::QIF_ARTEN. +CREATE TABLE IF NOT EXISTS konten ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + benutzer_id INT UNSIGNED NOT NULL, + kontengruppe_id INT UNSIGNED NULL, + name VARCHAR(128) NOT NULL, + kontoart VARCHAR(32) NOT NULL DEFAULT 'bank', + bankname VARCHAR(128) NOT NULL DEFAULT '', + kontonummer VARCHAR(64) NOT NULL DEFAULT '', + iban VARCHAR(34) NOT NULL DEFAULT '', + bic VARCHAR(11) NOT NULL DEFAULT '', + beschreibung VARCHAR(255) NOT NULL DEFAULT '', + anfangssaldo DECIMAL(15,2) NOT NULL DEFAULT 0.00, + kreditlimit DECIMAL(15,2) NULL, + waehrung CHAR(3) NOT NULL DEFAULT 'EUR', + aktiv TINYINT(1) NOT NULL DEFAULT 1, + sortierung INT NOT NULL DEFAULT 0, + erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_konto (benutzer_id, name), + KEY idx_konto_gruppe (kontengruppe_id), + CONSTRAINT fk_konto_benutzer FOREIGN KEY (benutzer_id) + REFERENCES benutzer (id) ON DELETE CASCADE, + CONSTRAINT fk_konto_gruppe FOREIGN KEY (kontengruppe_id) + REFERENCES kontengruppen (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Kategorien sind hierarchisch. "name" ist nur der Blattname, "pfad" der +-- vollständige Name mit ":" als Trenner ("400 - Leben:B-Essen alleine") – er +-- wird beim Anlegen und Verschieben mitgepflegt, weil praktisch jede Anzeige +-- und der gesamte QIF-Import darüber laufen. +CREATE TABLE IF NOT EXISTS kategorien ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + benutzer_id INT UNSIGNED NOT NULL, + eltern_id INT UNSIGNED NULL, + name VARCHAR(128) NOT NULL, + pfad VARCHAR(512) NOT NULL, + art ENUM('einnahme','ausgabe') NOT NULL DEFAULT 'ausgabe', + steuerrelevant TINYINT(1) NOT NULL DEFAULT 0, + steuerformular VARCHAR(32) NOT NULL DEFAULT '', + beschreibung VARCHAR(255) NOT NULL DEFAULT '', + aktiv TINYINT(1) NOT NULL DEFAULT 1, + sortierung INT NOT NULL DEFAULT 0, + erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_kategorie_pfad (benutzer_id, pfad), + KEY idx_kategorie_eltern (eltern_id), + CONSTRAINT fk_kategorie_benutzer FOREIGN KEY (benutzer_id) + REFERENCES benutzer (id) ON DELETE CASCADE, + CONSTRAINT fk_kategorie_eltern FOREIGN KEY (eltern_id) + REFERENCES kategorien (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS klassen ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + benutzer_id INT UNSIGNED NOT NULL, + name VARCHAR(128) NOT NULL, + beschreibung VARCHAR(255) NOT NULL DEFAULT '', + aktiv TINYINT(1) NOT NULL DEFAULT 1, + erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_klasse (benutzer_id, name), + CONSTRAINT fk_klasse_benutzer FOREIGN KEY (benutzer_id) + REFERENCES benutzer (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO einstellungen (schluessel, wert, bereich) VALUES + ('import_qif_verzeichnis', 'data/import', 'import') +ON DUPLICATE KEY UPDATE schluessel = schluessel; diff --git a/public/index.php b/public/index.php index 8b05077..2cc0723 100644 --- a/public/index.php +++ b/public/index.php @@ -12,8 +12,12 @@ use Finanzmanager\Auth; use Finanzmanager\Config; use Finanzmanager\Controllers\CockpitController; use Finanzmanager\Controllers\EinstellungenController; +use Finanzmanager\Controllers\ImportController; +use Finanzmanager\Controllers\KategorienController; +use Finanzmanager\Controllers\KontenController; use Finanzmanager\Controllers\LoginController; use Finanzmanager\Controllers\PlatzhalterController; +use Finanzmanager\Controllers\VerwaltungController; use Finanzmanager\Models\Einstellung; use Finanzmanager\Router; use Finanzmanager\Services\DebugLog; @@ -52,6 +56,48 @@ $router->get('/einstellungen', fn() => (new EinstellungenController())->index() $router->post('/einstellungen', fn() => (new EinstellungenController())->speichern()); $router->post('/einstellungen/log-leeren', fn() => (new EinstellungenController())->logLeeren()); +// Konten. Die festen Pfade stehen vor den Mustern mit {id}, sonst schluckt +// "/konten/{id}/…" die Route "/konten/neu". +$router->get('/konten', fn() => (new KontenController())->index()); +$router->get('/konten/neu', fn() => (new KontenController())->neu()); +$router->post('/konten/neu', fn() => (new KontenController())->anlegen()); +$router->get('/konten/{id}/bearbeiten', fn(array $p) => (new KontenController())->bearbeiten($p)); +$router->post('/konten/{id}/bearbeiten', fn(array $p) => (new KontenController())->speichern($p)); +$router->post('/konten/{id}/loeschen', fn(array $p) => (new KontenController())->loeschen($p)); + +// Verwaltung: Kategorien, Klassen, Kontengruppen, QIF-Import. +$router->get('/verwaltung', fn() => (new VerwaltungController())->index()); + +$router->get('/verwaltung/kategorien', fn() => (new KategorienController())->index()); +$router->get('/verwaltung/kategorien/neu', fn() => (new KategorienController())->neu()); +$router->post('/verwaltung/kategorien/neu', fn() => (new KategorienController())->anlegen()); +$router->get('/verwaltung/kategorien/{id}', fn(array $p) => (new KategorienController())->bearbeiten($p)); +$router->post('/verwaltung/kategorien/{id}', fn(array $p) => (new KategorienController())->speichern($p)); +$router->post('/verwaltung/kategorien/{id}/verschieben', + fn(array $p) => (new KategorienController())->verschieben($p)); +$router->post('/verwaltung/kategorien/{id}/zusammenfuehren', + fn(array $p) => (new KategorienController())->zusammenfuehren($p)); +$router->post('/verwaltung/kategorien/{id}/loeschen', + fn(array $p) => (new KategorienController())->loeschen($p)); + +$router->get('/verwaltung/klassen', fn() => (new VerwaltungController())->klassen()); +$router->post('/verwaltung/klassen/neu', fn() => (new VerwaltungController())->klasseAnlegen()); +$router->post('/verwaltung/klassen/{id}', fn(array $p) => (new VerwaltungController())->klasseSpeichern($p)); +$router->post('/verwaltung/klassen/{id}/loeschen', + fn(array $p) => (new VerwaltungController())->klasseLoeschen($p)); + +$router->get('/verwaltung/kontengruppen', fn() => (new VerwaltungController())->kontengruppen()); +$router->post('/verwaltung/kontengruppen/neu', fn() => (new VerwaltungController())->kontengruppeAnlegen()); +$router->post('/verwaltung/kontengruppen/{id}', + fn(array $p) => (new VerwaltungController())->kontengruppeSpeichern($p)); +$router->post('/verwaltung/kontengruppen/{id}/loeschen', + fn(array $p) => (new VerwaltungController())->kontengruppeLoeschen($p)); + +$router->get('/verwaltung/import', fn() => (new ImportController())->index()); +$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen()); +$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren()); +$router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new ImportController())->loeschen($p)); + // Noch nicht gebaute Bereiche: sichtbar in der Navigation, mit Hinweis auf die Phase. foreach (PlatzhalterController::pfade() as $bereich) { $router->get('/' . $bereich, fn() => (new PlatzhalterController())->zeige($bereich)); diff --git a/public/static/app.css b/public/static/app.css index 1432194..9c09167 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -361,6 +361,93 @@ table.liste tbody tr:hover { background: var(--flaeche-gedaempft); } .betrag.negativ { color: var(--negativ); } +.nowrap { white-space: nowrap; } + +/* Gliederung der Kontenübersicht: Gruppenkopf, Gruppensumme, Gesamtsumme. */ +table.liste tr.gruppenzeile th { + background: var(--flaeche-gedaempft); + border-top: 1px solid var(--rand); + font-size: .85rem; + letter-spacing: .03em; + text-transform: uppercase; + color: var(--text-gedaempft); +} + +table.liste tr.gruppensumme td { + font-weight: 600; + background: color-mix(in srgb, var(--flaeche-gedaempft) 55%, var(--flaeche)); +} + +table.liste tfoot tr.summenzeile td { + font-weight: 700; + border-top: 2px solid var(--rand-kraeftig); + background: var(--flaeche-gedaempft); +} + +table.liste tr.inaktiv td { color: var(--text-gedaempft); } + +table.liste input[type="text"], +table.liste input[type="number"] { + width: 100%; + padding: .25rem .4rem; + border: 1px solid var(--rand); + border-radius: 4px; + background: var(--flaeche); + color: var(--text); + font: inherit; +} + +table.liste input.schmal { width: 5rem; } + +/* Einrückung des Kategoriebaums. Die Ebene kommt als Custom Property aus der + Vorlage – so bleibt die Tabelle frei von verschachtelten Elementen. */ +.baumname { + display: inline-block; + padding-left: calc(var(--ebene, 0) * 1.4rem); +} + +.nebentext { + color: var(--text-gedaempft); + font-size: .88rem; +} + +.marke { + display: inline-block; + padding: .05rem .4rem; + border: 1px solid var(--rand-kraeftig); + border-radius: 999px; + font-size: .75rem; + color: var(--text-gedaempft); + white-space: nowrap; +} + +.kennzahl { + font-family: var(--schrift-zahl); + font-size: 2rem; + font-weight: 600; + margin: .2rem 0; +} + +.protokoll { + margin: .4rem 0 0; + padding-left: 1.1rem; + font-size: .9rem; +} + +.protokoll li.fehler { color: var(--negativ); } + +/* ── Aktionsleiste über den Listen ───────────────────────────────────────── */ + +.seitenaktionen { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .5rem; + margin-bottom: 1rem; +} + +.seitenaktionen .fuellung { flex: 1; } + /* ── Formulare ───────────────────────────────────────────────────────────── */ .formular { @@ -455,6 +542,26 @@ table.liste tbody tr:hover { background: var(--flaeche-gedaempft); } .knopf.primaer:hover { filter: brightness(1.08); } +/* Der Abstand oben gilt dem allein stehenden Knopf unter einem Formular – + in Aktionsleisten und Tabellen stört er die Ausrichtung. */ +.seitenaktionen .knopf, +table.liste .knopf { margin-top: 0; } + +.knopf.klein { + padding: .25rem .6rem; + font-size: .85rem; +} + +.knopf.gefahr { + border-color: var(--negativ); + color: var(--negativ); + background: var(--flaeche); +} + +.knopf.gefahr:hover { background: color-mix(in srgb, var(--negativ) 12%, var(--flaeche)); } + +.feldwert { margin: .1rem 0 .3rem; } + /* ── Meldungen ───────────────────────────────────────────────────────────── */ .meldung { diff --git a/scripts/qif-import.php b/scripts/qif-import.php new file mode 100644 index 0000000..fef87f2 --- /dev/null +++ b/scripts/qif-import.php @@ -0,0 +1,112 @@ +#!/usr/bin/env php +\n"); + exit(1); +} + +// Relative Angaben beziehen sich auf das Projektverzeichnis. +if (!str_starts_with($datei, '/')) { + $datei = dirname(__DIR__) . '/' . $datei; +} + +if (!is_file($datei)) { + fwrite(STDERR, "Datei nicht gefunden: {$datei}\n"); + exit(1); +} + +if ($benutzer !== null) { + $zeile = Benutzer::findByName($benutzer); + if ($zeile === null) { + fwrite(STDERR, "Benutzer nicht gefunden: {$benutzer}\n"); + exit(1); + } + Mandant::setze((int)$zeile['id']); +} + +try { + $parser = QifParser::ausDatei($datei); + $ergebnis = (new QifStammdatenImport($parser))->ausfuehren(!$uebernehmen); +} catch (\Throwable $e) { + fwrite(STDERR, 'Import fehlgeschlagen: ' . $e->getMessage() . "\n"); + exit(1); +} + +echo ($uebernehmen ? 'Import' : 'Trockenlauf') . ': ' . basename($datei) . "\n"; + +$abschnitte = []; +foreach ($parser->zusammenfassung() as $typ => $anzahl) { + $abschnitte[] = "{$typ}: {$anzahl}"; +} +echo 'Datei enthält ' . implode(' · ', $abschnitte) . "\n\n"; + +printf("%-12s %8s %12s %8s\n", 'Bereich', $uebernehmen ? 'angelegt' : 'neu', 'vorhanden', 'Fehler'); +foreach (['klassen' => 'Klassen', 'kategorien' => 'Kategorien', 'konten' => 'Konten'] as $schluessel => $titel) { + printf( + "%-12s %8d %12d %8d\n", + $titel, + $ergebnis[$schluessel]['neu'], + $ergebnis[$schluessel]['vorhanden'], + $ergebnis[$schluessel]['fehler'] + ); +} + +if ($ergebnis['meldungen'] !== []) { + echo "\nProtokoll:\n"; + foreach ($ergebnis['meldungen'] as $meldung) { + echo ' - ' . $meldung['text'] . "\n"; + } +} + +if (!$uebernehmen) { + echo "\nEs wurde nichts gespeichert. Mit --uebernehmen wird der Import durchgeführt.\n"; +} else { + echo "\nAnfangssalden stehen nicht im Export – sie werden je Konto in der\n" + . "Kontenübersicht nachgetragen.\n"; +} + +$fehler = $ergebnis['klassen']['fehler'] + $ergebnis['kategorien']['fehler'] + $ergebnis['konten']['fehler']; +exit($fehler > 0 ? 1 : 0); diff --git a/src/Controllers/CockpitController.php b/src/Controllers/CockpitController.php index fb1b291..5feb25b 100644 --- a/src/Controllers/CockpitController.php +++ b/src/Controllers/CockpitController.php @@ -4,16 +4,19 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; +use Finanzmanager\Models\Konto; + class CockpitController extends BaseController { public function index(): void { $this->verlangeAnmeldung(); - // Die Kacheln füllen sich ab Phase 2 mit echten Zahlen; bis dahin - // zeigt das Cockpit den Aufbaustand. + // Die Kontostandskachel zeigt schon jetzt echte Zahlen; die übrigen + // Kacheln brauchen Buchungen und füllen sich ab Phase 2. $this->render('cockpit/index.php', [ - 'titel' => 'Cockpit', + 'titel' => 'Cockpit', + 'uebersicht' => Konto::uebersicht(), ]); } } diff --git a/src/Controllers/EinstellungenController.php b/src/Controllers/EinstellungenController.php index 62fb928..4313623 100644 --- a/src/Controllers/EinstellungenController.php +++ b/src/Controllers/EinstellungenController.php @@ -85,6 +85,7 @@ class EinstellungenController extends BaseController 'allgemein' => ['mandant_name' => 'text', 'waehrung' => 'text'], 'darstellung' => ['darstellung_thema' => 'text', 'zeilen_pro_seite' => 'text'], 'buchhaltung' => ['erster_monat' => 'text'], + 'import' => ['import_qif_verzeichnis' => 'text'], 'diagnose' => ['debug_log_aktiv' => 'bool'], default => [], }; diff --git a/src/Controllers/ImportController.php b/src/Controllers/ImportController.php new file mode 100644 index 0000000..f0ebfa8 --- /dev/null +++ b/src/Controllers/ImportController.php @@ -0,0 +1,182 @@ +verlangeAnmeldung(); + + $this->render('verwaltung/import.php', [ + 'titel' => 'QIF-Import', + 'dateien' => $this->dateien(), + 'verzeichnis' => $this->verzeichnisAnzeige(), + 'ergebnis' => null, + 'datei' => '', + ]); + } + + public function hochladen(): void + { + $this->verlangeAnmeldung(); + + $datei = $_FILES['qif'] ?? null; + if (!is_array($datei) || ($datei['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + View::redirect('/verwaltung/import', $this->uploadFehler($datei['error'] ?? UPLOAD_ERR_NO_FILE), 'fehler'); + return; + } + + $name = self::sichererDateiname((string)$datei['name']); + if ($name === null) { + View::redirect('/verwaltung/import', 'Nur .QIF-Dateien können hochgeladen werden.', 'fehler'); + return; + } + + $ziel = $this->verzeichnis() . '/' . $name; + if (!is_uploaded_file((string)$datei['tmp_name']) || !move_uploaded_file((string)$datei['tmp_name'], $ziel)) { + View::redirect('/verwaltung/import', 'Die Datei konnte nicht abgelegt werden.', 'fehler'); + return; + } + + View::redirect('/verwaltung/import', 'Datei „' . $name . '“ hochgeladen.'); + } + + public function ausfuehren(): void + { + $this->verlangeAnmeldung(); + + $name = self::sichererDateiname($this->post('datei')); + if ($name === null) { + View::redirect('/verwaltung/import', 'Keine Datei ausgewählt.', 'fehler'); + return; + } + + $pfad = $this->verzeichnis() . '/' . $name; + if (!is_file($pfad)) { + View::redirect('/verwaltung/import', 'Die Datei „' . $name . '“ gibt es nicht mehr.', 'fehler'); + return; + } + + $trockenlauf = $this->post('modus') !== 'import'; + + try { + $parser = QifParser::ausDatei($pfad); + $importer = new QifStammdatenImport($parser); + $ergebnis = $importer->ausfuehren($trockenlauf); + } catch (\Throwable $e) { + View::redirect('/verwaltung/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler'); + return; + } + + $this->render('verwaltung/import.php', [ + 'titel' => 'QIF-Import', + 'dateien' => $this->dateien(), + 'verzeichnis' => $this->verzeichnisAnzeige(), + 'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()], + 'datei' => $name, + ]); + } + + /** @param array $params */ + public function loeschen(array $params): void + { + $this->verlangeAnmeldung(); + + $name = self::sichererDateiname($params['datei'] ?? ''); + if ($name !== null) { + $pfad = $this->verzeichnis() . '/' . $name; + if (is_file($pfad)) { + unlink($pfad); + } + } + + View::redirect('/verwaltung/import', 'Datei gelöscht.'); + } + + /** + * Nimmt nur den Dateinamen ohne Pfadanteile und lässt allein die erlaubten + * Endungen durch – der Name landet ungeprüft in einer Pfadangabe. + */ + public static function sichererDateiname(string $eingabe): ?string + { + $name = basename(trim($eingabe)); + if ($name === '' || $name === '.' || $name === '..' || str_contains($name, "\0")) { + return null; + } + + $endung = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + if (!in_array($endung, self::ERLAUBTE_ENDUNGEN, true)) { + return null; + } + + // Alles außer Buchstaben, Ziffern, Punkt, Strich und Unterstrich fällt + // weg; damit bleiben keine Sonderzeichen für die Shell oder das + // Dateisystem übrig. + $name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? ''; + + return $name === '' ? null : $name; + } + + /** @return list */ + private function dateien(): array + { + $verzeichnis = $this->verzeichnis(); + $dateien = []; + + foreach (glob($verzeichnis . '/*.[qQ][iI][fF]') ?: [] as $pfad) { + $dateien[] = [ + 'name' => basename($pfad), + 'groesse' => (int)filesize($pfad), + 'geaendert' => (int)filemtime($pfad), + ]; + } + + usort($dateien, static fn(array $a, array $b): int => $b['geaendert'] <=> $a['geaendert']); + return $dateien; + } + + private function verzeichnis(): string + { + $relativ = (string)Einstellung::get('import_qif_verzeichnis', 'data/import'); + $pfad = dirname(__DIR__, 2) . '/' . trim($relativ, '/'); + + if (!is_dir($pfad)) { + mkdir($pfad, 0o775, true); + } + + return $pfad; + } + + private function verzeichnisAnzeige(): string + { + return (string)Einstellung::get('import_qif_verzeichnis', 'data/import'); + } + + private function uploadFehler(int $code): string + { + return match ($code) { + UPLOAD_ERR_NO_FILE => 'Es wurde keine Datei ausgewählt.', + UPLOAD_ERR_INI_SIZE, + UPLOAD_ERR_FORM_SIZE => 'Die Datei ist zu groß.', + UPLOAD_ERR_PARTIAL => 'Die Datei wurde nur teilweise übertragen.', + default => 'Der Upload ist fehlgeschlagen (Code ' . $code . ').', + }; + } +} diff --git a/src/Controllers/KategorienController.php b/src/Controllers/KategorienController.php new file mode 100644 index 0000000..d81434a --- /dev/null +++ b/src/Controllers/KategorienController.php @@ -0,0 +1,183 @@ + 'Alle', + 'einnahmen' => 'Einnahmen', + 'ausgaben' => 'Ausgaben', + 'umbuchungskonten' => 'Umbuchungskonten', + ]; + + public function index(): void + { + $this->verlangeAnmeldung(); + + $register = $this->query('register', 'alle'); + if (!isset(self::REGISTER[$register])) { + $register = 'alle'; + } + + $art = match ($register) { + 'einnahmen' => 'einnahme', + 'ausgaben' => 'ausgabe', + default => null, + }; + + $this->render('verwaltung/kategorien.php', [ + 'titel' => 'Kategorien', + 'register' => $register, + 'registerListe' => self::REGISTER, + 'kategorien' => $register === 'umbuchungskonten' ? [] : Kategorie::baumFlach($art), + 'konten' => $register === 'umbuchungskonten' ? Konto::alle() : [], + ]); + } + + public function neu(): void + { + $this->verlangeAnmeldung(); + + $this->render('verwaltung/kategorie-formular.php', [ + 'titel' => 'Neue Kategorie', + 'kategorie' => null, + 'auswahl' => Kategorie::baumFlach(), + 'elternId' => $this->query('eltern') === '' ? null : (int)$this->query('eltern'), + ]); + } + + public function anlegen(): void + { + $this->verlangeAnmeldung(); + + try { + $id = Kategorie::anlegen([ + 'name' => $this->post('name'), + 'eltern_id' => $this->post('eltern_id'), + 'art' => $this->post('art', 'ausgabe'), + 'beschreibung' => $this->post('beschreibung'), + 'steuerrelevant' => isset($_POST['steuerrelevant']), + 'steuerformular' => $this->post('steuerformular'), + 'aktiv' => true, + ]); + } catch (\Throwable $e) { + View::redirect('/verwaltung/kategorien/neu', $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/verwaltung/kategorien/' . $id, 'Kategorie angelegt.'); + } + + /** @param array $params */ + public function bearbeiten(array $params): void + { + $this->verlangeAnmeldung(); + + $kategorie = Kategorie::find((int)$params['id']); + if ($kategorie === null) { + $this->nichtGefunden('/verwaltung/kategorien/' . $params['id']); + return; + } + + $this->render('verwaltung/kategorie-formular.php', [ + 'titel' => 'Kategorie: ' . $kategorie['pfad'], + 'kategorie' => $kategorie, + 'auswahl' => Kategorie::baumFlach(), + 'elternId' => $kategorie['eltern_id'] === null ? null : (int)$kategorie['eltern_id'], + ]); + } + + /** @param array $params */ + public function speichern(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + try { + Kategorie::speichern($id, [ + 'name' => $this->post('name'), + 'art' => $this->post('art', 'ausgabe'), + 'beschreibung' => $this->post('beschreibung'), + 'steuerrelevant' => isset($_POST['steuerrelevant']), + 'steuerformular' => $this->post('steuerformular'), + 'aktiv' => isset($_POST['aktiv']), + 'sortierung' => (int)$this->post('sortierung', '0'), + ]); + } catch (\Throwable $e) { + View::redirect('/verwaltung/kategorien/' . $id, $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/verwaltung/kategorien', 'Kategorie gespeichert.'); + } + + /** @param array $params */ + public function verschieben(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + $eltern = $this->post('eltern_id'); + + try { + Kategorie::verschieben($id, $eltern === '' || $eltern === '0' ? null : (int)$eltern); + } catch (\Throwable $e) { + View::redirect('/verwaltung/kategorien/' . $id, $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/verwaltung/kategorien/' . $id, 'Kategorie verschoben.'); + } + + /** @param array $params */ + public function zusammenfuehren(array $params): void + { + $this->verlangeAnmeldung(); + + $quelleId = (int)$params['id']; + $zielId = (int)$this->post('ziel_id'); + + try { + Kategorie::zusammenfuehren($quelleId, $zielId); + } catch (\Throwable $e) { + View::redirect('/verwaltung/kategorien/' . $quelleId, $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/verwaltung/kategorien', 'Kategorien zusammengeführt.'); + } + + /** @param array $params */ + public function loeschen(array $params): void + { + $this->verlangeAnmeldung(); + + $kategorie = Kategorie::find((int)$params['id']); + if ($kategorie === null) { + $this->nichtGefunden('/verwaltung/kategorien/' . $params['id']); + return; + } + + Kategorie::loeschen((int)$kategorie['id']); + View::redirect('/verwaltung/kategorien', 'Kategorie „' . $kategorie['pfad'] . '“ gelöscht.'); + } + + private function nichtGefunden(string $pfad): void + { + http_response_code(404); + $this->render('layout/404.php', ['pfad' => $pfad, 'titel' => 'Nicht gefunden']); + } +} diff --git a/src/Controllers/KontenController.php b/src/Controllers/KontenController.php new file mode 100644 index 0000000..b9e9b53 --- /dev/null +++ b/src/Controllers/KontenController.php @@ -0,0 +1,166 @@ +verlangeAnmeldung(); + + $mitInaktiven = $this->query('inaktive') === '1'; + + $this->render('konten/uebersicht.php', [ + 'titel' => 'Kontenübersicht', + 'uebersicht' => Konto::uebersicht(!$mitInaktiven), + 'mitInaktiven' => $mitInaktiven, + ]); + } + + public function neu(): void + { + $this->verlangeAnmeldung(); + + $this->render('konten/formular.php', [ + 'titel' => 'Neues Konto', + 'konto' => null, + 'gruppen' => Kontengruppe::alle(), + ]); + } + + public function anlegen(): void + { + $this->verlangeAnmeldung(); + + try { + $id = Konto::anlegen($this->formulardaten()); + } catch (\Throwable $e) { + View::redirect('/konten/neu', $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/konten/' . $id . '/bearbeiten', 'Konto angelegt.'); + } + + /** @param array $params */ + public function bearbeiten(array $params): void + { + $this->verlangeAnmeldung(); + + $konto = Konto::find((int)$params['id']); + if ($konto === null) { + $this->nichtGefunden('/konten/' . $params['id']); + return; + } + + $this->render('konten/formular.php', [ + 'titel' => 'Konto: ' . $konto['name'], + 'konto' => $konto, + 'gruppen' => Kontengruppe::alle(), + ]); + } + + /** @param array $params */ + public function speichern(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + if (Konto::find($id) === null) { + $this->nichtGefunden('/konten/' . $params['id']); + return; + } + + try { + Konto::speichern($id, $this->formulardaten()); + } catch (\Throwable $e) { + View::redirect('/konten/' . $id . '/bearbeiten', $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/konten', 'Konto gespeichert.'); + } + + /** @param array $params */ + public function loeschen(array $params): void + { + $this->verlangeAnmeldung(); + + $konto = Konto::find((int)$params['id']); + if ($konto === null) { + $this->nichtGefunden('/konten/' . $params['id']); + return; + } + + Konto::loeschen((int)$konto['id']); + View::redirect('/konten', 'Konto „' . $konto['name'] . '“ gelöscht.'); + } + + /** @return array */ + private function formulardaten(): array + { + return [ + 'name' => $this->post('name'), + 'kontoart' => $this->post('kontoart', 'bank'), + 'kontengruppe_id' => $this->post('kontengruppe_id'), + 'bankname' => $this->post('bankname'), + 'kontonummer' => $this->post('kontonummer'), + 'iban' => $this->post('iban'), + 'bic' => $this->post('bic'), + 'beschreibung' => $this->post('beschreibung'), + 'anfangssaldo' => self::zahl($this->post('anfangssaldo', '0')), + 'kreditlimit' => $this->post('kreditlimit') === '' ? null : self::zahl($this->post('kreditlimit')), + 'waehrung' => $this->post('waehrung', 'EUR'), + 'aktiv' => isset($_POST['aktiv']), + 'sortierung' => (int)$this->post('sortierung', '0'), + ]; + } + + /** + * Beträge kommen im deutschen Format aus dem Formular ("1.234,56"). Ohne + * Komma ist der Punkt mehrdeutig, deshalb entscheidet die Stellenzahl: + * genau drei Ziffern dahinter heißt Tausendertrenner ("1.500"), sonst + * Dezimaltrenner ("1234.56"). So gehen sowohl die deutsche Schreibweise als + * auch eingefügte Werte aus Tabellenkalkulationen durch. + */ + public static function zahl(string $eingabe): float + { + $eingabe = trim(str_replace(["\u{00A0}", ' ', '€'], '', $eingabe)); + if ($eingabe === '') { + return 0.0; + } + + if (str_contains($eingabe, ',')) { + return (float)str_replace(',', '.', str_replace('.', '', $eingabe)); + } + + $punkte = substr_count($eingabe, '.'); + if ($punkte === 0) { + return (float)$eingabe; + } + + $letzterTeil = substr($eingabe, (int)strrpos($eingabe, '.') + 1); + if ($punkte > 1 || strlen($letzterTeil) === 3) { + return (float)str_replace('.', '', $eingabe); + } + + return (float)$eingabe; + } + + private function nichtGefunden(string $pfad): void + { + http_response_code(404); + $this->render('layout/404.php', ['pfad' => $pfad, 'titel' => 'Nicht gefunden']); + } +} diff --git a/src/Controllers/PlatzhalterController.php b/src/Controllers/PlatzhalterController.php index 5f66c21..1c39c13 100644 --- a/src/Controllers/PlatzhalterController.php +++ b/src/Controllers/PlatzhalterController.php @@ -12,11 +12,6 @@ namespace Finanzmanager\Controllers; class PlatzhalterController extends BaseController { private const BEREICHE = [ - 'konten' => [ - 'titel' => 'Kontenübersicht', - 'phase' => 'Phase 1', - 'text' => 'Konten, Kontengruppen und Salden – mit Übernahme der 37 Konten aus dem QIF-Export.', - ], 'zahlungsverkehr' => [ 'titel' => 'Zahlungsverkehr', 'phase' => 'Phase 7', @@ -37,11 +32,6 @@ class PlatzhalterController extends BaseController 'phase' => 'Phase 6', 'text' => 'Depotübersicht, Bestände, Kurse und Veräußerungsgeschäfte.', ], - 'verwaltung' => [ - 'titel' => 'Verwaltung', - 'phase' => 'Phase 1 und später', - 'text' => 'Kategorien, Klassen, Bankverbindungen, Buchungsvorlagen, Regelverwaltung.', - ], ]; public function zeige(string $bereich): void diff --git a/src/Controllers/VerwaltungController.php b/src/Controllers/VerwaltungController.php new file mode 100644 index 0000000..5a3e0fa --- /dev/null +++ b/src/Controllers/VerwaltungController.php @@ -0,0 +1,150 @@ +verlangeAnmeldung(); + + $this->render('verwaltung/index.php', [ + 'titel' => 'Verwaltung', + 'zahlen' => [ + 'kategorien' => Kategorie::anzahl(), + 'einnahmen' => Kategorie::anzahl('einnahme'), + 'ausgaben' => Kategorie::anzahl('ausgabe'), + 'klassen' => Klasse::anzahl(), + 'konten' => Konto::anzahl(), + 'kontengruppen' => count(Kontengruppe::alle()), + ], + ]); + } + + public function klassen(): void + { + $this->verlangeAnmeldung(); + + $this->render('verwaltung/klassen.php', [ + 'titel' => 'Klassen', + 'klassen' => Klasse::alle(), + ]); + } + + public function klasseAnlegen(): void + { + $this->verlangeAnmeldung(); + + try { + Klasse::anlegen($this->post('name'), $this->post('beschreibung')); + } catch (\Throwable $e) { + View::redirect('/verwaltung/klassen', $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/verwaltung/klassen', 'Klasse angelegt.'); + } + + /** @param array $params */ + public function klasseSpeichern(array $params): void + { + $this->verlangeAnmeldung(); + + try { + Klasse::speichern( + (int)$params['id'], + $this->post('name'), + $this->post('beschreibung'), + isset($_POST['aktiv']) + ); + } catch (\Throwable $e) { + View::redirect('/verwaltung/klassen', $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/verwaltung/klassen', 'Klasse gespeichert.'); + } + + /** @param array $params */ + public function klasseLoeschen(array $params): void + { + $this->verlangeAnmeldung(); + + Klasse::loeschen((int)$params['id']); + View::redirect('/verwaltung/klassen', 'Klasse gelöscht.'); + } + + public function kontengruppen(): void + { + $this->verlangeAnmeldung(); + + $this->render('verwaltung/kontengruppen.php', [ + 'titel' => 'Kontengruppen', + 'gruppen' => Kontengruppe::alle(), + 'konten' => Konto::alle(), + ]); + } + + public function kontengruppeAnlegen(): void + { + $this->verlangeAnmeldung(); + + try { + Kontengruppe::anlegen( + $this->post('name'), + $this->post('beschreibung'), + (int)$this->post('sortierung', '0') + ); + } catch (\Throwable $e) { + View::redirect('/verwaltung/kontengruppen', $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/verwaltung/kontengruppen', 'Kontengruppe angelegt.'); + } + + /** @param array $params */ + public function kontengruppeSpeichern(array $params): void + { + $this->verlangeAnmeldung(); + + try { + Kontengruppe::speichern( + (int)$params['id'], + $this->post('name'), + $this->post('beschreibung'), + (int)$this->post('sortierung', '0') + ); + } catch (\Throwable $e) { + View::redirect('/verwaltung/kontengruppen', $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/verwaltung/kontengruppen', 'Kontengruppe gespeichert.'); + } + + /** @param array $params */ + public function kontengruppeLoeschen(array $params): void + { + $this->verlangeAnmeldung(); + + Kontengruppe::loeschen((int)$params['id']); + View::redirect( + '/verwaltung/kontengruppen', + 'Kontengruppe gelöscht. Die Konten stehen jetzt unter „' . Kontengruppe::OHNE_GRUPPE . '“.' + ); + } +} diff --git a/src/Mandant.php b/src/Mandant.php new file mode 100644 index 0000000..dd99f49 --- /dev/null +++ b/src/Mandant.php @@ -0,0 +1,55 @@ + 0; + } +} diff --git a/src/Models/Kategorie.php b/src/Models/Kategorie.php new file mode 100644 index 0000000..81d4695 --- /dev/null +++ b/src/Models/Kategorie.php @@ -0,0 +1,464 @@ + 'Einnahme', 'ausgabe' => 'Ausgabe']; + + public const TRENNER = ':'; + + /** @return list> */ + public static function alle(?string $art = null, bool $nurAktive = false): array + { + $sql = 'SELECT * FROM kategorien WHERE benutzer_id = ?'; + $params = [Mandant::id()]; + + if ($art !== null) { + $sql .= ' AND art = ?'; + $params[] = $art; + } + if ($nurAktive) { + $sql .= ' AND aktiv = 1'; + } + + return Database::fetchAll($sql . ' ORDER BY pfad', $params); + } + + public static function find(int $id): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM kategorien WHERE id = ? AND benutzer_id = ?', + [$id, Mandant::id()] + ); + return $zeile === false ? null : $zeile; + } + + public static function findeNachPfad(string $pfad): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM kategorien WHERE pfad = ? AND benutzer_id = ?', + [self::normalisierePfad($pfad), Mandant::id()] + ); + return $zeile === false ? null : $zeile; + } + + /** + * Baut den Kategoriebaum als verschachtelte Liste; jeder Knoten führt + * 'kinder' und 'ebene'. + * + * @return list> + */ + public static function baum(?string $art = null, bool $nurAktive = false): array + { + $flach = self::alle($art, $nurAktive); + + $knoten = []; + foreach ($flach as $zeile) { + $zeile['kinder'] = []; + $knoten[(int)$zeile['id']] = $zeile; + } + + $wurzeln = []; + foreach ($knoten as $id => $zeile) { + $eltern = $zeile['eltern_id'] === null ? null : (int)$zeile['eltern_id']; + // Beim Filtern nach Art oder aktiv kann der Elternknoten fehlen – + // die Kategorie hängt dann als Wurzel in der Liste, statt zu + // verschwinden. + if ($eltern !== null && isset($knoten[$eltern])) { + continue; + } + $wurzeln[] = $id; + } + + $baueTeilbaum = static function (int $id, int $ebene) use (&$baueTeilbaum, &$knoten): array { + $zeile = $knoten[$id]; + $zeile['ebene'] = $ebene; + foreach ($knoten as $kindId => $kind) { + if ($kind['eltern_id'] !== null && (int)$kind['eltern_id'] === $id) { + $zeile['kinder'][] = $baueTeilbaum($kindId, $ebene + 1); + } + } + return $zeile; + }; + + return array_map(static fn(int $id) => $baueTeilbaum($id, 0), $wurzeln); + } + + /** + * Flache Liste in Baumreihenfolge, jeder Eintrag mit 'ebene' – für + * Auswahllisten und die Tabellendarstellung des Baums. + * + * @return list> + */ + public static function baumFlach(?string $art = null, bool $nurAktive = false): array + { + $ergebnis = []; + + $durchlaufe = static function (array $knoten) use (&$durchlaufe, &$ergebnis): void { + foreach ($knoten as $zeile) { + $kinder = $zeile['kinder']; + unset($zeile['kinder']); + $zeile['hat_kinder'] = $kinder !== []; + $ergebnis[] = $zeile; + $durchlaufe($kinder); + } + }; + $durchlaufe(self::baum($art, $nurAktive)); + + return $ergebnis; + } + + /** + * @param array $daten + */ + public static function anlegen(array $daten): int + { + $name = self::pruefeName((string)($daten['name'] ?? '')); + + $elternId = self::elternIdAus($daten); + $eltern = $elternId === null ? null : self::find($elternId); + if ($elternId !== null && $eltern === null) { + throw new \RuntimeException('Die übergeordnete Kategorie gibt es nicht.'); + } + + // Die Art gilt für den ganzen Ast: eine Unterkategorie von „Einnahme“ + // kann keine Ausgabe sein. + $art = $eltern !== null ? (string)$eltern['art'] : self::pruefeArt((string)($daten['art'] ?? 'ausgabe')); + $pfad = $eltern !== null ? $eltern['pfad'] . self::TRENNER . $name : $name; + + if (self::findeNachPfad($pfad) !== null) { + throw new \RuntimeException('Die Kategorie „' . $pfad . '“ gibt es bereits.'); + } + + Database::execute( + 'INSERT INTO kategorien + (benutzer_id, eltern_id, name, pfad, art, steuerrelevant, steuerformular, + beschreibung, aktiv, sortierung) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + Mandant::id(), $elternId, $name, $pfad, $art, + !empty($daten['steuerrelevant']) ? 1 : 0, + trim((string)($daten['steuerformular'] ?? '')), + trim((string)($daten['beschreibung'] ?? '')), + array_key_exists('aktiv', $daten) && empty($daten['aktiv']) ? 0 : 1, + (int)($daten['sortierung'] ?? 0), + ] + ); + + return Database::lastInsertId(); + } + + /** + * Legt den kompletten Pfad an, soweit er fehlt, und liefert die Kennung der + * letzten Stufe. Der QIF-Import braucht das: dort tauchen Unterkategorien + * auch ohne vorher genannte Elternkategorie auf. + * + * @param array $daten Zusatzfelder nur für die letzte Stufe. + */ + public static function findenOderAnlegenNachPfad(string $pfad, string $art = 'ausgabe', array $daten = []): int + { + $pfad = self::normalisierePfad($pfad); + if ($pfad === '') { + throw new \InvalidArgumentException('Der Kategoriepfad ist leer.'); + } + + $vorhanden = self::findeNachPfad($pfad); + if ($vorhanden !== null) { + return (int)$vorhanden['id']; + } + + $teile = explode(self::TRENNER, $pfad); + $elternId = null; + $bisher = ''; + + foreach ($teile as $index => $teil) { + $bisher = $bisher === '' ? $teil : $bisher . self::TRENNER . $teil; + $stufe = self::findeNachPfad($bisher); + + if ($stufe !== null) { + $elternId = (int)$stufe['id']; + continue; + } + + $letzte = $index === count($teile) - 1; + $elternId = self::anlegen(array_merge( + $letzte ? $daten : [], + ['name' => $teil, 'eltern_id' => $elternId, 'art' => $art] + )); + } + + return (int)$elternId; + } + + /** + * @param array $daten + */ + public static function speichern(int $id, array $daten): void + { + $kategorie = self::find($id); + if ($kategorie === null) { + throw new \RuntimeException('Die Kategorie gibt es nicht.'); + } + + $name = self::pruefeName((string)($daten['name'] ?? '')); + $art = self::pruefeArt((string)($daten['art'] ?? $kategorie['art'])); + + Database::transaktion(static function () use ($id, $kategorie, $name, $art, $daten): void { + Database::execute( + 'UPDATE kategorien SET name = ?, steuerrelevant = ?, steuerformular = ?, + beschreibung = ?, aktiv = ?, sortierung = ? + WHERE id = ? AND benutzer_id = ?', + [ + $name, + !empty($daten['steuerrelevant']) ? 1 : 0, + trim((string)($daten['steuerformular'] ?? '')), + trim((string)($daten['beschreibung'] ?? '')), + empty($daten['aktiv']) ? 0 : 1, + (int)($daten['sortierung'] ?? $kategorie['sortierung']), + $id, Mandant::id(), + ] + ); + + // Die Art gilt für den ganzen Ast – Unterkategorien ziehen mit. + if ($kategorie['eltern_id'] === null && $art !== $kategorie['art']) { + self::setzeArtRekursiv($id, $art); + } + + if ($name !== $kategorie['name']) { + self::pfadeNeuAufbauen($id); + } + }); + } + + /** + * Hängt eine Kategorie unter eine andere (oder auf die oberste Ebene). + * Der eigene Teilbaum darf nicht Ziel sein, sonst hinge der Ast in der Luft. + */ + public static function verschieben(int $id, ?int $neuesElternId): void + { + $kategorie = self::find($id); + if ($kategorie === null) { + throw new \RuntimeException('Die Kategorie gibt es nicht.'); + } + if ($neuesElternId === $id) { + throw new \RuntimeException('Eine Kategorie kann sich nicht selbst untergeordnet werden.'); + } + + $eltern = null; + if ($neuesElternId !== null) { + $eltern = self::find($neuesElternId); + if ($eltern === null) { + throw new \RuntimeException('Die übergeordnete Kategorie gibt es nicht.'); + } + if (self::istNachfahre($neuesElternId, $id)) { + throw new \RuntimeException( + 'Die Kategorie kann nicht unter eine ihrer eigenen Unterkategorien verschoben werden.' + ); + } + } + + $neuerPfad = $eltern === null + ? $kategorie['name'] + : $eltern['pfad'] . self::TRENNER . $kategorie['name']; + + $belegt = self::findeNachPfad($neuerPfad); + if ($belegt !== null && (int)$belegt['id'] !== $id) { + throw new \RuntimeException('Am Zielort gibt es bereits eine Kategorie „' . $kategorie['name'] . '“.'); + } + + Database::transaktion(static function () use ($id, $neuesElternId, $eltern): void { + Database::execute( + 'UPDATE kategorien SET eltern_id = ? WHERE id = ? AND benutzer_id = ?', + [$neuesElternId, $id, Mandant::id()] + ); + if ($eltern !== null) { + self::setzeArtRekursiv($id, (string)$eltern['art']); + } + self::pfadeNeuAufbauen($id); + }); + } + + /** + * Führt zwei Kategorien zusammen: die Unterkategorien der Quelle hängen sich + * unter das Ziel, danach verschwindet die Quelle. + * + * Ab Phase 2 werden hier zusätzlich die Buchungen und Splitzeilen der Quelle + * auf das Ziel umgeschrieben – solange es keine Buchungstabellen gibt, ist + * das Umhängen des Teilbaums die vollständige Operation. + */ + public static function zusammenfuehren(int $quelleId, int $zielId): void + { + if ($quelleId === $zielId) { + throw new \RuntimeException('Quelle und Ziel sind dieselbe Kategorie.'); + } + + $quelle = self::find($quelleId); + $ziel = self::find($zielId); + if ($quelle === null || $ziel === null) { + throw new \RuntimeException('Quelle oder Ziel gibt es nicht.'); + } + if (self::istNachfahre($zielId, $quelleId)) { + throw new \RuntimeException('Das Ziel liegt innerhalb der Quelle.'); + } + + Database::transaktion(static function () use ($quelleId, $zielId): void { + foreach (self::kinder($quelleId) as $kind) { + self::verschieben((int)$kind['id'], $zielId); + } + self::loeschen($quelleId); + }); + } + + /** @return list> */ + public static function kinder(int $id): array + { + return Database::fetchAll( + 'SELECT * FROM kategorien WHERE eltern_id = ? AND benutzer_id = ? ORDER BY sortierung, name', + [$id, Mandant::id()] + ); + } + + /** + * Löscht die Kategorie. Unterkategorien hängen per ON DELETE CASCADE mit + * daran – deshalb verlangt der Controller vorher eine Bestätigung. + */ + public static function loeschen(int $id): void + { + Database::execute('DELETE FROM kategorien WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); + } + + public static function anzahl(?string $art = null): int + { + $sql = 'SELECT COUNT(*) FROM kategorien WHERE benutzer_id = ?'; + $params = [Mandant::id()]; + if ($art !== null) { + $sql .= ' AND art = ?'; + $params[] = $art; + } + return (int)Database::fetchValue($sql, $params, 0); + } + + /** + * Zerlegt die QIF-Zeile "L400 - Leben:B-Essen alleine/Privat" in Kategorie + * und Klasse. Umbuchungen ("[Konto]") behandelt der Importeur gesondert; + * hier bleibt die Klammer stehen, damit er sie erkennt. + * + * @return array{kategorie: string, klasse: string} + */ + public static function zerlegeQifAngabe(string $angabe): array + { + $angabe = trim($angabe); + $pos = strrpos($angabe, '/'); + + if ($pos === false) { + return ['kategorie' => $angabe, 'klasse' => '']; + } + + return [ + 'kategorie' => trim(substr($angabe, 0, $pos)), + 'klasse' => trim(substr($angabe, $pos + 1)), + ]; + } + + public static function normalisierePfad(string $pfad): string + { + $teile = array_map('trim', explode(self::TRENNER, trim($pfad))); + $teile = array_values(array_filter($teile, static fn(string $t): bool => $t !== '')); + return implode(self::TRENNER, $teile); + } + + private static function pruefeName(string $name): string + { + $name = trim($name); + if ($name === '') { + throw new \InvalidArgumentException('Die Kategorie braucht einen Namen.'); + } + if (str_contains($name, self::TRENNER)) { + throw new \InvalidArgumentException( + 'Der Name darf keinen Doppelpunkt enthalten – er trennt die Ebenen.' + ); + } + return $name; + } + + private static function pruefeArt(string $art): string + { + if (!isset(self::ARTEN[$art])) { + throw new \InvalidArgumentException('Unbekannte Kategorieart: ' . $art); + } + return $art; + } + + /** @param array $daten */ + private static function elternIdAus(array $daten): ?int + { + $wert = $daten['eltern_id'] ?? null; + if ($wert === null || $wert === '' || (int)$wert === 0) { + return null; + } + return (int)$wert; + } + + /** Läuft der Teilbaum von $wurzelId über $id? */ + private static function istNachfahre(int $id, int $wurzelId): bool + { + $aktuell = self::find($id); + while ($aktuell !== null && $aktuell['eltern_id'] !== null) { + if ((int)$aktuell['eltern_id'] === $wurzelId) { + return true; + } + $aktuell = self::find((int)$aktuell['eltern_id']); + } + return false; + } + + private static function setzeArtRekursiv(int $id, string $art): void + { + Database::execute( + 'UPDATE kategorien SET art = ? WHERE id = ? AND benutzer_id = ?', + [$art, $id, Mandant::id()] + ); + foreach (self::kinder($id) as $kind) { + self::setzeArtRekursiv((int)$kind['id'], $art); + } + } + + /** Schreibt pfad für die Kategorie und alle darunter neu. */ + private static function pfadeNeuAufbauen(int $id): void + { + $kategorie = self::find($id); + if ($kategorie === null) { + return; + } + + $pfad = $kategorie['name']; + if ($kategorie['eltern_id'] !== null) { + $eltern = self::find((int)$kategorie['eltern_id']); + if ($eltern !== null) { + $pfad = $eltern['pfad'] . self::TRENNER . $kategorie['name']; + } + } + + Database::execute( + 'UPDATE kategorien SET pfad = ? WHERE id = ? AND benutzer_id = ?', + [$pfad, $id, Mandant::id()] + ); + + foreach (self::kinder($id) as $kind) { + self::pfadeNeuAufbauen((int)$kind['id']); + } + } +} diff --git a/src/Models/Klasse.php b/src/Models/Klasse.php new file mode 100644 index 0000000..b05d1ba --- /dev/null +++ b/src/Models/Klasse.php @@ -0,0 +1,107 @@ +> */ + public static function alle(bool $nurAktive = false): array + { + $sql = 'SELECT * FROM klassen WHERE benutzer_id = ?'; + if ($nurAktive) { + $sql .= ' AND aktiv = 1'; + } + return Database::fetchAll($sql . ' ORDER BY name', [Mandant::id()]); + } + + public static function find(int $id): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM klassen WHERE id = ? AND benutzer_id = ?', + [$id, Mandant::id()] + ); + return $zeile === false ? null : $zeile; + } + + public static function findeNachName(string $name): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM klassen WHERE name = ? AND benutzer_id = ?', + [trim($name), Mandant::id()] + ); + return $zeile === false ? null : $zeile; + } + + public static function anlegen(string $name, string $beschreibung = ''): int + { + $name = trim($name); + if ($name === '') { + throw new \InvalidArgumentException('Die Klasse braucht einen Namen.'); + } + if (self::findeNachName($name) !== null) { + throw new \RuntimeException('Die Klasse „' . $name . '“ gibt es bereits.'); + } + + Database::execute( + 'INSERT INTO klassen (benutzer_id, name, beschreibung) VALUES (?, ?, ?)', + [Mandant::id(), $name, $beschreibung] + ); + return Database::lastInsertId(); + } + + /** + * Legt die Klasse an, falls es sie noch nicht gibt, und liefert in jedem + * Fall ihre Kennung. Der QIF-Import läuft ausschließlich hierüber, damit ein + * zweiter Importlauf keine Dubletten erzeugt. + */ + public static function findenOderAnlegen(string $name, string $beschreibung = ''): int + { + $vorhanden = self::findeNachName($name); + return $vorhanden !== null ? (int)$vorhanden['id'] : self::anlegen($name, $beschreibung); + } + + public static function speichern(int $id, string $name, string $beschreibung, bool $aktiv): void + { + $name = trim($name); + if ($name === '') { + throw new \InvalidArgumentException('Die Klasse braucht einen Namen.'); + } + + $andere = self::findeNachName($name); + if ($andere !== null && (int)$andere['id'] !== $id) { + throw new \RuntimeException('Die Klasse „' . $name . '“ gibt es bereits.'); + } + + Database::execute( + 'UPDATE klassen SET name = ?, beschreibung = ?, aktiv = ? WHERE id = ? AND benutzer_id = ?', + [$name, $beschreibung, $aktiv ? 1 : 0, $id, Mandant::id()] + ); + } + + public static function loeschen(int $id): void + { + Database::execute( + 'DELETE FROM klassen WHERE id = ? AND benutzer_id = ?', + [$id, Mandant::id()] + ); + } + + public static function anzahl(): int + { + return (int)Database::fetchValue( + 'SELECT COUNT(*) FROM klassen WHERE benutzer_id = ?', + [Mandant::id()], + 0 + ); + } +} diff --git a/src/Models/Kontengruppe.php b/src/Models/Kontengruppe.php new file mode 100644 index 0000000..3ebd9ac --- /dev/null +++ b/src/Models/Kontengruppe.php @@ -0,0 +1,95 @@ +> */ + public static function alle(): array + { + return Database::fetchAll( + 'SELECT * FROM kontengruppen WHERE benutzer_id = ? ORDER BY sortierung, name', + [Mandant::id()] + ); + } + + public static function find(int $id): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM kontengruppen WHERE id = ? AND benutzer_id = ?', + [$id, Mandant::id()] + ); + return $zeile === false ? null : $zeile; + } + + public static function findeNachName(string $name): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM kontengruppen WHERE name = ? AND benutzer_id = ?', + [trim($name), Mandant::id()] + ); + return $zeile === false ? null : $zeile; + } + + public static function anlegen(string $name, string $beschreibung = '', int $sortierung = 0): int + { + $name = trim($name); + if ($name === '') { + throw new \InvalidArgumentException('Die Kontengruppe braucht einen Namen.'); + } + if (self::findeNachName($name) !== null) { + throw new \RuntimeException('Die Kontengruppe „' . $name . '“ gibt es bereits.'); + } + + Database::execute( + 'INSERT INTO kontengruppen (benutzer_id, name, beschreibung, sortierung) VALUES (?, ?, ?, ?)', + [Mandant::id(), $name, $beschreibung, $sortierung] + ); + return Database::lastInsertId(); + } + + public static function findenOderAnlegen(string $name): int + { + $vorhanden = self::findeNachName($name); + return $vorhanden !== null ? (int)$vorhanden['id'] : self::anlegen($name); + } + + public static function speichern(int $id, string $name, string $beschreibung, int $sortierung): void + { + $name = trim($name); + if ($name === '') { + throw new \InvalidArgumentException('Die Kontengruppe braucht einen Namen.'); + } + + $andere = self::findeNachName($name); + if ($andere !== null && (int)$andere['id'] !== $id) { + throw new \RuntimeException('Die Kontengruppe „' . $name . '“ gibt es bereits.'); + } + + Database::execute( + 'UPDATE kontengruppen SET name = ?, beschreibung = ?, sortierung = ? WHERE id = ? AND benutzer_id = ?', + [$name, $beschreibung, $sortierung, $id, Mandant::id()] + ); + } + + /** Die zugeordneten Konten rutschen per ON DELETE SET NULL zu „Nicht gruppiert“. */ + public static function loeschen(int $id): void + { + Database::execute( + 'DELETE FROM kontengruppen WHERE id = ? AND benutzer_id = ?', + [$id, Mandant::id()] + ); + } +} diff --git a/src/Models/Konto.php b/src/Models/Konto.php new file mode 100644 index 0000000..c403ec3 --- /dev/null +++ b/src/Models/Konto.php @@ -0,0 +1,250 @@ + 'Bank', + 'kreditkarte' => 'Kreditkarte', + 'bar' => 'Bar', + 'sparkonto' => 'Tagesgeld/Sparkonto', + 'vermoegen' => 'Vermögen', + 'verbindlichkeit' => 'Verbindlichkeit', + 'rente' => 'Rentenversicherung', + 'sonstiges' => 'Sonstiges Vermögen', + 'depot' => 'Depot', + ]; + + /** + * QIF-Kontotyp → Kontoart. "Oth E" und "Oth F" fasst der FinanzManager beim + * Export zu sonstigem Vermögen zusammen, "Port" und "Invst" beide zum Depot. + */ + public const QIF_ARTEN = [ + 'Bank' => 'bank', + 'CCard' => 'kreditkarte', + 'Cash' => 'bar', + 'Oth T' => 'sparkonto', + 'Oth A' => 'vermoegen', + 'Oth L' => 'verbindlichkeit', + 'Oth P' => 'rente', + 'Oth E' => 'sonstiges', + 'Oth F' => 'sonstiges', + 'Port' => 'depot', + 'Invst' => 'depot', + ]; + + /** Konten, deren Saldo üblicherweise eine Schuld ausweist. */ + public const SCHULDARTEN = ['kreditkarte', 'verbindlichkeit']; + + public static function artAusQif(string $qifTyp): string + { + $typ = trim($qifTyp); + return self::QIF_ARTEN[$typ] ?? 'sonstiges'; + } + + public static function bezeichnungDerArt(string $art): string + { + return self::ARTEN[$art] ?? $art; + } + + /** @return list> */ + public static function alle(bool $nurAktive = false): array + { + $sql = 'SELECT k.*, g.name AS gruppenname + FROM konten k + LEFT JOIN kontengruppen g ON g.id = k.kontengruppe_id + WHERE k.benutzer_id = ?'; + if ($nurAktive) { + $sql .= ' AND k.aktiv = 1'; + } + + return Database::fetchAll( + $sql . ' ORDER BY g.sortierung IS NULL, g.sortierung, g.name, k.sortierung, k.name', + [Mandant::id()] + ); + } + + public static function find(int $id): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM konten WHERE id = ? AND benutzer_id = ?', + [$id, Mandant::id()] + ); + return $zeile === false ? null : $zeile; + } + + public static function findeNachName(string $name): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM konten WHERE name = ? AND benutzer_id = ?', + [trim($name), Mandant::id()] + ); + return $zeile === false ? null : $zeile; + } + + /** + * @param array $daten + */ + public static function anlegen(array $daten): int + { + $name = trim((string)($daten['name'] ?? '')); + if ($name === '') { + throw new \InvalidArgumentException('Das Konto braucht einen Namen.'); + } + if (self::findeNachName($name) !== null) { + throw new \RuntimeException('Das Konto „' . $name . '“ gibt es bereits.'); + } + + $felder = self::normalisiere($daten); + + Database::execute( + 'INSERT INTO konten + (benutzer_id, kontengruppe_id, name, kontoart, bankname, kontonummer, iban, bic, + beschreibung, anfangssaldo, kreditlimit, waehrung, aktiv, sortierung) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + Mandant::id(), $felder['kontengruppe_id'], $name, $felder['kontoart'], + $felder['bankname'], $felder['kontonummer'], $felder['iban'], $felder['bic'], + $felder['beschreibung'], $felder['anfangssaldo'], $felder['kreditlimit'], + $felder['waehrung'], $felder['aktiv'], $felder['sortierung'], + ] + ); + return Database::lastInsertId(); + } + + /** + * @param array $daten + */ + public static function speichern(int $id, array $daten): void + { + $name = trim((string)($daten['name'] ?? '')); + if ($name === '') { + throw new \InvalidArgumentException('Das Konto braucht einen Namen.'); + } + + $andere = self::findeNachName($name); + if ($andere !== null && (int)$andere['id'] !== $id) { + throw new \RuntimeException('Das Konto „' . $name . '“ gibt es bereits.'); + } + + $felder = self::normalisiere($daten); + + Database::execute( + 'UPDATE konten SET kontengruppe_id = ?, name = ?, kontoart = ?, bankname = ?, + kontonummer = ?, iban = ?, bic = ?, beschreibung = ?, anfangssaldo = ?, + kreditlimit = ?, waehrung = ?, aktiv = ?, sortierung = ? + WHERE id = ? AND benutzer_id = ?', + [ + $felder['kontengruppe_id'], $name, $felder['kontoart'], $felder['bankname'], + $felder['kontonummer'], $felder['iban'], $felder['bic'], $felder['beschreibung'], + $felder['anfangssaldo'], $felder['kreditlimit'], $felder['waehrung'], + $felder['aktiv'], $felder['sortierung'], $id, Mandant::id(), + ] + ); + } + + public static function loeschen(int $id): void + { + Database::execute('DELETE FROM konten WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); + } + + public static function anzahl(): int + { + return (int)Database::fetchValue( + 'SELECT COUNT(*) FROM konten WHERE benutzer_id = ?', + [Mandant::id()], + 0 + ); + } + + /** + * Kontenübersicht: Konten nach Kontengruppe gebündelt, je Gruppe eine Summe + * und darunter die Gesamtsumme. + * + * Die drei Saldospalten des FinanzManagers stehen schon jetzt in den Daten: + * Online-Saldo (Stand des letzten Bankabrufs, Phase 5), aktueller Saldo + * (gebuchte Umsätze bis heute) und erwarteter Saldo (einschließlich künftig + * datierter Buchungen). Solange es keine Buchungen gibt, entsprechen alle + * drei dem Anfangssaldo. + * + * @return array{gruppen: list>, summe: float} + */ + public static function uebersicht(bool $nurAktive = true): array + { + $gruppen = []; + $summe = 0.0; + + foreach (self::alle($nurAktive) as $konto) { + $konto = self::mitSalden($konto); + $name = $konto['gruppenname'] ?? null; + $name = ($name === null || $name === '') ? Kontengruppe::OHNE_GRUPPE : $name; + + if (!isset($gruppen[$name])) { + $gruppen[$name] = ['name' => $name, 'konten' => [], 'summe' => 0.0]; + } + $gruppen[$name]['konten'][] = $konto; + $gruppen[$name]['summe'] += $konto['saldo_erwartet']; + $summe += $konto['saldo_erwartet']; + } + + return ['gruppen' => array_values($gruppen), 'summe' => $summe]; + } + + /** + * Ergänzt die Saldospalten. Ab Phase 2 kommen hier die Buchungen dazu – + * die Aufrufer bleiben davon unberührt. + * + * @param array $konto + * @return array + */ + public static function mitSalden(array $konto): array + { + $anfang = (float)$konto['anfangssaldo']; + + $konto['saldo_online'] = null; + $konto['saldo_aktuell'] = $anfang; + $konto['saldo_erwartet'] = $anfang; + + return $konto; + } + + /** + * @param array $daten + * @return array + */ + private static function normalisiere(array $daten): array + { + $art = (string)($daten['kontoart'] ?? 'bank'); + if (!isset(self::ARTEN[$art])) { + throw new \InvalidArgumentException('Unbekannte Kontoart: ' . $art); + } + + $gruppe = $daten['kontengruppe_id'] ?? null; + $gruppe = ($gruppe === null || $gruppe === '' || (int)$gruppe === 0) ? null : (int)$gruppe; + + $limit = $daten['kreditlimit'] ?? null; + $limit = ($limit === null || $limit === '') ? null : (float)$limit; + + return [ + 'kontengruppe_id' => $gruppe, + 'kontoart' => $art, + 'bankname' => trim((string)($daten['bankname'] ?? '')), + 'kontonummer' => trim((string)($daten['kontonummer'] ?? '')), + 'iban' => strtoupper(str_replace(' ', '', (string)($daten['iban'] ?? ''))), + 'bic' => strtoupper(trim((string)($daten['bic'] ?? ''))), + 'beschreibung' => trim((string)($daten['beschreibung'] ?? '')), + 'anfangssaldo' => (float)($daten['anfangssaldo'] ?? 0), + 'kreditlimit' => $limit, + 'waehrung' => strtoupper(trim((string)($daten['waehrung'] ?? 'EUR'))) ?: 'EUR', + 'aktiv' => !empty($daten['aktiv']) ? 1 : 0, + 'sortierung' => (int)($daten['sortierung'] ?? 0), + ]; + } +} diff --git a/src/Services/QifParser.php b/src/Services/QifParser.php new file mode 100644 index 0000000..072df2f --- /dev/null +++ b/src/Services/QifParser.php @@ -0,0 +1,280 @@ +}> */ + private array $eintraege = []; + + public function __construct(string $inhalt) + { + $this->lies($inhalt); + } + + public static function ausDatei(string $pfad): self + { + if (!is_readable($pfad)) { + throw new \RuntimeException('Die Datei ist nicht lesbar: ' . $pfad); + } + $inhalt = file_get_contents($pfad); + if ($inhalt === false) { + throw new \RuntimeException('Die Datei konnte nicht gelesen werden: ' . $pfad); + } + return new self($inhalt); + } + + /** + * @return list}> + */ + public function eintraege(?string $typ = null): array + { + if ($typ === null) { + return $this->eintraege; + } + return array_values(array_filter( + $this->eintraege, + static fn(array $e): bool => $e['typ'] === $typ + )); + } + + public function datumsformat(): string + { + return $this->datumsformat; + } + + /** Wie viele Einträge je Sektionstyp? Grundlage der Import-Vorschau. */ + public function zusammenfassung(): array + { + $zaehler = []; + foreach ($this->eintraege as $eintrag) { + $zaehler[$eintrag['typ']] = ($zaehler[$eintrag['typ']] ?? 0) + 1; + } + return $zaehler; + } + + /** + * Erster Wert eines Feldes, oder null. + * + * @param array{zeilen: list} $eintrag + */ + public static function feld(array $eintrag, string $code): ?string + { + foreach ($eintrag['zeilen'] as [$c, $wert]) { + if ($c === $code) { + return $wert; + } + } + return null; + } + + /** + * Alle Werte eines Feldes – Splitzeilen und Adressen kommen mehrfach vor. + * + * @param array{zeilen: list} $eintrag + * @return list + */ + public static function felder(array $eintrag, string $code): array + { + $werte = []; + foreach ($eintrag['zeilen'] as [$c, $wert]) { + if ($c === $code) { + $werte[] = $wert; + } + } + return $werte; + } + + /** Ist das Feld vorhanden? Bei Kategorien sind "I" und "E" reine Marker ohne Wert. */ + public static function hatFeld(array $eintrag, string $code): bool + { + foreach ($eintrag['zeilen'] as [$c, $_]) { + if ($c === $code) { + return true; + } + } + return false; + } + + /** + * Beträge stehen im US-Format: "-1,241.04". Das Komma ist Tausendertrenner, + * der Punkt trennt die Nachkommastellen. + */ + public static function betrag(?string $wert): float + { + if ($wert === null) { + return 0.0; + } + $bereinigt = str_replace([',', ' ', "\u{00A0}"], '', trim($wert)); + return $bereinigt === '' ? 0.0 : (float)$bereinigt; + } + + /** + * Datum nach ISO. Der Export nennt das Format im Kopf ("!Option:MDY"), die + * Jahreszahl ist zweistellig: "D3.18.26" ist der 18.03.2026. Zweistellige + * Jahre ab 70 gelten als 19xx – Quicken-Konvention, und private Buchungen + * aus den Sechzigern gibt es nicht. + */ + public static function datum(?string $wert, string $format = 'MDY'): ?string + { + if ($wert === null) { + return null; + } + + $teile = preg_split('/[.\/\-]/', trim($wert)); + if ($teile === false || count($teile) !== 3) { + return null; + } + + [$a, $b, $c] = array_map(static fn(string $t): int => (int)preg_replace('/\D/', '', $t), $teile); + + if ($format === 'DMY') { + [$tag, $monat, $jahr] = [$a, $b, $c]; + } else { + [$monat, $tag, $jahr] = [$a, $b, $c]; + } + + if ($jahr < 100) { + $jahr += $jahr >= 70 ? 1900 : 2000; + } + + if (!checkdate($monat, $tag, $jahr)) { + return null; + } + + return sprintf('%04d-%02d-%02d', $jahr, $monat, $tag); + } + + private function lies(string $inhalt): void + { + $inhalt = self::nachUtf8($inhalt); + $zeilen = explode("\n", str_replace(["\r\n", "\r"], "\n", $inhalt)); + + $typ = ''; + $aktuellesKonto = null; + $zeilenPuffer = []; + + foreach ($zeilen as $zeile) { + $zeile = rtrim($zeile); + if ($zeile === '') { + continue; + } + + if ($zeile[0] === '!') { + $neuerTyp = $this->steuerzeile($zeile); + if ($neuerTyp !== null) { + $typ = $neuerTyp; + } + continue; + } + + if ($zeile[0] === '^') { + if ($zeilenPuffer !== []) { + // Jeder Kontoeintrag setzt zugleich das aktuelle Konto. In + // der AutoSwitch-Liste folgen mehrere aufeinander, ohne dass + // Buchungen dazwischenstehen – das schadet nicht, deckt aber + // die zweite Schreibweise mit ab, bei der ein einzelner + // "!Account" den Kontowechsel bedeutet. + if ($typ === self::TYP_KONTO) { + $aktuellesKonto = self::feld(['zeilen' => $zeilenPuffer], 'N'); + } + + $this->eintraege[] = [ + 'typ' => $typ, + 'konto' => $typ === self::TYP_KONTO ? null : $aktuellesKonto, + 'zeilen' => $zeilenPuffer, + ]; + $zeilenPuffer = []; + } + continue; + } + + $zeilenPuffer[] = [$zeile[0], substr($zeile, 1)]; + } + + // Ein fehlendes abschließendes "^" darf den letzten Eintrag nicht schlucken. + if ($zeilenPuffer !== []) { + $this->eintraege[] = [ + 'typ' => $typ, + 'konto' => $typ === self::TYP_KONTO ? null : $aktuellesKonto, + 'zeilen' => $zeilenPuffer, + ]; + } + } + + /** Wertet eine "!"-Zeile aus und liefert den neuen Sektionstyp, falls einer folgt. */ + private function steuerzeile(string $zeile): ?string + { + $zeile = trim($zeile); + + if (str_starts_with($zeile, '!Option:')) { + $option = trim(substr($zeile, 8)); + if (in_array($option, ['MDY', 'DMY', 'YMD'], true)) { + $this->datumsformat = $option; + } + return null; // AutoSwitch ändert den Sektionstyp nicht. + } + + if (str_starts_with($zeile, '!Clear:')) { + return null; + } + + if (str_starts_with($zeile, '!Type:')) { + return trim(substr($zeile, 6)); + } + + if (str_starts_with($zeile, '!Account')) { + return self::TYP_KONTO; + } + + return null; + } + + /** + * Der Export ist ISO-8859-1. Läge schon UTF-8 vor (etwa weil die Datei + * unterwegs konvertiert wurde), würde eine zweite Konvertierung die Umlaute + * zerstören – deshalb erst prüfen. + */ + public static function nachUtf8(string $inhalt): string + { + if (mb_check_encoding($inhalt, 'UTF-8')) { + return $inhalt; + } + return mb_convert_encoding($inhalt, 'UTF-8', 'ISO-8859-1'); + } +} diff --git a/src/Services/QifStammdatenImport.php b/src/Services/QifStammdatenImport.php new file mode 100644 index 0000000..4a0d8cd --- /dev/null +++ b/src/Services/QifStammdatenImport.php @@ -0,0 +1,229 @@ + */ + private array $meldungen = []; + + public function __construct(QifParser $parser) + { + $this->parser = $parser; + } + + public static function ausDatei(string $pfad): self + { + return new self(QifParser::ausDatei($pfad)); + } + + /** + * @return array{klassen: array, kategorien: array, + * konten: array, meldungen: list, + * trockenlauf: bool} + */ + public function ausfuehren(bool $trockenlauf = false): array + { + $this->meldungen = []; + + $lauf = function () use ($trockenlauf): array { + return [ + 'klassen' => $this->klassen($trockenlauf), + 'kategorien' => $this->kategorien($trockenlauf), + 'konten' => $this->konten($trockenlauf), + 'trockenlauf' => $trockenlauf, + ]; + }; + + // Beim echten Lauf gilt alles oder nichts – ein Abbruch mitten in der + // Kategorieliste hinterließe sonst einen halben Baum. + $ergebnis = $trockenlauf ? $lauf() : Database::transaktion($lauf); + + $ergebnis['meldungen'] = $this->meldungen; + return $ergebnis; + } + + /** @return array */ + private function klassen(bool $trockenlauf): array + { + $zaehler = ['neu' => 0, 'vorhanden' => 0, 'fehler' => 0]; + $bekannte = array_column(Klasse::alle(), 'name'); + + foreach ($this->parser->eintraege(QifParser::TYP_KLASSE) as $eintrag) { + $name = trim((string)QifParser::feld($eintrag, 'N')); + if ($name === '') { + $zaehler['fehler']++; + $this->melde('fehler', 'Klasse ohne Namen übersprungen.'); + continue; + } + + if (in_array($name, $bekannte, true)) { + $zaehler['vorhanden']++; + continue; + } + + try { + if (!$trockenlauf) { + Klasse::anlegen($name, (string)QifParser::feld($eintrag, 'D')); + } + $bekannte[] = $name; + $zaehler['neu']++; + } catch (\Throwable $e) { + $zaehler['fehler']++; + $this->melde('fehler', 'Klasse „' . $name . '“: ' . $e->getMessage()); + } + } + + return $zaehler; + } + + /** @return array */ + private function kategorien(bool $trockenlauf): array + { + $zaehler = ['neu' => 0, 'vorhanden' => 0, 'fehler' => 0]; + + // Im Trockenlauf werden auch die Elternstufen mitgezählt, die der + // Import selbst anlegen würde – sonst stimmte die Vorschau nicht. + $bekannte = array_flip(array_column(Kategorie::alle(), 'pfad')); + + foreach ($this->parser->eintraege(QifParser::TYP_KATEGORIE) as $eintrag) { + $pfad = Kategorie::normalisierePfad((string)QifParser::feld($eintrag, 'N')); + if ($pfad === '') { + $zaehler['fehler']++; + $this->melde('fehler', 'Kategorie ohne Namen übersprungen.'); + continue; + } + + if (isset($bekannte[$pfad])) { + $zaehler['vorhanden']++; + continue; + } + + $art = QifParser::hatFeld($eintrag, 'I') ? 'einnahme' : 'ausgabe'; + $daten = [ + 'beschreibung' => (string)QifParser::feld($eintrag, 'D'), + 'steuerrelevant' => QifParser::hatFeld($eintrag, 'T'), + 'steuerformular' => (string)QifParser::feld($eintrag, 'R'), + ]; + + try { + if (!$trockenlauf) { + Kategorie::findenOderAnlegenNachPfad($pfad, $art, $daten); + } + + // Fehlende Zwischenstufen zählen als eigene neue Kategorien. + $teile = explode(Kategorie::TRENNER, $pfad); + $bisher = ''; + foreach ($teile as $teil) { + $bisher = $bisher === '' ? $teil : $bisher . Kategorie::TRENNER . $teil; + if (!isset($bekannte[$bisher])) { + $bekannte[$bisher] = true; + $zaehler['neu']++; + } + } + } catch (\Throwable $e) { + $zaehler['fehler']++; + $this->melde('fehler', 'Kategorie „' . $pfad . '“: ' . $e->getMessage()); + } + } + + return $zaehler; + } + + /** @return array */ + private function konten(bool $trockenlauf): array + { + $zaehler = ['neu' => 0, 'vorhanden' => 0, 'fehler' => 0]; + $bekannte = array_flip(array_column(Konto::alle(), 'name')); + + foreach ($this->parser->eintraege(QifParser::TYP_KONTO) as $eintrag) { + $name = trim((string)QifParser::feld($eintrag, 'N')); + if ($name === '') { + $zaehler['fehler']++; + $this->melde('fehler', 'Konto ohne Namen übersprungen.'); + continue; + } + + // In der Kontenliste steht jedes Konto einmal, danach leitet + // derselbe Eintrag noch einmal die Buchungen ein – der zweite + // Treffer ist keine Dublette, sondern derselbe Datensatz. + if (isset($bekannte[$name])) { + $zaehler['vorhanden']++; + continue; + } + + try { + if (!$trockenlauf) { + Konto::anlegen($this->kontoDaten($eintrag, $name)); + } + $bekannte[$name] = true; + $zaehler['neu']++; + } catch (\Throwable $e) { + $zaehler['fehler']++; + $this->melde('fehler', 'Konto „' . $name . '“: ' . $e->getMessage()); + } + } + + return $zaehler; + } + + /** + * @param array{zeilen: list} $eintrag + * @return array + */ + private function kontoDaten(array $eintrag, string $name): array + { + $qifTyp = (string)QifParser::feld($eintrag, 'T'); + $beschreibung = trim((string)QifParser::feld($eintrag, 'D')); + $kontonummer = ''; + + if ($beschreibung !== '' && preg_match(self::KONTONUMMER_MUSTER, $beschreibung, $treffer) === 1) { + $kontonummer = $treffer[1]; + $beschreibung = ''; + } + + $limit = QifParser::feld($eintrag, 'L'); + + return [ + 'name' => $name, + 'kontoart' => Konto::artAusQif($qifTyp), + 'beschreibung' => $beschreibung, + 'kontonummer' => $kontonummer, + 'kreditlimit' => $limit === null ? null : QifParser::betrag($limit), + // Anfangssalden stehen nicht im Export; sie werden nach dem Import + // je Konto nachgetragen (PLAN.md, Kapitel 4). + 'anfangssaldo' => 0, + 'aktiv' => 1, + ]; + } + + private function melde(string $typ, string $text): void + { + $this->meldungen[] = ['typ' => $typ, 'text' => $text]; + } + + /** Vorschau ohne Schreibzugriff. */ + public function vorschau(): array + { + return $this->ausfuehren(true); + } +} diff --git a/src/View.php b/src/View.php index cf6015d..ef74832 100644 --- a/src/View.php +++ b/src/View.php @@ -55,6 +55,30 @@ class View return $mitWaehrung ? $text . "\u{00A0}€" : $text; } + /** + * Monatsnamen auf Deutsch. Nicht über date('F') oder IntlDateFormatter – + * date() ist immer englisch und intl ist keine Standarderweiterung. Der + * Budgetplaner und die Auswertungen greifen später ebenfalls hierauf zu. + */ + public const MONATE = [ + 1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April', + 5 => 'Mai', 6 => 'Juni', 7 => 'Juli', 8 => 'August', + 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember', + ]; + + /** Kurzform für schmale Spalten (zwölf Monatsspalten im Budgetplaner). */ + public const MONATE_KURZ = [ + 1 => 'Jan', 2 => 'Feb', 3 => 'Mär', 4 => 'Apr', + 5 => 'Mai', 6 => 'Jun', 7 => 'Jul', 8 => 'Aug', + 9 => 'Sep', 10 => 'Okt', 11 => 'Nov', 12 => 'Dez', + ]; + + public static function monat(int $monat, bool $kurz = false): string + { + $namen = $kurz ? self::MONATE_KURZ : self::MONATE; + return $namen[$monat] ?? (string)$monat; + } + public static function datum(?string $datum): string { if (!$datum) { diff --git a/src/Views/cockpit/index.php b/src/Views/cockpit/index.php index d7625d5..bcfd73f 100644 --- a/src/Views/cockpit/index.php +++ b/src/Views/cockpit/index.php @@ -2,6 +2,8 @@ use Finanzmanager\View; +/** @var array{gruppen: list>, summe: float} $uebersicht */ + include __DIR__ . '/../layout/kopf.php'; ?> @@ -12,8 +14,8 @@ include __DIR__ . '/../layout/kopf.php';

Vermögensentwicklung

-

Zeigt Kontostände über die Zeit, sobald Konten und - Buchungen angelegt sind (Phase 1 und 2).

+

Zeigt Kontostände über die Zeit, sobald Buchungen + erfasst sind (Phase 2).

@@ -23,14 +25,46 @@ include __DIR__ . '/../layout/kopf.php';

Kontostände

-

Übersicht aller Konten mit aktuellem und erwartetem Saldo (Phase 1).

+ +

+ Noch keine Konten angelegt – einzeln oder gemeinsam aus dem QIF-Export. +

+ + + + + + + + + + + + + + + + + +
+ +
Summe + +
+ +

Aufbaustand

  • Phase 0 – Grundgerüst, Anmeldung, Einstellungen
  • -
  • Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten
  • +
  • Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten
  • Phase 2 – Buchungen, Kontoblatt, Splitbuchungen
  • Phase 3 – Budgetplaner und Monatsabschluss
diff --git a/src/Views/einstellungen/index.php b/src/Views/einstellungen/index.php index 86db32b..25ce63b 100644 --- a/src/Views/einstellungen/index.php +++ b/src/Views/einstellungen/index.php @@ -52,12 +52,26 @@ include __DIR__ . '/../layout/kopf.php';

Bestimmt, wo Budgetplaner und Jahresauswertungen beginnen.

+ + + +

+ Relativ zum Projektverzeichnis. Hochgeladene QIF-Dateien landen hier und bleiben + liegen, damit derselbe Export erst im Trockenlauf geprüft und dann übernommen + werden kann. +

+ + +