From 4d62529e2c22f18c685899917e7684ee9291d960 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 12 Aug 2026 14:27:51 +0200 Subject: [PATCH] =?UTF-8?q?Wertpapiere:=20Depotbuchungen=20sammelweise=20l?= =?UTF-8?q?=C3=B6schen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nach einem Import, der schiefging, war das Löschen Zeile für Zeile mühsam. Die Liste der Depotbuchungen räumt jetzt so auf wie das Kontoblatt: Ankreuzfeld je Zeile, das Feld in der Titelzeile wählt alle angezeigten, „Auswahl löschen“ in der Aktionsleiste schickt sie an POST /wertpapiere/buchungen/loeschen. Der Knopf nennt die Anzahl und bleibt ohne Auswahl gesperrt; die Rückfrage nennt sie ebenfalls. Ohne JavaScript funktioniert alles weiter – dann ist der Knopf offen und der Controller weist eine leere Auswahl ab. Mit jeder Depotbuchung geht ihre Geldbuchung, wie beim Löschen einer einzelnen. Am Bestand ist nichts nachzuziehen: Er wird bei jedem Aufruf aus den Depotbuchungen gerechnet und gibt die Löschung von selbst wieder her, ebenso realisierter Gewinn, Veräußerungen und Erträge. Der eingestellte Filter (Depot, Wertpapier, Vorgang) hängt am Ziel des Formulars, deshalb steht danach wieder dieselbe Ansicht da. Zusammengesetzt wird er im Controller neu, damit kein fremdes Ziel eingeschleust werden kann. Das gilt jetzt auch für das „Löschen“ je Zeile, das bisher zur ungefilterten Liste zurücksprang. Co-Authored-By: Claude Opus 5 --- Changes.md | 23 +++ docs/wertpapiere.md | 23 +++ public/index.php | 2 + src/Controllers/WertpapiereController.php | 60 +++++++- src/Views/wertpapiere/buchungen.php | 38 ++++- tests/22_DepotbuchungenLoeschenTest.php | 177 ++++++++++++++++++++++ tests/e2e/specs/module.spec.js | 81 ++++++++++ 7 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 tests/22_DepotbuchungenLoeschenTest.php diff --git a/Changes.md b/Changes.md index 21f56e5..ad6f6e4 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,28 @@ # Änderungen +## Wertpapiere: Depotbuchungen sammelweise löschen (12.08.2026) + +Die Liste der Depotbuchungen räumt jetzt so auf wie das Kontoblatt – nach einem Import, +der schiefging, war das Löschen Zeile für Zeile mühsam. + +**Auswahl löschen** (`Views/wertpapiere/buchungen.php`, `WertpapiereController`, +`public/index.php`) +- Ankreuzfeld je Zeile, das Feld in der Titelzeile wählt alle angezeigten Buchungen. Der + Knopf steht oben in der Aktionsleiste, nennt die Anzahl und bleibt ohne Auswahl + gesperrt; die Rückfrage nennt sie ebenfalls. Ohne JavaScript funktioniert alles weiter: + Dann ist der Knopf offen und `POST /wertpapiere/buchungen/loeschen` weist eine leere + Auswahl ab. +- Mit jeder Depotbuchung geht ihre Geldbuchung – wie beim Löschen einer einzelnen. +- **Am Bestand ist nichts nachzuziehen.** Er wird bei jedem Aufruf aus den Depotbuchungen + gerechnet und gibt die Löschung von selbst wieder her, ebenso realisierter Gewinn, + Veräußerungen und Erträge. +- Der eingestellte Filter (Depot, Wertpapier, Vorgang) hängt am Ziel des Formulars, + deshalb steht danach wieder dieselbe Ansicht da. Das gilt jetzt auch für das *Löschen* + je Zeile, das bisher zur ungefilterten Liste zurücksprang. + +`tests/22_DepotbuchungenLoeschenTest.php` prüft Geldseite, Bestand und Auswertungen nach +dem Löschen; `tests/e2e/specs/module.spec.js` den Ablauf im Browser. + ## Wertpapiere: Depotbestand aus einer CSV-Datei übernehmen (12.08.2026) Ein Depot, das nie im FinanzManager geführt wurde, lässt sich jetzt in einem Zug diff --git a/docs/wertpapiere.md b/docs/wertpapiere.md index 15d1a41..1abb791 100644 --- a/docs/wertpapiere.md +++ b/docs/wertpapiere.md @@ -149,6 +149,24 @@ Der Ablauf ist zweistufig: Der Upload zeigt erst eine Vorschau mit allen Positio Einstand, Wert und dem Status je Zeile; erst *Jetzt übernehmen* schreibt. Die Datei bleibt im Importverzeichnis liegen. +## Depotbuchungen löschen + +Die Liste der Depotbuchungen löscht einzeln – *Löschen* je Zeile – oder mehrere auf +einmal, genau wie das Kontoblatt: Ankreuzfeld je Zeile, das Feld in der Titelzeile wählt +alle angezeigten, *Auswahl löschen* in der Aktionsleiste schickt sie an +`POST /wertpapiere/buchungen/loeschen`. Der Knopf nennt die Anzahl und bleibt ohne +Auswahl gesperrt; die Rückfrage kommt vor dem Absenden. Ohne JavaScript funktioniert +alles weiter – dann ist der Knopf offen und der Controller weist eine leere Auswahl ab. + +Der eingestellte Filter hängt am Ziel beider Formulare, deshalb steht danach wieder +dieselbe Ansicht da. Zusammengesetzt wird er im Controller neu, damit kein fremdes Ziel +eingeschleust werden kann. + +Mit jeder Depotbuchung geht ihre Geldbuchung (siehe *Zwei Seiten je Geschäft*). Am +Bestand ist nichts nachzuziehen: Er wird bei jedem Aufruf aus den Depotbuchungen +gerechnet und gibt die Löschung von selbst wieder her – ebenso realisierter Gewinn, +Veräußerungen und Erträge. Das Wertpapier selbst bleibt in den Stammdaten stehen. + ## Kontenübersicht Depots erscheinen dort mit ihrem **bewerteten Bestand**, nicht mit einem Buchungssaldo. Der @@ -166,6 +184,11 @@ Bestandsänderung; fehlende Kurse; Veräußerungsrechnung über die Historie hin QIF-Import eines echten Ausschnitts (Kauf, Dividende, Verkauf) inklusive Idempotenz; Löschen samt Geldbuchung. +`tests/22_DepotbuchungenLoeschenTest.php` – das Sammellöschen: die Geldbuchungen gehen +mit, das Verrechnungskonto stimmt danach wieder, Bestand, Einstand, realisierter Gewinn, +Erträge und die Veräußerungsauswertung geben die Löschung her, unbekannte Nummern in der +Auswahl bleiben folgenlos. Den Ablauf im Browser prüft `tests/e2e/specs/module.spec.js`. + `tests/21_DepotbestandImportTest.php` – der CSV-Bestandsimport an einem Ausschnitt des echten DKB-Exports: Vorschau ohne Nebenwirkung, Einstand und Wert aus beiden Kursen, Bruchstückzahlen, Assetklasse → Art, hinterlegte Bewertungskurse, Idempotenz beim zweiten diff --git a/public/index.php b/public/index.php index 5c8916b..9f20bf2 100644 --- a/public/index.php +++ b/public/index.php @@ -240,6 +240,8 @@ $router->get('/wertpapiere/auswertung', fn() => (new WertpapiereController())->a $router->get('/wertpapiere/buchungen', fn() => (new WertpapiereController())->buchungen()); $router->get('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->neu()); $router->post('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->anlegen()); +$router->post('/wertpapiere/buchungen/loeschen', + fn() => (new WertpapiereController())->auswahlLoeschen()); $router->post('/wertpapiere/buchungen/{id}/loeschen', fn(array $p) => (new WertpapiereController())->loeschen($p)); diff --git a/src/Controllers/WertpapiereController.php b/src/Controllers/WertpapiereController.php index dba3940..cc8db9f 100644 --- a/src/Controllers/WertpapiereController.php +++ b/src/Controllers/WertpapiereController.php @@ -128,7 +128,65 @@ class WertpapiereController extends BaseController Depotbuchung::loeschen((int)$params['id']); - View::redirect('/wertpapiere/buchungen', 'Depotbuchung gelöscht – samt ihrer Geldbuchung.'); + View::redirect($this->buchungenPfad(), 'Depotbuchung gelöscht – samt ihrer Geldbuchung.'); + } + + /** + * Sammellöschen aus der Liste der Depotbuchungen – wie im Kontoblatt. + * + * Der Bestand wird bei jedem Aufruf aus den Depotbuchungen gerechnet; er + * gibt die Löschung deshalb von selbst wieder her, ebenso Veräußerungen und + * Erträge. Fortgeschrieben wird nichts. + */ + public function auswahlLoeschen(): void + { + $this->verlangeAnmeldung(); + + $ziel = $this->buchungenPfad(); + $ids = $this->postIds('buchungen'); + if ($ids === []) { + View::redirect($ziel, 'Keine Depotbuchung ausgewählt.', 'warnung'); + return; + } + + $anzahl = 0; + foreach ($ids as $id) { + // Fremde oder längst gelöschte Nummern still übergehen – der Rest + // der Auswahl soll trotzdem verschwinden. + if (Depotbuchung::find($id) === null) { + continue; + } + + try { + Depotbuchung::loeschen($id); + } catch (\Throwable $e) { + View::redirect($ziel, $e->getMessage(), 'fehler'); + return; + } + $anzahl++; + } + + View::redirect($ziel, $anzahl === 1 + ? 'Depotbuchung gelöscht – samt ihrer Geldbuchung.' + : $anzahl . ' Depotbuchungen gelöscht – samt ihrer Geldbuchungen.'); + } + + /** + * Zurück in die Liste, mit demselben Filter. + * + * Die Werte kommen aus der Abfragezeichenfolge des Formularziels und werden + * hier neu zusammengesetzt statt übernommen: So kann kein fremdes Ziel + * eingeschleust werden. + */ + private function buchungenPfad(): string + { + $query = array_filter([ + 'depot' => $this->query('depot'), + 'wertpapier' => $this->query('wertpapier'), + 'vorgang' => $this->query('vorgang'), + ], static fn(string $wert): bool => $wert !== ''); + + return '/wertpapiere/buchungen' . ($query === [] ? '' : '?' . http_build_query($query)); } // ── Bestandsimport aus einer CSV-Datei ─────────────────────────────────── diff --git a/src/Views/wertpapiere/buchungen.php b/src/Views/wertpapiere/buchungen.php index f2aa4d0..5ca0a3c 100644 --- a/src/Views/wertpapiere/buchungen.php +++ b/src/Views/wertpapiere/buchungen.php @@ -11,6 +11,18 @@ use Finanzmanager\View; * @var string $csrfToken */ +// Nach dem Löschen soll dieselbe Ansicht wieder erscheinen – der Filter hängt +// deshalb am Ziel der Löschformulare. +$filterPfad = static function () use ($filter): string { + $query = array_filter([ + 'depot' => (string)($filter['konto_id'] ?? ''), + 'wertpapier' => (string)($filter['wertpapier_id'] ?? ''), + 'vorgang' => (string)($filter['vorgang'] ?? ''), + ], static fn(string $wert): bool => $wert !== ''); + + return $query === [] ? '' : '?' . http_build_query($query); +}; + include __DIR__ . '/../layout/kopf.php'; ?> @@ -53,6 +65,20 @@ include __DIR__ . '/../layout/kopf.php'; + + +
+ + +
+ Depotbuchung erfassen @@ -62,6 +88,10 @@ include __DIR__ . '/../layout/kopf.php'; + @@ -71,6 +101,11 @@ include __DIR__ . '/../layout/kopf.php'; +
+ + DatumVorgangWertpapierDepot StückKurs BetragKosten
+ + @@ -108,7 +143,8 @@ include __DIR__ . '/../layout/kopf.php'; -
diff --git a/tests/22_DepotbuchungenLoeschenTest.php b/tests/22_DepotbuchungenLoeschenTest.php new file mode 100644 index 0000000..0fe5590 --- /dev/null +++ b/tests/22_DepotbuchungenLoeschenTest.php @@ -0,0 +1,177 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->auswahlNimmtDieGeldbuchungenMit(); + $this->bestandGibtDieLoeschungWiederHer(); + $this->unbekannteNummernBleibenFolgenlos(); + } + + private function stammdaten(): void + { + $this->depot = Konto::anlegen([ + 'name' => 'Sammeldepot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->verrechnung = Konto::anlegen([ + 'name' => 'Sammelverrechnungskonto', 'kontoart' => 'bank', + 'anfangssaldo' => 10000, 'aktiv' => 1, + ]); + $this->papier = Wertpapier::findenOderAnlegen([ + 'name' => 'Sammelaktie AG', 'isin' => 'DE0007654321', 'art' => 'aktie', + ]); + } + + /** + * Zwei Käufe, ein Verkauf, eine Dividende – wie eine Auswahl in der Liste. + * + * @return list + */ + private function vierBuchungen(): array + { + return [ + Depotbuchung::anlegen([ + 'konto_id' => $this->depot, 'wertpapier_id' => $this->papier, + 'datum' => '2026-01-15', 'vorgang' => 'kauf', + 'stueck' => 10, 'kurs' => 100, 'betrag' => 1000, 'gebuehren' => 10, + 'verrechnungskonto_id' => $this->verrechnung, + ]), + Depotbuchung::anlegen([ + 'konto_id' => $this->depot, 'wertpapier_id' => $this->papier, + 'datum' => '2026-02-15', 'vorgang' => 'kauf', + 'stueck' => 10, 'kurs' => 120, 'betrag' => 1200, 'gebuehren' => 10, + 'verrechnungskonto_id' => $this->verrechnung, + ]), + Depotbuchung::anlegen([ + 'konto_id' => $this->depot, 'wertpapier_id' => $this->papier, + 'datum' => '2026-03-15', 'vorgang' => 'verkauf', + 'stueck' => 5, 'kurs' => 150, 'betrag' => 750, + 'gebuehren' => 15, 'steuern' => 20, + 'verrechnungskonto_id' => $this->verrechnung, + ]), + Depotbuchung::anlegen([ + 'konto_id' => $this->depot, 'wertpapier_id' => $this->papier, + 'datum' => '2026-04-01', 'vorgang' => 'dividende', + 'betrag' => 50, 'steuern' => 13.19, + 'verrechnungskonto_id' => $this->verrechnung, + ]), + ]; + } + + /** So arbeitet der Controller die Auswahl ab: Nummer für Nummer. */ + private function auswahlLoeschen(array $ids): int + { + $anzahl = 0; + foreach ($ids as $id) { + if (Depotbuchung::find((int)$id) === null) { + continue; + } + Depotbuchung::loeschen((int)$id); + $anzahl++; + } + + return $anzahl; + } + + private function auswahlNimmtDieGeldbuchungenMit(): void + { + $ids = $this->vierBuchungen(); + + $geldIds = []; + foreach ($ids as $id) { + $geldIds[] = (int)Depotbuchung::find($id)['buchung_id']; + } + $this->assertEquals(4, count(array_filter($geldIds)), 'Jede Depotbuchung hat ihre Geldbuchung'); + + // Die beiden Käufe auswählen – der Rest bleibt stehen. + $this->assertEquals(2, $this->auswahlLoeschen([$ids[0], $ids[1]]), 'Zwei Buchungen gelöscht'); + + $this->assertNull(Depotbuchung::find($ids[0]), 'Der erste Kauf ist weg'); + $this->assertNull(Depotbuchung::find($ids[1]), 'Der zweite Kauf auch'); + $this->assertNotEmpty(Depotbuchung::find($ids[2]), 'Der Verkauf steht noch'); + $this->assertNotEmpty(Depotbuchung::find($ids[3]), 'Die Dividende ebenso'); + + $this->assertNull(Buchung::find($geldIds[0]), 'Die Geldbuchung des ersten Kaufs ging mit'); + $this->assertNull(Buchung::find($geldIds[1]), 'Die des zweiten Kaufs ebenso'); + $this->assertNotEmpty(Buchung::find($geldIds[2]), 'Die des Verkaufs blieb unberührt'); + + // 10000 − 1010 − 1210 + 715 + 36,81; nach dem Löschen der Käufe fehlen + // deren Belastungen wieder. + $this->assertEquals( + 10000.0 + 715.0 + 36.81, + Konto::saldo($this->verrechnung, '2026-12-31'), + 'Das Verrechnungskonto trägt nur noch Verkauf und Dividende' + ); + + // Aufräumen für den nächsten Abschnitt. + $this->auswahlLoeschen([$ids[2], $ids[3]]); + } + + private function bestandGibtDieLoeschungWiederHer(): void + { + Wertpapier::kursSetzen($this->papier, '2026-06-30', 130.0, 'test'); + + $leer = Depot::bestand($this->depot, '2026-12-31'); + $this->assertEquals(0, count($leer['positionen']), 'Vorher ist das Depot leer'); + + $ids = $this->vierBuchungen(); + + $voll = Depot::bestand($this->depot, '2026-12-31'); + $this->assertEquals(15.0, $voll['positionen'][0]['stueck'], 'Fünfzehn Stück nach zwei Käufen und einem Verkauf'); + $this->assertGreaterThan(0.0, $voll['realisiert'], 'Der Verkauf hat etwas realisiert'); + $this->assertEquals(36.81, $voll['ertraege'], 'Die Dividende zählt als Ertrag'); + + // Verkauf und Dividende zurücknehmen: Der Bestand steht wieder auf den + // beiden Käufen, realisierter Gewinn und Erträge fallen auf null. + $this->assertEquals(2, $this->auswahlLoeschen([$ids[2], $ids[3]]), 'Zwei Buchungen gelöscht'); + + $nachher = Depot::bestand($this->depot, '2026-12-31'); + $this->assertEquals(20.0, $nachher['positionen'][0]['stueck'], 'Die verkauften Stücke sind zurück'); + $this->assertEquals(2220.0, $nachher['positionen'][0]['einstand'], 'Und der volle Einstand beider Käufe'); + $this->assertEquals(2600.0, $nachher['wert'], 'Zwanzig Stück zu 130 €'); + $this->assertEquals(0.0, $nachher['realisiert'], 'Ohne Verkauf ist nichts realisiert'); + $this->assertEquals(0.0, $nachher['ertraege'], 'Ohne Dividende gibt es keinen Ertrag'); + + $veraeusserungen = Depot::veraeusserungen('2026-01-01', '2026-12-31', $this->depot); + $this->assertEquals(0, count($veraeusserungen['geschaefte']), 'Kein Veräußerungsgeschäft mehr'); + $this->assertEquals(0.0, $veraeusserungen['gewinn'], 'Und kein Gewinn in der Auswertung'); + + $this->auswahlLoeschen([$ids[0], $ids[1]]); + } + + private function unbekannteNummernBleibenFolgenlos(): void + { + $id = $this->vierBuchungen()[0]; + + // Eine Nummer, die es nicht (mehr) gibt, wird übergangen – der Rest der + // Auswahl verschwindet trotzdem. + $this->assertEquals(1, $this->auswahlLoeschen([999999, $id]), 'Nur die vorhandene Buchung zählt'); + $this->assertNull(Depotbuchung::find($id), 'Und sie ist gelöscht'); + } +} diff --git a/tests/e2e/specs/module.spec.js b/tests/e2e/specs/module.spec.js index bd722da..397e475 100644 --- a/tests/e2e/specs/module.spec.js +++ b/tests/e2e/specs/module.spec.js @@ -170,6 +170,87 @@ test.describe('Module der Phasen 4 bis 7', () => { await expect(page.locator('.meldung')).toContainText('gelöscht') }) + test('Depotbuchungen lassen sich über die Auswahl sammelweise löschen', async ({ page }) => { + const marke = Date.now().toString().slice(-6) + const depot = `E2E Depot ${marke}` + const papier = `E2E Papier ${marke}` + + await page.goto('/konten/neu') + await page.fill('#name', depot) + await page.selectOption('#kontoart', 'depot') + await page.click('.formular button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + // Zwei Käufe desselben Papiers – der zweite über die Auswahlliste, damit + // kein zweites Wertpapier entsteht. + for (const [nr, stueck] of [[1, '10'], [2, '5']]) { + await page.goto('/wertpapiere/buchungen/neu') + await page.selectOption('#konto_id', { label: depot }) + await page.fill('#datum', '2026-05-0' + nr) + if (nr === 1) { + await page.fill('#wertpapier_name', papier) + } else { + await page.selectOption('#wertpapier_id', { label: papier }) + } + await page.fill('#stueck', stueck) + await page.fill('#kurs', '100') + await page.click('.formular button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + } + + // Der Filter bleibt über das Löschen hinweg stehen – das Formularziel + // trägt ihn mit. + await page.goto('/wertpapiere/buchungen') + await page.selectOption('select[name="depot"]', { label: depot }) + await page.click('button:has-text("Filtern")') + await expect(page.locator('tbody tr')).toHaveCount(2) + + // Der Bestand kennt beide Käufe. + await page.goto('/wertpapiere') + await page.selectOption('select[name="depot"]', { label: depot }) + await page.click('button:has-text("Anzeigen")') + await expect(page.locator('tbody tr', { hasText: papier })).toContainText('15') + + await page.goBack() + await page.goBack() + + // Ohne Auswahl bleibt der Knopf gesperrt. + const knopf = page.locator('#depotbuchungsauswahl button') + await expect(knopf).toBeDisabled() + + await page.check('.alle-waehlen') + await expect(knopf).toContainText('(2)') + + const gefragt = [] + page.on('dialog', async (dialog) => { + gefragt.push(dialog.message()) + await dialog.accept() + }) + + await knopf.click() + + expect(gefragt.join(' ')).toContain('löschen') + await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht') + await expect(page).toHaveURL(/depot=/) + // Der Name steht weiter im Filterfeld – gemeint ist die Liste. + await expect(page.locator('table.liste')).toHaveCount(0) + await expect(page.locator('.platzhalter')).toContainText('Keine Depotbuchungen') + + // Und der Bestand ist die Buchungen los – er wird gerechnet. + await page.goto('/wertpapiere') + await page.selectOption('select[name="depot"]', { label: depot }) + await page.click('button:has-text("Anzeigen")') + await expect(page.locator('.platzhalter')).toContainText('Keine Bestände') + + // Aufräumen: Das Testdepot wieder entfernen. Die Rückfrage übernimmt der + // Dialoghandler von oben. + await page.goto('/konten') + const pfad = await page.locator('a', { hasText: depot }).first().getAttribute('href') + await page.goto(pfad + '/bearbeiten') + await page.locator('button:has-text("Konto löschen")').click() + await expect(page.locator('.meldung')).toContainText('gelöscht') + }) + test('Der Finanzkalender ist erreichbar und lässt sich einstellen', async ({ page }) => { await page.goto('/termine/kalender') await expect(page.locator('h1')).toContainText('Finanzkalender')