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:
parent
9839bd5b16
commit
2c86e8522b
30 changed files with 3424 additions and 371 deletions
66
Changes.md
66
Changes.md
|
|
@ -1,5 +1,71 @@
|
|||
# Änderungen
|
||||
|
||||
## Import und Export in zwei Formaten (17.08.2026)
|
||||
|
||||
Aus der Kachel *QIF-Import* wird **Import/Export** mit je einem Knopf für beide Richtungen.
|
||||
Wer darauf drückt, wählt zuerst das **Dateiformat** – QIF oder CSV –, und erst danach öffnet
|
||||
sich der Dialog, der genau dieses Format annimmt bzw. schreibt. Bisher gab es nur den einen
|
||||
Weg hinein: QIF, und nur herein.
|
||||
|
||||
**Formatwahl** (`Dateiformat`, `Views/verwaltung/formatwahl.php`)
|
||||
|
||||
- Die Formate stehen an einer Stelle (`Dateiformat::ALLE`); Dialoge, Auswahlkarten und die
|
||||
Prüfung der Dateiendung hängen daran.
|
||||
- Auf der Verwaltungsseite ist die Auswahl ein `<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)
|
||||
|
||||
Die Umsatzlisten des Bankings haben jetzt dieselbe Löschfunktion wie ein Kontoblatt: ein
|
||||
|
|
|
|||
2
PLAN.md
2
PLAN.md
|
|
@ -232,7 +232,7 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag
|
|||
`!Type:Cat`, `!Account` — mit Trockenlauf, Protokoll und `scripts/qif-import.php` für
|
||||
die Kommandozeile.
|
||||
* *Ergebnis:* 17 Klassen, 105 Kategorien und 37 Konten sind aus dem Export übernommen und
|
||||
in der Oberfläche gepflegt. Einzelheiten in `docs/stammdaten.md` und `docs/qif-import.md`.
|
||||
in der Oberfläche gepflegt. Einzelheiten in `docs/stammdaten.md` und `docs/import-export.md`.
|
||||
|
||||
### Phase 2 — Buchungen *(erledigt)*
|
||||
* Migrationen `buchungen`, `buchung_splits`, `buchungsadressen`.
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -15,6 +15,9 @@ Haushalt wird beides parallel genutzt.
|
|||
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt
|
||||
mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen;
|
||||
QIF-Import mit Stichtag.
|
||||
* **Import und Export** – QIF und CSV in beide Richtungen: Stammdaten und Buchungen
|
||||
übernehmen, Buchungen nach Zeitraum, Konto, Kategorie und Klasse gefiltert ausgeben.
|
||||
Exportdateien werden nicht vorgehalten; das Housekeeping räumt hochgeladene Dateien weg.
|
||||
* **Planung** – Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf,
|
||||
Snapshot und Sperre.
|
||||
* **Auswertungen** – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht mit
|
||||
|
|
@ -83,7 +86,7 @@ Ersteinrichtung und legt den ersten Benutzer an.
|
|||
src/ Anwendungscode (Controllers, Models, Services, Views)
|
||||
public/ Front-Controller und statische Dateien
|
||||
migrations/ nummerierte SQL-Migrationen, laufen beim Containerstart
|
||||
scripts/ Migration, Update, Backup, Tests, Kursabruf
|
||||
scripts/ Migration, Update, Backup, Tests, Kursabruf, Housekeeping
|
||||
tests/ PHP-Testsuite; tests/e2e/ für Playwright
|
||||
data/ Nutzdaten: mariadb, logs, import, schluessel (nicht im Repository)
|
||||
Altdaten/ Screenshots und QIF-Export der Altanwendung (nicht im Repository)
|
||||
|
|
@ -97,7 +100,7 @@ Buchungen samt Splits sowie Wertpapierbuchungen. Einzelheiten zum Format und den
|
|||
Fallstricken stehen in `PLAN.md`, Kapitel 4.
|
||||
|
||||
Klassen, Kategorien, Konten und Buchungen lassen sich übernehmen – im Dialog unter
|
||||
*Verwaltung → QIF-Import* oder auf der Kommandozeile. Buchungen ab dem Stichtag kommen
|
||||
*Verwaltung → Import/Export* oder auf der Kommandozeile. Buchungen ab dem Stichtag kommen
|
||||
einzeln in die Datenbank, alles davor wird je Konto zum Anfangssaldo verrechnet:
|
||||
|
||||
```bash
|
||||
|
|
@ -109,6 +112,11 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf. Mit `--konto=NAME` (mehrfach an
|
|||
im Dialog als Ankreuzliste nach dem Trockenlauf) bleibt der Import auf einzelne Konten
|
||||
beschränkt; `--konten` nennt die Konten der Datei.
|
||||
|
||||
In die andere Richtung geht es über *Verwaltung → Import/Export → Export*: Buchungen als
|
||||
QIF oder CSV, gefiltert nach Zeitraum, Konto, Kategorie und Klasse. Die Datei entsteht
|
||||
erst beim Herunterladen und wird nirgends abgelegt. Der CSV-Import liest dieselbe Datei
|
||||
wieder ein. Einzelheiten in `docs/import-export.md`.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
- `PLAN.md` – Projektplan mit allen Phasen
|
||||
|
|
@ -122,4 +130,4 @@ beschränkt; `--konten` nennt die Konten der Datei.
|
|||
- `docs/banking.md` – FinTS-Abruf, TAN-Dialog, Abgleich und Regeln
|
||||
- `docs/wertpapiere.md` – Depots, Einstand, Kurse, Invst- und CSV-Bestandsimport
|
||||
- `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr
|
||||
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs
|
||||
- `docs/import-export.md` – QIF und CSV in beide Richtungen, Formatwahl, Housekeeping
|
||||
|
|
|
|||
|
|
@ -1,4 +1,19 @@
|
|||
# QIF-Import
|
||||
# Import und Export
|
||||
|
||||
Unter *Verwaltung → Import/Export* führen zwei Knöpfe hinein. Beide fragen zuerst nach dem
|
||||
**Dateiformat** (QIF oder CSV) und öffnen danach den passenden Dialog – der Import weiß
|
||||
dann, welche Dateien er annimmt, der Export, was er schreiben soll.
|
||||
|
||||
| | QIF | CSV |
|
||||
|---|---|---|
|
||||
| Import | Klassen, Kategorien, Konten, Buchungen, Buchungsvorlagen | Buchungen samt Splitzeilen |
|
||||
| Export | Stammdaten und Buchungen | Buchungen samt Splitzeilen |
|
||||
|
||||
Die Formate stehen an einer Stelle: `Dateiformat::ALLE`. Kommt ein drittes hinzu, ist das
|
||||
die einzige Liste, die es zu ergänzen gilt – Dialoge, Auswahlkarten und die Prüfung des
|
||||
Dateinamens hängen daran.
|
||||
|
||||
## QIF-Import
|
||||
|
||||
Der Lexware FinanzManager exportiert erweitertes Quicken-QIF. Die `.QDF`-Datei ist
|
||||
verschlüsselt und damit keine Migrationsquelle – der QIF-Export ist der einzige Weg.
|
||||
|
|
@ -7,7 +22,7 @@ Stand: Klassen, Kategorien, Konten (Phase 1) sowie Buchungen samt Splitzeilen un
|
|||
Umbuchungen (Phase 2) werden übernommen. Wertpapierbestände folgen in Phase 6,
|
||||
Buchungsvorlagen in Phase 7.
|
||||
|
||||
## Aufbau der Datei
|
||||
### Aufbau der Datei
|
||||
|
||||
```
|
||||
!Option:MDY Datumsformat für die gesamte Datei
|
||||
|
|
@ -33,7 +48,7 @@ Jeder Kontoeintrag setzt zugleich das aktuelle Konto. In der Kontenliste folgen
|
|||
aufeinander, ohne dass Buchungen dazwischenstehen; das schadet nicht und deckt zugleich die
|
||||
zweite Schreibweise ab, bei der ein einzelner `!Account` den Kontowechsel bedeutet.
|
||||
|
||||
## Felder der Stammdaten
|
||||
### Felder der Stammdaten
|
||||
|
||||
| Sektion | Feld | Bedeutung |
|
||||
|---|---|---|
|
||||
|
|
@ -53,7 +68,7 @@ Zuordnung. Der Import kann sie deshalb nicht anlegen: nach dem Einlesen stehen a
|
|||
unter „Nicht gruppiert“. Die Gruppen werden einmalig unter *Verwaltung → Kontengruppen*
|
||||
angelegt; dort lassen sich die Konten einer Gruppe in einem Zug ankreuzen.
|
||||
|
||||
## Fallstricke
|
||||
### Fallstricke
|
||||
|
||||
1. **Zeichensatz ISO-8859-1, Zeilenenden CRLF.** `QifParser::nachUtf8()` prüft erst, ob
|
||||
schon UTF-8 vorliegt – eine zweite Konvertierung zerstörte die Umlaute.
|
||||
|
|
@ -76,7 +91,7 @@ angelegt; dort lassen sich die Konten einer Gruppe in einem Zug ankreuzen.
|
|||
9. **Anfangssalden stehen nicht als eigenes Feld im Export.** Sie ergeben sich aus den
|
||||
Buchungen vor dem Stichtag, Gegenbuchungen eingerechnet.
|
||||
|
||||
## Arbeitsweise der Importeure
|
||||
### Arbeitsweise der Importeure
|
||||
|
||||
Beide sind **idempotent**: Was es schon gibt, wird gezählt und übersprungen, nie doppelt
|
||||
angelegt. Ein zweiter Lauf derselben Datei ändert nichts. Der echte Lauf liegt jeweils in
|
||||
|
|
@ -100,7 +115,7 @@ die der Import selbst anlegen würde, erscheinen in der Vorschau.
|
|||
Verrechnungskonto zu Buche (`L|[Konto]` und `$`). Ein Betrag auf dem Depot ohne
|
||||
Gegenwert würde die Summe der Kontostände verfälschen; Bestände folgen in Phase 6.
|
||||
|
||||
## Nur einzelne Konten übernehmen
|
||||
### Nur einzelne Konten übernehmen
|
||||
|
||||
Der Import lässt sich auf einzelne Konten begrenzen (Option `konten`, im Dialog eine
|
||||
Ankreuzliste, auf der Kommandozeile `--konto=NAME`). Drei Dinge sind dabei entschieden:
|
||||
|
|
@ -132,11 +147,12 @@ Text. Weil es echte Doppelbuchungen am selben Tag gibt (zweimal dasselbe im selb
|
|||
zählt ein laufender Index gleiche Fingerabdrücke durch – stabil über Läufe hinweg, weil
|
||||
die Datei immer dieselbe Reihenfolge hat.
|
||||
|
||||
## Bedienung
|
||||
### Bedienung
|
||||
|
||||
Im Dialog unter *Verwaltung → QIF-Import*: Datei hochladen, Trockenlauf, dann übernehmen.
|
||||
Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit derselbe Export
|
||||
erst geprüft und dann übernommen werden kann.
|
||||
Im Dialog unter *Verwaltung → Import/Export → Import → QIF*: Datei hochladen, Trockenlauf,
|
||||
dann übernehmen. Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit
|
||||
derselbe Export erst geprüft und dann übernommen werden kann; das
|
||||
[Housekeeping](#housekeeping) räumt sie später weg.
|
||||
|
||||
Nach dem Trockenlauf steht unter *Konten* die Ankreuzliste der Konten, die in der Datei
|
||||
vorkommen – vorher gibt es nichts zu wählen, weil die Datei noch nicht gelesen wurde. Von
|
||||
|
|
@ -167,7 +183,7 @@ docker compose exec -u www-data php php scripts/qif-import.php \
|
|||
`--konto=` grenzt den Import ein und ist mehrfach angebbar; ein Name, den die Datei nicht
|
||||
führt, bricht ab – sonst liefe der Import durch und legte nichts an.
|
||||
|
||||
## Prüfung gegen den echten Export
|
||||
### Prüfung gegen den echten Export
|
||||
|
||||
Der Vollexport (8780 Buchungen, 2017–2026) ergibt mit Stichtag 01.01.2026:
|
||||
|
||||
|
|
@ -185,3 +201,123 @@ darunter Wertpapiergeschäfte: 16 (nur Geldfluss)
|
|||
Der Härtetest sind die Salden: Acht Konten, deren Werte aus der Kontenübersicht des
|
||||
FinanzManagers vorliegen, stimmen danach **auf den Cent** – von „Whg Besigheim“
|
||||
(210.601,99 €) bis „Auslagen Eltern Wohnungskauf“ (−22.410,94 €).
|
||||
|
||||
## CSV-Import
|
||||
|
||||
Das Gegenstück zum CSV-Export: Buchungen samt Splitzeilen, sonst nichts. Konten,
|
||||
Kontengruppen und Buchungsvorlagen bleiben dem QIF vorbehalten.
|
||||
|
||||
**Die Kopfzeile bestimmt die Spalten, nicht ihre Reihenfolge.** Eine Datei aus einem
|
||||
anderen Programm darf sie umsortieren oder eigene ergänzen; verglichen wird
|
||||
kleingeschrieben und ohne Umlaute (`CsvTabelle::schluessel()`), „Empfänger“ und
|
||||
„empfaenger“ meinen dasselbe. Unverzichtbar sind `Datum`, `Konto` und `Betrag` – fehlt
|
||||
eine davon, bricht der Lauf ab, statt eine halbe Datei einzulesen.
|
||||
|
||||
| Spalte | Inhalt |
|
||||
|---|---|
|
||||
| `Art` | `Buchung` oder `Splitzeile` |
|
||||
| `Datum` / `Valuta` | `TT.MM.JJJJ` oder `JJJJ-MM-TT` |
|
||||
| `Konto` | Name eines vorhandenen Kontos |
|
||||
| `Nummer`, `Empfänger`, `Buchungstext` | wie im Kontoblatt; bei Splitzeilen ist `Buchungstext` die Notiz |
|
||||
| `Kategorie` | vollständiger Pfad mit `:` |
|
||||
| `Klasse` | Name |
|
||||
| `Gegenkonto` | statt einer Kategorie: macht die Zeile zur Umbuchung |
|
||||
| `Betrag` | deutsch (`-84,00`) oder englisch (`-84.00`) |
|
||||
| `Abgeglichen` | `ja`/`nein` |
|
||||
|
||||
Entschieden ist dabei:
|
||||
|
||||
1. **Splitzeilen folgen unmittelbar auf ihre Buchung** und tragen in `Art` das Wort
|
||||
`Splitzeile`. Alles bis zur nächsten Buchungszeile gehört dazu. Eine Splitzeile ohne
|
||||
Buchung davor ist ein Fehler und wird gemeldet – stillschweigend fallen zu lassen wäre
|
||||
ein unbemerkter Verlust. Die Beträge der Zeilen müssen den Buchungsbetrag ergeben; das
|
||||
prüft `Buchung::anlegen()`.
|
||||
2. **Kategorien und Klassen legt der Import bei Bedarf an, Konten nicht.** Eine Kategorie
|
||||
ist ein Pfad und mehr nicht; ein Konto trägt Kontoart, Bankverbindung und Anfangssaldo,
|
||||
und nichts davon steht in der Zeile. Ein unbekanntes Konto ist deshalb ein Fehler.
|
||||
3. **Dieselbe Dublettenerkennung wie beim QIF**: Fingerabdruck aus Konto, Datum, Betrag,
|
||||
Nummer und Text, mit laufendem Index für echte Doppelbuchungen am selben Tag. Ein
|
||||
zweiter Lauf derselben Datei ändert nichts.
|
||||
4. **Zahlen und Trenner werden erkannt, nicht vorausgesetzt.** `CsvTabelle` sieht auf der
|
||||
Kopfzeile nach, ob Semikolon oder Komma trennt, und liest Beträge in beiden
|
||||
Schreibweisen: Maßgeblich ist, welches Zeichen zuletzt steht – es trennt die
|
||||
Nachkommastellen, das andere ist Tausendertrenner. Gelesen wird über einen
|
||||
Speicherstrom und `fgetcsv()`, damit ein maskiertes Feld auch einen Zeilenumbruch
|
||||
enthalten darf.
|
||||
|
||||
## Export
|
||||
|
||||
*Verwaltung → Import/Export → Export*, dann das Format, dann der Filter: **Zeitraum,
|
||||
Konto, Kategorie und Klasse**. Ohne Häkchen zählt jeweils alles. Über dem Formular steht,
|
||||
wie viele Buchungen der Filter trifft – sonst lädt man eine leere Datei herunter und merkt
|
||||
es erst in der Tabellenkalkulation.
|
||||
|
||||
Zwei Eigenheiten des Datenmodells schlagen auf den Filter durch:
|
||||
|
||||
* **Der Kontofilter greift auf beide Seiten.** Eine Umbuchung ist EINE Buchung mit
|
||||
`gegenkonto_id` und steht nur beim erfassenden Konto. Zählte allein dieses, fehlte dem
|
||||
Auszug des Gegenkontos seine einzige Bewegung.
|
||||
* **Kategorie und Klasse zählen auch in den Splitzeilen.** Sonst fiele jede Splitbuchung
|
||||
aus dem Filter, obwohl sie die gesuchte Kategorie führt.
|
||||
|
||||
Die Kontenliste der Datei enthält zusätzlich jedes Konto, das nur Umbuchungsziel ist. Ohne
|
||||
seinen Eintrag bezöge sich die Datei auf ein Konto, das der Import nicht kennt, und die
|
||||
Umbuchung ginge verloren.
|
||||
|
||||
**Die Datei wird nicht vorgehalten.** Sie entsteht im Speicher und geht mit
|
||||
`Content-Disposition: attachment` unmittelbar an den Browser; ein Exportverzeichnis gibt es
|
||||
bewusst nicht. Ein Auszug der eigenen Finanzen, der irgendwo im Dateisystem liegen bleibt,
|
||||
ist eine Altlast, die niemand aufräumt.
|
||||
|
||||
Das Filterformular sendet als **POST**. Bei vielen angehakten Kategorien wächst eine
|
||||
GET-Adresse über das, was nginx annimmt – die Antwort wäre dann „414 Request-URI Too Large“
|
||||
statt der Datei. Die GET-Route bleibt für kurze Adressen bestehen, die man sich merken mag.
|
||||
|
||||
### QIF-Export
|
||||
|
||||
Derselbe Dialekt, den der Import liest: erst `!Type:Class`, dann `!Type:Cat`, dann die
|
||||
Kontenliste zwischen `!Option:AutoSwitch` und `!Clear:AutoSwitch`, danach je Konto ein
|
||||
`!Account`-Kopf und dessen Buchungen. Datumsformat ist `DMY` mit vierstelligem Jahr
|
||||
(`!Option:DMY`), Beträge stehen im US-Format.
|
||||
|
||||
Klassen und Kategorien kommen **vollständig** mit, auch wenn der Filter enger ist: Ohne sie
|
||||
ließe sich die Datei auf einer leeren Installation nicht einspielen. Die Konten dagegen nur,
|
||||
soweit der Filter sie zulässt – sonst wäre es kein Auszug mehr.
|
||||
|
||||
Wertpapiergeschäfte (`!Type:Invst`) stehen nicht darin; sie hängen am Depot und werden über
|
||||
die Wertpapierverwaltung gepflegt.
|
||||
|
||||
### CSV-Export
|
||||
|
||||
Deutsches Tabellenkalkulations-CSV: Semikolon als Trenner, Komma als Dezimalzeichen, kein
|
||||
Tausenderpunkt (der macht aus der Zahl einen Text), CRLF am Zeilenende und ein BOM vorweg –
|
||||
sonst zeigen Excel und LibreOffice beim Doppelklick „Übrige“ statt „Übrige“. Die Spalten
|
||||
sind die des CSV-Imports; dieselbe Datei liest er wieder ein.
|
||||
|
||||
`CsvTabelle` hält das Format an einer Stelle; `BerichtExport` (Auswertungen) nutzt dieselben
|
||||
Zeilen- und Zahlenregeln.
|
||||
|
||||
## Housekeeping
|
||||
|
||||
Exportdateien entstehen im Speicher und hinterlassen nichts. Die hochgeladenen
|
||||
Importdateien bleiben dagegen absichtlich liegen – erst Trockenlauf, dann Übernahme, ohne
|
||||
zweiten Upload. Genau dadurch sammeln sich im Importverzeichnis mit der Zeit Kontoauszüge
|
||||
an, die niemand mehr braucht.
|
||||
|
||||
`Housekeeping` löscht deshalb, was älter ist als die eingestellte Frist (*Einstellungen →
|
||||
Import/Export*, Vorgabe 14 Tage; `0` schaltet es ab). Überwacht werden das
|
||||
Importverzeichnis und `data/tmp`, der Platz für Arbeitsdateien; gelöscht werden nur
|
||||
gewöhnliche Dateien unmittelbar darin – ein Symlink zeigte womöglich aus dem überwachten
|
||||
Bereich heraus.
|
||||
|
||||
Angestoßen wird der Lauf **beiläufig beim Öffnen von Import und Export**, höchstens einmal
|
||||
am Tag (`housekeeping_letzter_lauf`). Ein Fehler dabei reißt die Seite nicht mit: Wer den
|
||||
Import öffnet, will importieren, nicht eine Fehlermeldung übers Aufräumen lesen. Für
|
||||
Installationen, die wochenlang nicht geöffnet werden, gibt es denselben Lauf als Skript:
|
||||
|
||||
```bash
|
||||
docker compose exec -u www-data php php scripts/housekeeping.php --probe
|
||||
docker compose exec -u www-data php php scripts/housekeeping.php --still
|
||||
```
|
||||
|
||||
`--probe` zählt nur auf, was wegfiele.
|
||||
|
|
@ -15,7 +15,7 @@ Gruppen werden wie Konten und Kategorien auf einer eigenen Formularseite gepfleg
|
|||
angekreuzt, `Kontengruppe::kontenZuordnen()` setzt sie in einem Zug. Ein Konto gehört zu
|
||||
genau einer Gruppe – wird es hier angehakt, verlässt es seine bisherige.
|
||||
|
||||
Der QIF-Export enthält **keine** Kontengruppen (siehe `docs/qif-import.md`); nach einem
|
||||
Der QIF-Export enthält **keine** Kontengruppen (siehe `docs/import-export.md`); nach einem
|
||||
Import stehen deshalb alle Konten unter „Nicht gruppiert“ und werden einmalig zugeordnet.
|
||||
|
||||
Kontoarten (`Konto::ARTEN`) und ihre Herkunft aus dem QIF-Export (`Konto::QIF_ARTEN`):
|
||||
|
|
|
|||
11
migrations/013_housekeeping.sql
Normal file
11
migrations/013_housekeeping.sql
Normal 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;
|
||||
|
|
@ -16,6 +16,7 @@ use Finanzmanager\Controllers\BuchungenController;
|
|||
use Finanzmanager\Controllers\BudgetController;
|
||||
use Finanzmanager\Controllers\CockpitController;
|
||||
use Finanzmanager\Controllers\EinstellungenController;
|
||||
use Finanzmanager\Controllers\ExportController;
|
||||
use Finanzmanager\Controllers\ImportController;
|
||||
use Finanzmanager\Controllers\KategorienController;
|
||||
use Finanzmanager\Controllers\KontenController;
|
||||
|
|
@ -315,6 +316,14 @@ $router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->
|
|||
$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren());
|
||||
$router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new ImportController())->loeschen($p));
|
||||
|
||||
// Der Filter kommt als POST: Sind viele Kategorien angehakt, wird eine
|
||||
// GET-Adresse länger, als der Webserver annimmt. Die GET-Route bleibt für den
|
||||
// ersten Aufruf und für kurze Adressen, die man sich merken mag.
|
||||
$router->get('/verwaltung/export', fn() => (new ExportController())->index());
|
||||
$router->post('/verwaltung/export', fn() => (new ExportController())->index());
|
||||
$router->get('/verwaltung/export/datei', fn() => (new ExportController())->datei());
|
||||
$router->post('/verwaltung/export/datei', fn() => (new ExportController())->datei());
|
||||
|
||||
try {
|
||||
$router->dispatch();
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -1149,6 +1149,50 @@ table.splittabelle input.rechts { text-align: right; }
|
|||
„Speichern“ und „Abbrechen“, damit es nicht im Vorbeigehen getroffen wird. */
|
||||
.splitdialog .knopfleiste .knopf.gefahr { margin-left: auto; }
|
||||
|
||||
/* Die Formatwahl vor Import und Export – als Dialog auf der Verwaltungsseite
|
||||
und als Kachelinhalt auf der Ausweichseite, deshalb zwei Ebenen: der Dialog
|
||||
trägt den Rahmen, .formatwahl die Karten. */
|
||||
.formatdialog {
|
||||
width: min(34rem, 92vw);
|
||||
padding: 1.1rem 1.25rem 1.25rem;
|
||||
border: 1px solid var(--rand-kraeftig);
|
||||
border-radius: var(--radius);
|
||||
background: var(--flaeche);
|
||||
color: var(--text);
|
||||
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
.formatdialog::backdrop { background: rgb(0 0 0 / 35%); }
|
||||
.formatdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; }
|
||||
.formatdialog .knopfleiste { margin-top: .9rem; }
|
||||
|
||||
.formatwahl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
gap: .7rem;
|
||||
margin-top: .8rem;
|
||||
}
|
||||
|
||||
.formatkarte {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .25rem;
|
||||
padding: .8rem .9rem;
|
||||
border: 1px solid var(--rand-kraeftig);
|
||||
border-radius: var(--radius);
|
||||
background: var(--flaeche-gedaempft);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.formatkarte:hover,
|
||||
.formatkarte:focus-visible {
|
||||
border-color: var(--akzent);
|
||||
background: color-mix(in srgb, var(--akzent) 8%, transparent);
|
||||
}
|
||||
|
||||
.formatname { font-weight: 600; font-size: 1.05rem; }
|
||||
|
||||
/* Der Wertpapierdialog trägt ein Formular und wird dadurch länger als der
|
||||
Splitdialog; bei kleinen Fenstern scrollt er. */
|
||||
.wertpapierdialog { max-height: 88vh; overflow-y: auto; }
|
||||
|
|
|
|||
|
|
@ -237,6 +237,35 @@
|
|||
anpassen();
|
||||
});
|
||||
|
||||
// Formatwahl von Import und Export. Die Kachel in der Verwaltung führt zwei
|
||||
// Knöpfe; welches Dateiformat gemeint ist, fragt dieser Dialog ab und hängt
|
||||
// es dann an die Zieladresse. Die Karten liefert die Ansicht mit – hier
|
||||
// stehen nur die Ziele.
|
||||
//
|
||||
// Kennt der Browser <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
|
||||
// Content-Security-Policy (style-src 'self') verwirft auch per JavaScript
|
||||
// gesetzte Inline-Styles, die Meldung bliebe sonst einfach stehen.
|
||||
|
|
|
|||
57
scripts/housekeeping.php
Normal file
57
scripts/housekeeping.php
Normal 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);
|
||||
|
|
@ -106,7 +106,11 @@ class EinstellungenController extends BaseController
|
|||
'banking_abgleich_tage' => 'text',
|
||||
'banking_produkt_id' => 'text',
|
||||
],
|
||||
'import' => ['import_qif_verzeichnis' => 'text', 'import_stichtag' => 'text'],
|
||||
'import' => [
|
||||
'import_qif_verzeichnis' => 'text',
|
||||
'import_stichtag' => 'text',
|
||||
'housekeeping_tage' => 'text',
|
||||
],
|
||||
'wertpapiere' => [
|
||||
'wertpapiere_kursdienst' => 'text',
|
||||
'wertpapiere_api_key' => 'text',
|
||||
|
|
|
|||
155
src/Controllers/ExportController.php
Normal file
155
src/Controllers/ExportController.php
Normal 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 : '';
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,9 @@ declare(strict_types=1);
|
|||
namespace Finanzmanager\Controllers;
|
||||
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Services\CsvBuchungsImport;
|
||||
use Finanzmanager\Services\Dateiformat;
|
||||
use Finanzmanager\Services\Housekeeping;
|
||||
use Finanzmanager\Services\QifBuchungsImport;
|
||||
use Finanzmanager\Services\QifParser;
|
||||
use Finanzmanager\Services\QifStammdatenImport;
|
||||
|
|
@ -12,58 +15,75 @@ use Finanzmanager\Services\QifVorlagenImport;
|
|||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* QIF-Import: Stammdaten (Klassen, Kategorien, Konten) und Buchungen.
|
||||
* Import: QIF (Stammdaten, Buchungen, Buchungsvorlagen) und CSV (Buchungen).
|
||||
*
|
||||
* Vor dem Hochladen steht die Formatwahl – erst danach weiß der Dialog, welche
|
||||
* Dateien er annimmt und welchen Ablauf er zeigt. Ohne `format` in der Adresse
|
||||
* erscheint deshalb die Auswahl; die Kachel in der Verwaltung nimmt sie über
|
||||
* ihren Auswahldialog vorweg.
|
||||
*
|
||||
* Hochgeladene Dateien landen im Importverzeichnis und bleiben dort – so lässt
|
||||
* sich derselbe Export erst im Trockenlauf prüfen und danach übernehmen, ohne
|
||||
* ihn zweimal hochzuladen.
|
||||
* ihn zweimal hochzuladen. Was zu lange liegen bleibt, räumt das Housekeeping
|
||||
* weg.
|
||||
*/
|
||||
class ImportController extends BaseController
|
||||
{
|
||||
private const ERLAUBTE_ENDUNGEN = ['qif'];
|
||||
/** Die Felder, die jede Ansicht kennt – ein Lauf füllt davon nur seine eigenen. */
|
||||
private const LEER = [
|
||||
'ergebnis' => null,
|
||||
'buchungen' => null,
|
||||
'vorlagen' => null,
|
||||
'ueberblick' => null,
|
||||
'datei' => '',
|
||||
'dateikonten' => [],
|
||||
'auswahl' => [],
|
||||
];
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
Housekeeping::beiGelegenheit();
|
||||
|
||||
$this->render('verwaltung/import.php', [
|
||||
'titel' => 'QIF-Import',
|
||||
'dateien' => $this->dateien(),
|
||||
'verzeichnis' => $this->verzeichnisAnzeige(),
|
||||
'ergebnis' => null,
|
||||
'buchungen' => null,
|
||||
'vorlagen' => null,
|
||||
'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'),
|
||||
'bis' => date('Y-m-d'),
|
||||
'datei' => '',
|
||||
'dateikonten' => [],
|
||||
'auswahl' => [],
|
||||
]);
|
||||
$format = Dateiformat::pruefe($this->query('format'));
|
||||
if ($format === null) {
|
||||
$this->formatwahl();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->render('verwaltung/import.php', array_merge(self::LEER, $this->grunddaten($format)));
|
||||
}
|
||||
|
||||
public function hochladen(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$datei = $_FILES['qif'] ?? null;
|
||||
$format = Dateiformat::pruefe($this->post('format')) ?? 'qif';
|
||||
$ziel = '/verwaltung/import?format=' . $format;
|
||||
|
||||
$datei = $_FILES['datei'] ?? null;
|
||||
if (!is_array($datei) || ($datei['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
View::redirect('/verwaltung/import', $this->uploadFehler($datei['error'] ?? UPLOAD_ERR_NO_FILE), 'fehler');
|
||||
View::redirect($ziel, $this->uploadFehler($datei['error'] ?? UPLOAD_ERR_NO_FILE), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
$name = self::sichererDateiname((string)$datei['name']);
|
||||
if ($name === null) {
|
||||
View::redirect('/verwaltung/import', 'Nur .QIF-Dateien können hochgeladen werden.', 'fehler');
|
||||
if ($name === null || Dateiformat::ausDateiname($name) !== $format) {
|
||||
View::redirect(
|
||||
$ziel,
|
||||
'Für diesen Import werden nur .' . strtoupper($format) . '-Dateien angenommen.',
|
||||
'fehler'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$ziel = $this->verzeichnis() . '/' . $name;
|
||||
if (!is_uploaded_file((string)$datei['tmp_name']) || !move_uploaded_file((string)$datei['tmp_name'], $ziel)) {
|
||||
View::redirect('/verwaltung/import', 'Die Datei konnte nicht abgelegt werden.', 'fehler');
|
||||
$pfad = $this->verzeichnis() . '/' . $name;
|
||||
if (!is_uploaded_file((string)$datei['tmp_name']) || !move_uploaded_file((string)$datei['tmp_name'], $pfad)) {
|
||||
View::redirect($ziel, 'Die Datei konnte nicht abgelegt werden.', 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
View::redirect('/verwaltung/import', 'Datei „' . $name . '“ hochgeladen.');
|
||||
View::redirect($ziel, 'Datei „' . $name . '“ hochgeladen.');
|
||||
}
|
||||
|
||||
public function ausfuehren(): void
|
||||
|
|
@ -76,18 +96,88 @@ class ImportController extends BaseController
|
|||
return;
|
||||
}
|
||||
|
||||
// Maßgeblich ist die Endung der Datei, nicht das Feld im Formular: Was
|
||||
// eingelesen wird, bestimmt der Inhalt – sonst liefe ein CSV durch den
|
||||
// QIF-Parser, bloß weil ein verstecktes Feld das behauptet.
|
||||
$format = Dateiformat::ausDateiname($name) ?? 'qif';
|
||||
$ziel = '/verwaltung/import?format=' . $format;
|
||||
|
||||
$pfad = $this->verzeichnis() . '/' . $name;
|
||||
if (!is_file($pfad)) {
|
||||
View::redirect('/verwaltung/import', 'Die Datei „' . $name . '“ gibt es nicht mehr.', 'fehler');
|
||||
View::redirect($ziel, 'Die Datei „' . $name . '“ gibt es nicht mehr.', 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
$trockenlauf = $this->post('modus') !== 'import';
|
||||
|
||||
try {
|
||||
$daten = $format === 'csv'
|
||||
? $this->csvLauf($pfad, $trockenlauf)
|
||||
: $this->qifLauf($pfad, $trockenlauf);
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect($ziel, 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reihenfolge zählt: Was der Lauf liefert, sticht die Vorbelegung –
|
||||
// etwa der im Formular gewählte Stichtag den aus den Einstellungen.
|
||||
$this->render('verwaltung/import.php', array_merge(
|
||||
self::LEER,
|
||||
$this->grunddaten($format),
|
||||
$daten,
|
||||
['datei' => $name]
|
||||
));
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
public function loeschen(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$name = self::sichererDateiname($params['datei'] ?? '');
|
||||
$format = $name === null ? 'qif' : (Dateiformat::ausDateiname($name) ?? 'qif');
|
||||
|
||||
if ($name !== null) {
|
||||
$pfad = $this->verzeichnis() . '/' . $name;
|
||||
if (is_file($pfad)) {
|
||||
unlink($pfad);
|
||||
}
|
||||
}
|
||||
|
||||
View::redirect('/verwaltung/import?format=' . $format, 'Datei gelöscht.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt nur den Dateinamen ohne Pfadanteile und lässt allein die erlaubten
|
||||
* Endungen durch – der Name landet ungeprüft in einer Pfadangabe.
|
||||
*/
|
||||
public static function sichererDateiname(string $eingabe): ?string
|
||||
{
|
||||
$name = basename(trim($eingabe));
|
||||
if ($name === '' || $name === '.' || $name === '..' || str_contains($name, "\0")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Dateiformat::ausDateiname($name) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Alles außer Buchstaben, Ziffern, Punkt, Strich und Unterstrich fällt
|
||||
// weg; damit bleiben keine Sonderzeichen für die Shell oder das
|
||||
// Dateisystem übrig.
|
||||
$name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? '';
|
||||
|
||||
return $name === '' ? null : $name;
|
||||
}
|
||||
|
||||
// ── 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');
|
||||
|
||||
try {
|
||||
$parser = QifParser::ausDatei($pfad);
|
||||
$ergebnis = (new QifStammdatenImport($parser))->ausfuehren($trockenlauf);
|
||||
|
||||
|
|
@ -114,73 +204,66 @@ class ImportController extends BaseController
|
|||
// zweiter Knopf für dieselbe Datei.
|
||||
$vorlagen = (new QifVorlagenImport($parser))->ausfuehren($trockenlauf);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect('/verwaltung/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->render('verwaltung/import.php', [
|
||||
'titel' => 'QIF-Import',
|
||||
'dateien' => $this->dateien(),
|
||||
'verzeichnis' => $this->verzeichnisAnzeige(),
|
||||
return [
|
||||
'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()],
|
||||
'buchungen' => $buchungen,
|
||||
'vorlagen' => $vorlagen,
|
||||
'stichtag' => $stichtag,
|
||||
'bis' => $bis,
|
||||
'datei' => $name,
|
||||
'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.',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
public function loeschen(array $params): void
|
||||
/** @return array<string,mixed> */
|
||||
private function grunddaten(string $format): array
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$name = self::sichererDateiname($params['datei'] ?? '');
|
||||
if ($name !== null) {
|
||||
$pfad = $this->verzeichnis() . '/' . $name;
|
||||
if (is_file($pfad)) {
|
||||
unlink($pfad);
|
||||
}
|
||||
}
|
||||
|
||||
View::redirect('/verwaltung/import', 'Datei gelöscht.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt nur den Dateinamen ohne Pfadanteile und lässt allein die erlaubten
|
||||
* Endungen durch – der Name landet ungeprüft in einer Pfadangabe.
|
||||
*/
|
||||
public static function sichererDateiname(string $eingabe): ?string
|
||||
{
|
||||
$name = basename(trim($eingabe));
|
||||
if ($name === '' || $name === '.' || $name === '..' || str_contains($name, "\0")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$endung = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
if (!in_array($endung, self::ERLAUBTE_ENDUNGEN, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Alles außer Buchstaben, Ziffern, Punkt, Strich und Unterstrich fällt
|
||||
// weg; damit bleiben keine Sonderzeichen für die Shell oder das
|
||||
// Dateisystem übrig.
|
||||
$name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? '';
|
||||
|
||||
return $name === '' ? null : $name;
|
||||
return [
|
||||
'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}> */
|
||||
private function dateien(): array
|
||||
private function dateien(string $format): array
|
||||
{
|
||||
$verzeichnis = $this->verzeichnis();
|
||||
$dateien = [];
|
||||
|
||||
foreach (glob($verzeichnis . '/*.[qQ][iI][fF]') ?: [] as $pfad) {
|
||||
foreach (glob($verzeichnis . '/*') ?: [] as $pfad) {
|
||||
if (!is_file($pfad) || Dateiformat::ausDateiname($pfad) !== $format) {
|
||||
continue;
|
||||
}
|
||||
$dateien[] = [
|
||||
'name' => basename($pfad),
|
||||
'groesse' => (int)filesize($pfad),
|
||||
|
|
|
|||
|
|
@ -11,15 +11,11 @@ use Finanzmanager\View;
|
|||
* Vorzeichen wie am Bildschirm. Wer den Export mit der Anzeige vergleicht, soll
|
||||
* nicht rechnen müssen.
|
||||
*
|
||||
* Deutsches Tabellenkalkulations-CSV: Semikolon als Trenner, Komma als
|
||||
* Dezimalzeichen, kein Tausenderpunkt (der macht aus der Zahl einen Text).
|
||||
* Vorangestellt ein BOM, sonst zeigen Excel und LibreOffice beim Doppelklick
|
||||
* „Übrige“ statt „Übrige“.
|
||||
* Das Dateiformat selbst – Trenner, Maskierung, Zahlenschreibweise – kommt aus
|
||||
* CsvTabelle; hier steht allein, welche Zeilen ein Bericht hergibt.
|
||||
*/
|
||||
class BerichtExport
|
||||
{
|
||||
private const BOM = "\u{FEFF}";
|
||||
|
||||
/** @param array<string,mixed> $bericht Rückgabe von Auswertung::bericht() */
|
||||
public static function csv(array $bericht): string
|
||||
{
|
||||
|
|
@ -36,12 +32,7 @@ class BerichtExport
|
|||
[],
|
||||
];
|
||||
|
||||
$text = '';
|
||||
foreach (array_merge($kopf, $zeilen) as $zeile) {
|
||||
$text .= self::zeile($zeile);
|
||||
}
|
||||
|
||||
return self::BOM . $text;
|
||||
return CsvTabelle::schreibe(array_merge($kopf, $zeilen));
|
||||
}
|
||||
|
||||
/** Dateiname ohne Pfad, aus Berichtsname und Zeitraum. */
|
||||
|
|
@ -182,29 +173,9 @@ class BerichtExport
|
|||
return $zeilen;
|
||||
}
|
||||
|
||||
/** @param list<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
|
||||
{
|
||||
return number_format($wert, $stellen, ',', '');
|
||||
return CsvTabelle::zahl($wert, $stellen);
|
||||
}
|
||||
|
||||
/** Umlaute im Dateinamen ausschreiben, statt sie zu Bindestrichen zu zermahlen. */
|
||||
|
|
|
|||
551
src/Services/BuchungsExport.php
Normal file
551
src/Services/BuchungsExport.php
Normal 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);
|
||||
}
|
||||
}
|
||||
396
src/Services/CsvBuchungsImport.php
Normal file
396
src/Services/CsvBuchungsImport.php
Normal 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
233
src/Services/CsvTabelle.php
Normal 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;
|
||||
}
|
||||
}
|
||||
51
src/Services/Dateiformat.php
Normal file
51
src/Services/Dateiformat.php
Normal 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));
|
||||
}
|
||||
}
|
||||
169
src/Services/Housekeeping.php
Normal file
169
src/Services/Housekeeping.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Services\Cockpit;
|
||||
use Finanzmanager\Services\Housekeeping;
|
||||
use Finanzmanager\Services\Kursabruf;
|
||||
use Finanzmanager\Services\Wertpapierkosten;
|
||||
use Finanzmanager\View;
|
||||
|
|
@ -15,6 +16,10 @@ use Finanzmanager\View;
|
|||
|
||||
$wert = static fn(string $k, string $standard = ''): string => (string)($werte[$k] ?? $standard);
|
||||
|
||||
// Der Zeitstempel des letzten Aufräumens ist kein Feld zum Bearbeiten, sondern
|
||||
// eine Auskunft – er steht deshalb im Hinweistext unter der Frist.
|
||||
$letzterLauf = $wert(Housekeeping::EINSTELLUNG_LAUF);
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
|
|
@ -83,9 +88,9 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<input type="text" id="import_qif_verzeichnis" name="import_qif_verzeichnis"
|
||||
value="<?= View::h($wert('import_qif_verzeichnis', 'data/import')) ?>">
|
||||
<p class="feldhinweis">
|
||||
Relativ zum Projektverzeichnis. Hochgeladene QIF-Dateien landen hier und bleiben
|
||||
liegen, damit derselbe Export erst im Trockenlauf geprüft und dann übernommen
|
||||
werden kann.
|
||||
Relativ zum Projektverzeichnis. Hochgeladene QIF- und CSV-Dateien landen hier und
|
||||
bleiben liegen, damit derselbe Export erst im Trockenlauf geprüft und dann
|
||||
übernommen werden kann.
|
||||
</p>
|
||||
|
||||
<label for="import_stichtag">Stichtag für Buchungen</label>
|
||||
|
|
@ -98,8 +103,22 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
FinanzManagers und bleiben draußen.
|
||||
</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">
|
||||
<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>
|
||||
|
||||
<?php elseif ($bereich === 'banking'): ?>
|
||||
|
|
|
|||
148
src/Views/verwaltung/export.php
Normal file
148
src/Views/verwaltung/export.php
Normal 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'; ?>
|
||||
32
src/Views/verwaltung/formatwahl.php
Normal file
32
src/Views/verwaltung/formatwahl.php
Normal 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'; ?>
|
||||
|
|
@ -1,248 +1,55 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Services\Dateiformat;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var string $format */
|
||||
/** @var list<array{name: string, groesse: int, geaendert: int}> $dateien */
|
||||
/** @var string $verzeichnis */
|
||||
/** @var array<string,mixed>|null $ergebnis */
|
||||
/** @var array<string,mixed>|null $buchungen */
|
||||
/** @var array<string,mixed>|null $vorlagen */
|
||||
/** @var array<string,mixed>|null $ueberblick */
|
||||
/** @var string $stichtag */
|
||||
/** @var string $bis */
|
||||
/** @var string $datei */
|
||||
/** @var array<string,int> $dateikonten */
|
||||
/** @var list<string> $auswahl */
|
||||
/** @var int $aufbewahrung */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$bereiche = [
|
||||
'klassen' => 'Klassen',
|
||||
'kategorien' => 'Kategorien',
|
||||
'konten' => 'Konten',
|
||||
];
|
||||
$endung = '.' . $format;
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
|
||||
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
|
||||
<a href="/konten" class="knopf">Kontenübersicht</a>
|
||||
<a href="/verwaltung/import" class="knopf">Anderes Format</a>
|
||||
<a href="/verwaltung/export?format=<?= View::h($format) ?>" class="knopf">Export</a>
|
||||
</div>
|
||||
|
||||
<?php if ($ergebnis !== null): ?>
|
||||
<div class="kachel">
|
||||
<header>
|
||||
<h2><?= $ergebnis['trockenlauf'] ? 'Trockenlauf' : 'Import abgeschlossen' ?></h2>
|
||||
<span class="phasenmarke"><?= View::h($datei) ?></span>
|
||||
</header>
|
||||
|
||||
<?php if ($ergebnis['trockenlauf']): ?>
|
||||
<p class="hinweis">
|
||||
Nichts wurde gespeichert. Die Zahlen zeigen, was ein Import anlegen würde.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bereich</th>
|
||||
<th class="rechts"><?= $ergebnis['trockenlauf'] ? 'Neu' : 'Angelegt' ?></th>
|
||||
<th class="rechts">Schon vorhanden</th>
|
||||
<th class="rechts">Fehler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($bereiche as $schluessel => $beschriftung): ?>
|
||||
<tr>
|
||||
<td><?= View::h($beschriftung) ?></td>
|
||||
<td class="rechts"><?= (int)$ergebnis[$schluessel]['neu'] ?></td>
|
||||
<td class="rechts nebentext"><?= (int)$ergebnis[$schluessel]['vorhanden'] ?></td>
|
||||
<td class="rechts <?= (int)$ergebnis[$schluessel]['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
|
||||
<?= (int)$ergebnis[$schluessel]['fehler'] ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<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 if ($format === 'csv'): ?>
|
||||
<?php include __DIR__ . '/teil-import-csv.php'; ?>
|
||||
<?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 include __DIR__ . '/teil-import-qif.php'; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<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">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<label for="qif">QIF-Datei</label>
|
||||
<input type="file" id="qif" name="qif" accept=".qif,.QIF" required>
|
||||
<input type="hidden" name="format" value="<?= View::h($format) ?>">
|
||||
<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">
|
||||
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.
|
||||
<?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>
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Hochladen</button>
|
||||
|
|
@ -251,24 +58,12 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>Vorhandene 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>
|
||||
<header><h2>Vorhandene <?= View::h(Dateiformat::name($format)) ?>-Dateien</h2></header>
|
||||
|
||||
<?php if ($dateien === []): ?>
|
||||
<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>
|
||||
<?php else: ?>
|
||||
<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="datei" value="<?= View::h($eintrag['name']) ?>">
|
||||
<input type="hidden" name="modus" value="vorschau">
|
||||
<?php if ($format === 'qif'): ?>
|
||||
<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>
|
||||
</form>
|
||||
<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="datei" value="<?= View::h($eintrag['name']) ?>">
|
||||
<input type="hidden" name="modus" value="import">
|
||||
<?php if ($format === 'qif'): ?>
|
||||
<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>
|
||||
</form>
|
||||
<form method="post" action="/verwaltung/import/<?= View::h(rawurlencode($eintrag['name'])) ?>/loeschen"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Services\Dateiformat;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,int> $zahlen */
|
||||
|
|
@ -44,13 +45,16 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>QIF-Import</h2></header>
|
||||
<header><h2>Import/Export</h2></header>
|
||||
<p class="platzhalter">
|
||||
Klassen, Kategorien und Konten aus einem Export des Lexware FinanzManagers
|
||||
übernehmen – erst im Trockenlauf prüfen, dann übernehmen.
|
||||
Stammdaten und Buchungen aus dem Lexware FinanzManager übernehmen oder den
|
||||
eigenen Bestand herausgeben – wahlweise als QIF oder als CSV.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
|
|
@ -94,4 +98,25 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</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'; ?>
|
||||
|
|
|
|||
127
src/Views/verwaltung/teil-import-csv.php
Normal file
127
src/Views/verwaltung/teil-import-csv.php
Normal 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>
|
||||
244
src/Views/verwaltung/teil-import-qif.php
Normal file
244
src/Views/verwaltung/teil-import-qif.php
Normal 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>
|
||||
|
|
@ -161,7 +161,7 @@ class NavigationTest extends TestCase
|
|||
|
||||
// Letzter Punkt: keine weitere Kachel hinter der von Verträge.
|
||||
$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(
|
||||
$letzte > (int)strrpos($kacheln, '<h2>' . $vorher . '</h2>'),
|
||||
'Verträge stehen hinter der Kachel ' . $vorher
|
||||
|
|
|
|||
559
tests/29_ImportExportTest.php
Normal file
559
tests/29_ImportExportTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
127
tests/e2e/specs/importexport.spec.js
Normal file
127
tests/e2e/specs/importexport.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -188,8 +188,8 @@ test.describe('Stammdaten', () => {
|
|||
'',
|
||||
].join('\r\n')
|
||||
|
||||
await page.goto('/verwaltung/import')
|
||||
await page.setInputFiles('#qif', {
|
||||
await page.goto('/verwaltung/import?format=qif')
|
||||
await page.setInputFiles('#datei', {
|
||||
name: dateiname,
|
||||
mimeType: 'application/octet-stream',
|
||||
buffer: Buffer.from(inhalt, 'latin1'),
|
||||
|
|
@ -215,7 +215,7 @@ test.describe('Stammdaten', () => {
|
|||
await expect(page.locator('body')).not.toContainText(klasse)
|
||||
|
||||
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
|
||||
await page.goto('/verwaltung/import')
|
||||
await page.goto('/verwaltung/import?format=qif')
|
||||
page.on('dialog', async (dialog) => await dialog.accept())
|
||||
await page.locator('tr', { hasText: dateiname })
|
||||
.getByRole('button', { name: 'Löschen' }).click()
|
||||
|
|
@ -254,8 +254,8 @@ test.describe('Stammdaten', () => {
|
|||
'',
|
||||
].join('\r\n')
|
||||
|
||||
await page.goto('/verwaltung/import')
|
||||
await page.setInputFiles('#qif', {
|
||||
await page.goto('/verwaltung/import?format=qif')
|
||||
await page.setInputFiles('#datei', {
|
||||
name: dateiname,
|
||||
mimeType: 'application/octet-stream',
|
||||
buffer: Buffer.from(inhalt, 'latin1'),
|
||||
|
|
@ -286,7 +286,7 @@ test.describe('Stammdaten', () => {
|
|||
await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).not.toBeChecked()
|
||||
|
||||
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
|
||||
await page.goto('/verwaltung/import')
|
||||
await page.goto('/verwaltung/import?format=qif')
|
||||
page.on('dialog', async (dialog) => await dialog.accept())
|
||||
await page.locator('tr', { hasText: dateiname })
|
||||
.getByRole('button', { name: 'Löschen' }).click()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue