Phase 1: Stammdaten und QIF-Stammdatenimport
Konten, Kontengruppen, Kategorien und Klassen samt Übernahme aus dem
QIF-Export des Lexware FinanzManagers.
Kontenübersicht nach Kontengruppen gebündelt, mit Gruppensummen und
Gesamtsumme; die drei Saldospalten (Online-Saldo, aktueller und erwarteter
Saldo) sind angelegt und werden bis Phase 2 aus dem Anfangssaldo gespeist.
Kategorien sind hierarchisch mit ":" als Trenner. Der vollständige Pfad
steht redundant in der Tabelle, weil jede Anzeige und der gesamte Import
darüber laufen; Umbenennen und Verschieben schreiben den Teilbaum neu. Die
Art (Einnahme/Ausgabe) gilt für den ganzen Ast. Register wie im Original:
Alle, Einnahmen, Ausgaben, Umbuchungskonten.
QifParser liest zustandsbehaftet und sequenziell, wandelt ISO-8859-1 nach
UTF-8 und versteht US-Beträge sowie das Datumsformat aus der Kopfzeile.
QifStammdatenImport ist idempotent und bietet Trockenlauf und Protokoll -
im Dialog unter Verwaltung sowie über scripts/qif-import.php. Am echten
Export geprüft: 17 Klassen, 105 Kategorien, 37 Konten.
Enthält außerdem die Korrektur der englischen Monatsnamen im
Einstellungsdialog: date('F') ist sprachlich nicht umschaltbar, deshalb
liegen die Namen jetzt zentral als View::MONATE vor. Die Änderung ließ
sich nicht als eigener Commit trennen, weil sie dieselben Dateien berührt
wie Phase 1.
Tests: 227 PHP-Prüfungen und 14 Playwright-Tests, alle grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
67f86bd360
commit
1e3227ce83
40 changed files with 4669 additions and 27 deletions
51
Changes.md
51
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.
|
||||
|
|
|
|||
11
PLAN.md
11
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`.
|
||||
|
|
|
|||
18
README.md
18
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
|
||||
|
|
|
|||
102
docs/qif-import.md
Normal file
102
docs/qif-import.md
Normal file
|
|
@ -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)
|
||||
```
|
||||
76
docs/stammdaten.md
Normal file
76
docs/stammdaten.md
Normal file
|
|
@ -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.
|
||||
88
migrations/002_stammdaten.sql
Normal file
88
migrations/002_stammdaten.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
112
scripts/qif-import.php
Normal file
112
scripts/qif-import.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* QIF-Stammdatenimport auf der Kommandozeile.
|
||||
*
|
||||
* Dieselbe Logik wie im Dialog unter Verwaltung → QIF-Import, aber ohne Upload –
|
||||
* praktisch für den großen Vollexport und für die Erstbefüllung.
|
||||
*
|
||||
* 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: es wird nichts gespeichert.
|
||||
*/
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use Finanzmanager\Config;
|
||||
use Finanzmanager\Mandant;
|
||||
use Finanzmanager\Models\Benutzer;
|
||||
use Finanzmanager\Services\QifParser;
|
||||
use Finanzmanager\Services\QifStammdatenImport;
|
||||
|
||||
Config::load();
|
||||
|
||||
$argumente = array_slice($argv, 1);
|
||||
$uebernehmen = false;
|
||||
$benutzer = null;
|
||||
$datei = null;
|
||||
|
||||
foreach ($argumente as $argument) {
|
||||
if ($argument === '--uebernehmen') {
|
||||
$uebernehmen = true;
|
||||
} elseif (str_starts_with($argument, '--benutzer=')) {
|
||||
$benutzer = substr($argument, 11);
|
||||
} elseif (str_starts_with($argument, '-')) {
|
||||
fwrite(STDERR, "Unbekannte Option: {$argument}\n");
|
||||
exit(1);
|
||||
} else {
|
||||
$datei = $argument;
|
||||
}
|
||||
}
|
||||
|
||||
if ($datei === null) {
|
||||
fwrite(STDERR, "Aufruf: qif-import.php [--uebernehmen] [--benutzer=NAME] <datei.qif>\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);
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => [],
|
||||
};
|
||||
|
|
|
|||
182
src/Controllers/ImportController.php
Normal file
182
src/Controllers/ImportController.php
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Controllers;
|
||||
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Services\QifParser;
|
||||
use Finanzmanager\Services\QifStammdatenImport;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* QIF-Import der Stammdaten (Klassen, Kategorien, Konten).
|
||||
*
|
||||
* Hochgeladene Dateien landen im Importverzeichnis und bleiben dort – so lässt
|
||||
* sich derselbe Export erst im Trockenlauf prüfen und danach übernehmen, ohne
|
||||
* ihn zweimal hochzuladen. Buchungen folgen in Phase 2.
|
||||
*/
|
||||
class ImportController extends BaseController
|
||||
{
|
||||
private const ERLAUBTE_ENDUNGEN = ['qif'];
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$this->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<string,string> $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<array{name: string, groesse: int, geaendert: int}> */
|
||||
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 . ').',
|
||||
};
|
||||
}
|
||||
}
|
||||
183
src/Controllers/KategorienController.php
Normal file
183
src/Controllers/KategorienController.php
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Controllers;
|
||||
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Kategorieverwaltung mit den Registern des FinanzManagers: Alle, Einnahmen,
|
||||
* Ausgaben, Umbuchungskonten. Umbuchungskonten sind keine eigenen Datensätze –
|
||||
* dort erscheinen die Konten, weil sie in der Kategorieauswahl einer Buchung
|
||||
* an derselben Stelle stehen.
|
||||
*/
|
||||
class KategorienController extends BaseController
|
||||
{
|
||||
private const REGISTER = [
|
||||
'alle' => '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<string,string> $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<string,string> $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<string,string> $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<string,string> $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<string,string> $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']);
|
||||
}
|
||||
}
|
||||
166
src/Controllers/KontenController.php
Normal file
166
src/Controllers/KontenController.php
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Controllers;
|
||||
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Kontengruppe;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Kontenübersicht und Kontenpflege. Die Übersicht bildet die Gliederung des
|
||||
* FinanzManagers nach: Konten nach Kontengruppen gebündelt, je Gruppe eine
|
||||
* Summe, darunter die Gesamtsumme.
|
||||
*/
|
||||
class KontenController extends BaseController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$this->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<string,string> $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<string,string> $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<string,string> $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<string,mixed> */
|
||||
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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
150
src/Controllers/VerwaltungController.php
Normal file
150
src/Controllers/VerwaltungController.php
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Controllers;
|
||||
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Klasse;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Kontengruppe;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Einstiegsseite der Verwaltung sowie die beiden schlanken Stammdatenlisten
|
||||
* Klassen und Kontengruppen – beide flach, beide mit Anlegen, Bearbeiten und
|
||||
* Löschen auf einer Seite.
|
||||
*/
|
||||
class VerwaltungController extends BaseController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$this->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<string,string> $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<string,string> $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<string,string> $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<string,string> $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 . '“.'
|
||||
);
|
||||
}
|
||||
}
|
||||
55
src/Mandant.php
Normal file
55
src/Mandant.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager;
|
||||
|
||||
use Finanzmanager\Models\Benutzer;
|
||||
|
||||
/**
|
||||
* Liefert die benutzer_id, unter der Stammdaten und Buchungen geführt werden.
|
||||
*
|
||||
* Die Oberfläche ist einbenutzerig, das Datenmodell nicht (PLAN.md, Kapitel 3).
|
||||
* Im Web kommt die Kennung aus der Session; CLI-Skripte (Import, Migration von
|
||||
* Altdaten) haben keine – dort greift der erste aktive Benutzer, solange kein
|
||||
* anderer über setze() vorgegeben wurde.
|
||||
*/
|
||||
class Mandant
|
||||
{
|
||||
private static ?int $festgelegt = null;
|
||||
|
||||
public static function id(): int
|
||||
{
|
||||
if (self::$festgelegt !== null) {
|
||||
return self::$festgelegt;
|
||||
}
|
||||
|
||||
$ausSession = Auth::benutzerId();
|
||||
if ($ausSession !== null) {
|
||||
return $ausSession;
|
||||
}
|
||||
|
||||
$id = Database::fetchValue(
|
||||
'SELECT id FROM benutzer WHERE aktiv = 1 ORDER BY id LIMIT 1'
|
||||
);
|
||||
if ($id === null || $id === false) {
|
||||
throw new \RuntimeException(
|
||||
'Kein Benutzer vorhanden. Zuerst die Ersteinrichtung durchführen.'
|
||||
);
|
||||
}
|
||||
|
||||
return (int)$id;
|
||||
}
|
||||
|
||||
/** Für CLI-Skripte und Tests: Mandanten fest vorgeben. */
|
||||
public static function setze(?int $benutzerId): void
|
||||
{
|
||||
self::$festgelegt = $benutzerId;
|
||||
}
|
||||
|
||||
/** Gibt es überhaupt einen Mandanten? Ohne Ausnahme, für Prüfungen vorab. */
|
||||
public static function vorhanden(): bool
|
||||
{
|
||||
return Benutzer::anzahl() > 0;
|
||||
}
|
||||
}
|
||||
464
src/Models/Kategorie.php
Normal file
464
src/Models/Kategorie.php
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Models;
|
||||
|
||||
use Finanzmanager\Database;
|
||||
use Finanzmanager\Mandant;
|
||||
|
||||
/**
|
||||
* Kategorien sind hierarchisch, Trenner ist ":" wie im FinanzManager
|
||||
* ("400 - Leben:B-Essen alleine"). Der vollständige Pfad steht redundant in der
|
||||
* Spalte "pfad": jede Auswahl, jeder Bericht und der gesamte QIF-Import
|
||||
* arbeiten damit, und ein rekursiver Aufbau bei jedem Zugriff wäre teuer.
|
||||
* Umbenennen und Verschieben ziehen die Pfade der Nachfahren mit.
|
||||
*/
|
||||
class Kategorie
|
||||
{
|
||||
public const ARTEN = ['einnahme' => 'Einnahme', 'ausgabe' => 'Ausgabe'];
|
||||
|
||||
public const TRENNER = ':';
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
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<array<string,mixed>>
|
||||
*/
|
||||
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<array<string,mixed>>
|
||||
*/
|
||||
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<string,mixed> $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<string,mixed> $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<string,mixed> $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<array<string,mixed>> */
|
||||
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<string,mixed> $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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
107
src/Models/Klasse.php
Normal file
107
src/Models/Klasse.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Models;
|
||||
|
||||
use Finanzmanager\Database;
|
||||
use Finanzmanager\Mandant;
|
||||
|
||||
/**
|
||||
* Klassen sind die zweite Auswertungsdimension neben der Kategorie – flach,
|
||||
* ohne Hierarchie. Im QIF hängen sie mit "/" an der Kategorie
|
||||
* ("400 - Leben:B-Essen alleine/Privat").
|
||||
*/
|
||||
class Klasse
|
||||
{
|
||||
/** @return list<array<string,mixed>> */
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
95
src/Models/Kontengruppe.php
Normal file
95
src/Models/Kontengruppe.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Models;
|
||||
|
||||
use Finanzmanager\Database;
|
||||
use Finanzmanager\Mandant;
|
||||
|
||||
/**
|
||||
* Kontengruppen bündeln Konten in der Kontenübersicht („Anlage“, „Girokonten“ …).
|
||||
* Konten ohne Gruppe erscheinen unter „Nicht gruppiert“ – das ist keine echte
|
||||
* Gruppe, sondern kontengruppe_id IS NULL.
|
||||
*/
|
||||
class Kontengruppe
|
||||
{
|
||||
public const OHNE_GRUPPE = 'Nicht gruppiert';
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
250
src/Models/Konto.php
Normal file
250
src/Models/Konto.php
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Models;
|
||||
|
||||
use Finanzmanager\Database;
|
||||
use Finanzmanager\Mandant;
|
||||
|
||||
class Konto
|
||||
{
|
||||
/** Kontoarten des FinanzManagers, Schlüssel → Bezeichnung in der Oberfläche. */
|
||||
public const ARTEN = [
|
||||
'bank' => '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<array<string,mixed>> */
|
||||
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<string,mixed> $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<string,mixed> $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<array<string,mixed>>, 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<string,mixed> $konto
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $daten
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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),
|
||||
];
|
||||
}
|
||||
}
|
||||
280
src/Services/QifParser.php
Normal file
280
src/Services/QifParser.php
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Services;
|
||||
|
||||
/**
|
||||
* Parser für das erweiterte QIF des Lexware FinanzManagers.
|
||||
*
|
||||
* Die Datei ist zustandsbehaftet und muss in der vorliegenden Reihenfolge
|
||||
* gelesen werden – der Kontobezug einer Buchung steckt allein darin, welcher
|
||||
* "!Account"-Abschnitt zuletzt kam. Deshalb liefert der Parser eine flache
|
||||
* Liste von Einträgen, jeweils mit Sektionstyp und aktuellem Konto, statt die
|
||||
* Datei nach Typen zu gruppieren.
|
||||
*
|
||||
* Aufbau:
|
||||
* !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 vorkommen (Splitzeilen,
|
||||
* Adresszeilen), deshalb wird jeder Eintrag als Liste von Paaren gehalten.
|
||||
*/
|
||||
class QifParser
|
||||
{
|
||||
/** Feldbuchstabe → Wert(e). */
|
||||
public const TYP_KLASSE = 'Class';
|
||||
public const TYP_KATEGORIE = 'Cat';
|
||||
public const TYP_KONTO = 'Account';
|
||||
|
||||
/** Sektionen, die Buchungen enthalten. */
|
||||
public const BUCHUNGSTYPEN = ['Bank', 'CCard', 'Cash', 'Oth A', 'Oth L', 'Oth T', 'Oth P',
|
||||
'Oth E', 'Oth F', 'Invst', 'Port', 'Memorized'];
|
||||
|
||||
private string $datumsformat = 'MDY';
|
||||
|
||||
/** @var list<array{typ: string, konto: ?string, zeilen: list<array{0: string, 1: string}>}> */
|
||||
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<array{typ: string, konto: ?string, zeilen: list<array{0: string, 1: string}>}>
|
||||
*/
|
||||
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<array{0: string, 1: string}>} $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<array{0: string, 1: string}>} $eintrag
|
||||
* @return list<string>
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}
|
||||
229
src/Services/QifStammdatenImport.php
Normal file
229
src/Services/QifStammdatenImport.php
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Services;
|
||||
|
||||
use Finanzmanager\Database;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Klasse;
|
||||
use Finanzmanager\Models\Konto;
|
||||
|
||||
/**
|
||||
* Übernimmt Klassen, Kategorien und Konten aus einem QIF-Export.
|
||||
*
|
||||
* Buchungen bleiben Phase 2 vorbehalten; dieser Importeur ist bewusst
|
||||
* idempotent: was es schon gibt, wird gezählt und übersprungen, nie doppelt
|
||||
* angelegt. Ein zweiter Lauf derselben Datei ändert also nichts.
|
||||
*/
|
||||
class QifStammdatenImport
|
||||
{
|
||||
/** Beschreibungen der Form "Kontonr. 1234567" tragen die Kontonummer. */
|
||||
private const KONTONUMMER_MUSTER = '/^Kontonr\.?\s*(\S+)$/u';
|
||||
|
||||
private QifParser $parser;
|
||||
|
||||
/** @var list<array{typ: string, text: string}> */
|
||||
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<string,int>, kategorien: array<string,int>,
|
||||
* konten: array<string,int>, meldungen: list<array{typ: string, text: string}>,
|
||||
* 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<string,int> */
|
||||
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<string,int> */
|
||||
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<string,int> */
|
||||
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<array{0: string, 1: string}>} $eintrag
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
24
src/View.php
24
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) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array{gruppen: list<array<string,mixed>>, summe: float} $uebersicht */
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
|
|
@ -12,8 +14,8 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<div class="kachelraster">
|
||||
<section class="kachel">
|
||||
<header><h2>Vermögensentwicklung</h2></header>
|
||||
<p class="platzhalter">Zeigt Kontostände über die Zeit, sobald Konten und
|
||||
Buchungen angelegt sind (Phase 1 und 2).</p>
|
||||
<p class="platzhalter">Zeigt Kontostände über die Zeit, sobald Buchungen
|
||||
erfasst sind (Phase 2).</p>
|
||||
</section>
|
||||
|
||||
<section class="kachel">
|
||||
|
|
@ -23,14 +25,46 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
|
||||
<section class="kachel">
|
||||
<header><h2>Kontostände</h2></header>
|
||||
<p class="platzhalter">Übersicht aller Konten mit aktuellem und erwartetem Saldo (Phase 1).</p>
|
||||
<?php if ($uebersicht['gruppen'] === []): ?>
|
||||
<p class="platzhalter">
|
||||
Noch keine Konten angelegt – einzeln oder gemeinsam aus dem QIF-Export.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<a href="/konten/neu" class="knopf primaer">Konto anlegen</a>
|
||||
<a href="/verwaltung/import" class="knopf">QIF-Import</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<table class="liste">
|
||||
<tbody>
|
||||
<?php foreach ($uebersicht['gruppen'] as $gruppe): ?>
|
||||
<tr>
|
||||
<td><?= View::h($gruppe['name']) ?></td>
|
||||
<td class="rechts <?= View::betragKlasse($gruppe['summe']) ?>">
|
||||
<?= View::betrag($gruppe['summe']) ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="summenzeile">
|
||||
<td>Summe</td>
|
||||
<td class="rechts <?= View::betragKlasse($uebersicht['summe']) ?>">
|
||||
<?= View::betrag($uebersicht['summe']) ?>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<div class="knopfleiste">
|
||||
<a href="/konten" class="knopf">Zur Kontenübersicht</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="kachel">
|
||||
<header><h2>Aufbaustand</h2></header>
|
||||
<ul class="checkliste">
|
||||
<li class="erledigt">Phase 0 – Grundgerüst, Anmeldung, Einstellungen</li>
|
||||
<li>Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten</li>
|
||||
<li class="erledigt">Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten</li>
|
||||
<li>Phase 2 – Buchungen, Kontoblatt, Splitbuchungen</li>
|
||||
<li>Phase 3 – Budgetplaner und Monatsabschluss</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -52,12 +52,26 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<select id="erster_monat" name="erster_monat">
|
||||
<?php foreach (range(1, 12) as $m): ?>
|
||||
<option value="<?= $m ?>" <?= (int)$wert('erster_monat', '1') === $m ? 'selected' : '' ?>>
|
||||
<?= View::h(date('F', mktime(0, 0, 0, $m, 1))) ?>
|
||||
<?= View::h(View::monat($m)) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="feldhinweis">Bestimmt, wo Budgetplaner und Jahresauswertungen beginnen.</p>
|
||||
|
||||
<?php elseif ($bereich === 'import'): ?>
|
||||
<label for="import_qif_verzeichnis">Importverzeichnis</label>
|
||||
<input type="text" id="import_qif_verzeichnis" name="import_qif_verzeichnis"
|
||||
value="<?= View::h($wert('import_qif_verzeichnis', 'data/import')) ?>">
|
||||
<p class="feldhinweis">
|
||||
Relativ zum Projektverzeichnis. Hochgeladene QIF-Dateien landen hier und bleiben
|
||||
liegen, damit derselbe Export erst im Trockenlauf geprüft und dann übernommen
|
||||
werden kann.
|
||||
</p>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<a href="/verwaltung/import" class="knopf">QIF-Import öffnen</a>
|
||||
</div>
|
||||
|
||||
<?php elseif ($bereich === 'diagnose'): ?>
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="debug_log_aktiv" value="1"
|
||||
|
|
@ -96,14 +110,13 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
Dieser Bereich füllt sich in einer späteren Phase:
|
||||
<?= View::h(match ($bereich) {
|
||||
'banking' => 'Bankverbindungen und Abrufeinstellungen (Phase 5)',
|
||||
'import' => 'QIF-Import und CSV-Export (Phase 1 und 2)',
|
||||
'wertpapiere' => 'Kursabruf und Depoteinstellungen (Phase 6)',
|
||||
default => 'noch offen',
|
||||
}) ?>.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (in_array($bereich, ['allgemein', 'darstellung', 'buchhaltung', 'diagnose'], true)): ?>
|
||||
<?php if (in_array($bereich, ['allgemein', 'darstellung', 'buchhaltung', 'import', 'diagnose'], true)): ?>
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Speichern</button>
|
||||
</div>
|
||||
|
|
|
|||
107
src/Views/konten/formular.php
Normal file
107
src/Views/konten/formular.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,mixed>|null $konto */
|
||||
/** @var list<array<string,mixed>> $gruppen */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$neu = $konto === null;
|
||||
$ziel = $neu ? '/konten/neu' : '/konten/' . (int)$konto['id'] . '/bearbeiten';
|
||||
$wert = static fn(string $feld, string $standard = ''): string
|
||||
=> View::h((string)($konto[$feld] ?? $standard));
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/konten" class="knopf">Zurück zur Kontenübersicht</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
|
||||
<label for="name">Name des Kontos</label>
|
||||
<input type="text" id="name" name="name" value="<?= $wert('name') ?>" required autofocus>
|
||||
|
||||
<label for="kontoart">Kontoart</label>
|
||||
<select id="kontoart" name="kontoart">
|
||||
<?php foreach (Konto::ARTEN as $schluessel => $beschriftung): ?>
|
||||
<option value="<?= View::h($schluessel) ?>"
|
||||
<?= ($konto['kontoart'] ?? 'bank') === $schluessel ? 'selected' : '' ?>>
|
||||
<?= View::h($beschriftung) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<label for="kontengruppe_id">Kontengruppe</label>
|
||||
<select id="kontengruppe_id" name="kontengruppe_id">
|
||||
<option value="">Nicht gruppiert</option>
|
||||
<?php foreach ($gruppen as $gruppe): ?>
|
||||
<option value="<?= (int)$gruppe['id'] ?>"
|
||||
<?= (int)($konto['kontengruppe_id'] ?? 0) === (int)$gruppe['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($gruppe['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<label for="bankname">Bank <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="bankname" name="bankname" value="<?= $wert('bankname') ?>">
|
||||
|
||||
<label for="kontonummer">Kontonummer <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="kontonummer" name="kontonummer" value="<?= $wert('kontonummer') ?>">
|
||||
|
||||
<label for="iban">IBAN <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="iban" name="iban" value="<?= $wert('iban') ?>">
|
||||
|
||||
<label for="bic">BIC <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="bic" name="bic" value="<?= $wert('bic') ?>">
|
||||
|
||||
<label for="anfangssaldo">Anfangssaldo</label>
|
||||
<input type="text" id="anfangssaldo" name="anfangssaldo" inputmode="decimal"
|
||||
value="<?= View::h(View::betrag($konto['anfangssaldo'] ?? 0, false)) ?>">
|
||||
<p class="feldhinweis">
|
||||
Kontostand vor der ersten erfassten Buchung. Der QIF-Export enthält ihn nicht –
|
||||
nach einem Import wird er hier je Konto nachgetragen.
|
||||
</p>
|
||||
|
||||
<label for="kreditlimit">Kreditlimit <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="kreditlimit" name="kreditlimit" inputmode="decimal"
|
||||
value="<?= $konto === null || $konto['kreditlimit'] === null
|
||||
? '' : View::h(View::betrag($konto['kreditlimit'], false)) ?>">
|
||||
|
||||
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="beschreibung" name="beschreibung" value="<?= $wert('beschreibung') ?>">
|
||||
|
||||
<label for="sortierung">Sortierung</label>
|
||||
<input type="number" id="sortierung" name="sortierung" value="<?= $wert('sortierung', '0') ?>" step="1">
|
||||
<p class="feldhinweis">Kleinere Werte stehen in der Kontenübersicht weiter oben.</p>
|
||||
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="aktiv" value="1" <?= $neu || (int)$konto['aktiv'] === 1 ? 'checked' : '' ?>>
|
||||
Konto ist aktiv
|
||||
</label>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer"><?= $neu ? 'Konto anlegen' : 'Speichern' ?></button>
|
||||
<a href="/konten" class="knopf">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (!$neu): ?>
|
||||
<form method="post" action="/konten/<?= (int)$konto['id'] ?>/loeschen" class="formular breit"
|
||||
onsubmit="return confirm('Konto „<?= View::h($konto['name']) ?>“ wirklich löschen?');">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h3>Konto löschen</h3>
|
||||
<p class="feldhinweis">
|
||||
Ein Konto, das nur nicht mehr genutzt wird, sollte auf inaktiv gesetzt statt
|
||||
gelöscht werden – gelöschte Konten nehmen später ihre Buchungen mit.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf gefahr">Konto löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
97
src/Views/konten/uebersicht.php
Normal file
97
src/Views/konten/uebersicht.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array{gruppen: list<array<string,mixed>>, summe: float} $uebersicht */
|
||||
/** @var bool $mitInaktiven */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/konten/neu" class="knopf primaer">Neues Konto</a>
|
||||
<a href="/verwaltung/kontengruppen" class="knopf">Kontengruppen</a>
|
||||
<a href="/verwaltung/import" class="knopf">QIF-Import</a>
|
||||
<span class="fuellung"></span>
|
||||
<a href="/konten<?= $mitInaktiven ? '' : '?inaktive=1' ?>" class="knopf">
|
||||
<?= $mitInaktiven ? 'Nur aktive Konten' : 'Inaktive einblenden' ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php if ($uebersicht['gruppen'] === []): ?>
|
||||
<div class="kachel einzeln">
|
||||
<h2>Noch keine Konten</h2>
|
||||
<p class="platzhalter">
|
||||
Konten lassen sich einzeln anlegen oder gemeinsam mit Kategorien und Klassen
|
||||
aus einem QIF-Export des FinanzManagers übernehmen.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<a href="/konten/neu" class="knopf primaer">Konto anlegen</a>
|
||||
<a href="/verwaltung/import" class="knopf">QIF-Export einlesen</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<table class="liste konten">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Konto</th>
|
||||
<th>Kontoart</th>
|
||||
<th>Kontonummer</th>
|
||||
<th class="rechts">Online-Saldo</th>
|
||||
<th class="rechts">Aktueller Saldo</th>
|
||||
<th class="rechts">Erwarteter Saldo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<?php foreach ($uebersicht['gruppen'] as $gruppe): ?>
|
||||
<tbody>
|
||||
<tr class="gruppenzeile">
|
||||
<th colspan="6"><?= View::h($gruppe['name']) ?></th>
|
||||
</tr>
|
||||
<?php foreach ($gruppe['konten'] as $konto): ?>
|
||||
<tr<?= (int)$konto['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
|
||||
<td>
|
||||
<a href="/konten/<?= (int)$konto['id'] ?>/bearbeiten"><?= View::h($konto['name']) ?></a>
|
||||
<?php if ((int)$konto['aktiv'] !== 1): ?>
|
||||
<span class="marke">inaktiv</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($konto['beschreibung'] !== ''): ?>
|
||||
<span class="nebentext"><?= View::h($konto['beschreibung']) ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= View::h(Konto::bezeichnungDerArt((string)$konto['kontoart'])) ?></td>
|
||||
<td><?= View::h($konto['iban'] !== '' ? $konto['iban'] : $konto['kontonummer']) ?></td>
|
||||
<td class="rechts nebentext">
|
||||
<?= $konto['saldo_online'] === null ? '–' : View::betrag($konto['saldo_online']) ?>
|
||||
</td>
|
||||
<td class="rechts <?= View::betragKlasse($konto['saldo_aktuell']) ?>">
|
||||
<?= View::betrag($konto['saldo_aktuell']) ?>
|
||||
</td>
|
||||
<td class="rechts <?= View::betragKlasse($konto['saldo_erwartet']) ?>">
|
||||
<?= View::betrag($konto['saldo_erwartet']) ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<tr class="gruppensumme">
|
||||
<td colspan="5">Summe <?= View::h($gruppe['name']) ?></td>
|
||||
<td class="rechts <?= View::betragKlasse($gruppe['summe']) ?>"><?= View::betrag($gruppe['summe']) ?></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<?php endforeach; ?>
|
||||
<tfoot>
|
||||
<tr class="summenzeile">
|
||||
<td colspan="5">Summe Kontostände</td>
|
||||
<td class="rechts <?= View::betragKlasse($uebersicht['summe']) ?>"><?= View::betrag($uebersicht['summe']) ?></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<p class="hinweis">
|
||||
Online-Saldo und die Trennung zwischen aktuellem und erwartetem Saldo füllen sich,
|
||||
sobald Buchungen (Phase 2) und der Bankabruf (Phase 5) da sind. Bis dahin zeigen die
|
||||
Spalten den Anfangssaldo.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
170
src/Views/verwaltung/import.php
Normal file
170
src/Views/verwaltung/import.php
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var list<array{name: string, groesse: int, geaendert: int}> $dateien */
|
||||
/** @var string $verzeichnis */
|
||||
/** @var array<string,mixed>|null $ergebnis */
|
||||
/** @var string $datei */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$bereiche = [
|
||||
'klassen' => 'Klassen',
|
||||
'kategorien' => 'Kategorien',
|
||||
'konten' => 'Konten',
|
||||
];
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
|
||||
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
|
||||
<a href="/konten" class="knopf">Kontenübersicht</a>
|
||||
</div>
|
||||
|
||||
<?php if ($ergebnis !== null): ?>
|
||||
<div class="kachel">
|
||||
<header>
|
||||
<h2><?= $ergebnis['trockenlauf'] ? 'Trockenlauf' : 'Import abgeschlossen' ?></h2>
|
||||
<span class="phasenmarke"><?= View::h($datei) ?></span>
|
||||
</header>
|
||||
|
||||
<?php if ($ergebnis['trockenlauf']): ?>
|
||||
<p class="hinweis">
|
||||
Nichts wurde gespeichert. Die Zahlen zeigen, was ein Import anlegen würde.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bereich</th>
|
||||
<th class="rechts"><?= $ergebnis['trockenlauf'] ? 'Neu' : 'Angelegt' ?></th>
|
||||
<th class="rechts">Schon vorhanden</th>
|
||||
<th class="rechts">Fehler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($bereiche as $schluessel => $beschriftung): ?>
|
||||
<tr>
|
||||
<td><?= View::h($beschriftung) ?></td>
|
||||
<td class="rechts"><?= (int)$ergebnis[$schluessel]['neu'] ?></td>
|
||||
<td class="rechts nebentext"><?= (int)$ergebnis[$schluessel]['vorhanden'] ?></td>
|
||||
<td class="rechts <?= (int)$ergebnis[$schluessel]['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
|
||||
<?= (int)$ergebnis[$schluessel]['fehler'] ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if (!empty($ergebnis['abschnitte'])): ?>
|
||||
<h3>Inhalt der Datei</h3>
|
||||
<p class="hinweis">
|
||||
<?php $teile = [];
|
||||
foreach ($ergebnis['abschnitte'] as $typ => $anzahl) {
|
||||
$teile[] = View::h((string)$typ) . ': ' . (int)$anzahl;
|
||||
}
|
||||
echo implode(' · ', $teile); ?>
|
||||
</p>
|
||||
<p class="hinweis">
|
||||
Buchungen und Buchungsvorlagen werden in Phase 2 übernommen – dieser Lauf
|
||||
betrifft ausschließlich Klassen, Kategorien und Konten.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($ergebnis['meldungen'])): ?>
|
||||
<h3>Protokoll</h3>
|
||||
<ul class="protokoll">
|
||||
<?php foreach ($ergebnis['meldungen'] as $meldung): ?>
|
||||
<li class="<?= View::h($meldung['typ']) ?>"><?= View::h($meldung['text']) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($ergebnis['trockenlauf']): ?>
|
||||
<form method="post" action="/verwaltung/import/ausfuehren" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="hidden" name="datei" value="<?= View::h($datei) ?>">
|
||||
<input type="hidden" name="modus" value="import">
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Jetzt übernehmen</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<div class="knopfleiste">
|
||||
<a href="/verwaltung/kategorien" class="knopf primaer">Kategorien ansehen</a>
|
||||
<a href="/konten" class="knopf">Konten ansehen</a>
|
||||
</div>
|
||||
<p class="hinweis">
|
||||
Die Anfangssalden stehen nicht im Export. Sie werden je Konto in der
|
||||
Kontenübersicht nachgetragen.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>Exportdatei hochladen</h2></header>
|
||||
<form method="post" action="/verwaltung/import/hochladen" enctype="multipart/form-data" class="formular">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<label for="qif">QIF-Datei</label>
|
||||
<input type="file" id="qif" name="qif" accept=".qif,.QIF" required>
|
||||
<p class="feldhinweis">
|
||||
Die Datei wird unter <code><?= View::h($verzeichnis) ?></code> abgelegt und bleibt
|
||||
dort – so lässt sich derselbe Export erst prüfen und dann übernehmen.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Hochladen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>Vorhandene Dateien</h2></header>
|
||||
|
||||
<?php if ($dateien === []): ?>
|
||||
<p class="platzhalter">
|
||||
Noch keine Datei in <code><?= View::h($verzeichnis) ?></code>.
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr><th>Datei</th><th class="rechts">Größe</th><th>Abgelegt</th><th class="rechts">Aktion</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($dateien as $eintrag): ?>
|
||||
<tr>
|
||||
<td><code><?= View::h($eintrag['name']) ?></code></td>
|
||||
<td class="rechts"><?= View::h(number_format($eintrag['groesse'] / 1024, 1, ',', '.')) ?> kB</td>
|
||||
<td><?= View::h(date('d.m.Y H:i', $eintrag['geaendert'])) ?></td>
|
||||
<td class="rechts nowrap">
|
||||
<form method="post" action="/verwaltung/import/ausfuehren" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>">
|
||||
<input type="hidden" name="modus" value="vorschau">
|
||||
<button type="submit" class="knopf klein">Trockenlauf</button>
|
||||
</form>
|
||||
<form method="post" action="/verwaltung/import/ausfuehren" class="inline"
|
||||
onsubmit="return confirm('„<?= View::h($eintrag['name']) ?>“ jetzt importieren?');">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>">
|
||||
<input type="hidden" name="modus" value="import">
|
||||
<button type="submit" class="knopf klein primaer">Importieren</button>
|
||||
</form>
|
||||
<form method="post" action="/verwaltung/import/<?= View::h(rawurlencode($eintrag['name'])) ?>/loeschen"
|
||||
class="inline"
|
||||
onsubmit="return confirm('Datei „<?= View::h($eintrag['name']) ?>“ löschen?');">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<button type="submit" class="knopf klein gefahr">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
70
src/Views/verwaltung/index.php
Normal file
70
src/Views/verwaltung/index.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,int> $zahlen */
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="kachelraster">
|
||||
<div class="kachel">
|
||||
<header><h2>Kategorien</h2></header>
|
||||
<p class="kennzahl"><?= (int)$zahlen['kategorien'] ?></p>
|
||||
<p class="platzhalter">
|
||||
<?= (int)$zahlen['einnahmen'] ?> Einnahmen, <?= (int)$zahlen['ausgaben'] ?> Ausgaben.
|
||||
Hierarchisch, mit Klassen als zweiter Auswertungsdimension.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<a href="/verwaltung/kategorien" class="knopf primaer">Kategorien verwalten</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>Klassen</h2></header>
|
||||
<p class="kennzahl"><?= (int)$zahlen['klassen'] ?></p>
|
||||
<p class="platzhalter">
|
||||
Zweite Dimension neben der Kategorie – etwa Ehemann, Ehefrau, Haus 1.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<a href="/verwaltung/klassen" class="knopf primaer">Klassen verwalten</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>Konten</h2></header>
|
||||
<p class="kennzahl"><?= (int)$zahlen['konten'] ?></p>
|
||||
<p class="platzhalter">
|
||||
In <?= (int)$zahlen['kontengruppen'] ?> Kontengruppen gegliedert.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<a href="/konten" class="knopf primaer">Kontenübersicht</a>
|
||||
<a href="/verwaltung/kontengruppen" class="knopf">Kontengruppen</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>QIF-Import</h2></header>
|
||||
<p class="platzhalter">
|
||||
Klassen, Kategorien und Konten aus einem Export des Lexware FinanzManagers
|
||||
übernehmen – erst im Trockenlauf prüfen, dann übernehmen.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<a href="/verwaltung/import" class="knopf primaer">Import öffnen</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header>
|
||||
<h2>Weitere Bereiche</h2>
|
||||
<span class="phasenmarke">später</span>
|
||||
</header>
|
||||
<ul class="checkliste">
|
||||
<li>Bankverbindungen und Regelverwaltung – Phase 5</li>
|
||||
<li>Buchungsvorlagen und regelmäßige Buchungen – Phase 7</li>
|
||||
<li>Adressen, Verträge, Sparziele – Phase 7</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
164
src/Views/verwaltung/kategorie-formular.php
Normal file
164
src/Views/verwaltung/kategorie-formular.php
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,mixed>|null $kategorie */
|
||||
/** @var list<array<string,mixed>> $auswahl */
|
||||
/** @var int|null $elternId */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$neu = $kategorie === null;
|
||||
$id = $neu ? 0 : (int)$kategorie['id'];
|
||||
$ziel = $neu ? '/verwaltung/kategorien/neu' : '/verwaltung/kategorien/' . $id;
|
||||
|
||||
// Beim Verschieben und Zusammenführen darf der eigene Teilbaum nicht als Ziel
|
||||
// erscheinen – sonst hinge der Ast in sich selbst.
|
||||
$imEigenenAst = static function (array $eintrag) use ($neu, $id, $kategorie): bool {
|
||||
if ($neu) {
|
||||
return false;
|
||||
}
|
||||
$pfad = (string)$kategorie['pfad'];
|
||||
return (int)$eintrag['id'] === $id
|
||||
|| str_starts_with((string)$eintrag['pfad'], $pfad . Kategorie::TRENNER);
|
||||
};
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/verwaltung/kategorien" class="knopf">Zurück zur Kategorieliste</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required autofocus
|
||||
value="<?= View::h((string)($kategorie['name'] ?? '')) ?>">
|
||||
<p class="feldhinweis">Ohne Doppelpunkt – der trennt die Ebenen im vollständigen Namen.</p>
|
||||
|
||||
<?php if ($neu): ?>
|
||||
<label for="eltern_id">Übergeordnete Kategorie</label>
|
||||
<select id="eltern_id" name="eltern_id">
|
||||
<option value="">— oberste Ebene —</option>
|
||||
<?php foreach ($auswahl as $eintrag): ?>
|
||||
<option value="<?= (int)$eintrag['id'] ?>"
|
||||
<?= $elternId === (int)$eintrag['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($eintrag['pfad']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="feldhinweis">
|
||||
Unterkategorien übernehmen die Art der übergeordneten Kategorie.
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<label>Vollständiger Name</label>
|
||||
<p class="feldwert"><code><?= View::h($kategorie['pfad']) ?></code></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<label for="art">Art</label>
|
||||
<select id="art" name="art" <?= !$neu && $kategorie['eltern_id'] !== null ? 'disabled' : '' ?>>
|
||||
<?php foreach (Kategorie::ARTEN as $schluessel => $beschriftung): ?>
|
||||
<option value="<?= View::h($schluessel) ?>"
|
||||
<?= ($kategorie['art'] ?? 'ausgabe') === $schluessel ? 'selected' : '' ?>>
|
||||
<?= View::h($beschriftung) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if (!$neu && $kategorie['eltern_id'] !== null): ?>
|
||||
<p class="feldhinweis">
|
||||
Die Art richtet sich nach der übergeordneten Kategorie und lässt sich nur dort ändern.
|
||||
</p>
|
||||
<input type="hidden" name="art" value="<?= View::h((string)$kategorie['art']) ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="beschreibung" name="beschreibung"
|
||||
value="<?= View::h((string)($kategorie['beschreibung'] ?? '')) ?>">
|
||||
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="steuerrelevant" value="1"
|
||||
<?= !empty($kategorie['steuerrelevant']) ? 'checked' : '' ?>>
|
||||
Steuerrelevant
|
||||
</label>
|
||||
|
||||
<label for="steuerformular">Steuerformular <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="steuerformular" name="steuerformular"
|
||||
value="<?= View::h((string)($kategorie['steuerformular'] ?? '')) ?>">
|
||||
<p class="feldhinweis">Kennziffer aus dem FinanzManager, etwa <code>16304</code>.</p>
|
||||
|
||||
<?php if (!$neu): ?>
|
||||
<label for="sortierung">Sortierung</label>
|
||||
<input type="number" id="sortierung" name="sortierung" step="1"
|
||||
value="<?= (int)$kategorie['sortierung'] ?>">
|
||||
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="aktiv" value="1" <?= (int)$kategorie['aktiv'] === 1 ? 'checked' : '' ?>>
|
||||
Kategorie ist aktiv
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer"><?= $neu ? 'Kategorie anlegen' : 'Speichern' ?></button>
|
||||
<a href="/verwaltung/kategorien" class="knopf">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (!$neu): ?>
|
||||
<form method="post" action="/verwaltung/kategorien/<?= $id ?>/verschieben" class="formular breit">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h3>Verschieben</h3>
|
||||
<label for="ziel_eltern">Neue übergeordnete Kategorie</label>
|
||||
<select id="ziel_eltern" name="eltern_id">
|
||||
<option value="">— oberste Ebene —</option>
|
||||
<?php foreach ($auswahl as $eintrag): ?>
|
||||
<?php if ($imEigenenAst($eintrag)) { continue; } ?>
|
||||
<option value="<?= (int)$eintrag['id'] ?>"
|
||||
<?= (int)($kategorie['eltern_id'] ?? 0) === (int)$eintrag['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($eintrag['pfad']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="feldhinweis">Unterkategorien wandern mit und behalten ihre Reihenfolge.</p>
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf">Verschieben</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/verwaltung/kategorien/<?= $id ?>/zusammenfuehren" class="formular breit"
|
||||
onsubmit="return confirm('„<?= View::h($kategorie['pfad']) ?>“ in die gewählte Kategorie überführen?');">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h3>Zusammenführen</h3>
|
||||
<label for="ziel_id">Zielkategorie</label>
|
||||
<select id="ziel_id" name="ziel_id" required>
|
||||
<option value="">— bitte wählen —</option>
|
||||
<?php foreach ($auswahl as $eintrag): ?>
|
||||
<?php if ($imEigenenAst($eintrag)) { continue; } ?>
|
||||
<option value="<?= (int)$eintrag['id'] ?>"><?= View::h($eintrag['pfad']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="feldhinweis">
|
||||
Die Unterkategorien wechseln zur Zielkategorie, danach verschwindet diese Kategorie.
|
||||
Ab Phase 2 wandern auch die Buchungen mit.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf">Zusammenführen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/verwaltung/kategorien/<?= $id ?>/loeschen" class="formular breit"
|
||||
onsubmit="return confirm('„<?= View::h($kategorie['pfad']) ?>“ mit allen Unterkategorien löschen?');">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h3>Löschen</h3>
|
||||
<p class="feldhinweis">
|
||||
Löscht die Kategorie samt allen Unterkategorien. Wer sie nur aus den Auswahllisten
|
||||
nehmen will, setzt sie stattdessen auf inaktiv.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf gefahr">Kategorie löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
112
src/Views/verwaltung/kategorien.php
Normal file
112
src/Views/verwaltung/kategorien.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var string $register */
|
||||
/** @var array<string,string> $registerListe */
|
||||
/** @var list<array<string,mixed>> $kategorien */
|
||||
/** @var list<array<string,mixed>> $konten */
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="tableiste">
|
||||
<?php foreach ($registerListe as $schluessel => $beschriftung): ?>
|
||||
<a href="/verwaltung/kategorien?register=<?= View::h($schluessel) ?>"
|
||||
class="tab <?= $register === $schluessel ? 'aktiv' : '' ?>"><?= View::h($beschriftung) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<?php if ($register === 'umbuchungskonten'): ?>
|
||||
<a href="/konten/neu" class="knopf primaer">Neues Konto</a>
|
||||
<?php else: ?>
|
||||
<a href="/verwaltung/kategorien/neu" class="knopf primaer">Neue Kategorie</a>
|
||||
<?php endif; ?>
|
||||
<a href="/verwaltung/import" class="knopf">QIF-Import</a>
|
||||
</div>
|
||||
|
||||
<?php if ($register === 'umbuchungskonten'): ?>
|
||||
<p class="hinweis">
|
||||
Umbuchungskonten sind keine eigenen Kategorien: Bei einer Umbuchung wird statt einer
|
||||
Kategorie das Gegenkonto gewählt. Deshalb stehen hier die Konten, so wie sie später in
|
||||
der Kategorieauswahl einer Buchung erscheinen – in eckigen Klammern.
|
||||
</p>
|
||||
|
||||
<?php if ($konten === []): ?>
|
||||
<p class="platzhalter">Noch keine Konten angelegt.</p>
|
||||
<?php else: ?>
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr><th>Umbuchungskonto</th><th>Kontoart</th><th>Kontengruppe</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($konten as $konto): ?>
|
||||
<tr<?= (int)$konto['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
|
||||
<td>
|
||||
<a href="/konten/<?= (int)$konto['id'] ?>/bearbeiten">[<?= View::h($konto['name']) ?>]</a>
|
||||
</td>
|
||||
<td><?= View::h(Konto::bezeichnungDerArt((string)$konto['kontoart'])) ?></td>
|
||||
<td><?= View::h($konto['gruppenname'] ?? 'Nicht gruppiert') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php elseif ($kategorien === []): ?>
|
||||
<div class="kachel einzeln">
|
||||
<h2>Noch keine Kategorien</h2>
|
||||
<p class="platzhalter">
|
||||
Kategorien lassen sich einzeln anlegen oder komplett aus einem QIF-Export
|
||||
des FinanzManagers übernehmen.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<a href="/verwaltung/kategorien/neu" class="knopf primaer">Kategorie anlegen</a>
|
||||
<a href="/verwaltung/import" class="knopf">QIF-Export einlesen</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
<table class="liste kategorien">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kategorie</th>
|
||||
<th>Art</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Steuer</th>
|
||||
<th class="rechts">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($kategorien as $kategorie): ?>
|
||||
<tr<?= (int)$kategorie['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
|
||||
<td>
|
||||
<span class="baumname" style="--ebene: <?= (int)$kategorie['ebene'] ?>">
|
||||
<a href="/verwaltung/kategorien/<?= (int)$kategorie['id'] ?>">
|
||||
<?= View::h($kategorie['name']) ?>
|
||||
</a>
|
||||
<?php if (!empty($kategorie['hat_kinder'])): ?>
|
||||
<span class="nebentext">(Oberkategorie)</span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?= $kategorie['art'] === 'einnahme' ? 'Einnahme' : 'Ausgabe' ?></td>
|
||||
<td class="nebentext"><?= View::h($kategorie['beschreibung']) ?></td>
|
||||
<td>
|
||||
<?php if ((int)$kategorie['steuerrelevant'] === 1): ?>
|
||||
<span class="marke">steuerrelevant<?= $kategorie['steuerformular'] !== ''
|
||||
? ' · ' . View::h($kategorie['steuerformular']) : '' ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="rechts">
|
||||
<a href="/verwaltung/kategorien/neu?eltern=<?= (int)$kategorie['id'] ?>">Unterkategorie</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
87
src/Views/verwaltung/klassen.php
Normal file
87
src/Views/verwaltung/klassen.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var list<array<string,mixed>> $klassen */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
|
||||
<a href="/verwaltung/import" class="knopf">QIF-Import</a>
|
||||
</div>
|
||||
|
||||
<p class="hinweis">
|
||||
Klassen sind die zweite Auswertungsdimension neben der Kategorie: dieselbe Ausgabe lässt
|
||||
sich damit zusätzlich einer Person, einem Haus oder einem Projekt zuordnen.
|
||||
</p>
|
||||
|
||||
<?php if ($klassen === []): ?>
|
||||
<p class="platzhalter">Noch keine Klassen angelegt.</p>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
// Die Formulare stehen außerhalb der Tabelle und werden über das
|
||||
// form-Attribut angesprochen – ein <form> zwischen <tr> und <td> wäre
|
||||
// ungültiges HTML und würde vom Browser aus der Tabelle geschoben.
|
||||
foreach ($klassen as $klasse): ?>
|
||||
<form id="klasse-<?= (int)$klasse['id'] ?>" method="post"
|
||||
action="/verwaltung/klassen/<?= (int)$klasse['id'] ?>"></form>
|
||||
<form id="klasse-loeschen-<?= (int)$klasse['id'] ?>" method="post"
|
||||
action="/verwaltung/klassen/<?= (int)$klasse['id'] ?>/loeschen"
|
||||
onsubmit="return confirm('Klasse „<?= View::h($klasse['name']) ?>“ löschen?');"></form>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Beschreibung</th><th>Status</th><th class="rechts">Aktion</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($klassen as $klasse): ?>
|
||||
<?php $f = 'klasse-' . (int)$klasse['id']; ?>
|
||||
<tr<?= (int)$klasse['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
|
||||
<td>
|
||||
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="text" form="<?= $f ?>" name="name"
|
||||
value="<?= View::h($klasse['name']) ?>" required>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" form="<?= $f ?>" name="beschreibung"
|
||||
value="<?= View::h($klasse['beschreibung']) ?>">
|
||||
</td>
|
||||
<td>
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" form="<?= $f ?>" name="aktiv" value="1"
|
||||
<?= (int)$klasse['aktiv'] === 1 ? 'checked' : '' ?>> aktiv
|
||||
</label>
|
||||
</td>
|
||||
<td class="rechts nowrap">
|
||||
<input type="hidden" form="klasse-loeschen-<?= (int)$klasse['id'] ?>"
|
||||
name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button>
|
||||
<button type="submit" form="klasse-loeschen-<?= (int)$klasse['id'] ?>"
|
||||
class="knopf klein gefahr">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/verwaltung/klassen/neu" class="formular breit">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h3>Neue Klasse</h3>
|
||||
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
|
||||
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="beschreibung" name="beschreibung">
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Klasse anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
101
src/Views/verwaltung/kontengruppen.php
Normal file
101
src/Views/verwaltung/kontengruppen.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Models\Kontengruppe;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var list<array<string,mixed>> $gruppen */
|
||||
/** @var list<array<string,mixed>> $konten */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$anzahlJeGruppe = [];
|
||||
$ohneGruppe = 0;
|
||||
foreach ($konten as $konto) {
|
||||
if ($konto['kontengruppe_id'] === null) {
|
||||
$ohneGruppe++;
|
||||
continue;
|
||||
}
|
||||
$id = (int)$konto['kontengruppe_id'];
|
||||
$anzahlJeGruppe[$id] = ($anzahlJeGruppe[$id] ?? 0) + 1;
|
||||
}
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/konten" class="knopf">Zur Kontenübersicht</a>
|
||||
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
|
||||
</div>
|
||||
|
||||
<p class="hinweis">
|
||||
Kontengruppen gliedern die Kontenübersicht. Konten ohne Gruppe erscheinen dort unter
|
||||
„<?= View::h(Kontengruppe::OHNE_GRUPPE) ?>“ – derzeit <?= (int)$ohneGruppe ?>.
|
||||
</p>
|
||||
|
||||
<?php if ($gruppen === []): ?>
|
||||
<p class="platzhalter">Noch keine Kontengruppen angelegt.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($gruppen as $gruppe): ?>
|
||||
<form id="gruppe-<?= (int)$gruppe['id'] ?>" method="post"
|
||||
action="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>"></form>
|
||||
<form id="gruppe-loeschen-<?= (int)$gruppe['id'] ?>" method="post"
|
||||
action="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>/loeschen"
|
||||
onsubmit="return confirm('Kontengruppe „<?= View::h($gruppe['name']) ?>“ löschen? Die Konten bleiben erhalten.');"></form>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Beschreibung</th><th class="rechts">Sortierung</th>
|
||||
<th class="rechts">Konten</th><th class="rechts">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($gruppen as $gruppe): ?>
|
||||
<?php $f = 'gruppe-' . (int)$gruppe['id']; ?>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="hidden" form="gruppe-loeschen-<?= (int)$gruppe['id'] ?>"
|
||||
name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="text" form="<?= $f ?>" name="name"
|
||||
value="<?= View::h($gruppe['name']) ?>" required>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" form="<?= $f ?>" name="beschreibung"
|
||||
value="<?= View::h($gruppe['beschreibung']) ?>">
|
||||
</td>
|
||||
<td class="rechts">
|
||||
<input type="number" form="<?= $f ?>" name="sortierung" step="1" class="schmal"
|
||||
value="<?= (int)$gruppe['sortierung'] ?>">
|
||||
</td>
|
||||
<td class="rechts"><?= (int)($anzahlJeGruppe[(int)$gruppe['id']] ?? 0) ?></td>
|
||||
<td class="rechts nowrap">
|
||||
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button>
|
||||
<button type="submit" form="gruppe-loeschen-<?= (int)$gruppe['id'] ?>"
|
||||
class="knopf klein gefahr">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/verwaltung/kontengruppen/neu" class="formular breit">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h3>Neue Kontengruppe</h3>
|
||||
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
|
||||
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="beschreibung" name="beschreibung">
|
||||
|
||||
<label for="sortierung">Sortierung</label>
|
||||
<input type="number" id="sortierung" name="sortierung" value="0" step="1">
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Kontengruppe anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
59
tests/05_DarstellungTest.php
Normal file
59
tests/05_DarstellungTest.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
class DarstellungTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 05: Darstellung (Formate auf Deutsch)';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->monatsnamen();
|
||||
$this->betraege();
|
||||
$this->datumsformat();
|
||||
}
|
||||
|
||||
private function monatsnamen(): void
|
||||
{
|
||||
$this->assertEquals('Januar', View::monat(1), 'Erster Monat heißt Januar');
|
||||
$this->assertEquals('März', View::monat(3), 'Umlaut im Monatsnamen bleibt erhalten');
|
||||
$this->assertEquals('Dezember', View::monat(12), 'Letzter Monat heißt Dezember');
|
||||
$this->assertEquals('Mär', View::monat(3, true), 'Kurzform mit Umlaut');
|
||||
$this->assertEquals('Dez', View::monat(12, true), 'Kurzform des letzten Monats');
|
||||
|
||||
$this->assertEquals(12, count(View::MONATE), 'Zwölf Monatsnamen');
|
||||
$this->assertEquals(12, count(View::MONATE_KURZ), 'Zwölf Kurzformen');
|
||||
|
||||
// Vollständige Sollliste: date('F') liefert englische Namen, und für
|
||||
// April, August, September und November fällt das nicht auf, weil sie
|
||||
// in beiden Sprachen gleich heißen. Deshalb Wert für Wert vergleichen.
|
||||
$soll = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||
foreach ($soll as $i => $name) {
|
||||
$this->assertEquals($name, View::monat($i + 1), 'Monat ' . ($i + 1) . ' heißt ' . $name);
|
||||
$this->assertNotEmpty(View::monat($i + 1, true), 'Kurzform für Monat ' . ($i + 1) . ' vorhanden');
|
||||
}
|
||||
|
||||
// Ungültige Werte dürfen die Oberfläche nicht zerlegen.
|
||||
$this->assertEquals('0', View::monat(0), 'Unbekannter Monat gibt die Zahl zurück');
|
||||
$this->assertEquals('13', View::monat(13), 'Monat außerhalb des Bereichs gibt die Zahl zurück');
|
||||
}
|
||||
|
||||
private function betraege(): void
|
||||
{
|
||||
$this->assertEquals('1.234,50', View::betrag(1234.5, false), 'Deutsches Zahlenformat');
|
||||
$this->assertEquals("1.234,50\u{00A0}€", View::betrag(1234.5), 'Währung mit geschütztem Leerzeichen');
|
||||
$this->assertEquals('-58.457,07', View::betrag(-58457.07, false), 'Negativer Betrag');
|
||||
$this->assertEquals('betrag negativ', View::betragKlasse(-1), 'Negative Beträge werden markiert');
|
||||
$this->assertEquals('betrag', View::betragKlasse(0), 'Null gilt nicht als negativ');
|
||||
}
|
||||
|
||||
private function datumsformat(): void
|
||||
{
|
||||
$this->assertEquals('18.03.2026', View::datum('2026-03-18'), 'Deutsches Datumsformat');
|
||||
$this->assertEquals('18.03.2026', View::datum('2026-03-18 14:22:01'), 'Zeitanteil wird abgeschnitten');
|
||||
$this->assertEquals('–', View::datum(null), 'Leeres Datum als Gedankenstrich');
|
||||
}
|
||||
}
|
||||
241
tests/06_KategorienTest.php
Normal file
241
tests/06_KategorienTest.php
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Klasse;
|
||||
|
||||
class KategorienTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 06: Kategorien und Klassen';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
|
||||
$this->hierarchieUndPfade();
|
||||
$this->artVererbtSich();
|
||||
$this->umbenennenZiehtPfadeMit();
|
||||
$this->verschieben();
|
||||
$this->verschiebenInDenEigenenAst();
|
||||
$this->zusammenfuehren();
|
||||
$this->baumdarstellung();
|
||||
$this->pfadImportAnlegen();
|
||||
$this->qifAngabeZerlegen();
|
||||
$this->klassen();
|
||||
}
|
||||
|
||||
private function hierarchieUndPfade(): void
|
||||
{
|
||||
$obenId = Kategorie::anlegen(['name' => '400 - Leben', 'art' => 'ausgabe']);
|
||||
$unten = Kategorie::anlegen(['name' => 'B-Essen alleine', 'eltern_id' => $obenId]);
|
||||
|
||||
$kategorie = Kategorie::find($unten);
|
||||
$this->assertEquals('400 - Leben:B-Essen alleine', $kategorie['pfad'], 'Pfad wird zusammengesetzt');
|
||||
$this->assertEquals('B-Essen alleine', $kategorie['name'], 'Der Name bleibt der Blattname');
|
||||
|
||||
$this->assertNotEmpty(
|
||||
Kategorie::findeNachPfad('400 - Leben:B-Essen alleine'),
|
||||
'Kategorie ist über den vollständigen Pfad auffindbar'
|
||||
);
|
||||
|
||||
$doppelt = false;
|
||||
try {
|
||||
Kategorie::anlegen(['name' => 'B-Essen alleine', 'eltern_id' => $obenId]);
|
||||
} catch (\Throwable) {
|
||||
$doppelt = true;
|
||||
}
|
||||
$this->assert($doppelt, 'Derselbe Pfad lässt sich nicht zweimal anlegen');
|
||||
|
||||
$mitDoppelpunkt = false;
|
||||
try {
|
||||
Kategorie::anlegen(['name' => 'Auto:Benzin']);
|
||||
} catch (\Throwable) {
|
||||
$mitDoppelpunkt = true;
|
||||
}
|
||||
$this->assert($mitDoppelpunkt, 'Ein Doppelpunkt im Namen wird abgewiesen');
|
||||
}
|
||||
|
||||
private function artVererbtSich(): void
|
||||
{
|
||||
$einnahmeId = Kategorie::anlegen(['name' => '100 - Gehalt', 'art' => 'einnahme']);
|
||||
$kindId = Kategorie::anlegen([
|
||||
'name' => 'Sonderzahlung',
|
||||
'eltern_id' => $einnahmeId,
|
||||
'art' => 'ausgabe', // wird ignoriert
|
||||
]);
|
||||
|
||||
$this->assertEquals('einnahme', Kategorie::find($kindId)['art'], 'Unterkategorie erbt die Art');
|
||||
|
||||
// Ändert sich die Art oben, zieht der ganze Ast mit.
|
||||
Kategorie::speichern($einnahmeId, [
|
||||
'name' => '100 - Gehalt', 'art' => 'ausgabe', 'aktiv' => true,
|
||||
]);
|
||||
$this->assertEquals('ausgabe', Kategorie::find($kindId)['art'], 'Artwechsel wirkt auf den ganzen Ast');
|
||||
}
|
||||
|
||||
private function umbenennenZiehtPfadeMit(): void
|
||||
{
|
||||
$obenId = Kategorie::anlegen(['name' => 'Wohnen']);
|
||||
$kindId = Kategorie::anlegen(['name' => 'Strom', 'eltern_id' => $obenId]);
|
||||
$enkeId = Kategorie::anlegen(['name' => 'Abschlag', 'eltern_id' => $kindId]);
|
||||
|
||||
Kategorie::speichern($obenId, ['name' => 'Haus', 'art' => 'ausgabe', 'aktiv' => true]);
|
||||
|
||||
$this->assertEquals('Haus', Kategorie::find($obenId)['pfad'], 'Pfad der umbenannten Kategorie');
|
||||
$this->assertEquals('Haus:Strom', Kategorie::find($kindId)['pfad'], 'Pfad des Kindes wandert mit');
|
||||
$this->assertEquals('Haus:Strom:Abschlag', Kategorie::find($enkeId)['pfad'], 'Pfad des Enkels wandert mit');
|
||||
}
|
||||
|
||||
private function verschieben(): void
|
||||
{
|
||||
$aId = Kategorie::anlegen(['name' => 'Freizeit']);
|
||||
$bId = Kategorie::anlegen(['name' => 'Urlaub']);
|
||||
$kindId = Kategorie::anlegen(['name' => 'Flug', 'eltern_id' => $bId]);
|
||||
|
||||
Kategorie::verschieben($bId, $aId);
|
||||
|
||||
$this->assertEquals('Freizeit:Urlaub', Kategorie::find($bId)['pfad'], 'Verschobene Kategorie hat neuen Pfad');
|
||||
$this->assertEquals('Freizeit:Urlaub:Flug', Kategorie::find($kindId)['pfad'], 'Unterkategorie folgt');
|
||||
|
||||
Kategorie::verschieben($bId, null);
|
||||
$this->assertEquals('Urlaub', Kategorie::find($bId)['pfad'], 'Verschieben auf die oberste Ebene');
|
||||
$this->assertEquals('Urlaub:Flug', Kategorie::find($kindId)['pfad'], 'Unterkategorie folgt auch dorthin');
|
||||
}
|
||||
|
||||
private function verschiebenInDenEigenenAst(): void
|
||||
{
|
||||
$obenId = Kategorie::anlegen(['name' => 'Versicherungen']);
|
||||
$kindId = Kategorie::anlegen(['name' => 'Haftpflicht', 'eltern_id' => $obenId]);
|
||||
|
||||
$abgewiesen = false;
|
||||
try {
|
||||
Kategorie::verschieben($obenId, $kindId);
|
||||
} catch (\Throwable) {
|
||||
$abgewiesen = true;
|
||||
}
|
||||
$this->assert($abgewiesen, 'Verschieben unter die eigene Unterkategorie wird abgewiesen');
|
||||
|
||||
$selbst = false;
|
||||
try {
|
||||
Kategorie::verschieben($obenId, $obenId);
|
||||
} catch (\Throwable) {
|
||||
$selbst = true;
|
||||
}
|
||||
$this->assert($selbst, 'Eine Kategorie kann sich nicht selbst untergeordnet werden');
|
||||
|
||||
$this->assertEquals('Versicherungen', Kategorie::find($obenId)['pfad'], 'Der Pfad blieb unverändert');
|
||||
}
|
||||
|
||||
private function zusammenfuehren(): void
|
||||
{
|
||||
$quelleId = Kategorie::anlegen(['name' => 'Telefon']);
|
||||
$zielId = Kategorie::anlegen(['name' => 'Kommunikation']);
|
||||
$kindId = Kategorie::anlegen(['name' => 'Mobilfunk', 'eltern_id' => $quelleId]);
|
||||
|
||||
Kategorie::zusammenfuehren($quelleId, $zielId);
|
||||
|
||||
$this->assertNull(Kategorie::find($quelleId), 'Die Quellkategorie ist verschwunden');
|
||||
$this->assertEquals(
|
||||
'Kommunikation:Mobilfunk',
|
||||
Kategorie::find($kindId)['pfad'],
|
||||
'Die Unterkategorie hängt jetzt am Ziel'
|
||||
);
|
||||
}
|
||||
|
||||
private function baumdarstellung(): void
|
||||
{
|
||||
$obenId = Kategorie::anlegen(['name' => 'Auto']);
|
||||
Kategorie::anlegen(['name' => 'Benzin', 'eltern_id' => $obenId]);
|
||||
Kategorie::anlegen(['name' => 'Werkstatt', 'eltern_id' => $obenId]);
|
||||
|
||||
$flach = Kategorie::baumFlach();
|
||||
$nach = [];
|
||||
foreach ($flach as $eintrag) {
|
||||
$nach[$eintrag['pfad']] = $eintrag['ebene'];
|
||||
}
|
||||
|
||||
$this->assertEquals(0, $nach['Auto'] ?? -1, 'Oberkategorie liegt auf Ebene 0');
|
||||
$this->assertEquals(1, $nach['Auto:Benzin'] ?? -1, 'Unterkategorie liegt auf Ebene 1');
|
||||
|
||||
$baum = Kategorie::baum();
|
||||
$auto = null;
|
||||
foreach ($baum as $knoten) {
|
||||
if ($knoten['pfad'] === 'Auto') {
|
||||
$auto = $knoten;
|
||||
}
|
||||
}
|
||||
$this->assertNotEmpty($auto, 'Oberkategorie steht als Wurzel im Baum');
|
||||
$this->assertEquals(2, count($auto['kinder']), 'Beide Unterkategorien hängen darunter');
|
||||
}
|
||||
|
||||
private function pfadImportAnlegen(): void
|
||||
{
|
||||
// Der QIF-Import trifft Unterkategorien auch ohne vorher genannte
|
||||
// Elternkategorie – die Zwischenstufen müssen entstehen.
|
||||
$id = Kategorie::findenOderAnlegenNachPfad(
|
||||
'0 - Freiberuflich:01 - Rückstellungen:010 - Rückst. USt',
|
||||
'einnahme',
|
||||
['beschreibung' => 'aus dem Export']
|
||||
);
|
||||
|
||||
$this->assertGreaterThan(0, $id, 'Der Pfad wird angelegt');
|
||||
$this->assertNotEmpty(Kategorie::findeNachPfad('0 - Freiberuflich'), 'Erste Stufe entstand');
|
||||
$this->assertNotEmpty(
|
||||
Kategorie::findeNachPfad('0 - Freiberuflich:01 - Rückstellungen'),
|
||||
'Zwischenstufe entstand'
|
||||
);
|
||||
$this->assertEquals('einnahme', Kategorie::find($id)['art'], 'Art gilt für den ganzen Pfad');
|
||||
$this->assertEquals('aus dem Export', Kategorie::find($id)['beschreibung'], 'Beschreibung nur auf der letzten Stufe');
|
||||
$this->assertEquals(
|
||||
'',
|
||||
Kategorie::findeNachPfad('0 - Freiberuflich')['beschreibung'],
|
||||
'Zwischenstufen bleiben ohne Beschreibung'
|
||||
);
|
||||
|
||||
// Zweiter Aufruf liefert dieselbe Kategorie, legt nichts Neues an.
|
||||
$vorher = Kategorie::anzahl();
|
||||
$zweite = Kategorie::findenOderAnlegenNachPfad('0 - Freiberuflich:01 - Rückstellungen:010 - Rückst. USt');
|
||||
$this->assertEquals($id, $zweite, 'Zweiter Aufruf liefert dieselbe Kategorie');
|
||||
$this->assertEquals($vorher, Kategorie::anzahl(), 'Es kam nichts hinzu');
|
||||
}
|
||||
|
||||
private function qifAngabeZerlegen(): void
|
||||
{
|
||||
$ohne = Kategorie::zerlegeQifAngabe('400 - Leben:B-Essen alleine');
|
||||
$this->assertEquals('400 - Leben:B-Essen alleine', $ohne['kategorie'], 'Kategorie ohne Klasse');
|
||||
$this->assertEquals('', $ohne['klasse'], 'Keine Klasse angegeben');
|
||||
|
||||
$mit = Kategorie::zerlegeQifAngabe('400 - Leben:B-Essen alleine/Privat');
|
||||
$this->assertEquals('400 - Leben:B-Essen alleine', $mit['kategorie'], 'Kategorie wird abgetrennt');
|
||||
$this->assertEquals('Privat', $mit['klasse'], 'Klasse wird erkannt');
|
||||
|
||||
$umbuchung = Kategorie::zerlegeQifAngabe('[DKB Girokonto alt]/Privat');
|
||||
$this->assertEquals('[DKB Girokonto alt]', $umbuchung['kategorie'], 'Umbuchung behält die Klammern');
|
||||
$this->assertEquals('Privat', $umbuchung['klasse'], 'Auch Umbuchungen können eine Klasse tragen');
|
||||
}
|
||||
|
||||
private function klassen(): void
|
||||
{
|
||||
$id = Klasse::anlegen('Ehemann', 'Unterscheidet Ausgaben einzelner Familienmitglieder');
|
||||
$this->assertGreaterThan(0, $id, 'Klasse bekommt eine ID');
|
||||
|
||||
$doppelt = false;
|
||||
try {
|
||||
Klasse::anlegen('Ehemann');
|
||||
} catch (\Throwable) {
|
||||
$doppelt = true;
|
||||
}
|
||||
$this->assert($doppelt, 'Klassennamen sind eindeutig');
|
||||
|
||||
$this->assertEquals($id, Klasse::findenOderAnlegen('Ehemann'), 'Vorhandene Klasse wird wiederverwendet');
|
||||
|
||||
Klasse::speichern($id, 'Ehefrau', 'geändert', false);
|
||||
$klasse = Klasse::find($id);
|
||||
$this->assertEquals('Ehefrau', $klasse['name'], 'Klasse lässt sich umbenennen');
|
||||
$this->assertEquals(0, (int)$klasse['aktiv'], 'Klasse lässt sich stilllegen');
|
||||
|
||||
Klasse::loeschen($id);
|
||||
$this->assertNull(Klasse::find($id), 'Klasse lässt sich löschen');
|
||||
}
|
||||
}
|
||||
158
tests/07_KontenTest.php
Normal file
158
tests/07_KontenTest.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Controllers\KontenController;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Kontengruppe;
|
||||
|
||||
class KontenTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 07: Konten und Kontengruppen';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
|
||||
$this->anlegenUndFinden();
|
||||
$this->kontoarten();
|
||||
$this->uebersichtGruppiertUndSummiert();
|
||||
$this->betragsEingabe();
|
||||
}
|
||||
|
||||
private function anlegenUndFinden(): void
|
||||
{
|
||||
$id = Konto::anlegen([
|
||||
'name' => 'DKB Girokonto',
|
||||
'kontoart' => 'bank',
|
||||
'kontonummer' => '1010903027',
|
||||
'anfangssaldo' => 1234.56,
|
||||
'aktiv' => 1,
|
||||
]);
|
||||
|
||||
$konto = Konto::find($id);
|
||||
$this->assertEquals('DKB Girokonto', $konto['name'], 'Konto wird angelegt');
|
||||
$this->assertEquals('1234.56', $konto['anfangssaldo'], 'Anfangssaldo wird übernommen');
|
||||
|
||||
$doppelt = false;
|
||||
try {
|
||||
Konto::anlegen(['name' => 'DKB Girokonto']);
|
||||
} catch (\Throwable) {
|
||||
$doppelt = true;
|
||||
}
|
||||
$this->assert($doppelt, 'Kontonamen sind eindeutig');
|
||||
|
||||
$ohneName = false;
|
||||
try {
|
||||
Konto::anlegen(['name' => ' ']);
|
||||
} catch (\Throwable) {
|
||||
$ohneName = true;
|
||||
}
|
||||
$this->assert($ohneName, 'Ein Konto ohne Namen wird abgewiesen');
|
||||
|
||||
// Leerzeichen in der IBAN sind beim Abtippen normal und müssen weg.
|
||||
Konto::speichern($id, [
|
||||
'name' => 'DKB Girokonto',
|
||||
'kontoart' => 'bank',
|
||||
'iban' => 'de89 3704 0044 0532 0130 00',
|
||||
'aktiv' => 1,
|
||||
]);
|
||||
$this->assertEquals(
|
||||
'DE89370400440532013000',
|
||||
Konto::find($id)['iban'],
|
||||
'IBAN wird ohne Leerzeichen und in Großbuchstaben abgelegt'
|
||||
);
|
||||
|
||||
Konto::loeschen($id);
|
||||
$this->assertNull(Konto::find($id), 'Konto lässt sich löschen');
|
||||
}
|
||||
|
||||
private function kontoarten(): void
|
||||
{
|
||||
$this->assertEquals('bank', Konto::artAusQif('Bank'), 'QIF-Typ Bank');
|
||||
$this->assertEquals('kreditkarte', Konto::artAusQif('CCard'), 'QIF-Typ CCard');
|
||||
$this->assertEquals('sparkonto', Konto::artAusQif('Oth T'), 'QIF-Typ Oth T');
|
||||
$this->assertEquals('depot', Konto::artAusQif('Port'), 'QIF-Typ Port');
|
||||
$this->assertEquals('depot', Konto::artAusQif('Invst'), 'QIF-Typ Invst');
|
||||
$this->assertEquals('sonstiges', Konto::artAusQif('Unbekannt'), 'Unbekannter Typ fällt auf Sonstiges');
|
||||
$this->assertEquals('Kreditkarte', Konto::bezeichnungDerArt('kreditkarte'), 'Bezeichnung auf Deutsch');
|
||||
|
||||
$unbekannt = false;
|
||||
try {
|
||||
Konto::anlegen(['name' => 'Testkonto', 'kontoart' => 'gibtesnicht']);
|
||||
} catch (\Throwable) {
|
||||
$unbekannt = true;
|
||||
}
|
||||
$this->assert($unbekannt, 'Unbekannte Kontoart wird abgewiesen');
|
||||
}
|
||||
|
||||
private function uebersichtGruppiertUndSummiert(): void
|
||||
{
|
||||
$anlage = Kontengruppe::anlegen('Anlage', '', 1);
|
||||
|
||||
Konto::anlegen(['name' => 'Tagesgeld', 'kontoart' => 'sparkonto',
|
||||
'kontengruppe_id' => $anlage, 'anfangssaldo' => 10000, 'aktiv' => 1]);
|
||||
Konto::anlegen(['name' => 'Depot', 'kontoart' => 'depot',
|
||||
'kontengruppe_id' => $anlage, 'anfangssaldo' => 5000, 'aktiv' => 1]);
|
||||
Konto::anlegen(['name' => 'Girokonto', 'kontoart' => 'bank',
|
||||
'anfangssaldo' => 1500.50, 'aktiv' => 1]);
|
||||
Konto::anlegen(['name' => 'Kreditkarte', 'kontoart' => 'kreditkarte',
|
||||
'anfangssaldo' => -320.25, 'aktiv' => 1]);
|
||||
Konto::anlegen(['name' => 'Altes Konto', 'kontoart' => 'bank',
|
||||
'anfangssaldo' => 999, 'aktiv' => 0]);
|
||||
|
||||
$uebersicht = Konto::uebersicht();
|
||||
$summeErwartet = 10000 + 5000 + 1500.50 - 320.25;
|
||||
|
||||
$this->assertEquals($summeErwartet, $uebersicht['summe'], 'Gesamtsumme ohne inaktive Konten');
|
||||
$this->assertEquals(2, count($uebersicht['gruppen']), 'Zwei Gruppen: Anlage und Nicht gruppiert');
|
||||
|
||||
$nachName = [];
|
||||
foreach ($uebersicht['gruppen'] as $gruppe) {
|
||||
$nachName[$gruppe['name']] = $gruppe;
|
||||
}
|
||||
|
||||
$this->assertEquals(15000.0, $nachName['Anlage']['summe'] ?? 0.0, 'Summe der Gruppe Anlage');
|
||||
$this->assertEquals(
|
||||
2,
|
||||
count($nachName[Kontengruppe::OHNE_GRUPPE]['konten'] ?? []),
|
||||
'Konten ohne Gruppe stehen unter „Nicht gruppiert“'
|
||||
);
|
||||
|
||||
$mitInaktiven = Konto::uebersicht(false);
|
||||
$this->assertEquals(
|
||||
$summeErwartet + 999,
|
||||
$mitInaktiven['summe'],
|
||||
'Inaktive Konten zählen mit, wenn sie eingeblendet werden'
|
||||
);
|
||||
|
||||
// Die Saldospalten existieren schon, gespeist aus dem Anfangssaldo.
|
||||
$giro = null;
|
||||
foreach ($nachName[Kontengruppe::OHNE_GRUPPE]['konten'] as $konto) {
|
||||
if ($konto['name'] === 'Girokonto') {
|
||||
$giro = $konto;
|
||||
}
|
||||
}
|
||||
$this->assertEquals(1500.50, $giro['saldo_aktuell'], 'Aktueller Saldo entspricht dem Anfangssaldo');
|
||||
$this->assertEquals(1500.50, $giro['saldo_erwartet'], 'Erwarteter Saldo entspricht dem Anfangssaldo');
|
||||
$this->assertNull($giro['saldo_online'], 'Online-Saldo bleibt leer bis zum ersten Bankabruf');
|
||||
|
||||
// Wird die Gruppe gelöscht, bleiben die Konten erhalten.
|
||||
Kontengruppe::loeschen($anlage);
|
||||
$danach = Konto::uebersicht();
|
||||
$this->assertEquals(1, count($danach['gruppen']), 'Nach dem Löschen bleibt nur „Nicht gruppiert“');
|
||||
$this->assertEquals($summeErwartet, $danach['summe'], 'Die Summe ändert sich dadurch nicht');
|
||||
}
|
||||
|
||||
private function betragsEingabe(): void
|
||||
{
|
||||
$this->assertEquals(1234.56, KontenController::zahl('1.234,56'), 'Deutsches Format mit Tausenderpunkt');
|
||||
$this->assertEquals(-320.25, KontenController::zahl('-320,25'), 'Negativer Betrag');
|
||||
$this->assertEquals(1234.56, KontenController::zahl('1234.56'), 'Punkt vor zwei Ziffern ist Dezimaltrenner');
|
||||
$this->assertEquals(1500.0, KontenController::zahl('1.500'), 'Punkt vor drei Ziffern ist Tausendertrenner');
|
||||
$this->assertEquals(1234567.0, KontenController::zahl('1.234.567'), 'Mehrere Punkte sind Tausendertrenner');
|
||||
$this->assertEquals(0.5, KontenController::zahl('0.5'), 'Einzelne Nachkommastelle');
|
||||
$this->assertEquals(0.0, KontenController::zahl(''), 'Leere Eingabe ergibt null');
|
||||
$this->assertEquals(99.9, KontenController::zahl(' 99,90 € '), 'Währungszeichen und Leerzeichen stören nicht');
|
||||
}
|
||||
}
|
||||
255
tests/08_QifImportTest.php
Normal file
255
tests/08_QifImportTest.php
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Klasse;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Services\QifParser;
|
||||
use Finanzmanager\Services\QifStammdatenImport;
|
||||
use Finanzmanager\Controllers\ImportController;
|
||||
|
||||
/**
|
||||
* Der Ausschnitt entspricht dem Aufbau des echten Exports: Kopfzeile mit
|
||||
* Datumsformat, Klassen, Kategorien, die Kontenliste zwischen !Option:AutoSwitch
|
||||
* und !Clear:AutoSwitch und danach ein Konto mit seinen Buchungen.
|
||||
*/
|
||||
class QifImportTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 08: QIF-Parser und Stammdatenimport';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
|
||||
$this->parserLiestSektionen();
|
||||
$this->parserKonvertiertZeichensatz();
|
||||
$this->betragUndDatum();
|
||||
$this->stammdatenImport();
|
||||
$this->zweiterLaufAendertNichts();
|
||||
$this->trockenlaufSchreibtNicht();
|
||||
$this->dateinamenPruefung();
|
||||
}
|
||||
|
||||
private function beispiel(): string
|
||||
{
|
||||
$zeilen = [
|
||||
'!Option:MDY',
|
||||
'!Type:Class',
|
||||
'NEhemann',
|
||||
'DUnterscheidet Ausgaben einzelner Familienmitglieder',
|
||||
'^',
|
||||
'NPrivat',
|
||||
'^',
|
||||
'!Type:Cat',
|
||||
'N100 - Gehalt',
|
||||
'DGleason et al',
|
||||
'T',
|
||||
'I',
|
||||
'^',
|
||||
'N400 - Leben:B-Essen alleine',
|
||||
'E',
|
||||
'^',
|
||||
'N400 - Leben:A-Krankheitskosten',
|
||||
'DVorbeugung+Heilung',
|
||||
'T',
|
||||
'R16304',
|
||||
'E',
|
||||
'^',
|
||||
'!Option:AutoSwitch',
|
||||
'!Account',
|
||||
'NDKB Girokonto alt',
|
||||
'TBank',
|
||||
'DKontonr. 1010903027',
|
||||
'^',
|
||||
'NADAC LBB Kreditkarte',
|
||||
'TCCard',
|
||||
'L3,000.00',
|
||||
'^',
|
||||
'NDepot comdirect',
|
||||
'TPort',
|
||||
'^',
|
||||
'!Clear:AutoSwitch',
|
||||
'!Option:AutoSwitch',
|
||||
'!Account',
|
||||
'NDKB Girokonto alt',
|
||||
'TBank',
|
||||
'DKontonr. 1010903027',
|
||||
'^',
|
||||
'!Type:Bank ',
|
||||
'D3.18.26',
|
||||
'T-1,241.04',
|
||||
'PStadtwerke',
|
||||
'L400 - Leben:B-Essen alleine/Privat',
|
||||
'^',
|
||||
];
|
||||
|
||||
// Der echte Export ist ISO-8859-1 mit CRLF – genau so wird hier geprüft.
|
||||
return mb_convert_encoding(implode("\r\n", $zeilen) . "\r\n", 'ISO-8859-1', 'UTF-8');
|
||||
}
|
||||
|
||||
private function parserLiestSektionen(): void
|
||||
{
|
||||
$parser = new QifParser($this->beispiel());
|
||||
|
||||
$this->assertEquals('MDY', $parser->datumsformat(), 'Datumsformat aus der Kopfzeile');
|
||||
$this->assertEquals(2, count($parser->eintraege(QifParser::TYP_KLASSE)), 'Zwei Klassen');
|
||||
$this->assertEquals(3, count($parser->eintraege(QifParser::TYP_KATEGORIE)), 'Drei Kategorien');
|
||||
|
||||
// Das Girokonto steht zweimal in der Datei: in der Kontenliste und als
|
||||
// Kopf seiner Buchungen.
|
||||
$this->assertEquals(4, count($parser->eintraege(QifParser::TYP_KONTO)), 'Vier Kontoeinträge');
|
||||
|
||||
$buchungen = $parser->eintraege('Bank');
|
||||
$this->assertEquals(1, count($buchungen), 'Eine Buchung');
|
||||
$this->assertEquals(
|
||||
'DKB Girokonto alt',
|
||||
$buchungen[0]['konto'],
|
||||
'Die Buchung trägt das zuletzt genannte Konto'
|
||||
);
|
||||
$this->assertEquals('Stadtwerke', QifParser::feld($buchungen[0], 'P'), 'Zahlungsempfänger');
|
||||
|
||||
$kategorien = $parser->eintraege(QifParser::TYP_KATEGORIE);
|
||||
$this->assert(QifParser::hatFeld($kategorien[0], 'I'), 'Erste Kategorie ist eine Einnahme');
|
||||
$this->assert(QifParser::hatFeld($kategorien[0], 'T'), 'Erste Kategorie ist steuerrelevant');
|
||||
$this->assert(!QifParser::hatFeld($kategorien[1], 'T'), 'Zweite Kategorie ist nicht steuerrelevant');
|
||||
$this->assertEquals('16304', QifParser::feld($kategorien[2], 'R'), 'Steuerformular wird gelesen');
|
||||
|
||||
$this->assertEquals(
|
||||
['Class' => 2, 'Cat' => 3, 'Account' => 4, 'Bank' => 1],
|
||||
$parser->zusammenfassung(),
|
||||
'Zusammenfassung zählt je Sektionstyp'
|
||||
);
|
||||
}
|
||||
|
||||
private function parserKonvertiertZeichensatz(): void
|
||||
{
|
||||
$iso = mb_convert_encoding("!Type:Class\r\nNRückstellungen\r\n^\r\n", 'ISO-8859-1', 'UTF-8');
|
||||
$parser = new QifParser($iso);
|
||||
|
||||
$name = QifParser::feld($parser->eintraege(QifParser::TYP_KLASSE)[0], 'N');
|
||||
$this->assertEquals('Rückstellungen', $name, 'ISO-8859-1 wird nach UTF-8 gewandelt');
|
||||
|
||||
// Eine bereits als UTF-8 vorliegende Datei darf nicht ein zweites Mal
|
||||
// konvertiert werden.
|
||||
$utf8 = new QifParser("!Type:Class\nNRückstellungen\n^\n");
|
||||
$this->assertEquals(
|
||||
'Rückstellungen',
|
||||
QifParser::feld($utf8->eintraege(QifParser::TYP_KLASSE)[0], 'N'),
|
||||
'UTF-8 bleibt unverändert'
|
||||
);
|
||||
}
|
||||
|
||||
private function betragUndDatum(): void
|
||||
{
|
||||
$this->assertEquals(-1241.04, QifParser::betrag('-1,241.04'), 'US-Format mit Tausenderkomma');
|
||||
$this->assertEquals(3000.0, QifParser::betrag('3,000.00'), 'Kreditlimit');
|
||||
$this->assertEquals(0.0, QifParser::betrag(null), 'Fehlender Betrag ist null');
|
||||
|
||||
$this->assertEquals('2026-03-18', QifParser::datum('3.18.26'), 'MDY mit zweistelligem Jahr');
|
||||
$this->assertEquals('2026-01-02', QifParser::datum('1.2.26'), 'Einstellige Monats- und Tagesangabe');
|
||||
$this->assertEquals('1998-12-31', QifParser::datum('12.31.98'), 'Jahr ab 70 liegt im letzten Jahrhundert');
|
||||
$this->assertEquals('2026-03-18', QifParser::datum('18.3.26', 'DMY'), 'DMY wird berücksichtigt');
|
||||
$this->assertNull(QifParser::datum('13.45.26'), 'Unmögliches Datum ergibt null');
|
||||
$this->assertNull(QifParser::datum('Unfug'), 'Unlesbares Datum ergibt null');
|
||||
}
|
||||
|
||||
private function stammdatenImport(): void
|
||||
{
|
||||
$ergebnis = (new QifStammdatenImport(new QifParser($this->beispiel())))->ausfuehren();
|
||||
|
||||
$this->assertEquals(2, $ergebnis['klassen']['neu'], 'Beide Klassen angelegt');
|
||||
// Drei genannte Kategorien plus die Oberkategorie "400 - Leben",
|
||||
// die im Export nur als Pfadbestandteil vorkommt.
|
||||
$this->assertEquals(4, $ergebnis['kategorien']['neu'], 'Kategorien samt Zwischenstufe angelegt');
|
||||
$this->assertEquals(3, $ergebnis['konten']['neu'], 'Drei Konten angelegt');
|
||||
$this->assertEquals(1, $ergebnis['konten']['vorhanden'], 'Der zweite Eintrag desselben Kontos zählt als vorhanden');
|
||||
$this->assertEquals(0, $ergebnis['kategorien']['fehler'], 'Keine Fehler');
|
||||
$this->assertEquals([], $ergebnis['meldungen'], 'Kein Protokolleintrag');
|
||||
|
||||
$gehalt = Kategorie::findeNachPfad('100 - Gehalt');
|
||||
$this->assertEquals('einnahme', $gehalt['art'], 'Einnahmekategorie erkannt');
|
||||
$this->assertEquals(1, (int)$gehalt['steuerrelevant'], 'Steuerkennzeichen übernommen');
|
||||
$this->assertEquals('Gleason et al', $gehalt['beschreibung'], 'Beschreibung übernommen');
|
||||
|
||||
$krank = Kategorie::findeNachPfad('400 - Leben:A-Krankheitskosten');
|
||||
$this->assertEquals('16304', $krank['steuerformular'], 'Steuerformular übernommen');
|
||||
$this->assertEquals('ausgabe', $krank['art'], 'Ausgabekategorie erkannt');
|
||||
|
||||
$oben = Kategorie::findeNachPfad('400 - Leben');
|
||||
$this->assertNotEmpty($oben, 'Die Oberkategorie entstand selbsttätig');
|
||||
$this->assertEquals('ausgabe', $oben['art'], 'Sie übernimmt die Art ihres Astes');
|
||||
|
||||
$giro = Konto::findeNachName('DKB Girokonto alt');
|
||||
$this->assertEquals('bank', $giro['kontoart'], 'Kontoart aus dem QIF-Typ');
|
||||
$this->assertEquals('1010903027', $giro['kontonummer'], 'Kontonummer aus der Beschreibung gezogen');
|
||||
$this->assertEquals('', $giro['beschreibung'], 'Die reine Kontonummer bleibt nicht als Beschreibung stehen');
|
||||
|
||||
$karte = Konto::findeNachName('ADAC LBB Kreditkarte');
|
||||
$this->assertEquals('kreditkarte', $karte['kontoart'], 'Kreditkarte erkannt');
|
||||
$this->assertEquals('3000.00', $karte['kreditlimit'], 'Kreditlimit übernommen');
|
||||
|
||||
$this->assertEquals('depot', Konto::findeNachName('Depot comdirect')['kontoart'], 'Depot erkannt');
|
||||
$this->assertNotEmpty(Klasse::findeNachName('Ehemann'), 'Klasse angelegt');
|
||||
}
|
||||
|
||||
private function zweiterLaufAendertNichts(): void
|
||||
{
|
||||
$vorher = [Kategorie::anzahl(), Konto::anzahl(), Klasse::anzahl()];
|
||||
|
||||
$ergebnis = (new QifStammdatenImport(new QifParser($this->beispiel())))->ausfuehren();
|
||||
|
||||
$this->assertEquals(0, $ergebnis['kategorien']['neu'], 'Zweiter Lauf legt keine Kategorie an');
|
||||
$this->assertEquals(0, $ergebnis['konten']['neu'], 'Zweiter Lauf legt kein Konto an');
|
||||
$this->assertEquals(0, $ergebnis['klassen']['neu'], 'Zweiter Lauf legt keine Klasse an');
|
||||
$this->assertEquals(3, $ergebnis['kategorien']['vorhanden'], 'Alle Kategorien als vorhanden gezählt');
|
||||
|
||||
$this->assertEquals(
|
||||
$vorher,
|
||||
[Kategorie::anzahl(), Konto::anzahl(), Klasse::anzahl()],
|
||||
'Die Bestände sind unverändert'
|
||||
);
|
||||
}
|
||||
|
||||
private function trockenlaufSchreibtNicht(): void
|
||||
{
|
||||
$zusatz = "!Type:Class\r\nNProjekt 3\r\n^\r\n!Type:Cat\r\nNNeue Kategorie\r\nE\r\n^\r\n"
|
||||
. "!Option:AutoSwitch\r\n!Account\r\nNNeues Konto\r\nTBank\r\n^\r\n!Clear:AutoSwitch\r\n";
|
||||
|
||||
$vorher = [Kategorie::anzahl(), Konto::anzahl(), Klasse::anzahl()];
|
||||
$ergebnis = (new QifStammdatenImport(new QifParser($zusatz)))->vorschau();
|
||||
|
||||
$this->assert($ergebnis['trockenlauf'], 'Ergebnis ist als Trockenlauf gekennzeichnet');
|
||||
$this->assertEquals(1, $ergebnis['klassen']['neu'], 'Vorschau zählt die neue Klasse');
|
||||
$this->assertEquals(1, $ergebnis['kategorien']['neu'], 'Vorschau zählt die neue Kategorie');
|
||||
$this->assertEquals(1, $ergebnis['konten']['neu'], 'Vorschau zählt das neue Konto');
|
||||
|
||||
$this->assertEquals(
|
||||
$vorher,
|
||||
[Kategorie::anzahl(), Konto::anzahl(), Klasse::anzahl()],
|
||||
'Der Trockenlauf hat nichts gespeichert'
|
||||
);
|
||||
}
|
||||
|
||||
private function dateinamenPruefung(): void
|
||||
{
|
||||
$this->assertEquals(
|
||||
'Export.QIF',
|
||||
ImportController::sichererDateiname('Export.QIF'),
|
||||
'Gültiger Dateiname bleibt erhalten'
|
||||
);
|
||||
$this->assertEquals(
|
||||
'passwd.qif',
|
||||
ImportController::sichererDateiname('../../etc/passwd.qif'),
|
||||
'Pfadanteile werden abgeschnitten'
|
||||
);
|
||||
$this->assertNull(ImportController::sichererDateiname('böse.php'), 'Fremde Endung wird abgewiesen');
|
||||
$this->assertNull(ImportController::sichererDateiname(''), 'Leerer Name wird abgewiesen');
|
||||
$this->assertNull(ImportController::sichererDateiname('..'), 'Verzeichniswechsel wird abgewiesen');
|
||||
$this->assertEquals(
|
||||
'mein_export_2026.qif',
|
||||
ImportController::sichererDateiname('mein export 2026.qif'),
|
||||
'Leerzeichen werden ersetzt'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -64,10 +64,27 @@ abstract class TestCase
|
|||
// Der Rollback dreht nur die Datenbank zurück; im Einstellungs-Cache
|
||||
// stünden sonst Werte, die es nicht mehr gibt.
|
||||
\Finanzmanager\Models\Einstellung::cacheLeeren();
|
||||
\Finanzmanager\Mandant::setze(null);
|
||||
|
||||
$this->raeumeTempPfadeAuf();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen Benutzer an und macht ihn zum Mandanten. Stammdatentests
|
||||
* brauchen das: Konten, Kategorien und Klassen hängen an einer benutzer_id.
|
||||
* Der Rollback in tearDown() räumt ihn wieder weg.
|
||||
*/
|
||||
protected function mandantAnlegen(): int
|
||||
{
|
||||
$id = \Finanzmanager\Models\Benutzer::anlegen(
|
||||
'test_' . bin2hex(random_bytes(4)),
|
||||
'testpasswort-' . bin2hex(random_bytes(4))
|
||||
);
|
||||
\Finanzmanager\Mandant::setze($id);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
// ── Assertions ───────────────────────────────────────────────────────────
|
||||
|
||||
protected function assert(bool $bedingung, string $beschreibung): void
|
||||
|
|
|
|||
181
tests/e2e/specs/stammdaten.spec.js
Normal file
181
tests/e2e/specs/stammdaten.spec.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test')
|
||||
|
||||
const BENUTZER = process.env.E2E_USER || 'e2e'
|
||||
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
|
||||
|
||||
// Eindeutige Namen je Lauf: die Tests arbeiten auf einer bestehenden Instanz
|
||||
// und dürfen sich nicht an Resten eines früheren Laufs stoßen.
|
||||
const MARKE = Date.now().toString().slice(-6)
|
||||
|
||||
async function anmelden(page) {
|
||||
await page.goto('/login')
|
||||
|
||||
if (page.url().includes('/einrichtung')) {
|
||||
throw new Error(
|
||||
'Instanz ist noch nicht eingerichtet. Zuerst einen Benutzer anlegen: ' +
|
||||
'docker compose exec -u www-data php php scripts/e2e-benutzer.php'
|
||||
)
|
||||
}
|
||||
|
||||
await page.fill('#benutzername', BENUTZER)
|
||||
await page.fill('#passwort', PASSWORT)
|
||||
await page.click('button[type="submit"]')
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
}
|
||||
|
||||
test.describe('Stammdaten', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await anmelden(page)
|
||||
})
|
||||
|
||||
test('Kontenübersicht ist erreichbar', async ({ page }) => {
|
||||
await page.click('.seitenleiste a[href="/konten"]')
|
||||
|
||||
await expect(page.locator('h1')).toContainText('Kontenübersicht')
|
||||
await expect(page.locator('a[href="/konten/neu"]').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Konto anlegen und in der Übersicht wiederfinden', async ({ page }) => {
|
||||
const name = `E2E Girokonto ${MARKE}`
|
||||
|
||||
await page.goto('/konten/neu')
|
||||
await page.fill('#name', name)
|
||||
await page.selectOption('#kontoart', 'bank')
|
||||
await page.fill('#anfangssaldo', '1.234,56')
|
||||
await page.click('.formular button[type="submit"]')
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
await page.goto('/konten')
|
||||
const zeile = page.locator('tr', { hasText: name })
|
||||
await expect(zeile).toContainText('Bank')
|
||||
// Deutsches Betragsformat, geschütztes Leerzeichen vor dem Euro-Zeichen.
|
||||
await expect(zeile).toContainText('1.234,56')
|
||||
await expect(page.locator('.summenzeile')).toContainText('Summe Kontostände')
|
||||
})
|
||||
|
||||
test('Kategorie mit Unterkategorie anlegen', async ({ page }) => {
|
||||
const oben = `E2E Wohnen ${MARKE}`
|
||||
const unten = `Strom ${MARKE}`
|
||||
|
||||
await page.goto('/verwaltung/kategorien/neu')
|
||||
await page.fill('#name', oben)
|
||||
await page.selectOption('#art', 'ausgabe')
|
||||
await page.click('.formular button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
// Nach dem Anlegen steht man im Bearbeitungsformular der Kategorie.
|
||||
await expect(page.locator('.feldwert code')).toContainText(oben)
|
||||
|
||||
await page.goto('/verwaltung/kategorien/neu')
|
||||
await page.fill('#name', unten)
|
||||
await page.selectOption('#eltern_id', { label: oben })
|
||||
await page.click('.formular button[type="submit"]')
|
||||
|
||||
await expect(page.locator('.feldwert code')).toContainText(`${oben}:${unten}`)
|
||||
|
||||
await page.goto('/verwaltung/kategorien')
|
||||
await expect(page.locator('tr', { hasText: oben }).first()).toContainText('Oberkategorie')
|
||||
// Die Unterkategorie ist um eine Ebene eingerückt.
|
||||
await expect(page.locator(`tr:has-text("${unten}") .baumname`))
|
||||
.toHaveAttribute('style', /--ebene: 1/)
|
||||
})
|
||||
|
||||
test('Register filtern nach Einnahmen und Ausgaben', async ({ page }) => {
|
||||
const einnahme = `E2E Gehalt ${MARKE}`
|
||||
|
||||
await page.goto('/verwaltung/kategorien/neu')
|
||||
await page.fill('#name', einnahme)
|
||||
await page.selectOption('#art', 'einnahme')
|
||||
await page.click('.formular button[type="submit"]')
|
||||
|
||||
await page.goto('/verwaltung/kategorien?register=einnahmen')
|
||||
await expect(page.locator('.tab.aktiv')).toContainText('Einnahmen')
|
||||
await expect(page.locator('tbody')).toContainText(einnahme)
|
||||
|
||||
await page.goto('/verwaltung/kategorien?register=ausgaben')
|
||||
await expect(page.locator('tbody')).not.toContainText(einnahme)
|
||||
})
|
||||
|
||||
test('Umbuchungskonten zeigen die Konten in eckigen Klammern', async ({ page }) => {
|
||||
await page.goto('/verwaltung/kategorien?register=umbuchungskonten')
|
||||
|
||||
await expect(page.locator('.tab.aktiv')).toContainText('Umbuchungskonten')
|
||||
await expect(page.locator('tbody a').first()).toContainText(/^\[.+\]$/)
|
||||
})
|
||||
|
||||
test('Klasse anlegen und wieder löschen', async ({ page }) => {
|
||||
const name = `E2E Projekt ${MARKE}`
|
||||
|
||||
await page.goto('/verwaltung/klassen')
|
||||
await page.fill('.formular #name', name)
|
||||
await page.fill('.formular #beschreibung', 'aus dem E2E-Test')
|
||||
await page.click('.formular button[type="submit"]')
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
// Die Liste ist bearbeitbar: Name und Beschreibung stehen in
|
||||
// Eingabefeldern, nicht als Text – deshalb über den Attributwert suchen.
|
||||
const zeile = page.locator('tr').filter({
|
||||
has: page.locator(`input[name="name"][value="${name}"]`),
|
||||
})
|
||||
await expect(zeile).toHaveCount(1)
|
||||
await expect(zeile.locator('input[name="beschreibung"]')).toHaveValue('aus dem E2E-Test')
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept())
|
||||
await zeile.getByRole('button', { name: 'Löschen' }).click()
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
|
||||
})
|
||||
|
||||
test('QIF-Trockenlauf zeigt an, was ankäme, ohne zu speichern', async ({ page }) => {
|
||||
const dateiname = `e2e-probe-${MARKE}.qif`
|
||||
const klasse = `E2E Klasse ${MARKE}`
|
||||
const inhalt = [
|
||||
'!Option:MDY',
|
||||
'!Type:Class',
|
||||
`N${klasse}`,
|
||||
'^',
|
||||
'!Type:Cat',
|
||||
`NE2E Kategorie ${MARKE}`,
|
||||
'E',
|
||||
'^',
|
||||
'!Option:AutoSwitch',
|
||||
'!Account',
|
||||
`NE2E Konto ${MARKE}`,
|
||||
'TBank',
|
||||
'^',
|
||||
'!Clear:AutoSwitch',
|
||||
'',
|
||||
].join('\r\n')
|
||||
|
||||
await page.goto('/verwaltung/import')
|
||||
await page.setInputFiles('#qif', {
|
||||
name: dateiname,
|
||||
mimeType: 'application/octet-stream',
|
||||
buffer: Buffer.from(inhalt, 'latin1'),
|
||||
})
|
||||
await page.click('.formular button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('hochgeladen')
|
||||
|
||||
const zeile = page.locator('tr', { hasText: dateiname })
|
||||
await zeile.getByRole('button', { name: 'Trockenlauf' }).click()
|
||||
|
||||
await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf')
|
||||
const uebersicht = page.locator('.kachel table.liste').first()
|
||||
await expect(uebersicht.locator('tr', { hasText: 'Klassen' })).toContainText('1')
|
||||
await expect(uebersicht.locator('tr', { hasText: 'Konten' })).toContainText('1')
|
||||
|
||||
// Nichts gespeichert: die Klasse darf noch nicht in der Liste stehen.
|
||||
await page.goto('/verwaltung/klassen')
|
||||
await expect(page.locator('body')).not.toContainText(klasse)
|
||||
|
||||
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
|
||||
await page.goto('/verwaltung/import')
|
||||
page.once('dialog', (dialog) => dialog.accept())
|
||||
await page.locator('tr', { hasText: dateiname })
|
||||
.getByRole('button', { name: 'Löschen' }).click()
|
||||
await expect(page.locator('body')).not.toContainText(dateiname)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue