From 2c86e8522bd92ea49932699e7ba62fd3d394d3ea Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 17 Aug 2026 23:01:30 +0200 Subject: [PATCH] =?UTF-8?q?Import=20und=20Export=20in=20QIF=20und=20CSV,?= =?UTF-8?q?=20Housekeeping=20f=C3=BCr=20Importdateien?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aus der Kachel QIF-Import wird Import/Export mit je einem Knopf für beide Richtungen; davor steht die Wahl des Dateiformats (QIF oder CSV), die als Dialog und ohne Skript als eigene Seite funktioniert. - CsvBuchungsImport/CsvTabelle: Buchungen samt Splitzeilen, Spalten über die Kopfzeile, dieselbe Dublettenerkennung und der Trockenlauf wie beim QIF. - BuchungsExport/ExportController: Filter nach Zeitraum, Konto, Kategorie und Klasse; die Datei entsteht im Speicher, ein Exportverzeichnis gibt es nicht. - Housekeeping: Hochgeladene Importdateien und data/tmp werden nach der eingestellten Frist gelöscht (Vorgabe 14 Tage, 0 schaltet ab). Migration 013, Test 29 sowie E2E-Spec importexport.spec.js. Co-Authored-By: Claude Opus 5 --- Changes.md | 66 +++ PLAN.md | 2 +- README.md | 14 +- docs/{qif-import.md => import-export.md} | 158 +++++- docs/stammdaten.md | 2 +- migrations/013_housekeeping.sql | 11 + public/index.php | 9 + public/static/app.css | 44 ++ public/static/app.js | 29 + scripts/housekeeping.php | 57 ++ src/Controllers/EinstellungenController.php | 6 +- src/Controllers/ExportController.php | 155 ++++++ src/Controllers/ImportController.php | 231 +++++--- src/Services/BerichtExport.php | 37 +- src/Services/BuchungsExport.php | 551 +++++++++++++++++++ src/Services/CsvBuchungsImport.php | 396 ++++++++++++++ src/Services/CsvTabelle.php | 233 ++++++++ src/Services/Dateiformat.php | 51 ++ src/Services/Housekeeping.php | 169 ++++++ src/Views/einstellungen/index.php | 27 +- src/Views/verwaltung/export.php | 148 ++++++ src/Views/verwaltung/formatwahl.php | 32 ++ src/Views/verwaltung/import.php | 263 ++------- src/Views/verwaltung/index.php | 33 +- src/Views/verwaltung/teil-import-csv.php | 127 +++++ src/Views/verwaltung/teil-import-qif.php | 244 +++++++++ tests/24_NavigationTest.php | 2 +- tests/29_ImportExportTest.php | 559 ++++++++++++++++++++ tests/e2e/specs/importexport.spec.js | 127 +++++ tests/e2e/specs/stammdaten.spec.js | 12 +- 30 files changed, 3424 insertions(+), 371 deletions(-) rename docs/{qif-import.md => import-export.md} (55%) create mode 100644 migrations/013_housekeeping.sql create mode 100644 scripts/housekeeping.php create mode 100644 src/Controllers/ExportController.php create mode 100644 src/Services/BuchungsExport.php create mode 100644 src/Services/CsvBuchungsImport.php create mode 100644 src/Services/CsvTabelle.php create mode 100644 src/Services/Dateiformat.php create mode 100644 src/Services/Housekeeping.php create mode 100644 src/Views/verwaltung/export.php create mode 100644 src/Views/verwaltung/formatwahl.php create mode 100644 src/Views/verwaltung/teil-import-csv.php create mode 100644 src/Views/verwaltung/teil-import-qif.php create mode 100644 tests/29_ImportExportTest.php create mode 100644 tests/e2e/specs/importexport.spec.js diff --git a/Changes.md b/Changes.md index 0ec2372..0440a14 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,71 @@ # Änderungen +## Import und Export in zwei Formaten (17.08.2026) + +Aus der Kachel *QIF-Import* wird **Import/Export** mit je einem Knopf für beide Richtungen. +Wer darauf drückt, wählt zuerst das **Dateiformat** – QIF oder CSV –, und erst danach öffnet +sich der Dialog, der genau dieses Format annimmt bzw. schreibt. Bisher gab es nur den einen +Weg hinein: QIF, und nur herein. + +**Formatwahl** (`Dateiformat`, `Views/verwaltung/formatwahl.php`) + +- Die Formate stehen an einer Stelle (`Dateiformat::ALLE`); Dialoge, Auswahlkarten und die + Prüfung der Dateiendung hängen daran. +- Auf der Verwaltungsseite ist die Auswahl ein ``; die Karten liefert die Ansicht + mit, das Skript setzt nur die Ziele. Ohne Skript – oder ohne `` – führt derselbe + Knopf auf dieselbe Auswahl als eigene Seite (`/verwaltung/import` bzw. `/verwaltung/export` + ohne `format`). + +**CSV-Import** (`CsvBuchungsImport`, `CsvTabelle`) + +- Buchungen samt Splitzeilen. Die Kopfzeile bestimmt die Spalten, nicht ihre Reihenfolge – + verglichen wird kleingeschrieben und ohne Umlaute. Unverzichtbar sind `Datum`, `Konto` und + `Betrag`; fehlt eine, bricht der Lauf ab, statt eine halbe Datei einzulesen. +- Splitzeilen folgen unmittelbar auf ihre Buchung und tragen in `Art` das Wort *Splitzeile*. + Eine verwaiste Splitzeile ist ein Fehler und wird gemeldet, nicht stillschweigend fallen + gelassen. +- Kategorien und Klassen legt der Import bei Bedarf an, **Konten nicht**: Ein Konto trägt + Kontoart, Bankverbindung und Anfangssaldo, und nichts davon steht in der Zeile. +- Dieselbe Dublettenerkennung wie beim QIF (Fingerabdruck aus Konto, Datum, Betrag, Nummer + und Text, mit laufendem Index für echte Doppelbuchungen am selben Tag). Trockenlauf und + echter Lauf wie gehabt. +- `CsvTabelle` erkennt Semikolon oder Komma als Trenner und liest Beträge in deutscher wie + englischer Schreibweise; gelesen wird über `fgetcsv()`, damit ein maskiertes Feld einen + Zeilenumbruch enthalten darf. + +**Export** (`BuchungsExport`, `ExportController`) + +- Filter nach **Zeitraum, Konto, Kategorie und Klasse**; ohne Häkchen zählt jeweils alles. + Über dem Formular steht, wie viele Buchungen der Filter trifft. +- Der Kontofilter greift auf beide Seiten einer Umbuchung, und Kategorie wie Klasse zählen + auch in den Splitzeilen – sonst fielen genau die Buchungen heraus, die man sucht. +- Das QIF trägt den Dialekt des FinanzManagers und lässt sich vom eigenen Import wieder + einlesen; Klassen und Kategorien kommen vollständig mit, die Konten nur, soweit der Filter + sie zulässt. Das CSV ist dasselbe Format, das der CSV-Import liest. +- **Exportdateien werden nicht vorgehalten.** Sie entstehen im Speicher und gehen unmittelbar + an den Browser; ein Exportverzeichnis gibt es bewusst nicht. +- Das Filterformular sendet als POST: Bei vielen angehakten Kategorien wächst eine GET-Adresse + über das, was nginx annimmt – die Antwort wäre „414 Request-URI Too Large“ statt der Datei. + +**Housekeeping** (`Housekeeping`, `scripts/housekeeping.php`, Migration `013`) + +- Hochgeladene Importdateien bleiben absichtlich liegen (erst Trockenlauf, dann Übernahme) + und sammeln sich sonst unbegrenzt an. Was älter ist als die eingestellte Frist, wird + gelöscht – Vorgabe 14 Tage, `0` schaltet es ab (*Einstellungen → Import/Export*). +- Überwacht werden das Importverzeichnis und `data/tmp`; gelöscht werden nur gewöhnliche + Dateien unmittelbar darin, keine Symlinks und keine Unterverzeichnisse. +- Angestoßen beiläufig beim Öffnen von Import und Export, höchstens einmal am Tag. Ein Fehler + dabei reißt die Seite nicht mit. Für lange ungenutzte Installationen gibt es denselben Lauf + als Skript (`--probe` zählt nur auf, was wegfiele). + +**Nebenbei** + +- Der Upload nimmt jetzt beide Endungen an; welcher Importeur läuft, entscheidet die Endung + der Datei und nicht ein verstecktes Formularfeld. +- `BerichtExport` (Auswertungen) nutzt die Zeilen- und Zahlenregeln aus `CsvTabelle`, statt + sie ein zweites Mal zu führen. +- `docs/qif-import.md` heißt jetzt `docs/import-export.md` und beschreibt beide Richtungen. + ## Umsätze im Banking sammelweise löschen (17.08.2026) Die Umsatzlisten des Bankings haben jetzt dieselbe Löschfunktion wie ein Kontoblatt: ein diff --git a/PLAN.md b/PLAN.md index 151271c..6794eb8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -232,7 +232,7 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag `!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. Einzelheiten in `docs/stammdaten.md` und `docs/qif-import.md`. + in der Oberfläche gepflegt. Einzelheiten in `docs/stammdaten.md` und `docs/import-export.md`. ### Phase 2 — Buchungen *(erledigt)* * Migrationen `buchungen`, `buchung_splits`, `buchungsadressen`. diff --git a/README.md b/README.md index 537ec84..cabd269 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ Haushalt wird beides parallel genutzt. * **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen; QIF-Import mit Stichtag. +* **Import und Export** – QIF und CSV in beide Richtungen: Stammdaten und Buchungen + übernehmen, Buchungen nach Zeitraum, Konto, Kategorie und Klasse gefiltert ausgeben. + Exportdateien werden nicht vorgehalten; das Housekeeping räumt hochgeladene Dateien weg. * **Planung** – Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf, Snapshot und Sperre. * **Auswertungen** – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht mit @@ -83,7 +86,7 @@ Ersteinrichtung und legt den ersten Benutzer an. src/ Anwendungscode (Controllers, Models, Services, Views) public/ Front-Controller und statische Dateien migrations/ nummerierte SQL-Migrationen, laufen beim Containerstart -scripts/ Migration, Update, Backup, Tests, Kursabruf +scripts/ Migration, Update, Backup, Tests, Kursabruf, Housekeeping tests/ PHP-Testsuite; tests/e2e/ für Playwright data/ Nutzdaten: mariadb, logs, import, schluessel (nicht im Repository) Altdaten/ Screenshots und QIF-Export der Altanwendung (nicht im Repository) @@ -97,7 +100,7 @@ Buchungen samt Splits sowie Wertpapierbuchungen. Einzelheiten zum Format und den Fallstricken stehen in `PLAN.md`, Kapitel 4. Klassen, Kategorien, Konten und Buchungen lassen sich übernehmen – im Dialog unter -*Verwaltung → QIF-Import* oder auf der Kommandozeile. Buchungen ab dem Stichtag kommen +*Verwaltung → Import/Export* oder auf der Kommandozeile. Buchungen ab dem Stichtag kommen einzeln in die Datenbank, alles davor wird je Konto zum Anfangssaldo verrechnet: ```bash @@ -109,6 +112,11 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf. Mit `--konto=NAME` (mehrfach an im Dialog als Ankreuzliste nach dem Trockenlauf) bleibt der Import auf einzelne Konten beschränkt; `--konten` nennt die Konten der Datei. +In die andere Richtung geht es über *Verwaltung → Import/Export → Export*: Buchungen als +QIF oder CSV, gefiltert nach Zeitraum, Konto, Kategorie und Klasse. Die Datei entsteht +erst beim Herunterladen und wird nirgends abgelegt. Der CSV-Import liest dieselbe Datei +wieder ein. Einzelheiten in `docs/import-export.md`. + ## Dokumentation - `PLAN.md` – Projektplan mit allen Phasen @@ -122,4 +130,4 @@ beschränkt; `--konten` nennt die Konten der Datei. - `docs/banking.md` – FinTS-Abruf, TAN-Dialog, Abgleich und Regeln - `docs/wertpapiere.md` – Depots, Einstand, Kurse, Invst- und CSV-Bestandsimport - `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr -- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs +- `docs/import-export.md` – QIF und CSV in beide Richtungen, Formatwahl, Housekeeping diff --git a/docs/qif-import.md b/docs/import-export.md similarity index 55% rename from docs/qif-import.md rename to docs/import-export.md index 36fddf2..876b4e1 100644 --- a/docs/qif-import.md +++ b/docs/import-export.md @@ -1,4 +1,19 @@ -# QIF-Import +# Import und Export + +Unter *Verwaltung → Import/Export* führen zwei Knöpfe hinein. Beide fragen zuerst nach dem +**Dateiformat** (QIF oder CSV) und öffnen danach den passenden Dialog – der Import weiß +dann, welche Dateien er annimmt, der Export, was er schreiben soll. + +| | QIF | CSV | +|---|---|---| +| Import | Klassen, Kategorien, Konten, Buchungen, Buchungsvorlagen | Buchungen samt Splitzeilen | +| Export | Stammdaten und Buchungen | Buchungen samt Splitzeilen | + +Die Formate stehen an einer Stelle: `Dateiformat::ALLE`. Kommt ein drittes hinzu, ist das +die einzige Liste, die es zu ergänzen gilt – Dialoge, Auswahlkarten und die Prüfung des +Dateinamens hängen daran. + +## 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. @@ -7,7 +22,7 @@ Stand: Klassen, Kategorien, Konten (Phase 1) sowie Buchungen samt Splitzeilen un Umbuchungen (Phase 2) werden übernommen. Wertpapierbestände folgen in Phase 6, Buchungsvorlagen in Phase 7. -## Aufbau der Datei +### Aufbau der Datei ``` !Option:MDY Datumsformat für die gesamte Datei @@ -33,7 +48,7 @@ Jeder Kontoeintrag setzt zugleich das aktuelle Konto. In der Kontenliste folgen 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 +### Felder der Stammdaten | Sektion | Feld | Bedeutung | |---|---|---| @@ -53,7 +68,7 @@ Zuordnung. Der Import kann sie deshalb nicht anlegen: nach dem Einlesen stehen a unter „Nicht gruppiert“. Die Gruppen werden einmalig unter *Verwaltung → Kontengruppen* angelegt; dort lassen sich die Konten einer Gruppe in einem Zug ankreuzen. -## Fallstricke +### 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. @@ -76,7 +91,7 @@ angelegt; dort lassen sich die Konten einer Gruppe in einem Zug ankreuzen. 9. **Anfangssalden stehen nicht als eigenes Feld im Export.** Sie ergeben sich aus den Buchungen vor dem Stichtag, Gegenbuchungen eingerechnet. -## Arbeitsweise der Importeure +### Arbeitsweise der Importeure Beide sind **idempotent**: Was es schon gibt, wird gezählt und übersprungen, nie doppelt angelegt. Ein zweiter Lauf derselben Datei ändert nichts. Der echte Lauf liegt jeweils in @@ -100,7 +115,7 @@ die der Import selbst anlegen würde, erscheinen in der Vorschau. Verrechnungskonto zu Buche (`L|[Konto]` und `$`). Ein Betrag auf dem Depot ohne Gegenwert würde die Summe der Kontostände verfälschen; Bestände folgen in Phase 6. -## Nur einzelne Konten übernehmen +### Nur einzelne Konten übernehmen Der Import lässt sich auf einzelne Konten begrenzen (Option `konten`, im Dialog eine Ankreuzliste, auf der Kommandozeile `--konto=NAME`). Drei Dinge sind dabei entschieden: @@ -132,11 +147,12 @@ Text. Weil es echte Doppelbuchungen am selben Tag gibt (zweimal dasselbe im selb zählt ein laufender Index gleiche Fingerabdrücke durch – stabil über Läufe hinweg, weil die Datei immer dieselbe Reihenfolge hat. -## Bedienung +### 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. +Im Dialog unter *Verwaltung → Import/Export → Import → QIF*: Datei hochladen, Trockenlauf, +dann übernehmen. Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit +derselbe Export erst geprüft und dann übernommen werden kann; das +[Housekeeping](#housekeeping) räumt sie später weg. Nach dem Trockenlauf steht unter *Konten* die Ankreuzliste der Konten, die in der Datei vorkommen – vorher gibt es nichts zu wählen, weil die Datei noch nicht gelesen wurde. Von @@ -167,7 +183,7 @@ docker compose exec -u www-data php php scripts/qif-import.php \ `--konto=` grenzt den Import ein und ist mehrfach angebbar; ein Name, den die Datei nicht führt, bricht ab – sonst liefe der Import durch und legte nichts an. -## Prüfung gegen den echten Export +### Prüfung gegen den echten Export Der Vollexport (8780 Buchungen, 2017–2026) ergibt mit Stichtag 01.01.2026: @@ -185,3 +201,123 @@ darunter Wertpapiergeschäfte: 16 (nur Geldfluss) Der Härtetest sind die Salden: Acht Konten, deren Werte aus der Kontenübersicht des FinanzManagers vorliegen, stimmen danach **auf den Cent** – von „Whg Besigheim“ (210.601,99 €) bis „Auslagen Eltern Wohnungskauf“ (−22.410,94 €). + +## CSV-Import + +Das Gegenstück zum CSV-Export: Buchungen samt Splitzeilen, sonst nichts. Konten, +Kontengruppen und Buchungsvorlagen bleiben dem QIF vorbehalten. + +**Die Kopfzeile bestimmt die Spalten, nicht ihre Reihenfolge.** Eine Datei aus einem +anderen Programm darf sie umsortieren oder eigene ergänzen; verglichen wird +kleingeschrieben und ohne Umlaute (`CsvTabelle::schluessel()`), „Empfänger“ und +„empfaenger“ meinen dasselbe. Unverzichtbar sind `Datum`, `Konto` und `Betrag` – fehlt +eine davon, bricht der Lauf ab, statt eine halbe Datei einzulesen. + +| Spalte | Inhalt | +|---|---| +| `Art` | `Buchung` oder `Splitzeile` | +| `Datum` / `Valuta` | `TT.MM.JJJJ` oder `JJJJ-MM-TT` | +| `Konto` | Name eines vorhandenen Kontos | +| `Nummer`, `Empfänger`, `Buchungstext` | wie im Kontoblatt; bei Splitzeilen ist `Buchungstext` die Notiz | +| `Kategorie` | vollständiger Pfad mit `:` | +| `Klasse` | Name | +| `Gegenkonto` | statt einer Kategorie: macht die Zeile zur Umbuchung | +| `Betrag` | deutsch (`-84,00`) oder englisch (`-84.00`) | +| `Abgeglichen` | `ja`/`nein` | + +Entschieden ist dabei: + +1. **Splitzeilen folgen unmittelbar auf ihre Buchung** und tragen in `Art` das Wort + `Splitzeile`. Alles bis zur nächsten Buchungszeile gehört dazu. Eine Splitzeile ohne + Buchung davor ist ein Fehler und wird gemeldet – stillschweigend fallen zu lassen wäre + ein unbemerkter Verlust. Die Beträge der Zeilen müssen den Buchungsbetrag ergeben; das + prüft `Buchung::anlegen()`. +2. **Kategorien und Klassen legt der Import bei Bedarf an, Konten nicht.** Eine Kategorie + ist ein Pfad und mehr nicht; ein Konto trägt Kontoart, Bankverbindung und Anfangssaldo, + und nichts davon steht in der Zeile. Ein unbekanntes Konto ist deshalb ein Fehler. +3. **Dieselbe Dublettenerkennung wie beim QIF**: Fingerabdruck aus Konto, Datum, Betrag, + Nummer und Text, mit laufendem Index für echte Doppelbuchungen am selben Tag. Ein + zweiter Lauf derselben Datei ändert nichts. +4. **Zahlen und Trenner werden erkannt, nicht vorausgesetzt.** `CsvTabelle` sieht auf der + Kopfzeile nach, ob Semikolon oder Komma trennt, und liest Beträge in beiden + Schreibweisen: Maßgeblich ist, welches Zeichen zuletzt steht – es trennt die + Nachkommastellen, das andere ist Tausendertrenner. Gelesen wird über einen + Speicherstrom und `fgetcsv()`, damit ein maskiertes Feld auch einen Zeilenumbruch + enthalten darf. + +## Export + +*Verwaltung → Import/Export → Export*, dann das Format, dann der Filter: **Zeitraum, +Konto, Kategorie und Klasse**. Ohne Häkchen zählt jeweils alles. Über dem Formular steht, +wie viele Buchungen der Filter trifft – sonst lädt man eine leere Datei herunter und merkt +es erst in der Tabellenkalkulation. + +Zwei Eigenheiten des Datenmodells schlagen auf den Filter durch: + +* **Der Kontofilter greift auf beide Seiten.** Eine Umbuchung ist EINE Buchung mit + `gegenkonto_id` und steht nur beim erfassenden Konto. Zählte allein dieses, fehlte dem + Auszug des Gegenkontos seine einzige Bewegung. +* **Kategorie und Klasse zählen auch in den Splitzeilen.** Sonst fiele jede Splitbuchung + aus dem Filter, obwohl sie die gesuchte Kategorie führt. + +Die Kontenliste der Datei enthält zusätzlich jedes Konto, das nur Umbuchungsziel ist. Ohne +seinen Eintrag bezöge sich die Datei auf ein Konto, das der Import nicht kennt, und die +Umbuchung ginge verloren. + +**Die Datei wird nicht vorgehalten.** Sie entsteht im Speicher und geht mit +`Content-Disposition: attachment` unmittelbar an den Browser; ein Exportverzeichnis gibt es +bewusst nicht. Ein Auszug der eigenen Finanzen, der irgendwo im Dateisystem liegen bleibt, +ist eine Altlast, die niemand aufräumt. + +Das Filterformular sendet als **POST**. Bei vielen angehakten Kategorien wächst eine +GET-Adresse über das, was nginx annimmt – die Antwort wäre dann „414 Request-URI Too Large“ +statt der Datei. Die GET-Route bleibt für kurze Adressen bestehen, die man sich merken mag. + +### QIF-Export + +Derselbe Dialekt, den der Import liest: erst `!Type:Class`, dann `!Type:Cat`, dann die +Kontenliste zwischen `!Option:AutoSwitch` und `!Clear:AutoSwitch`, danach je Konto ein +`!Account`-Kopf und dessen Buchungen. Datumsformat ist `DMY` mit vierstelligem Jahr +(`!Option:DMY`), Beträge stehen im US-Format. + +Klassen und Kategorien kommen **vollständig** mit, auch wenn der Filter enger ist: Ohne sie +ließe sich die Datei auf einer leeren Installation nicht einspielen. Die Konten dagegen nur, +soweit der Filter sie zulässt – sonst wäre es kein Auszug mehr. + +Wertpapiergeschäfte (`!Type:Invst`) stehen nicht darin; sie hängen am Depot und werden über +die Wertpapierverwaltung gepflegt. + +### CSV-Export + +Deutsches Tabellenkalkulations-CSV: Semikolon als Trenner, Komma als Dezimalzeichen, kein +Tausenderpunkt (der macht aus der Zahl einen Text), CRLF am Zeilenende und ein BOM vorweg – +sonst zeigen Excel und LibreOffice beim Doppelklick „Übrige“ statt „Übrige“. Die Spalten +sind die des CSV-Imports; dieselbe Datei liest er wieder ein. + +`CsvTabelle` hält das Format an einer Stelle; `BerichtExport` (Auswertungen) nutzt dieselben +Zeilen- und Zahlenregeln. + +## Housekeeping + +Exportdateien entstehen im Speicher und hinterlassen nichts. Die hochgeladenen +Importdateien bleiben dagegen absichtlich liegen – erst Trockenlauf, dann Übernahme, ohne +zweiten Upload. Genau dadurch sammeln sich im Importverzeichnis mit der Zeit Kontoauszüge +an, die niemand mehr braucht. + +`Housekeeping` löscht deshalb, was älter ist als die eingestellte Frist (*Einstellungen → +Import/Export*, Vorgabe 14 Tage; `0` schaltet es ab). Überwacht werden das +Importverzeichnis und `data/tmp`, der Platz für Arbeitsdateien; gelöscht werden nur +gewöhnliche Dateien unmittelbar darin – ein Symlink zeigte womöglich aus dem überwachten +Bereich heraus. + +Angestoßen wird der Lauf **beiläufig beim Öffnen von Import und Export**, höchstens einmal +am Tag (`housekeeping_letzter_lauf`). Ein Fehler dabei reißt die Seite nicht mit: Wer den +Import öffnet, will importieren, nicht eine Fehlermeldung übers Aufräumen lesen. Für +Installationen, die wochenlang nicht geöffnet werden, gibt es denselben Lauf als Skript: + +```bash +docker compose exec -u www-data php php scripts/housekeeping.php --probe +docker compose exec -u www-data php php scripts/housekeeping.php --still +``` + +`--probe` zählt nur auf, was wegfiele. diff --git a/docs/stammdaten.md b/docs/stammdaten.md index fbf3c66..de51b6e 100644 --- a/docs/stammdaten.md +++ b/docs/stammdaten.md @@ -15,7 +15,7 @@ Gruppen werden wie Konten und Kategorien auf einer eigenen Formularseite gepfleg angekreuzt, `Kontengruppe::kontenZuordnen()` setzt sie in einem Zug. Ein Konto gehört zu genau einer Gruppe – wird es hier angehakt, verlässt es seine bisherige. -Der QIF-Export enthält **keine** Kontengruppen (siehe `docs/qif-import.md`); nach einem +Der QIF-Export enthält **keine** Kontengruppen (siehe `docs/import-export.md`); nach einem Import stehen deshalb alle Konten unter „Nicht gruppiert“ und werden einmalig zugeordnet. Kontoarten (`Konto::ARTEN`) und ihre Herkunft aus dem QIF-Export (`Konto::QIF_ARTEN`): diff --git a/migrations/013_housekeeping.sql b/migrations/013_housekeeping.sql new file mode 100644 index 0000000..ed9951a --- /dev/null +++ b/migrations/013_housekeeping.sql @@ -0,0 +1,11 @@ +-- Aufräumen der temporären Import-Dateien. +-- +-- Exportdateien werden nicht vorgehalten – sie entstehen im Speicher und gehen +-- direkt an den Browser. Die hochgeladenen Importdateien bleiben dagegen +-- absichtlich liegen (erst Trockenlauf, dann Übernahme) und sammeln sich sonst +-- unbegrenzt an. housekeeping_tage nennt die Frist, 0 schaltet das Aufräumen ab. + +INSERT INTO einstellungen (schluessel, wert, bereich) VALUES + ('housekeeping_tage', '14', 'import'), + ('housekeeping_letzter_lauf', NULL, 'import') +ON DUPLICATE KEY UPDATE schluessel = schluessel; diff --git a/public/index.php b/public/index.php index 3a3f488..f432666 100644 --- a/public/index.php +++ b/public/index.php @@ -16,6 +16,7 @@ use Finanzmanager\Controllers\BuchungenController; use Finanzmanager\Controllers\BudgetController; use Finanzmanager\Controllers\CockpitController; use Finanzmanager\Controllers\EinstellungenController; +use Finanzmanager\Controllers\ExportController; use Finanzmanager\Controllers\ImportController; use Finanzmanager\Controllers\KategorienController; use Finanzmanager\Controllers\KontenController; @@ -315,6 +316,14 @@ $router->post('/verwaltung/import/hochladen', fn() => (new ImportController())-> $router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren()); $router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new ImportController())->loeschen($p)); +// Der Filter kommt als POST: Sind viele Kategorien angehakt, wird eine +// GET-Adresse länger, als der Webserver annimmt. Die GET-Route bleibt für den +// ersten Aufruf und für kurze Adressen, die man sich merken mag. +$router->get('/verwaltung/export', fn() => (new ExportController())->index()); +$router->post('/verwaltung/export', fn() => (new ExportController())->index()); +$router->get('/verwaltung/export/datei', fn() => (new ExportController())->datei()); +$router->post('/verwaltung/export/datei', fn() => (new ExportController())->datei()); + try { $router->dispatch(); } catch (\Throwable $e) { diff --git a/public/static/app.css b/public/static/app.css index 82b0dfb..1bf1805 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1149,6 +1149,50 @@ table.splittabelle input.rechts { text-align: right; } „Speichern“ und „Abbrechen“, damit es nicht im Vorbeigehen getroffen wird. */ .splitdialog .knopfleiste .knopf.gefahr { margin-left: auto; } +/* Die Formatwahl vor Import und Export – als Dialog auf der Verwaltungsseite + und als Kachelinhalt auf der Ausweichseite, deshalb zwei Ebenen: der Dialog + trägt den Rahmen, .formatwahl die Karten. */ +.formatdialog { + width: min(34rem, 92vw); + padding: 1.1rem 1.25rem 1.25rem; + border: 1px solid var(--rand-kraeftig); + border-radius: var(--radius); + background: var(--flaeche); + color: var(--text); + box-shadow: 0 10px 30px rgb(0 0 0 / 25%); +} + +.formatdialog::backdrop { background: rgb(0 0 0 / 35%); } +.formatdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; } +.formatdialog .knopfleiste { margin-top: .9rem; } + +.formatwahl { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: .7rem; + margin-top: .8rem; +} + +.formatkarte { + display: flex; + flex-direction: column; + gap: .25rem; + padding: .8rem .9rem; + border: 1px solid var(--rand-kraeftig); + border-radius: var(--radius); + background: var(--flaeche-gedaempft); + color: inherit; + text-decoration: none; +} + +.formatkarte:hover, +.formatkarte:focus-visible { + border-color: var(--akzent); + background: color-mix(in srgb, var(--akzent) 8%, transparent); +} + +.formatname { font-weight: 600; font-size: 1.05rem; } + /* Der Wertpapierdialog trägt ein Formular und wird dadurch länger als der Splitdialog; bei kleinen Fenstern scrollt er. */ .wertpapierdialog { max-height: 88vh; overflow-y: auto; } diff --git a/public/static/app.js b/public/static/app.js index faf63f2..97c97a8 100644 --- a/public/static/app.js +++ b/public/static/app.js @@ -237,6 +237,35 @@ anpassen(); }); + // Formatwahl von Import und Export. Die Kachel in der Verwaltung führt zwei + // Knöpfe; welches Dateiformat gemeint ist, fragt dieser Dialog ab und hängt + // es dann an die Zieladresse. Die Karten liefert die Ansicht mit – hier + // stehen nur die Ziele. + // + // Kennt der Browser nicht, bleibt der Knopf ein gewöhnlicher Link + // und führt auf dieselbe Auswahl als eigene Seite. + const formatdialog = document.getElementById('formatwahl'); + if (formatdialog && typeof formatdialog.showModal === 'function') { + const titel = formatdialog.querySelector('[data-formatdialog-titel]'); + + document.querySelectorAll('[data-formatwahl]').forEach(function (knopf) { + knopf.addEventListener('click', function (ereignis) { + ereignis.preventDefault(); + + const ziel = knopf.getAttribute('href'); + if (titel) { + titel.textContent = knopf.dataset.formatwahl + ': Format wählen'; + } + + formatdialog.querySelectorAll('.formatkarte').forEach(function (karte) { + karte.setAttribute('href', ziel + '?format=' + karte.dataset.format); + }); + + formatdialog.showModal(); + }); + }); + } + // Meldungen ausblenden. Über eine Klasse, nicht über element.style: Die // Content-Security-Policy (style-src 'self') verwirft auch per JavaScript // gesetzte Inline-Styles, die Meldung bliebe sonst einfach stehen. diff --git a/scripts/housekeeping.php b/scripts/housekeeping.php new file mode 100644 index 0000000..097b42a --- /dev/null +++ b/scripts/housekeeping.php @@ -0,0 +1,57 @@ + 'text', 'banking_produkt_id' => 'text', ], - 'import' => ['import_qif_verzeichnis' => 'text', 'import_stichtag' => 'text'], + 'import' => [ + 'import_qif_verzeichnis' => 'text', + 'import_stichtag' => 'text', + 'housekeeping_tage' => 'text', + ], 'wertpapiere' => [ 'wertpapiere_kursdienst' => 'text', 'wertpapiere_api_key' => 'text', diff --git a/src/Controllers/ExportController.php b/src/Controllers/ExportController.php new file mode 100644 index 0000000..d1f1ff6 --- /dev/null +++ b/src/Controllers/ExportController.php @@ -0,0 +1,155 @@ +verlangeAnmeldung(); + Housekeeping::beiGelegenheit(); + + $format = Dateiformat::pruefe($this->text('format')); + if ($format === null) { + $this->render('verwaltung/formatwahl.php', [ + 'titel' => 'Export', + 'richtung' => 'Export', + 'ziel' => '/verwaltung/export', + 'einleitung' => 'In welchem Format soll die Datei entstehen? Danach folgt der Filter ' + . 'nach Zeitraum, Konto, Kategorie und Klasse.', + ]); + return; + } + + $filter = $this->filter(); + + $this->render('verwaltung/export.php', [ + 'titel' => 'Export (' . Dateiformat::name($format) . ')', + 'format' => $format, + 'filter' => $filter, + 'konten' => Konto::alle(), + 'kategorien' => Kategorie::baumFlach(), + 'klassen' => Klasse::alle(), + // Wie viele Buchungen der Filter trifft, steht schon vor dem + // Herunterladen da – sonst lädt man eine leere Datei und merkt es + // erst in der Tabellenkalkulation. + 'anzahl' => BuchungsExport::anzahl($filter), + ]); + } + + /** Erzeugt die Datei und schickt sie als Download. */ + public function datei(): void + { + $this->verlangeAnmeldung(); + + $format = Dateiformat::pruefe($this->text('format')); + if ($format === null) { + View::redirect('/verwaltung/export', 'Kein Format gewählt.', 'fehler'); + return; + } + + try { + $datei = BuchungsExport::datei($format, $this->filter()); + } catch (\Throwable $e) { + View::redirect( + '/verwaltung/export?format=' . $format, + 'Export fehlgeschlagen: ' . $e->getMessage(), + 'fehler' + ); + return; + } + + header('Content-Type: ' . $datei['typ']); + header('Content-Disposition: attachment; filename="' . $datei['name'] . '"'); + header('Content-Length: ' . strlen($datei['inhalt'])); + // Ein Auszug der eigenen Finanzen gehört in keinen Zwischenspeicher. + header('Cache-Control: no-store'); + echo $datei['inhalt']; + } + + /** + * Filterwerte, gleichgültig ob sie aus der Adresse oder aus dem Formular + * kommen. + * + * Das Formular sendet als POST: Bei vielen angehakten Kategorien wächst + * eine GET-Adresse über das, was der Webserver annimmt – nginx antwortet + * dann mit „414 Request-URI Too Large“ statt mit der Datei. Ein kurzer + * Aufruf mit wenigen Parametern bleibt trotzdem als Adresse möglich, + * deshalb zählen beide Quellen; POST sticht, weil es die frischere Eingabe + * ist. + * + * @return array + */ + private function eingabe(): array + { + return $_POST + $_GET; + } + + /** + * @return array{von: string, bis: string, konten: list, + * kategorien: list, klassen: list} + */ + private function filter(): array + { + $eingabe = $this->eingabe(); + + return [ + 'von' => $this->datum($this->text('von')), + 'bis' => $this->datum($this->text('bis')), + 'konten' => $this->ids($eingabe['konten'] ?? null), + 'kategorien' => $this->ids($eingabe['kategorien'] ?? null), + 'klassen' => $this->ids($eingabe['klassen'] ?? null), + ]; + } + + /** Ein einzelner Textwert; ein Feld, das als Liste ankommt, gilt als leer. */ + private function text(string $feld): string + { + $wert = $this->eingabe()[$feld] ?? ''; + + return is_string($wert) ? trim($wert) : ''; + } + + /** @return list */ + private function ids(mixed $werte): array + { + if (!is_array($werte)) { + return []; + } + + $ids = []; + foreach ($werte as $wert) { + if (is_scalar($wert) && (int)$wert > 0) { + $ids[] = (int)$wert; + } + } + + return array_values(array_unique($ids)); + } + + private function datum(string $wert): string + { + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert) === 1 ? $wert : ''; + } +} diff --git a/src/Controllers/ImportController.php b/src/Controllers/ImportController.php index 6afb5ea..635fb58 100644 --- a/src/Controllers/ImportController.php +++ b/src/Controllers/ImportController.php @@ -5,6 +5,9 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; use Finanzmanager\Models\Einstellung; +use Finanzmanager\Services\CsvBuchungsImport; +use Finanzmanager\Services\Dateiformat; +use Finanzmanager\Services\Housekeeping; use Finanzmanager\Services\QifBuchungsImport; use Finanzmanager\Services\QifParser; use Finanzmanager\Services\QifStammdatenImport; @@ -12,58 +15,75 @@ use Finanzmanager\Services\QifVorlagenImport; use Finanzmanager\View; /** - * QIF-Import: Stammdaten (Klassen, Kategorien, Konten) und Buchungen. + * Import: QIF (Stammdaten, Buchungen, Buchungsvorlagen) und CSV (Buchungen). + * + * Vor dem Hochladen steht die Formatwahl – erst danach weiß der Dialog, welche + * Dateien er annimmt und welchen Ablauf er zeigt. Ohne `format` in der Adresse + * erscheint deshalb die Auswahl; die Kachel in der Verwaltung nimmt sie über + * ihren Auswahldialog vorweg. * * 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. + * ihn zweimal hochzuladen. Was zu lange liegen bleibt, räumt das Housekeeping + * weg. */ class ImportController extends BaseController { - private const ERLAUBTE_ENDUNGEN = ['qif']; + /** Die Felder, die jede Ansicht kennt – ein Lauf füllt davon nur seine eigenen. */ + private const LEER = [ + 'ergebnis' => null, + 'buchungen' => null, + 'vorlagen' => null, + 'ueberblick' => null, + 'datei' => '', + 'dateikonten' => [], + 'auswahl' => [], + ]; public function index(): void { $this->verlangeAnmeldung(); + Housekeeping::beiGelegenheit(); - $this->render('verwaltung/import.php', [ - 'titel' => 'QIF-Import', - 'dateien' => $this->dateien(), - 'verzeichnis' => $this->verzeichnisAnzeige(), - 'ergebnis' => null, - 'buchungen' => null, - 'vorlagen' => null, - 'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'), - 'bis' => date('Y-m-d'), - 'datei' => '', - 'dateikonten' => [], - 'auswahl' => [], - ]); + $format = Dateiformat::pruefe($this->query('format')); + if ($format === null) { + $this->formatwahl(); + return; + } + + $this->render('verwaltung/import.php', array_merge(self::LEER, $this->grunddaten($format))); } public function hochladen(): void { $this->verlangeAnmeldung(); - $datei = $_FILES['qif'] ?? null; + $format = Dateiformat::pruefe($this->post('format')) ?? 'qif'; + $ziel = '/verwaltung/import?format=' . $format; + + $datei = $_FILES['datei'] ?? 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'); + View::redirect($ziel, $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'); + if ($name === null || Dateiformat::ausDateiname($name) !== $format) { + View::redirect( + $ziel, + 'Für diesen Import werden nur .' . strtoupper($format) . '-Dateien angenommen.', + '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'); + $pfad = $this->verzeichnis() . '/' . $name; + if (!is_uploaded_file((string)$datei['tmp_name']) || !move_uploaded_file((string)$datei['tmp_name'], $pfad)) { + View::redirect($ziel, 'Die Datei konnte nicht abgelegt werden.', 'fehler'); return; } - View::redirect('/verwaltung/import', 'Datei „' . $name . '“ hochgeladen.'); + View::redirect($ziel, 'Datei „' . $name . '“ hochgeladen.'); } public function ausfuehren(): void @@ -76,62 +96,37 @@ class ImportController extends BaseController return; } + // Maßgeblich ist die Endung der Datei, nicht das Feld im Formular: Was + // eingelesen wird, bestimmt der Inhalt – sonst liefe ein CSV durch den + // QIF-Parser, bloß weil ein verstecktes Feld das behauptet. + $format = Dateiformat::ausDateiname($name) ?? 'qif'; + $ziel = '/verwaltung/import?format=' . $format; + $pfad = $this->verzeichnis() . '/' . $name; if (!is_file($pfad)) { - View::redirect('/verwaltung/import', 'Die Datei „' . $name . '“ gibt es nicht mehr.', 'fehler'); + View::redirect($ziel, 'Die Datei „' . $name . '“ gibt es nicht mehr.', 'fehler'); return; } $trockenlauf = $this->post('modus') !== 'import'; - $stichtag = $this->post('stichtag') ?: (string)Einstellung::get('import_stichtag', '2026-01-01'); - $bis = $this->post('bis') ?: date('Y-m-d'); - try { - $parser = QifParser::ausDatei($pfad); - $ergebnis = (new QifStammdatenImport($parser))->ausfuehren($trockenlauf); - - // Erst nach dem Lesen steht fest, welche Konten die Datei führt – - // nur die stehen zur Wahl. Was im Formular sonst noch ankäme, fiele - // hier heraus. - $dateikonten = QifBuchungsImport::kontenDerDatei($parser); - $auswahl = array_values(array_intersect($this->postTexte('konten'), array_keys($dateikonten))); - - // Buchungen brauchen die Stammdaten – im Trockenlauf sind die noch - // nicht angelegt, deshalb bleibt es dort bei der Stammdatenvorschau, - // sobald die Konten fehlen. - $buchungen = null; - $vorlagen = null; - if (!$this->post('nur_stammdaten')) { - $buchungen = (new QifBuchungsImport($parser, [ - 'stichtag' => $stichtag, - 'bis' => $bis, - 'konten' => $auswahl, - ]))->ausfuehren($trockenlauf); - - // Buchungsvorlagen stehen in derselben Datei (!Type:Memorized) - // und kommen gleich mit – ein eigener Durchgang wäre nur ein - // zweiter Knopf für dieselbe Datei. - $vorlagen = (new QifVorlagenImport($parser))->ausfuehren($trockenlauf); - } + $daten = $format === 'csv' + ? $this->csvLauf($pfad, $trockenlauf) + : $this->qifLauf($pfad, $trockenlauf); } catch (\Throwable $e) { - View::redirect('/verwaltung/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler'); + View::redirect($ziel, '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()], - 'buchungen' => $buchungen, - 'vorlagen' => $vorlagen, - 'stichtag' => $stichtag, - 'bis' => $bis, - 'datei' => $name, - 'dateikonten' => $dateikonten, - 'auswahl' => $auswahl, - ]); + // Reihenfolge zählt: Was der Lauf liefert, sticht die Vorbelegung – + // etwa der im Formular gewählte Stichtag den aus den Einstellungen. + $this->render('verwaltung/import.php', array_merge( + self::LEER, + $this->grunddaten($format), + $daten, + ['datei' => $name] + )); } /** @param array $params */ @@ -139,7 +134,9 @@ class ImportController extends BaseController { $this->verlangeAnmeldung(); - $name = self::sichererDateiname($params['datei'] ?? ''); + $name = self::sichererDateiname($params['datei'] ?? ''); + $format = $name === null ? 'qif' : (Dateiformat::ausDateiname($name) ?? 'qif'); + if ($name !== null) { $pfad = $this->verzeichnis() . '/' . $name; if (is_file($pfad)) { @@ -147,7 +144,7 @@ class ImportController extends BaseController } } - View::redirect('/verwaltung/import', 'Datei gelöscht.'); + View::redirect('/verwaltung/import?format=' . $format, 'Datei gelöscht.'); } /** @@ -161,8 +158,7 @@ class ImportController extends BaseController return null; } - $endung = strtolower(pathinfo($name, PATHINFO_EXTENSION)); - if (!in_array($endung, self::ERLAUBTE_ENDUNGEN, true)) { + if (Dateiformat::ausDateiname($name) === null) { return null; } @@ -174,13 +170,100 @@ class ImportController extends BaseController return $name === '' ? null : $name; } + // ── Läufe je Format ────────────────────────────────────────────────────── + + /** @return array */ + private function qifLauf(string $pfad, bool $trockenlauf): array + { + $stichtag = $this->post('stichtag') ?: (string)Einstellung::get('import_stichtag', '2026-01-01'); + $bis = $this->post('bis') ?: date('Y-m-d'); + + $parser = QifParser::ausDatei($pfad); + $ergebnis = (new QifStammdatenImport($parser))->ausfuehren($trockenlauf); + + // Erst nach dem Lesen steht fest, welche Konten die Datei führt – + // nur die stehen zur Wahl. Was im Formular sonst noch ankäme, fiele + // hier heraus. + $dateikonten = QifBuchungsImport::kontenDerDatei($parser); + $auswahl = array_values(array_intersect($this->postTexte('konten'), array_keys($dateikonten))); + + // Buchungen brauchen die Stammdaten – im Trockenlauf sind die noch + // nicht angelegt, deshalb bleibt es dort bei der Stammdatenvorschau, + // sobald die Konten fehlen. + $buchungen = null; + $vorlagen = null; + if (!$this->post('nur_stammdaten')) { + $buchungen = (new QifBuchungsImport($parser, [ + 'stichtag' => $stichtag, + 'bis' => $bis, + 'konten' => $auswahl, + ]))->ausfuehren($trockenlauf); + + // Buchungsvorlagen stehen in derselben Datei (!Type:Memorized) + // und kommen gleich mit – ein eigener Durchgang wäre nur ein + // zweiter Knopf für dieselbe Datei. + $vorlagen = (new QifVorlagenImport($parser))->ausfuehren($trockenlauf); + } + + return [ + 'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()], + 'buchungen' => $buchungen, + 'vorlagen' => $vorlagen, + 'stichtag' => $stichtag, + 'bis' => $bis, + 'dateikonten' => $dateikonten, + 'auswahl' => $auswahl, + ]; + } + + /** @return array */ + private function csvLauf(string $pfad, bool $trockenlauf): array + { + $import = CsvBuchungsImport::ausDatei($pfad); + + return [ + 'buchungen' => $import->ausfuehren($trockenlauf), + 'ueberblick' => $import->ueberblick(), + ]; + } + + // ── Ansicht ────────────────────────────────────────────────────────────── + + private function formatwahl(): void + { + $this->render('verwaltung/formatwahl.php', [ + 'titel' => 'Import', + 'richtung' => 'Import', + 'ziel' => '/verwaltung/import', + 'einleitung' => 'In welchem Format liegt die Datei vor? Danach öffnet sich der Dialog, ' + . 'der genau dieses Format annimmt.', + ]); + } + + /** @return array */ + private function grunddaten(string $format): array + { + return [ + 'titel' => 'Import (' . Dateiformat::name($format) . ')', + 'format' => $format, + 'dateien' => $this->dateien($format), + 'verzeichnis' => $this->verzeichnisAnzeige(), + 'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'), + 'bis' => date('Y-m-d'), + 'aufbewahrung' => Housekeeping::tage(), + ]; + } + /** @return list */ - private function dateien(): array + private function dateien(string $format): array { $verzeichnis = $this->verzeichnis(); $dateien = []; - foreach (glob($verzeichnis . '/*.[qQ][iI][fF]') ?: [] as $pfad) { + foreach (glob($verzeichnis . '/*') ?: [] as $pfad) { + if (!is_file($pfad) || Dateiformat::ausDateiname($pfad) !== $format) { + continue; + } $dateien[] = [ 'name' => basename($pfad), 'groesse' => (int)filesize($pfad), diff --git a/src/Services/BerichtExport.php b/src/Services/BerichtExport.php index f649517..7ffc8d6 100644 --- a/src/Services/BerichtExport.php +++ b/src/Services/BerichtExport.php @@ -11,15 +11,11 @@ use Finanzmanager\View; * Vorzeichen wie am Bildschirm. Wer den Export mit der Anzeige vergleicht, soll * nicht rechnen müssen. * - * Deutsches Tabellenkalkulations-CSV: Semikolon als Trenner, Komma als - * Dezimalzeichen, kein Tausenderpunkt (der macht aus der Zahl einen Text). - * Vorangestellt ein BOM, sonst zeigen Excel und LibreOffice beim Doppelklick - * „Übrige“ statt „Übrige“. + * Das Dateiformat selbst – Trenner, Maskierung, Zahlenschreibweise – kommt aus + * CsvTabelle; hier steht allein, welche Zeilen ein Bericht hergibt. */ class BerichtExport { - private const BOM = "\u{FEFF}"; - /** @param array $bericht Rückgabe von Auswertung::bericht() */ public static function csv(array $bericht): string { @@ -36,12 +32,7 @@ class BerichtExport [], ]; - $text = ''; - foreach (array_merge($kopf, $zeilen) as $zeile) { - $text .= self::zeile($zeile); - } - - return self::BOM . $text; + return CsvTabelle::schreibe(array_merge($kopf, $zeilen)); } /** Dateiname ohne Pfad, aus Berichtsname und Zeitraum. */ @@ -182,29 +173,9 @@ class BerichtExport return $zeilen; } - /** @param list $felder */ - private static function zeile(array $felder): string - { - $maskiert = array_map( - // Nur maskieren, wo es sein muss: Ein Feld in Anführungszeichen - // liest sich schlechter, wenn es keinen Trenner enthält. - static function (string $feld): string { - if (preg_match('/[;"\r\n]/', $feld) !== 1) { - return $feld; - } - return '"' . str_replace('"', '""', $feld) . '"'; - }, - array_map('strval', $felder) - ); - - // CRLF: Das erwarten Tabellenkalkulationen unter Windows, und der Rest - // kommt damit ebenfalls zurecht. - return implode(';', $maskiert) . "\r\n"; - } - private static function zahl(float $wert, int $stellen = 2): string { - return number_format($wert, $stellen, ',', ''); + return CsvTabelle::zahl($wert, $stellen); } /** Umlaute im Dateinamen ausschreiben, statt sie zu Bindestrichen zu zermahlen. */ diff --git a/src/Services/BuchungsExport.php b/src/Services/BuchungsExport.php new file mode 100644 index 0000000..6e59f52 --- /dev/null +++ b/src/Services/BuchungsExport.php @@ -0,0 +1,551 @@ + $filter + * @return array{name: string, typ: string, inhalt: string, anzahl: int} + */ + public static function datei(string $format, array $filter): array + { + if (!Dateiformat::gueltig($format)) { + throw new \InvalidArgumentException('Unbekanntes Exportformat: ' . $format); + } + + $buchungen = self::buchungen($filter); + + return [ + 'name' => self::dateiname($format, $filter), + 'typ' => $format === 'qif' ? 'application/qif' : 'text/csv; charset=utf-8', + 'inhalt' => $format === 'qif' ? self::qif($buchungen, $filter) : self::csv($buchungen), + 'anzahl' => count($buchungen), + ]; + } + + /** + * Wie viele Buchungen trifft der Filter? Für die Anzeige im Dialog – die + * Buchungen selbst zu laden, nur um sie zu zählen, wäre bei einem Bestand + * über mehrere Jahre die ganze Datei umsonst. + * + * @param array $filter + */ + public static function anzahl(array $filter): int + { + [$wo, $params] = self::bedingung($filter); + + return (int)Database::fetchValue( + 'SELECT COUNT(*) FROM buchungen b WHERE ' . $wo, + $params, + 0 + ); + } + + /** + * Die gefilterten Buchungen samt Splitzeilen und Adressen. + * + * @param array $filter von, bis, konten[], kategorien[], klassen[] + * @return list> + */ + public static function buchungen(array $filter): array + { + [$wo, $params] = self::bedingung($filter); + + $buchungen = Database::fetchAll( + 'SELECT b.*, k.name AS kontoname, k.kontoart, + g.name AS gegenkontoname, kat.pfad AS kategoriepfad, kl.name AS klassenname + FROM buchungen b + JOIN konten k ON k.id = b.konto_id + LEFT JOIN konten g ON g.id = b.gegenkonto_id + LEFT JOIN kategorien kat ON kat.id = b.kategorie_id + LEFT JOIN klassen kl ON kl.id = b.klasse_id + WHERE ' . $wo . ' + ORDER BY k.name, b.datum, b.id', + $params + ); + + if ($buchungen === []) { + return []; + } + + $ids = array_map(static fn(array $b): int => (int)$b['id'], $buchungen); + $splits = self::splits($ids); + $adressen = self::adressen($ids); + + foreach ($buchungen as $nr => $buchung) { + $id = (int)$buchung['id']; + $buchungen[$nr]['splits'] = $splits[$id] ?? []; + $buchungen[$nr]['adressen'] = $adressen[$id] ?? []; + } + + return $buchungen; + } + + /** + * Die WHERE-Klausel des Filters samt Parametern – einmal geschrieben, + * damit Zählen und Laden nie auseinanderlaufen. + * + * @param array $filter + * @return array{0: string, 1: list} + */ + private static function bedingung(array $filter): array + { + $wo = ['b.benutzer_id = ?']; + $params = [Mandant::id()]; + + $von = self::datum($filter['von'] ?? null); + $bis = self::datum($filter['bis'] ?? null); + + if ($von !== null) { + $wo[] = 'b.datum >= ?'; + $params[] = $von; + } + if ($bis !== null) { + $wo[] = 'b.datum <= ?'; + $params[] = $bis; + } + + $konten = self::ids($filter['konten'] ?? []); + if ($konten !== []) { + $platzhalter = self::platzhalter($konten); + $wo[] = '(b.konto_id IN (' . $platzhalter . ') OR b.gegenkonto_id IN (' . $platzhalter . ') + OR EXISTS (SELECT 1 FROM buchung_splits s WHERE s.buchung_id = b.id + AND s.gegenkonto_id IN (' . $platzhalter . ')))'; + $params = array_merge($params, $konten, $konten, $konten); + } + + // Kategorie und Klasse können in der Buchung oder in einer ihrer + // Splitzeilen stehen – beides zählt, sonst fiele jede Splitbuchung aus + // dem Filter, obwohl sie die gesuchte Kategorie führt. + foreach ([['kategorien', 'kategorie_id'], ['klassen', 'klasse_id']] as [$schluessel, $spalte]) { + $ids = self::ids($filter[$schluessel] ?? []); + if ($ids === []) { + continue; + } + $platzhalter = self::platzhalter($ids); + $wo[] = '(b.' . $spalte . ' IN (' . $platzhalter . ') + OR EXISTS (SELECT 1 FROM buchung_splits s WHERE s.buchung_id = b.id + AND s.' . $spalte . ' IN (' . $platzhalter . ')))'; + $params = array_merge($params, $ids, $ids); + } + + return [implode(' AND ', $wo), $params]; + } + + /** + * @param list> $buchungen + */ + public static function csv(array $buchungen): string + { + $zeilen = [self::SPALTEN]; + + foreach ($buchungen as $buchung) { + $zeilen[] = [ + self::ART_BUCHUNG, + self::datumDeutsch((string)$buchung['datum']), + self::datumDeutsch((string)($buchung['valuta'] ?? '')), + (string)$buchung['kontoname'], + (string)$buchung['nummer'], + (string)$buchung['empfaenger'], + (string)($buchung['buchungstext'] ?? ''), + (string)($buchung['kategoriepfad'] ?? ''), + (string)($buchung['klassenname'] ?? ''), + (string)($buchung['gegenkontoname'] ?? ''), + CsvTabelle::zahl((float)$buchung['betrag']), + (int)$buchung['abgeglichen'] === 1 ? 'ja' : 'nein', + ]; + + // Die Splitzeilen folgen unmittelbar auf ihre Buchung und lassen + // alles leer, was schon oben steht. So bleibt die Zuordnung auch in + // einer Tabellenkalkulation erkennbar, ohne eine zweite Datei. + foreach ($buchung['splits'] as $split) { + $zeilen[] = [ + self::ART_SPLIT, + '', '', '', '', '', + (string)$split['notiz'], + (string)($split['kategoriepfad'] ?? ''), + (string)($split['klassenname'] ?? ''), + (string)($split['gegenkontoname'] ?? ''), + CsvTabelle::zahl((float)$split['betrag']), + '', + ]; + } + } + + return CsvTabelle::schreibe($zeilen); + } + + /** + * QIF im Dialekt des Lexware FinanzManagers – dieselbe Datei, die der + * eigene Import wieder liest. + * + * Stammdaten kommen vollständig mit (Klassen und Kategorien), die Konten + * dagegen nur, soweit der Filter sie zulässt: Ohne die Stammdaten ließe + * sich die Datei auf einer leeren Installation nicht einspielen, mit + * fremden Konten wäre sie kein Auszug mehr. + * + * @param list> $buchungen + * @param array $filter + */ + public static function qif(array $buchungen, array $filter = []): string + { + $text = "!Option:DMY\n"; + + $text .= "!Type:Class\n"; + foreach (Klasse::alle() as $klasse) { + $text .= 'N' . $klasse['name'] . "\n"; + if (($klasse['beschreibung'] ?? '') !== '') { + $text .= 'D' . $klasse['beschreibung'] . "\n"; + } + $text .= "^\n"; + } + + $text .= "!Type:Cat\n"; + foreach (Kategorie::alle() as $kategorie) { + $text .= 'N' . $kategorie['pfad'] . "\n"; + if (($kategorie['beschreibung'] ?? '') !== '') { + $text .= 'D' . $kategorie['beschreibung'] . "\n"; + } + $text .= ($kategorie['art'] === 'einnahme' ? 'I' : 'E') . "\n"; + if ((int)$kategorie['steuerrelevant'] === 1) { + $text .= "T\n"; + } + $text .= "^\n"; + } + + $konten = self::kontenDesExports($buchungen, $filter); + + // Erst die Kontenliste, dann je Konto der Abschnitt mit den Buchungen – + // dieselbe Reihenfolge wie im Export des FinanzManagers. Der Parser + // braucht sie so: Der Kontobezug einer Buchung steckt allein darin, + // welcher !Account-Abschnitt zuletzt kam. + $text .= "!Option:AutoSwitch\n!Account\n"; + foreach ($konten as $konto) { + $text .= self::kontoblock($konto); + } + $text .= "!Clear:AutoSwitch\n"; + + $jeKonto = []; + foreach ($buchungen as $buchung) { + $jeKonto[(string)$buchung['kontoname']][] = $buchung; + } + + foreach ($konten as $konto) { + $eigene = $jeKonto[(string)$konto['name']] ?? []; + if ($eigene === []) { + continue; + } + + $text .= "!Account\n" . self::kontoblock($konto); + $text .= '!Type:' . self::qifTyp((string)$konto['kontoart']) . "\n"; + + foreach ($eigene as $buchung) { + $text .= self::buchungsblock($buchung); + } + } + + return $text; + } + + /** Dateiname ohne Pfad, aus Format und Zeitraum. */ + public static function dateiname(string $format, array $filter): string + { + $von = self::datum($filter['von'] ?? null); + $bis = self::datum($filter['bis'] ?? null); + + $teile = ['buchungen']; + if ($von !== null) { + $teile[] = $von; + } + if ($bis !== null) { + $teile[] = 'bis-' . $bis; + } + if ($von === null && $bis === null) { + $teile[] = date('Y-m-d'); + } + + return implode('-', $teile) . '.' . $format; + } + + // ── QIF-Bausteine ──────────────────────────────────────────────────────── + + /** @param array $konto */ + private static function kontoblock(array $konto): string + { + $block = 'N' . $konto['name'] . "\n" + . 'T' . self::qifTyp((string)$konto['kontoart']) . "\n"; + + // Der FinanzManager legt die Kontonummer in die Beschreibung; der + // eigene Import liest sie dort wieder heraus. + if (($konto['kontonummer'] ?? '') !== '') { + $block .= 'DKontonr. ' . $konto['kontonummer'] . "\n"; + } elseif (($konto['beschreibung'] ?? '') !== '') { + $block .= 'D' . $konto['beschreibung'] . "\n"; + } + + if (($konto['kreditlimit'] ?? null) !== null) { + $block .= 'L' . self::zahlQif((float)$konto['kreditlimit']) . "\n"; + } + + return $block . "^\n"; + } + + /** @param array $buchung */ + private static function buchungsblock(array $buchung): string + { + $block = 'D' . self::datumDeutsch((string)$buchung['datum']) . "\n"; + + if (($buchung['valuta'] ?? null) !== null) { + $block .= 'V' . self::datumDeutsch((string)$buchung['valuta']) . "\n"; + } + + $betrag = self::zahlQif((float)$buchung['betrag']); + $block .= 'T' . $betrag . "\n" . 'U' . $betrag . "\n"; + + if ((string)$buchung['empfaenger'] !== '') { + $block .= 'P' . $buchung['empfaenger'] . "\n"; + } + if ((string)($buchung['buchungstext'] ?? '') !== '') { + $block .= 'M' . self::einzeilig((string)$buchung['buchungstext']) . "\n"; + } + if ((string)$buchung['nummer'] !== '') { + $block .= 'N' . $buchung['nummer'] . "\n"; + } + if ((int)$buchung['abgeglichen'] === 1) { + $block .= "C*\n"; + } + + $ziel = self::zielangabe( + (string)($buchung['gegenkontoname'] ?? ''), + (string)($buchung['kategoriepfad'] ?? ''), + (string)($buchung['klassenname'] ?? '') + ); + if ($ziel !== '') { + $block .= 'L' . $ziel . "\n"; + } + + foreach ($buchung['splits'] as $split) { + $block .= 'S' . self::zielangabe( + (string)($split['gegenkontoname'] ?? ''), + (string)($split['kategoriepfad'] ?? ''), + (string)($split['klassenname'] ?? '') + ) . "\n"; + $block .= 'E' . self::einzeilig((string)$split['notiz']) . "\n"; + $block .= '$' . self::zahlQif((float)$split['betrag']) . "\n"; + } + + foreach ($buchung['adressen'] as $adresse) { + $block .= 'A' . $adresse . "\n"; + } + + return $block . "^\n"; + } + + /** + * Die L- oder S-Zeile: entweder ein Gegenkonto in eckigen Klammern oder ein + * Kategoriepfad – die Klasse hängt mit Schrägstrich daran. + */ + private static function zielangabe(string $gegenkonto, string $kategorie, string $klasse): string + { + $ziel = $gegenkonto !== '' ? '[' . $gegenkonto . ']' : $kategorie; + + if ($klasse === '') { + return $ziel; + } + + return $ziel . '/' . $klasse; + } + + /** + * Kontoart → QIF-Typ, die Umkehrung von Konto::QIF_ARTEN. Zwei Arten stehen + * dort doppelt („Oth E“/„Oth F“ und „Port“/„Invst“); genommen wird der + * erste Treffer, damit die Zuordnung eindeutig bleibt. + */ + private static function qifTyp(string $kontoart): string + { + $typ = array_search($kontoart, Konto::QIF_ARTEN, true); + + return $typ === false ? 'Bank' : $typ; + } + + /** Beträge stehen im QIF im US-Format, ohne Tausendertrenner. */ + private static function zahlQif(float $wert): string + { + return number_format($wert, 2, '.', ''); + } + + /** + * Ein Zeilenumbruch im Buchungstext beendete im QIF den Eintrag. Er wird + * durch ein Leerzeichen ersetzt – der Text bleibt lesbar, die Datei heil. + */ + private static function einzeilig(string $text): string + { + return trim((string)preg_replace('/\s*[\r\n]+\s*/', ' ', $text)); + } + + // ── Hilfen ─────────────────────────────────────────────────────────────── + + /** + * Welche Konten führt die Exportdatei? + * + * Die Buchungen liefern die Namen, und zwar von beiden Seiten: Ein Konto, + * das nur Umbuchungsziel ist, führt in der Datei keine eigene Zeile – ohne + * seinen Eintrag in der Kontenliste bezöge sich die Datei aber auf ein + * Konto, das der Import nicht kennt, und die Umbuchung ginge verloren. + * + * Ein Kontofilter zieht die gewählten Konten hinzu, damit auch ein leer + * ausgefallener Auszug die Kontenliste mitbringt. + * + * @param list> $buchungen + * @return list> + */ + private static function kontenDesExports(array $buchungen, array $filter): array + { + $gewaehlt = self::ids($filter['konten'] ?? []); + $namen = []; + + foreach ($buchungen as $buchung) { + $namen[(string)$buchung['kontoname']] = true; + + if (($buchung['gegenkontoname'] ?? null) !== null) { + $namen[(string)$buchung['gegenkontoname']] = true; + } + foreach ($buchung['splits'] as $split) { + if (($split['gegenkontoname'] ?? null) !== null) { + $namen[(string)$split['gegenkontoname']] = true; + } + } + } + + $konten = []; + foreach (Konto::alle() as $konto) { + if (isset($namen[(string)$konto['name']]) || in_array((int)$konto['id'], $gewaehlt, true)) { + $konten[] = $konto; + } + } + + return $konten; + } + + /** + * @param list $buchungIds + * @return array>> + */ + private static function splits(array $buchungIds): array + { + $zeilen = Database::fetchAll( + 'SELECT s.*, kat.pfad AS kategoriepfad, kl.name AS klassenname, g.name AS gegenkontoname + FROM buchung_splits s + LEFT JOIN kategorien kat ON kat.id = s.kategorie_id + LEFT JOIN klassen kl ON kl.id = s.klasse_id + LEFT JOIN konten g ON g.id = s.gegenkonto_id + WHERE s.buchung_id IN (' . self::platzhalter($buchungIds) . ') + ORDER BY s.buchung_id, s.sortierung, s.id', + $buchungIds + ); + + $jeBuchung = []; + foreach ($zeilen as $zeile) { + $jeBuchung[(int)$zeile['buchung_id']][] = $zeile; + } + + return $jeBuchung; + } + + /** + * @param list $buchungIds + * @return array> + */ + private static function adressen(array $buchungIds): array + { + $zeilen = Database::fetchAll( + 'SELECT buchung_id, text FROM buchungsadressen + WHERE buchung_id IN (' . self::platzhalter($buchungIds) . ') + ORDER BY buchung_id, zeile', + $buchungIds + ); + + $jeBuchung = []; + foreach ($zeilen as $zeile) { + $jeBuchung[(int)$zeile['buchung_id']][] = (string)$zeile['text']; + } + + return $jeBuchung; + } + + /** @param mixed $werte */ + private static function ids(mixed $werte): array + { + if (!is_array($werte)) { + return []; + } + + $ids = []; + foreach ($werte as $wert) { + $id = (int)$wert; + if ($id > 0) { + $ids[$id] = $id; + } + } + + return array_values($ids); + } + + private static function platzhalter(array $werte): string + { + return implode(',', array_fill(0, max(1, count($werte)), '?')); + } + + private static function datum(mixed $wert): ?string + { + $wert = trim((string)$wert); + + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert) === 1 ? $wert : null; + } + + private static function datumDeutsch(string $iso): string + { + if ($iso === '') { + return ''; + } + + $zeit = strtotime($iso); + + return $zeit === false ? '' : date('d.m.Y', $zeit); + } +} diff --git a/src/Services/CsvBuchungsImport.php b/src/Services/CsvBuchungsImport.php new file mode 100644 index 0000000..3b11eeb --- /dev/null +++ b/src/Services/CsvBuchungsImport.php @@ -0,0 +1,396 @@ +> */ + private array $saetze; + + /** @var array Kontoname → id */ + private array $konten = []; + /** @var array Kategoriepfad → id */ + private array $kategorien = []; + /** @var array Klassenname → id */ + private array $klassen = []; + /** @var array Basis-Hash → wie oft schon gesehen */ + private array $hashZaehler = []; + + /** @var list */ + private array $meldungen = []; + + public function __construct(string $inhalt) + { + $this->saetze = CsvTabelle::datensaetze($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); + } + + /** + * Was steht in der Datei? Grundlage der Vorschau, bevor überhaupt gelesen + * wird: Zeilenzahl und die Konten, die vorkommen. + * + * @return array{zeilen: int, splitzeilen: int, konten: array, spalten: list} + */ + public function ueberblick(): array + { + $konten = []; + $zeilen = 0; + $splitzeilen = 0; + + foreach ($this->saetze as $satz) { + if (self::istSplitzeile($satz)) { + $splitzeilen++; + continue; + } + $zeilen++; + $name = trim($satz['konto'] ?? ''); + if ($name !== '') { + $konten[$name] = ($konten[$name] ?? 0) + 1; + } + } + + ksort($konten, SORT_NATURAL | SORT_FLAG_CASE); + + return [ + 'zeilen' => $zeilen, + 'splitzeilen' => $splitzeilen, + 'konten' => $konten, + 'spalten' => $this->saetze === [] ? [] : array_keys($this->saetze[0]), + ]; + } + + /** + * @return array{buchungen: array, trockenlauf: bool, + * meldungen: list} + */ + public function ausfuehren(bool $trockenlauf = false): array + { + $this->meldungen = []; + $this->hashZaehler = []; + $this->ladeStammdaten(); + + $fehlend = $this->fehlendePflichtspalten(); + if ($fehlend !== []) { + throw new \RuntimeException( + 'Der Datei fehlen unverzichtbare Spalten: ' . implode(', ', $fehlend) . '.' + ); + } + + $lauf = fn(): array => $this->zeilen($trockenlauf); + + // Beim echten Lauf gilt alles oder nichts – eine halb eingelesene Datei + // ließe sich nicht wiederholen, ohne Dubletten zu riskieren. + $ergebnis = $trockenlauf ? $lauf() : Database::transaktion($lauf); + + return [ + 'buchungen' => $ergebnis, + 'trockenlauf' => $trockenlauf, + 'meldungen' => $this->meldungen, + ]; + } + + public function vorschau(): array + { + return $this->ausfuehren(true); + } + + /** + * @return array + */ + private function zeilen(bool $trockenlauf): array + { + $zaehler = ['neu' => 0, 'vorhanden' => 0, 'fehler' => 0, 'splitzeilen' => 0]; + + // Eine Buchung ist erst vollständig, wenn feststeht, dass keine + // Splitzeile mehr folgt – deshalb wird sie zurückgehalten und erst beim + // nächsten Buchungskopf (oder am Dateiende) geschrieben. + $offen = null; + $offeneZeile = 0; + $splits = []; + + $schreibe = function () use (&$offen, &$offeneZeile, &$splits, &$zaehler, $trockenlauf): void { + if ($offen === null) { + return; + } + + try { + $angelegt = $this->buchung($offen, $splits, $trockenlauf); + $angelegt === null ? $zaehler['vorhanden']++ : $zaehler['neu']++; + } catch (\Throwable $e) { + $zaehler['fehler']++; + $this->melde('fehler', 'Zeile ' . $offeneZeile . ': ' . $e->getMessage()); + } + + $offen = null; + $splits = []; + }; + + foreach ($this->saetze as $nr => $satz) { + // +2: die Kopfzeile zählt mit, und Tabellenprogramme beginnen bei 1. + $zeilennummer = $nr + 2; + + if (self::istSplitzeile($satz)) { + if ($offen === null) { + $zaehler['fehler']++; + $this->melde('fehler', 'Zeile ' . $zeilennummer . ': Splitzeile ohne Buchung davor.'); + continue; + } + $splits[] = $satz; + $zaehler['splitzeilen']++; + continue; + } + + $schreibe(); + $offen = $satz; + $offeneZeile = $zeilennummer; + } + + $schreibe(); + + return $zaehler; + } + + /** + * @param array $satz + * @param list> $splitzeilen + * @return int|null Kennung der neuen Buchung, oder null wenn sie schon da war. + */ + private function buchung(array $satz, array $splitzeilen, bool $trockenlauf): ?int + { + $kontoName = trim($satz['konto'] ?? ''); + $kontoId = $this->kontoId($kontoName); + if ($kontoId === null) { + throw new \RuntimeException( + 'Unbekanntes Konto „' . $kontoName . '“ – Konten legt der Import nicht an.' + ); + } + + $datum = CsvTabelle::datum($satz['datum'] ?? ''); + if ($datum === null) { + throw new \RuntimeException('Kein lesbares Datum („' . ($satz['datum'] ?? '') . '“).'); + } + + $betrag = CsvTabelle::betrag($satz['betrag'] ?? ''); + $text = trim($satz['buchungstext'] ?? ''); + $nummer = trim($satz['nummer'] ?? ''); + + $hash = $this->eindeutigerHash($kontoId, $datum, $betrag, $nummer, $text); + if (Buchung::findeNachHash($hash) !== null) { + return null; + } + + $zuordnung = $this->zuordnung($satz, $kontoId); + + $splits = []; + foreach ($splitzeilen as $zeile) { + $splits[] = $this->zuordnung($zeile, $kontoId) + [ + 'betrag' => CsvTabelle::betrag($zeile['betrag'] ?? ''), + 'notiz' => trim($zeile['buchungstext'] ?? ''), + ]; + } + + $daten = [ + 'konto_id' => $kontoId, + 'datum' => $datum, + 'valuta' => CsvTabelle::datum($satz['valuta'] ?? ''), + 'betrag' => $betrag, + 'empfaenger' => trim($satz['empfaenger'] ?? ''), + 'buchungstext' => $text, + 'nummer' => $nummer, + 'abgeglichen' => self::jaNein($satz['abgeglichen'] ?? ''), + 'kategorie_id' => $zuordnung['kategorie_id'], + 'klasse_id' => $zuordnung['klasse_id'], + 'gegenkonto_id' => $zuordnung['gegenkonto_id'], + 'quelle' => 'csv', + 'import_hash' => $hash, + ]; + + if ($trockenlauf) { + return 0; + } + + return Buchung::anlegen($daten, $splits); + } + + /** + * Kategorie, Klasse und Gegenkonto einer Zeile. + * + * Kategorien und Klassen legt der Import bei Bedarf an – eine Exportdatei + * bringt sie oft mit, ohne dass sie hier schon bestünden. Konten dagegen + * nicht: Sie tragen Kontoart, Bankverbindung und Anfangssaldo, und nichts + * davon steht in der Zeile. + * + * @param array $satz + * @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int} + */ + private function zuordnung(array $satz, int $kontoId): array + { + $gegenkonto = trim($satz['gegenkonto'] ?? ''); + $klasseId = $this->klasseId(trim($satz['klasse'] ?? '')); + + if ($gegenkonto !== '') { + $gegenId = $this->kontoId($gegenkonto); + if ($gegenId === null) { + $this->melde('fehler', 'Umbuchung auf unbekanntes Konto „' . $gegenkonto + . '“ – ohne Gegenkonto übernommen.'); + } + + // Eine Umbuchung auf das eigene Konto höbe sich auf; der QIF-Import + // verwirft sie aus demselben Grund. + return [ + 'kategorie_id' => null, + 'klasse_id' => $klasseId, + 'gegenkonto_id' => $gegenId === $kontoId ? null : $gegenId, + ]; + } + + return [ + 'kategorie_id' => $this->kategorieId(trim($satz['kategorie'] ?? '')), + 'klasse_id' => $klasseId, + 'gegenkonto_id' => null, + ]; + } + + /** @return list */ + private function fehlendePflichtspalten(): array + { + if ($this->saetze === []) { + return []; + } + + $vorhanden = array_keys($this->saetze[0]); + + return array_values(array_filter( + self::PFLICHTSPALTEN, + static fn(string $spalte): bool => !in_array($spalte, $vorhanden, true) + )); + } + + /** @param array $satz */ + private static function istSplitzeile(array $satz): bool + { + $art = CsvTabelle::schluessel($satz['art'] ?? ''); + + return $art === 'splitzeile' || $art === 'split'; + } + + private static function jaNein(string $wert): bool + { + return in_array(CsvTabelle::schluessel($wert), ['ja', 'j', '1', 'x', 'wahr', 'true'], true); + } + + /** + * Identische Buchungen am selben Tag kommen vor. Der laufende Zähler hält + * sie auseinander und bleibt über Läufe hinweg stabil, weil die Datei immer + * dieselbe Reihenfolge hat – dieselbe Regel wie beim QIF-Import. + */ + private function eindeutigerHash(int $kontoId, string $datum, float $betrag, string $nummer, string $text): string + { + $basis = Buchung::hash($kontoId, $datum, $betrag, $nummer, $text); + $n = $this->hashZaehler[$basis] = ($this->hashZaehler[$basis] ?? 0) + 1; + + return $n === 1 ? $basis : sha1($basis . '#' . $n); + } + + private function ladeStammdaten(): void + { + $this->konten = []; + $this->kategorien = []; + $this->klassen = []; + + foreach (Konto::alle() as $konto) { + $this->konten[$konto['name']] = (int)$konto['id']; + } + foreach (Kategorie::alle() as $kategorie) { + $this->kategorien[$kategorie['pfad']] = (int)$kategorie['id']; + } + foreach (Klasse::alle() as $klasse) { + $this->klassen[$klasse['name']] = (int)$klasse['id']; + } + } + + private function kontoId(string $name): ?int + { + return $this->konten[trim($name)] ?? null; + } + + private function kategorieId(string $pfad): ?int + { + $pfad = Kategorie::normalisierePfad($pfad); + if ($pfad === '') { + return null; + } + if (isset($this->kategorien[$pfad])) { + return $this->kategorien[$pfad]; + } + + $id = Kategorie::findenOderAnlegenNachPfad($pfad); + $this->kategorien[$pfad] = $id; + $this->melde('hinweis', 'Kategorie „' . $pfad . '“ war nicht angelegt und wurde ergänzt.'); + + return $id; + } + + private function klasseId(string $name): ?int + { + $name = trim($name); + if ($name === '') { + return null; + } + if (!isset($this->klassen[$name])) { + $this->klassen[$name] = Klasse::findenOderAnlegen($name); + } + + return $this->klassen[$name]; + } + + private function melde(string $typ, string $text): void + { + // Dieselbe Meldung nicht hundertfach wiederholen. + foreach ($this->meldungen as $vorhanden) { + if ($vorhanden['text'] === $text) { + return; + } + } + $this->meldungen[] = ['typ' => $typ, 'text' => $text]; + } +} diff --git a/src/Services/CsvTabelle.php b/src/Services/CsvTabelle.php new file mode 100644 index 0000000..b272b19 --- /dev/null +++ b/src/Services/CsvTabelle.php @@ -0,0 +1,233 @@ +> $zeilen */ + public static function schreibe(array $zeilen): string + { + $text = ''; + foreach ($zeilen as $zeile) { + $text .= self::zeile($zeile); + } + + return self::BOM . $text; + } + + /** Eine einzelne Zeile, fertig maskiert und mit CRLF abgeschlossen. */ + public static function zeile(array $felder): string + { + $maskiert = array_map( + // Nur maskieren, wo es sein muss: Ein Feld in Anführungszeichen + // liest sich schlechter, wenn es keinen Trenner enthält. + static function (string $feld): string { + if (preg_match('/[;"\r\n]/', $feld) !== 1) { + return $feld; + } + return '"' . str_replace('"', '""', $feld) . '"'; + }, + array_map('strval', $felder) + ); + + // CRLF: Das erwarten Tabellenkalkulationen unter Windows, und der Rest + // kommt damit ebenfalls zurecht. + return implode(self::TRENNER, $maskiert) . "\r\n"; + } + + /** + * Zerlegt eine ganze Datei in Zeilen und Felder. + * + * Gearbeitet wird über einen Speicherstrom und `fgetcsv()`, nicht Zeile für + * Zeile: Ein maskiertes Feld darf einen Zeilenumbruch enthalten, und den + * überlebt nur ein Leser, der die Anführungszeichen mitzählt. + * + * @return list> + */ + public static function lies(string $inhalt): array + { + // Der Export ist UTF-8, eine von Hand gepflegte Datei aber oft + // ISO-8859-1. Dieselbe Prüfung wie beim QIF – zweimal konvertieren + // zerstörte die Umlaute. + $inhalt = QifParser::nachUtf8($inhalt); + + if (str_starts_with($inhalt, self::BOM)) { + $inhalt = substr($inhalt, strlen(self::BOM)); + } + + $trenner = self::trennerVon($inhalt); + + $strom = fopen('php://memory', 'r+'); + if ($strom === false) { + throw new \RuntimeException('Die CSV-Datei konnte nicht gelesen werden.'); + } + + fwrite($strom, $inhalt); + rewind($strom); + + $zeilen = []; + while (($felder = fgetcsv($strom, 0, $trenner, '"', '')) !== false) { + // Eine Leerzeile liefert [null]; sie trägt nichts bei. + if ($felder === [null]) { + continue; + } + $zeilen[] = array_map(static fn($feld): string => trim((string)$feld), $felder); + } + + fclose($strom); + + return $zeilen; + } + + /** + * Zerlegt eine Datei in Kopfzeile und Datensätze: jeder Datensatz wird über + * die Spaltenüberschriften angesprochen, nicht über seine Position. So darf + * die Datei die Spalten umsortieren oder eigene ergänzen. + * + * Die Überschriften werden kleingeschrieben und von Akzenten befreit + * verglichen – „Empfänger“, „empfaenger“ und „EMPFÄNGER“ meinen dasselbe. + * + * @return list> + */ + public static function datensaetze(string $inhalt): array + { + $zeilen = self::lies($inhalt); + if ($zeilen === []) { + return []; + } + + $kopf = array_map(self::schluessel(...), array_shift($zeilen)); + + $saetze = []; + foreach ($zeilen as $zeile) { + // Eine Zeile, in der nichts steht, ist keine – Tabellenprogramme + // hängen gern eine ans Ende. + if (implode('', $zeile) === '') { + continue; + } + + $satz = []; + foreach ($kopf as $nr => $name) { + if ($name !== '') { + $satz[$name] = $zeile[$nr] ?? ''; + } + } + $saetze[] = $satz; + } + + return $saetze; + } + + /** Spaltenname → Schlüssel: klein, ohne Umlaute, ohne Sonderzeichen. */ + public static function schluessel(string $name): string + { + $name = strtr(mb_strtolower(trim($name)), [ + 'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss', + ]); + + return preg_replace('/[^a-z0-9]/', '', $name) ?? ''; + } + + /** Zahl im deutschen Format, ohne Tausenderpunkt. */ + public static function zahl(float $wert, int $stellen = 2): string + { + return number_format($wert, $stellen, ',', ''); + } + + /** + * Betrag aus einem Feld – deutsch („1.234,56“) wie englisch („1,234.56“). + * Maßgeblich ist, welches Zeichen zuletzt steht: Es trennt die + * Nachkommastellen, das andere ist Tausendertrenner. + */ + public static function betrag(string $wert): float + { + $wert = str_replace(["\u{00A0}", ' ', '€', "'"], '', trim($wert)); + if ($wert === '') { + return 0.0; + } + + $komma = strrpos($wert, ','); + $punkt = strrpos($wert, '.'); + + if ($komma !== false && ($punkt === false || $komma > $punkt)) { + $wert = str_replace(',', '.', str_replace('.', '', $wert)); + } else { + $wert = str_replace(',', '', $wert); + } + + return (float)$wert; + } + + /** + * Datum aus einem Feld nach ISO. Angenommen werden „TT.MM.JJJJ“ (so + * schreibt es der Export) und „JJJJ-MM-TT“ (so kommt es aus fremden + * Programmen). Zweistellige Jahre ab 70 gelten als 19xx. + */ + public static function datum(string $wert): ?string + { + $wert = trim($wert); + if ($wert === '') { + return null; + } + + if (preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $wert, $t) === 1) { + [$jahr, $monat, $tag] = [(int)$t[1], (int)$t[2], (int)$t[3]]; + } elseif (preg_match('/^(\d{1,2})\.(\d{1,2})\.(\d{2,4})$/', $wert, $t) === 1) { + [$tag, $monat, $jahr] = [(int)$t[1], (int)$t[2], (int)$t[3]]; + if ($jahr < 100) { + $jahr += $jahr >= 70 ? 1900 : 2000; + } + } else { + return null; + } + + if (!checkdate($monat, $tag, $jahr)) { + return null; + } + + return sprintf('%04d-%02d-%02d', $jahr, $monat, $tag); + } + + /** + * Semikolon oder Komma? Gezählt wird auf der Kopfzeile außerhalb von + * Anführungszeichen – ein Empfängername mit Komma soll die Erkennung nicht + * kippen. + */ + private static function trennerVon(string $inhalt): string + { + $ende = strcspn($inhalt, "\r\n"); + $kopf = substr($inhalt, 0, $ende); + + $zaehle = static function (string $zeichen) use ($kopf): int { + $anzahl = 0; + $inKlammern = false; + for ($i = 0, $n = strlen($kopf); $i < $n; $i++) { + if ($kopf[$i] === '"') { + $inKlammern = !$inKlammern; + } elseif (!$inKlammern && $kopf[$i] === $zeichen) { + $anzahl++; + } + } + return $anzahl; + }; + + return $zaehle(',') > $zaehle(self::TRENNER) ? ',' : self::TRENNER; + } +} diff --git a/src/Services/Dateiformat.php b/src/Services/Dateiformat.php new file mode 100644 index 0000000..1b03326 --- /dev/null +++ b/src/Services/Dateiformat.php @@ -0,0 +1,51 @@ + 'QIF', + 'csv' => 'CSV', + ]; + + /** Kurze Beschreibung für die Formatwahl. */ + public const BESCHREIBUNGEN = [ + 'qif' => 'Das Format des Lexware FinanzManagers – Klassen, Kategorien, Konten und Buchungen in einer Datei.', + 'csv' => 'Tabelle mit Semikolon als Trenner, für Tabellenkalkulationen. Nur Buchungen, dafür überall lesbar.', + ]; + + public static function gueltig(string $format): bool + { + return isset(self::ALLE[strtolower(trim($format))]); + } + + /** Der geprüfte Schlüssel, oder null. */ + public static function pruefe(string $format): ?string + { + $format = strtolower(trim($format)); + + return isset(self::ALLE[$format]) ? $format : null; + } + + public static function name(string $format): string + { + return self::ALLE[strtolower(trim($format))] ?? strtoupper($format); + } + + /** Format zu einem Dateinamen – anhand der Endung. */ + public static function ausDateiname(string $name): ?string + { + return self::pruefe(pathinfo($name, PATHINFO_EXTENSION)); + } +} diff --git a/src/Services/Housekeeping.php b/src/Services/Housekeeping.php new file mode 100644 index 0000000..44cb6ea --- /dev/null +++ b/src/Services/Housekeeping.php @@ -0,0 +1,169 @@ + Anzeigename → absoluter Pfad + */ + public static function verzeichnisse(): array + { + $wurzel = dirname(__DIR__, 2); + $import = trim((string)Einstellung::get('import_qif_verzeichnis', 'data/import'), '/'); + + return [ + $import => $wurzel . '/' . $import, + 'data/tmp' => $wurzel . '/data/tmp', + ]; + } + + /** Verzeichnis für Arbeitsdateien; wird bei Bedarf angelegt. */ + public static function tempVerzeichnis(): string + { + $pfad = dirname(__DIR__, 2) . '/data/tmp'; + if (!is_dir($pfad)) { + mkdir($pfad, 0o775, true); + } + + return $pfad; + } + + /** Lief das Aufräumen heute schon? */ + public static function faellig(?string $heute = null): bool + { + if (self::tage() === 0) { + return false; + } + + $heute = $heute ?? date('Y-m-d'); + $letzter = (string)Einstellung::get(self::EINSTELLUNG_LAUF, ''); + + return substr($letzter, 0, 10) !== $heute; + } + + /** + * Beiläufiger Lauf: höchstens einmal am Tag, und ohne dass ein Fehler die + * aufrufende Seite mitreißt – wer den Import öffnet, will importieren, nicht + * eine Fehlermeldung übers Aufräumen lesen. + * + * @return array|null Das Ergebnis, oder null wenn nichts anstand. + */ + public static function beiGelegenheit(): ?array + { + if (!self::faellig()) { + return null; + } + + try { + return self::ausfuehren(); + } catch (\Throwable $e) { + DebugLog::schreibe('housekeeping', 'Aufräumen fehlgeschlagen: ' . $e->getMessage()); + return null; + } + } + + /** + * @return array{tage: int, geprueft: int, geloescht: int, bytes: int, + * dateien: list, trockenlauf: bool} + */ + public static function ausfuehren(bool $trockenlauf = false): array + { + $tage = self::tage(); + $grenze = time() - $tage * 86400; + $ergebnis = [ + 'tage' => $tage, + 'geprueft' => 0, + 'geloescht' => 0, + 'bytes' => 0, + 'dateien' => [], + 'trockenlauf' => $trockenlauf, + ]; + + if ($tage === 0) { + return $ergebnis; + } + + foreach (self::verzeichnisse() as $name => $pfad) { + $echt = realpath($pfad); + if ($echt === false || !is_dir($echt)) { + continue; + } + + foreach (scandir($echt) ?: [] as $eintrag) { + if ($eintrag === '.' || $eintrag === '..') { + continue; + } + + $datei = $echt . '/' . $eintrag; + + // Nur gewöhnliche Dateien direkt im Verzeichnis: ein Symlink + // zeigte womöglich aus dem überwachten Bereich heraus, und ein + // Unterverzeichnis hat hier niemand angelegt. + if (is_link($datei) || !is_file($datei)) { + continue; + } + + $ergebnis['geprueft']++; + + $alter = filemtime($datei); + if ($alter === false || $alter > $grenze) { + continue; + } + + $groesse = (int)filesize($datei); + if (!$trockenlauf && !@unlink($datei)) { + continue; + } + + $ergebnis['geloescht']++; + $ergebnis['bytes'] += $groesse; + $ergebnis['dateien'][] = $name . '/' . $eintrag; + } + } + + if (!$trockenlauf) { + Einstellung::set(self::EINSTELLUNG_LAUF, date('Y-m-d H:i:s'), 'import'); + } + + return $ergebnis; + } +} diff --git a/src/Views/einstellungen/index.php b/src/Views/einstellungen/index.php index 2b6be9d..2910b8a 100644 --- a/src/Views/einstellungen/index.php +++ b/src/Views/einstellungen/index.php @@ -2,6 +2,7 @@ use Finanzmanager\Models\Einstellung; use Finanzmanager\Services\Cockpit; +use Finanzmanager\Services\Housekeeping; use Finanzmanager\Services\Kursabruf; use Finanzmanager\Services\Wertpapierkosten; use Finanzmanager\View; @@ -15,6 +16,10 @@ use Finanzmanager\View; $wert = static fn(string $k, string $standard = ''): string => (string)($werte[$k] ?? $standard); +// Der Zeitstempel des letzten Aufräumens ist kein Feld zum Bearbeiten, sondern +// eine Auskunft – er steht deshalb im Hinweistext unter der Frist. +$letzterLauf = $wert(Housekeeping::EINSTELLUNG_LAUF); + include __DIR__ . '/../layout/kopf.php'; ?> @@ -83,9 +88,9 @@ include __DIR__ . '/../layout/kopf.php';

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

