From c26c316798e4d9600e35cc94e399e8d0557ceca5 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 28 Sep 2026 12:57:13 +0200 Subject: [PATCH] Fix #76: Klassen der Splitzeilen in der Klassenspalte des Kontoblatts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bei einer Splitbuchung zeigt die Klassenspalte jetzt die gemeinsame Klasse der Splitzeilen. Unterscheiden sie sich, steht dort die Marke „Splitbuchung“, der Tooltip nennt je Splitzeile Kategorie und Klasse. Ein Klick auf die Klassenzelle einer Splitbuchung öffnet den Splitdialog. Co-Authored-By: Claude Opus 5.5 --- Changes.md | 13 ++ docs/buchungen.md | 12 +- public/static/kontoblatt.js | 9 ++ src/Models/Buchung.php | 26 ++++ src/Views/buchungen/kontoblatt.php | 64 ++++++++-- tests/47_SplitKlasseTest.php | 192 +++++++++++++++++++++++++++++ tests/e2e/specs/buchungen.spec.js | 36 ++++++ 7 files changed, 337 insertions(+), 15 deletions(-) create mode 100644 tests/47_SplitKlasseTest.php diff --git a/Changes.md b/Changes.md index 588fc03..97538f8 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,18 @@ # Änderungen +## Kontoblatt: Klassen der Splitzeilen in der Klassenspalte (28.09.2026) + +Die Klassenspalte des Kontoblatts zeigte bei einer Splitbuchung nur die Klasse der Buchung +selbst, nicht die ihrer Splitzeilen (#76). + +- Neu `Buchung::splitKlasse()`: Tragen alle Splitzeilen dieselbe Klasse, steht sie in der + Spalte. Unterscheiden sie sich, steht dort die Marke *Splitbuchung*, der Tooltip nennt je + Splitzeile Kategorie und Klasse. Ohne Klasse in den Zeilen bleibt es bei der Klasse der + Buchung. +- Ein Klick auf die Klassenzelle einer Splitbuchung öffnet den Splitdialog, in dem die + Klassen der Zeilen geändert werden. +- Neuer Test `tests/47_SplitKlasseTest.php`, E2E-Prüfung in `buchungen.spec.js`. + ## Online-Banking: Alle Umsätze abrufen (28.09.2026) Über der Tabelle der Bankverbindungen steht ein Knopf *Alle Umsätze abrufen*, der jede diff --git a/docs/buchungen.md b/docs/buchungen.md index f23ffa7..fe2680a 100644 --- a/docs/buchungen.md +++ b/docs/buchungen.md @@ -140,6 +140,14 @@ Auswertungsdimension. Anders als `Buchung::zuordnen()` rührt `klasseSetzen()` d Kategorie noch Gegenkonto noch Splitzeilen an – auch eine gesplittete Buchung bleibt beim Wechsel der Klasse gesplittet. Den Monatsabschluss achtet die Methode wie alle anderen. +Bei einer **Splitbuchung** zählen die Klassen ihrer Zeilen (`Buchung::splitKlasse()`): +Tragen alle Zeilen dieselbe Klasse, steht sie in der Spalte; unterscheiden sie sich – eine +Zeile ohne Klasse zählt dabei als eigene Angabe –, steht dort die Marke *Splitbuchung*, und +der Tooltip nennt je Zeile Kategorie (oder Gegenkonto) und Klasse. Hat keine Zeile eine +Klasse, bleibt es bei der Klasse der Buchung selbst. Ein Klick auf die Klassenzelle einer +Splitbuchung öffnet den Splitdialog statt der Klassenliste (`data-split`): Dort stehen ihre +Klassen. + ## Zeilenhöhe und Tooltips im Kontoblatt Jede Buchungszeile ist **zwei Textzeilen hoch**, gleich wie viel in ihr steht, und ihr @@ -161,8 +169,8 @@ Den vollständigen Text zeigt der Tooltip: Fensterbreite. Ein Tooltip, der bloß wiederholt, was ohnehin dasteht, wäre lästig. * **Kategorie und Klasse** tragen ihren Tooltip vom Server. Bei einer **Splitbuchung** nennt er die Zeilen der Aufteilung: je Zeile Kategorie (oder Gegenkonto) und Betrag, sonst - nichts. Notiz und Klasse stünden nur im Weg; wer sie ändern will, öffnet ohnehin den - Dialog. + nichts. Notiz und Klasse stünden nur im Weg; die Klassen nennt bei verschiedenen Klassen + der Tooltip der Klassenspalte (siehe oben). ## Splitbuchungen diff --git a/public/static/kontoblatt.js b/public/static/kontoblatt.js index 78c00e9..fe3e5cf 100644 --- a/public/static/kontoblatt.js +++ b/public/static/kontoblatt.js @@ -151,6 +151,15 @@ const klassenfeld = klassenauswahl && ereignis.target.closest('a.klassenfeld'); if (klassenfeld) { ereignis.preventDefault(); + // Die Klassen einer Splitbuchung stehen an ihren Zeilen – der Klick + // führt deshalb in den Splitdialog, nicht in die Klassenliste. + const splitfeld = klassenfeld.dataset.split + ? klassenfeld.closest('tr').querySelector('a.zuordnungsfeld[data-splits]') + : null; + if (splitfeld) { + splitOeffnen(splitfeld); + return; + } const klasse = klassenfeld.dataset.klasse || ''; oeffnen(klassenfeld, klassenauswahl, 'klasse', klasse === '0' ? '' : klasse); return; diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index 5c14dfa..7dfe2b8 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -221,6 +221,32 @@ class Buchung ); } + /** + * Die Klasse, die das Kontoblatt für eine Splitbuchung zeigt. Tragen alle + * Splitzeilen dieselbe Klasse, gilt sie für die ganze Buchung; unterscheiden + * sie sich, ist die Buchung gemischt und die Spalte zeigt „Splitbuchung“. + * Eine Zeile ohne Klasse zählt dabei als eigene Angabe. Hat keine Zeile eine + * Klasse, bleibt es bei der Klasse der Buchung selbst – so zeigen ältere + * Splitbuchungen weiter, was an ihnen steht. + * + * @param list> $splitzeilen + * @return array{gemischt: bool, klasse_id: ?int} + */ + public static function splitKlasse(array $splitzeilen, ?int $eigeneKlasse): array + { + $klassen = []; + foreach ($splitzeilen as $zeile) { + $klassen[(int)($zeile['klasse_id'] ?? 0)] = true; + } + + if (count($klassen) > 1) { + return ['gemischt' => true, 'klasse_id' => null]; + } + + $klasse = (int)array_key_first($klassen); + return ['gemischt' => false, 'klasse_id' => $klasse > 0 ? $klasse : $eigeneKlasse]; + } + /** * Die Splitzeilen einer Buchung ersetzen – der Splitdialog des Kontoblatts. * Eine leere Liste hebt die Splitbuchung auf; die Buchung steht danach ohne diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index 59dd83e..fce03ad 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -1,6 +1,7 @@ > $splitzeilen */ -$splituebersicht = static function (array $splitzeilen) use ($kategorieNamen, $kontenNamen): string { +$splitname = static fn(array $zeile): string => match (true) { + !empty($zeile['gegenkonto_id']) => '[' . ($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') . ']', + !empty($zeile['kategorie_id']) => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? '?'), + default => '(ohne Kategorie)', +}; + +$splituebersicht = static function (array $splitzeilen) use ($splitname): string { $texte = []; foreach ($splitzeilen as $zeile) { - $name = match (true) { - !empty($zeile['gegenkonto_id']) => '[' . ($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') . ']', - !empty($zeile['kategorie_id']) => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? '?'), - default => '(ohne Kategorie)', - }; - $texte[] = $name . ': ' . View::betrag($zeile['betrag']); + $texte[] = $splitname($zeile) . ': ' . View::betrag($zeile['betrag']); + } + + return implode("\n", $texte); +}; + +/** + * Der Tooltip der Klassenspalte, wenn die Splitzeilen verschiedene Klassen + * tragen: je Zeile Kategorie und Klasse. + * + * @param list> $splitzeilen + */ +$splitklassen = static function (array $splitzeilen) use ($splitname, $klassenNamen): string { + $texte = []; + foreach ($splitzeilen as $zeile) { + $texte[] = $splitname($zeile) . ': ' + . (string)($klassenNamen[(int)($zeile['klasse_id'] ?? 0)] ?? '(ohne Klasse)'); } return implode("\n", $texte); @@ -358,19 +376,39 @@ include __DIR__ . '/../layout/kopf.php'; - + false, 'klasse_id' => (int)$zeile['klasse_id']]; + $klassenname = (string)($klassenNamen[(int)$klasse['klasse_id']] ?? ''); + $klassentitel = match (true) { + $klasse['gemischt'] => $splitklassen($splitzeilen), + $klassenname !== '' => $klassenname, + default => 'Klasse zuordnen', + }; + ?> + an Ort und Stelle – bei einer Splitbuchung der + Splitdialog, denn dort stehen ihre Klassen. */ ?> - + data-klasse="" + data-split="1" + title=""> + + Splitbuchung + + + diff --git a/tests/47_SplitKlasseTest.php b/tests/47_SplitKlasseTest.php new file mode 100644 index 0000000..0bf124e --- /dev/null +++ b/tests/47_SplitKlasseTest.php @@ -0,0 +1,192 @@ +mandantAnlegen(); + + $this->konto = Konto::anlegen([ + 'name' => 'Splitklasse Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->lebensmittel = Kategorie::anlegen(['name' => 'Splitklasse Lebensmittel', 'art' => 'ausgabe']); + $this->drogerie = Kategorie::anlegen(['name' => 'Splitklasse Drogerie', 'art' => 'ausgabe']); + $this->privat = Klasse::anlegen('Splitklasse Privat'); + $this->beruf = Klasse::anlegen('Splitklasse Beruf'); + $this->alt = Klasse::anlegen('Splitklasse Buchungsklasse'); + + $this->splitKlasseErmitteln(); + $this->kontoblattZeigtGemeinsameKlasse(); + $this->kontoblattZeigtMarkeBeiVerschiedenenKlassen(); + $this->ohneSplitBleibtEsBeiDerBuchungsklasse(); + } + + private function splitKlasseErmitteln(): void + { + $gleich = [['klasse_id' => $this->privat], ['klasse_id' => (string)$this->privat]]; + $this->assertEquals( + ['gemischt' => false, 'klasse_id' => $this->privat], + Buchung::splitKlasse($gleich, $this->alt), + 'Gleiche Klassen in allen Zeilen gelten für die ganze Buchung' + ); + + $verschieden = [['klasse_id' => $this->privat], ['klasse_id' => $this->beruf]]; + $this->assertEquals( + ['gemischt' => true, 'klasse_id' => null], + Buchung::splitKlasse($verschieden, $this->alt), + 'Verschiedene Klassen machen die Buchung gemischt' + ); + + $teilweise = [['klasse_id' => $this->privat], ['klasse_id' => null]]; + $this->assertEquals( + true, + Buchung::splitKlasse($teilweise, null)['gemischt'], + 'Eine Zeile ohne Klasse unterscheidet sich von einer mit Klasse' + ); + + $ohne = [['klasse_id' => null], ['klasse_id' => null]]; + $this->assertEquals( + ['gemischt' => false, 'klasse_id' => $this->alt], + Buchung::splitKlasse($ohne, $this->alt), + 'Ohne Klasse in den Zeilen bleibt die Klasse der Buchung' + ); + $this->assertEquals( + ['gemischt' => false, 'klasse_id' => null], + Buchung::splitKlasse($ohne, null), + 'Ohne jede Klasse bleibt die Spalte leer' + ); + } + + private function kontoblattZeigtGemeinsameKlasse(): void + { + $id = $this->splitbuchung('Gleichmarkt', $this->privat, $this->privat); + $zelle = $this->klassenzelle($id); + + $this->assert(str_contains($zelle, 'Splitklasse Privat'), 'Die gemeinsame Klasse steht in der Spalte'); + $this->assert(!str_contains($zelle, 'class="marke"'), 'Ohne Marke „Splitbuchung“'); + $this->assert(str_contains($zelle, 'data-split="1"'), 'Der Klick führt in den Splitdialog'); + $this->assert( + str_contains($zelle, 'data-klasse="' . $this->privat . '"'), + 'Die Zelle trägt die gemeinsame Klasse' + ); + } + + private function kontoblattZeigtMarkeBeiVerschiedenenKlassen(): void + { + $id = $this->splitbuchung('Mischmarkt', $this->privat, $this->beruf); + $zelle = $this->klassenzelle($id); + + $this->assert( + str_contains($zelle, 'Splitbuchung'), + 'Verschiedene Klassen zeigen die Marke „Splitbuchung“' + ); + $this->assert( + str_contains($zelle, 'title="Splitklasse Lebensmittel: Splitklasse Privat' . "\n" + . 'Splitklasse Drogerie: Splitklasse Beruf"'), + 'Der Tooltip nennt je Splitzeile Kategorie und Klasse' + ); + $this->assert( + !str_contains($zelle, 'Splitklasse Buchungsklasse'), + 'Die Klasse der Buchung selbst spielt keine Rolle' + ); + } + + private function ohneSplitBleibtEsBeiDerBuchungsklasse(): void + { + $id = Buchung::anlegen([ + 'konto_id' => $this->konto, 'datum' => '2026-05-03', 'betrag' => -12.00, + 'empfaenger' => 'Einzelmarkt', 'kategorie_id' => $this->lebensmittel, 'klasse_id' => $this->beruf, + ]); + $zelle = $this->klassenzelle($id); + + $this->assert(str_contains($zelle, 'Splitklasse Beruf'), 'Eine einfache Buchung zeigt ihre Klasse'); + $this->assert(!str_contains($zelle, 'data-split'), 'Und öffnet weiter die Klassenliste'); + } + + private function splitbuchung(string $empfaenger, int $klasseA, int $klasseB): int + { + return Buchung::anlegen( + [ + 'konto_id' => $this->konto, 'datum' => '2026-05-02', 'betrag' => -30.00, + 'empfaenger' => $empfaenger, 'klasse_id' => $this->alt, + ], + [ + ['kategorie_id' => $this->lebensmittel, 'klasse_id' => $klasseA, 'betrag' => -20.00], + ['kategorie_id' => $this->drogerie, 'klasse_id' => $klasseB, 'betrag' => -10.00], + ] + ); + } + + /** + * Die Klassenzelle einer Buchung aus dem gerenderten Kontoblatt. + */ + private function klassenzelle(int $buchungId): string + { + $html = $this->kontoblattHtml(); + $muster = '#\s*#s'; + + return preg_match($muster, $html, $treffer) === 1 ? $treffer[0] : ''; + } + + /** + * Das Kontoblatt rendern wie der Controller es täte – ohne Webserver. + */ + private function kontoblattHtml(): string + { + $zeilen = Buchung::kontoblatt($this->konto); + $gesplittete = []; + foreach ($zeilen as $zeile) { + if ((int)$zeile['gesplittet'] === 1 && !$zeile['gegensicht']) { + $gesplittete[] = (int)$zeile['id']; + } + } + + $daten = [ + 'konto' => Konto::mitSalden(Konto::find($this->konto)), + 'zeilen' => $zeilen, + 'gesamt' => count($zeilen), + 'seite' => 1, + 'seiten' => 1, + 'filter' => [], + 'kategorien' => Kategorie::baumFlach(), + 'klassen' => Klasse::alle(), + 'konten' => Konto::alle(), + 'splits' => Buchung::splitsJeBuchung($gesplittete), + 'wertpapiere' => [], + 'bargeld' => false, + 'titel' => 'Test', + 'flash' => [], + 'benutzer' => null, + 'csrfToken' => 'x', + 'aktuellerPfad' => '/konten/' . $this->konto, + ]; + + extract($daten, EXTR_SKIP); + + ob_start(); + include dirname(__DIR__) . '/src/Views/buchungen/kontoblatt.php'; + return (string)ob_get_clean(); + } +} diff --git a/tests/e2e/specs/buchungen.spec.js b/tests/e2e/specs/buchungen.spec.js index 6011309..b5da638 100644 --- a/tests/e2e/specs/buchungen.spec.js +++ b/tests/e2e/specs/buchungen.spec.js @@ -372,6 +372,42 @@ test.describe('Buchungen', () => { expect(titel).toContain(`${KATEGORIE}: -97,98`) }) + test('Klassen der Splitzeilen stehen in der Klassenspalte', async ({ page }) => { + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-05-03') + await page.fill('#betrag', '45') + await page.fill('#empfaenger', `Klassensplit ${MARKE}`) + await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE }) + await page.selectOption('select[name="split[0][klasse_id]"]', { label: KLASSE }) + await page.fill('input[name="split[0][betrag]"]', '-25') + await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE }) + await page.fill('input[name="split[1][betrag]"]', '-20') + await page.click('.buchungsmaske button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + // Verschiedene Klassen: die Marke, im Tooltip die Klasse je Zeile. + const zeile = page.locator('tr', { hasText: `Klassensplit ${MARKE}` }) + const klassenfeld = zeile.locator('a.klassenfeld') + await expect(klassenfeld.locator('.marke')).toHaveText('Splitbuchung') + const titel = await klassenfeld.getAttribute('title') + expect(titel).toContain(`${KATEGORIE}: ${KLASSE}`) + expect(titel).toContain(`${KATEGORIE}: (ohne Klasse)`) + + // Der Klick führt in den Splitdialog, wo die Klassen der Zeilen stehen. + await klassenfeld.click() + const dialog = page.locator('#splitdialog') + await expect(dialog).toBeVisible() + await expect(page.locator('#klassenauswahl')).toBeHidden() + await dialog.locator('select[name="split[1][klasse_id]"]').selectOption({ label: KLASSE }) + await dialog.locator('button[type="submit"].primaer').click() + await expect(page.locator('.meldung-erfolg').last()).toContainText('Splitbuchung gespeichert') + + // Gleiche Klassen: die Klasse selbst, ohne Marke. + const danach = page.locator('tr', { hasText: `Klassensplit ${MARKE}` }).locator('a.klassenfeld') + await expect(danach).toHaveText(KLASSE) + await expect(danach.locator('.marke')).toHaveCount(0) + }) + test('Buchungszeilen sind gleich hoch, abgeschnittene Texte stehen im Tooltip', async ({ page }) => { const LANG = `Verwendungszweck ${MARKE} mit sehr vielen Wörtern, `.repeat(6).trim()