Banking: Umsätze sammelweise löschen

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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-17 22:24:14 +02:00
parent ca8496f770
commit 9839bd5b16
8 changed files with 397 additions and 5 deletions

View file

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

View file

@ -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,

View file

@ -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',

View file

@ -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; }

View file

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

View file

@ -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<int> $ids
* @return int Wie viele Umsätze tatsächlich gelöscht wurden.
*/
public static function mehrereLoeschen(array $ids): int
{
$ids = array_values(array_unique(array_filter($ids, static fn (int $id): bool => $id > 0)));
if ($ids === []) {
return 0;
}
$platzhalter = implode(', ', array_fill(0, count($ids), '?'));
return Database::execute(
'DELETE FROM bank_umsaetze WHERE benutzer_id = ? AND id IN (' . $platzhalter . ')',
array_merge([Mandant::id()], $ids)
);
}
}

View file

@ -38,14 +38,28 @@ include __DIR__ . '/../layout/kopf.php';
<a href="/banking/regeln" class="tab rechtsbuendig">Regeln</a>
</div>
<?php if ($status === 'offen' && $umsaetze !== []): ?>
<?php if ($umsaetze !== []): ?>
<div class="seitenaktionen">
<form method="post" action="/banking/umsaetze/alle" class="inline"
data-bestaetigen="Alle eindeutigen Vorschläge übernehmen? Umsätze ohne Regel bleiben offen.">
<?php if ($status === 'offen'): ?>
<form method="post" action="/banking/umsaetze/alle" class="inline"
data-bestaetigen="Alle eindeutigen Vorschläge übernehmen? Umsätze ohne Regel bleiben offen.">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf primaer">Alle eindeutigen übernehmen</button>
</form>
<a href="/banking/regeln" class="knopf">Regeln pflegen</a>
<?php endif; ?>
<?php /* Wie im Kontoblatt: Das Formular steht in der Aktionsleiste, seine
Ankreuzfelder stehen in den Umsatzkarten – verbunden über das
form-Attribut. Ein Formular um die ganze Liste verschachtelte
sich mit den Formularen der einzelnen Umsätze. */ ?>
<form method="post" action="/banking/umsaetze/loeschen?status=<?= View::h($status) ?>"
id="umsatzauswahl" class="inline"
data-bestaetigen="Die ausgewählten Umsätze wirklich löschen?"
data-frage-eine="Den ausgewählten Umsatz wirklich löschen? Eine daraus erzeugte Buchung bleibt im Kontoblatt stehen."
data-frage-mehrere="{anzahl} ausgewählte Umsätze wirklich löschen? Daraus erzeugte Buchungen bleiben im Kontoblatt stehen.">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf primaer">Alle eindeutigen übernehmen</button>
<button type="submit" class="knopf gefahr">Auswahl löschen</button>
</form>
<a href="/banking/regeln" class="knopf">Regeln pflegen</a>
</div>
<?php endif; ?>
@ -56,6 +70,19 @@ include __DIR__ . '/../layout/kopf.php';
: 'Keine Umsätze mit diesem Status.' ?>
</p>
<?php else: ?>
<?php /* Die Titelleiste der Liste. Eine Tabelle mit Kopfzeile gibt es hier
nicht, das Ankreuzfeld „alle wählen“ braucht aber seinen Platz. */ ?>
<div class="listenkopf">
<label class="ankreuzfeld">
<input type="checkbox" class="alle-waehlen" data-waehlt="umsatzauswahl"
aria-label="Alle angezeigten Umsätze auswählen">
Alle auswählen
</label>
<span class="nebentext">
<?= count($umsaetze) ?> <?= count($umsaetze) === 1 ? 'Umsatz' : 'Umsätze' ?>
</span>
</div>
<?php foreach ($umsaetze as $umsatz): ?>
<?php
$id = (int)$umsatz['id'];
@ -65,6 +92,9 @@ include __DIR__ . '/../layout/kopf.php';
?>
<section class="umsatz">
<div class="umsatzkopf">
<input type="checkbox" name="umsaetze[]" value="<?= $id ?>" form="umsatzauswahl"
class="umsatzwahl"
aria-label="Umsatz vom <?= View::h(View::datum($umsatz['buchungstag'])) ?> über <?= View::h(View::betrag($umsatz['betrag'])) ?> auswählen">
<div class="umsatztext">
<strong><?= View::h($umsatz['empfaenger'] ?: 'Ohne Empfänger') ?></strong>
<span class="nebentext"><?= View::h(View::datum($umsatz['buchungstag'])) ?>

View file

@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
use Finanzmanager\Database;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
/**
* Sammellöschen in den Umsatzlisten des Bankings.
*
* Geprüft wird, was hinter dem Knopf *Auswahl löschen* passiert: Die gewählten
* Umsätze verschwinden – und zwar nur sie. Eine schon erzeugte Buchung gehört
* ins Kontoblatt und bleibt dort stehen; sie verliert lediglich den Umsatz, der
* sie belegt hat. Der Knopf steht in jedem Reiter, also müssen offene,
* übernommene und verworfene Umsätze gleichermaßen gehen.
*/
class UmsaetzeLoeschenTest extends TestCase
{
protected string $testName = 'Test 28: Bankumsätze sammelweise löschen';
private int $giro = 0;
private int $zugang = 0;
private int $bankkonto = 0;
private int $laufendeNummer = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->auswahlVerschwindetAusDerListe();
$this->buchungUeberlebtDenGeloeschtenUmsatz();
$this->jederStatusLaesstSichLoeschen();
$this->leereUndUnbekannteAuswahlBleibtFolgenlos();
$this->jedeAnsichtBietetDieAuswahlAn();
}
private function stammdaten(): void
{
$this->giro = Konto::anlegen([
'name' => 'Sammelgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->zugang = Bankzugang::anlegen([
'name' => 'Sammelbank',
'bankname' => 'Sammelbank AG',
'blz' => '20041112',
'fints_url' => 'https://banking.example.org/fints',
'benutzerkennung' => 'testkunde',
'pin' => 'geheim1234',
'abrufzeitraum' => 'letzter_abruf',
'aktiv' => 1,
]);
$this->bankkonto = (int)Bankzugang::findeOderLegeKontoAn(
$this->zugang,
'DE53200411120847626901',
'847626901',
'Girokonto'
)['id'];
Bankzugang::ordneKontoZu($this->bankkonto, $this->giro);
}
private function umsatzAnlegen(string $zweck, float $betrag = -19.99): int
{
// Jeder Umsatz bekommt einen eigenen Tag: Sonst fiele der Fingerabdruck
// zweier gleich lautender Zeilen zusammen und der zweite käme nie an.
$tag = str_pad((string)(++$this->laufendeNummer), 2, '0', STR_PAD_LEFT);
$id = Bankumsatz::anlegen([
'zugang_id' => $this->zugang,
'konto_id' => $this->giro,
'bank_konto_id' => $this->bankkonto,
'extern_id' => Bankumsatz::externId($this->bankkonto, '2026-07-' . $tag, $betrag, $zweck),
'buchungstag' => '2026-07-' . $tag,
'wertstellung' => '2026-07-' . $tag,
'betrag' => $betrag,
'empfaenger' => 'Sammelempfänger',
'verwendungszweck' => $zweck,
'buchungsart' => 'LASTSCHRIFT',
]);
$this->assertNotEmpty($id, 'Umsatz angelegt: ' . $zweck);
return (int)$id;
}
private function existiert(int $umsatzId): bool
{
return Database::fetchValue('SELECT 1 FROM bank_umsaetze WHERE id = ?', [$umsatzId]) !== null;
}
/** Die Mehrfachauswahl trifft genau die angekreuzten Zeilen. */
private function auswahlVerschwindetAusDerListe(): void
{
$eins = $this->umsatzAnlegen('Sammelstrom');
$zwei = $this->umsatzAnlegen('Sammelwasser');
$drei = $this->umsatzAnlegen('Sammelgas');
$anzahl = Bankumsatz::mehrereLoeschen([$eins, $drei]);
$this->assertEquals(2, $anzahl, 'Zwei ausgewählte Umsätze gelöscht');
$this->assert(!$this->existiert($eins), 'Der erste ausgewählte Umsatz ist weg');
$this->assert(!$this->existiert($drei), 'Der zweite ausgewählte Umsatz ist weg');
$this->assert($this->existiert($zwei), 'Der nicht ausgewählte Umsatz steht noch da');
$offene = array_map('intval', array_column(Bankumsatz::liste(['status' => 'offen']), 'id'));
$this->assert(!in_array($eins, $offene, true), 'Der gelöschte Umsatz steht nicht mehr in der Liste');
$this->assert(in_array($zwei, $offene, true), 'Der übrige Umsatz steht weiter zum Abgleich bereit');
}
/**
* Der Unterschied zum Kontoblatt: Hier wird die abgerufene Zeile gelöscht,
* nicht die Buchung. Was einmal gebucht ist, bleibt gebucht.
*/
private function buchungUeberlebtDenGeloeschtenUmsatz(): void
{
$umsatz = $this->umsatzAnlegen('Sammelmiete', -750.00);
$buchungId = Bankumsatz::uebernehmen($umsatz, []);
$this->assertEquals(1, Bankumsatz::mehrereLoeschen([$umsatz]), 'Der übernommene Umsatz wurde gelöscht');
$this->assert(!$this->existiert($umsatz), 'Der übernommene Umsatz ist weg');
$buchung = Buchung::find((int)$buchungId);
$this->assertNotEmpty($buchung, 'Die Buchung steht weiter im Kontoblatt');
$this->assertEquals(-750.00, (float)$buchung['betrag'], 'Und zwar unverändert');
}
/** Der Knopf steht in jedem Reiter – offen, übernommen und verworfen. */
private function jederStatusLaesstSichLoeschen(): void
{
$offen = $this->umsatzAnlegen('Sammelversicherung', -44.00);
$uebernommen = $this->umsatzAnlegen('Sammelrundfunk', -55.08);
$verworfen = $this->umsatzAnlegen('Sammelfehlbuchung', -1.00);
Bankumsatz::uebernehmen($uebernommen, []);
Bankumsatz::verwerfen($verworfen);
$anzahl = Bankumsatz::mehrereLoeschen([$offen, $uebernommen, $verworfen]);
$this->assertEquals(3, $anzahl, 'Aus jedem Reiter lässt sich löschen');
$this->assert(!$this->existiert($offen), 'Offener Umsatz gelöscht');
$this->assert(!$this->existiert($uebernommen), 'Übernommener Umsatz gelöscht');
$this->assert(!$this->existiert($verworfen), 'Verworfener Umsatz gelöscht');
}
/**
* Ohne Auswahl darf nichts geschehen – der Controller weist den leeren Fall
* ab, das Modell darf ihn trotzdem nicht als „alles löschen“ lesen.
*/
private function leereUndUnbekannteAuswahlBleibtFolgenlos(): void
{
$bleibt = $this->umsatzAnlegen('Sammelabo', -9.99);
$this->assertEquals(0, Bankumsatz::mehrereLoeschen([]), 'Leere Auswahl löscht nichts');
$this->assertEquals(0, Bankumsatz::mehrereLoeschen([0, -3]), 'Unbrauchbare Nummern löschen nichts');
$this->assertEquals(0, Bankumsatz::mehrereLoeschen([$bleibt + 100000]), 'Unbekannte Nummer löscht nichts');
$this->assert($this->existiert($bleibt), 'Der vorhandene Umsatz ist unangetastet');
// Doppelt angekreuzt gibt es nicht, doppelt gesendet schon – die Anzahl
// in der Meldung darf dadurch nicht steigen.
$this->assertEquals(1, Bankumsatz::mehrereLoeschen([$bleibt, $bleibt]), 'Doppelte Nummer zählt einmal');
}
/**
* Die Oberfläche: In jedem Reiter stehen die Ankreuzfelder vor den Umsätzen,
* das Feld „alle wählen“ in der Titelleiste und darüber der Knopf.
*/
private function jedeAnsichtBietetDieAuswahlAn(): void
{
$offen = $this->umsatzAnlegen('Ansichtsstrom', -31.00);
$uebernommen = $this->umsatzAnlegen('Ansichtsmiete', -420.00);
$verworfen = $this->umsatzAnlegen('Ansichtsdublette', -2.50);
Bankumsatz::uebernehmen($uebernommen, []);
Bankumsatz::verwerfen($verworfen);
$erwartet = ['offen' => $offen, 'uebernommen' => $uebernommen, 'verworfen' => $verworfen];
foreach ($erwartet as $status => $umsatzId) {
$html = $this->ansicht($status);
$this->assert(
str_contains($html, 'action="/banking/umsaetze/loeschen?status=' . $status . '"'),
'Reiter „' . $status . '“: Das Löschformular kehrt in denselben Reiter zurück'
);
$this->assert(
str_contains($html, '>Auswahl löschen</button>'),
'Reiter „' . $status . '“: Der Knopf „Auswahl löschen“ steht über der Liste'
);
$this->assert(
str_contains($html, 'class="alle-waehlen" data-waehlt="umsatzauswahl"'),
'Reiter „' . $status . '“: Die Titelleiste hat ein Feld „alle wählen“'
);
$this->assert(
str_contains($html, 'name="umsaetze[]" value="' . $umsatzId . '" form="umsatzauswahl"'),
'Reiter „' . $status . '“: Vor dem Umsatz steht sein Ankreuzfeld'
);
}
// Ohne Umsätze steht auch kein Löschknopf da – sonst zeigte die leere
// Liste eine Schaltfläche, die nichts treffen kann.
Bankumsatz::mehrereLoeschen(array_values($erwartet));
$this->assert(
!str_contains($this->ansicht('verworfen'), '>Auswahl löschen</button>'),
'Ohne Umsätze gibt es nichts zu löschen'
);
}
/** Rendert die Abgleichliste eines Reiters so, wie der Controller es täte. */
private function ansicht(string $status): string
{
$umsaetze = [];
foreach (Bankumsatz::liste(['status' => $status]) as $umsatz) {
$umsatz['vorschlag'] = $status === 'offen' ? Bankumsatz::vorschlag($umsatz) : null;
$umsaetze[] = $umsatz;
}
$titel = 'Umsätze abgleichen';
$flash = [];
$benutzer = null;
$csrfToken = 'x';
$kategorien = Kategorie::baumFlach(null, true);
$klassen = Klasse::alle();
$konten = Konto::alle(true);
$offen = Bankumsatz::anzahlOffen();
ob_start();
include dirname(__DIR__) . '/src/Views/banking/umsaetze.php';
return (string)ob_get_clean();
}
}