@@ -98,8 +103,22 @@ include __DIR__ . '/../layout/kopf.php'; FinanzManagers und bleiben draußen.

+ + +

+ Das Housekeeping räumt ältere Dateien aus dem Importverzeichnis und aus + data/tmp weg – höchstens einmal am Tag, angestoßen beim Öffnen von + Import und Export. 0 schaltet es ab. Exportdateien entstehen im + Speicher und werden ohnehin nicht abgelegt. + + Zuletzt gelaufen: . + +

+ diff --git a/src/Views/verwaltung/export.php b/src/Views/verwaltung/export.php new file mode 100644 index 0000000..2a0f41b --- /dev/null +++ b/src/Views/verwaltung/export.php @@ -0,0 +1,148 @@ +, kategorien: list, klassen: list} $filter */ +/** @var list> $konten */ +/** @var list> $kategorien */ +/** @var list> $klassen */ +/** @var int $anzahl */ +/** @var string $csrfToken */ + +// Kein Häkchen heißt „alles“ – deshalb ist angehakt nur, was ausdrücklich +// gewählt wurde. Alles vorzuhaken sähe gefälliger aus, schickte aber bei jedem +// Klick die Kennung jeder Kategorie mit. +$gewaehlt = static fn(array $ids, int $id): bool => in_array($id, $ids, true); + +include __DIR__ . '/../layout/kopf.php'; +?> + + + +
+
+

