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