Die Klassenspalte ist anklickbar wie die Zuordnung daneben: Ein Klick
öffnet die Klassenliste in der Zelle, die Auswahl wird sofort über
POST /buchungen/{id}/klasse gespeichert. Buchung::klasseSetzen() lässt
Kategorie, Gegenkonto und Splitzeilen unberührt.
Die Buchungszeilen sind jetzt alle zwei Textzeilen hoch, ihr Inhalt steht
senkrecht in der Mitte. Was nicht auf zwei Zeilen passt, wird
abgeschnitten; den vollständigen Text zeigt der Tooltip – bei Empfänger
und Buchungstext nur dort, wo wirklich etwas fehlt, über dem
Kategoriefeld einer Splitbuchung deren Zeilen mit Kategorie und Betrag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
331 lines
14 KiB
PHP
331 lines
14 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Controllers\BuchungenController;
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Klasse;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Models\Monatsabschluss;
|
||
|
||
/**
|
||
* Die Schnellzuordnung aus dem Kontoblatt: Kategorie oder Gegenkonto in einem
|
||
* Feld, Splitzeilen aus dem Dialog.
|
||
*/
|
||
class ZuordnungTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 23: Zuordnung im Kontoblatt';
|
||
|
||
private int $giro = 0;
|
||
private int $spar = 0;
|
||
private int $kategorie = 0;
|
||
private int $klasse = 0;
|
||
|
||
public function run(): void
|
||
{
|
||
$this->mandantAnlegen();
|
||
$this->stammdaten();
|
||
|
||
$this->auswahlwerteLesen();
|
||
$this->kategorieUndGegenkontoWechseln();
|
||
$this->gegensichtNenntIhrKonto();
|
||
$this->splitzeilenErsetzen();
|
||
$this->splitbuchungLoeschen();
|
||
$this->splitzeilenJeBuchung();
|
||
$this->klasseWechseln();
|
||
$this->pruefungen();
|
||
}
|
||
|
||
private function stammdaten(): void
|
||
{
|
||
$this->giro = Konto::anlegen([
|
||
'name' => 'Zuordnung Giro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1,
|
||
]);
|
||
$this->spar = Konto::anlegen([
|
||
'name' => 'Zuordnung Spar', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||
]);
|
||
$this->kategorie = Kategorie::anlegen(['name' => 'Zuordnung Einkauf', 'art' => 'ausgabe']);
|
||
$this->klasse = Klasse::anlegen('Zuordnung Privat');
|
||
}
|
||
|
||
private function buchung(float $betrag = -50.0, string $datum = '2026-03-10'): int
|
||
{
|
||
return Buchung::anlegen([
|
||
'konto_id' => $this->giro,
|
||
'datum' => $datum,
|
||
'betrag' => $betrag,
|
||
'empfaenger' => 'Noch zuzuordnen',
|
||
]);
|
||
}
|
||
|
||
/** Kategorie und Gegenkonto stehen in einem Feld – hier wird es gelesen. */
|
||
private function auswahlwerteLesen(): void
|
||
{
|
||
$this->assertEquals(
|
||
['kategorie_id' => 12, 'gegenkonto_id' => null],
|
||
BuchungenController::zuordnungAus('kategorie:12'),
|
||
'Eine Kategorie wird erkannt'
|
||
);
|
||
$this->assertEquals(
|
||
['kategorie_id' => null, 'gegenkonto_id' => 7],
|
||
BuchungenController::zuordnungAus('konto:7'),
|
||
'Ein Gegenkonto wird erkannt'
|
||
);
|
||
|
||
$leer = ['kategorie_id' => null, 'gegenkonto_id' => null];
|
||
$this->assertEquals($leer, BuchungenController::zuordnungAus(''), 'Leer heißt keine Zuordnung');
|
||
$this->assertEquals($leer, BuchungenController::zuordnungAus('split'), 'Splitbuchung ist keine Zuordnung');
|
||
$this->assertEquals($leer, BuchungenController::zuordnungAus('konto:0'), 'Kennung 0 zählt nicht');
|
||
$this->assertEquals($leer, BuchungenController::zuordnungAus('unfug:3'), 'Unbekanntes Präfix zählt nicht');
|
||
}
|
||
|
||
private function kategorieUndGegenkontoWechseln(): void
|
||
{
|
||
$id = $this->buchung();
|
||
|
||
Buchung::zuordnen($id, $this->kategorie, null);
|
||
$buchung = Buchung::find($id);
|
||
$this->assertEquals($this->kategorie, (int)$buchung['kategorie_id'], 'Die Kategorie steht in der Buchung');
|
||
$this->assertNull($buchung['gegenkonto_id'], 'Ohne Gegenkonto');
|
||
|
||
// Aus der Kategorie wird eine Umbuchung: beides zugleich gibt es nicht.
|
||
Buchung::zuordnen($id, $this->kategorie, $this->spar);
|
||
$buchung = Buchung::find($id);
|
||
$this->assertEquals($this->spar, (int)$buchung['gegenkonto_id'], 'Das Gegenkonto steht in der Buchung');
|
||
$this->assertNull($buchung['kategorie_id'], 'Die Kategorie weicht dem Gegenkonto');
|
||
|
||
// Die Umbuchung wirkt sofort auf beiden Seiten.
|
||
$this->assertEquals(50.0, Konto::saldo($this->spar), 'Das Gegenkonto bekommt die Gegenseite');
|
||
|
||
Buchung::zuordnen($id, null, null);
|
||
$buchung = Buchung::find($id);
|
||
$this->assertNull($buchung['kategorie_id'], 'Die Zuordnung lässt sich wieder leeren');
|
||
$this->assertNull($buchung['gegenkonto_id'], 'Auch das Gegenkonto');
|
||
$this->assertEquals(0.0, Konto::saldo($this->spar), 'Und das Gegenkonto ist den Betrag wieder los');
|
||
}
|
||
|
||
/**
|
||
* Der Anzeigefehler, um den es ging: Auf dem Gegenkonto stand eine Umbuchung
|
||
* ohne erkennbares Gegenüber. Die Zeile muss das Ursprungskonto nennen.
|
||
*/
|
||
private function gegensichtNenntIhrKonto(): void
|
||
{
|
||
$id = $this->buchung(-120.0, '2026-03-11');
|
||
Buchung::zuordnen($id, null, $this->spar);
|
||
|
||
$zeilen = array_values(array_filter(
|
||
Buchung::kontoblatt($this->spar),
|
||
static fn(array $zeile): bool => (int)$zeile['id'] === $id
|
||
));
|
||
|
||
$this->assertEquals(1, count($zeilen), 'Die Umbuchung erscheint auf dem Gegenkonto');
|
||
$this->assert($zeilen[0]['gegensicht'], 'Und zwar als Gegensicht');
|
||
$this->assertEquals(
|
||
$this->giro,
|
||
(int)$zeilen[0]['gegenkonto_id'],
|
||
'Die Gegensicht nennt das Konto, von dem die Umbuchung kommt'
|
||
);
|
||
$this->assertEquals(120.0, (float)$zeilen[0]['betrag'], 'Mit umgekehrtem Vorzeichen');
|
||
|
||
Buchung::loeschen($id);
|
||
}
|
||
|
||
private function splitzeilenErsetzen(): void
|
||
{
|
||
$id = $this->buchung(-100.0, '2026-03-12');
|
||
Buchung::zuordnen($id, $this->kategorie, null);
|
||
|
||
Buchung::splitsErsetzen($id, [
|
||
['kategorie_id' => $this->kategorie, 'klasse_id' => $this->klasse, 'betrag' => -60.0, 'notiz' => 'Einkauf'],
|
||
['gegenkonto_id' => $this->spar, 'betrag' => -40.0, 'notiz' => 'Rücklage'],
|
||
]);
|
||
|
||
$buchung = Buchung::find($id);
|
||
$this->assertEquals(1, (int)$buchung['gesplittet'], 'Die Buchung ist jetzt gesplittet');
|
||
$this->assertNull($buchung['kategorie_id'], 'Die Kategorie von oben weicht den Zeilen');
|
||
|
||
$splits = Buchung::splits($id);
|
||
$this->assertEquals(2, count($splits), 'Zwei Zeilen sind gespeichert');
|
||
$this->assertEquals($this->klasse, (int)$splits[0]['klasse_id'], 'Die Klasse der ersten Zeile');
|
||
$this->assertEquals($this->spar, (int)$splits[1]['gegenkonto_id'], 'Das Gegenkonto der zweiten Zeile');
|
||
$this->assertEquals(40.0, Konto::saldo($this->spar), 'Nur der Betrag dieser Zeile geht aufs Gegenkonto');
|
||
|
||
// Eine einzelne Zuordnung hebt die Aufteilung auf.
|
||
Buchung::zuordnen($id, $this->kategorie, null);
|
||
$this->assertEquals(0, (int)Buchung::find($id)['gesplittet'], 'Die Splitbuchung ist aufgehoben');
|
||
$this->assertEquals([], Buchung::splits($id), 'Und die Zeilen sind weg');
|
||
$this->assertEquals(0.0, Konto::saldo($this->spar), 'Das Gegenkonto ist den Anteil wieder los');
|
||
|
||
// Ohne Zeilen bleibt eine Buchung ohne Zuordnung stehen.
|
||
Buchung::splitsErsetzen($id, [
|
||
['kategorie_id' => $this->kategorie, 'betrag' => -100.0, 'notiz' => ''],
|
||
]);
|
||
Buchung::splitsErsetzen($id, []);
|
||
$buchung = Buchung::find($id);
|
||
$this->assertEquals(0, (int)$buchung['gesplittet'], 'Leere Zeilen heben die Splitbuchung auf');
|
||
$this->assertNull($buchung['kategorie_id'], 'Die Buchung steht danach ohne Zuordnung da');
|
||
$this->assertEquals(-100.0, (float)$buchung['betrag'], 'Der Buchungsbetrag bleibt unberührt');
|
||
|
||
Buchung::loeschen($id);
|
||
}
|
||
|
||
/**
|
||
* „Splitbuchung löschen“ aus dem Splitdialog: Die Aufteilung fällt weg und
|
||
* mit ihr jede Zuordnung – auch die einer Zeile mit Gegenkonto, deren
|
||
* Gegenseite auf dem anderen Konto zu verschwinden hat.
|
||
*/
|
||
private function splitbuchungLoeschen(): void
|
||
{
|
||
$id = $this->buchung(-90.0, '2026-03-16');
|
||
|
||
Buchung::splitsErsetzen($id, [
|
||
['kategorie_id' => $this->kategorie, 'klasse_id' => $this->klasse, 'betrag' => -55.0, 'notiz' => 'Teil A'],
|
||
['gegenkonto_id' => $this->spar, 'betrag' => -35.0, 'notiz' => 'Teil B'],
|
||
]);
|
||
$this->assertEquals(35.0, Konto::saldo($this->spar), 'Die Zeile mit Gegenkonto wirkt zunächst');
|
||
|
||
Buchung::splitsErsetzen($id, []);
|
||
|
||
$buchung = Buchung::find($id);
|
||
$this->assertEquals([], Buchung::splits($id), 'Alle Splitzeilen sind gelöscht');
|
||
$this->assertEquals(0, (int)$buchung['gesplittet'], 'Die Buchung gilt nicht mehr als gesplittet');
|
||
$this->assertNull($buchung['kategorie_id'], 'Sie hat keine Kategorie');
|
||
$this->assertNull($buchung['gegenkonto_id'], 'Und kein Gegenkonto');
|
||
$this->assertEquals(-90.0, (float)$buchung['betrag'], 'Die Buchung selbst bleibt stehen');
|
||
$this->assertEquals(0.0, Konto::saldo($this->spar), 'Das Gegenkonto ist seinen Anteil wieder los');
|
||
|
||
Buchung::loeschen($id);
|
||
}
|
||
|
||
private function splitzeilenJeBuchung(): void
|
||
{
|
||
$eine = $this->buchung(-30.0, '2026-03-13');
|
||
$keine = $this->buchung(-20.0, '2026-03-14');
|
||
|
||
Buchung::splitsErsetzen($eine, [
|
||
['kategorie_id' => $this->kategorie, 'betrag' => -10.0, 'notiz' => 'A'],
|
||
['kategorie_id' => $this->kategorie, 'betrag' => -20.0, 'notiz' => 'B'],
|
||
]);
|
||
|
||
$jeBuchung = Buchung::splitsJeBuchung([$eine, $keine]);
|
||
$this->assertEquals(2, count($jeBuchung[$eine]), 'Die Zeilen kommen der Buchung zugeordnet zurück');
|
||
$this->assert(!isset($jeBuchung[$keine]), 'Eine Buchung ohne Zeilen fehlt in der Liste');
|
||
$this->assertEquals([], Buchung::splitsJeBuchung([]), 'Ohne Kennungen wird nicht abgefragt');
|
||
|
||
Buchung::loeschen($eine);
|
||
Buchung::loeschen($keine);
|
||
}
|
||
|
||
/**
|
||
* Die Schnellauswahl der Klassenspalte: setzen, wechseln, löschen – und die
|
||
* Zuordnung bleibt dabei, wie sie ist.
|
||
*/
|
||
private function klasseWechseln(): void
|
||
{
|
||
$zweite = Klasse::anlegen('Zuordnung Beruflich');
|
||
$id = $this->buchung(-70.0, '2026-03-17');
|
||
|
||
Buchung::zuordnen($id, $this->kategorie, null);
|
||
Buchung::klasseSetzen($id, $this->klasse);
|
||
|
||
$buchung = Buchung::find($id);
|
||
$this->assertEquals($this->klasse, (int)$buchung['klasse_id'], 'Die Klasse steht in der Buchung');
|
||
$this->assertEquals(
|
||
$this->kategorie,
|
||
(int)$buchung['kategorie_id'],
|
||
'Die Zuordnung bleibt von der Klasse unberührt'
|
||
);
|
||
|
||
Buchung::klasseSetzen($id, $zweite);
|
||
$this->assertEquals($zweite, (int)Buchung::find($id)['klasse_id'], 'Die Klasse lässt sich wechseln');
|
||
|
||
// Der erste Eintrag der Liste – „keine“ – löscht die vorhandene Klasse.
|
||
Buchung::klasseSetzen($id, null);
|
||
$this->assertNull(Buchung::find($id)['klasse_id'], 'Und wieder löschen');
|
||
|
||
// Eine Splitbuchung behält ihre Zeilen: Die Klasse steht daneben.
|
||
Buchung::splitsErsetzen($id, [
|
||
['kategorie_id' => $this->kategorie, 'betrag' => -40.0, 'notiz' => 'A'],
|
||
['kategorie_id' => $this->kategorie, 'betrag' => -30.0, 'notiz' => 'B'],
|
||
]);
|
||
Buchung::klasseSetzen($id, $this->klasse);
|
||
$this->assertEquals(2, count(Buchung::splits($id)), 'Die Splitzeilen bleiben erhalten');
|
||
$this->assertEquals(1, (int)Buchung::find($id)['gesplittet'], 'Und die Buchung bleibt gesplittet');
|
||
|
||
$fremdeKlasse = false;
|
||
try {
|
||
Buchung::klasseSetzen($id, 999999);
|
||
} catch (\Throwable) {
|
||
$fremdeKlasse = true;
|
||
}
|
||
$this->assert($fremdeKlasse, 'Eine Klasse, die es nicht gibt, wird abgewiesen');
|
||
|
||
Buchung::loeschen($id);
|
||
}
|
||
|
||
private function pruefungen(): void
|
||
{
|
||
$id = $this->buchung(-80.0, '2026-03-15');
|
||
|
||
$aufSichSelbst = false;
|
||
try {
|
||
Buchung::zuordnen($id, null, $this->giro);
|
||
} catch (\Throwable) {
|
||
$aufSichSelbst = true;
|
||
}
|
||
$this->assert($aufSichSelbst, 'Ein Konto kann nicht sein eigenes Gegenkonto sein');
|
||
|
||
$splitAufSichSelbst = false;
|
||
try {
|
||
Buchung::splitsErsetzen($id, [['gegenkonto_id' => $this->giro, 'betrag' => -80.0]]);
|
||
} catch (\Throwable) {
|
||
$splitAufSichSelbst = true;
|
||
}
|
||
$this->assert($splitAufSichSelbst, 'Auch eine Splitzeile nicht');
|
||
|
||
$krummeSumme = false;
|
||
try {
|
||
Buchung::splitsErsetzen($id, [['kategorie_id' => $this->kategorie, 'betrag' => -30.0]]);
|
||
} catch (\Throwable) {
|
||
$krummeSumme = true;
|
||
}
|
||
$this->assert($krummeSumme, 'Zeilen, die nicht aufgehen, werden abgewiesen');
|
||
$this->assertEquals(0, (int)Buchung::find($id)['gesplittet'], 'Und die Buchung bleibt ungesplittet');
|
||
|
||
$fremdeKategorie = false;
|
||
try {
|
||
Buchung::zuordnen($id, 999999, null);
|
||
} catch (\Throwable) {
|
||
$fremdeKategorie = true;
|
||
}
|
||
$this->assert($fremdeKategorie, 'Eine Kategorie, die es nicht gibt, wird abgewiesen');
|
||
|
||
// Ein abgeschlossener Monat ist auch für die Schnellzuordnung zu.
|
||
Monatsabschluss::abschliessen(2026, 3);
|
||
|
||
$zu = false;
|
||
try {
|
||
Buchung::zuordnen($id, $this->kategorie, null);
|
||
} catch (\Throwable) {
|
||
$zu = true;
|
||
}
|
||
$this->assert($zu, 'Im abgeschlossenen Monat wird nichts mehr zugeordnet');
|
||
|
||
$zuFuerSplits = false;
|
||
try {
|
||
Buchung::splitsErsetzen($id, []);
|
||
} catch (\Throwable) {
|
||
$zuFuerSplits = true;
|
||
}
|
||
$this->assert($zuFuerSplits, 'Auch die Splitzeilen bleiben, wie sie sind');
|
||
|
||
$zuFuerKlasse = false;
|
||
try {
|
||
Buchung::klasseSetzen($id, $this->klasse);
|
||
} catch (\Throwable) {
|
||
$zuFuerKlasse = true;
|
||
}
|
||
$this->assert($zuFuerKlasse, 'Und die Klasse ebenso');
|
||
}
|
||
}
|