Buchungen als ausgeben

+ Buchungen +
+ +

+ Ohne Häkchen zählt alles. Eine Umbuchung steht nur einmal in der + Datei, beim erfassenden Konto – sie kommt deshalb auch dann mit, wenn allein das + Gegenkonto gewählt ist. Bei Splitbuchungen zählt die Kategorie und + die Klasse jeder einzelnen Zeile. +

+ +
+ + + +
+ + +
+ +

Konten

+ +

Noch keine Konten angelegt.

+ +
+ + + +
+ + +

Kategorien

+ +

Noch keine Kategorien angelegt.

+ +
+ + + +
+

+ Gewählt wird jede Kategorie für sich – eine Oberkategorie zieht ihre + Unterkategorien nicht mit. +

+ + +

Klassen

+ +

Noch keine Klassen angelegt.

+ +
+ + + +
+ + +
+ + +
+
+
+ +
+

Was in der Datei steht

+ + +

+ Das QIF folgt dem Dialekt des Lexware FinanzManagers: erst die Klassen, dann die + Kategorien, dann die Konten, danach je Konto dessen Buchungen. Klassen und + Kategorien kommen vollständig mit – ohne sie ließe sich die Datei auf einer leeren + Installation nicht einspielen. Die Konten dagegen nur, soweit der Filter sie + zulässt. +

