Import und Export in QIF und CSV, Housekeeping für Importdateien

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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-17 23:01:30 +02:00
parent 9839bd5b16
commit 2c86e8522b
30 changed files with 3424 additions and 371 deletions

View file

@ -1,5 +1,71 @@
# Änderungen # Ä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 `<dialog>`; die Karten liefert die Ansicht
mit, das Skript setzt nur die Ziele. Ohne Skript – oder ohne `<dialog>` – 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) ## Umsätze im Banking sammelweise löschen (17.08.2026)
Die Umsatzlisten des Bankings haben jetzt dieselbe Löschfunktion wie ein Kontoblatt: ein Die Umsatzlisten des Bankings haben jetzt dieselbe Löschfunktion wie ein Kontoblatt: ein

View file

@ -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 `!Type:Cat`, `!Account` — mit Trockenlauf, Protokoll und `scripts/qif-import.php` für
die Kommandozeile. die Kommandozeile.
* *Ergebnis:* 17 Klassen, 105 Kategorien und 37 Konten sind aus dem Export übernommen und * *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)* ### Phase 2 — Buchungen *(erledigt)*
* Migrationen `buchungen`, `buchung_splits`, `buchungsadressen`. * Migrationen `buchungen`, `buchung_splits`, `buchungsadressen`.

View file

@ -15,6 +15,9 @@ Haushalt wird beides parallel genutzt.
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt * **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt
mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen; mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen;
QIF-Import mit Stichtag. 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, * **Planung** – Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf,
Snapshot und Sperre. Snapshot und Sperre.
* **Auswertungen** – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht mit * **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) src/ Anwendungscode (Controllers, Models, Services, Views)
public/ Front-Controller und statische Dateien public/ Front-Controller und statische Dateien
migrations/ nummerierte SQL-Migrationen, laufen beim Containerstart 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 tests/ PHP-Testsuite; tests/e2e/ für Playwright
data/ Nutzdaten: mariadb, logs, import, schluessel (nicht im Repository) data/ Nutzdaten: mariadb, logs, import, schluessel (nicht im Repository)
Altdaten/ Screenshots und QIF-Export der Altanwendung (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. Fallstricken stehen in `PLAN.md`, Kapitel 4.
Klassen, Kategorien, Konten und Buchungen lassen sich übernehmen – im Dialog unter 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: einzeln in die Datenbank, alles davor wird je Konto zum Anfangssaldo verrechnet:
```bash ```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 im Dialog als Ankreuzliste nach dem Trockenlauf) bleibt der Import auf einzelne Konten
beschränkt; `--konten` nennt die Konten der Datei. 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 ## Dokumentation
- `PLAN.md` – Projektplan mit allen Phasen - `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/banking.md` – FinTS-Abruf, TAN-Dialog, Abgleich und Regeln
- `docs/wertpapiere.md` – Depots, Einstand, Kurse, Invst- und CSV-Bestandsimport - `docs/wertpapiere.md` – Depots, Einstand, Kurse, Invst- und CSV-Bestandsimport
- `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr - `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

View file

@ -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 Der Lexware FinanzManager exportiert erweitertes Quicken-QIF. Die `.QDF`-Datei ist
verschlüsselt und damit keine Migrationsquelle – der QIF-Export ist der einzige Weg. 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, Umbuchungen (Phase 2) werden übernommen. Wertpapierbestände folgen in Phase 6,
Buchungsvorlagen in Phase 7. Buchungsvorlagen in Phase 7.
## Aufbau der Datei ### Aufbau der Datei
``` ```
!Option:MDY Datumsformat für die gesamte 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 aufeinander, ohne dass Buchungen dazwischenstehen; das schadet nicht und deckt zugleich die
zweite Schreibweise ab, bei der ein einzelner `!Account` den Kontowechsel bedeutet. zweite Schreibweise ab, bei der ein einzelner `!Account` den Kontowechsel bedeutet.
## Felder der Stammdaten ### Felder der Stammdaten
| Sektion | Feld | Bedeutung | | 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* unter „Nicht gruppiert“. Die Gruppen werden einmalig unter *Verwaltung → Kontengruppen*
angelegt; dort lassen sich die Konten einer Gruppe in einem Zug ankreuzen. 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 1. **Zeichensatz ISO-8859-1, Zeilenenden CRLF.** `QifParser::nachUtf8()` prüft erst, ob
schon UTF-8 vorliegt – eine zweite Konvertierung zerstörte die Umlaute. 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 9. **Anfangssalden stehen nicht als eigenes Feld im Export.** Sie ergeben sich aus den
Buchungen vor dem Stichtag, Gegenbuchungen eingerechnet. 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 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 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 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. 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 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: 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 zählt ein laufender Index gleiche Fingerabdrücke durch – stabil über Läufe hinweg, weil
die Datei immer dieselbe Reihenfolge hat. die Datei immer dieselbe Reihenfolge hat.
## Bedienung ### Bedienung
Im Dialog unter *Verwaltung → QIF-Import*: Datei hochladen, Trockenlauf, dann übernehmen. Im Dialog unter *Verwaltung → Import/Export → Import → QIF*: Datei hochladen, Trockenlauf,
Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit derselbe Export dann übernehmen. Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit
erst geprüft und dann übernommen werden kann. 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 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 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 `--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. 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: 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 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“ FinanzManagers vorliegen, stimmen danach **auf den Cent** – von „Whg Besigheim“
(210.601,99 €) bis „Auslagen Eltern Wohnungskauf“ (−22.410,94 €). (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.

View file

@ -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 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. 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. 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`): Kontoarten (`Konto::ARTEN`) und ihre Herkunft aus dem QIF-Export (`Konto::QIF_ARTEN`):

View file

@ -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;

View file

