Merge pull request 'Sammel-PR: Umsätze sammelweise löschen, Import/Export in QIF und CSV, Konto- und Buchungsformular zweispaltig' (#32) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/32
This commit is contained in:
commit
5072e9d5d8
38 changed files with 4170 additions and 495 deletions
128
Changes.md
128
Changes.md
|
|
@ -1,5 +1,133 @@
|
||||||
# Änderungen
|
# Änderungen
|
||||||
|
|
||||||
|
## Buchungsmaske nennt das Konto und passt auf einen Bildschirm (17.08.2026)
|
||||||
|
|
||||||
|
*Neue Buchung* und *Buchung bearbeiten* zeigen oben, in welchem Konto die Buchung liegt
|
||||||
|
bzw. gespeichert wird; bisher stand das Konto nur klein in der Kopfzeile über dem
|
||||||
|
Formular. Die Maske steht zweispaltig, die Beschriftungen links neben ihren Eingaben und
|
||||||
|
die Erklärungen in deren Tooltip – damit endet sie samt Splitzeilen und Knopfleiste
|
||||||
|
oberhalb der Fensterhöhe, wo sie vorher über 1190 px lang war.
|
||||||
|
|
||||||
|
- Kontoangabe als erstes Feld über beide Spalten: Name des Kontos, dahinter die IBAN –
|
||||||
|
oder, wenn keine hinterlegt ist, Bank und Kontonummer.
|
||||||
|
- Feldreihenfolge zeilenweise: Art/Datum, Betrag/Empfänger, Kategorie-Gegenkonto/Klasse,
|
||||||
|
Buchungstext/Nummer, darunter *Abgeglichen*. Namen, Werte und Feld-IDs bleiben
|
||||||
|
unverändert; der Bereich *Splitbuchung* läuft weiter über die volle Breite.
|
||||||
|
- Beschriftungen stehen links neben ihrer Eingabe, nicht darüber (neue Variante
|
||||||
|
`.formular.beschriftung-links`). Das halbiert die Höhe je Feld; Hinweise laufen unter
|
||||||
|
Beschriftung und Eingabe hinweg, unter 560 px Fensterbreite rutscht die Beschriftung
|
||||||
|
wieder nach oben.
|
||||||
|
- Die erklärenden Texte stehen nicht mehr als eigene Zeile zwischen den Feldern, sondern
|
||||||
|
im `title` ihrer Beschriftung: Konto, Betrag, *Kategorie / Gegenkonto* und
|
||||||
|
*Splitbuchung*. Eine gepunktete Unterstreichung zeigt, wo ein Tooltip hängt.
|
||||||
|
- `.formular.zweispaltig` (`public/static/app.css`) stellt jetzt auch Tabellen und
|
||||||
|
`.feldwert` richtig: Tabellen laufen über beide Spalten, ohne eigenen Rand.
|
||||||
|
- E2E-Test in `tests/e2e/specs/buchungen.spec.js`: Die Maske nennt das Konto, Datum steht
|
||||||
|
rechts neben der Art, die Beschriftung links neben ihrer Eingabe, die Erklärungen hängen
|
||||||
|
in deren Tooltip, und das ganze Formular endet oberhalb der Fensterhöhe.
|
||||||
|
|
||||||
|
## Kontoformular zweispaltig (17.08.2026)
|
||||||
|
|
||||||
|
*Konto bearbeiten* und *Neues Konto* zeigen ihre Felder in zwei Spalten. Alle Eingaben
|
||||||
|
stehen damit auf einem Bildschirm, ohne zu scrollen; bisher lagen die vierzehn Felder
|
||||||
|
untereinander.
|
||||||
|
|
||||||
|
- Neue Formularvariante `.formular.zweispaltig` (`public/static/app.css`): ein Grid mit
|
||||||
|
zwei Spalten, je Feld ein `.feld` aus Beschriftung, Eingabe und Hinweis. Überschriften,
|
||||||
|
Knopfleiste und `.feld.ganz` laufen über beide Spalten. Unter 720 px Fensterbreite fällt
|
||||||
|
das Formular auf eine Spalte zurück.
|
||||||
|
- Feldreihenfolge zeilenweise: Name/Kontoart, Kontengruppe/Beschreibung, Bank/Kontonummer,
|
||||||
|
IBAN/BIC, Anfangssaldo/Kreditlimit, Sortierung/Kennzeichen. Namen, Werte und Feld-IDs
|
||||||
|
bleiben unverändert.
|
||||||
|
- Der Bereich *Konto löschen* darunter bekommt dieselbe Breite, bleibt aber einspaltig.
|
||||||
|
- E2E-Test in `tests/e2e/specs/stammdaten.spec.js`: Kontoart steht rechts neben dem Namen,
|
||||||
|
und das Formular endet oberhalb der Fensterhöhe.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
Ankreuzfeld vor jedem Umsatz, eines in der Titelleiste für alle angezeigten und darüber den
|
||||||
|
Knopf **Auswahl löschen**. Bisher ließ sich ein abgerufener Umsatz nur übernehmen oder
|
||||||
|
verwerfen – wegräumen ging gar nicht.
|
||||||
|
|
||||||
|
- Der Knopf steht in jedem Reiter – *offen*, *übernommen* und *verworfen* –, nennt die Anzahl
|
||||||
|
der Auswahl und bleibt ohne Auswahl gesperrt. Die Rückfrage vor dem Löschen nennt sie
|
||||||
|
ebenfalls.
|
||||||
|
- Gelöscht wird allein die abgerufene Zeile. Eine daraus entstandene Buchung bleibt im
|
||||||
|
Kontoblatt stehen; mit dem Umsatz geht aber sein Fingerabdruck, sodass er beim nächsten
|
||||||
|
Abruf wieder hereinkommen kann.
|
||||||
|
- Neue Route `POST /banking/umsaetze/loeschen` auf `Bankumsatz::mehrereLoeschen()`; sie kehrt
|
||||||
|
in den Reiter zurück, aus dem gelöscht wurde.
|
||||||
|
- Die Liste ist keine Tabelle, sondern eine Folge von Karten. Die Kopfzeile, die eine Tabelle
|
||||||
|
von selbst hätte, gibt es deshalb neu als `.listenkopf`; die Mechanik dahinter ist
|
||||||
|
dieselbe wie im Kontoblatt (`.alle-waehlen` mit `data-waehlt`).
|
||||||
|
|
||||||
## Ruhige Buchungszeilen im Kontoblatt (17.08.2026)
|
## Ruhige Buchungszeilen im Kontoblatt (17.08.2026)
|
||||||
|
|
||||||
Die Zeilen des Kontoblatts sind jetzt alle gleich hoch – zwei Textzeilen – und ihr Inhalt
|
Die Zeilen des Kontoblatts sind jetzt alle gleich hoch – zwei Textzeilen – und ihr Inhalt
|
||||||
|
|
|
||||||
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
|
`!Type:Cat`, `!Account` — mit Trockenlauf, Protokoll und `scripts/qif-import.php` für
|
||||||
die Kommandozeile.
|
die Kommandozeile.
|
||||||
* *Ergebnis:* 17 Klassen, 105 Kategorien und 37 Konten sind aus dem Export übernommen und
|
* *Ergebnis:* 17 Klassen, 105 Kategorien und 37 Konten sind aus dem Export übernommen und
|
||||||
in der Oberfläche gepflegt. Einzelheiten in `docs/stammdaten.md` und `docs/qif-import.md`.
|
in der Oberfläche gepflegt. Einzelheiten in `docs/stammdaten.md` und `docs/import-export.md`.
|
||||||
|
|
||||||
### Phase 2 — Buchungen *(erledigt)*
|
### Phase 2 — Buchungen *(erledigt)*
|
||||||
* Migrationen `buchungen`, `buchung_splits`, `buchungsadressen`.
|
* Migrationen `buchungen`, `buchung_splits`, `buchungsadressen`.
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -15,6 +15,9 @@ Haushalt wird beides parallel genutzt.
|
||||||
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt
|
* **Stammdaten und Buchungen** – Konten, Kontengruppen, Kategorien, Klassen; Kontoblatt
|
||||||
mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen;
|
mit Schnellzuordnung und Splitdialog, Buchungsmaske mit Splitbuchungen und Umbuchungen;
|
||||||
QIF-Import mit Stichtag.
|
QIF-Import mit Stichtag.
|
||||||
|
* **Import und Export** – QIF und CSV in beide Richtungen: Stammdaten und Buchungen
|
||||||
|
übernehmen, Buchungen nach Zeitraum, Konto, Kategorie und Klasse gefiltert ausgeben.
|
||||||
|
Exportdateien werden nicht vorgehalten; das Housekeeping räumt hochgeladene Dateien weg.
|
||||||
* **Planung** – Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf,
|
* **Planung** – Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf,
|
||||||
Snapshot und Sperre.
|
Snapshot und Sperre.
|
||||||
* **Auswertungen** – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht mit
|
* **Auswertungen** – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht mit
|
||||||
|
|
@ -83,7 +86,7 @@ Ersteinrichtung und legt den ersten Benutzer an.
|
||||||
src/ Anwendungscode (Controllers, Models, Services, Views)
|
src/ Anwendungscode (Controllers, Models, Services, Views)
|
||||||
public/ Front-Controller und statische Dateien
|
public/ Front-Controller und statische Dateien
|
||||||
migrations/ nummerierte SQL-Migrationen, laufen beim Containerstart
|
migrations/ nummerierte SQL-Migrationen, laufen beim Containerstart
|
||||||
scripts/ Migration, Update, Backup, Tests, Kursabruf
|
scripts/ Migration, Update, Backup, Tests, Kursabruf, Housekeeping
|
||||||
tests/ PHP-Testsuite; tests/e2e/ für Playwright
|
tests/ PHP-Testsuite; tests/e2e/ für Playwright
|
||||||
data/ Nutzdaten: mariadb, logs, import, schluessel (nicht im Repository)
|
data/ Nutzdaten: mariadb, logs, import, schluessel (nicht im Repository)
|
||||||
Altdaten/ Screenshots und QIF-Export der Altanwendung (nicht im Repository)
|
Altdaten/ Screenshots und QIF-Export der Altanwendung (nicht im Repository)
|
||||||
|
|
@ -97,7 +100,7 @@ Buchungen samt Splits sowie Wertpapierbuchungen. Einzelheiten zum Format und den
|
||||||
Fallstricken stehen in `PLAN.md`, Kapitel 4.
|
Fallstricken stehen in `PLAN.md`, Kapitel 4.
|
||||||
|
|
||||||
Klassen, Kategorien, Konten und Buchungen lassen sich übernehmen – im Dialog unter
|
Klassen, Kategorien, Konten und Buchungen lassen sich übernehmen – im Dialog unter
|
||||||
*Verwaltung → QIF-Import* oder auf der Kommandozeile. Buchungen ab dem Stichtag kommen
|
*Verwaltung → Import/Export* oder auf der Kommandozeile. Buchungen ab dem Stichtag kommen
|
||||||
einzeln in die Datenbank, alles davor wird je Konto zum Anfangssaldo verrechnet:
|
einzeln in die Datenbank, alles davor wird je Konto zum Anfangssaldo verrechnet:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -109,6 +112,11 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf. Mit `--konto=NAME` (mehrfach an
|
||||||
im Dialog als Ankreuzliste nach dem Trockenlauf) bleibt der Import auf einzelne Konten
|
im Dialog als Ankreuzliste nach dem Trockenlauf) bleibt der Import auf einzelne Konten
|
||||||
beschränkt; `--konten` nennt die Konten der Datei.
|
beschränkt; `--konten` nennt die Konten der Datei.
|
||||||
|
|
||||||
|
In die andere Richtung geht es über *Verwaltung → Import/Export → Export*: Buchungen als
|
||||||
|
QIF oder CSV, gefiltert nach Zeitraum, Konto, Kategorie und Klasse. Die Datei entsteht
|
||||||
|
erst beim Herunterladen und wird nirgends abgelegt. Der CSV-Import liest dieselbe Datei
|
||||||
|
wieder ein. Einzelheiten in `docs/import-export.md`.
|
||||||
|
|
||||||
## Dokumentation
|
## Dokumentation
|
||||||
|
|
||||||
- `PLAN.md` – Projektplan mit allen Phasen
|
- `PLAN.md` – Projektplan mit allen Phasen
|
||||||
|
|
@ -122,4 +130,4 @@ beschränkt; `--konten` nennt die Konten der Datei.
|
||||||
- `docs/banking.md` – FinTS-Abruf, TAN-Dialog, Abgleich und Regeln
|
- `docs/banking.md` – FinTS-Abruf, TAN-Dialog, Abgleich und Regeln
|
||||||
- `docs/wertpapiere.md` – Depots, Einstand, Kurse, Invst- und CSV-Bestandsimport
|
- `docs/wertpapiere.md` – Depots, Einstand, Kurse, Invst- und CSV-Bestandsimport
|
||||||
- `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr
|
- `docs/nebenmodule.md` – Vorlagen, Termine, Verträge, Sparziele, Kredite, Zahlungsverkehr
|
||||||
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs
|
- `docs/import-export.md` – QIF und CSV in beide Richtungen, Formatwahl, Housekeeping
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,20 @@ Nichts verschwindet von allein: Ein Umsatz, der weder übernommen noch verworfen
|
||||||
offen und steht beim nächsten Öffnen wieder da. Das Cockpit zählt die offenen unter *zum
|
offen und steht beim nächsten Öffnen wieder da. Das Cockpit zählt die offenen unter *zum
|
||||||
Abgleich*.
|
Abgleich*.
|
||||||
|
|
||||||
|
### Umsätze löschen
|
||||||
|
|
||||||
|
Jeder Reiter der Liste – *offen*, *übernommen*, *verworfen* – hat dieselbe Mehrfachauswahl
|
||||||
|
wie ein Kontoblatt: ein Ankreuzfeld vor jedem Umsatz, eines in der Titelleiste für alle
|
||||||
|
angezeigten und darüber der Knopf **Auswahl löschen** (`POST /banking/umsaetze/loeschen`,
|
||||||
|
`Bankumsatz::mehrereLoeschen()`). Der Knopf nennt die Anzahl und bleibt ohne Auswahl
|
||||||
|
gesperrt; ohne JavaScript weist der Controller eine leere Auswahl ab.
|
||||||
|
|
||||||
|
Gelöscht wird allein die abgerufene Zeile. Eine daraus entstandene Buchung gehört ins
|
||||||
|
Kontoblatt und bleibt dort stehen – wer sie loswerden will, löscht sie dort, und dann kehrt
|
||||||
|
ihr Umsatz ohnehin in die Vorschlagsliste zurück. Mit dem Umsatz geht aber sein
|
||||||
|
Fingerabdruck: Was innerhalb des nächsten Abrufzeitraums liegt, kommt danach wieder herein –
|
||||||
|
bei einem verworfenen Umsatz also erneut als offen.
|
||||||
|
|
||||||
## Regeln
|
## Regeln
|
||||||
|
|
||||||
Eine Regel prüft ein Muster gegen Empfänger und/oder Verwendungszweck:
|
Eine Regel prüft ein Muster gegen Empfänger und/oder Verwendungszweck:
|
||||||
|
|
@ -312,6 +326,12 @@ angehängt, und das Speichern des Zugangsformulars lässt die Wahl stehen. Die A
|
||||||
Bank steckt hinter derselben Grenze wie der Dialog – geprüft wird, was mit ihrem Ergebnis
|
Bank steckt hinter derselben Grenze wie der Dialog – geprüft wird, was mit ihrem Ergebnis
|
||||||
geschieht.
|
geschieht.
|
||||||
|
|
||||||
|
`tests/28_UmsaetzeLoeschenTest.php` prüft das Sammellöschen: Es trifft genau die
|
||||||
|
ausgewählten Zeilen, eine schon erzeugte Buchung überlebt es, jeder Status lässt sich
|
||||||
|
löschen, leere und unbekannte Auswahl bleiben folgenlos, eine doppelt gesendete Nummer zählt
|
||||||
|
einmal – und die gerenderte Ansicht trägt in jedem Reiter Ankreuzfelder, Titelleiste und
|
||||||
|
Knopf, während die leere Liste keinen Knopf zeigt.
|
||||||
|
|
||||||
`tests/19_KontenzuordnungTest.php` nimmt sich den Weg vor, den ein Umsatz nimmt, wenn die
|
`tests/19_KontenzuordnungTest.php` nimmt sich den Weg vor, den ein Umsatz nimmt, wenn die
|
||||||
Zuordnung erst nach dem Abruf getroffen wird: Offene Umsätze ziehen mit, übernommene nicht,
|
Zuordnung erst nach dem Abruf getroffen wird: Offene Umsätze ziehen mit, übernommene nicht,
|
||||||
und nur das Bankkonto, um das es geht. Dazu das automatische Buchen – mit Regel, ohne Regel,
|
und nur das Bankkonto, um das es geht. Dazu das automatische Buchen – mit Regel, ohne Regel,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,19 @@
|
||||||
# QIF-Import
|
# Import und Export
|
||||||
|
|
||||||
|
Unter *Verwaltung → Import/Export* führen zwei Knöpfe hinein. Beide fragen zuerst nach dem
|
||||||
|
**Dateiformat** (QIF oder CSV) und öffnen danach den passenden Dialog – der Import weiß
|
||||||
|
dann, welche Dateien er annimmt, der Export, was er schreiben soll.
|
||||||
|
|
||||||
|
| | QIF | CSV |
|
||||||
|
|---|---|---|
|
||||||
|
| Import | Klassen, Kategorien, Konten, Buchungen, Buchungsvorlagen | Buchungen samt Splitzeilen |
|
||||||
|
| Export | Stammdaten und Buchungen | Buchungen samt Splitzeilen |
|
||||||
|
|
||||||
|
Die Formate stehen an einer Stelle: `Dateiformat::ALLE`. Kommt ein drittes hinzu, ist das
|
||||||
|
die einzige Liste, die es zu ergänzen gilt – Dialoge, Auswahlkarten und die Prüfung des
|
||||||
|
Dateinamens hängen daran.
|
||||||
|
|
||||||
|
## QIF-Import
|
||||||
|
|
||||||
Der Lexware FinanzManager exportiert erweitertes Quicken-QIF. Die `.QDF`-Datei ist
|
Der Lexware FinanzManager exportiert erweitertes Quicken-QIF. Die `.QDF`-Datei ist
|
||||||
verschlüsselt und damit keine Migrationsquelle – der QIF-Export ist der einzige Weg.
|
verschlüsselt und damit keine Migrationsquelle – der QIF-Export ist der einzige Weg.
|
||||||
|
|
@ -7,7 +22,7 @@ Stand: Klassen, Kategorien, Konten (Phase 1) sowie Buchungen samt Splitzeilen un
|
||||||
Umbuchungen (Phase 2) werden übernommen. Wertpapierbestände folgen in Phase 6,
|
Umbuchungen (Phase 2) werden übernommen. Wertpapierbestände folgen in Phase 6,
|
||||||
Buchungsvorlagen in Phase 7.
|
Buchungsvorlagen in Phase 7.
|
||||||
|
|
||||||
## Aufbau der Datei
|
### Aufbau der Datei
|
||||||
|
|
||||||
```
|
```
|
||||||
!Option:MDY Datumsformat für die gesamte Datei
|
!Option:MDY Datumsformat für die gesamte Datei
|
||||||
|
|
@ -33,7 +48,7 @@ Jeder Kontoeintrag setzt zugleich das aktuelle Konto. In der Kontenliste folgen
|
||||||
aufeinander, ohne dass Buchungen dazwischenstehen; das schadet nicht und deckt zugleich die
|
aufeinander, ohne dass Buchungen dazwischenstehen; das schadet nicht und deckt zugleich die
|
||||||
zweite Schreibweise ab, bei der ein einzelner `!Account` den Kontowechsel bedeutet.
|
zweite Schreibweise ab, bei der ein einzelner `!Account` den Kontowechsel bedeutet.
|
||||||
|
|
||||||
## Felder der Stammdaten
|
### Felder der Stammdaten
|
||||||
|
|
||||||
| Sektion | Feld | Bedeutung |
|
| Sektion | Feld | Bedeutung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -53,7 +68,7 @@ Zuordnung. Der Import kann sie deshalb nicht anlegen: nach dem Einlesen stehen a
|
||||||
unter „Nicht gruppiert“. Die Gruppen werden einmalig unter *Verwaltung → Kontengruppen*
|
unter „Nicht gruppiert“. Die Gruppen werden einmalig unter *Verwaltung → Kontengruppen*
|
||||||
angelegt; dort lassen sich die Konten einer Gruppe in einem Zug ankreuzen.
|
angelegt; dort lassen sich die Konten einer Gruppe in einem Zug ankreuzen.
|
||||||
|
|
||||||
## Fallstricke
|
### Fallstricke
|
||||||
|
|
||||||
1. **Zeichensatz ISO-8859-1, Zeilenenden CRLF.** `QifParser::nachUtf8()` prüft erst, ob
|
1. **Zeichensatz ISO-8859-1, Zeilenenden CRLF.** `QifParser::nachUtf8()` prüft erst, ob
|
||||||
schon UTF-8 vorliegt – eine zweite Konvertierung zerstörte die Umlaute.
|
schon UTF-8 vorliegt – eine zweite Konvertierung zerstörte die Umlaute.
|
||||||
|
|
@ -76,7 +91,7 @@ angelegt; dort lassen sich die Konten einer Gruppe in einem Zug ankreuzen.
|
||||||
9. **Anfangssalden stehen nicht als eigenes Feld im Export.** Sie ergeben sich aus den
|
9. **Anfangssalden stehen nicht als eigenes Feld im Export.** Sie ergeben sich aus den
|
||||||
Buchungen vor dem Stichtag, Gegenbuchungen eingerechnet.
|
Buchungen vor dem Stichtag, Gegenbuchungen eingerechnet.
|
||||||
|
|
||||||
## Arbeitsweise der Importeure
|
### Arbeitsweise der Importeure
|
||||||
|
|
||||||
Beide sind **idempotent**: Was es schon gibt, wird gezählt und übersprungen, nie doppelt
|
Beide sind **idempotent**: Was es schon gibt, wird gezählt und übersprungen, nie doppelt
|
||||||
angelegt. Ein zweiter Lauf derselben Datei ändert nichts. Der echte Lauf liegt jeweils in
|
angelegt. Ein zweiter Lauf derselben Datei ändert nichts. Der echte Lauf liegt jeweils in
|
||||||
|
|
@ -100,7 +115,7 @@ die der Import selbst anlegen würde, erscheinen in der Vorschau.
|
||||||
Verrechnungskonto zu Buche (`L|[Konto]` und `$`). Ein Betrag auf dem Depot ohne
|
Verrechnungskonto zu Buche (`L|[Konto]` und `$`). Ein Betrag auf dem Depot ohne
|
||||||
Gegenwert würde die Summe der Kontostände verfälschen; Bestände folgen in Phase 6.
|
Gegenwert würde die Summe der Kontostände verfälschen; Bestände folgen in Phase 6.
|
||||||
|
|
||||||
## Nur einzelne Konten übernehmen
|
### Nur einzelne Konten übernehmen
|
||||||
|
|
||||||
Der Import lässt sich auf einzelne Konten begrenzen (Option `konten`, im Dialog eine
|
Der Import lässt sich auf einzelne Konten begrenzen (Option `konten`, im Dialog eine
|
||||||
Ankreuzliste, auf der Kommandozeile `--konto=NAME`). Drei Dinge sind dabei entschieden:
|
Ankreuzliste, auf der Kommandozeile `--konto=NAME`). Drei Dinge sind dabei entschieden:
|
||||||
|
|
@ -132,11 +147,12 @@ Text. Weil es echte Doppelbuchungen am selben Tag gibt (zweimal dasselbe im selb
|
||||||
zählt ein laufender Index gleiche Fingerabdrücke durch – stabil über Läufe hinweg, weil
|
zählt ein laufender Index gleiche Fingerabdrücke durch – stabil über Läufe hinweg, weil
|
||||||
die Datei immer dieselbe Reihenfolge hat.
|
die Datei immer dieselbe Reihenfolge hat.
|
||||||
|
|
||||||
## Bedienung
|
### Bedienung
|
||||||
|
|
||||||
Im Dialog unter *Verwaltung → QIF-Import*: Datei hochladen, Trockenlauf, dann übernehmen.
|
Im Dialog unter *Verwaltung → Import/Export → Import → QIF*: Datei hochladen, Trockenlauf,
|
||||||
Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit derselbe Export
|
dann übernehmen. Hochgeladene Dateien bleiben im Importverzeichnis (`data/import`), damit
|
||||||
erst geprüft und dann übernommen werden kann.
|
derselbe Export erst geprüft und dann übernommen werden kann; das
|
||||||
|
[Housekeeping](#housekeeping) räumt sie später weg.
|
||||||
|
|
||||||
Nach dem Trockenlauf steht unter *Konten* die Ankreuzliste der Konten, die in der Datei
|
Nach dem Trockenlauf steht unter *Konten* die Ankreuzliste der Konten, die in der Datei
|
||||||
vorkommen – vorher gibt es nichts zu wählen, weil die Datei noch nicht gelesen wurde. Von
|
vorkommen – vorher gibt es nichts zu wählen, weil die Datei noch nicht gelesen wurde. Von
|
||||||
|
|
@ -167,7 +183,7 @@ docker compose exec -u www-data php php scripts/qif-import.php \
|
||||||
`--konto=` grenzt den Import ein und ist mehrfach angebbar; ein Name, den die Datei nicht
|
`--konto=` grenzt den Import ein und ist mehrfach angebbar; ein Name, den die Datei nicht
|
||||||
führt, bricht ab – sonst liefe der Import durch und legte nichts an.
|
führt, bricht ab – sonst liefe der Import durch und legte nichts an.
|
||||||
|
|
||||||
## Prüfung gegen den echten Export
|
### Prüfung gegen den echten Export
|
||||||
|
|
||||||
Der Vollexport (8780 Buchungen, 2017–2026) ergibt mit Stichtag 01.01.2026:
|
Der Vollexport (8780 Buchungen, 2017–2026) ergibt mit Stichtag 01.01.2026:
|
||||||
|
|
||||||
|
|
@ -185,3 +201,123 @@ darunter Wertpapiergeschäfte: 16 (nur Geldfluss)
|
||||||
Der Härtetest sind die Salden: Acht Konten, deren Werte aus der Kontenübersicht des
|
Der Härtetest sind die Salden: Acht Konten, deren Werte aus der Kontenübersicht des
|
||||||
FinanzManagers vorliegen, stimmen danach **auf den Cent** – von „Whg Besigheim“
|
FinanzManagers vorliegen, stimmen danach **auf den Cent** – von „Whg Besigheim“
|
||||||
(210.601,99 €) bis „Auslagen Eltern Wohnungskauf“ (−22.410,94 €).
|
(210.601,99 €) bis „Auslagen Eltern Wohnungskauf“ (−22.410,94 €).
|
||||||
|
|
||||||
|
## CSV-Import
|
||||||
|
|
||||||
|
Das Gegenstück zum CSV-Export: Buchungen samt Splitzeilen, sonst nichts. Konten,
|
||||||
|
Kontengruppen und Buchungsvorlagen bleiben dem QIF vorbehalten.
|
||||||
|
|
||||||
|
**Die Kopfzeile bestimmt die Spalten, nicht ihre Reihenfolge.** Eine Datei aus einem
|
||||||
|
anderen Programm darf sie umsortieren oder eigene ergänzen; verglichen wird
|
||||||
|
kleingeschrieben und ohne Umlaute (`CsvTabelle::schluessel()`), „Empfänger“ und
|
||||||
|
„empfaenger“ meinen dasselbe. Unverzichtbar sind `Datum`, `Konto` und `Betrag` – fehlt
|
||||||
|
eine davon, bricht der Lauf ab, statt eine halbe Datei einzulesen.
|
||||||
|
|
||||||
|
| Spalte | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `Art` | `Buchung` oder `Splitzeile` |
|
||||||
|
| `Datum` / `Valuta` | `TT.MM.JJJJ` oder `JJJJ-MM-TT` |
|
||||||
|
| `Konto` | Name eines vorhandenen Kontos |
|
||||||
|
| `Nummer`, `Empfänger`, `Buchungstext` | wie im Kontoblatt; bei Splitzeilen ist `Buchungstext` die Notiz |
|
||||||
|
| `Kategorie` | vollständiger Pfad mit `:` |
|
||||||
|
| `Klasse` | Name |
|
||||||
|
| `Gegenkonto` | statt einer Kategorie: macht die Zeile zur Umbuchung |
|
||||||
|
| `Betrag` | deutsch (`-84,00`) oder englisch (`-84.00`) |
|
||||||
|
| `Abgeglichen` | `ja`/`nein` |
|
||||||
|
|
||||||
|
Entschieden ist dabei:
|
||||||
|
|
||||||
|
1. **Splitzeilen folgen unmittelbar auf ihre Buchung** und tragen in `Art` das Wort
|
||||||
|
`Splitzeile`. Alles bis zur nächsten Buchungszeile gehört dazu. Eine Splitzeile ohne
|
||||||
|
Buchung davor ist ein Fehler und wird gemeldet – stillschweigend fallen zu lassen wäre
|
||||||
|
ein unbemerkter Verlust. Die Beträge der Zeilen müssen den Buchungsbetrag ergeben; das
|
||||||
|
prüft `Buchung::anlegen()`.
|
||||||
|
2. **Kategorien und Klassen legt der Import bei Bedarf an, Konten nicht.** Eine Kategorie
|
||||||
|
ist ein Pfad und mehr nicht; ein Konto trägt Kontoart, Bankverbindung und Anfangssaldo,
|
||||||
|
und nichts davon steht in der Zeile. Ein unbekanntes Konto ist deshalb ein Fehler.
|
||||||
|
3. **Dieselbe Dublettenerkennung wie beim QIF**: Fingerabdruck aus Konto, Datum, Betrag,
|
||||||
|
Nummer und Text, mit laufendem Index für echte Doppelbuchungen am selben Tag. Ein
|
||||||
|
zweiter Lauf derselben Datei ändert nichts.
|
||||||
|
4. **Zahlen und Trenner werden erkannt, nicht vorausgesetzt.** `CsvTabelle` sieht auf der
|
||||||
|
Kopfzeile nach, ob Semikolon oder Komma trennt, und liest Beträge in beiden
|
||||||
|
Schreibweisen: Maßgeblich ist, welches Zeichen zuletzt steht – es trennt die
|
||||||
|
Nachkommastellen, das andere ist Tausendertrenner. Gelesen wird über einen
|
||||||
|
Speicherstrom und `fgetcsv()`, damit ein maskiertes Feld auch einen Zeilenumbruch
|
||||||
|
enthalten darf.
|
||||||
|
|
||||||
|
## Export
|
||||||
|
|
||||||
|
*Verwaltung → Import/Export → Export*, dann das Format, dann der Filter: **Zeitraum,
|
||||||
|
Konto, Kategorie und Klasse**. Ohne Häkchen zählt jeweils alles. Über dem Formular steht,
|
||||||
|
wie viele Buchungen der Filter trifft – sonst lädt man eine leere Datei herunter und merkt
|
||||||
|
es erst in der Tabellenkalkulation.
|
||||||
|
|
||||||
|
Zwei Eigenheiten des Datenmodells schlagen auf den Filter durch:
|
||||||
|
|
||||||
|
* **Der Kontofilter greift auf beide Seiten.** Eine Umbuchung ist EINE Buchung mit
|
||||||
|
`gegenkonto_id` und steht nur beim erfassenden Konto. Zählte allein dieses, fehlte dem
|
||||||
|
Auszug des Gegenkontos seine einzige Bewegung.
|
||||||
|
* **Kategorie und Klasse zählen auch in den Splitzeilen.** Sonst fiele jede Splitbuchung
|
||||||
|
aus dem Filter, obwohl sie die gesuchte Kategorie führt.
|
||||||
|
|
||||||
|
Die Kontenliste der Datei enthält zusätzlich jedes Konto, das nur Umbuchungsziel ist. Ohne
|
||||||
|
seinen Eintrag bezöge sich die Datei auf ein Konto, das der Import nicht kennt, und die
|
||||||
|
Umbuchung ginge verloren.
|
||||||
|
|
||||||
|
**Die Datei wird nicht vorgehalten.** Sie entsteht im Speicher und geht mit
|
||||||
|
`Content-Disposition: attachment` unmittelbar an den Browser; ein Exportverzeichnis gibt es
|
||||||
|
bewusst nicht. Ein Auszug der eigenen Finanzen, der irgendwo im Dateisystem liegen bleibt,
|
||||||
|
ist eine Altlast, die niemand aufräumt.
|
||||||
|
|
||||||
|
Das Filterformular sendet als **POST**. Bei vielen angehakten Kategorien wächst eine
|
||||||
|
GET-Adresse über das, was nginx annimmt – die Antwort wäre dann „414 Request-URI Too Large“
|
||||||
|
statt der Datei. Die GET-Route bleibt für kurze Adressen bestehen, die man sich merken mag.
|
||||||
|
|
||||||
|
### QIF-Export
|
||||||
|
|
||||||
|
Derselbe Dialekt, den der Import liest: erst `!Type:Class`, dann `!Type:Cat`, dann die
|
||||||
|
Kontenliste zwischen `!Option:AutoSwitch` und `!Clear:AutoSwitch`, danach je Konto ein
|
||||||
|
`!Account`-Kopf und dessen Buchungen. Datumsformat ist `DMY` mit vierstelligem Jahr
|
||||||
|
(`!Option:DMY`), Beträge stehen im US-Format.
|
||||||
|
|
||||||
|
Klassen und Kategorien kommen **vollständig** mit, auch wenn der Filter enger ist: Ohne sie
|
||||||
|
ließe sich die Datei auf einer leeren Installation nicht einspielen. Die Konten dagegen nur,
|
||||||
|
soweit der Filter sie zulässt – sonst wäre es kein Auszug mehr.
|
||||||
|
|
||||||
|
Wertpapiergeschäfte (`!Type:Invst`) stehen nicht darin; sie hängen am Depot und werden über
|
||||||
|
die Wertpapierverwaltung gepflegt.
|
||||||
|
|
||||||
|
### CSV-Export
|
||||||
|
|
||||||
|
Deutsches Tabellenkalkulations-CSV: Semikolon als Trenner, Komma als Dezimalzeichen, kein
|
||||||
|
Tausenderpunkt (der macht aus der Zahl einen Text), CRLF am Zeilenende und ein BOM vorweg –
|
||||||
|
sonst zeigen Excel und LibreOffice beim Doppelklick „Übrige“ statt „Übrige“. Die Spalten
|
||||||
|
sind die des CSV-Imports; dieselbe Datei liest er wieder ein.
|
||||||
|
|
||||||
|
`CsvTabelle` hält das Format an einer Stelle; `BerichtExport` (Auswertungen) nutzt dieselben
|
||||||
|
Zeilen- und Zahlenregeln.
|
||||||
|
|
||||||
|
## Housekeeping
|
||||||
|
|
||||||
|
Exportdateien entstehen im Speicher und hinterlassen nichts. Die hochgeladenen
|
||||||
|
Importdateien bleiben dagegen absichtlich liegen – erst Trockenlauf, dann Übernahme, ohne
|
||||||
|
zweiten Upload. Genau dadurch sammeln sich im Importverzeichnis mit der Zeit Kontoauszüge
|
||||||
|
an, die niemand mehr braucht.
|
||||||
|
|
||||||
|
`Housekeeping` löscht deshalb, was älter ist als die eingestellte Frist (*Einstellungen →
|
||||||
|
Import/Export*, Vorgabe 14 Tage; `0` schaltet es ab). Überwacht werden das
|
||||||
|
Importverzeichnis und `data/tmp`, der Platz für Arbeitsdateien; gelöscht werden nur
|
||||||
|
gewöhnliche Dateien unmittelbar darin – ein Symlink zeigte womöglich aus dem überwachten
|
||||||
|
Bereich heraus.
|
||||||
|
|
||||||
|
Angestoßen wird der Lauf **beiläufig beim Öffnen von Import und Export**, höchstens einmal
|
||||||
|
am Tag (`housekeeping_letzter_lauf`). Ein Fehler dabei reißt die Seite nicht mit: Wer den
|
||||||
|
Import öffnet, will importieren, nicht eine Fehlermeldung übers Aufräumen lesen. Für
|
||||||
|
Installationen, die wochenlang nicht geöffnet werden, gibt es denselben Lauf als Skript:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -u www-data php php scripts/housekeeping.php --probe
|
||||||
|
docker compose exec -u www-data php php scripts/housekeeping.php --still
|
||||||
|
```
|
||||||
|
|
||||||
|
`--probe` zählt nur auf, was wegfiele.
|
||||||
|
|
@ -15,7 +15,7 @@ Gruppen werden wie Konten und Kategorien auf einer eigenen Formularseite gepfleg
|
||||||
angekreuzt, `Kontengruppe::kontenZuordnen()` setzt sie in einem Zug. Ein Konto gehört zu
|
angekreuzt, `Kontengruppe::kontenZuordnen()` setzt sie in einem Zug. Ein Konto gehört zu
|
||||||
genau einer Gruppe – wird es hier angehakt, verlässt es seine bisherige.
|
genau einer Gruppe – wird es hier angehakt, verlässt es seine bisherige.
|
||||||
|
|
||||||
Der QIF-Export enthält **keine** Kontengruppen (siehe `docs/qif-import.md`); nach einem
|
Der QIF-Export enthält **keine** Kontengruppen (siehe `docs/import-export.md`); nach einem
|
||||||
Import stehen deshalb alle Konten unter „Nicht gruppiert“ und werden einmalig zugeordnet.
|
Import stehen deshalb alle Konten unter „Nicht gruppiert“ und werden einmalig zugeordnet.
|
||||||
|
|
||||||
Kontoarten (`Konto::ARTEN`) und ihre Herkunft aus dem QIF-Export (`Konto::QIF_ARTEN`):
|
Kontoarten (`Konto::ARTEN`) und ihre Herkunft aus dem QIF-Export (`Konto::QIF_ARTEN`):
|
||||||
|
|
|
||||||
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\BudgetController;
|
||||||
use Finanzmanager\Controllers\CockpitController;
|
use Finanzmanager\Controllers\CockpitController;
|
||||||
use Finanzmanager\Controllers\EinstellungenController;
|
use Finanzmanager\Controllers\EinstellungenController;
|
||||||
|
use Finanzmanager\Controllers\ExportController;
|
||||||
use Finanzmanager\Controllers\ImportController;
|
use Finanzmanager\Controllers\ImportController;
|
||||||
use Finanzmanager\Controllers\KategorienController;
|
use Finanzmanager\Controllers\KategorienController;
|
||||||
use Finanzmanager\Controllers\KontenController;
|
use Finanzmanager\Controllers\KontenController;
|
||||||
|
|
@ -278,6 +279,7 @@ $router->post('/banking/neu', fn() => (new BankingController())->anlegen());
|
||||||
// „/banking/umsaetze“ und „/banking/regeln“.
|
// „/banking/umsaetze“ und „/banking/regeln“.
|
||||||
$router->get('/banking/umsaetze', fn() => (new BankingController())->umsaetze());
|
$router->get('/banking/umsaetze', fn() => (new BankingController())->umsaetze());
|
||||||
$router->post('/banking/umsaetze/alle', fn() => (new BankingController())->alleUebernehmen());
|
$router->post('/banking/umsaetze/alle', fn() => (new BankingController())->alleUebernehmen());
|
||||||
|
$router->post('/banking/umsaetze/loeschen', fn() => (new BankingController())->umsaetzeLoeschen());
|
||||||
$router->post('/banking/umsaetze/{id}/uebernehmen',
|
$router->post('/banking/umsaetze/{id}/uebernehmen',
|
||||||
fn(array $p) => (new BankingController())->uebernehmen($p));
|
fn(array $p) => (new BankingController())->uebernehmen($p));
|
||||||
$router->post('/banking/umsaetze/{id}/verwerfen',
|
$router->post('/banking/umsaetze/{id}/verwerfen',
|
||||||
|
|
@ -314,6 +316,14 @@ $router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->
|
||||||
$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren());
|
$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren());
|
||||||
$router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new ImportController())->loeschen($p));
|
$router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new ImportController())->loeschen($p));
|
||||||
|
|
||||||
|
// Der Filter kommt als POST: Sind viele Kategorien angehakt, wird eine
|
||||||
|
// GET-Adresse länger, als der Webserver annimmt. Die GET-Route bleibt für den
|
||||||
|
// ersten Aufruf und für kurze Adressen, die man sich merken mag.
|
||||||
|
$router->get('/verwaltung/export', fn() => (new ExportController())->index());
|
||||||
|
$router->post('/verwaltung/export', fn() => (new ExportController())->index());
|
||||||
|
$router->get('/verwaltung/export/datei', fn() => (new ExportController())->datei());
|
||||||
|
$router->post('/verwaltung/export/datei', fn() => (new ExportController())->datei());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$router->dispatch();
|
$router->dispatch();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
|
||||||
|
|
@ -1149,6 +1149,50 @@ table.splittabelle input.rechts { text-align: right; }
|
||||||
„Speichern“ und „Abbrechen“, damit es nicht im Vorbeigehen getroffen wird. */
|
„Speichern“ und „Abbrechen“, damit es nicht im Vorbeigehen getroffen wird. */
|
||||||
.splitdialog .knopfleiste .knopf.gefahr { margin-left: auto; }
|
.splitdialog .knopfleiste .knopf.gefahr { margin-left: auto; }
|
||||||
|
|
||||||
|
/* Die Formatwahl vor Import und Export – als Dialog auf der Verwaltungsseite
|
||||||
|
und als Kachelinhalt auf der Ausweichseite, deshalb zwei Ebenen: der Dialog
|
||||||
|
trägt den Rahmen, .formatwahl die Karten. */
|
||||||
|
.formatdialog {
|
||||||
|
width: min(34rem, 92vw);
|
||||||
|
padding: 1.1rem 1.25rem 1.25rem;
|
||||||
|
border: 1px solid var(--rand-kraeftig);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--flaeche);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formatdialog::backdrop { background: rgb(0 0 0 / 35%); }
|
||||||
|
.formatdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; }
|
||||||
|
.formatdialog .knopfleiste { margin-top: .9rem; }
|
||||||
|
|
||||||
|
.formatwahl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||||
|
gap: .7rem;
|
||||||
|
margin-top: .8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formatkarte {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .25rem;
|
||||||
|
padding: .8rem .9rem;
|
||||||
|
border: 1px solid var(--rand-kraeftig);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--flaeche-gedaempft);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formatkarte:hover,
|
||||||
|
.formatkarte:focus-visible {
|
||||||
|
border-color: var(--akzent);
|
||||||
|
background: color-mix(in srgb, var(--akzent) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formatname { font-weight: 600; font-size: 1.05rem; }
|
||||||
|
|
||||||
/* Der Wertpapierdialog trägt ein Formular und wird dadurch länger als der
|
/* Der Wertpapierdialog trägt ein Formular und wird dadurch länger als der
|
||||||
Splitdialog; bei kleinen Fenstern scrollt er. */
|
Splitdialog; bei kleinen Fenstern scrollt er. */
|
||||||
.wertpapierdialog { max-height: 88vh; overflow-y: auto; }
|
.wertpapierdialog { max-height: 88vh; overflow-y: auto; }
|
||||||
|
|
@ -1187,6 +1231,87 @@ table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; }
|
||||||
margin-top: .6rem;
|
margin-top: .6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Erklärungen zu einem Feld stehen im Tooltip seiner Beschriftung statt als
|
||||||
|
eigene Zeile darunter – das spart in langen Masken sichtbar Höhe. Die
|
||||||
|
gepunktete Linie zeigt, dass dort etwas zu holen ist. */
|
||||||
|
.formular label[title],
|
||||||
|
.formular h3 span[title] {
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
text-underline-offset: .25em;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zweispaltiges Formular: je Feld ein .feld mit Beschriftung, Eingabe und
|
||||||
|
Hinweis, zwei davon nebeneinander. Lange Formulare – das Kontoformular etwa –
|
||||||
|
bleiben so ohne vertikales Scrollen einsehbar. */
|
||||||
|
.formular.zweispaltig {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
gap: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formular.breit.zweispaltig { max-width: 900px; }
|
||||||
|
|
||||||
|
.formular.zweispaltig .feld {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formular.zweispaltig .feld label { margin-top: 0; }
|
||||||
|
|
||||||
|
.formular.zweispaltig .feld .feldhinweis { margin-top: 0; }
|
||||||
|
|
||||||
|
.formular.zweispaltig .feld .feldwert { margin: 0; }
|
||||||
|
|
||||||
|
.formular.zweispaltig .feld label.ankreuzfeld { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Über beide Spalten: verstecktes CSRF-Feld, Überschriften, Knopfleiste,
|
||||||
|
Tabellen – die Splitzeilen der Buchungsmaske etwa – und Felder, die für eine
|
||||||
|
halbe Zeile zu breit sind. */
|
||||||
|
.formular.zweispaltig > input[type="hidden"],
|
||||||
|
.formular.zweispaltig > h3,
|
||||||
|
.formular.zweispaltig > p,
|
||||||
|
.formular.zweispaltig > table,
|
||||||
|
.formular.zweispaltig > .knopfleiste,
|
||||||
|
.formular.zweispaltig .feld.ganz { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
/* Die Abstände kommen im Grid aus dem gap – eigene Ränder addierten sich dazu. */
|
||||||
|
.formular.zweispaltig > h3 { margin: 0; }
|
||||||
|
.formular.zweispaltig > p { margin: 0; }
|
||||||
|
.formular.zweispaltig > table { margin: 0; }
|
||||||
|
.formular.zweispaltig > .knopfleiste { margin-top: .5rem; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.formular.zweispaltig { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Beschriftung links neben der Eingabe statt darüber. Das halbiert die Höhe je
|
||||||
|
Feld – die Buchungsmaske passt damit samt Splitzeilen auf einen Bildschirm.
|
||||||
|
Hinweise stehen unter der Eingabe, also in derselben Spalte wie diese. */
|
||||||
|
.formular.beschriftung-links .feld {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 10rem) minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: .25rem .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formular.beschriftung-links .feld > label { grid-column: 1; }
|
||||||
|
.formular.beschriftung-links .feld > :not(label) { grid-column: 2; }
|
||||||
|
|
||||||
|
/* Das Ankreuzfeld trägt seine Beschriftung selbst und braucht keine Spalte.
|
||||||
|
Hinweise laufen über beide Spalten: in der schmalen Eingabespalte allein
|
||||||
|
würden sie zu einem hohen Textblock, der die Zeile auseinanderzieht. */
|
||||||
|
.formular.beschriftung-links .feld > label.ankreuzfeld,
|
||||||
|
.formular.beschriftung-links .feld > .feldhinweis { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.formular.beschriftung-links .feld { grid-template-columns: 1fr; }
|
||||||
|
.formular.beschriftung-links .feld > label,
|
||||||
|
.formular.beschriftung-links .feld > :not(label) { grid-column: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.formular label.ankreuzfeld {
|
.formular label.ankreuzfeld {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -1637,6 +1762,34 @@ table.bericht .wertlink:hover {
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Das Ankreuzfeld der Mehrfachauswahl steht vor dem Text und richtet sich an
|
||||||
|
der ersten Zeile aus, nicht an der Mitte der Karte. */
|
||||||
|
.umsatzwahl {
|
||||||
|
flex: none;
|
||||||
|
margin-top: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Titelleiste einer Kartenliste: Sie vertritt die Kopfzeile, die eine Tabelle
|
||||||
|
von selbst hätte – hier steht das Ankreuzfeld „alle wählen“. */
|
||||||
|
.listenkopf {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: .35rem .9rem;
|
||||||
|
margin-bottom: .4rem;
|
||||||
|
border-bottom: 1px solid var(--rand);
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--text-gedaempft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listenkopf .ankreuzfeld {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.umsatztext { min-width: 0; }
|
.umsatztext { min-width: 0; }
|
||||||
|
|
||||||
.umsatztext .nebentext { display: block; font-size: .8rem; }
|
.umsatztext .nebentext { display: block; font-size: .8rem; }
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,35 @@
|
||||||
anpassen();
|
anpassen();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Formatwahl von Import und Export. Die Kachel in der Verwaltung führt zwei
|
||||||
|
// Knöpfe; welches Dateiformat gemeint ist, fragt dieser Dialog ab und hängt
|
||||||
|
// es dann an die Zieladresse. Die Karten liefert die Ansicht mit – hier
|
||||||
|
// stehen nur die Ziele.
|
||||||
|
//
|
||||||
|
// Kennt der Browser <dialog> nicht, bleibt der Knopf ein gewöhnlicher Link
|
||||||
|
// und führt auf dieselbe Auswahl als eigene Seite.
|
||||||
|
const formatdialog = document.getElementById('formatwahl');
|
||||||
|
if (formatdialog && typeof formatdialog.showModal === 'function') {
|
||||||
|
const titel = formatdialog.querySelector('[data-formatdialog-titel]');
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-formatwahl]').forEach(function (knopf) {
|
||||||
|
knopf.addEventListener('click', function (ereignis) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
|
||||||
|
const ziel = knopf.getAttribute('href');
|
||||||
|
if (titel) {
|
||||||
|
titel.textContent = knopf.dataset.formatwahl + ': Format wählen';
|
||||||
|
}
|
||||||
|
|
||||||
|
formatdialog.querySelectorAll('.formatkarte').forEach(function (karte) {
|
||||||
|
karte.setAttribute('href', ziel + '?format=' + karte.dataset.format);
|
||||||
|
});
|
||||||
|
|
||||||
|
formatdialog.showModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Meldungen ausblenden. Über eine Klasse, nicht über element.style: Die
|
// Meldungen ausblenden. Über eine Klasse, nicht über element.style: Die
|
||||||
// Content-Security-Policy (style-src 'self') verwirft auch per JavaScript
|
// Content-Security-Policy (style-src 'self') verwirft auch per JavaScript
|
||||||
// gesetzte Inline-Styles, die Meldung bliebe sonst einfach stehen.
|
// gesetzte Inline-Styles, die Meldung bliebe sonst einfach stehen.
|
||||||
|
|
|
||||||
57
scripts/housekeeping.php
Normal file
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);
|
||||||
|
|
@ -557,6 +557,36 @@ class BankingController extends BaseController
|
||||||
View::redirect('/banking/umsaetze?status=offen', 'Umsatz steht wieder zum Abgleich bereit.');
|
View::redirect('/banking/umsaetze?status=offen', 'Umsatz steht wieder zum Abgleich bereit.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mehrere in der Abgleichliste angekreuzte Umsätze auf einmal löschen.
|
||||||
|
*
|
||||||
|
* Gelöscht wird nur die abgerufene Zeile. Eine daraus entstandene Buchung
|
||||||
|
* bleibt im Kontoblatt stehen – wer sie loswerden will, löscht sie dort.
|
||||||
|
*/
|
||||||
|
public function umsaetzeLoeschen(): void
|
||||||
|
{
|
||||||
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
// Zurück in denselben Reiter, aus dem gelöscht wurde.
|
||||||
|
$status = $this->query('status', 'offen');
|
||||||
|
if (!array_key_exists($status, Bankumsatz::STATUS)) {
|
||||||
|
$status = 'offen';
|
||||||
|
}
|
||||||
|
$ziel = '/banking/umsaetze?status=' . $status;
|
||||||
|
|
||||||
|
$ids = $this->postIds('umsaetze');
|
||||||
|
if ($ids === []) {
|
||||||
|
View::redirect($ziel, 'Kein Umsatz ausgewählt.', 'warnung');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$anzahl = Bankumsatz::mehrereLoeschen($ids);
|
||||||
|
|
||||||
|
View::redirect($ziel, $anzahl === 1
|
||||||
|
? 'Umsatz gelöscht.'
|
||||||
|
: $anzahl . ' Umsätze gelöscht.');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alles übernehmen, was einen eindeutigen Vorschlag hat.
|
* Alles übernehmen, was einen eindeutigen Vorschlag hat.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,11 @@ class EinstellungenController extends BaseController
|
||||||
'banking_abgleich_tage' => 'text',
|
'banking_abgleich_tage' => 'text',
|
||||||
'banking_produkt_id' => 'text',
|
'banking_produkt_id' => 'text',
|
||||||
],
|
],
|
||||||
'import' => ['import_qif_verzeichnis' => 'text', 'import_stichtag' => 'text'],
|
'import' => [
|
||||||
|
'import_qif_verzeichnis' => 'text',
|
||||||
|
'import_stichtag' => 'text',
|
||||||
|
'housekeeping_tage' => 'text',
|
||||||
|
],
|
||||||
'wertpapiere' => [
|
'wertpapiere' => [
|
||||||
'wertpapiere_kursdienst' => 'text',
|
'wertpapiere_kursdienst' => 'text',
|
||||||
'wertpapiere_api_key' => 'text',
|
'wertpapiere_api_key' => 'text',
|
||||||
|
|
|
||||||
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;
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
use Finanzmanager\Models\Einstellung;
|
use Finanzmanager\Models\Einstellung;
|
||||||
|
use Finanzmanager\Services\CsvBuchungsImport;
|
||||||
|
use Finanzmanager\Services\Dateiformat;
|
||||||
|
use Finanzmanager\Services\Housekeeping;
|
||||||
use Finanzmanager\Services\QifBuchungsImport;
|
use Finanzmanager\Services\QifBuchungsImport;
|
||||||
use Finanzmanager\Services\QifParser;
|
use Finanzmanager\Services\QifParser;
|
||||||
use Finanzmanager\Services\QifStammdatenImport;
|
use Finanzmanager\Services\QifStammdatenImport;
|
||||||
|
|
@ -12,58 +15,75 @@ use Finanzmanager\Services\QifVorlagenImport;
|
||||||
use Finanzmanager\View;
|
use Finanzmanager\View;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QIF-Import: Stammdaten (Klassen, Kategorien, Konten) und Buchungen.
|
* Import: QIF (Stammdaten, Buchungen, Buchungsvorlagen) und CSV (Buchungen).
|
||||||
|
*
|
||||||
|
* Vor dem Hochladen steht die Formatwahl – erst danach weiß der Dialog, welche
|
||||||
|
* Dateien er annimmt und welchen Ablauf er zeigt. Ohne `format` in der Adresse
|
||||||
|
* erscheint deshalb die Auswahl; die Kachel in der Verwaltung nimmt sie über
|
||||||
|
* ihren Auswahldialog vorweg.
|
||||||
*
|
*
|
||||||
* Hochgeladene Dateien landen im Importverzeichnis und bleiben dort – so lässt
|
* Hochgeladene Dateien landen im Importverzeichnis und bleiben dort – so lässt
|
||||||
* sich derselbe Export erst im Trockenlauf prüfen und danach übernehmen, ohne
|
* sich derselbe Export erst im Trockenlauf prüfen und danach übernehmen, ohne
|
||||||
* ihn zweimal hochzuladen.
|
* ihn zweimal hochzuladen. Was zu lange liegen bleibt, räumt das Housekeeping
|
||||||
|
* weg.
|
||||||
*/
|
*/
|
||||||
class ImportController extends BaseController
|
class ImportController extends BaseController
|
||||||
{
|
{
|
||||||
private const ERLAUBTE_ENDUNGEN = ['qif'];
|
/** Die Felder, die jede Ansicht kennt – ein Lauf füllt davon nur seine eigenen. */
|
||||||
|
private const LEER = [
|
||||||
|
'ergebnis' => null,
|
||||||
|
'buchungen' => null,
|
||||||
|
'vorlagen' => null,
|
||||||
|
'ueberblick' => null,
|
||||||
|
'datei' => '',
|
||||||
|
'dateikonten' => [],
|
||||||
|
'auswahl' => [],
|
||||||
|
];
|
||||||
|
|
||||||
public function index(): void
|
public function index(): void
|
||||||
{
|
{
|
||||||
$this->verlangeAnmeldung();
|
$this->verlangeAnmeldung();
|
||||||
|
Housekeeping::beiGelegenheit();
|
||||||
|
|
||||||
$this->render('verwaltung/import.php', [
|
$format = Dateiformat::pruefe($this->query('format'));
|
||||||
'titel' => 'QIF-Import',
|
if ($format === null) {
|
||||||
'dateien' => $this->dateien(),
|
$this->formatwahl();
|
||||||
'verzeichnis' => $this->verzeichnisAnzeige(),
|
return;
|
||||||
'ergebnis' => null,
|
}
|
||||||
'buchungen' => null,
|
|
||||||
'vorlagen' => null,
|
$this->render('verwaltung/import.php', array_merge(self::LEER, $this->grunddaten($format)));
|
||||||
'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'),
|
|
||||||
'bis' => date('Y-m-d'),
|
|
||||||
'datei' => '',
|
|
||||||
'dateikonten' => [],
|
|
||||||
'auswahl' => [],
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hochladen(): void
|
public function hochladen(): void
|
||||||
{
|
{
|
||||||
$this->verlangeAnmeldung();
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
$datei = $_FILES['qif'] ?? null;
|
$format = Dateiformat::pruefe($this->post('format')) ?? 'qif';
|
||||||
|
$ziel = '/verwaltung/import?format=' . $format;
|
||||||
|
|
||||||
|
$datei = $_FILES['datei'] ?? null;
|
||||||
if (!is_array($datei) || ($datei['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
if (!is_array($datei) || ($datei['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
View::redirect('/verwaltung/import', $this->uploadFehler($datei['error'] ?? UPLOAD_ERR_NO_FILE), 'fehler');
|
View::redirect($ziel, $this->uploadFehler($datei['error'] ?? UPLOAD_ERR_NO_FILE), 'fehler');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$name = self::sichererDateiname((string)$datei['name']);
|
$name = self::sichererDateiname((string)$datei['name']);
|
||||||
if ($name === null) {
|
if ($name === null || Dateiformat::ausDateiname($name) !== $format) {
|
||||||
View::redirect('/verwaltung/import', 'Nur .QIF-Dateien können hochgeladen werden.', 'fehler');
|
View::redirect(
|
||||||
|
$ziel,
|
||||||
|
'Für diesen Import werden nur .' . strtoupper($format) . '-Dateien angenommen.',
|
||||||
|
'fehler'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ziel = $this->verzeichnis() . '/' . $name;
|
$pfad = $this->verzeichnis() . '/' . $name;
|
||||||
if (!is_uploaded_file((string)$datei['tmp_name']) || !move_uploaded_file((string)$datei['tmp_name'], $ziel)) {
|
if (!is_uploaded_file((string)$datei['tmp_name']) || !move_uploaded_file((string)$datei['tmp_name'], $pfad)) {
|
||||||
View::redirect('/verwaltung/import', 'Die Datei konnte nicht abgelegt werden.', 'fehler');
|
View::redirect($ziel, 'Die Datei konnte nicht abgelegt werden.', 'fehler');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
View::redirect('/verwaltung/import', 'Datei „' . $name . '“ hochgeladen.');
|
View::redirect($ziel, 'Datei „' . $name . '“ hochgeladen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function ausfuehren(): void
|
public function ausfuehren(): void
|
||||||
|
|
@ -76,18 +96,88 @@ class ImportController extends BaseController
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maßgeblich ist die Endung der Datei, nicht das Feld im Formular: Was
|
||||||
|
// eingelesen wird, bestimmt der Inhalt – sonst liefe ein CSV durch den
|
||||||
|
// QIF-Parser, bloß weil ein verstecktes Feld das behauptet.
|
||||||
|
$format = Dateiformat::ausDateiname($name) ?? 'qif';
|
||||||
|
$ziel = '/verwaltung/import?format=' . $format;
|
||||||
|
|
||||||
$pfad = $this->verzeichnis() . '/' . $name;
|
$pfad = $this->verzeichnis() . '/' . $name;
|
||||||
if (!is_file($pfad)) {
|
if (!is_file($pfad)) {
|
||||||
View::redirect('/verwaltung/import', 'Die Datei „' . $name . '“ gibt es nicht mehr.', 'fehler');
|
View::redirect($ziel, 'Die Datei „' . $name . '“ gibt es nicht mehr.', 'fehler');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$trockenlauf = $this->post('modus') !== 'import';
|
$trockenlauf = $this->post('modus') !== 'import';
|
||||||
|
|
||||||
|
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');
|
$stichtag = $this->post('stichtag') ?: (string)Einstellung::get('import_stichtag', '2026-01-01');
|
||||||
$bis = $this->post('bis') ?: date('Y-m-d');
|
$bis = $this->post('bis') ?: date('Y-m-d');
|
||||||
|
|
||||||
try {
|
|
||||||
$parser = QifParser::ausDatei($pfad);
|
$parser = QifParser::ausDatei($pfad);
|
||||||
$ergebnis = (new QifStammdatenImport($parser))->ausfuehren($trockenlauf);
|
$ergebnis = (new QifStammdatenImport($parser))->ausfuehren($trockenlauf);
|
||||||
|
|
||||||
|
|
@ -114,73 +204,66 @@ class ImportController extends BaseController
|
||||||
// zweiter Knopf für dieselbe Datei.
|
// zweiter Knopf für dieselbe Datei.
|
||||||
$vorlagen = (new QifVorlagenImport($parser))->ausfuehren($trockenlauf);
|
$vorlagen = (new QifVorlagenImport($parser))->ausfuehren($trockenlauf);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
|
||||||
View::redirect('/verwaltung/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->render('verwaltung/import.php', [
|
return [
|
||||||
'titel' => 'QIF-Import',
|
|
||||||
'dateien' => $this->dateien(),
|
|
||||||
'verzeichnis' => $this->verzeichnisAnzeige(),
|
|
||||||
'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()],
|
'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()],
|
||||||
'buchungen' => $buchungen,
|
'buchungen' => $buchungen,
|
||||||
'vorlagen' => $vorlagen,
|
'vorlagen' => $vorlagen,
|
||||||
'stichtag' => $stichtag,
|
'stichtag' => $stichtag,
|
||||||
'bis' => $bis,
|
'bis' => $bis,
|
||||||
'datei' => $name,
|
|
||||||
'dateikonten' => $dateikonten,
|
'dateikonten' => $dateikonten,
|
||||||
'auswahl' => $auswahl,
|
'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 */
|
/** @return array<string,mixed> */
|
||||||
public function loeschen(array $params): void
|
private function grunddaten(string $format): array
|
||||||
{
|
{
|
||||||
$this->verlangeAnmeldung();
|
return [
|
||||||
|
'titel' => 'Import (' . Dateiformat::name($format) . ')',
|
||||||
$name = self::sichererDateiname($params['datei'] ?? '');
|
'format' => $format,
|
||||||
if ($name !== null) {
|
'dateien' => $this->dateien($format),
|
||||||
$pfad = $this->verzeichnis() . '/' . $name;
|
'verzeichnis' => $this->verzeichnisAnzeige(),
|
||||||
if (is_file($pfad)) {
|
'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'),
|
||||||
unlink($pfad);
|
'bis' => date('Y-m-d'),
|
||||||
}
|
'aufbewahrung' => Housekeeping::tage(),
|
||||||
}
|
];
|
||||||
|
|
||||||
View::redirect('/verwaltung/import', 'Datei gelöscht.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nimmt nur den Dateinamen ohne Pfadanteile und lässt allein die erlaubten
|
|
||||||
* Endungen durch – der Name landet ungeprüft in einer Pfadangabe.
|
|
||||||
*/
|
|
||||||
public static function sichererDateiname(string $eingabe): ?string
|
|
||||||
{
|
|
||||||
$name = basename(trim($eingabe));
|
|
||||||
if ($name === '' || $name === '.' || $name === '..' || str_contains($name, "\0")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$endung = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
|
||||||
if (!in_array($endung, self::ERLAUBTE_ENDUNGEN, true)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alles außer Buchstaben, Ziffern, Punkt, Strich und Unterstrich fällt
|
|
||||||
// weg; damit bleiben keine Sonderzeichen für die Shell oder das
|
|
||||||
// Dateisystem übrig.
|
|
||||||
$name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? '';
|
|
||||||
|
|
||||||
return $name === '' ? null : $name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<array{name: string, groesse: int, geaendert: int}> */
|
/** @return list<array{name: string, groesse: int, geaendert: int}> */
|
||||||
private function dateien(): array
|
private function dateien(string $format): array
|
||||||
{
|
{
|
||||||
$verzeichnis = $this->verzeichnis();
|
$verzeichnis = $this->verzeichnis();
|
||||||
$dateien = [];
|
$dateien = [];
|
||||||
|
|
||||||
foreach (glob($verzeichnis . '/*.[qQ][iI][fF]') ?: [] as $pfad) {
|
foreach (glob($verzeichnis . '/*') ?: [] as $pfad) {
|
||||||
|
if (!is_file($pfad) || Dateiformat::ausDateiname($pfad) !== $format) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$dateien[] = [
|
$dateien[] = [
|
||||||
'name' => basename($pfad),
|
'name' => basename($pfad),
|
||||||
'groesse' => (int)filesize($pfad),
|
'groesse' => (int)filesize($pfad),
|
||||||
|
|
|
||||||
|
|
@ -363,4 +363,30 @@ class Bankumsatz
|
||||||
{
|
{
|
||||||
Database::execute('DELETE FROM bank_umsaetze WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
Database::execute('DELETE FROM bank_umsaetze WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mehrere Umsätze auf einmal löschen – die Mehrfachauswahl der Abgleichliste.
|
||||||
|
*
|
||||||
|
* Eine schon erzeugte Buchung bleibt stehen: Sie gehört ins Kontoblatt und
|
||||||
|
* wird auch nur dort gelöscht. Weg ist allein die abgerufene Zeile – und mit
|
||||||
|
* ihr ihr Fingerabdruck, weshalb ein gelöschter Umsatz beim nächsten Abruf
|
||||||
|
* wieder hereinkommen kann.
|
||||||
|
*
|
||||||
|
* @param list<int> $ids
|
||||||
|
* @return int Wie viele Umsätze tatsächlich gelöscht wurden.
|
||||||
|
*/
|
||||||
|
public static function mehrereLoeschen(array $ids): int
|
||||||
|
{
|
||||||
|
$ids = array_values(array_unique(array_filter($ids, static fn (int $id): bool => $id > 0)));
|
||||||
|
if ($ids === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$platzhalter = implode(', ', array_fill(0, count($ids), '?'));
|
||||||
|
|
||||||
|
return Database::execute(
|
||||||
|
'DELETE FROM bank_umsaetze WHERE benutzer_id = ? AND id IN (' . $platzhalter . ')',
|
||||||
|
array_merge([Mandant::id()], $ids)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,11 @@ use Finanzmanager\View;
|
||||||
* Vorzeichen wie am Bildschirm. Wer den Export mit der Anzeige vergleicht, soll
|
* Vorzeichen wie am Bildschirm. Wer den Export mit der Anzeige vergleicht, soll
|
||||||
* nicht rechnen müssen.
|
* nicht rechnen müssen.
|
||||||
*
|
*
|
||||||
* Deutsches Tabellenkalkulations-CSV: Semikolon als Trenner, Komma als
|
* Das Dateiformat selbst – Trenner, Maskierung, Zahlenschreibweise – kommt aus
|
||||||
* Dezimalzeichen, kein Tausenderpunkt (der macht aus der Zahl einen Text).
|
* CsvTabelle; hier steht allein, welche Zeilen ein Bericht hergibt.
|
||||||
* Vorangestellt ein BOM, sonst zeigen Excel und LibreOffice beim Doppelklick
|
|
||||||
* „Übrige“ statt „Übrige“.
|
|
||||||
*/
|
*/
|
||||||
class BerichtExport
|
class BerichtExport
|
||||||
{
|
{
|
||||||
private const BOM = "\u{FEFF}";
|
|
||||||
|
|
||||||
/** @param array<string,mixed> $bericht Rückgabe von Auswertung::bericht() */
|
/** @param array<string,mixed> $bericht Rückgabe von Auswertung::bericht() */
|
||||||
public static function csv(array $bericht): string
|
public static function csv(array $bericht): string
|
||||||
{
|
{
|
||||||
|
|
@ -36,12 +32,7 @@ class BerichtExport
|
||||||
[],
|
[],
|
||||||
];
|
];
|
||||||
|
|
||||||
$text = '';
|
return CsvTabelle::schreibe(array_merge($kopf, $zeilen));
|
||||||
foreach (array_merge($kopf, $zeilen) as $zeile) {
|
|
||||||
$text .= self::zeile($zeile);
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::BOM . $text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dateiname ohne Pfad, aus Berichtsname und Zeitraum. */
|
/** Dateiname ohne Pfad, aus Berichtsname und Zeitraum. */
|
||||||
|
|
@ -182,29 +173,9 @@ class BerichtExport
|
||||||
return $zeilen;
|
return $zeilen;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param list<string> $felder */
|
|
||||||
private static function zeile(array $felder): string
|
|
||||||
{
|
|
||||||
$maskiert = array_map(
|
|
||||||
// Nur maskieren, wo es sein muss: Ein Feld in Anführungszeichen
|
|
||||||
// liest sich schlechter, wenn es keinen Trenner enthält.
|
|
||||||
static function (string $feld): string {
|
|
||||||
if (preg_match('/[;"\r\n]/', $feld) !== 1) {
|
|
||||||
return $feld;
|
|
||||||
}
|
|
||||||
return '"' . str_replace('"', '""', $feld) . '"';
|
|
||||||
},
|
|
||||||
array_map('strval', $felder)
|
|
||||||
);
|
|
||||||
|
|
||||||
// CRLF: Das erwarten Tabellenkalkulationen unter Windows, und der Rest
|
|
||||||
// kommt damit ebenfalls zurecht.
|
|
||||||
return implode(';', $maskiert) . "\r\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function zahl(float $wert, int $stellen = 2): string
|
private static function zahl(float $wert, int $stellen = 2): string
|
||||||
{
|
{
|
||||||
return number_format($wert, $stellen, ',', '');
|
return CsvTabelle::zahl($wert, $stellen);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Umlaute im Dateinamen ausschreiben, statt sie zu Bindestrichen zu zermahlen. */
|
/** Umlaute im Dateinamen ausschreiben, statt sie zu Bindestrichen zu zermahlen. */
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,14 +38,28 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<a href="/banking/regeln" class="tab rechtsbuendig">Regeln</a>
|
<a href="/banking/regeln" class="tab rechtsbuendig">Regeln</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($status === 'offen' && $umsaetze !== []): ?>
|
<?php if ($umsaetze !== []): ?>
|
||||||
<div class="seitenaktionen">
|
<div class="seitenaktionen">
|
||||||
|
<?php if ($status === 'offen'): ?>
|
||||||
<form method="post" action="/banking/umsaetze/alle" class="inline"
|
<form method="post" action="/banking/umsaetze/alle" class="inline"
|
||||||
data-bestaetigen="Alle eindeutigen Vorschläge übernehmen? Umsätze ohne Regel bleiben offen.">
|
data-bestaetigen="Alle eindeutigen Vorschläge übernehmen? Umsätze ohne Regel bleiben offen.">
|
||||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
<button type="submit" class="knopf primaer">Alle eindeutigen übernehmen</button>
|
<button type="submit" class="knopf primaer">Alle eindeutigen übernehmen</button>
|
||||||
</form>
|
</form>
|
||||||
<a href="/banking/regeln" class="knopf">Regeln pflegen</a>
|
<a href="/banking/regeln" class="knopf">Regeln pflegen</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php /* Wie im Kontoblatt: Das Formular steht in der Aktionsleiste, seine
|
||||||
|
Ankreuzfelder stehen in den Umsatzkarten – verbunden über das
|
||||||
|
form-Attribut. Ein Formular um die ganze Liste verschachtelte
|
||||||
|
sich mit den Formularen der einzelnen Umsätze. */ ?>
|
||||||
|
<form method="post" action="/banking/umsaetze/loeschen?status=<?= View::h($status) ?>"
|
||||||
|
id="umsatzauswahl" class="inline"
|
||||||
|
data-bestaetigen="Die ausgewählten Umsätze wirklich löschen?"
|
||||||
|
data-frage-eine="Den ausgewählten Umsatz wirklich löschen? Eine daraus erzeugte Buchung bleibt im Kontoblatt stehen."
|
||||||
|
data-frage-mehrere="{anzahl} ausgewählte Umsätze wirklich löschen? Daraus erzeugte Buchungen bleiben im Kontoblatt stehen.">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
<button type="submit" class="knopf gefahr">Auswahl löschen</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|
@ -56,6 +70,19 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
: 'Keine Umsätze mit diesem Status.' ?>
|
: 'Keine Umsätze mit diesem Status.' ?>
|
||||||
</p>
|
</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
<?php /* Die Titelleiste der Liste. Eine Tabelle mit Kopfzeile gibt es hier
|
||||||
|
nicht, das Ankreuzfeld „alle wählen“ braucht aber seinen Platz. */ ?>
|
||||||
|
<div class="listenkopf">
|
||||||
|
<label class="ankreuzfeld">
|
||||||
|
<input type="checkbox" class="alle-waehlen" data-waehlt="umsatzauswahl"
|
||||||
|
aria-label="Alle angezeigten Umsätze auswählen">
|
||||||
|
Alle auswählen
|
||||||
|
</label>
|
||||||
|
<span class="nebentext">
|
||||||
|
<?= count($umsaetze) ?> <?= count($umsaetze) === 1 ? 'Umsatz' : 'Umsätze' ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php foreach ($umsaetze as $umsatz): ?>
|
<?php foreach ($umsaetze as $umsatz): ?>
|
||||||
<?php
|
<?php
|
||||||
$id = (int)$umsatz['id'];
|
$id = (int)$umsatz['id'];
|
||||||
|
|
@ -65,6 +92,9 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
?>
|
?>
|
||||||
<section class="umsatz">
|
<section class="umsatz">
|
||||||
<div class="umsatzkopf">
|
<div class="umsatzkopf">
|
||||||
|
<input type="checkbox" name="umsaetze[]" value="<?= $id ?>" form="umsatzauswahl"
|
||||||
|
class="umsatzwahl"
|
||||||
|
aria-label="Umsatz vom <?= View::h(View::datum($umsatz['buchungstag'])) ?> über <?= View::h(View::betrag($umsatz['betrag'])) ?> auswählen">
|
||||||
<div class="umsatztext">
|
<div class="umsatztext">
|
||||||
<strong><?= View::h($umsatz['empfaenger'] ?: 'Ohne Empfänger') ?></strong>
|
<strong><?= View::h($umsatz['empfaenger'] ?: 'Ohne Empfänger') ?></strong>
|
||||||
<span class="nebentext"><?= View::h(View::datum($umsatz['buchungstag'])) ?>
|
<span class="nebentext"><?= View::h(View::datum($umsatz['buchungstag'])) ?>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,15 @@ $eigenesKonto = (int)$konto['id'];
|
||||||
$mitSplit = false;
|
$mitSplit = false;
|
||||||
$mitDepot = true;
|
$mitDepot = true;
|
||||||
|
|
||||||
|
// Zusatz hinter dem Kontonamen: was das Konto eindeutig macht, ohne die Zeile
|
||||||
|
// zu überladen. IBAN sticht, sonst Bank und Kontonummer.
|
||||||
|
$kontozusatz = trim((string)($konto['iban'] ?? '')) !== ''
|
||||||
|
? (string)$konto['iban']
|
||||||
|
: trim(implode(' · ', array_filter([
|
||||||
|
trim((string)($konto['bankname'] ?? '')),
|
||||||
|
trim((string)($konto['kontonummer'] ?? '')),
|
||||||
|
])));
|
||||||
|
|
||||||
// Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu.
|
// Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu.
|
||||||
$splitZeilen = $splits;
|
$splitZeilen = $splits;
|
||||||
for ($i = count($splitZeilen); $i < 4; $i++) {
|
for ($i = count($splitZeilen); $i < 4; $i++) {
|
||||||
|
|
@ -48,9 +57,22 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<span class="saldoanzeige">Konto <strong><?= View::h($konto['name']) ?></strong></span>
|
<span class="saldoanzeige">Konto <strong><?= View::h($konto['name']) ?></strong></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit buchungsmaske">
|
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit zweispaltig beschriftung-links buchungsmaske">
|
||||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
|
||||||
|
<div class="feld ganz">
|
||||||
|
<label title="<?= View::h($neu
|
||||||
|
? 'In diesem Konto wird die Buchung gespeichert.'
|
||||||
|
: 'In diesem Konto ist die Buchung gespeichert.') ?>">Konto</label>
|
||||||
|
<p class="feldwert">
|
||||||
|
<strong><?= View::h($konto['name']) ?></strong>
|
||||||
|
<?php if ($kontozusatz !== ''): ?>
|
||||||
|
<span class="optional"><?= View::h($kontozusatz) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="art">Art</label>
|
<label for="art">Art</label>
|
||||||
<select id="art" name="art">
|
<select id="art" name="art">
|
||||||
<?php foreach ([
|
<?php foreach ([
|
||||||
|
|
@ -62,31 +84,35 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</option>
|
</option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="datum">Datum</label>
|
<label for="datum">Datum</label>
|
||||||
<input type="date" id="datum" name="datum" required
|
<input type="date" id="datum" name="datum" required
|
||||||
value="<?= View::h($neu ? date('Y-m-d') : substr((string)$buchung['datum'], 0, 10)) ?>">
|
value="<?= View::h($neu ? date('Y-m-d') : substr((string)$buchung['datum'], 0, 10)) ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
<label for="betrag">Betrag</label>
|
<div class="feld">
|
||||||
|
<label for="betrag" title="Immer positiv eingeben – die Richtung bestimmt die Art.">Betrag</label>
|
||||||
<input type="text" id="betrag" name="betrag" inputmode="decimal" required
|
<input type="text" id="betrag" name="betrag" inputmode="decimal" required
|
||||||
value="<?= View::h($neu ? '' : View::betrag(abs($betrag), false)) ?>">
|
value="<?= View::h($neu ? '' : View::betrag(abs($betrag), false)) ?>">
|
||||||
<p class="feldhinweis">Immer positiv eingeben – die Richtung bestimmt die Art oben.</p>
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="empfaenger">Empfänger</label>
|
<label for="empfaenger">Empfänger</label>
|
||||||
<input type="text" id="empfaenger" name="empfaenger"
|
<input type="text" id="empfaenger" name="empfaenger"
|
||||||
value="<?= View::h((string)($buchung['empfaenger'] ?? '')) ?>">
|
value="<?= View::h((string)($buchung['empfaenger'] ?? '')) ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
<label for="zuordnung">Kategorie / Gegenkonto</label>
|
<div class="feld">
|
||||||
|
<label for="zuordnung" title="Unter den Kategorien stehen die eigenen Konten in eckigen Klammern. Eines davon macht die Buchung zur Umbuchung: Sie wird einmal gespeichert und erscheint auf beiden Kontoblättern – auf dem Gegenkonto mit umgekehrtem Vorzeichen.">Kategorie / Gegenkonto</label>
|
||||||
<select id="zuordnung" name="zuordnung">
|
<select id="zuordnung" name="zuordnung">
|
||||||
<?php $gewaehlt = $zuordnungswert($buchung); ?>
|
<?php $gewaehlt = $zuordnungswert($buchung); ?>
|
||||||
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
||||||
</select>
|
</select>
|
||||||
<p class="feldhinweis">
|
</div>
|
||||||
Unter den Kategorien stehen die eigenen Konten in eckigen Klammern. Eines davon
|
|
||||||
macht die Buchung zur Umbuchung: Sie wird einmal gespeichert und erscheint auf
|
|
||||||
beiden Kontoblättern – auf dem Gegenkonto mit umgekehrtem Vorzeichen.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="klasse_id">Klasse</label>
|
<label for="klasse_id">Klasse</label>
|
||||||
<select id="klasse_id" name="klasse_id">
|
<select id="klasse_id" name="klasse_id">
|
||||||
<option value="">— keine —</option>
|
<option value="">— keine —</option>
|
||||||
|
|
@ -97,26 +123,29 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</option>
|
</option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="buchungstext">Buchungstext</label>
|
<label for="buchungstext">Buchungstext</label>
|
||||||
<input type="text" id="buchungstext" name="buchungstext"
|
<input type="text" id="buchungstext" name="buchungstext"
|
||||||
value="<?= View::h((string)($buchung['buchungstext'] ?? '')) ?>">
|
value="<?= View::h((string)($buchung['buchungstext'] ?? '')) ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="nummer">Nummer <span class="optional">(optional)</span></label>
|
<label for="nummer">Nummer <span class="optional">(optional)</span></label>
|
||||||
<input type="text" id="nummer" name="nummer" value="<?= View::h((string)($buchung['nummer'] ?? '')) ?>">
|
<input type="text" id="nummer" name="nummer" value="<?= View::h((string)($buchung['nummer'] ?? '')) ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld ganz">
|
||||||
<label class="ankreuzfeld">
|
<label class="ankreuzfeld">
|
||||||
<input type="checkbox" name="abgeglichen" value="1"
|
<input type="checkbox" name="abgeglichen" value="1"
|
||||||
<?= !empty($buchung['abgeglichen']) ? 'checked' : '' ?>>
|
<?= !empty($buchung['abgeglichen']) ? 'checked' : '' ?>>
|
||||||
Abgeglichen
|
Abgeglichen
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3>Splitbuchung <span class="optional">(optional)</span></h3>
|
<h3><span title="Sobald hier Beträge stehen, gilt die Zuordnung aus den Zeilen; Kategorie und Gegenkonto oben bleiben dann außen vor. Die Summe muss dem Buchungsbetrag entsprechen.">Splitbuchung</span>
|
||||||
<p class="feldhinweis">
|
<span class="optional">(optional)</span></h3>
|
||||||
Sobald hier Beträge stehen, gilt die Zuordnung aus den Zeilen; Kategorie und
|
|
||||||
Gegenkonto oben bleiben dann außen vor. Die Summe muss dem Buchungsbetrag
|
|
||||||
entsprechen.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<table class="liste splittabelle">
|
<table class="liste splittabelle">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use Finanzmanager\Models\Einstellung;
|
use Finanzmanager\Models\Einstellung;
|
||||||
use Finanzmanager\Services\Cockpit;
|
use Finanzmanager\Services\Cockpit;
|
||||||
|
use Finanzmanager\Services\Housekeeping;
|
||||||
use Finanzmanager\Services\Kursabruf;
|
use Finanzmanager\Services\Kursabruf;
|
||||||
use Finanzmanager\Services\Wertpapierkosten;
|
use Finanzmanager\Services\Wertpapierkosten;
|
||||||
use Finanzmanager\View;
|
use Finanzmanager\View;
|
||||||
|
|
@ -15,6 +16,10 @@ use Finanzmanager\View;
|
||||||
|
|
||||||
$wert = static fn(string $k, string $standard = ''): string => (string)($werte[$k] ?? $standard);
|
$wert = static fn(string $k, string $standard = ''): string => (string)($werte[$k] ?? $standard);
|
||||||
|
|
||||||
|
// Der Zeitstempel des letzten Aufräumens ist kein Feld zum Bearbeiten, sondern
|
||||||
|
// eine Auskunft – er steht deshalb im Hinweistext unter der Frist.
|
||||||
|
$letzterLauf = $wert(Housekeeping::EINSTELLUNG_LAUF);
|
||||||
|
|
||||||
include __DIR__ . '/../layout/kopf.php';
|
include __DIR__ . '/../layout/kopf.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|
@ -83,9 +88,9 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<input type="text" id="import_qif_verzeichnis" name="import_qif_verzeichnis"
|
<input type="text" id="import_qif_verzeichnis" name="import_qif_verzeichnis"
|
||||||
value="<?= View::h($wert('import_qif_verzeichnis', 'data/import')) ?>">
|
value="<?= View::h($wert('import_qif_verzeichnis', 'data/import')) ?>">
|
||||||
<p class="feldhinweis">
|
<p class="feldhinweis">
|
||||||
Relativ zum Projektverzeichnis. Hochgeladene QIF-Dateien landen hier und bleiben
|
Relativ zum Projektverzeichnis. Hochgeladene QIF- und CSV-Dateien landen hier und
|
||||||
liegen, damit derselbe Export erst im Trockenlauf geprüft und dann übernommen
|
bleiben liegen, damit derselbe Export erst im Trockenlauf geprüft und dann
|
||||||
werden kann.
|
übernommen werden kann.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label for="import_stichtag">Stichtag für Buchungen</label>
|
<label for="import_stichtag">Stichtag für Buchungen</label>
|
||||||
|
|
@ -98,8 +103,22 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
FinanzManagers und bleiben draußen.
|
FinanzManagers und bleiben draußen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<label for="housekeeping_tage">Importdateien aufbewahren (Tage)</label>
|
||||||
|
<input type="text" id="housekeeping_tage" name="housekeeping_tage" inputmode="numeric"
|
||||||
|
value="<?= View::h($wert('housekeeping_tage', (string)Housekeeping::STANDARD_TAGE)) ?>">
|
||||||
|
<p class="feldhinweis">
|
||||||
|
Das Housekeeping räumt ältere Dateien aus dem Importverzeichnis und aus
|
||||||
|
<code>data/tmp</code> weg – höchstens einmal am Tag, angestoßen beim Öffnen von
|
||||||
|
Import und Export. <code>0</code> schaltet es ab. Exportdateien entstehen im
|
||||||
|
Speicher und werden ohnehin nicht abgelegt.
|
||||||
|
<?php if ($letzterLauf !== ''): ?>
|
||||||
|
Zuletzt gelaufen: <?= View::h($letzterLauf) ?>.
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="knopfleiste">
|
<div class="knopfleiste">
|
||||||
<a href="/verwaltung/import" class="knopf">QIF-Import öffnen</a>
|
<a href="/verwaltung/import" class="knopf">Import öffnen</a>
|
||||||
|
<a href="/verwaltung/export" class="knopf">Export öffnen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php elseif ($bereich === 'banking'): ?>
|
<?php elseif ($bereich === 'banking'): ?>
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,15 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<a href="/konten" class="knopf">Zurück zur Kontenübersicht</a>
|
<a href="/konten" class="knopf">Zurück zur Kontenübersicht</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit">
|
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit zweispaltig">
|
||||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="name">Name des Kontos</label>
|
<label for="name">Name des Kontos</label>
|
||||||
<input type="text" id="name" name="name" value="<?= $wert('name') ?>" required autofocus>
|
<input type="text" id="name" name="name" value="<?= $wert('name') ?>" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="kontoart">Kontoart</label>
|
<label for="kontoart">Kontoart</label>
|
||||||
<select id="kontoart" name="kontoart">
|
<select id="kontoart" name="kontoart">
|
||||||
<?php foreach (Konto::ARTEN as $schluessel => $beschriftung): ?>
|
<?php foreach (Konto::ARTEN as $schluessel => $beschriftung): ?>
|
||||||
|
|
@ -34,7 +37,9 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</option>
|
</option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="kontengruppe_id">Kontengruppe</label>
|
<label for="kontengruppe_id">Kontengruppe</label>
|
||||||
<select id="kontengruppe_id" name="kontengruppe_id">
|
<select id="kontengruppe_id" name="kontengruppe_id">
|
||||||
<option value="">Nicht gruppiert</option>
|
<option value="">Nicht gruppiert</option>
|
||||||
|
|
@ -45,19 +50,34 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</option>
|
</option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
|
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
|
||||||
|
<input type="text" id="beschreibung" name="beschreibung" value="<?= $wert('beschreibung') ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="bankname">Bank <span class="optional">(optional)</span></label>
|
<label for="bankname">Bank <span class="optional">(optional)</span></label>
|
||||||
<input type="text" id="bankname" name="bankname" value="<?= $wert('bankname') ?>">
|
<input type="text" id="bankname" name="bankname" value="<?= $wert('bankname') ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="kontonummer">Kontonummer <span class="optional">(optional)</span></label>
|
<label for="kontonummer">Kontonummer <span class="optional">(optional)</span></label>
|
||||||
<input type="text" id="kontonummer" name="kontonummer" value="<?= $wert('kontonummer') ?>">
|
<input type="text" id="kontonummer" name="kontonummer" value="<?= $wert('kontonummer') ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="iban">IBAN <span class="optional">(optional)</span></label>
|
<label for="iban">IBAN <span class="optional">(optional)</span></label>
|
||||||
<input type="text" id="iban" name="iban" value="<?= $wert('iban') ?>">
|
<input type="text" id="iban" name="iban" value="<?= $wert('iban') ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="bic">BIC <span class="optional">(optional)</span></label>
|
<label for="bic">BIC <span class="optional">(optional)</span></label>
|
||||||
<input type="text" id="bic" name="bic" value="<?= $wert('bic') ?>">
|
<input type="text" id="bic" name="bic" value="<?= $wert('bic') ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="anfangssaldo">Anfangssaldo</label>
|
<label for="anfangssaldo">Anfangssaldo</label>
|
||||||
<input type="text" id="anfangssaldo" name="anfangssaldo" inputmode="decimal"
|
<input type="text" id="anfangssaldo" name="anfangssaldo" inputmode="decimal"
|
||||||
value="<?= View::h(View::betrag($konto['anfangssaldo'] ?? 0, false)) ?>">
|
value="<?= View::h(View::betrag($konto['anfangssaldo'] ?? 0, false)) ?>">
|
||||||
|
|
@ -65,19 +85,22 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
Kontostand vor der ersten erfassten Buchung. Der QIF-Export enthält ihn nicht –
|
Kontostand vor der ersten erfassten Buchung. Der QIF-Export enthält ihn nicht –
|
||||||
nach einem Import wird er hier je Konto nachgetragen.
|
nach einem Import wird er hier je Konto nachgetragen.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label for="kreditlimit">Kreditlimit <span class="optional">(optional)</span></label>
|
<label for="kreditlimit">Kreditlimit <span class="optional">(optional)</span></label>
|
||||||
<input type="text" id="kreditlimit" name="kreditlimit" inputmode="decimal"
|
<input type="text" id="kreditlimit" name="kreditlimit" inputmode="decimal"
|
||||||
value="<?= $konto === null || $konto['kreditlimit'] === null
|
value="<?= $konto === null || $konto['kreditlimit'] === null
|
||||||
? '' : View::h(View::betrag($konto['kreditlimit'], false)) ?>">
|
? '' : View::h(View::betrag($konto['kreditlimit'], false)) ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
|
<div class="feld">
|
||||||
<input type="text" id="beschreibung" name="beschreibung" value="<?= $wert('beschreibung') ?>">
|
|
||||||
|
|
||||||
<label for="sortierung">Sortierung</label>
|
<label for="sortierung">Sortierung</label>
|
||||||
<input type="number" id="sortierung" name="sortierung" value="<?= $wert('sortierung', '0') ?>" step="1">
|
<input type="number" id="sortierung" name="sortierung" value="<?= $wert('sortierung', '0') ?>" step="1">
|
||||||
<p class="feldhinweis">Kleinere Werte stehen in der Kontenübersicht weiter oben.</p>
|
<p class="feldhinweis">Kleinere Werte stehen in der Kontenübersicht weiter oben.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld">
|
||||||
<label class="ankreuzfeld">
|
<label class="ankreuzfeld">
|
||||||
<input type="checkbox" name="aktiv" value="1" <?= $neu || (int)$konto['aktiv'] === 1 ? 'checked' : '' ?>>
|
<input type="checkbox" name="aktiv" value="1" <?= $neu || (int)$konto['aktiv'] === 1 ? 'checked' : '' ?>>
|
||||||
Konto ist aktiv
|
Konto ist aktiv
|
||||||
|
|
@ -93,6 +116,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
darstellen – durchlaufende Auslagen etwa –, bleiben so aus der Gesamtsumme heraus,
|
darstellen – durchlaufende Auslagen etwa –, bleiben so aus der Gesamtsumme heraus,
|
||||||
erscheinen aber weiter in der Übersicht.
|
erscheinen aber weiter in der Übersicht.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="knopfleiste">
|
<div class="knopfleiste">
|
||||||
<button type="submit" class="knopf primaer"><?= $neu ? 'Konto anlegen' : 'Speichern' ?></button>
|
<button type="submit" class="knopf primaer"><?= $neu ? 'Konto anlegen' : 'Speichern' ?></button>
|
||||||
|
|
@ -101,7 +125,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<?php if (!$neu): ?>
|
<?php if (!$neu): ?>
|
||||||
<form method="post" action="/konten/<?= (int)$konto['id'] ?>/loeschen" class="formular breit"
|
<form method="post" action="/konten/<?= (int)$konto['id'] ?>/loeschen" class="formular breit zweispaltig"
|
||||||
data-bestaetigen="Konto „<?= View::h($konto['name']) ?>“ wirklich löschen?">
|
data-bestaetigen="Konto „<?= View::h($konto['name']) ?>“ wirklich löschen?">
|
||||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
<h3>Konto löschen</h3>
|
<h3>Konto löschen</h3>
|
||||||
|
|
|
||||||
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
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\Services\Dateiformat;
|
||||||
use Finanzmanager\View;
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/** @var string $format */
|
||||||
/** @var list<array{name: string, groesse: int, geaendert: int}> $dateien */
|
/** @var list<array{name: string, groesse: int, geaendert: int}> $dateien */
|
||||||
/** @var string $verzeichnis */
|
/** @var string $verzeichnis */
|
||||||
/** @var array<string,mixed>|null $ergebnis */
|
/** @var array<string,mixed>|null $ergebnis */
|
||||||
/** @var array<string,mixed>|null $buchungen */
|
/** @var array<string,mixed>|null $buchungen */
|
||||||
/** @var array<string,mixed>|null $vorlagen */
|
/** @var array<string,mixed>|null $vorlagen */
|
||||||
|
/** @var array<string,mixed>|null $ueberblick */
|
||||||
/** @var string $stichtag */
|
/** @var string $stichtag */
|
||||||
/** @var string $bis */
|
/** @var string $bis */
|
||||||
/** @var string $datei */
|
/** @var string $datei */
|
||||||
/** @var array<string,int> $dateikonten */
|
/** @var array<string,int> $dateikonten */
|
||||||
/** @var list<string> $auswahl */
|
/** @var list<string> $auswahl */
|
||||||
|
/** @var int $aufbewahrung */
|
||||||
/** @var string $csrfToken */
|
/** @var string $csrfToken */
|
||||||
|
|
||||||
$bereiche = [
|
$endung = '.' . $format;
|
||||||
'klassen' => 'Klassen',
|
|
||||||
'kategorien' => 'Kategorien',
|
|
||||||
'konten' => 'Konten',
|
|
||||||
];
|
|
||||||
|
|
||||||
include __DIR__ . '/../layout/kopf.php';
|
include __DIR__ . '/../layout/kopf.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="seitenaktionen">
|
<div class="seitenaktionen">
|
||||||
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
|
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
|
||||||
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
|
<a href="/verwaltung/import" class="knopf">Anderes Format</a>
|
||||||
<a href="/konten" class="knopf">Kontenübersicht</a>
|
<a href="/verwaltung/export?format=<?= View::h($format) ?>" class="knopf">Export</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($ergebnis !== null): ?>
|
<?php if ($format === 'csv'): ?>
|
||||||
<div class="kachel">
|
<?php include __DIR__ . '/teil-import-csv.php'; ?>
|
||||||
<header>
|
<?php else: ?>
|
||||||
<h2><?= $ergebnis['trockenlauf'] ? 'Trockenlauf' : 'Import abgeschlossen' ?></h2>
|
<?php include __DIR__ . '/teil-import-qif.php'; ?>
|
||||||
<span class="phasenmarke"><?= View::h($datei) ?></span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<?php if ($ergebnis['trockenlauf']): ?>
|
|
||||||
<p class="hinweis">
|
|
||||||
Nichts wurde gespeichert. Die Zahlen zeigen, was ein Import anlegen würde.
|
|
||||||
</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<table class="liste">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Bereich</th>
|
|
||||||
<th class="rechts"><?= $ergebnis['trockenlauf'] ? 'Neu' : 'Angelegt' ?></th>
|
|
||||||
<th class="rechts">Schon vorhanden</th>
|
|
||||||
<th class="rechts">Fehler</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($bereiche as $schluessel => $beschriftung): ?>
|
|
||||||
<tr>
|
|
||||||
<td><?= View::h($beschriftung) ?></td>
|
|
||||||
<td class="rechts"><?= (int)$ergebnis[$schluessel]['neu'] ?></td>
|
|
||||||
<td class="rechts nebentext"><?= (int)$ergebnis[$schluessel]['vorhanden'] ?></td>
|
|
||||||
<td class="rechts <?= (int)$ergebnis[$schluessel]['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
|
|
||||||
<?= (int)$ergebnis[$schluessel]['fehler'] ?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<tr>
|
|
||||||
<td>Kontengruppen</td>
|
|
||||||
<td class="rechts nebentext" colspan="3">
|
|
||||||
nicht im QIF-Export enthalten – <a href="/verwaltung/kontengruppen">hier zuordnen</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<p class="hinweis">
|
|
||||||
Der FinanzManager exportiert je Konto nur Name, Kontoart, Beschreibung und
|
|
||||||
Kreditlimit. Die Zuordnung zu einer Kontengruppe steht weder im QIF noch in der
|
|
||||||
<code>QSD</code>-Datei; sie wird deshalb einmalig in der Verwaltung nachgetragen.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<?php if ($buchungen !== null): ?>
|
|
||||||
<h3>Buchungen</h3>
|
|
||||||
<table class="liste">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><?= $buchungen['trockenlauf'] ? 'Neu' : 'Angelegt' ?></td>
|
|
||||||
<td class="rechts"><?= (int)$buchungen['buchungen']['neu'] ?></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="nebentext">schon vorhanden</td>
|
|
||||||
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['vorhanden'] ?></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="nebentext">
|
|
||||||
vor dem Stichtag – in den Anfangssaldo von
|
|
||||||
<?= (int)$buchungen['anfangssalden']['konten'] ?> Konten verrechnet
|
|
||||||
</td>
|
|
||||||
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['vor_stichtag'] ?></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="nebentext">nach dem Enddatum – vorausberechnet, verworfen</td>
|
|
||||||
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['zukunft'] ?></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="nebentext">darunter Wertpapiergeschäfte (Depot- und Geldbuchung)</td>
|
|
||||||
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['wertpapier'] ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php if ((int)($buchungen['buchungen']['nicht_gewaehlt'] ?? 0) > 0): ?>
|
|
||||||
<tr>
|
|
||||||
<td class="nebentext">gehört zu keinem der gewählten Konten</td>
|
|
||||||
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['nicht_gewaehlt'] ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endif; ?>
|
|
||||||
<tr>
|
|
||||||
<td>Fehler</td>
|
|
||||||
<td class="rechts <?= (int)$buchungen['buchungen']['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
|
|
||||||
<?= (int)$buchungen['buchungen']['fehler'] ?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="hinweis">
|
|
||||||
Zeitraum <?= View::datum($buchungen['stichtag']) ?> bis <?= View::datum($buchungen['bis']) ?>.
|
|
||||||
Alles davor ist nicht verloren: es steckt im Anfangssaldo der Konten.
|
|
||||||
<?php if (($buchungen['konten'] ?? null) !== null): ?>
|
|
||||||
Übernommen wurden nur die Buchungen von
|
|
||||||
<strong><?= View::h(implode(', ', $buchungen['konten'])) ?></strong> – einschließlich
|
|
||||||
der Umbuchungen, die auf einem anderen Konto erfasst wurden und hierher zeigen.
|
|
||||||
Die Anfangssalden der übrigen Konten bleiben unangetastet.
|
|
||||||
<?php endif; ?>
|
|
||||||
</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (($vorlagen ?? null) !== null): ?>
|
|
||||||
<h3>Buchungsvorlagen</h3>
|
|
||||||
<table class="liste">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><?= $buchungen !== null && $buchungen['trockenlauf'] ? 'Neu' : 'Angelegt' ?></td>
|
|
||||||
<td class="rechts"><?= (int)$vorlagen['neu'] ?></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="nebentext">bereits vorhanden</td>
|
|
||||||
<td class="rechts nebentext"><?= (int)$vorlagen['vorhanden'] ?></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Fehler</td>
|
|
||||||
<td class="rechts <?= (int)$vorlagen['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
|
|
||||||
<?= (int)$vorlagen['fehler'] ?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="hinweis">
|
|
||||||
Der FinanzManager merkt sich jede erfasste Zahlung als Vorlage – im
|
|
||||||
Beispielexport sind das mehrere hundert. Sie stehen danach unter
|
|
||||||
<a href="/vorlagen">Buchungsvorlagen</a>.
|
|
||||||
</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (!empty($ergebnis['abschnitte'])): ?>
|
|
||||||
<h3>Inhalt der Datei</h3>
|
|
||||||
<p class="hinweis">
|
|
||||||
<?php $teile = [];
|
|
||||||
foreach ($ergebnis['abschnitte'] as $typ => $anzahl) {
|
|
||||||
$teile[] = View::h((string)$typ) . ': ' . (int)$anzahl;
|
|
||||||
}
|
|
||||||
echo implode(' · ', $teile); ?>
|
|
||||||
</p>
|
|
||||||
<p class="hinweis">
|
|
||||||
Buchungsvorlagen (<code>Memorized</code>) folgen in Phase 7, Wertpapierbestände
|
|
||||||
in Phase 6.
|
|
||||||
</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (!empty($ergebnis['meldungen'])): ?>
|
|
||||||
<h3>Protokoll</h3>
|
|
||||||
<ul class="protokoll">
|
|
||||||
<?php foreach ($ergebnis['meldungen'] as $meldung): ?>
|
|
||||||
<li class="<?= View::h($meldung['typ']) ?>"><?= View::h($meldung['text']) ?></li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($ergebnis['trockenlauf']): ?>
|
|
||||||
<form method="post" action="/verwaltung/import/ausfuehren" class="formular">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
|
||||||
<input type="hidden" name="datei" value="<?= View::h($datei) ?>">
|
|
||||||
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
|
|
||||||
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
|
|
||||||
|
|
||||||
<?php if ($dateikonten !== []): ?>
|
|
||||||
<h3>Konten</h3>
|
|
||||||
<p class="feldhinweis">
|
|
||||||
Zur Wahl stehen die Konten, die in dieser Datei vorkommen. Ohne Häkchen
|
|
||||||
zählt die ganze Datei. Eine Umbuchung kommt mit, sobald eines der beiden
|
|
||||||
Konten gewählt ist – sie steht im Export nur einmal, beim erfassenden
|
|
||||||
Konto. Die Zahl nennt die Einträge, die das Konto berühren.
|
|
||||||
</p>
|
|
||||||
<div class="auswahlliste">
|
|
||||||
<?php foreach ($dateikonten as $name => $anzahl): ?>
|
|
||||||
<label class="ankreuzfeld">
|
|
||||||
<input type="checkbox" name="konten[]" value="<?= View::h((string)$name) ?>"
|
|
||||||
<?= $auswahl === [] || in_array((string)$name, $auswahl, true) ? 'checked' : '' ?>>
|
|
||||||
<span>
|
|
||||||
<?= View::h((string)$name) ?>
|
|
||||||
<span class="nebentext"><?= (int)$anzahl ?></span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="knopfleiste">
|
|
||||||
<button type="submit" name="modus" value="import" class="knopf primaer">Jetzt übernehmen</button>
|
|
||||||
<?php if ($dateikonten !== []): ?>
|
|
||||||
<button type="submit" name="modus" value="vorschau" class="knopf">
|
|
||||||
Trockenlauf mit dieser Auswahl
|
|
||||||
</button>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<?php else: ?>
|
|
||||||
<div class="knopfleiste">
|
|
||||||
<a href="/konten" class="knopf primaer">Kontenübersicht</a>
|
|
||||||
<a href="/verwaltung/kontengruppen" class="knopf">Kontengruppen</a>
|
|
||||||
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
|
|
||||||
</div>
|
|
||||||
<p class="hinweis">
|
|
||||||
Die Anfangssalden wurden aus dem Zeitraum vor dem Stichtag berechnet. In der
|
|
||||||
Kontenübersicht lässt sich jeder Wert nachträglich ändern – ebenso das
|
|
||||||
Kennzeichen, ob ein Konto zur Summe der Kontostände zählt.
|
|
||||||
</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="kachel">
|
<div class="kachel">
|
||||||
<header><h2>Exportdatei hochladen</h2></header>
|
<header><h2><?= View::h(Dateiformat::name($format)) ?>-Datei hochladen</h2></header>
|
||||||
<form method="post" action="/verwaltung/import/hochladen" enctype="multipart/form-data" class="formular">
|
<form method="post" action="/verwaltung/import/hochladen" enctype="multipart/form-data" class="formular">
|
||||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
<label for="qif">QIF-Datei</label>
|
<input type="hidden" name="format" value="<?= View::h($format) ?>">
|
||||||
<input type="file" id="qif" name="qif" accept=".qif,.QIF" required>
|
<label for="datei"><?= View::h(Dateiformat::name($format)) ?>-Datei</label>
|
||||||
|
<input type="file" id="datei" name="datei"
|
||||||
|
accept="<?= View::h($endung . ',' . strtoupper($endung)) ?>" required>
|
||||||
<p class="feldhinweis">
|
<p class="feldhinweis">
|
||||||
Die Datei wird unter <code><?= View::h($verzeichnis) ?></code> abgelegt und bleibt
|
Die Datei wird unter <code><?= View::h($verzeichnis) ?></code> abgelegt und bleibt
|
||||||
dort – so lässt sich derselbe Export erst prüfen und dann übernehmen.
|
dort – so lässt sich derselbe Export erst prüfen und dann übernehmen.
|
||||||
|
<?php if ($aufbewahrung > 0): ?>
|
||||||
|
Nach <?= (int)$aufbewahrung ?> Tagen räumt das Housekeeping sie weg; die Frist steht
|
||||||
|
in den <a href="/einstellungen?bereich=import">Einstellungen</a>.
|
||||||
|
<?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
<div class="knopfleiste">
|
<div class="knopfleiste">
|
||||||
<button type="submit" class="knopf primaer">Hochladen</button>
|
<button type="submit" class="knopf primaer">Hochladen</button>
|
||||||
|
|
@ -251,24 +58,12 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kachel">
|
<div class="kachel">
|
||||||
<header><h2>Vorhandene Dateien</h2></header>
|
<header><h2>Vorhandene <?= View::h(Dateiformat::name($format)) ?>-Dateien</h2></header>
|
||||||
|
|
||||||
<p class="hinweis">
|
|
||||||
Übernommen werden Buchungen ab <strong><?= View::datum($stichtag) ?></strong> bis
|
|
||||||
<strong><?= View::datum($bis) ?></strong>. Alles davor fließt in den Anfangssaldo der
|
|
||||||
Konten, alles danach gilt als Vorausberechnung des FinanzManagers und bleibt draußen.
|
|
||||||
Der Stichtag steht in den <a href="/einstellungen?bereich=import">Einstellungen</a>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="hinweis">
|
|
||||||
Soll nur ein Teil der Konten übernommen werden, zuerst den <strong>Trockenlauf</strong>
|
|
||||||
starten: danach steht fest, welche Konten die Datei führt, und sie stehen einzeln zur
|
|
||||||
Wahl. <strong>Importieren</strong> übernimmt die ganze Datei.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<?php if ($dateien === []): ?>
|
<?php if ($dateien === []): ?>
|
||||||
<p class="platzhalter">
|
<p class="platzhalter">
|
||||||
Noch keine Datei in <code><?= View::h($verzeichnis) ?></code>.
|
Noch keine <?= View::h(Dateiformat::name($format)) ?>-Datei in
|
||||||
|
<code><?= View::h($verzeichnis) ?></code>.
|
||||||
</p>
|
</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<table class="liste">
|
<table class="liste">
|
||||||
|
|
@ -286,8 +81,10 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
<input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>">
|
<input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>">
|
||||||
<input type="hidden" name="modus" value="vorschau">
|
<input type="hidden" name="modus" value="vorschau">
|
||||||
|
<?php if ($format === 'qif'): ?>
|
||||||
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
|
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
|
||||||
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
|
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
<button type="submit" class="knopf klein">Trockenlauf</button>
|
<button type="submit" class="knopf klein">Trockenlauf</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/verwaltung/import/ausfuehren" class="inline"
|
<form method="post" action="/verwaltung/import/ausfuehren" class="inline"
|
||||||
|
|
@ -295,8 +92,10 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
<input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>">
|
<input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>">
|
||||||
<input type="hidden" name="modus" value="import">
|
<input type="hidden" name="modus" value="import">
|
||||||
|
<?php if ($format === 'qif'): ?>
|
||||||
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
|
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
|
||||||
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
|
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
<button type="submit" class="knopf klein primaer">Importieren</button>
|
<button type="submit" class="knopf klein primaer">Importieren</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/verwaltung/import/<?= View::h(rawurlencode($eintrag['name'])) ?>/loeschen"
|
<form method="post" action="/verwaltung/import/<?= View::h(rawurlencode($eintrag['name'])) ?>/loeschen"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\Services\Dateiformat;
|
||||||
use Finanzmanager\View;
|
use Finanzmanager\View;
|
||||||
|
|
||||||
/** @var array<string,int> $zahlen */
|
/** @var array<string,int> $zahlen */
|
||||||
|
|
@ -44,13 +45,16 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kachel">
|
<div class="kachel">
|
||||||
<header><h2>QIF-Import</h2></header>
|
<header><h2>Import/Export</h2></header>
|
||||||
<p class="platzhalter">
|
<p class="platzhalter">
|
||||||
Klassen, Kategorien und Konten aus einem Export des Lexware FinanzManagers
|
Stammdaten und Buchungen aus dem Lexware FinanzManager übernehmen oder den
|
||||||
übernehmen – erst im Trockenlauf prüfen, dann übernehmen.
|
eigenen Bestand herausgeben – wahlweise als QIF oder als CSV.
|
||||||
</p>
|
</p>
|
||||||
<div class="knopfleiste">
|
<div class="knopfleiste">
|
||||||
<a href="/verwaltung/import" class="knopf klein primaer">Import öffnen</a>
|
<?php /* data-formatwahl öffnet den Auswahldialog; ohne Skript führt der Link
|
||||||
|
auf dieselbe Auswahl als Seite. */ ?>
|
||||||
|
<a href="/verwaltung/import" class="knopf klein primaer" data-formatwahl="Import">Import</a>
|
||||||
|
<a href="/verwaltung/export" class="knopf klein" data-formatwahl="Export">Export</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -94,4 +98,25 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php /*
|
||||||
|
Die Formatwahl von Import und Export. Die Karten stehen hier im Markup und
|
||||||
|
nicht im Skript: Die Liste der Formate gehört Dateiformat::ALLE, und ein
|
||||||
|
Inline-Skript verböte ohnehin die Content-Security-Policy. Das Skript setzt
|
||||||
|
nur die Ziele und öffnet den Dialog; ohne Skript führen die Knöpfe auf
|
||||||
|
dieselbe Auswahl als eigene Seite. */ ?>
|
||||||
|
<dialog class="formatdialog" id="formatwahl">
|
||||||
|
<h2 data-formatdialog-titel>Format wählen</h2>
|
||||||
|
<div class="formatwahl">
|
||||||
|
<?php foreach (Dateiformat::ALLE as $schluessel => $name): ?>
|
||||||
|
<a class="formatkarte" href="#" data-format="<?= View::h($schluessel) ?>">
|
||||||
|
<span class="formatname"><?= View::h($name) ?></span>
|
||||||
|
<span class="nebentext"><?= View::h(Dateiformat::BESCHREIBUNGEN[$schluessel] ?? '') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="knopfleiste">
|
||||||
|
<button type="submit" class="knopf klein">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||||
|
|
|
||||||
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.
|
// Letzter Punkt: keine weitere Kachel hinter der von Verträge.
|
||||||
$letzte = (int)strrpos($kacheln, '<h2>Verträge</h2>');
|
$letzte = (int)strrpos($kacheln, '<h2>Verträge</h2>');
|
||||||
foreach (['Kategorien', 'Klassen', 'Konten', 'QIF-Import', 'Online-Banking', 'Termine'] as $vorher) {
|
foreach (['Kategorien', 'Klassen', 'Konten', 'Import/Export', 'Online-Banking', 'Termine'] as $vorher) {
|
||||||
$this->assert(
|
$this->assert(
|
||||||
$letzte > (int)strrpos($kacheln, '<h2>' . $vorher . '</h2>'),
|
$letzte > (int)strrpos($kacheln, '<h2>' . $vorher . '</h2>'),
|
||||||
'Verträge stehen hinter der Kachel ' . $vorher
|
'Verträge stehen hinter der Kachel ' . $vorher
|
||||||
|
|
|
||||||
238
tests/28_UmsaetzeLoeschenTest.php
Normal file
238
tests/28_UmsaetzeLoeschenTest.php
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Finanzmanager\Database;
|
||||||
|
use Finanzmanager\Models\Bankumsatz;
|
||||||
|
use Finanzmanager\Models\Bankzugang;
|
||||||
|
use Finanzmanager\Models\Buchung;
|
||||||
|
use Finanzmanager\Models\Kategorie;
|
||||||
|
use Finanzmanager\Models\Klasse;
|
||||||
|
use Finanzmanager\Models\Konto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sammellöschen in den Umsatzlisten des Bankings.
|
||||||
|
*
|
||||||
|
* Geprüft wird, was hinter dem Knopf *Auswahl löschen* passiert: Die gewählten
|
||||||
|
* Umsätze verschwinden – und zwar nur sie. Eine schon erzeugte Buchung gehört
|
||||||
|
* ins Kontoblatt und bleibt dort stehen; sie verliert lediglich den Umsatz, der
|
||||||
|
* sie belegt hat. Der Knopf steht in jedem Reiter, also müssen offene,
|
||||||
|
* übernommene und verworfene Umsätze gleichermaßen gehen.
|
||||||
|
*/
|
||||||
|
class UmsaetzeLoeschenTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 28: Bankumsätze sammelweise löschen';
|
||||||
|
|
||||||
|
private int $giro = 0;
|
||||||
|
private int $zugang = 0;
|
||||||
|
private int $bankkonto = 0;
|
||||||
|
private int $laufendeNummer = 0;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->mandantAnlegen();
|
||||||
|
$this->stammdaten();
|
||||||
|
|
||||||
|
$this->auswahlVerschwindetAusDerListe();
|
||||||
|
$this->buchungUeberlebtDenGeloeschtenUmsatz();
|
||||||
|
$this->jederStatusLaesstSichLoeschen();
|
||||||
|
$this->leereUndUnbekannteAuswahlBleibtFolgenlos();
|
||||||
|
$this->jedeAnsichtBietetDieAuswahlAn();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stammdaten(): void
|
||||||
|
{
|
||||||
|
$this->giro = Konto::anlegen([
|
||||||
|
'name' => 'Sammelgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->zugang = Bankzugang::anlegen([
|
||||||
|
'name' => 'Sammelbank',
|
||||||
|
'bankname' => 'Sammelbank AG',
|
||||||
|
'blz' => '20041112',
|
||||||
|
'fints_url' => 'https://banking.example.org/fints',
|
||||||
|
'benutzerkennung' => 'testkunde',
|
||||||
|
'pin' => 'geheim1234',
|
||||||
|
'abrufzeitraum' => 'letzter_abruf',
|
||||||
|
'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->bankkonto = (int)Bankzugang::findeOderLegeKontoAn(
|
||||||
|
$this->zugang,
|
||||||
|
'DE53200411120847626901',
|
||||||
|
'847626901',
|
||||||
|
'Girokonto'
|
||||||
|
)['id'];
|
||||||
|
|
||||||
|
Bankzugang::ordneKontoZu($this->bankkonto, $this->giro);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function umsatzAnlegen(string $zweck, float $betrag = -19.99): int
|
||||||
|
{
|
||||||
|
// Jeder Umsatz bekommt einen eigenen Tag: Sonst fiele der Fingerabdruck
|
||||||
|
// zweier gleich lautender Zeilen zusammen und der zweite käme nie an.
|
||||||
|
$tag = str_pad((string)(++$this->laufendeNummer), 2, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
$id = Bankumsatz::anlegen([
|
||||||
|
'zugang_id' => $this->zugang,
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'bank_konto_id' => $this->bankkonto,
|
||||||
|
'extern_id' => Bankumsatz::externId($this->bankkonto, '2026-07-' . $tag, $betrag, $zweck),
|
||||||
|
'buchungstag' => '2026-07-' . $tag,
|
||||||
|
'wertstellung' => '2026-07-' . $tag,
|
||||||
|
'betrag' => $betrag,
|
||||||
|
'empfaenger' => 'Sammelempfänger',
|
||||||
|
'verwendungszweck' => $zweck,
|
||||||
|
'buchungsart' => 'LASTSCHRIFT',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotEmpty($id, 'Umsatz angelegt: ' . $zweck);
|
||||||
|
|
||||||
|
return (int)$id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function existiert(int $umsatzId): bool
|
||||||
|
{
|
||||||
|
return Database::fetchValue('SELECT 1 FROM bank_umsaetze WHERE id = ?', [$umsatzId]) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Mehrfachauswahl trifft genau die angekreuzten Zeilen. */
|
||||||
|
private function auswahlVerschwindetAusDerListe(): void
|
||||||
|
{
|
||||||
|
$eins = $this->umsatzAnlegen('Sammelstrom');
|
||||||
|
$zwei = $this->umsatzAnlegen('Sammelwasser');
|
||||||
|
$drei = $this->umsatzAnlegen('Sammelgas');
|
||||||
|
|
||||||
|
$anzahl = Bankumsatz::mehrereLoeschen([$eins, $drei]);
|
||||||
|
|
||||||
|
$this->assertEquals(2, $anzahl, 'Zwei ausgewählte Umsätze gelöscht');
|
||||||
|
$this->assert(!$this->existiert($eins), 'Der erste ausgewählte Umsatz ist weg');
|
||||||
|
$this->assert(!$this->existiert($drei), 'Der zweite ausgewählte Umsatz ist weg');
|
||||||
|
$this->assert($this->existiert($zwei), 'Der nicht ausgewählte Umsatz steht noch da');
|
||||||
|
|
||||||
|
$offene = array_map('intval', array_column(Bankumsatz::liste(['status' => 'offen']), 'id'));
|
||||||
|
$this->assert(!in_array($eins, $offene, true), 'Der gelöschte Umsatz steht nicht mehr in der Liste');
|
||||||
|
$this->assert(in_array($zwei, $offene, true), 'Der übrige Umsatz steht weiter zum Abgleich bereit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Unterschied zum Kontoblatt: Hier wird die abgerufene Zeile gelöscht,
|
||||||
|
* nicht die Buchung. Was einmal gebucht ist, bleibt gebucht.
|
||||||
|
*/
|
||||||
|
private function buchungUeberlebtDenGeloeschtenUmsatz(): void
|
||||||
|
{
|
||||||
|
$umsatz = $this->umsatzAnlegen('Sammelmiete', -750.00);
|
||||||
|
$buchungId = Bankumsatz::uebernehmen($umsatz, []);
|
||||||
|
|
||||||
|
$this->assertEquals(1, Bankumsatz::mehrereLoeschen([$umsatz]), 'Der übernommene Umsatz wurde gelöscht');
|
||||||
|
$this->assert(!$this->existiert($umsatz), 'Der übernommene Umsatz ist weg');
|
||||||
|
|
||||||
|
$buchung = Buchung::find((int)$buchungId);
|
||||||
|
$this->assertNotEmpty($buchung, 'Die Buchung steht weiter im Kontoblatt');
|
||||||
|
$this->assertEquals(-750.00, (float)$buchung['betrag'], 'Und zwar unverändert');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Knopf steht in jedem Reiter – offen, übernommen und verworfen. */
|
||||||
|
private function jederStatusLaesstSichLoeschen(): void
|
||||||
|
{
|
||||||
|
$offen = $this->umsatzAnlegen('Sammelversicherung', -44.00);
|
||||||
|
$uebernommen = $this->umsatzAnlegen('Sammelrundfunk', -55.08);
|
||||||
|
$verworfen = $this->umsatzAnlegen('Sammelfehlbuchung', -1.00);
|
||||||
|
|
||||||
|
Bankumsatz::uebernehmen($uebernommen, []);
|
||||||
|
Bankumsatz::verwerfen($verworfen);
|
||||||
|
|
||||||
|
$anzahl = Bankumsatz::mehrereLoeschen([$offen, $uebernommen, $verworfen]);
|
||||||
|
|
||||||
|
$this->assertEquals(3, $anzahl, 'Aus jedem Reiter lässt sich löschen');
|
||||||
|
$this->assert(!$this->existiert($offen), 'Offener Umsatz gelöscht');
|
||||||
|
$this->assert(!$this->existiert($uebernommen), 'Übernommener Umsatz gelöscht');
|
||||||
|
$this->assert(!$this->existiert($verworfen), 'Verworfener Umsatz gelöscht');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ohne Auswahl darf nichts geschehen – der Controller weist den leeren Fall
|
||||||
|
* ab, das Modell darf ihn trotzdem nicht als „alles löschen“ lesen.
|
||||||
|
*/
|
||||||
|
private function leereUndUnbekannteAuswahlBleibtFolgenlos(): void
|
||||||
|
{
|
||||||
|
$bleibt = $this->umsatzAnlegen('Sammelabo', -9.99);
|
||||||
|
|
||||||
|
$this->assertEquals(0, Bankumsatz::mehrereLoeschen([]), 'Leere Auswahl löscht nichts');
|
||||||
|
$this->assertEquals(0, Bankumsatz::mehrereLoeschen([0, -3]), 'Unbrauchbare Nummern löschen nichts');
|
||||||
|
$this->assertEquals(0, Bankumsatz::mehrereLoeschen([$bleibt + 100000]), 'Unbekannte Nummer löscht nichts');
|
||||||
|
$this->assert($this->existiert($bleibt), 'Der vorhandene Umsatz ist unangetastet');
|
||||||
|
|
||||||
|
// Doppelt angekreuzt gibt es nicht, doppelt gesendet schon – die Anzahl
|
||||||
|
// in der Meldung darf dadurch nicht steigen.
|
||||||
|
$this->assertEquals(1, Bankumsatz::mehrereLoeschen([$bleibt, $bleibt]), 'Doppelte Nummer zählt einmal');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Oberfläche: In jedem Reiter stehen die Ankreuzfelder vor den Umsätzen,
|
||||||
|
* das Feld „alle wählen“ in der Titelleiste und darüber der Knopf.
|
||||||
|
*/
|
||||||
|
private function jedeAnsichtBietetDieAuswahlAn(): void
|
||||||
|
{
|
||||||
|
$offen = $this->umsatzAnlegen('Ansichtsstrom', -31.00);
|
||||||
|
$uebernommen = $this->umsatzAnlegen('Ansichtsmiete', -420.00);
|
||||||
|
$verworfen = $this->umsatzAnlegen('Ansichtsdublette', -2.50);
|
||||||
|
|
||||||
|
Bankumsatz::uebernehmen($uebernommen, []);
|
||||||
|
Bankumsatz::verwerfen($verworfen);
|
||||||
|
|
||||||
|
$erwartet = ['offen' => $offen, 'uebernommen' => $uebernommen, 'verworfen' => $verworfen];
|
||||||
|
|
||||||
|
foreach ($erwartet as $status => $umsatzId) {
|
||||||
|
$html = $this->ansicht($status);
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
str_contains($html, 'action="/banking/umsaetze/loeschen?status=' . $status . '"'),
|
||||||
|
'Reiter „' . $status . '“: Das Löschformular kehrt in denselben Reiter zurück'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
str_contains($html, '>Auswahl löschen</button>'),
|
||||||
|
'Reiter „' . $status . '“: Der Knopf „Auswahl löschen“ steht über der Liste'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
str_contains($html, 'class="alle-waehlen" data-waehlt="umsatzauswahl"'),
|
||||||
|
'Reiter „' . $status . '“: Die Titelleiste hat ein Feld „alle wählen“'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
str_contains($html, 'name="umsaetze[]" value="' . $umsatzId . '" form="umsatzauswahl"'),
|
||||||
|
'Reiter „' . $status . '“: Vor dem Umsatz steht sein Ankreuzfeld'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Umsätze steht auch kein Löschknopf da – sonst zeigte die leere
|
||||||
|
// Liste eine Schaltfläche, die nichts treffen kann.
|
||||||
|
Bankumsatz::mehrereLoeschen(array_values($erwartet));
|
||||||
|
$this->assert(
|
||||||
|
!str_contains($this->ansicht('verworfen'), '>Auswahl löschen</button>'),
|
||||||
|
'Ohne Umsätze gibt es nichts zu löschen'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rendert die Abgleichliste eines Reiters so, wie der Controller es täte. */
|
||||||
|
private function ansicht(string $status): string
|
||||||
|
{
|
||||||
|
$umsaetze = [];
|
||||||
|
foreach (Bankumsatz::liste(['status' => $status]) as $umsatz) {
|
||||||
|
$umsatz['vorschlag'] = $status === 'offen' ? Bankumsatz::vorschlag($umsatz) : null;
|
||||||
|
$umsaetze[] = $umsatz;
|
||||||
|
}
|
||||||
|
|
||||||
|
$titel = 'Umsätze abgleichen';
|
||||||
|
$flash = [];
|
||||||
|
$benutzer = null;
|
||||||
|
$csrfToken = 'x';
|
||||||
|
$kategorien = Kategorie::baumFlach(null, true);
|
||||||
|
$klassen = Klasse::alle();
|
||||||
|
$konten = Konto::alle(true);
|
||||||
|
$offen = Bankumsatz::anzahlOffen();
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
include dirname(__DIR__) . '/src/Views/banking/umsaetze.php';
|
||||||
|
return (string)ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,41 @@ test.describe('Buchungen', () => {
|
||||||
await expect(page.locator('.saldoanzeige').first()).toContainText('1.000,00')
|
await expect(page.locator('.saldoanzeige').first()).toContainText('1.000,00')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Die Maske nennt oben das Konto, in dem die Buchung liegt, und stellt ihre
|
||||||
|
// Felder zweispaltig – beides, damit man beim Erfassen weder rätselt noch
|
||||||
|
// vertikal scrollt.
|
||||||
|
test('Buchungsmaske nennt das Konto und passt auf einen Bildschirm', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
await page.goto(`/konten/${giroId}/buchung`)
|
||||||
|
|
||||||
|
// Das Konto steht in der Maske selbst, nicht nur in der Kopfzeile.
|
||||||
|
await expect(page.locator('.buchungsmaske .feldwert')).toContainText(GIRO)
|
||||||
|
|
||||||
|
// Gleiche Zeile, zweite Spalte.
|
||||||
|
const art = await page.locator('#art').boundingBox()
|
||||||
|
const datum = await page.locator('#datum').boundingBox()
|
||||||
|
expect(Math.abs(art.y - datum.y)).toBeLessThan(5)
|
||||||
|
expect(datum.x).toBeGreaterThan(art.x + art.width)
|
||||||
|
|
||||||
|
// Und die Beschriftung steht links neben der Eingabe, nicht darüber.
|
||||||
|
const beschriftung = await page.locator('label[for="betrag"]').boundingBox()
|
||||||
|
const betrag = await page.locator('#betrag').boundingBox()
|
||||||
|
expect(beschriftung.x + beschriftung.width).toBeLessThanOrEqual(betrag.x)
|
||||||
|
expect(Math.abs(
|
||||||
|
(beschriftung.y + beschriftung.height / 2) - (betrag.y + betrag.height / 2)
|
||||||
|
)).toBeLessThan(5)
|
||||||
|
|
||||||
|
// Die Erklärungen stehen im Tooltip der Beschriftung, nicht als eigene
|
||||||
|
// Zeile darunter – sonst passte die Maske nicht auf einen Bildschirm.
|
||||||
|
await expect(page.locator('label[for="zuordnung"]')).toHaveAttribute('title', /Umbuchung/)
|
||||||
|
await expect(page.locator('label[for="betrag"]')).toHaveAttribute('title', /positiv/)
|
||||||
|
|
||||||
|
// Und genau das ist der Zweck: Die ganze Maske samt Splitzeilen und
|
||||||
|
// Knopfleiste endet oberhalb der Fensterhöhe.
|
||||||
|
const maske = await page.locator('.buchungsmaske').boundingBox()
|
||||||
|
expect(maske.y + maske.height).toBeLessThan(900)
|
||||||
|
})
|
||||||
|
|
||||||
test('Ausgabe buchen und Saldo fortschreiben', async ({ page }) => {
|
test('Ausgabe buchen und Saldo fortschreiben', async ({ page }) => {
|
||||||
await page.goto(`/konten/${giroId}/buchung`)
|
await page.goto(`/konten/${giroId}/buchung`)
|
||||||
|
|
||||||
|
|
|
||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -89,6 +89,24 @@ test.describe('Stammdaten', () => {
|
||||||
await expect(page.locator('tr', { hasText: name })).not.toContainText('ohne Σ')
|
await expect(page.locator('tr', { hasText: name })).not.toContainText('ohne Σ')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Das Kontoformular steht zweispaltig, damit alle Felder ohne vertikales
|
||||||
|
// Scrollen sichtbar sind. Geprüft wird an den Kästen: Kontoart steht rechts
|
||||||
|
// neben dem Namen, und das Formular endet oberhalb der Fensterhöhe.
|
||||||
|
test('Kontoformular steht zweispaltig und ohne Scrollen', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
await page.goto('/konten/neu')
|
||||||
|
|
||||||
|
const name = await page.locator('#name').boundingBox()
|
||||||
|
const kontoart = await page.locator('#kontoart').boundingBox()
|
||||||
|
|
||||||
|
// Gleiche Zeile, zweite Spalte.
|
||||||
|
expect(Math.abs(name.y - kontoart.y)).toBeLessThan(5)
|
||||||
|
expect(kontoart.x).toBeGreaterThan(name.x + name.width)
|
||||||
|
|
||||||
|
const knopf = await page.locator('.formular button[type="submit"]').first().boundingBox()
|
||||||
|
expect(knopf.y + knopf.height).toBeLessThan(900)
|
||||||
|
})
|
||||||
|
|
||||||
test('Kategorie mit Unterkategorie anlegen', async ({ page }) => {
|
test('Kategorie mit Unterkategorie anlegen', async ({ page }) => {
|
||||||
const oben = `E2E Wohnen ${MARKE}`
|
const oben = `E2E Wohnen ${MARKE}`
|
||||||
const unten = `Strom ${MARKE}`
|
const unten = `Strom ${MARKE}`
|
||||||
|
|
@ -188,8 +206,8 @@ test.describe('Stammdaten', () => {
|
||||||
'',
|
'',
|
||||||
].join('\r\n')
|
].join('\r\n')
|
||||||
|
|
||||||
await page.goto('/verwaltung/import')
|
await page.goto('/verwaltung/import?format=qif')
|
||||||
await page.setInputFiles('#qif', {
|
await page.setInputFiles('#datei', {
|
||||||
name: dateiname,
|
name: dateiname,
|
||||||
mimeType: 'application/octet-stream',
|
mimeType: 'application/octet-stream',
|
||||||
buffer: Buffer.from(inhalt, 'latin1'),
|
buffer: Buffer.from(inhalt, 'latin1'),
|
||||||
|
|
@ -215,7 +233,7 @@ test.describe('Stammdaten', () => {
|
||||||
await expect(page.locator('body')).not.toContainText(klasse)
|
await expect(page.locator('body')).not.toContainText(klasse)
|
||||||
|
|
||||||
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
|
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
|
||||||
await page.goto('/verwaltung/import')
|
await page.goto('/verwaltung/import?format=qif')
|
||||||
page.on('dialog', async (dialog) => await dialog.accept())
|
page.on('dialog', async (dialog) => await dialog.accept())
|
||||||
await page.locator('tr', { hasText: dateiname })
|
await page.locator('tr', { hasText: dateiname })
|
||||||
.getByRole('button', { name: 'Löschen' }).click()
|
.getByRole('button', { name: 'Löschen' }).click()
|
||||||
|
|
@ -254,8 +272,8 @@ test.describe('Stammdaten', () => {
|
||||||
'',
|
'',
|
||||||
].join('\r\n')
|
].join('\r\n')
|
||||||
|
|
||||||
await page.goto('/verwaltung/import')
|
await page.goto('/verwaltung/import?format=qif')
|
||||||
await page.setInputFiles('#qif', {
|
await page.setInputFiles('#datei', {
|
||||||
name: dateiname,
|
name: dateiname,
|
||||||
mimeType: 'application/octet-stream',
|
mimeType: 'application/octet-stream',
|
||||||
buffer: Buffer.from(inhalt, 'latin1'),
|
buffer: Buffer.from(inhalt, 'latin1'),
|
||||||
|
|
@ -286,7 +304,7 @@ test.describe('Stammdaten', () => {
|
||||||
await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).not.toBeChecked()
|
await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).not.toBeChecked()
|
||||||
|
|
||||||
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
|
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
|
||||||
await page.goto('/verwaltung/import')
|
await page.goto('/verwaltung/import?format=qif')
|
||||||
page.on('dialog', async (dialog) => await dialog.accept())
|
page.on('dialog', async (dialog) => await dialog.accept())
|
||||||
await page.locator('tr', { hasText: dateiname })
|
await page.locator('tr', { hasText: dateiname })
|
||||||
.getByRole('button', { name: 'Löschen' }).click()
|
.getByRole('button', { name: 'Löschen' }).click()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue