Merge pull request 'Fix #76: Klassen der Splitzeilen in der Klassenspalte des Kontoblatts' (#88) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/88
This commit is contained in:
fds2610 2026-09-28 15:33:14 +02:00
commit 6f29e4644f
7 changed files with 337 additions and 15 deletions

View file

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

View file

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

View file

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

View file

@ -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<array<string,mixed>> $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

View file

@ -1,6 +1,7 @@
<?php
use Finanzmanager\Models\Adresse;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\View;
@ -89,19 +90,36 @@ $splitdaten = static function (array $splitzeilen) use ($zuordnungswert): string
/**
* Der Tooltip einer Splitbuchung: je Zeile Kategorie und Betrag. In der Spalte
* steht nur „Splitbuchung“ – wohin das Geld geht, wäre sonst erst im Dialog zu
* sehen. Notiz und Klasse bleiben draußen; sie stünden im Weg.
* sehen. Notiz und Klasse bleiben draußen; die Klassen nennt die Klassenspalte.
*
* @param list<array<string,mixed>> $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<array<string,mixed>> $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';
</a>
<?php endif; ?>
</td>
<?php $klassenname = (string)($klassenNamen[(int)$zeile['klasse_id']] ?? ''); ?>
<?php
// Bei einer Splitbuchung zählen die Klassen ihrer Zeilen – wie
// bei der Kategorie: eine gemeinsame Klasse steht in der
// Spalte, verschiedene nennt der Tooltip der Marke.
$splitzeilen = !$zeile['gegensicht'] ? ($splits[(int)$zeile['id']] ?? null) : null;
$klasse = $splitzeilen !== null
? Buchung::splitKlasse($splitzeilen, $zeile['klasse_id'] !== null ? (int)$zeile['klasse_id'] : null)
: ['gemischt' => 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',
};
?>
<td class="klassenzelle nebentext">
<?php if ($zeile['gegensicht']): ?>
<?= View::h($klassenname) ?>
<?php else: ?>
<?php /* Wie bei der Zuordnung: Ohne JavaScript führt der Klick
in die Buchungsmaske, mit ihm öffnet sich die Auswahl
an Ort und Stelle. */ ?>
an Ort und Stelle – bei einer Splitbuchung der
Splitdialog, denn dort stehen ihre Klassen. */ ?>
<a href="/buchungen/<?= (int)$zeile['id'] ?>" class="klassenfeld"
data-buchung="<?= (int)$zeile['id'] ?>"
data-klasse="<?= (int)$zeile['klasse_id'] ?>"
title="<?= View::h($klassenname !== '' ? $klassenname : 'Klasse zuordnen') ?>">
<?= View::h($klassenname) ?>
data-klasse="<?= (int)$klasse['klasse_id'] ?>"
<?php if ($splitzeilen !== null): ?>data-split="1"<?php endif; ?>
title="<?= View::h($klassentitel) ?>">
<?php if ($klasse['gemischt']): ?>
<span class="marke">Splitbuchung</span>
<?php else: ?>
<?= View::h($klassenname) ?>
<?php endif; ?>
</a>
<?php endif; ?>
</td>

View file

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
/**
* Fix #76: Die Klassenspalte des Kontoblatts berücksichtigt bei einer
* Splitbuchung die Klassen ihrer Zeilen. Tragen alle Zeilen dieselbe Klasse,
* steht sie in der Spalte; unterscheiden sie sich, steht dort die Marke
* „Splitbuchung“ mit den Klassen je Zeile im Tooltip.
*/
class SplitKlasseTest extends TestCase
{
protected string $testName = 'Test 47: Klassen der Splitzeilen im Kontoblatt';
private int $konto = 0;
private int $lebensmittel = 0;
private int $drogerie = 0;
private int $privat = 0;
private int $beruf = 0;
private int $alt = 0;
public function run(): void
{
$this->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, '<span class="marke">Splitbuchung</span>'),
'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 = '#<td class="klassenzelle nebentext">\s*<a href="/buchungen/' . $buchungId . '".*?</td>#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();
}
}

View file

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