@ -16,6 +16,7 @@ use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Controllers\BudgetController; use Finanzmanager\Controllers\BudgetController;
use Finanzmanager\Controllers\CockpitController; use Finanzmanager\Controllers\CockpitController;
use Finanzmanager\Controllers\EinstellungenController; use Finanzmanager\Controllers\EinstellungenController;
use Finanzmanager\Controllers\ExportController;
use Finanzmanager\Controllers\ImportController; use Finanzmanager\Controllers\ImportController;
use Finanzmanager\Controllers\KategorienController; use Finanzmanager\Controllers\KategorienController;
use Finanzmanager\Controllers\KontenController; 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/ausfuehren', fn() => (new ImportController())->ausfuehren());
$router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new ImportController())->loeschen($p)); $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 { try {
$router->dispatch(); $router->dispatch();
} catch (\Throwable $e) { } catch (\Throwable $e) {

View file

@ -1149,6 +1149,50 @@ table.splittabelle input.rechts { text-align: right; }
„Speichern“ und „Abbrechen“, damit es nicht im Vorbeigehen getroffen wird. */ „Speichern“ und „Abbrechen“, damit es nicht im Vorbeigehen getroffen wird. */
.splitdialog .knopfleiste .knopf.gefahr { margin-left: auto; } .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 /* Der Wertpapierdialog trägt ein Formular und wird dadurch länger als der
Splitdialog; bei kleinen Fenstern scrollt er. */ Splitdialog; bei kleinen Fenstern scrollt er. */
.wertpapierdialog { max-height: 88vh; overflow-y: auto; } .wertpapierdialog { max-height: 88vh; overflow-y: auto; }

View file

@ -237,6 +237,35 @@
anpassen(); 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 <dialog> 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 // Meldungen ausblenden. Über eine Klasse, nicht über element.style: Die
// Content-Security-Policy (style-src 'self') verwirft auch per JavaScript // Content-Security-Policy (style-src 'self') verwirft auch per JavaScript
// gesetzte Inline-Styles, die Meldung bliebe sonst einfach stehen. // gesetzte Inline-Styles, die Meldung bliebe sonst einfach stehen.

57
scripts/housekeeping.php Normal file
View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/**
* Räumt die temporären Dateien von Import und Export weg.
*
* Gedacht für einen Cron-Eintrag:
*
* docker compose exec -u www-data php php scripts/housekeeping.php --still
*
* Nötig ist der Eintrag nicht: Beim Öffnen von Import und Export läuft dasselbe
* Aufräumen beiläufig mit, höchstens einmal am Tag. Wer den Finanzmanager
* wochenlang nicht öffnet, hat mit dem Cron trotzdem ein leeres
* Importverzeichnis.
*
* Mit `--probe` wird nur aufgezählt, was wegfiele; gelöscht wird dann nichts.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use Finanzmanager\Config;
use Finanzmanager\Services\Housekeeping;
Config::load();
$still = in_array('--still', $argv, true);
$probe = in_array('--probe', $argv, true);
$sage = static function (string $zeile) use ($still): void {
if (!$still) {
echo $zeile . PHP_EOL;
}
};
$tage = Housekeeping::tage();
if ($tage === 0) {
$sage('Das Aufräumen ist abgeschaltet (Einstellungen → Import/Export, Frist 0).');
exit(0);
}
$ergebnis = Housekeeping::ausfuehren($probe);
$sage(sprintf(
'%s: %d von %d Dateien älter als %d Tage (%s).',
$probe ? 'Probelauf' : 'Aufgeräumt',
$ergebnis['geloescht'],
$ergebnis['geprueft'],
$ergebnis['tage'],
number_format($ergebnis['bytes'] / 1024, 1, ',', '.') . ' kB'
));
foreach ($ergebnis['dateien'] as $datei) {
$sage(' ' . $datei);
}
exit(0);

View file

@ -106,7 +106,11 @@ class EinstellungenController extends BaseController
'banking_abgleich_tage' => 'text', 'banking_abgleich_tage' => 'text',
'banking_produkt_id' => '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' => [
'wertpapiere_kursdienst' => 'text', 'wertpapiere_kursdienst' => 'text',
'wertpapiere_api_key' => 'text', 'wertpapiere_api_key' => 'text',

View file

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\BuchungsExport;
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\Services\Housekeeping;
use Finanzmanager\View;
/**
* Export der Buchungen als QIF oder CSV.
*
* Wie beim Import steht die Formatwahl vorweg; ohne `format` in der Adresse
* erscheint sie als Seite. Danach folgt der Filter nach Zeitraum, Konto,
* Kategorie und Klasse, und der Knopf liefert die Datei unmittelbar aus.
*
* Die Datei wird nirgends zwischengespeichert – sie entsteht im Speicher und
* geht direkt an den Browser. Ein Exportverzeichnis gäbe es sonst zu pflegen,
* und darin lägen Auszüge der eigenen Finanzen herum.
*/
class ExportController extends BaseController
{
public function index(): void
{
$this->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<string,mixed>
*/
private function eingabe(): array
{
return $_POST + $_GET;
}
/**
* @return array{von: string, bis: string, konten: list<int>,
* kategorien: list<int>, klassen: list<int>}
*/
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<int> */
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 : '';
}
}

View file

@ -5,6 +5,9 @@ declare(strict_types=1);
namespace Finanzmanager\Controllers; namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Einstellung;
use Finanzmanager\Services\CsvBuchungsImport;
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\Services\Housekeeping;
use Finanzmanager\Services\QifBuchungsImport; use Finanzmanager\Services\QifBuchungsImport;
use Finanzmanager\Services\QifParser; use Finanzmanager\Services\QifParser;
use Finanzmanager\Services\QifStammdatenImport; use Finanzmanager\Services\QifStammdatenImport;
@ -12,58 +15,75 @@ use Finanzmanager\Services\QifVorlagenImport;
use Finanzmanager\View; 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 * Hochgeladene Dateien landen im Importverzeichnis und bleiben dort – so lässt
* sich derselbe Export erst im Trockenlauf prüfen und danach übernehmen, ohne * 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 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 public function index(): void
{ {
$this->verlangeAnmeldung(); $this->verlangeAnmeldung();
Housekeeping::beiGelegenheit();
$this->render('verwaltung/import.php', [ $format = Dateiformat::pruefe($this->query('format'));
'titel' => 'QIF-Import', if ($format === null) {
'dateien' => $this->dateien(), $this->formatwahl();
'verzeichnis' => $this->verzeichnisAnzeige(), return;
'ergebnis' => null, }
'buchungen' => null,
'vorlagen' => null, $this->render('verwaltung/import.php', array_merge(self::LEER, $this->grunddaten($format)));
'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'),
'bis' => date('Y-m-d'),
'datei' => '',
'dateikonten' => [],
'auswahl' => [],
]);
} }
public function hochladen(): void public function hochladen(): void
{ {
$this->verlangeAnmeldung(); $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) { 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; return;
} }
$name = self::sichererDateiname((string)$datei['name']); $name = self::sichererDateiname((string)$datei['name']);
if ($name === null) { if ($name === null || Dateiformat::ausDateiname($name) !== $format) {
View::redirect('/verwaltung/import', 'Nur .QIF-Dateien können hochgeladen werden.', 'fehler'); View::redirect(
$ziel,
'Für diesen Import werden nur .' . strtoupper($format) . '-Dateien angenommen.',
'fehler'
);
return; return;
} }
$ziel = $this->verzeichnis() . '/' . $name; $pfad = $this->verzeichnis() . '/' . $name;
if (!is_uploaded_file((string)$datei['tmp_name']) || !move_uploaded_file((string)$datei['tmp_name'], $ziel)) { if (!is_uploaded_file((string)$datei['tmp_name']) || !move_uploaded_file((string)$datei['tmp_name'], $pfad)) {
View::redirect('/verwaltung/import', 'Die Datei konnte nicht abgelegt werden.', 'fehler'); View::redirect($ziel, 'Die Datei konnte nicht abgelegt werden.', 'fehler');
return; return;
} }
View::redirect('/verwaltung/import', 'Datei „' . $name . '“ hochgeladen.'); View::redirect($ziel, 'Datei „' . $name . '“ hochgeladen.');
} }
public function ausfuehren(): void public function ausfuehren(): void
@ -76,62 +96,37 @@ class ImportController extends BaseController
return; 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; $pfad = $this->verzeichnis() . '/' . $name;
if (!is_file($pfad)) { 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; return;
} }
$trockenlauf = $this->post('modus') !== 'import'; $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 { try {
$parser = QifParser::ausDatei($pfad); $daten = $format === 'csv'
$ergebnis = (new QifStammdatenImport($parser))->ausfuehren($trockenlauf); ? $this->csvLauf($pfad, $trockenlauf)
: $this->qifLauf($pfad, $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);
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
View::redirect('/verwaltung/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler'); View::redirect($ziel, 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler');
return; return;
} }
$this->render('verwaltung/import.php', [ // Reihenfolge zählt: Was der Lauf liefert, sticht die Vorbelegung –
'titel' => 'QIF-Import', // etwa der im Formular gewählte Stichtag den aus den Einstellungen.
'dateien' => $this->dateien(), $this->render('verwaltung/import.php', array_merge(
'verzeichnis' => $this->verzeichnisAnzeige(), self::LEER,
'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()], $this->grunddaten($format),
'buchungen' => $buchungen, $daten,
'vorlagen' => $vorlagen, ['datei' => $name]
'stichtag' => $stichtag, ));
'bis' => $bis,
'datei' => $name,
'dateikonten' => $dateikonten,
'auswahl' => $auswahl,
]);
} }
/** @param array<string,string> $params */ /** @param array<string,string> $params */
@ -139,7 +134,9 @@ class ImportController extends BaseController
{ {
$this->verlangeAnmeldung(); $this->verlangeAnmeldung();
$name = self::sichererDateiname($params['datei'] ?? ''); $name = self::sichererDateiname($params['datei'] ?? '');
$format = $name === null ? 'qif' : (Dateiformat::ausDateiname($name) ?? 'qif');
if ($name !== null) { if ($name !== null) {
$pfad = $this->verzeichnis() . '/' . $name; $pfad = $this->verzeichnis() . '/' . $name;
if (is_file($pfad)) { 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; return null;
} }
$endung = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if (Dateiformat::ausDateiname($name) === null) {
if (!in_array($endung, self::ERLAUBTE_ENDUNGEN, true)) {
return null; return null;
} }
@ -174,13 +170,100 @@ class ImportController extends BaseController
return $name === '' ? null : $name; return $name === '' ? null : $name;
} }
// ── Läufe je Format ──────────────────────────────────────────────────────
/** @return array<string,mixed> */
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<string,mixed> */
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<string,mixed> */
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<array{name: string, groesse: int, geaendert: int}> */ /** @return list<array{name: string, groesse: int, geaendert: int}> */
private function dateien(): array private function dateien(string $format): array
{ {
$verzeichnis = $this->verzeichnis(); $verzeichnis = $this->verzeichnis();
$dateien = []; $dateien = [];
foreach (glob($verzeichnis . '/*.[qQ][iI][fF]') ?: [] as $pfad) { foreach (glob($verzeichnis . '/*') ?: [] as $pfad) {
if (!is_file($pfad) || Dateiformat::ausDateiname($pfad) !== $format) {
continue;
}
$dateien[] = [ $dateien[] = [
'name' => basename($pfad), 'name' => basename($pfad),
'groesse' => (int)filesize($pfad), 'groesse' => (int)filesize($pfad),

View file

@ -11,15 +11,11 @@ use Finanzmanager\View;
* Vorzeichen wie am Bildschirm. Wer den Export mit der Anzeige vergleicht, soll * Vorzeichen wie am Bildschirm. Wer den Export mit der Anzeige vergleicht, soll
* nicht rechnen müssen. * nicht rechnen müssen.
* *
* Deutsches Tabellenkalkulations-CSV: Semikolon als Trenner, Komma als * Das Dateiformat selbst – Trenner, Maskierung, Zahlenschreibweise – kommt aus
* Dezimalzeichen, kein Tausenderpunkt (der macht aus der Zahl einen Text). * CsvTabelle; hier steht allein, welche Zeilen ein Bericht hergibt.
* Vorangestellt ein BOM, sonst zeigen Excel und LibreOffice beim Doppelklick
* „Übrige“ statt „Übrige“.
*/ */
class BerichtExport class BerichtExport
{ {
private const BOM = "\u{FEFF}";
/** @param array<string,mixed> $bericht Rückgabe von Auswertung::bericht() */ /** @param array<string,mixed> $bericht Rückgabe von Auswertung::bericht() */
public static function csv(array $bericht): string public static function csv(array $bericht): string
{ {
@ -36,12 +32,7 @@ class BerichtExport
[], [],
]; ];
$text = ''; return CsvTabelle::schreibe(array_merge($kopf, $zeilen));
foreach (array_merge($kopf, $zeilen) as $zeile) {
$text .= self::zeile($zeile);
}
return self::BOM . $text;
} }
/** Dateiname ohne Pfad, aus Berichtsname und Zeitraum. */ /** Dateiname ohne Pfad, aus Berichtsname und Zeitraum. */
@ -182,29 +173,9 @@ class BerichtExport
return $zeilen; return $zeilen;
} }
/** @param list<string> $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 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. */ /** Umlaute im Dateinamen ausschreiben, statt sie zu Bindestrichen zu zermahlen. */

View file

@ -0,0 +1,551 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
/**
* Buchungen herausgeben – als QIF oder als CSV, gefiltert nach Zeitraum,
* Konto, Kategorie und Klasse.
*
* Die Datei entsteht im Speicher und geht unmittelbar an den Browser; auf der
* Platte bleibt nichts liegen. Ein Exportverzeichnis gibt es deshalb bewusst
* nicht: Ein Auszug der eigenen Finanzen, der irgendwo im Dateisystem liegen
* bleibt, ist eine Altlast, die niemand aufräumt.
*
* Eine Umbuchung steht – wie im Datenmodell und im QIF des FinanzManagers –
* genau einmal in der Datei, beim erfassenden Konto; die Gegenseite ist als
* `[Kontoname]` benannt. Deshalb greift der Kontofilter auch auf das
* Gegenkonto: Wer ein Konto exportiert, will die Umbuchungen dorthin mithaben.
*/
class BuchungsExport
{
/** Spalten des CSV-Exports, in dieser Reihenfolge. */
public const SPALTEN = [
'Art', 'Datum', 'Valuta', 'Konto', 'Nummer', 'Empfänger', 'Buchungstext',
'Kategorie', 'Klasse', 'Gegenkonto', 'Betrag', 'Abgeglichen',
];
/** Zeilenart in der ersten Spalte des CSV. */
public const ART_BUCHUNG = 'Buchung';
public const ART_SPLIT = 'Splitzeile';
/**
* Die Datei, fertig zum Ausliefern.
*
* @param array<string,mixed> $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<string,mixed> $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<string,mixed> $filter von, bis, konten[], kategorien[], klassen[]
* @return list<array<string,mixed>>
*/
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<string,mixed> $filter
* @return array{0: string, 1: list<mixed>}
*/
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<array<string,mixed>> $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<array<string,mixed>> $buchungen
* @param array<string,mixed> $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<string,mixed> $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<string,mixed> $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<array<string,mixed>> $buchungen
* @return list<array<string,mixed>>
*/
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<int> $buchungIds
* @return array<int, list<array<string,mixed>>>
*/
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<int> $buchungIds
* @return array<int, list<string>>
*/
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);
}
}

View file

@ -0,0 +1,396 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
/**
* Übernimmt Buchungen aus einer CSV-Datei – dem Gegenstück zum CSV-Export.
*
* Die Spalten werden über die Kopfzeile angesprochen, nicht über ihre Position:
* Eine Datei aus einem anderen Programm darf sie umsortieren oder eigene
* ergänzen, solange die Überschriften stimmen. Fehlt eine Spalte, bleibt das
* Feld leer; nur Datum, Konto und Betrag sind unverzichtbar.
*
* Splitzeilen stehen unmittelbar hinter ihrer Buchung und tragen in der Spalte
* „Art“ das Wort „Splitzeile“. Alles bis zur nächsten Buchungszeile gehört
* dazu.
*
* Wie beim QIF-Import gilt: derselbe Fingerabdruck heißt dieselbe Buchung. Ein
* zweiter Lauf derselben Datei legt also nichts doppelt an.
*/
class CsvBuchungsImport
{
/** Ohne diese drei Spalten lässt sich keine Buchung bilden. */
private const PFLICHTSPALTEN = ['datum', 'konto', 'betrag'];
/** @var list<array<string,string>> */
private array $saetze;
/** @var array<string,int> Kontoname → id */
private array $konten = [];
/** @var array<string,int> Kategoriepfad → id */
private array $kategorien = [];
/** @var array<string,int> Klassenname → id */
private array $klassen = [];
/** @var array<string,int> Basis-Hash → wie oft schon gesehen */
private array $hashZaehler = [];
/** @var list<array{typ: string, text: string}> */
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<string,int>, spalten: list<string>}
*/
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<string,int>, trockenlauf: bool,
* meldungen: list<array{typ: string, text: string}>}
*/
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<string,int>
*/
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<string,string> $satz
* @param list<array<string,string>> $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<string,string> $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<string> */
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<string,string> $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];
}
}

233
src/Services/CsvTabelle.php Normal file
View file

@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
/**
* Lesen und Schreiben von deutschem 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“.
*
* Beim Lesen wird großzügiger verfahren als beim Schreiben: Eine Datei, die
* anderswo entstanden ist, trennt womöglich mit Komma und schreibt Beträge im
* englischen Format. Beides wird erkannt, statt den Import daran scheitern zu
* lassen.
*/
class CsvTabelle
{
public const BOM = "\u{FEFF}";
public const TRENNER = ';';
/** @param list<list<string>> $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<list<string>>
*/
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<array<string,string>>
*/
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;
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
/**
* Die Dateiformate, die Import und Export beherrschen.
*
* Beide Richtungen kennen dieselben Formate, deshalb stehen sie hier und nicht
* je einmal im Importeur und im Exporteur. Kommt ein drittes hinzu, ist das die
* einzige Liste, die es zu ergänzen gilt.
*/
class Dateiformat
{
/** Schlüssel → Bezeichnung in der Oberfläche. Der Schlüssel ist zugleich die Dateiendung. */
public const ALLE = [
'qif' => '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));
}
}

View file

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Models\Einstellung;
/**
* Räumt die temporären Dateien weg, die Import und Export hinterlassen.
*
* Exportdateien entstehen im Speicher und gehen unmittelbar an den Browser –
* dort bleibt nichts liegen. Beim Import ist es umgekehrt: Die hochgeladene
* Datei bleibt absichtlich stehen, damit sich derselbe Export erst im
* Trockenlauf prüfen und danach übernehmen lässt, ohne ihn zweimal hochzuladen.
* Genau dadurch sammeln sich dort mit der Zeit Kontoauszüge an – Dateien mit
* allen Buchungen des Haushalts, die niemand mehr braucht.
*
* Deshalb dieses Aufräumen: Was älter ist als die eingestellte Frist, wird
* gelöscht. Angestoßen wird es beiläufig beim Öffnen von Import und Export
* (höchstens einmal am Tag) und zusätzlich von `scripts/housekeeping.php`, das
* sich in einen Cron hängen lässt.
*/
class Housekeeping
{
public const EINSTELLUNG_TAGE = 'housekeeping_tage';
public const EINSTELLUNG_LAUF = 'housekeeping_letzter_lauf';
/** Zwei Wochen: lang genug für einen Import, der über ein Wochenende liegt. */
public const STANDARD_TAGE = 14;
/**
* Wie viele Tage bleiben Dateien liegen? 0 schaltet das Aufräumen ab –
* dann bleibt alles stehen, bis es von Hand gelöscht wird.
*/
public static function tage(): int
{
return max(0, Einstellung::getInt(self::EINSTELLUNG_TAGE, self::STANDARD_TAGE));
}
/**
* Die überwachten Verzeichnisse, absolut. Das Importverzeichnis ist
* einstellbar; `data/tmp` ist der Platz für Arbeitsdateien, die ein Modul
* unterwegs braucht – es muss nicht existieren.
*
* @return array<string,string> 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<string,mixed>|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<string>, 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;
}
}

View file

@ -2,6 +2,7 @@
use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Einstellung;
use Finanzmanager\Services\Cockpit; use Finanzmanager\Services\Cockpit;
use Finanzmanager\Services\Housekeeping;
use Finanzmanager\Services\Kursabruf; use Finanzmanager\Services\Kursabruf;
use Finanzmanager\Services\Wertpapierkosten; use Finanzmanager\Services\Wertpapierkosten;
use Finanzmanager\View; use Finanzmanager\View;
@ -15,6 +16,10 @@ use Finanzmanager\View;
$wert = static fn(string $k, string $standard = ''): string => (string)($werte[$k] ?? $standard); $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'; include __DIR__ . '/../layout/kopf.php';
?> ?>
@ -83,9 +88,9 @@ include __DIR__ . '/../layout/kopf.php';
<input type="text" id="import_qif_verzeichnis" name="import_qif_verzeichnis" <input type="text" id="import_qif_verzeichnis" name="import_qif_verzeichnis"
value="<?= View::h($wert('import_qif_verzeichnis', 'data/import')) ?>"> value="<?= View::h($wert('import_qif_verzeichnis', 'data/import')) ?>">
<p class="feldhinweis"> <p class="feldhinweis">
Relativ zum Projektverzeichnis. Hochgeladene QIF-Dateien landen hier und bleiben Relativ zum Projektverzeichnis. Hochgeladene QIF- und CSV-Dateien landen hier und
liegen, damit derselbe Export erst im Trockenlauf geprüft und dann übernommen bleiben liegen, damit derselbe Export erst im Trockenlauf geprüft und dann
werden kann. übernommen werden kann.
</p> </p>
<label for="import_stichtag">Stichtag für Buchungen</label> <label for="import_stichtag">Stichtag für Buchungen</label>
@ -98,8 +103,22 @@ include __DIR__ . '/../layout/kopf.php';
FinanzManagers und bleiben draußen. FinanzManagers und bleiben draußen.
</p> </p>
<label for="housekeeping_tage">Importdateien aufbewahren (Tage)</label>
<input type="text" id="housekeeping_tage" name="housekeeping_tage" inputmode="numeric"
value="<?= View::h($wert('housekeeping_tage', (string)Housekeeping::STANDARD_TAGE)) ?>">
<p class="feldhinweis">
Das Housekeeping räumt ältere Dateien aus dem Importverzeichnis und aus
<code>data/tmp</code> weg – höchstens einmal am Tag, angestoßen beim Öffnen von
Import und Export. <code>0</code> schaltet es ab. Exportdateien entstehen im
Speicher und werden ohnehin nicht abgelegt.
<?php if ($letzterLauf !== ''): ?>
Zuletzt gelaufen: <?= View::h($letzterLauf) ?>.
<?php endif; ?>
</p>
<div class="knopfleiste"> <div class="knopfleiste">
<a href="/verwaltung/import" class="knopf">QIF-Import öffnen</a> <a href="/verwaltung/import" class="knopf">Import öffnen</a>
<a href="/verwaltung/export" class="knopf">Export öffnen</a>
</div> </div>
<?php elseif ($bereich === 'banking'): ?> <?php elseif ($bereich === 'banking'): ?>

View file

@ -0,0 +1,148 @@
<?php
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\View;
/** @var string $format */
/** @var array{von: string, bis: string, konten: list<int>, kategorien: list<int>, klassen: list<int>} $filter */
/** @var list<array<string,mixed>> $konten */
/** @var list<array<string,mixed>> $kategorien */
/** @var list<array<string,mixed>> $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';
?>
<div class="seitenaktionen">
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
<a href="/verwaltung/export" class="knopf">Anderes Format</a>
<a href="/verwaltung/import?format=<?= View::h($format) ?>" class="knopf">Import</a>
</div>
<div class="kachel">
<header>
<h2>Buchungen als <?= View::h(Dateiformat::name($format)) ?> ausgeben</h2>
<span class="phasenmarke"><?= (int)$anzahl ?> Buchungen</span>
</header>
<p class="hinweis">
Ohne Häkchen zählt alles. Eine <strong>Umbuchung</strong> steht nur einmal in der
Datei, beim erfassenden Konto – sie kommt deshalb auch dann mit, wenn allein das
Gegenkonto gewählt ist. Bei <strong>Splitbuchungen</strong> zählt die Kategorie und
die Klasse jeder einzelnen Zeile.
</p>
<form method="post" action="/verwaltung/export" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="format" value="<?= View::h($format) ?>">
<div class="filterzeile">
<label>
Buchungen ab
<input type="date" name="von" value="<?= View::h($filter['von']) ?>">
</label>
<label>
bis
<input type="date" name="bis" value="<?= View::h($filter['bis']) ?>">
</label>
</div>
<h3>Konten</h3>
<?php if ($konten === []): ?>
<p class="platzhalter">Noch keine Konten angelegt.</p>
<?php else: ?>
<div class="auswahlliste">
<?php foreach ($konten as $konto): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="konten[]" value="<?= (int)$konto['id'] ?>"
<?= $gewaehlt($filter['konten'], (int)$konto['id']) ? 'checked' : '' ?>>
<span><?= View::h((string)$konto['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<h3>Kategorien</h3>
<?php if ($kategorien === []): ?>
<p class="platzhalter">Noch keine Kategorien angelegt.</p>
<?php else: ?>
<div class="auswahlliste">
<?php foreach ($kategorien as $kategorie): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="kategorien[]" value="<?= (int)$kategorie['id'] ?>"
<?= $gewaehlt($filter['kategorien'], (int)$kategorie['id']) ? 'checked' : '' ?>>
<span><?= View::h(str_repeat('· ', (int)$kategorie['ebene']) . $kategorie['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
<p class="feldhinweis">
Gewählt wird jede Kategorie für sich – eine Oberkategorie zieht ihre
Unterkategorien nicht mit.
</p>
<?php endif; ?>
<h3>Klassen</h3>
<?php if ($klassen === []): ?>
<p class="platzhalter">Noch keine Klassen angelegt.</p>
<?php else: ?>
<div class="auswahlliste">
<?php foreach ($klassen as $klasse): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="klassen[]" value="<?= (int)$klasse['id'] ?>"
<?= $gewaehlt($filter['klassen'], (int)$klasse['id']) ? 'checked' : '' ?>>
<span><?= View::h((string)$klasse['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="knopfleiste">
<button type="submit" formaction="/verwaltung/export/datei" class="knopf primaer">
<?= View::h(Dateiformat::name($format)) ?> herunterladen
</button>
<button type="submit" class="knopf">Treffer zählen</button>
</div>
</form>
</div>
<div class="kachel">
<header><h2>Was in der Datei steht</h2></header>
<?php if ($format === 'qif'): ?>
<p class="hinweis">
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.
</p>
<p class="hinweis">
Wertpapiergeschäfte stehen nicht darin; sie hängen am Depot und werden über die
<a href="/wertpapiere">Wertpapierverwaltung</a> gepflegt.
</p>
<?php else: ?>
<p class="hinweis">
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 <code>Splitzeile</code>.
</p>
<p class="hinweis">
Dieselbe Datei liest der <a href="/verwaltung/import?format=csv">CSV-Import</a>
wieder ein.
</p>
<?php endif; ?>
<p class="hinweis">
Die Datei entsteht erst beim Herunterladen und wird nirgends abgelegt: Ein Auszug der
eigenen Finanzen soll nicht im Dateisystem des Servers liegen bleiben.
</p>
</div>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,32 @@
<?php
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\View;
/** @var string $richtung Import oder Export – nur für die Beschriftung. */
/** @var string $ziel Adresse, an die das gewählte Format angehängt wird. */
/** @var string $einleitung */
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
</div>
<div class="kachel">
<header><h2><?= View::h($richtung) ?>: Format wählen</h2></header>
<p class="platzhalter"><?= View::h($einleitung) ?></p>
<div class="formatwahl">
<?php foreach (Dateiformat::ALLE as $schluessel => $name): ?>
<a class="formatkarte" href="<?= View::h($ziel) ?>?format=<?= View::h($schluessel) ?>">
<span class="formatname"><?= View::h($name) ?></span>
<span class="nebentext"><?= View::h(Dateiformat::BESCHREIBUNGEN[$schluessel] ?? '') ?></span>
</a>
<?php endforeach; ?>
</div>
</div>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -1,248 +1,55 @@
<?php <?php
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\View; use Finanzmanager\View;
/** @var string $format */
/** @var list<array{name: string, groesse: int, geaendert: int}> $dateien */ /** @var list<array{name: string, groesse: int, geaendert: int}> $dateien */
/** @var string $verzeichnis */ /** @var string $verzeichnis */
/** @var array<string,mixed>|null $ergebnis */ /** @var array<string,mixed>|null $ergebnis */
/** @var array<string,mixed>|null $buchungen */ /** @var array<string,mixed>|null $buchungen */
/** @var array<string,mixed>|null $vorlagen */ /** @var array<string,mixed>|null $vorlagen */
/** @var array<string,mixed>|null $ueberblick */
/** @var string $stichtag */ /** @var string $stichtag */
/** @var string $bis */ /** @var string $bis */
/** @var string $datei */ /** @var string $datei */
/** @var array<string,int> $dateikonten */ /** @var array<string,int> $dateikonten */
/** @var list<string> $auswahl */ /** @var list<string> $auswahl */
/** @var int $aufbewahrung */
/** @var string $csrfToken */ /** @var string $csrfToken */
$bereiche = [ $endung = '.' . $format;
'klassen' => 'Klassen',
'kategorien' => 'Kategorien',
'konten' => 'Konten',
];
include __DIR__ . '/../layout/kopf.php'; include __DIR__ . '/../layout/kopf.php';
?> ?>
<div class="seitenaktionen"> <div class="seitenaktionen">
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a> <a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a> <a href="/verwaltung/import" class="knopf">Anderes Format</a>
<a href="/konten" class="knopf">Kontenübersicht</a> <a href="/verwaltung/export?format=<?= View::h($format) ?>" class="knopf">Export</a>
</div> </div>
<?php if ($ergebnis !== null): ?> <?php if ($format === 'csv'): ?>
<div class="kachel"> <?php include __DIR__ . '/teil-import-csv.php'; ?>
<header> <?php else: ?>
<h2><?= $ergebnis['trockenlauf'] ? 'Trockenlauf' : 'Import abgeschlossen' ?></h2> <?php include __DIR__ . '/teil-import-qif.php'; ?>
<span class="phasenmarke"><?= View::h($datei) ?></span>
</header>
<?php if ($ergebnis['trockenlauf']): ?>
<p class="hinweis">
Nichts wurde gespeichert. Die Zahlen zeigen, was ein Import anlegen würde.
</p>
<?php endif; ?>
<table class="liste">
<thead>
<tr>
<th>Bereich</th>
<th class="rechts"><?= $ergebnis['trockenlauf'] ? 'Neu' : 'Angelegt' ?></th>
<th class="rechts">Schon vorhanden</th>
<th class="rechts">Fehler</th>
</tr>
</thead>
<tbody>
<?php foreach ($bereiche as $schluessel => $beschriftung): ?>
<tr>
<td><?= View::h($beschriftung) ?></td>
<td class="rechts"><?= (int)$ergebnis[$schluessel]['neu'] ?></td>
<td class="rechts nebentext"><?= (int)$ergebnis[$schluessel]['vorhanden'] ?></td>
<td class="rechts <?= (int)$ergebnis[$schluessel]['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$ergebnis[$schluessel]['fehler'] ?>
</td>
</tr>
<?php endforeach; ?>
<tr>
<td>Kontengruppen</td>
<td class="rechts nebentext" colspan="3">
nicht im QIF-Export enthalten – <a href="/verwaltung/kontengruppen">hier zuordnen</a>
</td>
</tr>
</tbody>
</table>
<p class="hinweis">
Der FinanzManager exportiert je Konto nur Name, Kontoart, Beschreibung und
Kreditlimit. Die Zuordnung zu einer Kontengruppe steht weder im QIF noch in der
<code>QSD</code>-Datei; sie wird deshalb einmalig in der Verwaltung nachgetragen.
</p>
<?php if ($buchungen !== null): ?>
<h3>Buchungen</h3>
<table class="liste">
<tbody>
<tr>
<td><?= $buchungen['trockenlauf'] ? 'Neu' : 'Angelegt' ?></td>
<td class="rechts"><?= (int)$buchungen['buchungen']['neu'] ?></td>
</tr>
<tr>
<td class="nebentext">schon vorhanden</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['vorhanden'] ?></td>
</tr>
<tr>
<td class="nebentext">
vor dem Stichtag – in den Anfangssaldo von
<?= (int)$buchungen['anfangssalden']['konten'] ?> Konten verrechnet
</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['vor_stichtag'] ?></td>
</tr>
<tr>
<td class="nebentext">nach dem Enddatum – vorausberechnet, verworfen</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['zukunft'] ?></td>
</tr>
<tr>
<td class="nebentext">darunter Wertpapiergeschäfte (Depot- und Geldbuchung)</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['wertpapier'] ?></td>
</tr>
<?php if ((int)($buchungen['buchungen']['nicht_gewaehlt'] ?? 0) > 0): ?>
<tr>
<td class="nebentext">gehört zu keinem der gewählten Konten</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['nicht_gewaehlt'] ?></td>
</tr>
<?php endif; ?>
<tr>
<td>Fehler</td>
<td class="rechts <?= (int)$buchungen['buchungen']['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$buchungen['buchungen']['fehler'] ?>
</td>
</tr>
</tbody>
</table>
<p class="hinweis">
Zeitraum <?= View::datum($buchungen['stichtag']) ?> bis <?= View::datum($buchungen['bis']) ?>.
Alles davor ist nicht verloren: es steckt im Anfangssaldo der Konten.
<?php if (($buchungen['konten'] ?? null) !== null): ?>
Übernommen wurden nur die Buchungen von
<strong><?= View::h(implode(', ', $buchungen['konten'])) ?></strong> – einschließlich
der Umbuchungen, die auf einem anderen Konto erfasst wurden und hierher zeigen.
Die Anfangssalden der übrigen Konten bleiben unangetastet.
<?php endif; ?>
</p>
<?php endif; ?>
<?php if (($vorlagen ?? null) !== null): ?>
<h3>Buchungsvorlagen</h3>
<table class="liste">
<tbody>
<tr>
<td><?= $buchungen !== null && $buchungen['trockenlauf'] ? 'Neu' : 'Angelegt' ?></td>
<td class="rechts"><?= (int)$vorlagen['neu'] ?></td>
</tr>
<tr>
<td class="nebentext">bereits vorhanden</td>
<td class="rechts nebentext"><?= (int)$vorlagen['vorhanden'] ?></td>
</tr>
<tr>
<td>Fehler</td>
<td class="rechts <?= (int)$vorlagen['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$vorlagen['fehler'] ?>
</td>
</tr>
</tbody>
</table>
<p class="hinweis">
Der FinanzManager merkt sich jede erfasste Zahlung als Vorlage – im
Beispielexport sind das mehrere hundert. Sie stehen danach unter
<a href="/vorlagen">Buchungsvorlagen</a>.
</p>
<?php endif; ?>
<?php if (!empty($ergebnis['abschnitte'])): ?>
<h3>Inhalt der Datei</h3>
<p class="hinweis">
<?php $teile = [];
foreach ($ergebnis['abschnitte'] as $typ => $anzahl) {
$teile[] = View::h((string)$typ) . ': ' . (int)$anzahl;
}
echo implode(' · ', $teile); ?>
</p>
<p class="hinweis">
Buchungsvorlagen (<code>Memorized</code>) folgen in Phase 7, Wertpapierbestände
in Phase 6.
</p>
<?php endif; ?>
<?php if (!empty($ergebnis['meldungen'])): ?>
<h3>Protokoll</h3>
<ul class="protokoll">
<?php foreach ($ergebnis['meldungen'] as $meldung): ?>
<li class="<?= View::h($meldung['typ']) ?>"><?= View::h($meldung['text']) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($ergebnis['trockenlauf']): ?>
<form method="post" action="/verwaltung/import/ausfuehren" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($datei) ?>">
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
<?php if ($dateikonten !== []): ?>
<h3>Konten</h3>
<p class="feldhinweis">
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.
</p>
<div class="auswahlliste">
<?php foreach ($dateikonten as $name => $anzahl): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="konten[]" value="<?= View::h((string)$name) ?>"
<?= $auswahl === [] || in_array((string)$name, $auswahl, true) ? 'checked' : '' ?>>
<span>
<?= View::h((string)$name) ?>
<span class="nebentext"><?= (int)$anzahl ?></span>
</span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="knopfleiste">
<button type="submit" name="modus" value="import" class="knopf primaer">Jetzt übernehmen</button>
<?php if ($dateikonten !== []): ?>
<button type="submit" name="modus" value="vorschau" class="knopf">
Trockenlauf mit dieser Auswahl
</button>
<?php endif; ?>
</div>
</form>
<?php else: ?>
<div class="knopfleiste">
<a href="/konten" class="knopf primaer">Kontenübersicht</a>
<a href="/verwaltung/kontengruppen" class="knopf">Kontengruppen</a>
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
</div>
<p class="hinweis">
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.
</p>
<?php endif; ?>
</div>
<?php endif; ?> <?php endif; ?>
<div class="kachel"> <div class="kachel">
<header><h2>Exportdatei hochladen</h2></header> <header><h2><?= View::h(Dateiformat::name($format)) ?>-Datei hochladen</h2></header>
<form method="post" action="/verwaltung/import/hochladen" enctype="multipart/form-data" class="formular"> <form method="post" action="/verwaltung/import/hochladen" enctype="multipart/form-data" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="qif">QIF-Datei</label> <input type="hidden" name="format" value="<?= View::h($format) ?>">
<input type="file" id="qif" name="qif" accept=".qif,.QIF" required> <label for="datei"><?= View::h(Dateiformat::name($format)) ?>-Datei</label>
<input type="file" id="datei" name="datei"
accept="<?= View::h($endung . ',' . strtoupper($endung)) ?>" required>
<p class="feldhinweis"> <p class="feldhinweis">
Die Datei wird unter <code><?= View::h($verzeichnis) ?></code> abgelegt und bleibt Die Datei wird unter <code><?= View::h($verzeichnis) ?></code> abgelegt und bleibt
dort – so lässt sich derselbe Export erst prüfen und dann übernehmen. dort – so lässt sich derselbe Export erst prüfen und dann übernehmen.
<?php if ($aufbewahrung > 0): ?>
Nach <?= (int)$aufbewahrung ?> Tagen räumt das Housekeeping sie weg; die Frist steht
in den <a href="/einstellungen?bereich=import">Einstellungen</a>.
<?php endif; ?>
</p> </p>
<div class="knopfleiste"> <div class="knopfleiste">
<button type="submit" class="knopf primaer">Hochladen</button> <button type="submit" class="knopf primaer">Hochladen</button>
@ -251,24 +58,12 @@ include __DIR__ . '/../layout/kopf.php';
</div> </div>
<div class="kachel"> <div class="kachel">
<header><h2>Vorhandene Dateien</h2></header> <header><h2>Vorhandene <?= View::h(Dateiformat::name($format)) ?>-Dateien</h2></header>
<p class="hinweis">
Übernommen werden Buchungen ab <strong><?= View::datum($stichtag) ?></strong> bis
<strong><?= View::datum($bis) ?></strong>. 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 <a href="/einstellungen?bereich=import">Einstellungen</a>.
</p>
<p class="hinweis">
Soll nur ein Teil der Konten übernommen werden, zuerst den <strong>Trockenlauf</strong>
starten: danach steht fest, welche Konten die Datei führt, und sie stehen einzeln zur
Wahl. <strong>Importieren</strong> übernimmt die ganze Datei.
</p>
<?php if ($dateien === []): ?> <?php if ($dateien === []): ?>
<p class="platzhalter"> <p class="platzhalter">
Noch keine Datei in <code><?= View::h($verzeichnis) ?></code>. Noch keine <?= View::h(Dateiformat::name($format)) ?>-Datei in
<code><?= View::h($verzeichnis) ?></code>.
</p> </p>
<?php else: ?> <?php else: ?>
<table class="liste"> <table class="liste">
@ -286,8 +81,10 @@ include __DIR__ . '/../layout/kopf.php';
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>"> <input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>">
<input type="hidden" name="modus" value="vorschau"> <input type="hidden" name="modus" value="vorschau">
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>"> <?php if ($format === 'qif'): ?>
<input type="hidden" name="bis" value="<?= View::h($bis) ?>"> <input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
<?php endif; ?>
<button type="submit" class="knopf klein">Trockenlauf</button> <button type="submit" class="knopf klein">Trockenlauf</button>
</form> </form>
<form method="post" action="/verwaltung/import/ausfuehren" class="inline" <form method="post" action="/verwaltung/import/ausfuehren" class="inline"
@ -295,8 +92,10 @@ include __DIR__ . '/../layout/kopf.php';
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>"> <input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>">
<input type="hidden" name="modus" value="import"> <input type="hidden" name="modus" value="import">
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>"> <?php if ($format === 'qif'): ?>
<input type="hidden" name="bis" value="<?= View::h($bis) ?>"> <input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
<?php endif; ?>
<button type="submit" class="knopf klein primaer">Importieren</button> <button type="submit" class="knopf klein primaer">Importieren</button>
</form> </form>
<form method="post" action="/verwaltung/import/<?= View::h(rawurlencode($eintrag['name'])) ?>/loeschen" <form method="post" action="/verwaltung/import/<?= View::h(rawurlencode($eintrag['name'])) ?>/loeschen"

View file

@ -1,5 +1,6 @@
<?php <?php
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\View; use Finanzmanager\View;
/** @var array<string,int> $zahlen */ /** @var array<string,int> $zahlen */
@ -44,13 +45,16 @@ include __DIR__ . '/../layout/kopf.php';
</div> </div>
<div class="kachel"> <div class="kachel">
<header><h2>QIF-Import</h2></header> <header><h2>Import/Export</h2></header>
<p class="platzhalter"> <p class="platzhalter">
Klassen, Kategorien und Konten aus einem Export des Lexware FinanzManagers Stammdaten und Buchungen aus dem Lexware FinanzManager übernehmen oder den
übernehmen – erst im Trockenlauf prüfen, dann übernehmen. eigenen Bestand herausgeben – wahlweise als QIF oder als CSV.
</p> </p>
<div class="knopfleiste"> <div class="knopfleiste">
<a href="/verwaltung/import" class="knopf klein primaer">Import öffnen</a> <?php /* data-formatwahl öffnet den Auswahldialog; ohne Skript führt der Link
auf dieselbe Auswahl als Seite. */ ?>
<a href="/verwaltung/import" class="knopf klein primaer" data-formatwahl="Import">Import</a>
<a href="/verwaltung/export" class="knopf klein" data-formatwahl="Export">Export</a>
</div> </div>
</div> </div>
@ -94,4 +98,25 @@ include __DIR__ . '/../layout/kopf.php';
</div> </div>
</div> </div>
<?php /*
Die Formatwahl von Import und Export. Die Karten stehen hier im Markup und
nicht im Skript: Die Liste der Formate gehört Dateiformat::ALLE, und ein
Inline-Skript verböte ohnehin die Content-Security-Policy. Das Skript setzt
nur die Ziele und öffnet den Dialog; ohne Skript führen die Knöpfe auf
dieselbe Auswahl als eigene Seite. */ ?>
<dialog class="formatdialog" id="formatwahl">
<h2 data-formatdialog-titel>Format wählen</h2>
<div class="formatwahl">
<?php foreach (Dateiformat::ALLE as $schluessel => $name): ?>
<a class="formatkarte" href="#" data-format="<?= View::h($schluessel) ?>">
<span class="formatname"><?= View::h($name) ?></span>
<span class="nebentext"><?= View::h(Dateiformat::BESCHREIBUNGEN[$schluessel] ?? '') ?></span>
</a>
<?php endforeach; ?>
</div>
<form method="dialog" class="knopfleiste">
<button type="submit" class="knopf klein">Abbrechen</button>
</form>
</dialog>
<?php include __DIR__ . '/../layout/fuss.php'; ?> <?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,127 @@
<?php
use Finanzmanager\Services\BuchungsExport;
use Finanzmanager\View;
/**
* Ergebnis und Erläuterungen des CSV-Imports. Eingebunden aus import.php,
* das die Datei- und Uploadkacheln beisteuert.
*/
/** @var array<string,mixed>|null $buchungen */
/** @var array<string,mixed>|null $ueberblick */
/** @var string $datei */
?>
<?php if ($buchungen !== null): ?>
<div class="kachel">
<header>
<h2><?= $buchungen['trockenlauf'] ? 'Trockenlauf' : 'Import abgeschlossen' ?></h2>
<span class="phasenmarke"><?= View::h($datei) ?></span>
</header>
<?php if ($buchungen['trockenlauf']): ?>
<p class="hinweis">
Nichts wurde gespeichert. Die Zahlen zeigen, was ein Import anlegen würde.
</p>
<?php endif; ?>
<table class="liste">
<tbody>
<tr>
<td><?= $buchungen['trockenlauf'] ? 'Neu' : 'Angelegt' ?></td>
<td class="rechts"><?= (int)$buchungen['buchungen']['neu'] ?></td>
</tr>
<tr>
<td class="nebentext">schon vorhanden – am Fingerabdruck erkannt</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['vorhanden'] ?></td>
</tr>
<tr>
<td class="nebentext">darunter Splitzeilen</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['splitzeilen'] ?></td>
</tr>
<tr>
<td>Fehler</td>
<td class="rechts <?= (int)$buchungen['buchungen']['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$buchungen['buchungen']['fehler'] ?>
</td>
</tr>
</tbody>
</table>
<?php if (!empty($ueberblick['konten'])): ?>
<h3>Konten in der Datei</h3>
<p class="hinweis">
<?php $teile = [];
foreach ($ueberblick['konten'] as $name => $anzahl) {
$teile[] = View::h((string)$name) . ': ' . (int)$anzahl;
}
echo implode(' · ', $teile); ?>
</p>
<?php endif; ?>
<?php if (!empty($buchungen['meldungen'])): ?>
<h3>Protokoll</h3>
<ul class="protokoll">
<?php foreach ($buchungen['meldungen'] as $meldung): ?>
<li class="<?= View::h($meldung['typ']) ?>"><?= View::h($meldung['text']) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<div class="knopfleiste">
<a href="/konten" class="knopf primaer">Kontenübersicht</a>
<a href="/buchungen" class="knopf">Buchungen</a>
</div>
</div>
<?php endif; ?>
<div class="kachel">
<header><h2>So muss die CSV aussehen</h2></header>
<p class="hinweis">
Semikolon als Trenner, Komma als Dezimalzeichen, Datum als <code>TT.MM.JJJJ</code> –
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 <strong>Datum</strong>, <strong>Konto</strong> und
<strong>Betrag</strong>.
</p>
<table class="liste">
<thead>
<tr><?php foreach (BuchungsExport::SPALTEN as $spalte): ?>
<th><?= View::h($spalte) ?></th>
<?php endforeach; ?></tr>
</thead>
<tbody>
<tr>
<td>Buchung</td><td>18.03.2026</td><td></td><td>Girokonto</td><td>042</td>
<td>Stadtwerke</td><td>Abschlag März</td><td>Wohnen:Strom</td><td>Haus 1</td>
<td></td><td class="rechts">-84,00</td><td>ja</td>
</tr>
<tr>
<td>Buchung</td><td>20.03.2026</td><td></td><td>Girokonto</td><td></td>
<td>Eigenübertrag</td><td>Rücklage</td><td></td><td></td>
<td>Tagesgeld</td><td class="rechts">-500,00</td><td>nein</td>
</tr>
<tr class="nebentext">
<td>Splitzeile</td><td></td><td></td><td></td><td></td>
<td></td><td>Anteil Wasser</td><td>Wohnen:Wasser</td><td>Haus 1</td>
<td></td><td class="rechts">-30,00</td><td></td>
</tr>
</tbody>
</table>
<p class="hinweis">
Eine <strong>Umbuchung</strong> trägt statt der Kategorie ein Gegenkonto und steht –
wie im Datenmodell – nur einmal in der Datei, beim erfassenden Konto.
<strong>Splitzeilen</strong> folgen unmittelbar auf ihre Buchung; ihre Beträge müssen
deren Summe ergeben. Konten legt der Import nicht an, Kategorien und Klassen schon.
</p>
<p class="hinweis">
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.
</p>
</div>

View file

@ -0,0 +1,244 @@
<?php
use Finanzmanager\View;
/**
* Ergebnis und Erläuterungen des QIF-Imports. Eingebunden aus import.php,
* das die Datei- und Uploadkacheln beisteuert.
*/
/** @var array<string,mixed>|null $ergebnis */
/** @var array<string,mixed>|null $buchungen */
/** @var array<string,mixed>|null $vorlagen */
/** @var string $stichtag */
/** @var string $bis */
/** @var string $datei */
/** @var array<string,int> $dateikonten */
/** @var list<string> $auswahl */
/** @var string $csrfToken */
$bereiche = [
'klassen' => 'Klassen',
'kategorien' => 'Kategorien',
'konten' => 'Konten',
];
?>
<?php if ($ergebnis !== null): ?>
<div class="kachel">
<header>
<h2><?= $ergebnis['trockenlauf'] ? 'Trockenlauf' : 'Import abgeschlossen' ?></h2>
<span class="phasenmarke"><?= View::h($datei) ?></span>
</header>
<?php if ($ergebnis['trockenlauf']): ?>
<p class="hinweis">
Nichts wurde gespeichert. Die Zahlen zeigen, was ein Import anlegen würde.
</p>
<?php endif; ?>
<table class="liste">
<thead>
<tr>
<th>Bereich</th>
<th class="rechts"><?= $ergebnis['trockenlauf'] ? 'Neu' : 'Angelegt' ?></th>
<th class="rechts">Schon vorhanden</th>
<th class="rechts">Fehler</th>
</tr>
</thead>
<tbody>
<?php foreach ($bereiche as $schluessel => $beschriftung): ?>
<tr>
<td><?= View::h($beschriftung) ?></td>
<td class="rechts"><?= (int)$ergebnis[$schluessel]['neu'] ?></td>
<td class="rechts nebentext"><?= (int)$ergebnis[$schluessel]['vorhanden'] ?></td>
<td class="rechts <?= (int)$ergebnis[$schluessel]['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$ergebnis[$schluessel]['fehler'] ?>
</td>
</tr>
<?php endforeach; ?>
<tr>
<td>Kontengruppen</td>
<td class="rechts nebentext" colspan="3">
nicht im QIF-Export enthalten – <a href="/verwaltung/kontengruppen">hier zuordnen</a>
</td>
</tr>
</tbody>
</table>
<p class="hinweis">
Der FinanzManager exportiert je Konto nur Name, Kontoart, Beschreibung und
Kreditlimit. Die Zuordnung zu einer Kontengruppe steht weder im QIF noch in der
<code>QSD</code>-Datei; sie wird deshalb einmalig in der Verwaltung nachgetragen.
</p>
<?php if ($buchungen !== null): ?>
<h3>Buchungen</h3>
<table class="liste">
<tbody>
<tr>
<td><?= $buchungen['trockenlauf'] ? 'Neu' : 'Angelegt' ?></td>
<td class="rechts"><?= (int)$buchungen['buchungen']['neu'] ?></td>
</tr>
<tr>
<td class="nebentext">schon vorhanden</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['vorhanden'] ?></td>
</tr>
<tr>
<td class="nebentext">
vor dem Stichtag – in den Anfangssaldo von
<?= (int)$buchungen['anfangssalden']['konten'] ?> Konten verrechnet
</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['vor_stichtag'] ?></td>
</tr>
<tr>
<td class="nebentext">nach dem Enddatum – vorausberechnet, verworfen</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['zukunft'] ?></td>
</tr>
<tr>
<td class="nebentext">darunter Wertpapiergeschäfte (Depot- und Geldbuchung)</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['wertpapier'] ?></td>
</tr>
<?php if ((int)($buchungen['buchungen']['nicht_gewaehlt'] ?? 0) > 0): ?>
<tr>
<td class="nebentext">gehört zu keinem der gewählten Konten</td>
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['nicht_gewaehlt'] ?></td>
</tr>
<?php endif; ?>
<tr>
<td>Fehler</td>
<td class="rechts <?= (int)$buchungen['buchungen']['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$buchungen['buchungen']['fehler'] ?>
</td>
</tr>
</tbody>
</table>
<p class="hinweis">
Zeitraum <?= View::datum($buchungen['stichtag']) ?> bis <?= View::datum($buchungen['bis']) ?>.
Alles davor ist nicht verloren: es steckt im Anfangssaldo der Konten.
<?php if (($buchungen['konten'] ?? null) !== null): ?>
Übernommen wurden nur die Buchungen von
<strong><?= View::h(implode(', ', $buchungen['konten'])) ?></strong> – einschließlich
der Umbuchungen, die auf einem anderen Konto erfasst wurden und hierher zeigen.
Die Anfangssalden der übrigen Konten bleiben unangetastet.
<?php endif; ?>
</p>
<?php endif; ?>
<?php if (($vorlagen ?? null) !== null): ?>
<h3>Buchungsvorlagen</h3>
<table class="liste">
<tbody>
<tr>
<td><?= $buchungen !== null && $buchungen['trockenlauf'] ? 'Neu' : 'Angelegt' ?></td>
<td class="rechts"><?= (int)$vorlagen['neu'] ?></td>
</tr>
<tr>
<td class="nebentext">bereits vorhanden</td>
<td class="rechts nebentext"><?= (int)$vorlagen['vorhanden'] ?></td>
</tr>
<tr>
<td>Fehler</td>
<td class="rechts <?= (int)$vorlagen['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
<?= (int)$vorlagen['fehler'] ?>
</td>
</tr>
</tbody>
</table>
<p class="hinweis">
Der FinanzManager merkt sich jede erfasste Zahlung als Vorlage – im
Beispielexport sind das mehrere hundert. Sie stehen danach unter
<a href="/vorlagen">Buchungsvorlagen</a>.
</p>
<?php endif; ?>
<?php if (!empty($ergebnis['abschnitte'])): ?>
<h3>Inhalt der Datei</h3>
<p class="hinweis">
<?php $teile = [];
foreach ($ergebnis['abschnitte'] as $typ => $anzahl) {
$teile[] = View::h((string)$typ) . ': ' . (int)$anzahl;
}
echo implode(' · ', $teile); ?>
</p>
<?php endif; ?>
<?php if (!empty($ergebnis['meldungen'])): ?>
<h3>Protokoll</h3>
<ul class="protokoll">
<?php foreach ($ergebnis['meldungen'] as $meldung): ?>
<li class="<?= View::h($meldung['typ']) ?>"><?= View::h($meldung['text']) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($ergebnis['trockenlauf']): ?>
<form method="post" action="/verwaltung/import/ausfuehren" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="datei" value="<?= View::h($datei) ?>">
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
<?php if ($dateikonten !== []): ?>
<h3>Konten</h3>
<p class="feldhinweis">
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.
</p>
<div class="auswahlliste">
<?php foreach ($dateikonten as $name => $anzahl): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="konten[]" value="<?= View::h((string)$name) ?>"
<?= $auswahl === [] || in_array((string)$name, $auswahl, true) ? 'checked' : '' ?>>
<span>
<?= View::h((string)$name) ?>
<span class="nebentext"><?= (int)$anzahl ?></span>
</span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="knopfleiste">
<button type="submit" name="modus" value="import" class="knopf primaer">Jetzt übernehmen</button>
<?php if ($dateikonten !== []): ?>
<button type="submit" name="modus" value="vorschau" class="knopf">
Trockenlauf mit dieser Auswahl
</button>
<?php endif; ?>
</div>
</form>
<?php else: ?>
<div class="knopfleiste">
<a href="/konten" class="knopf primaer">Kontenübersicht</a>
<a href="/verwaltung/kontengruppen" class="knopf">Kontengruppen</a>
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
</div>
<p class="hinweis">
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.
</p>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="kachel">
<header><h2>So läuft der QIF-Import</h2></header>
<p class="hinweis">
Übernommen werden Buchungen ab <strong><?= View::datum($stichtag) ?></strong> bis
<strong><?= View::datum($bis) ?></strong>. 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 <a href="/einstellungen?bereich=import">Einstellungen</a>.
</p>
<p class="hinweis">
Soll nur ein Teil der Konten übernommen werden, zuerst den <strong>Trockenlauf</strong>
starten: danach steht fest, welche Konten die Datei führt, und sie stehen einzeln zur
Wahl. <strong>Importieren</strong> übernimmt die ganze Datei – Klassen, Kategorien,
Konten, Buchungen und Buchungsvorlagen in einem Zug.
</p>
</div>

View file

@ -161,7 +161,7 @@ class NavigationTest extends TestCase
// Letzter Punkt: keine weitere Kachel hinter der von Verträge. // Letzter Punkt: keine weitere Kachel hinter der von Verträge.
$letzte = (int)strrpos($kacheln, '<h2>Verträge</h2>'); $letzte = (int)strrpos($kacheln, '<h2>Verträge</h2>');
foreach (['Kategorien', 'Klassen', 'Konten', 'QIF-Import', 'Online-Banking', 'Termine'] as $vorher) { foreach (['Kategorien', 'Klassen', 'Konten', 'Import/Export', 'Online-Banking', 'Termine'] as $vorher) {
$this->assert( $this->assert(
$letzte > (int)strrpos($kacheln, '<h2>' . $vorher . '</h2>'), $letzte > (int)strrpos($kacheln, '<h2>' . $vorher . '</h2>'),
'Verträge stehen hinter der Kachel ' . $vorher 'Verträge stehen hinter der Kachel ' . $vorher

View file

@ -0,0 +1,559 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\BuchungsExport;
use Finanzmanager\Services\CsvBuchungsImport;
use Finanzmanager\Services\CsvTabelle;
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\Services\Housekeeping;
use Finanzmanager\Services\QifBuchungsImport;
use Finanzmanager\Services\QifParser;
use Finanzmanager\Services\QifStammdatenImport;
use Finanzmanager\Controllers\ImportController;
/**
* Import und Export in beiden Formaten.
*
* Der Prüfstein ist der Rundlauf: Was der Export schreibt, muss der eigene
* Import wieder einlesen können – und dabei dieselben Buchungen ergeben, nicht
* Dubletten. Dazu die Filter (Zeitraum, Konto, Kategorie, Klasse), die
* Formatwahl vor beiden Dialogen und das Housekeeping, das die hochgeladenen
* Dateien wieder wegräumt.
*/
class ImportExportTest extends TestCase
{
protected string $testName = 'Test 29: Import und Export (QIF und CSV)';
private int $giro = 0;
private int $tagesgeld = 0;
private int $strom = 0;
private int $wasser = 0;
private int $haus = 0;
private int $privat = 0;
public function run(): void
{
$this->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, '<h2>Import/Export</h2>'), 'Die Kachel heißt Import/Export');
$this->assert(!str_contains($seite, '<h2>QIF-Import</h2>'), '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<string,mixed>|null, 1: array<string,mixed>|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<string> */
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, '<main'));
}
/** @param array<string,mixed> $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();
}
}

View file

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

View file

@ -188,8 +188,8 @@ test.describe('Stammdaten', () => {
'', '',
].join('\r\n') ].join('\r\n')
await page.goto('/verwaltung/import') await page.goto('/verwaltung/import?format=qif')
await page.setInputFiles('#qif', { await page.setInputFiles('#datei', {
name: dateiname, name: dateiname,
mimeType: 'application/octet-stream', mimeType: 'application/octet-stream',
buffer: Buffer.from(inhalt, 'latin1'), buffer: Buffer.from(inhalt, 'latin1'),
@ -215,7 +215,7 @@ test.describe('Stammdaten', () => {
await expect(page.locator('body')).not.toContainText(klasse) await expect(page.locator('body')).not.toContainText(klasse)
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen. // 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()) page.on('dialog', async (dialog) => await dialog.accept())
await page.locator('tr', { hasText: dateiname }) await page.locator('tr', { hasText: dateiname })
.getByRole('button', { name: 'Löschen' }).click() .getByRole('button', { name: 'Löschen' }).click()
@ -254,8 +254,8 @@ test.describe('Stammdaten', () => {
'', '',
].join('\r\n') ].join('\r\n')
await page.goto('/verwaltung/import') await page.goto('/verwaltung/import?format=qif')
await page.setInputFiles('#qif', { await page.setInputFiles('#datei', {
name: dateiname, name: dateiname,
mimeType: 'application/octet-stream', mimeType: 'application/octet-stream',
buffer: Buffer.from(inhalt, 'latin1'), buffer: Buffer.from(inhalt, 'latin1'),
@ -286,7 +286,7 @@ test.describe('Stammdaten', () => {
await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).not.toBeChecked() await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).not.toBeChecked()
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen. // 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()) page.on('dialog', async (dialog) => await dialog.accept())
await page.locator('tr', { hasText: dateiname }) await page.locator('tr', { hasText: dateiname })
.getByRole('button', { name: 'Löschen' }).click() .getByRole('button', { name: 'Löschen' }).click()