From 9839bd5b16800680ade7a4c30737b69dbbcd1941 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 17 Aug 2026 22:24:14 +0200 Subject: [PATCH] =?UTF-8?q?Banking:=20Ums=C3=A4tze=20sammelweise=20l=C3=B6?= =?UTF-8?q?schen?= 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(); + } +}