+

+ Wertpapiergeschäfte stehen nicht darin; sie hängen am Depot und werden über die + Wertpapierverwaltung gepflegt. +

+ +

+ Eine Tabelle mit Semikolon als Trenner, Komma als Dezimalzeichen und einem BOM + vorweg – so öffnet sie Excel wie LibreOffice per Doppelklick, ohne dass die + Umlaute zerfallen. Splitzeilen folgen unmittelbar auf ihre Buchung und tragen in + der ersten Spalte das Wort Splitzeile. +

+

+ Dieselbe Datei liest der CSV-Import + wieder ein. +

+ + +

+ Die Datei entsteht erst beim Herunterladen und wird nirgends abgelegt: Ein Auszug der + eigenen Finanzen soll nicht im Dateisystem des Servers liegen bleiben. +

+
+ + diff --git a/src/Views/verwaltung/formatwahl.php b/src/Views/verwaltung/formatwahl.php new file mode 100644 index 0000000..d296824 --- /dev/null +++ b/src/Views/verwaltung/formatwahl.php @@ -0,0 +1,32 @@ + + + + +
+

: Format wählen

+ +

+ +
+ $name): ?> + + + + + +
+
+ + diff --git a/src/Views/verwaltung/import.php b/src/Views/verwaltung/import.php index a764cae..6d80c6e 100644 --- a/src/Views/verwaltung/import.php +++ b/src/Views/verwaltung/import.php @@ -1,248 +1,55 @@ $dateien */ /** @var string $verzeichnis */ /** @var array|null $ergebnis */ /** @var array|null $buchungen */ /** @var array|null $vorlagen */ +/** @var array|null $ueberblick */ /** @var string $stichtag */ /** @var string $bis */ /** @var string $datei */ /** @var array $dateikonten */ /** @var list $auswahl */ +/** @var int $aufbewahrung */ /** @var string $csrfToken */ -$bereiche = [ - 'klassen' => 'Klassen', - 'kategorien' => 'Kategorien', - 'konten' => 'Konten', -]; +$endung = '.' . $format; include __DIR__ . '/../layout/kopf.php'; ?> - -
-
-

