Eine Umbuchung war auf dem Gegenkonto nicht mehr zuzuordnen: Die Zeile trug die
Marke „Umbuchung“, die Kategoriespalte daneben blieb leer. Die Daten waren
richtig, die Anzeige verschwieg sie. In der Kategoriespalte steht jetzt auch in
der Gegensicht das Konto in eckigen Klammern, von dem die Umbuchung kommt – die
Abfrage lieferte es längst mit.
Kategorie und Gegenkonto stehen in einem Feld (`Views/buchungen/zuordnungsliste.php`):
Unter den Kategorien stehen die eigenen Konten in eckigen Klammern, wie im
FinanzManager, dessen Kategoriefeld `[Konto]` annimmt. Eines davon macht die
Buchung zur Umbuchung; die Auswahl „Ausgabe / Einnahme“ bestimmt seither nur
noch die Richtung. Dieselbe Liste steht in den Splitzeilen – eine Splitzeile mit
Gegenkonto ließ sich bisher nur im Import anlegen und ging beim Bearbeiten der
Buchung stillschweigend verloren.
Schnellzuordnung: Ein Klick auf die Kategoriespalte öffnet die Liste in der
Zelle, die Auswahl speichert sofort (POST /buchungen/{id}/zuordnung) und kommt
in dieselbe Ansicht zurück. Die Liste gibt es einmal und wandert in die
angeklickte Zelle – eine Liste je Zeile wären bei hundert Zeilen ebenso viele
vollständige Kategoriebäume im HTML. Feld und Liste sind gleich breit und gleich
hoch, sonst rückte beim Anklicken die halbe Zeile. Ohne JavaScript führt
derselbe Klick in die Buchungsmaske: Die Zelle ist ein Link. Die Gegenseite
einer fremden Umbuchung führt zu ihrer Buchung auf dem anderen Konto.
Der Eintrag „Splitbuchung“ öffnet ein modales Fenster mit Zeilen für Kategorie
oder Gegenkonto, Klasse, Notiz und Betrag; der Restbetrag wird mitgerechnet. Ein
Klick auf eine bereits gesplittete Buchung öffnet direkt den Dialog. „Splitbuchung
löschen“ verwirft alle Zeilen samt ihren Zuordnungen – auch die Gegenseite einer
Splitzeile mit Gegenkonto –, die Buchung steht danach unzugeordnet im Kontoblatt.
Beide Wege gehen über `Buchung::zuordnen()` und `Buchung::splitsErsetzen()`,
achten den Monatsabschluss und lassen den Rest der Buchung unberührt.
`public/static/betrag.js` bündelt das Lesen deutscher Beträge und die
Restbetragsrechnung für Maske und Kontoblatt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
275 lines
11 KiB
PHP
275 lines
11 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->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);
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|