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