mandantAnlegen(); $this->stammdaten(); $this->buchungen(); $this->kachelauswahlMerktSich(); $this->monatsreiheTrenntEinnahmenUndAusgaben(); $this->monatsreiheLaesstUmbuchungenAus(); $this->verteilungFasstAufDerWurzelZusammen(); $this->verteilungKuerztAufDieGrenze(); $this->offeneVorgaengeZaehlenWasWartet(); $this->bargeldFehlerErscheintAlsOffenerVorgang(); $this->nichtAbgeglichenFuehrtInDenBuchungsbericht(); $this->legendenFuehrenZuDenBuchungen(); $this->vorgangspruefungenKennenJedenVorgang(); } private function vorgangspruefungenKennenJedenVorgang(): void { foreach (Cockpit::offeneVorgaenge(self::HEUTE) as $vorgang) { $this->assert( isset(Cockpit::VORGANGSPRUEFUNGEN[$vorgang['schluessel']]), 'Der Tooltip kennt die Prüfung „' . $vorgang['schluessel'] . '“' ); } $this->assertEquals(8, count(Cockpit::VORGANGSPRUEFUNGEN), 'Acht Prüfungen sind aufgelistet'); } private function stammdaten(): void { $this->giro = Konto::anlegen([ 'name' => 'Cockpitgiro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1, ]); $this->sparkonto = Konto::anlegen([ 'name' => 'Cockpitsparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1, ]); $this->gehalt = Kategorie::findenOderAnlegenNachPfad('100 - Einkommen:Gehalt', 'einnahme'); $this->wohnen = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen', 'ausgabe'); $this->miete = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Miete', 'ausgabe'); $this->strom = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Strom', 'ausgabe'); $this->freizeit = Kategorie::findenOderAnlegenNachPfad('500 - Freizeit', 'ausgabe'); } private function buchungen(): void { // Zwei Monate des laufenden Jahres, beide im Zwölfmonatsfenster. Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-07-01', 'betrag' => 3000, 'empfaenger' => 'Arbeitgeber', 'kategorie_id' => $this->gehalt, 'abgeglichen' => 1, ]); Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-07-03', 'betrag' => -900, 'empfaenger' => 'Vermieter', 'kategorie_id' => $this->miete, 'abgeglichen' => 1, ]); Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-07-15', 'betrag' => -100, 'empfaenger' => 'Stadtwerke', 'kategorie_id' => $this->strom, 'abgeglichen' => 1, ]); Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-08-01', 'betrag' => 3000, 'empfaenger' => 'Arbeitgeber', 'kategorie_id' => $this->gehalt, 'abgeglichen' => 1, ]); Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-08-05', 'betrag' => -200, 'empfaenger' => 'Kino', 'kategorie_id' => $this->freizeit, 'abgeglichen' => 1, ]); } private function kachelauswahlMerktSich(): void { $this->assertEquals( array_keys(Cockpit::KACHELN), Cockpit::sichtbar(), 'Ohne gespeicherte Auswahl sind alle Kacheln sichtbar' ); Cockpit::setzeSichtbar(['ausgaben', 'vermoegen', 'gibtesnicht']); $this->assertEquals( ['vermoegen', 'ausgaben'], Cockpit::sichtbar(), 'Gespeichert wird in Katalogreihenfolge, Unbekanntes fällt weg' ); $this->assert(Cockpit::zeigt('vermoegen'), 'Die gewählte Kachel wird gezeigt'); $this->assert(!Cockpit::zeigt('kontostaende'), 'Die abgewählte nicht'); // Alles abwählen muss möglich bleiben – sonst käme man aus einer leeren // Auswahl nie heraus, weil sie als „nie gespeichert“ gälte. Cockpit::setzeSichtbar([]); $this->assertEquals([], Cockpit::sichtbar(), 'Auch die leere Auswahl bleibt leer'); Cockpit::setzeSichtbar(array_keys(Cockpit::KACHELN)); } private function monatsreiheTrenntEinnahmenUndAusgaben(): void { $reihe = Cockpit::monatsreihe(self::HEUTE); $this->assertEquals(12, count($reihe['monate']), 'Zwölf Monatsspalten'); $this->assertEquals('2026-08', $reihe['monate'][11]['schluessel'], 'Die letzte Spalte ist der laufende Monat'); $this->assertEquals('2025-09', $reihe['monate'][0]['schluessel'], 'Die erste liegt elf Monate davor'); $this->assertEquals(3000.0, $reihe['einnahmen'][10], 'Juli: das Gehalt'); $this->assertEquals(1000.0, $reihe['ausgaben'][10], 'Juli: Miete und Strom, vorzeichenlos'); $this->assertEquals(2000.0, $reihe['salden'][10], 'Juli: Saldo ist Einnahmen minus Ausgaben'); $this->assertEquals(2800.0, $reihe['salden'][11], 'August: 3000 minus 200'); $this->assertEquals(6000.0, $reihe['summe_einnahmen'], 'Summe der Einnahmen über alle Monate'); $this->assertEquals(1200.0, $reihe['summe_ausgaben'], 'Summe der Ausgaben über alle Monate'); $this->assert($reihe['hatWerte'], 'Es gibt etwas zu zeichnen'); } /** Wie überall in den Auswertungen: Umbuchungen sind weder Einnahme noch Ausgabe. */ private function monatsreiheLaesstUmbuchungenAus(): void { $vorher = Cockpit::monatsreihe(self::HEUTE); Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-08-06', 'betrag' => -500, 'empfaenger' => 'Aufs Sparbuch', 'gegenkonto_id' => $this->sparkonto, ]); $nachher = Cockpit::monatsreihe(self::HEUTE); $this->assertEquals( $vorher['summe_ausgaben'], $nachher['summe_ausgaben'], 'Die Umbuchung taucht in den Ausgaben nicht auf' ); $this->assertEquals( $vorher['salden'][11], $nachher['salden'][11], 'Und ändert den Saldo des Monats nicht' ); } /** * Die Kachel zeigt oberste Kategorien: „300 - Wohnen“ statt Miete und Strom * einzeln – sonst hätte der Ring so viele Segmente wie es Kategorien gibt. */ private function verteilungFasstAufDerWurzelZusammen(): void { $verteilung = Cockpit::ausgaben(self::HEUTE); $namen = array_column($verteilung['zeilen'], 'betrag', 'name'); $this->assertEquals(1000.0, $namen['300 - Wohnen'] ?? 0.0, 'Miete und Strom stehen unter ihrer Wurzel'); $this->assertEquals(200.0, $namen['500 - Freizeit'] ?? 0.0, 'Freizeit hat keine Kinder und steht für sich'); $this->assert(!isset($namen['300 - Wohnen:Miete']), 'Unterkategorien haben kein eigenes Segment'); $this->assertEquals(1200.0, $verteilung['summe'], 'Die Summe sind alle Ausgaben des Jahres'); $this->assertEquals('ausgabe', $verteilung['art'], 'Gefragt war nach Ausgaben'); $this->assert(!$verteilung['leer'], 'Die Kachel hat etwas zu zeigen'); $anteile = 0.0; foreach ($verteilung['zeilen'] as $zeile) { $this->assertGreaterThan(0.0, $zeile['betrag'], 'Jedes Segment trägt einen positiven Betrag'); $anteile += $zeile['anteil']; } $this->assertEquals(1.0, round($anteile, 6), 'Die Anteile ergeben zusammen den ganzen Ring'); // Einnahmen gehen genauso, nur andersherum. $einnahmen = Auswertung::verteilung( ['typ' => 'summenbericht', 'zeitraum' => 'aktuelles_jahr'], 'einnahme', 8, self::HEUTE ); $this->assertEquals(6000.0, $einnahmen['summe'], 'Die Einnahmenseite zählt das Gehalt'); } private function verteilungKuerztAufDieGrenze(): void { for ($nummer = 1; $nummer <= 12; $nummer++) { $kategorie = Kategorie::findenOderAnlegenNachPfad('9' . $nummer . ' - Klein ' . $nummer, 'ausgabe'); Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-06-10', 'betrag' => -$nummer, 'empfaenger' => 'Kleinbetrag ' . $nummer, 'kategorie_id' => $kategorie, ]); } $verteilung = Auswertung::verteilung( ['typ' => 'summenbericht', 'zeitraum' => 'aktuelles_jahr'], 'ausgabe', 5, self::HEUTE ); $this->assertEquals(5, count($verteilung['zeilen']), 'Nicht mehr Segmente als die Grenze erlaubt'); $letzte = $verteilung['zeilen'][4]; $this->assert(str_starts_with($letzte['name'], 'Übrige ('), 'Das letzte Segment sammelt den Rest'); // Gekürzt wird die Darstellung, nicht die Summe. $gezeichnet = array_sum(array_column($verteilung['zeilen'], 'betrag')); $this->assertEquals( $verteilung['summe'], round($gezeichnet, 2), 'Die Segmente ergeben zusammen die Gesamtsumme' ); } private function offeneVorgaengeZaehlenWasWartet(): void { $schluessel = static fn(array $vorgaenge): array => array_column($vorgaenge, 'anzahl', 'schluessel'); $vorher = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE)); Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-08-07', 'betrag' => -42, 'empfaenger' => 'Unbekannt', ]); $nachher = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE)); $this->assertEquals( ($vorher['ohne_kategorie'] ?? 0) + 1, $nachher['ohne_kategorie'] ?? 0, 'Eine Buchung ohne Kategorie erscheint als offener Vorgang' ); $this->assertGreaterThan( 0, $nachher['nicht_abgeglichen'] ?? 0, 'Nicht abgeglichene Buchungen werden mitgezählt' ); // Umbuchungen brauchen keine Kategorie – sie sind keine offene Arbeit. $mitUmbuchung = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE)); $this->assertEquals( $nachher['ohne_kategorie'] ?? 0, $mitUmbuchung['ohne_kategorie'] ?? 0, 'Die Umbuchung von vorhin zählt nicht als unkategorisiert' ); foreach (Cockpit::offeneVorgaenge(self::HEUTE) as $vorgang) { $this->assertNotEmpty($vorgang['pfad'], 'Jeder Vorgang führt irgendwohin'); $this->assertNotEmpty($vorgang['text'], 'Und trägt einen Text'); } } /** * Eine Buchung, deren letzter Abgleich mit dem Bargeld-Tool gescheitert * ist, erscheint als eigener offener Vorgang – unabhängig vom Konto, denn * die Prüfung hängt an der Kategorie. */ private function bargeldFehlerErscheintAlsOffenerVorgang(): void { $schluessel = static fn(array $vorgaenge): array => array_column($vorgaenge, 'anzahl', 'schluessel'); $vorher = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE)); $this->assert( !isset($vorher['bargeld_fehler']), 'Ohne betroffene Buchung fehlt der Vorgang ganz' ); $id = Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-08-09', 'betrag' => -15, 'empfaenger' => 'Bargeldabhebung', 'kategorie_id' => $this->freizeit, 'abgeglichen' => 1, ]); Database::execute( "UPDATE buchungen SET bargeld_status = 'fehler', bargeld_hinweis = 'Kasse nicht erreichbar.' WHERE id = ?", [$id] ); $nachher = Cockpit::offeneVorgaenge(self::HEUTE); $eintrag = null; foreach ($nachher as $vorgang) { if ($vorgang['schluessel'] === 'bargeld_fehler') { $eintrag = $vorgang; } } $this->assertNotEmpty($eintrag, 'Der offene Vorgang erscheint jetzt'); $this->assertEquals(1, $eintrag['anzahl'], 'Genau die eine betroffene Buchung wird gezählt'); $this->assertEquals( '/auswertungen/bericht?typ=buchungsbericht&zeitraum=alles&umbuchungen=1&bargeld=fehler&angepasst=1', $eintrag['pfad'], 'Der Weg führt in den Buchungsbericht, gefiltert auf fehlgeschlagene Prüfungen (#131)' ); // Ein Status ungleich 'fehler' (z. B. 'gefunden') zählt nicht mit. Database::execute("UPDATE buchungen SET bargeld_status = 'gefunden' WHERE id = ?", [$id]); $ohne = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE)); $this->assert(!isset($ohne['bargeld_fehler']), 'Ein gefundener Treffer ist kein offener Vorgang mehr'); // Nicht gefunden: eigener Vorgang mit eigenem Filter im Buchungsbericht. Database::execute("UPDATE buchungen SET bargeld_status = 'fehlt' WHERE id = ?", [$id]); $fehlt = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE)); $this->assertEquals(1, $fehlt['bargeld_fehlt'] ?? 0, 'Eine im Bargeld-Tool nicht gefundene Buchung ist ein offener Vorgang'); $this->assert(!isset($fehlt['bargeld_fehler']), 'Nicht gefunden zählt nicht als gescheiterter Abruf'); $pfade = array_column(Cockpit::offeneVorgaenge(self::HEUTE), 'pfad', 'schluessel'); $this->assert( str_contains($pfade['bargeld_fehlt'] ?? '', 'typ=buchungsbericht') && str_contains($pfade['bargeld_fehlt'] ?? '', 'bargeld=fehlt'), 'Der Weg führt in den Buchungsbericht, gefiltert auf „nicht gefunden“ (#131)' ); Database::execute("UPDATE buchungen SET bargeld_status = 'fehler' WHERE id = ?", [$id]); } /** * „Buchungen nicht abgeglichen“ öffnet den Buchungsbericht, gefiltert auf * nicht abgeglichene Buchungen – und der zeigt genau die gezählten (#137). */ private function nichtAbgeglichenFuehrtInDenBuchungsbericht(): void { $pruefen = function (string $fall): void { $vorgang = null; foreach (Cockpit::offeneVorgaenge(self::HEUTE) as $eintrag) { if ($eintrag['schluessel'] === 'nicht_abgeglichen') { $vorgang = $eintrag; } } $this->assertNotEmpty($vorgang, $fall . ': Der Vorgang erscheint'); $this->assert(str_starts_with($vorgang['pfad'], '/auswertungen/bericht?'), $fall . ': Der Weg führt in den Buchungsbericht'); parse_str((string)parse_url($vorgang['pfad'], PHP_URL_QUERY), $filter); $this->assertEquals('buchungsbericht', $filter['typ'] ?? '', $fall . ': Typ Buchungsbericht'); $this->assertEquals('nein', $filter['abgeglichen'] ?? '', $fall . ': Gefiltert auf nicht abgeglichen'); $bericht = Auswertung::bericht($filter, self::HEUTE); $ids = []; foreach ($bericht['gruppen'] as $gruppe) { foreach ($gruppe['buchungen'] as $buchung) { $this->assert(!$buchung['abgeglichen'], $fall . ': Nur nicht abgeglichene Buchungen'); $ids[$buchung['buchung_id']] = true; } } $this->assertEquals($vorgang['anzahl'], count($ids), $fall . ': Der Bericht zeigt genau die gezählten Buchungen'); }; // Eine künftige Buchung zählt der Vorgang nicht – der Bericht auch nicht. Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-12-24', 'betrag' => -40, 'empfaenger' => 'Zukunft', 'kategorie_id' => $this->freizeit, ]); $pruefen('Aktive Konten'); // Ein aufgelöstes Konto zählt nicht mit – der Bericht beschränkt sich // dann auf die aktiven Konten. $alt = Konto::anlegen([ 'name' => 'Cockpitaltkonto', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 0, ]); Buchung::anlegen([ 'konto_id' => $alt, 'datum' => '2026-08-01', 'betrag' => -5, 'empfaenger' => 'Altlast', 'kategorie_id' => $this->freizeit, ]); $pruefen('Mit aufgelöstem Konto'); } /** * Jeder Legendeneintrag führt zu einer Buchungsliste, deren Summe genau * seinen Wert trifft – auch „Nicht kategorisiert“ und das Sammelsegment. */ private function legendenFuehrenZuDenBuchungen(): void { // Eine unkategorisierte Gutschrift: Sie ist eine Einnahme und darf in // der Ausgabenlegende „Nicht kategorisiert“ nicht auftauchen. Buchung::anlegen([ 'konto_id' => $this->giro, 'datum' => '2026-08-08', 'betrag' => 50, 'empfaenger' => 'Unbekannte Gutschrift', ]); $ausgaben = Cockpit::ausgaben(self::HEUTE); $namen = array_column($ausgaben['zeilen'], 'name'); $this->assert(in_array(Auswertung::OHNE_KATEGORIE, $namen, true), 'Die Kachel führt „Nicht kategorisiert“'); $this->assert( (bool)array_filter($namen, static fn(string $name): bool => str_starts_with($name, 'Übrige (')), 'Und ein Sammelsegment „Übrige“' ); foreach ($ausgaben['zeilen'] as $zeile) { $this->assertNotEmpty($zeile['pfad'] ?? '', 'Legendeneintrag „' . $zeile['name'] . '“ hat einen Weg'); $liste = $this->folge($zeile['pfad']); $this->assertEquals( -$zeile['betrag'], $liste['summe'], 'Die Buchungen hinter „' . $zeile['name'] . '“ ergeben den Legendenwert' ); $this->assertEquals('ausgabe', $liste['art'], 'Gefiltert wird auf Ausgaben'); } $ohne = null; foreach ($ausgaben['zeilen'] as $zeile) { if ($zeile['name'] === Auswertung::OHNE_KATEGORIE) { $ohne = $this->folge($zeile['pfad']); } } $empfaenger = array_column($ohne['buchungen'] ?? [], 'empfaenger'); $this->assert(in_array('Unbekannt', $empfaenger, true), 'Die unkategorisierte Ausgabe steht in der Liste'); $this->assert(!in_array('Unbekannte Gutschrift', $empfaenger, true), 'Die unkategorisierte Gutschrift nicht'); $wohnen = null; foreach ($ausgaben['zeilen'] as $zeile) { if ($zeile['name'] === '300 - Wohnen') { $wohnen = $this->folge($zeile['pfad']); } } $this->assertEquals(2, count($wohnen['buchungen'] ?? []), 'Die Wurzel zieht Miete und Strom mit'); // Einnahmen und Ausgaben: drei Legendeneinträge, drei Summen. $reihe = Cockpit::monatsreihe(self::HEUTE); $einnahmen = $this->folge($reihe['pfade']['einnahme']); $this->assertEquals($reihe['summe_einnahmen'], $einnahmen['summe'], 'Die Einnahmen-Liste trifft die Legende'); $ausgabenListe = $this->folge($reihe['pfade']['ausgabe']); $this->assertEquals(-$reihe['summe_ausgaben'], $ausgabenListe['summe'], 'Die Ausgaben-Liste trifft die Legende'); $saldo = $this->folge($reihe['pfade']['saldo']); $this->assertEquals( round($reihe['summe_einnahmen'] - $reihe['summe_ausgaben'], 2), $saldo['summe'], 'Die Saldo-Liste trifft die Legende' ); $this->assert( !in_array('Aufs Sparbuch', array_column($saldo['buchungen'], 'empfaenger'), true), 'Umbuchungen bleiben draußen wie in der Kachel' ); $this->assert( str_contains($reihe['pfade']['saldo'], 'herkunft=cockpit'), 'Der Weg zurück führt ins Cockpit' ); } /** * Ruft den Drilldown so auf, wie der Controller es mit dieser Adresse täte – * nur mit festem Stichtag. * * @return array */ private function folge(string $pfad): array { $this->assert(str_starts_with($pfad, '/auswertungen/buchungen?'), 'Der Weg führt in die Buchungsliste'); parse_str((string)parse_url($pfad, PHP_URL_QUERY), $query); return Auswertung::zeilenbuchungen( Auswertung::normalisiere($query), (string)($query['zeile'] ?? ''), (string)($query['spalte'] ?? ''), ($query['ast'] ?? '') === '1', self::HEUTE, (string)($query['art'] ?? '') ); } }