From 9839bd5b16800680ade7a4c30737b69dbbcd1941 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 17 Aug 2026 22:24:14 +0200 Subject: [PATCH 1/4] =?UTF-8?q?Banking:=20Ums=C3=A4tze=20sammelweise=20l?= =?UTF-8?q?=C3=B6schen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Umsatzlisten haben jetzt 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“. Er steht in jedem Reiter – offen, übernommen und verworfen –, nennt die Anzahl der Auswahl und bleibt ohne Auswahl gesperrt. Bisher ließ sich ein abgerufener Umsatz nur übernehmen oder verwerfen; wegräumen ging gar nicht. 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 im nächsten Abrufzeitraum liegt, kommt danach wieder herein, bei einem verworfenen Umsatz also erneut als offen. - Neue Route POST /banking/umsaetze/loeschen auf Bankumsatz::mehrereLoeschen(); der Status hängt am Formularziel, sodass der Weg in denselben Reiter zurückführt. Ohne JavaScript weist der Controller eine leere Auswahl ab. - 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 (.alle-waehlen mit data-waehlt) ist unverändert die des Kontoblatts. Co-Authored-By: Claude Opus 5 --- Changes.md | 19 ++ docs/banking.md | 20 +++ public/index.php | 1 + public/static/app.css | 28 +++ src/Controllers/BankingController.php | 30 ++++ src/Models/Bankumsatz.php | 26 +++ src/Views/banking/umsaetze.php | 40 ++++- tests/28_UmsaetzeLoeschenTest.php | 238 ++++++++++++++++++++++++++ 8 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 tests/28_UmsaetzeLoeschenTest.php diff --git a/Changes.md b/Changes.md index df18f6e..0ec2372 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,24 @@ # Änderungen +## 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/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/public/index.php b/public/index.php index 7f0eb68..3a3f488 100644 --- a/public/index.php +++ b/public/index.php @@ -278,6 +278,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', diff --git a/public/static/app.css b/public/static/app.css index f8ad4fc..82b0dfb 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1637,6 +1637,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/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index dc6c81e..df7c300 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -557,6 +557,36 @@ class BankingController extends BaseController 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. * 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/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 - +
-
+ + + + +
+ Regeln pflegen + + +
- +
- Regeln pflegen
@@ -56,6 +70,19 @@ include __DIR__ . '/../layout/kopf.php'; : 'Keine Umsätze mit diesem Status.' ?>

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

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

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

+ + +

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

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

Buchungen als ausgeben

+ Buchungen +
+ +

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

+ +
+ + + +
+ + +
+ +

Konten

+ +

Noch keine Konten angelegt.

+ +
+ + + +
+ + +

Kategorien

+ +

Noch keine Kategorien angelegt.

+ +
+ + + +
+

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

+ + +

Klassen

+ +

Noch keine Klassen angelegt.

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

Was in der Datei steht

+ + +

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

+

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

+ +

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

+

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

+ + +

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

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

: Format wählen

+ +

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

- -
- - -

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

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

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

- - -

Buchungen

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

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

- - - -

Buchungsvorlagen

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

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

- - - -

Inhalt der Datei

-

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

-

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

- - - -

Protokoll

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

Konten

-

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

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

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

- -
+ + + +
-

Exportdatei hochladen

+

-Datei hochladen

- - + + +

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

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

Vorhandene Dateien

- -

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

- -

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

+

Vorhandene -Dateien

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

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

QIF-Import

+

Import/Export

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

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

Format wählen

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

+ +
+ + +

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

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

Konten in der Datei

+

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

+ + + +

Protokoll

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

So muss die CSV aussehen

+ +

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

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

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

+ +

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

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

+ +
+ + +

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

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

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

+ + +

Buchungen

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

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

+ + + +

Buchungsvorlagen

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

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

+ + + +

Inhalt der Datei

+

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

+ + + +

Protokoll

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

Konten

+

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

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

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

+ +
+ + +
+

So läuft der QIF-Import

+ +

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

+ +

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

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

Verträge

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

' . $vorher . '

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

Import/Export

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

QIF-Import

'), 'Die alte Beschriftung ist weg'); + $this->assert( + (bool)preg_match('#href="/verwaltung/import" class="knopf klein[^"]*" data-formatwahl="Import"#', $seite), + 'Der Importknopf öffnet die Formatwahl' + ); + $this->assert( + (bool)preg_match('#href="/verwaltung/export" class="knopf klein[^"]*" data-formatwahl="Export"#', $seite), + 'Der Exportknopf öffnet die Formatwahl' + ); + + // Die Karten stehen im Markup, damit das Skript ohne Formatliste auskommt. + foreach (array_keys(Dateiformat::ALLE) as $format) { + $this->assert( + str_contains($seite, 'data-format="' . $format . '"'), + 'Der Auswahldialog führt eine Karte für ' . strtoupper($format) + ); + } + + $skript = (string)file_get_contents(dirname(__DIR__) . '/public/static/app.js'); + $this->assert(str_contains($skript, 'data-formatwahl'), 'Das Skript kennt die Formatknöpfe'); + $this->assert(str_contains($skript, 'showModal'), 'Es öffnet den Dialog'); + + // Ohne Skript führt derselbe Knopf auf die Auswahl als eigene Seite. + $wahl = $this->rendere('verwaltung/formatwahl.php', [ + 'richtung' => 'Export', + 'ziel' => '/verwaltung/export', + 'einleitung' => 'Welches Format?', + ]); + $this->assert(str_contains($wahl, 'href="/verwaltung/export?format=qif"'), 'Die Seite führt zum QIF-Export'); + $this->assert(str_contains($wahl, 'href="/verwaltung/export?format=csv"'), 'Die Seite führt zum CSV-Export'); + } + + // ── Hilfen ─────────────────────────────────────────────────────────────── + + /** + * Die Umbuchung und die Splitbuchung des aktuellen Mandanten. + * + * @return array{0: array|null, 1: array|null} + */ + private function umbuchungUndSplit(): array + { + $umbuchung = null; + $split = null; + + foreach (BuchungsExport::buchungen([]) as $buchung) { + if (($buchung['gegenkontoname'] ?? '') === 'Exportrücklage') { + $umbuchung = $buchung; + } + if ($buchung['splits'] !== []) { + $split = $buchung; + } + } + + return [$umbuchung, $split]; + } + + /** @return list */ + private function dateienImImportverzeichnis(): array + { + $pfad = dirname(__DIR__) . '/' . trim( + (string)Einstellung::get('import_qif_verzeichnis', 'data/import'), + '/' + ); + + $dateien = glob($pfad . '/*') ?: []; + sort($dateien); + + return $dateien; + } + + private function verwaltungsseite(): string + { + $seite = $this->rendere('verwaltung/index.php', [ + 'aktuellerPfad' => '/verwaltung', + 'zahlen' => [ + 'kategorien' => 0, 'einnahmen' => 0, 'ausgaben' => 0, 'klassen' => 0, + 'konten' => 0, 'kontengruppen' => 0, 'reihen' => 0, 'erinnerungen' => 0, + 'vertraege' => 0, 'fristen' => 0, + ], + ]); + + return substr($seite, (int)strpos($seite, ' $daten */ + private function rendere(string $view, array $daten): string + { + $daten += ['titel' => 'Test', 'flash' => [], 'benutzer' => null, 'csrfToken' => 'x']; + + extract($daten, EXTR_SKIP); + + ob_start(); + include dirname(__DIR__) . '/src/Views/' . $view; + return (string)ob_get_clean(); + } +} diff --git a/tests/e2e/specs/importexport.spec.js b/tests/e2e/specs/importexport.spec.js new file mode 100644 index 0000000..1d54fd9 --- /dev/null +++ b/tests/e2e/specs/importexport.spec.js @@ -0,0 +1,127 @@ +// @ts-check +const { test, expect } = require('@playwright/test') + +const BENUTZER = process.env.E2E_USER || 'e2e' +const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' + +const MARKE = Date.now().toString().slice(-6) + +async function anmelden(page) { + await page.goto('/login') + + if (page.url().includes('/einrichtung')) { + throw new Error( + 'Instanz ist noch nicht eingerichtet. Zuerst einen Benutzer anlegen: ' + + 'docker compose exec -u www-data php php scripts/e2e-benutzer.php' + ) + } + + await page.fill('#benutzername', BENUTZER) + await page.fill('#passwort', PASSWORT) + await page.click('button[type="submit"]') + await expect(page).toHaveURL(/\/$/) +} + +// Formatwahl und Download laufen im Browser ab – der Dialog braucht das Skript, +// der Download den Antwortkopf. Beides lässt sich in der PHP-Suite nicht prüfen. +test.describe('Import und Export', () => { + test.beforeEach(async ({ page }) => { + await anmelden(page) + }) + + test('Die Kachel fragt erst nach dem Format und öffnet dann den Dialog', async ({ page }) => { + await page.goto('/verwaltung') + + const kachel = page.locator('.kachel', { hasText: 'Import/Export' }) + await expect(kachel.getByRole('link', { name: 'Import', exact: true })).toBeVisible() + await expect(kachel.getByRole('link', { name: 'Export', exact: true })).toBeVisible() + + // Vor dem Klick ist der Auswahldialog zu. + const dialog = page.locator('dialog#formatwahl') + await expect(dialog).toBeHidden() + + await kachel.getByRole('link', { name: 'Export', exact: true }).click() + await expect(dialog).toBeVisible() + await expect(dialog.locator('h2')).toContainText('Export') + + await dialog.getByRole('link', { name: 'CSV' }).click() + await expect(page).toHaveURL(/\/verwaltung\/export\?format=csv$/) + await expect(page.locator('h1')).toContainText('Export (CSV)') + + // Derselbe Weg für den Import – der Dialog merkt sich die Richtung nicht. + await page.goto('/verwaltung') + await kachel.getByRole('link', { name: 'Import', exact: true }).click() + await expect(dialog.locator('h2')).toContainText('Import') + await dialog.getByRole('link', { name: 'QIF' }).click() + await expect(page).toHaveURL(/\/verwaltung\/import\?format=qif$/) + }) + + test('Ohne Skript führt derselbe Knopf auf die Auswahl als Seite', async ({ page }) => { + await page.goto('/verwaltung/export') + + await expect(page.locator('h2').first()).toContainText('Format wählen') + await expect(page.locator('a[href="/verwaltung/export?format=qif"]')).toBeVisible() + await expect(page.locator('a[href="/verwaltung/export?format=csv"]')).toBeVisible() + }) + + test('Der gefilterte Export lädt eine Datei herunter', async ({ page }) => { + await page.goto('/verwaltung/export?format=csv') + + await page.fill('input[name="von"]', '2026-01-01') + await page.fill('input[name="bis"]', '2026-12-31') + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'CSV herunterladen' }).click(), + ]) + + expect(download.suggestedFilename()).toMatch(/^buchungen-2026-01-01-bis-2026-12-31\.csv$/) + + // Die Kopfzeile muss stehen, sonst wäre es keine Buchungstabelle. + const strom = await download.createReadStream() + const teile = [] + for await (const stueck of strom) { + teile.push(stueck) + } + expect(Buffer.concat(teile).toString('utf8')).toContain('Buchungstext') + }) + + test('Eine CSV lässt sich hochladen und im Trockenlauf prüfen', async ({ page }) => { + const dateiname = `e2e-buchungen-${MARKE}.csv` + const konto = `E2E CSV Konto ${MARKE}` + + // Das Konto muss es geben – Konten legt der CSV-Import nicht an. + await page.goto('/konten/neu') + await page.fill('#name', konto) + await page.click('.formular button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + const inhalt = [ + 'Art;Datum;Konto;Empfänger;Buchungstext;Betrag', + `Buchung;15.06.2026;${konto};E2E Laden;Testeinkauf ${MARKE};-12,34`, + '', + ].join('\r\n') + + await page.goto('/verwaltung/import?format=csv') + await page.setInputFiles('#datei', { + name: dateiname, + mimeType: 'text/csv', + buffer: Buffer.from(inhalt, 'utf8'), + }) + await page.click('.formular button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toContainText('hochgeladen') + + await page.locator('tr', { hasText: dateiname }) + .getByRole('button', { name: 'Trockenlauf' }).click() + + await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf') + await expect(page.locator('tr', { hasText: 'Neu' }).first()).toContainText('1') + + // Aufräumen – die Datei bliebe sonst bis zum Housekeeping liegen. + await page.goto('/verwaltung/import?format=csv') + page.on('dialog', async (dialog) => await dialog.accept()) + await page.locator('tr', { hasText: dateiname }) + .getByRole('button', { name: 'Löschen' }).click() + await expect(page.locator('body')).not.toContainText(dateiname) + }) +}) diff --git a/tests/e2e/specs/stammdaten.spec.js b/tests/e2e/specs/stammdaten.spec.js index 09a2b95..9c1adc2 100644 --- a/tests/e2e/specs/stammdaten.spec.js +++ b/tests/e2e/specs/stammdaten.spec.js @@ -188,8 +188,8 @@ test.describe('Stammdaten', () => { '', ].join('\r\n') - await page.goto('/verwaltung/import') - await page.setInputFiles('#qif', { + await page.goto('/verwaltung/import?format=qif') + await page.setInputFiles('#datei', { name: dateiname, mimeType: 'application/octet-stream', buffer: Buffer.from(inhalt, 'latin1'), @@ -215,7 +215,7 @@ test.describe('Stammdaten', () => { await expect(page.locator('body')).not.toContainText(klasse) // Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen. - await page.goto('/verwaltung/import') + await page.goto('/verwaltung/import?format=qif') page.on('dialog', async (dialog) => await dialog.accept()) await page.locator('tr', { hasText: dateiname }) .getByRole('button', { name: 'Löschen' }).click() @@ -254,8 +254,8 @@ test.describe('Stammdaten', () => { '', ].join('\r\n') - await page.goto('/verwaltung/import') - await page.setInputFiles('#qif', { + await page.goto('/verwaltung/import?format=qif') + await page.setInputFiles('#datei', { name: dateiname, mimeType: 'application/octet-stream', buffer: Buffer.from(inhalt, 'latin1'), @@ -286,7 +286,7 @@ test.describe('Stammdaten', () => { await expect(page.locator(`input[name="konten[]"][value="${giro}"]`)).not.toBeChecked() // Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen. - await page.goto('/verwaltung/import') + await page.goto('/verwaltung/import?format=qif') page.on('dialog', async (dialog) => await dialog.accept()) await page.locator('tr', { hasText: dateiname }) .getByRole('button', { name: 'Löschen' }).click() From a22c33bb12a04fb90f15e4813fc50d95d132ffcd Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 17 Aug 2026 23:12:35 +0200 Subject: [PATCH 3/4] Kontoformular zweispaltig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Konto bearbeiten und Neues Konto zeigen ihre vierzehn Felder in zwei Spalten, damit alles ohne vertikales Scrollen auf einen Bildschirm passt. - Neue Formularvariante .formular.zweispaltig: 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 fällt das Formular auf eine Spalte zurück. - Feldreihenfolge zeilenweise; Namen, Werte und Feld-IDs unverändert. - E2E-Test: Kontoart steht rechts neben dem Namen, das Formular endet oberhalb der Fensterhöhe. Co-Authored-By: Claude Opus 5 --- Changes.md | 17 ++++ public/static/app.css | 39 ++++++++ src/Views/konten/formular.php | 146 +++++++++++++++++------------ tests/e2e/specs/stammdaten.spec.js | 18 ++++ 4 files changed, 159 insertions(+), 61 deletions(-) diff --git a/Changes.md b/Changes.md index 0440a14..d8bfd70 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,22 @@ # Änderungen +## 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. diff --git a/public/static/app.css b/public/static/app.css index 1bf1805..d48e5b6 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1231,6 +1231,45 @@ table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; } margin-top: .6rem; } +/* 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; } + +/* Über beide Spalten: verstecktes CSRF-Feld, Überschriften, Knopfleiste und + Felder, die für eine halbe Zeile zu breit sind. */ +.formular.zweispaltig > input[type="hidden"], +.formular.zweispaltig > h3, +.formular.zweispaltig > p, +.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 > .knopfleiste { margin-top: .5rem; } + +@media (max-width: 720px) { + .formular.zweispaltig { grid-template-columns: 1fr; } +} + .formular label.ankreuzfeld { display: flex; align-items: center; diff --git a/src/Views/konten/formular.php b/src/Views/konten/formular.php index 629330c..2fe8c3e 100644 --- a/src/Views/konten/formular.php +++ b/src/Views/konten/formular.php @@ -19,80 +19,104 @@ include __DIR__ . '/../layout/kopf.php'; Zurück zur Kontenübersicht
-
+ - - +
+ + +
- - +
+ + +
- - +
+ + +
- - +
+ + +
- - +
+ + +
- - +
+ + +
- - +
+ + +
- - -

- Kontostand vor der ersten erfassten Buchung. Der QIF-Export enthält ihn nicht – - nach einem Import wird er hier je Konto nachgetragen. -

+
+ + +
- - +
+ + +

+ Kontostand vor der ersten erfassten Buchung. Der QIF-Export enthält ihn nicht – + nach einem Import wird er hier je Konto nachgetragen. +

+
- - +
+ + +
- - -

Kleinere Werte stehen in der Kontenübersicht weiter oben.

+
+ + +

Kleinere Werte stehen in der Kontenübersicht weiter oben.

+
- +
+ - -

- Entspricht der Σ-Spalte im FinanzManager. Konten, die kein eigenes Vermögen - darstellen – durchlaufende Auslagen etwa –, bleiben so aus der Gesamtsumme heraus, - erscheinen aber weiter in der Übersicht. -

+ +

+ Entspricht der Σ-Spalte im FinanzManager. Konten, die kein eigenes Vermögen + darstellen – durchlaufende Auslagen etwa –, bleiben so aus der Gesamtsumme heraus, + erscheinen aber weiter in der Übersicht. +

+
@@ -101,7 +125,7 @@ include __DIR__ . '/../layout/kopf.php'; -

Konto löschen

diff --git a/tests/e2e/specs/stammdaten.spec.js b/tests/e2e/specs/stammdaten.spec.js index 9c1adc2..b5f4f5e 100644 --- a/tests/e2e/specs/stammdaten.spec.js +++ b/tests/e2e/specs/stammdaten.spec.js @@ -89,6 +89,24 @@ test.describe('Stammdaten', () => { 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 }) => { const oben = `E2E Wohnen ${MARKE}` const unten = `Strom ${MARKE}` From 4e3230c88809bba1555729891c6f6eaf1aa89e2e Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 17 Aug 2026 23:36:47 +0200 Subject: [PATCH 4/4] Buchungsmaske nennt das Konto und passt auf einen Bildschirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Buchung und Buchung bearbeiten zeigen oben das Konto, in dem die Buchung liegt. 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. Co-Authored-By: Claude Opus 5 --- Changes.md | 26 ++++++ public/static/app.css | 46 +++++++++- src/Views/buchungen/formular.php | 145 ++++++++++++++++++------------ tests/e2e/specs/buchungen.spec.js | 35 ++++++++ 4 files changed, 192 insertions(+), 60 deletions(-) diff --git a/Changes.md b/Changes.md index d8bfd70..91f8f27 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,31 @@ # Ä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 diff --git a/public/static/app.css b/public/static/app.css index d48e5b6..6b27610 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1231,6 +1231,16 @@ 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. */ @@ -1253,23 +1263,55 @@ table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; } .formular.zweispaltig .feld .feldhinweis { margin-top: 0; } -/* Über beide Spalten: verstecktes CSRF-Feld, Überschriften, Knopfleiste und - Felder, die für eine halbe Zeile zu breit sind. */ +.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; 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
- + - - +
+ +

+ + + + +

+
- - +
+ + +
- - -

Immer positiv eingeben – die Richtung bestimmt die Art oben.

+
+ + +
- - +
+ + +
- - -

- 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. -

+
+ + +
- - +
+ + +
- - +
+ + +
- - +
+ + +
- +
+ + +
-

Splitbuchung (optional)

-

- 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 + (optional)

diff --git a/tests/e2e/specs/buchungen.spec.js b/tests/e2e/specs/buchungen.spec.js index 234fea1..f23be17 100644 --- a/tests/e2e/specs/buchungen.spec.js +++ b/tests/e2e/specs/buchungen.spec.js @@ -70,6 +70,41 @@ test.describe('Buchungen', () => { 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 }) => { await page.goto(`/konten/${giroId}/buchung`)