- -
- - -

- Nichts wurde gespeichert. Die Zahlen zeigen, was ein Import anlegen würde. -

- - - - - - - - - - - - - $beschriftung): ?> - - - - - - - - - - - - -
BereichSchon vorhandenFehler
- -
Kontengruppen - nicht im QIF-Export enthalten – hier zuordnen -
- -

- Der FinanzManager exportiert je Konto nur Name, Kontoart, Beschreibung und - Kreditlimit. Die Zuordnung zu einer Kontengruppe steht weder im QIF noch in der - QSD-Datei; sie wird deshalb einmalig in der Verwaltung nachgetragen. -

- - -

Buchungen

- - - - - - - - - - - - - - - - - - - - - - - 0): ?> - - - - - - - - - - -
schon vorhanden
- vor dem Stichtag – in den Anfangssaldo von - Konten verrechnet -
nach dem Enddatum – vorausberechnet, verworfen
darunter Wertpapiergeschäfte (Depot- und Geldbuchung)
gehört zu keinem der gewählten Konten
Fehler - -
-

- Zeitraum bis . - Alles davor ist nicht verloren: es steckt im Anfangssaldo der Konten. - - Übernommen wurden nur die Buchungen von - – einschließlich - der Umbuchungen, die auf einem anderen Konto erfasst wurden und hierher zeigen. - Die Anfangssalden der übrigen Konten bleiben unangetastet. - -

- - - -

Buchungsvorlagen

- - - - - - - - - - - - - - - -
bereits vorhanden
Fehler - -
-

- Der FinanzManager merkt sich jede erfasste Zahlung als Vorlage – im - Beispielexport sind das mehrere hundert. Sie stehen danach unter - Buchungsvorlagen. -

- - - -

Inhalt der Datei

-

- $anzahl) { - $teile[] = View::h((string)$typ) . ': ' . (int)$anzahl; - } - echo implode(' · ', $teile); ?> -

-

- Buchungsvorlagen (Memorized) folgen in Phase 7, Wertpapierbestände - in Phase 6. -

- - - -

Protokoll

-
    - -
  • - -
- - - -
- - - - - - -

Konten

-

- Zur Wahl stehen die Konten, die in dieser Datei vorkommen. Ohne Häkchen - zählt die ganze Datei. Eine Umbuchung kommt mit, sobald eines der beiden - Konten gewählt ist – sie steht im Export nur einmal, beim erfassenden - Konto. Die Zahl nennt die Einträge, die das Konto berühren. -

-
- $anzahl): ?> - - -
- - -
- - - - -
-
- - -

- Die Anfangssalden wurden aus dem Zeitraum vor dem Stichtag berechnet. In der - Kontenübersicht lässt sich jeder Wert nachträglich ändern – ebenso das - Kennzeichen, ob ein Konto zur Summe der Kontostände zählt. -

- -
+ + + +
-

Exportdatei hochladen

+

-Datei hochladen

- - + + +

Die Datei wird unter abgelegt und bleibt dort – so lässt sich derselbe Export erst prüfen und dann übernehmen. + 0): ?> + Nach Tagen räumt das Housekeeping sie weg; die Frist steht + in den Einstellungen. +

@@ -251,24 +58,12 @@ include __DIR__ . '/../layout/kopf.php';
-

Vorhandene Dateien

- -

- Übernommen werden Buchungen ab bis - . Alles davor fließt in den Anfangssaldo der - Konten, alles danach gilt als Vorausberechnung des FinanzManagers und bleibt draußen. - Der Stichtag steht in den Einstellungen. -

- -

- Soll nur ein Teil der Konten übernommen werden, zuerst den Trockenlauf - starten: danach steht fest, welche Konten die Datei führt, und sie stehen einzeln zur - Wahl. Importieren übernimmt die ganze Datei. -

+

Vorhandene -Dateien

- Noch keine Datei in . + Noch keine -Datei in + .

@@ -286,8 +81,10 @@ include __DIR__ . '/../layout/kopf.php'; - - + + + + - - + + + + $zahlen */ @@ -44,13 +45,16 @@ include __DIR__ . '/../layout/kopf.php';
-

QIF-Import

+

Import/Export

- Klassen, Kategorien und Konten aus einem Export des Lexware FinanzManagers - übernehmen – erst im Trockenlauf prüfen, dann übernehmen. + Stammdaten und Buchungen aus dem Lexware FinanzManager übernehmen oder den + eigenen Bestand herausgeben – wahlweise als QIF oder als CSV.

@@ -94,4 +98,25 @@ include __DIR__ . '/../layout/kopf.php'; + + +

Format wählen

+
+ $name): ?> + + + + + +
+ + + +
+ diff --git a/src/Views/verwaltung/teil-import-csv.php b/src/Views/verwaltung/teil-import-csv.php new file mode 100644 index 0000000..55493fd --- /dev/null +++ b/src/Views/verwaltung/teil-import-csv.php @@ -0,0 +1,127 @@ +|null $buchungen */ +/** @var array|null $ueberblick */ +/** @var string $datei */ +?> + + +
+
+

+ +
+ + +

+ Nichts wurde gespeichert. Die Zahlen zeigen, was ein Import anlegen würde. +

+ + +
+ + + + + + + + + + + + + + + + + + +
schon vorhanden – am Fingerabdruck erkannt
darunter Splitzeilen
Fehler + +
+ + +

Konten in der Datei

+

+ $anzahl) { + $teile[] = View::h((string)$name) . ': ' . (int)$anzahl; + } + echo implode(' · ', $teile); ?> +

+ + + +

Protokoll

+
    + +
  • + +
+ + + +
+ + +
+

So muss die CSV aussehen

+ +

+ Semikolon als Trenner, Komma als Dezimalzeichen, Datum als TT.MM.JJJJ – + genau so, wie der Export sie schreibt. Eine Datei aus einem anderen Programm darf die + Spalten umsortieren: Zugeordnet wird über die Kopfzeile, nicht über die Position. + Unverzichtbar sind Datum, Konto und + Betrag. +

+ + + + + + + + + + + + + + + + + + + + + + + + +
Buchung18.03.2026Girokonto042StadtwerkeAbschlag MärzWohnen:StromHaus 1-84,00ja
Buchung20.03.2026GirokontoEigenübertragRücklageTagesgeld-500,00nein
SplitzeileAnteil WasserWohnen:WasserHaus 1-30,00
+ +

+ Eine Umbuchung trägt statt der Kategorie ein Gegenkonto und steht – + wie im Datenmodell – nur einmal in der Datei, beim erfassenden Konto. + Splitzeilen folgen unmittelbar auf ihre Buchung; ihre Beträge müssen + deren Summe ergeben. Konten legt der Import nicht an, Kategorien und Klassen schon. +

+ +

+ Was schon vorhanden ist, wird übersprungen: Gleiches Konto, Datum, Betrag, Nummer und + Buchungstext gelten als dieselbe Buchung. Ein zweiter Lauf derselben Datei ändert also + nichts. +

+
diff --git a/src/Views/verwaltung/teil-import-qif.php b/src/Views/verwaltung/teil-import-qif.php new file mode 100644 index 0000000..16a5d82 --- /dev/null +++ b/src/Views/verwaltung/teil-import-qif.php @@ -0,0 +1,244 @@ +|null $ergebnis */ +/** @var array|null $buchungen */ +/** @var array|null $vorlagen */ +/** @var string $stichtag */ +/** @var string $bis */ +/** @var string $datei */ +/** @var array $dateikonten */ +/** @var list $auswahl */ +/** @var string $csrfToken */ + +$bereiche = [ + 'klassen' => 'Klassen', + 'kategorien' => 'Kategorien', + 'konten' => 'Konten', +]; +?> + + +
+
+

+ +
+ + +

+ Nichts wurde gespeichert. Die Zahlen zeigen, was ein Import anlegen würde. +

+ + + + + + + + + + + + + $beschriftung): ?> + + + + + + + + + + + + +
BereichSchon vorhandenFehler
+ +
Kontengruppen + nicht im QIF-Export enthalten – hier zuordnen +
+ +

+ Der FinanzManager exportiert je Konto nur Name, Kontoart, Beschreibung und + Kreditlimit. Die Zuordnung zu einer Kontengruppe steht weder im QIF noch in der + QSD-Datei; sie wird deshalb einmalig in der Verwaltung nachgetragen. +

+ + +

Buchungen

+ + + + + + + + + + + + + + + + + + + + + + + 0): ?> + + + + + + + + + + +
schon vorhanden
+ vor dem Stichtag – in den Anfangssaldo von + Konten verrechnet +
nach dem Enddatum – vorausberechnet, verworfen
darunter Wertpapiergeschäfte (Depot- und Geldbuchung)
gehört zu keinem der gewählten Konten
Fehler + +
+

+ Zeitraum bis . + Alles davor ist nicht verloren: es steckt im Anfangssaldo der Konten. + + Übernommen wurden nur die Buchungen von + – einschließlich + der Umbuchungen, die auf einem anderen Konto erfasst wurden und hierher zeigen. + Die Anfangssalden der übrigen Konten bleiben unangetastet. + +

+ + + +

Buchungsvorlagen

+ + + + + + + + + + + + + + + +
bereits vorhanden
Fehler + +
+

+ Der FinanzManager merkt sich jede erfasste Zahlung als Vorlage – im + Beispielexport sind das mehrere hundert. Sie stehen danach unter + Buchungsvorlagen. +

+ + + +

Inhalt der Datei

+

+ $anzahl) { + $teile[] = View::h((string)$typ) . ': ' . (int)$anzahl; + } + echo implode(' · ', $teile); ?> +

+ + + +

Protokoll

+
    + +
  • + +
+ + + +
+ + + + + + +

Konten

+

+ Zur Wahl stehen die Konten, die in dieser Datei vorkommen. Ohne Häkchen + zählt die ganze Datei. Eine Umbuchung kommt mit, sobald eines der beiden + Konten gewählt ist – sie steht im Export nur einmal, beim erfassenden + Konto. Die Zahl nennt die Einträge, die das Konto berühren. +

+
+ $anzahl): ?> + + +
+ + +
+ + + + +
+
+ + +

+ Die Anfangssalden wurden aus dem Zeitraum vor dem Stichtag berechnet. In der + Kontenübersicht lässt sich jeder Wert nachträglich ändern – ebenso das + Kennzeichen, ob ein Konto zur Summe der Kontostände zählt. +

+ +
+ + +
+

So läuft der QIF-Import

+ +

+ Übernommen werden Buchungen ab bis + . Alles davor fließt in den Anfangssaldo der + Konten, alles danach gilt als Vorausberechnung des FinanzManagers und bleibt draußen. + Der Stichtag steht in den Einstellungen. +

+ +

+ Soll nur ein Teil der Konten übernommen werden, zuerst den Trockenlauf + starten: danach steht fest, welche Konten die Datei führt, und sie stehen einzeln zur + Wahl. Importieren übernimmt die ganze Datei – Klassen, Kategorien, + Konten, Buchungen und Buchungsvorlagen in einem Zug. +

+
diff --git a/tests/24_NavigationTest.php b/tests/24_NavigationTest.php index 36e709b..41e8e09 100644 --- a/tests/24_NavigationTest.php +++ b/tests/24_NavigationTest.php @@ -161,7 +161,7 @@ class NavigationTest extends TestCase // Letzter Punkt: keine weitere Kachel hinter der von Verträge. $letzte = (int)strrpos($kacheln, '

Verträge

'); - foreach (['Kategorien', 'Klassen', 'Konten', 'QIF-Import', 'Online-Banking', 'Termine'] as $vorher) { + foreach (['Kategorien', 'Klassen', 'Konten', 'Import/Export', 'Online-Banking', 'Termine'] as $vorher) { $this->assert( $letzte > (int)strrpos($kacheln, '

' . $vorher . '

'), 'Verträge stehen hinter der Kachel ' . $vorher diff --git a/tests/29_ImportExportTest.php b/tests/29_ImportExportTest.php new file mode 100644 index 0000000..a31e0b9 --- /dev/null +++ b/tests/29_ImportExportTest.php @@ -0,0 +1,559 @@ +mandantAnlegen(); + $this->stammdaten(); + $this->buchungen(); + + $this->csvTabelleLiestUndSchreibt(); + $this->csvExportFuehrtSplitsUndUmbuchungen(); + $this->filterGreifenEinzelnUndGemeinsam(); + $this->qifExportTraegtDenDialektDesFinanzManagers(); + $this->qifRundlaufUeberEinenZweitenMandanten(); + $this->csvRundlaufUeberEinenDrittenMandanten(); + $this->csvImportErkenntDublettenUndFehler(); + $this->housekeepingRaeumtAlteDateienWeg(); + $this->dateinamenUndFormate(); + $this->exportLegtKeineDateiAb(); + $this->verwaltungFuehrtImportUndExport(); + } + + // ── Aufbau ─────────────────────────────────────────────────────────────── + + private function stammdaten(): void + { + $this->giro = Konto::anlegen([ + 'name' => 'Exportgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->tagesgeld = Konto::anlegen([ + 'name' => 'Exportrücklage', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + + $this->strom = Kategorie::findenOderAnlegenNachPfad('Wohnen:Strom'); + $this->wasser = Kategorie::findenOderAnlegenNachPfad('Wohnen:Wasser'); + + $this->haus = Klasse::findenOderAnlegen('Haus 1'); + $this->privat = Klasse::findenOderAnlegen('Privat'); + } + + private function buchungen(): void + { + // Eine gewöhnliche Buchung mit Kategorie und Klasse … + Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-03-18', 'betrag' => -84.00, + 'empfaenger' => 'Stadtwerke', 'buchungstext' => 'Abschlag März', 'nummer' => '042', + 'kategorie_id' => $this->strom, 'klasse_id' => $this->haus, 'abgeglichen' => 1, + ]); + + // … eine Umbuchung auf das zweite Konto … + Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-03-20', 'betrag' => -500.00, + 'empfaenger' => 'Eigenübertrag', 'buchungstext' => 'Rücklage', + 'gegenkonto_id' => $this->tagesgeld, + ]); + + // … und eine Splitbuchung, deren Zeilen zwei Kategorien tragen. + Buchung::anlegen( + [ + 'konto_id' => $this->giro, 'datum' => '2026-04-02', 'betrag' => -110.00, + 'empfaenger' => 'Versorger', 'buchungstext' => 'Jahresabrechnung', + ], + [ + ['kategorie_id' => $this->strom, 'klasse_id' => $this->haus, 'betrag' => -80.00, 'notiz' => 'Strom'], + ['kategorie_id' => $this->wasser, 'klasse_id' => $this->privat, 'betrag' => -30.00, 'notiz' => 'Wasser'], + ] + ); + } + + // ── Das Dateiformat selbst ─────────────────────────────────────────────── + + private function csvTabelleLiestUndSchreibt(): void + { + $text = CsvTabelle::schreibe([ + ['Feld', 'Mit;Trenner', 'Mit"Zitat'], + ['Wert', 'zwei', 'drei'], + ]); + + $this->assert(str_starts_with($text, CsvTabelle::BOM), 'Die Datei beginnt mit einem BOM'); + $this->assert(str_contains($text, '"Mit;Trenner"'), 'Ein Feld mit Trenner wird maskiert'); + $this->assert(str_contains($text, '"Mit""Zitat"'), 'Anführungszeichen werden verdoppelt'); + $this->assert(str_contains($text, "\r\n"), 'Zeilen enden mit CRLF'); + + $zeilen = CsvTabelle::lies($text); + $this->assertEquals(2, count($zeilen), 'Die geschriebene Datei liest sich wieder ein'); + $this->assertEquals('Mit;Trenner', $zeilen[0][1], 'Der maskierte Trenner überlebt den Rundlauf'); + $this->assertEquals('Mit"Zitat', $zeilen[0][2], 'Das Anführungszeichen überlebt den Rundlauf'); + + // Fremde Dateien: Komma als Trenner, Punkt als Dezimalzeichen. + $fremd = CsvTabelle::datensaetze("Datum,Konto,Betrag\r\n2026-03-18,Giro,\"1,234.56\"\r\n"); + $this->assertEquals(1, count($fremd), 'Auch eine Datei mit Komma-Trenner wird gelesen'); + $this->assertEquals('2026-03-18', $fremd[0]['datum'], 'Die Kopfzeile wird zum Schlüssel'); + + $this->assertEquals(1234.56, CsvTabelle::betrag('1,234.56'), 'Betrag im englischen Format'); + $this->assertEquals(-1234.56, CsvTabelle::betrag('-1.234,56'), 'Betrag im deutschen Format'); + $this->assertEquals(-84.0, CsvTabelle::betrag('-84,00 €'), 'Währungszeichen stört nicht'); + $this->assertEquals(0.0, CsvTabelle::betrag(''), 'Ein leeres Feld ist null'); + + $this->assertEquals('2026-03-18', CsvTabelle::datum('18.03.2026'), 'Datum als TT.MM.JJJJ'); + $this->assertEquals('2026-03-18', CsvTabelle::datum('2026-03-18'), 'Datum als ISO'); + $this->assertEquals('1998-03-18', CsvTabelle::datum('18.03.98'), 'Zweistellige Jahre ab 70 sind 19xx'); + $this->assertNull(CsvTabelle::datum('31.02.2026'), 'Ein Datum, das es nicht gibt, fällt heraus'); + $this->assertNull(CsvTabelle::datum('irgendwann'), 'Text ist kein Datum'); + } + + // ── Export ─────────────────────────────────────────────────────────────── + + private function csvExportFuehrtSplitsUndUmbuchungen(): void + { + $csv = BuchungsExport::csv(BuchungsExport::buchungen([])); + $zeilen = CsvTabelle::lies($csv); + + $this->assertEquals(BuchungsExport::SPALTEN, $zeilen[0], 'Die Kopfzeile nennt alle Spalten'); + + // Drei Buchungen, dazu zwei Splitzeilen – plus Kopfzeile. + $arten = array_count_values(array_column($zeilen, 0)); + $this->assertEquals(3, $arten[BuchungsExport::ART_BUCHUNG] ?? 0, 'Drei Buchungen im Export'); + $this->assertEquals(2, $arten[BuchungsExport::ART_SPLIT] ?? 0, 'Zwei Splitzeilen im Export'); + + $this->assert(str_contains($csv, '18.03.2026'), 'Das Datum steht deutsch in der Datei'); + $this->assert(str_contains($csv, '-84,00'), 'Der Betrag steht mit Komma in der Datei'); + $this->assert(str_contains($csv, 'Wohnen:Strom'), 'Die Kategorie steht als Pfad in der Datei'); + $this->assert(str_contains($csv, 'Exportrücklage'), 'Die Umbuchung nennt ihr Gegenkonto'); + + // Die Splitzeilen folgen unmittelbar auf ihre Buchung. + $split = null; + foreach ($zeilen as $nr => $zeile) { + if ($zeile[0] === BuchungsExport::ART_SPLIT) { + $split = $nr; + break; + } + } + $this->assertNotEmpty($split, 'Es gibt eine Splitzeile'); + $this->assertEquals( + BuchungsExport::ART_BUCHUNG, + $zeilen[$split - 1][0], + 'Über der ersten Splitzeile steht ihre Buchung' + ); + } + + private function filterGreifenEinzelnUndGemeinsam(): void + { + // Gezählt wird über COUNT, geladen über dieselbe Bedingung – geprüft + // wird deshalb beides gegeneinander. + $anzahl = function (array $filter): int { + $geladen = count(BuchungsExport::buchungen($filter)); + $this->assertEquals($geladen, BuchungsExport::anzahl($filter), 'Zählen und Laden sind sich einig'); + + return $geladen; + }; + + $this->assertEquals(3, $anzahl([]), 'Ohne Filter kommt alles mit'); + $this->assertEquals(2, $anzahl(['von' => '2026-03-19']), 'Der Zeitraum grenzt nach vorn ab'); + $this->assertEquals(2, $anzahl(['bis' => '2026-03-31']), 'Der Zeitraum grenzt nach hinten ab'); + $this->assertEquals(1, $anzahl(['von' => '2026-03-19', 'bis' => '2026-03-31']), 'Beide Grenzen zusammen'); + + // Das Tagesgeld führt selbst keine Buchung – die Umbuchung steht beim + // Girokonto. Sie muss trotzdem kommen, sonst fehlte dem Auszug die + // einzige Bewegung des Kontos. + $this->assertEquals( + 1, + $anzahl(['konten' => [$this->tagesgeld]]), + 'Der Kontofilter holt die Umbuchung von der anderen Seite' + ); + $this->assertEquals(3, $anzahl(['konten' => [$this->giro]]), 'Das erfassende Konto führt alle drei'); + + // Die Kategorie steckt bei der Splitbuchung allein in den Zeilen. + $this->assertEquals(2, $anzahl(['kategorien' => [$this->strom]]), 'Kategorie: Buchung und Splitzeile'); + $this->assertEquals(1, $anzahl(['kategorien' => [$this->wasser]]), 'Kategorie nur in einer Splitzeile'); + $this->assertEquals(2, $anzahl(['klassen' => [$this->haus]]), 'Klasse: Buchung und Splitzeile'); + $this->assertEquals(1, $anzahl(['klassen' => [$this->privat]]), 'Klasse nur in einer Splitzeile'); + + $this->assertEquals( + 1, + $anzahl(['kategorien' => [$this->strom], 'bis' => '2026-03-31']), + 'Kategorie und Zeitraum wirken zusammen' + ); + $this->assertEquals(0, $anzahl(['von' => '2027-01-01']), 'Ein leerer Zeitraum liefert nichts'); + } + + private function qifExportTraegtDenDialektDesFinanzManagers(): void + { + $qif = BuchungsExport::qif(BuchungsExport::buchungen([])); + + $this->assert(str_contains($qif, '!Type:Class'), 'Das QIF führt die Klassen'); + $this->assert(str_contains($qif, '!Type:Cat'), 'Das QIF führt die Kategorien'); + $this->assert(str_contains($qif, '!Option:AutoSwitch'), 'Das QIF führt die Kontenliste'); + $this->assert(str_contains($qif, 'T-84.00'), 'Beträge stehen im QIF im US-Format'); + $this->assert(str_contains($qif, 'D18.03.2026'), 'Das Datum folgt der Angabe !Option:DMY'); + $this->assert(str_contains($qif, 'LWohnen:Strom/Haus 1'), 'Kategorie und Klasse stehen in einer L-Zeile'); + $this->assert(str_contains($qif, 'L[Exportrücklage]'), 'Die Umbuchung nennt ihr Gegenkonto in Klammern'); + $this->assert(str_contains($qif, 'SWohnen:Wasser/Privat'), 'Die Splitzeile trägt Kategorie und Klasse'); + $this->assert(str_contains($qif, '$-30.00'), 'Die Splitzeile trägt ihren Betrag'); + + $parser = new QifParser($qif); + $konten = QifBuchungsImport::kontenDerDatei($parser); + + $this->assertEquals(3, $parser->zusammenfassung()['Bank'] ?? 0, 'Der eigene Parser findet drei Buchungen'); + $this->assert(isset($konten['Exportgiro']), 'Der Parser erkennt das Girokonto'); + $this->assert( + isset($konten['Exportrücklage']), + 'Auch das Umbuchungsziel steht in der Kontenliste des Parsers' + ); + } + + // ── Import ─────────────────────────────────────────────────────────────── + + /** + * Der Rundlauf über einen frischen Mandanten: Stammdaten und Buchungen + * müssen sich aus der Exportdatei allein wiederherstellen lassen. + */ + private function qifRundlaufUeberEinenZweitenMandanten(): void + { + $qif = BuchungsExport::qif(BuchungsExport::buchungen([])); + + $this->mandantAnlegen(); + + $stammdaten = (new QifStammdatenImport(new QifParser($qif)))->ausfuehren(); + $this->assertEquals(2, $stammdaten['konten']['neu'], 'Beide Konten entstehen aus der Datei'); + $this->assertEquals(2, $stammdaten['klassen']['neu'], 'Beide Klassen entstehen aus der Datei'); + $this->assertGreaterThan(2, $stammdaten['kategorien']['neu'], 'Der Kategoriebaum entsteht mit'); + + $lauf = ['stichtag' => '2026-01-01', 'bis' => '2026-12-31']; + + $ergebnis = (new QifBuchungsImport(new QifParser($qif), $lauf))->ausfuehren(); + $this->assertEquals(3, $ergebnis['buchungen']['neu'], 'Alle drei Buchungen kommen an'); + $this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Der Rundlauf läuft fehlerfrei'); + $this->assertEquals(3, Buchung::anzahl(), 'Drei Buchungen stehen in der Datenbank'); + + // Der zweite Lauf derselben Datei darf nichts verdoppeln. + $zweiter = (new QifBuchungsImport(new QifParser($qif), $lauf))->ausfuehren(); + $this->assertEquals(0, $zweiter['buchungen']['neu'], 'Der zweite Lauf legt nichts an'); + $this->assertEquals(3, $zweiter['buchungen']['vorhanden'], 'Alle drei gelten als vorhanden'); + + // Umbuchung und Splitbuchung müssen den Weg heil überstanden haben. + [$umbuchung, $split] = $this->umbuchungUndSplit(); + $this->assertNotEmpty($umbuchung, 'Die Umbuchung kam als Umbuchung an'); + $this->assertEquals(2, count($split['splits'] ?? []), 'Die Splitbuchung hat wieder zwei Zeilen'); + $this->assertEquals( + 'Wohnen:Wasser', + $split['splits'][1]['kategoriepfad'], + 'Die zweite Splitzeile trägt ihre Kategorie' + ); + } + + private function csvRundlaufUeberEinenDrittenMandanten(): void + { + $csv = BuchungsExport::csv(BuchungsExport::buchungen([])); + + // Ein weiterer Mandant mit gleichnamigen Konten: So lässt sich prüfen, + // dass der Import wirklich anlegt, statt nur Dubletten zu zählen. + $this->mandantAnlegen(); + Konto::anlegen(['name' => 'Exportgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1]); + Konto::anlegen(['name' => 'Exportrücklage', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1]); + + $vorschau = (new CsvBuchungsImport($csv))->vorschau(); + $this->assertEquals(3, $vorschau['buchungen']['neu'], 'Der Trockenlauf zählt drei neue Buchungen'); + $this->assertEquals(0, Buchung::anzahl(), 'Der Trockenlauf hat nichts gespeichert'); + + $ergebnis = (new CsvBuchungsImport($csv))->ausfuehren(); + $this->assertEquals(3, $ergebnis['buchungen']['neu'], 'Der echte Lauf legt drei Buchungen an'); + $this->assertEquals(2, $ergebnis['buchungen']['splitzeilen'], 'Beide Splitzeilen kommen mit'); + $this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Der Import läuft fehlerfrei'); + $this->assertEquals(3, Buchung::anzahl(), 'Drei Buchungen stehen in der Datenbank'); + + // Kategorien und Klassen bringt der Import mit, Konten nicht. + $this->assertNotEmpty(Kategorie::findeNachPfad('Wohnen:Strom'), 'Die Kategorie wurde ergänzt'); + $this->assertNotEmpty(Klasse::findeNachName('Haus 1'), 'Die Klasse wurde ergänzt'); + + $wieder = BuchungsExport::csv(BuchungsExport::buchungen([])); + $this->assertEquals( + count(CsvTabelle::lies($csv)), + count(CsvTabelle::lies($wieder)), + 'Der erneute Export hat genauso viele Zeilen wie der erste' + ); + + // Umbuchung und Splitbuchung müssen den Weg heil überstanden haben. + [$umbuchung, $split] = $this->umbuchungUndSplit(); + + $this->assertNotEmpty($umbuchung, 'Die Umbuchung kam als Umbuchung an'); + $this->assertNotEmpty($split, 'Die Splitbuchung kam mit Zeilen an'); + $this->assertEquals(2, count($split['splits'] ?? []), 'Die Splitbuchung hat wieder zwei Zeilen'); + $this->assertEquals('Wohnen:Wasser', $split['splits'][1]['kategoriepfad'], 'Die zweite Zeile trägt ihre Kategorie'); + $this->assertEquals('Privat', $split['splits'][1]['klassenname'], 'Die zweite Zeile trägt ihre Klasse'); + } + + private function csvImportErkenntDublettenUndFehler(): void + { + $csv = BuchungsExport::csv(BuchungsExport::buchungen([])); + + $zweiter = (new CsvBuchungsImport($csv))->ausfuehren(); + $this->assertEquals(0, $zweiter['buchungen']['neu'], 'Der zweite Lauf legt nichts an'); + $this->assertEquals(3, $zweiter['buchungen']['vorhanden'], 'Alle drei gelten als vorhanden'); + $this->assertEquals(3, Buchung::anzahl(), 'Die Zahl der Buchungen bleibt gleich'); + + // Ein unbekanntes Konto legt der Import nicht an – die Zeile fällt aus. + $fremd = "Art;Datum;Konto;Betrag\r\nBuchung;01.05.2026;Gibtesnicht;-1,00\r\n"; + $lauf = (new CsvBuchungsImport($fremd))->vorschau(); + $this->assertEquals(1, $lauf['buchungen']['fehler'], 'Ein unbekanntes Konto ist ein Fehler'); + $this->assertEquals(0, $lauf['buchungen']['neu'], 'Aus der Zeile wird keine Buchung'); + $this->assertNotEmpty($lauf['meldungen'], 'Das Protokoll nennt den Grund'); + + // Ohne Pflichtspalte lässt sich gar nichts einlesen. + $ohneKonto = "Art;Datum;Betrag\r\nBuchung;01.05.2026;-1,00\r\n"; + $meldung = ''; + try { + (new CsvBuchungsImport($ohneKonto))->vorschau(); + } catch (\Throwable $e) { + $meldung = $e->getMessage(); + } + $this->assert(str_contains($meldung, 'Spalten'), 'Eine fehlende Pflichtspalte bricht den Lauf ab'); + + // Eine Splitzeile ohne Buchung davor ist ein Fehler, kein stiller Verlust. + $verwaist = "Art;Datum;Konto;Betrag\r\nSplitzeile;;;-1,00\r\n"; + $lauf = (new CsvBuchungsImport($verwaist))->vorschau(); + $this->assertEquals(1, $lauf['buchungen']['fehler'], 'Eine verwaiste Splitzeile wird gemeldet'); + } + + // ── Housekeeping ───────────────────────────────────────────────────────── + + private function housekeepingRaeumtAlteDateienWeg(): void + { + // Ein eigenes Verzeichnis unterhalb des Importverzeichnisses: Housekeeping + // arbeitet auf einem Pfad relativ zum Projekt, und der Aufräumer der + // Testbasis löscht nur dort (und im Temp-Verzeichnis) etwas. + $name = 'hk_' . bin2hex(random_bytes(4)); + $verzeichnis = $this->registriereTempPfad(\Finanzmanager\Config::get('import_dir') . '/' . $name); + mkdir($verzeichnis, 0o775, true); + + $alt = $this->registriereTempPfad($verzeichnis . '/alt.qif'); + $neu = $this->registriereTempPfad($verzeichnis . '/neu.qif'); + file_put_contents($alt, '!Type:Bank'); + file_put_contents($neu, '!Type:Bank'); + touch($alt, time() - 30 * 86400); + + // Das überwachte Verzeichnis kommt aus den Einstellungen; für den Test + // zeigt es auf den frisch angelegten Pfad. Der Rollback dreht die + // Einstellung wieder zurück. + Einstellung::set('import_qif_verzeichnis', 'data/import/' . $name, 'import'); + Einstellung::set(Housekeeping::EINSTELLUNG_TAGE, '14', 'import'); + Einstellung::set(Housekeeping::EINSTELLUNG_LAUF, '', 'import'); + + $this->assertEquals(14, Housekeeping::tage(), 'Die Frist kommt aus den Einstellungen'); + $this->assert(Housekeeping::faellig(), 'Ohne vorherigen Lauf ist das Aufräumen fällig'); + + $probe = Housekeeping::ausfuehren(true); + $this->assertEquals(1, $probe['geloescht'], 'Der Probelauf zählt die alte Datei'); + $this->assert(is_file($alt), 'Der Probelauf löscht nichts'); + + $ergebnis = Housekeeping::ausfuehren(); + $this->assertEquals(1, $ergebnis['geloescht'], 'Der echte Lauf löscht die alte Datei'); + $this->assertGreaterThan(0, $ergebnis['bytes'], 'Der Lauf meldet den freigewordenen Platz'); + $this->assert(!is_file($alt), 'Die alte Datei ist weg'); + $this->assert(is_file($neu), 'Die frische Datei bleibt liegen'); + + $this->assert(!Housekeeping::faellig(), 'Nach dem Lauf steht heute nichts mehr an'); + $this->assertNull(Housekeeping::beiGelegenheit(), 'Der beiläufige Lauf hält sich daran'); + + // Frist 0 schaltet das Aufräumen ab. + Einstellung::set(Housekeeping::EINSTELLUNG_TAGE, '0', 'import'); + touch($neu, time() - 400 * 86400); + $aus = Housekeeping::ausfuehren(); + $this->assertEquals(0, $aus['geloescht'], 'Mit Frist 0 wird nichts gelöscht'); + $this->assert(is_file($neu), 'Auch eine uralte Datei bleibt dann liegen'); + $this->assert(!Housekeeping::faellig(), 'Abgeschaltet ist nie etwas fällig'); + } + + // ── Formate, Dateinamen und Oberfläche ─────────────────────────────────── + + private function dateinamenUndFormate(): void + { + $this->assertEquals(['qif', 'csv'], array_keys(Dateiformat::ALLE), 'Zwei Formate, QIF zuerst'); + $this->assertEquals('qif', Dateiformat::pruefe('QIF'), 'Die Prüfung ist unempfindlich für Großschreibung'); + $this->assertNull(Dateiformat::pruefe('xlsx'), 'Ein fremdes Format fällt heraus'); + $this->assertEquals('csv', Dateiformat::ausDateiname('Export 2026.CSV'), 'Das Format steckt in der Endung'); + $this->assertNull(Dateiformat::ausDateiname('böse.php'), 'Eine fremde Endung ergibt kein Format'); + + $this->assertEquals( + 'buchungen-2026-01-01-bis-2026-12-31.csv', + BuchungsExport::dateiname('csv', ['von' => '2026-01-01', 'bis' => '2026-12-31']), + 'Der Dateiname nennt Format und Zeitraum' + ); + $this->assert( + str_ends_with(BuchungsExport::dateiname('qif', []), '.qif'), + 'Ohne Zeitraum bleibt wenigstens die Endung richtig' + ); + + // Der Upload nimmt jetzt beide Formate an – und nur die. + $this->assertEquals('Export.CSV', ImportController::sichererDateiname('Export.CSV'), 'CSV wird angenommen'); + $this->assertEquals('Export.QIF', ImportController::sichererDateiname('Export.QIF'), 'QIF bleibt erlaubt'); + $this->assertNull(ImportController::sichererDateiname('böse.php'), 'Fremde Endung wird abgewiesen'); + $this->assertEquals( + 'passwd.csv', + ImportController::sichererDateiname('../../etc/passwd.csv'), + 'Pfadanteile fallen auch beim CSV weg' + ); + } + + private function exportLegtKeineDateiAb(): void + { + $vorher = $this->dateienImImportverzeichnis(); + + $datei = BuchungsExport::datei('csv', []); + + $this->assertNotEmpty($datei['inhalt'], 'Die Datei entsteht im Speicher'); + $this->assertEquals(3, $datei['anzahl'], 'Sie meldet, wie viele Buchungen sie führt'); + $this->assert(str_contains($datei['typ'], 'text/csv'), 'Der Inhaltstyp passt zum Format'); + $this->assertEquals( + $vorher, + $this->dateienImImportverzeichnis(), + 'Der Export hinterlässt keine Datei auf der Platte' + ); + + $meldung = ''; + try { + BuchungsExport::datei('xlsx', []); + } catch (\Throwable $e) { + $meldung = $e->getMessage(); + } + $this->assertNotEmpty($meldung, 'Ein unbekanntes Format wird abgewiesen'); + } + + private function verwaltungFuehrtImportUndExport(): void + { + $seite = $this->verwaltungsseite(); + + $this->assert(str_contains($seite, '

Import/Export

'), 'Die Kachel heißt Import/Export'); + $this->assert(!str_contains($seite, '

QIF-Import

'), 'Die alte Beschriftung ist weg'); + $this->assert( + (bool)preg_match('#href="/verwaltung/import" class="knopf klein[^"]*" data-formatwahl="Import"#', $seite), + 'Der Importknopf öffnet die Formatwahl' + ); + $this->assert( + (bool)preg_match('#href="/verwaltung/export" class="knopf klein[^"]*" data-formatwahl="Export"#', $seite), + 'Der Exportknopf öffnet die Formatwahl' + ); + + // Die Karten stehen im Markup, damit das Skript ohne Formatliste auskommt. + foreach (array_keys(Dateiformat::ALLE) as $format) { + $this->assert( + str_contains($seite, 'data-format="' . $format . '"'), + 'Der Auswahldialog führt eine Karte für ' . strtoupper($format) + ); + } + + $skript = (string)file_get_contents(dirname(__DIR__) . '/public/static/app.js'); + $this->assert(str_contains($skript, 'data-formatwahl'), 'Das Skript kennt die Formatknöpfe'); + $this->assert(str_contains($skript, 'showModal'), 'Es öffnet den Dialog'); + + // Ohne Skript führt derselbe Knopf auf die Auswahl als eigene Seite. + $wahl = $this->rendere('verwaltung/formatwahl.php', [ + 'richtung' => 'Export', + 'ziel' => '/verwaltung/export', + 'einleitung' => 'Welches Format?', + ]); + $this->assert(str_contains($wahl, 'href="/verwaltung/export?format=qif"'), 'Die Seite führt zum QIF-Export'); + $this->assert(str_contains($wahl, 'href="/verwaltung/export?format=csv"'), 'Die Seite führt zum CSV-Export'); + } + + // ── Hilfen ─────────────────────────────────────────────────────────────── + + /** + * Die Umbuchung und die Splitbuchung des aktuellen Mandanten. + * + * @return array{0: array|null, 1: array|null} + */ + private function umbuchungUndSplit(): array + { + $umbuchung = null; + $split = null; + + foreach (BuchungsExport::buchungen([]) as $buchung) { + if (($buchung['gegenkontoname'] ?? '') === 'Exportrücklage') { + $umbuchung = $buchung; + } + if ($buchung['splits'] !== []) { + $split = $buchung; + } + } + + return [$umbuchung, $split]; + } + + /** @return list */ + private function dateienImImportverzeichnis(): array + { + $pfad = dirname(__DIR__) . '/' . trim( + (string)Einstellung::get('import_qif_verzeichnis', 'data/import'), + '/' + ); + + $dateien = glob($pfad . '/*') ?: []; + sort($dateien); + + return $dateien; + } + + private function verwaltungsseite(): string + { + $seite = $this->rendere('verwaltung/index.php', [ + 'aktuellerPfad' => '/verwaltung', + 'zahlen' => [ + 'kategorien' => 0, 'einnahmen' => 0, 'ausgaben' => 0, 'klassen' => 0, + 'konten' => 0, 'kontengruppen' => 0, 'reihen' => 0, 'erinnerungen' => 0, + 'vertraege' => 0, 'fristen' => 0, + ], + ]); + + return substr($seite, (int)strpos($seite, ' $daten */ + private function rendere(string $view, array $daten): string + { + $daten += ['titel' => 'Test', 'flash' => [], 'benutzer' => null, 'csrfToken' => 'x']; + + extract($daten, EXTR_SKIP); + + ob_start(); + include dirname(__DIR__) . '/src/Views/' . $view; + return (string)ob_get_clean(); + } +} diff --git a/tests/e2e/specs/importexport.spec.js b/tests/e2e/specs/importexport.spec.js new file mode 100644 index 0000000..1d54fd9 --- /dev/null +++ b/tests/e2e/specs/importexport.spec.js @@ -0,0 +1,127 @@ +// @ts-check +const { test, expect } = require('@playwright/test') + +const BENUTZER = process.env.E2E_USER || 'e2e' +const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' + +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(/\/$/) +} + +// Formatwahl und Download laufen im Browser ab – der Dialog braucht das Skript, +// der Download den Antwortkopf. Beides lässt sich in der PHP-Suite nicht prüfen. +test.describe('Import und Export', () => { + test.beforeEach(async ({ page }) => { + await anmelden(page) + }) + + test('Die Kachel fragt erst nach dem Format und öffnet dann den Dialog', async ({ page }) => { + await page.goto('/verwaltung') + + const kachel = page.locator('.kachel', { hasText: 'Import/Export' }) + await expect(kachel.getByRole('link', { name: 'Import', exact: true })).toBeVisible() + await expect(kachel.getByRole('link', { name: 'Export', exact: true })).toBeVisible() + + // Vor dem Klick ist der Auswahldialog zu. + const dialog = page.locator('dialog#formatwahl') + await expect(dialog).toBeHidden() + + await kachel.getByRole('link', { name: 'Export', exact: true }).click() + await expect(dialog).toBeVisible() + await expect(dialog.locator('h2')).toContainText('Export') + + await dialog.getByRole('link', { name: 'CSV' }).click() + await expect(page).toHaveURL(/\/verwaltung\/export\?format=csv$/) + await expect(page.locator('h1')).toContainText('Export (CSV)') + + // Derselbe Weg für den Import – der Dialog merkt sich die Richtung nicht. + await page.goto('/verwaltung') + await kachel.getByRole('link', { name: 'Import', exact: true }).click() + await expect(dialog.locator('h2')).toContainText('Import') + await dialog.getByRole('link', { name: 'QIF' }).click() + await expect(page).toHaveURL(/\/verwaltung\/import\?format=qif$/) + }) + + test('Ohne Skript führt derselbe Knopf auf die Auswahl als Seite', async ({ page }) => { + await page.goto('/verwaltung/export') + + await expect(page.locator('h2').first()).toContainText('Format wählen') + await expect(page.locator('a[href="/verwaltung/export?format=qif"]')).toBeVisible() + await expect(page.locator('a[href="/verwaltung/export?format=csv"]')).toBeVisible() + }) + + test('Der gefilterte Export lädt eine Datei herunter', async ({ page }) => { + await page.goto('/verwaltung/export?format=csv') + + await page.fill('input[name="von"]', '2026-01-01') + await page.fill('input[name="bis"]', '2026-12-31') + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'CSV herunterladen' }).click(), + ]) + + expect(download.suggestedFilename()).toMatch(/^buchungen-2026-01-01-bis-2026-12-31\.csv$/) + + // Die Kopfzeile muss stehen, sonst wäre es keine Buchungstabelle. + const strom = await download.createReadStream() + const teile = [] + for await (const stueck of strom) { + teile.push(stueck) + } + expect(Buffer.concat(teile).toString('utf8')).toContain('Buchungstext') + }) + + test('Eine CSV lässt sich hochladen und im Trockenlauf prüfen', async ({ page }) => { + const dateiname = `e2e-buchungen-${MARKE}.csv` + const konto = `E2E CSV Konto ${MARKE}` + + // Das Konto muss es geben – Konten legt der CSV-Import nicht an. + await page.goto('/konten/neu') + await page.fill('#name', konto) + await page.click('.formular button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + const inhalt = [ + 'Art;Datum;Konto;Empfänger;Buchungstext;Betrag', + `Buchung;15.06.2026;${konto};E2E Laden;Testeinkauf ${MARKE};-12,34`, + '', + ].join('\r\n') + + await page.goto('/verwaltung/import?format=csv') + await page.setInputFiles('#datei', { + name: dateiname, + mimeType: 'text/csv', + buffer: Buffer.from(inhalt, 'utf8'), + }) + await page.click('.formular button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toContainText('hochgeladen') + + await page.locator('tr', { hasText: dateiname }) + .getByRole('button', { name: 'Trockenlauf' }).click() + + await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf') + await expect(page.locator('tr', { hasText: 'Neu' }).first()).toContainText('1') + + // Aufräumen – die Datei bliebe sonst bis zum Housekeeping liegen. + await page.goto('/verwaltung/import?format=csv') + page.on('dialog', async (dialog) => await dialog.accept()) + await page.locator('tr', { hasText: dateiname }) + .getByRole('button', { name: 'Löschen' }).click() + await expect(page.locator('body')).not.toContainText(dateiname) + }) +}) diff --git a/tests/e2e/specs/stammdaten.spec.js b/tests/e2e/specs/stammdaten.spec.js index 09a2b95..9c1adc2 100644 --- a/tests/e2e/specs/stammdaten.spec.js +++ b/tests/e2e/specs/stammdaten.spec.js @@ -188,8 +188,8 @@ test.describe('Stammdaten', () => { '', ].join('\r\n') - await page.goto('/verwaltung/import') - await page.setInputFiles('#qif', { + await page.goto('/verwaltung/import?format=qif') + await page.setInputFiles('#datei', { name: dateiname, mimeType: 'application/octet-stream', buffer: Buffer.from(inhalt, 'latin1'), @@ -215,7 +215,7 @@ test.describe('Stammdaten', () => { await expect(page.locator('body')).not.toContainText(klasse) // Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen. - await page.goto('/verwaltung/import') + await page.goto('/verwaltung/import?format=qif') page.on('dialog', async (dialog) => await dialog.accept()) await page.locator('tr', { hasText: dateiname }) .getByRole('button', { name: 'Löschen' }).click() @@ -254,8 +254,8 @@ test.describe('Stammdaten', () => { '', ].join('\r\n') - await page.goto('/verwaltung/import') - await page.setInputFiles('#qif', { + await page.goto('/verwaltung/import?format=qif') + await page.setInputFiles('#datei', { name: dateiname, mimeType: 'application/octet-stream', buffer: Buffer.from(inhalt, 'latin1'), @@ -286,7 +286,7 @@ test.describe('Stammdaten', () => { await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).not.toBeChecked() // Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen. - await page.goto('/verwaltung/import') + await page.goto('/verwaltung/import?format=qif') page.on('dialog', async (dialog) => await dialog.accept()) await page.locator('tr', { hasText: dateiname }) .getByRole('button', { name: 'Löschen' }).click()