Merge pull request 'Test: Umbuchungen spiegeln sich zuverlässig' (#33) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/33
This commit is contained in:
commit
ff022baf3a
2 changed files with 487 additions and 0 deletions
26
Changes.md
26
Changes.md
|
|
@ -1,5 +1,31 @@
|
||||||
# Änderungen
|
# Änderungen
|
||||||
|
|
||||||
|
## Spiegelung der Umbuchungen unter Test (17.08.2026)
|
||||||
|
|
||||||
|
Neuer Test `tests/30_UmbuchungSpiegelungTest.php`. Er nagelt die zwei Zusagen fest, die
|
||||||
|
sich aus dem Datenmodell ergeben – eine Umbuchung ist EIN Datensatz mit `gegenkonto_id`,
|
||||||
|
die Gegenseite leitet `Buchung::kontoblatt()` daraus ab:
|
||||||
|
|
||||||
|
1. **Was ein Gegenkonto trägt, steht im Kontoblatt dieses Gegenkontos** – auf allen vier
|
||||||
|
Wegen, auf denen ein Gegenkonto an eine Buchung kommt: beim Anlegen, über die
|
||||||
|
Schnellzuordnung im Kontoblatt, über die Buchungsmaske und als einzelne Splitzeile.
|
||||||
|
Ein gewechseltes Gegenkonto bleibt nicht beim alten hängen, ein entferntes taucht
|
||||||
|
nirgends mehr auf, und Such-, Zeitraum- und Kategoriefilter verstecken die Gegensicht
|
||||||
|
nicht.
|
||||||
|
2. **Wer die Buchung löscht, löscht beide Sichten** – Kontoblatt und Saldo des
|
||||||
|
Gegenkontos sind danach wieder wie vorher, Splitzeilen bleiben nicht als Waisen
|
||||||
|
zurück. Eine Gegenseite ohne Ursprungsbuchung kann nicht entstehen: Sie trägt die
|
||||||
|
Nummer des Originals, ein Löschen „auf dem Gegenkonto“ trifft deshalb dieses.
|
||||||
|
|
||||||
|
Dazu eine Invariantenprüfung über alle Daten des Mandanten, die an sieben Stellen des
|
||||||
|
Testablaufs läuft: Zu jedem gesetzten Gegenkonto gibt es eine Gegensicht, und keine
|
||||||
|
Buchung trägt ein Gegenkonto, das durch `gesplittet = 1` verdeckt wäre – das ist die
|
||||||
|
einzige Konstellation, in der eine Umbuchung unsichtbar würde, weil dann weder der Zweig
|
||||||
|
für Buchungen (`gesplittet = 0`) noch der für Splitzeilen greift. Gegenprobe: Vier
|
||||||
|
absichtliche Eingriffe ins Modell (beide Gegensicht-Zweige des Kontoblatts abgeklemmt,
|
||||||
|
Gegenkonto hinter einer Splitbuchung stehen gelassen, Löschen wirkungslos gemacht)
|
||||||
|
lassen den Test zuverlässig fehlschlagen.
|
||||||
|
|
||||||
## Buchungsmaske nennt das Konto und passt auf einen Bildschirm (17.08.2026)
|
## Buchungsmaske nennt das Konto und passt auf einen Bildschirm (17.08.2026)
|
||||||
|
|
||||||
*Neue Buchung* und *Buchung bearbeiten* zeigen oben, in welchem Konto die Buchung liegt
|
*Neue Buchung* und *Buchung bearbeiten* zeigen oben, in welchem Konto die Buchung liegt
|
||||||
|
|
|
||||||
461
tests/30_UmbuchungSpiegelungTest.php
Normal file
461
tests/30_UmbuchungSpiegelungTest.php
Normal file
|
|
@ -0,0 +1,461 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Finanzmanager\Database;
|
||||||
|
use Finanzmanager\Mandant;
|
||||||
|
use Finanzmanager\Models\Buchung;
|
||||||
|
use Finanzmanager\Models\Kategorie;
|
||||||
|
use Finanzmanager\Models\Konto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwei Zusagen des Datenmodells, festgenagelt:
|
||||||
|
*
|
||||||
|
* 1. Was ein Gegenkonto trägt, steht auch im Kontoblatt dieses Gegenkontos –
|
||||||
|
* egal, auf welchem Weg das Gegenkonto dorthin kam.
|
||||||
|
* 2. Wer die Buchung löscht, löscht beide Sichten. Eine Gegenseite ohne
|
||||||
|
* Ursprungsbuchung kann es nicht geben.
|
||||||
|
*
|
||||||
|
* Beides folgt daraus, dass eine Umbuchung EIN Datensatz mit `gegenkonto_id`
|
||||||
|
* ist und die Gegenseite in `Buchung::kontoblatt()` abgeleitet wird. Diese
|
||||||
|
* Tests halten die Ableitung dicht: Die einzige Lücke, durch die eine
|
||||||
|
* Umbuchung unsichtbar würde, wäre eine Buchung, die zugleich `gesplittet`
|
||||||
|
* ist und oben ein Gegenkonto trägt – dann griffe weder der Zweig für
|
||||||
|
* Buchungen (`gesplittet = 0`) noch der für Splitzeilen.
|
||||||
|
*/
|
||||||
|
class UmbuchungSpiegelungTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 30: Umbuchungen spiegeln sich zuverlässig';
|
||||||
|
|
||||||
|
private int $giro = 0;
|
||||||
|
private int $tagesgeld = 0;
|
||||||
|
private int $bargeld = 0;
|
||||||
|
private int $kategorie = 0;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->mandantAnlegen();
|
||||||
|
$this->stammdaten();
|
||||||
|
|
||||||
|
$this->jederWegErzeugtDieGegensicht();
|
||||||
|
$this->gegenkontoWechseltUndVerschwindet();
|
||||||
|
$this->splitbuchungVerstecktKeineUmbuchung();
|
||||||
|
$this->filterVerstecktDieGegensichtNicht();
|
||||||
|
$this->loeschenNimmtDieGegenseiteMit();
|
||||||
|
$this->gegenseiteIstNichtEinzelnLoeschbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stammdaten(): void
|
||||||
|
{
|
||||||
|
$this->giro = Konto::anlegen([
|
||||||
|
'name' => 'Spiegelgiro', 'kontoart' => 'bank', 'anfangssaldo' => 2000, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
$this->tagesgeld = Konto::anlegen([
|
||||||
|
'name' => 'Spiegeltagesgeld', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
$this->bargeld = Konto::anlegen([
|
||||||
|
'name' => 'Spiegelkasse', 'kontoart' => 'bar', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
$this->kategorie = Kategorie::anlegen(['name' => 'Spiegelausgabe', 'art' => 'ausgabe']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Gegenkonto kann auf vier Wegen an eine Buchung kommen. Jeder davon
|
||||||
|
* muss im Kontoblatt des Gegenkontos ankommen – sonst gäbe es Umbuchungen,
|
||||||
|
* die nur auf einer Seite sichtbar sind.
|
||||||
|
*/
|
||||||
|
private function jederWegErzeugtDieGegensicht(): void
|
||||||
|
{
|
||||||
|
// Weg 1: gleich beim Anlegen.
|
||||||
|
$beimAnlegen = Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'datum' => '2026-07-01',
|
||||||
|
'betrag' => -100.00,
|
||||||
|
'empfaenger' => 'Übertrag beim Anlegen',
|
||||||
|
'gegenkonto_id' => $this->tagesgeld,
|
||||||
|
]);
|
||||||
|
$this->assert(
|
||||||
|
$this->gegensichtVorhanden($this->tagesgeld, $beimAnlegen),
|
||||||
|
'Beim Anlegen gesetztes Gegenkonto erscheint im Gegenkonto'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Weg 2: nachträglich über die Schnellzuordnung im Kontoblatt.
|
||||||
|
$perZuordnung = Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'datum' => '2026-07-02',
|
||||||
|
'betrag' => -200.00,
|
||||||
|
'empfaenger' => 'Übertrag per Zuordnung',
|
||||||
|
]);
|
||||||
|
$this->assert(
|
||||||
|
!$this->gegensichtVorhanden($this->tagesgeld, $perZuordnung),
|
||||||
|
'Ohne Gegenkonto steht die Buchung nur auf ihrem eigenen Konto'
|
||||||
|
);
|
||||||
|
Buchung::zuordnen($perZuordnung, null, $this->tagesgeld);
|
||||||
|
$this->assert(
|
||||||
|
$this->gegensichtVorhanden($this->tagesgeld, $perZuordnung),
|
||||||
|
'Nachträglich zugeordnetes Gegenkonto erscheint im Gegenkonto'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Weg 3: über die Buchungsmaske.
|
||||||
|
$perSpeichern = Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'datum' => '2026-07-03',
|
||||||
|
'betrag' => -300.00,
|
||||||
|
'empfaenger' => 'Übertrag per Maske',
|
||||||
|
'kategorie_id' => $this->kategorie,
|
||||||
|
]);
|
||||||
|
Buchung::speichern($perSpeichern, [
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'datum' => '2026-07-03',
|
||||||
|
'betrag' => -300.00,
|
||||||
|
'empfaenger' => 'Übertrag per Maske',
|
||||||
|
'gegenkonto_id' => $this->tagesgeld,
|
||||||
|
]);
|
||||||
|
$this->assert(
|
||||||
|
$this->gegensichtVorhanden($this->tagesgeld, $perSpeichern),
|
||||||
|
'Über die Maske gesetztes Gegenkonto erscheint im Gegenkonto'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Weg 4: als einzelne Splitzeile über den Splitdialog.
|
||||||
|
$perSplit = Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'datum' => '2026-07-04',
|
||||||
|
'betrag' => -400.00,
|
||||||
|
'empfaenger' => 'Teils Einkauf, teils Rücklage',
|
||||||
|
'kategorie_id' => $this->kategorie,
|
||||||
|
]);
|
||||||
|
Buchung::splitsErsetzen($perSplit, [
|
||||||
|
['kategorie_id' => $this->kategorie, 'betrag' => -150.00, 'notiz' => 'Einkauf'],
|
||||||
|
['gegenkonto_id' => $this->tagesgeld, 'betrag' => -250.00, 'notiz' => 'Rücklage'],
|
||||||
|
]);
|
||||||
|
$splitZeile = Buchung::splits($perSplit)[1];
|
||||||
|
$this->assert(
|
||||||
|
$this->gegensichtVorhanden($this->tagesgeld, $perSplit, (int)$splitZeile['id']),
|
||||||
|
'Gegenkonto einer Splitzeile erscheint im Gegenkonto'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
4,
|
||||||
|
count($this->gegensichten($this->tagesgeld)),
|
||||||
|
'Alle vier Wege stehen im Kontoblatt des Gegenkontos'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
850.0,
|
||||||
|
Konto::saldo($this->tagesgeld),
|
||||||
|
'Und zwar mit umgekehrtem Vorzeichen: 100 + 200 + 300 + 250'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->pruefeInvariante('nach allen vier Wegen');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein gewechseltes Gegenkonto darf nicht auf dem alten hängenbleiben, ein
|
||||||
|
* entferntes nirgends mehr auftauchen.
|
||||||
|
*/
|
||||||
|
private function gegenkontoWechseltUndVerschwindet(): void
|
||||||
|
{
|
||||||
|
$id = Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'datum' => '2026-07-10',
|
||||||
|
'betrag' => -50.00,
|
||||||
|
'empfaenger' => 'Wanderndes Gegenkonto',
|
||||||
|
'gegenkonto_id' => $this->tagesgeld,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Buchung::zuordnen($id, null, $this->bargeld);
|
||||||
|
$this->assert(
|
||||||
|
!$this->gegensichtVorhanden($this->tagesgeld, $id),
|
||||||
|
'Nach dem Wechsel steht die Gegensicht nicht mehr beim alten Gegenkonto'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
$this->gegensichtVorhanden($this->bargeld, $id),
|
||||||
|
'Sondern beim neuen'
|
||||||
|
);
|
||||||
|
$this->assertEquals(50.0, Konto::saldo($this->bargeld), 'Der Saldo wandert mit');
|
||||||
|
|
||||||
|
// Umbuchung zurücknehmen: aus der Umbuchung wird eine Ausgabe.
|
||||||
|
Buchung::zuordnen($id, $this->kategorie, null);
|
||||||
|
$this->assert(
|
||||||
|
!$this->gegensichtVorhanden($this->bargeld, $id),
|
||||||
|
'Wird das Gegenkonto entfernt, verschwindet die Gegensicht'
|
||||||
|
);
|
||||||
|
$this->assertEquals(0.0, Konto::saldo($this->bargeld), 'Und der Saldo des Gegenkontos mit ihr');
|
||||||
|
|
||||||
|
$this->pruefeInvariante('nach Wechsel und Rücknahme des Gegenkontos');
|
||||||
|
|
||||||
|
Buchung::loeschen($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die einzige Konstellation, in der eine Umbuchung unsichtbar würde: oben
|
||||||
|
* ein Gegenkonto, gleichzeitig Splitzeilen. Kein Schreibweg darf sie
|
||||||
|
* herstellen – bei einer Splitbuchung steckt die Zuordnung in den Zeilen.
|
||||||
|
*/
|
||||||
|
private function splitbuchungVerstecktKeineUmbuchung(): void
|
||||||
|
{
|
||||||
|
// Beim Anlegen: Splitzeilen schlagen das Gegenkonto oben aus.
|
||||||
|
$id = Buchung::anlegen(
|
||||||
|
[
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'datum' => '2026-07-20',
|
||||||
|
'betrag' => -80.00,
|
||||||
|
'empfaenger' => 'Split trotz Gegenkonto',
|
||||||
|
'gegenkonto_id' => $this->tagesgeld,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
['kategorie_id' => $this->kategorie, 'betrag' => -80.00, 'notiz' => 'alles eine Zeile'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$buchung = Buchung::find($id);
|
||||||
|
$this->assertEquals(1, (int)$buchung['gesplittet'], 'Die Buchung ist gesplittet');
|
||||||
|
$this->assertNull($buchung['gegenkonto_id'], 'Beim Anlegen fällt das Gegenkonto oben weg');
|
||||||
|
$this->pruefeInvariante('nach dem Anlegen einer Splitbuchung mit Gegenkonto');
|
||||||
|
|
||||||
|
// Beim Speichern über die Maske ebenso.
|
||||||
|
Buchung::speichern(
|
||||||
|
$id,
|
||||||
|
[
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'datum' => '2026-07-20',
|
||||||
|
'betrag' => -80.00,
|
||||||
|
'empfaenger' => 'Split trotz Gegenkonto',
|
||||||
|
'gegenkonto_id' => $this->tagesgeld,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
['kategorie_id' => $this->kategorie, 'betrag' => -80.00, 'notiz' => 'alles eine Zeile'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$this->assertNull(Buchung::find($id)['gegenkonto_id'], 'Beim Speichern fällt es ebenfalls weg');
|
||||||
|
$this->pruefeInvariante('nach dem Speichern einer Splitbuchung mit Gegenkonto');
|
||||||
|
|
||||||
|
// Umgekehrt: Wer eine Splitbuchung zur Umbuchung macht, verliert die Zeilen.
|
||||||
|
Buchung::zuordnen($id, null, $this->tagesgeld);
|
||||||
|
$nachher = Buchung::find($id);
|
||||||
|
$this->assertEquals(0, (int)$nachher['gesplittet'], 'Die Zuordnung zur Umbuchung hebt den Split auf');
|
||||||
|
$this->assertEquals(0, count(Buchung::splits($id)), 'Die Splitzeilen sind fort');
|
||||||
|
$this->assert(
|
||||||
|
$this->gegensichtVorhanden($this->tagesgeld, $id),
|
||||||
|
'Und die Umbuchung steht im Gegenkonto'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Und wer aus der Umbuchung eine Splitbuchung macht, verliert das Gegenkonto.
|
||||||
|
Buchung::splitsErsetzen($id, [
|
||||||
|
['kategorie_id' => $this->kategorie, 'betrag' => -80.00, 'notiz' => 'wieder gesplittet'],
|
||||||
|
]);
|
||||||
|
$this->assertNull(Buchung::find($id)['gegenkonto_id'], 'Der Splitdialog räumt das Gegenkonto ab');
|
||||||
|
$this->assert(
|
||||||
|
!$this->gegensichtVorhanden($this->tagesgeld, $id),
|
||||||
|
'Damit ist die Gegensicht fort – sie hätte sonst kein Gegenüber'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->pruefeInvariante('nach dem Wechsel zwischen Split und Umbuchung');
|
||||||
|
|
||||||
|
Buchung::loeschen($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein gesetzter Filter schränkt das Kontoblatt ein – aber er darf die
|
||||||
|
* Gegenseite nicht anders behandeln als die eigenen Buchungen.
|
||||||
|
*/
|
||||||
|
private function filterVerstecktDieGegensichtNicht(): void
|
||||||
|
{
|
||||||
|
$id = Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'datum' => '2026-07-15',
|
||||||
|
'betrag' => -33.00,
|
||||||
|
'empfaenger' => 'Findbarer Übertrag',
|
||||||
|
'gegenkonto_id' => $this->tagesgeld,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$gefunden = Buchung::kontoblatt($this->tagesgeld, ['suche' => 'Findbarer']);
|
||||||
|
$this->assertEquals(1, count($gefunden), 'Die Volltextsuche findet die Gegensicht');
|
||||||
|
$this->assert($gefunden[0]['gegensicht'], 'Und kennzeichnet sie als solche');
|
||||||
|
$this->assertEquals(33.0, (float)$gefunden[0]['betrag'], 'Mit umgekehrtem Vorzeichen');
|
||||||
|
|
||||||
|
$imZeitraum = Buchung::kontoblatt($this->tagesgeld, ['von' => '2026-07-15', 'bis' => '2026-07-15']);
|
||||||
|
$this->assertEquals(1, count($imZeitraum), 'Der Zeitraumfilter greift auf dem Buchungsdatum');
|
||||||
|
|
||||||
|
Buchung::loeschen($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zusage 2: Die Gegenseite überlebt ihre Buchung nicht – weder im
|
||||||
|
* Kontoblatt noch im Saldo, weder als ganze Buchung noch als Splitzeile.
|
||||||
|
*/
|
||||||
|
private function loeschenNimmtDieGegenseiteMit(): void
|
||||||
|
{
|
||||||
|
$vorherGiro = Konto::saldo($this->giro);
|
||||||
|
$vorherSpar = Konto::saldo($this->tagesgeld);
|
||||||
|
$vorherBlatt = count(Buchung::kontoblatt($this->tagesgeld));
|
||||||
|
|
||||||
|
$umbuchung = Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'datum' => '2026-07-25',
|
||||||
|
'betrag' => -600.00,
|
||||||
|
'empfaenger' => 'Zu löschende Umbuchung',
|
||||||
|
'gegenkonto_id' => $this->tagesgeld,
|
||||||
|
]);
|
||||||
|
$mitSplit = Buchung::anlegen(
|
||||||
|
['konto_id' => $this->giro, 'datum' => '2026-07-26', 'betrag' => -700.00],
|
||||||
|
[
|
||||||
|
['kategorie_id' => $this->kategorie, 'betrag' => -300.00, 'notiz' => 'Einkauf'],
|
||||||
|
['gegenkonto_id' => $this->tagesgeld, 'betrag' => -400.00, 'notiz' => 'Rücklage'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$vorherBlatt + 2,
|
||||||
|
count(Buchung::kontoblatt($this->tagesgeld)),
|
||||||
|
'Beide Gegenseiten stehen im Gegenkonto'
|
||||||
|
);
|
||||||
|
|
||||||
|
Buchung::loeschen($umbuchung);
|
||||||
|
$this->assert(
|
||||||
|
!$this->gegensichtVorhanden($this->tagesgeld, $umbuchung),
|
||||||
|
'Mit der Buchung verschwindet ihre Gegenseite aus dem Gegenkonto'
|
||||||
|
);
|
||||||
|
|
||||||
|
Buchung::loeschen($mitSplit);
|
||||||
|
$this->assert(
|
||||||
|
!$this->gegensichtVorhanden($this->tagesgeld, $mitSplit),
|
||||||
|
'Auch die Gegenseite einer Splitzeile verschwindet mit der Buchung'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
0,
|
||||||
|
(int)Database::fetchValue('SELECT COUNT(*) FROM buchung_splits WHERE buchung_id = ?', [$mitSplit]),
|
||||||
|
'Die Splitzeile selbst bleibt nicht als Waise zurück'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals($vorherGiro, Konto::saldo($this->giro), 'Der Saldo des Ausgangskontos ist zurück');
|
||||||
|
$this->assertEquals($vorherSpar, Konto::saldo($this->tagesgeld), 'Der des Gegenkontos ebenso');
|
||||||
|
$this->assertEquals(
|
||||||
|
$vorherBlatt,
|
||||||
|
count(Buchung::kontoblatt($this->tagesgeld)),
|
||||||
|
'Und das Kontoblatt des Gegenkontos ist wieder so lang wie vorher'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->pruefeInvariante('nach dem Löschen');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Gegenseite ist keine eigene Buchung: Sie trägt die Nummer der
|
||||||
|
* Ursprungsbuchung. Ein Löschen „auf dem Gegenkonto“ trifft deshalb
|
||||||
|
* zwangsläufig das Original – eine halbe Umbuchung kann nicht entstehen.
|
||||||
|
* Das Kontoblatt bietet auf solchen Zeilen entsprechend keine Auswahl an
|
||||||
|
* (`gegensicht` steuert die Kästchen).
|
||||||
|
*/
|
||||||
|
private function gegenseiteIstNichtEinzelnLoeschbar(): void
|
||||||
|
{
|
||||||
|
$id = Buchung::anlegen([
|
||||||
|
'konto_id' => $this->giro,
|
||||||
|
'datum' => '2026-07-28',
|
||||||
|
'betrag' => -120.00,
|
||||||
|
'empfaenger' => 'Vom Gegenkonto aus gelöscht',
|
||||||
|
'gegenkonto_id' => $this->tagesgeld,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$gegenzeilen = array_values(array_filter(
|
||||||
|
$this->gegensichten($this->tagesgeld),
|
||||||
|
static fn (array $zeile): bool => (string)$zeile['empfaenger'] === 'Vom Gegenkonto aus gelöscht'
|
||||||
|
));
|
||||||
|
$this->assertEquals(1, count($gegenzeilen), 'Auf dem Gegenkonto steht genau eine Gegensicht dazu');
|
||||||
|
$this->assertEquals(
|
||||||
|
$id,
|
||||||
|
(int)$gegenzeilen[0]['id'],
|
||||||
|
'Sie trägt die Nummer der Ursprungsbuchung, keine eigene'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Genau diese Nummer bekäme ein Löschbefehl vom Gegenkonto aus in die Hand.
|
||||||
|
Buchung::loeschen((int)$gegenzeilen[0]['id']);
|
||||||
|
|
||||||
|
$this->assertNull(Buchung::find($id), 'Damit ist die Ursprungsbuchung fort');
|
||||||
|
$this->assert(
|
||||||
|
!$this->gegensichtVorhanden($this->tagesgeld, $id),
|
||||||
|
'Und auf dem Gegenkonto bleibt keine verwaiste Gegenseite stehen'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->pruefeInvariante('am Ende');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hilfen ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Kernzusage als Invariante über alle Daten dieses Mandanten: Zu jedem
|
||||||
|
* gesetzten Gegenkonto – ob an der Buchung oder an einer Splitzeile – gibt
|
||||||
|
* es im Kontoblatt jenes Kontos eine Gegensicht. Und keine Buchung trägt
|
||||||
|
* ein Gegenkonto, das durch `gesplittet = 1` verdeckt würde.
|
||||||
|
*/
|
||||||
|
private function pruefeInvariante(string $kontext): void
|
||||||
|
{
|
||||||
|
$verdeckt = (int)Database::fetchValue(
|
||||||
|
'SELECT COUNT(*) FROM buchungen
|
||||||
|
WHERE benutzer_id = ? AND gegenkonto_id IS NOT NULL AND gesplittet = 1',
|
||||||
|
[Mandant::id()]
|
||||||
|
);
|
||||||
|
$this->assertEquals(0, $verdeckt, "Kein Gegenkonto hinter einer Splitbuchung verdeckt ({$kontext})");
|
||||||
|
|
||||||
|
$umbuchungen = Database::fetchAll(
|
||||||
|
'SELECT id, gegenkonto_id FROM buchungen
|
||||||
|
WHERE benutzer_id = ? AND gegenkonto_id IS NOT NULL AND gesplittet = 0',
|
||||||
|
[Mandant::id()]
|
||||||
|
);
|
||||||
|
$splitzeilen = Database::fetchAll(
|
||||||
|
'SELECT s.id, s.gegenkonto_id, s.buchung_id FROM buchung_splits s
|
||||||
|
JOIN buchungen b ON b.id = s.buchung_id
|
||||||
|
WHERE b.benutzer_id = ? AND s.gegenkonto_id IS NOT NULL',
|
||||||
|
[Mandant::id()]
|
||||||
|
);
|
||||||
|
|
||||||
|
$fehlend = 0;
|
||||||
|
foreach ($umbuchungen as $zeile) {
|
||||||
|
if (!$this->gegensichtVorhanden((int)$zeile['gegenkonto_id'], (int)$zeile['id'])) {
|
||||||
|
$fehlend++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($splitzeilen as $zeile) {
|
||||||
|
$vorhanden = $this->gegensichtVorhanden(
|
||||||
|
(int)$zeile['gegenkonto_id'],
|
||||||
|
(int)$zeile['buchung_id'],
|
||||||
|
(int)$zeile['id']
|
||||||
|
);
|
||||||
|
if (!$vorhanden) {
|
||||||
|
$fehlend++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
0,
|
||||||
|
$fehlend,
|
||||||
|
"Jede Umbuchung steht im Kontoblatt ihres Gegenkontos ({$kontext})"
|
||||||
|
);
|
||||||
|
$this->assertGreaterThan(
|
||||||
|
0,
|
||||||
|
count($umbuchungen) + count($splitzeilen),
|
||||||
|
"Es gab überhaupt Umbuchungen zu prüfen ({$kontext})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Steht die Buchung (oder eine ihrer Splitzeilen) als Gegensicht im Kontoblatt? */
|
||||||
|
private function gegensichtVorhanden(int $kontoId, int $buchungId, ?int $splitId = null): bool
|
||||||
|
{
|
||||||
|
foreach ($this->gegensichten($kontoId) as $zeile) {
|
||||||
|
$passt = (int)$zeile['id'] === $buchungId
|
||||||
|
&& ($splitId === null
|
||||||
|
? $zeile['split_id'] === null
|
||||||
|
: (int)$zeile['split_id'] === $splitId);
|
||||||
|
if ($passt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string,mixed>> */
|
||||||
|
private function gegensichten(int $kontoId): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
Buchung::kontoblatt($kontoId),
|
||||||
|
static fn (array $zeile): bool => (bool)$zeile['gegensicht']
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue