mandantAnlegen(); $this->stammdaten(); $this->buchungen(); $this->zeitraumVorgabenRechnenAusHeute(); $this->spaltenTeilenDenZeitraum(); $this->vorjahrBleibtAufDemMonatsletzten(); $this->filterWirdGeprueft(); $this->summenbericht(); $this->summenbericht_umbuchungBleibtDraussen(); $this->summenbericht_astsummen(); $this->summenbericht_ohneEinrueckung(); $this->summenbericht_spalten(); $this->summenbericht_andereZeilensummen(); $this->summenbericht_einschraenkungen(); $this->buchungsbericht(); $this->vergleichsbericht(); $this->kontostandsbericht(); $this->budgetbericht(); $this->drilldown(); $this->csvExport(); $this->gespeicherteBerichte(); } // ── Aufbau ─────────────────────────────────────────────────────────────── private function stammdaten(): void { $this->giro = Konto::anlegen([ 'name' => 'Auswertungsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1, ]); $this->spar = Konto::anlegen([ 'name' => 'Auswertungssparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 500, 'aktiv' => 1, ]); $this->leben = Kategorie::anlegen(['name' => '400 - Auswertungsleben', 'art' => 'ausgabe']); $this->einkauf = Kategorie::anlegen(['name' => 'A-Einkaufen', 'art' => 'ausgabe', 'eltern_id' => $this->leben]); $this->freizeit = Kategorie::anlegen(['name' => 'A-Freizeit', 'art' => 'ausgabe', 'eltern_id' => $this->leben]); $this->gehalt = Kategorie::anlegen(['name' => 'Auswertungsgehalt', 'art' => 'einnahme']); $this->privat = Klasse::anlegen('Auswertung-Privat'); $this->urlaub = Klasse::anlegen('Auswertung-Urlaub'); } /** * Juli 2026: 3.000 Einnahmen, 237,50 Ausgaben (davon 7,50 ohne Kategorie), * dazu eine Umbuchung über 200 und ein Vorjahresfall im Juli 2025. */ private function buchungen(): void { Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-07-05', 'betrag' => -100.0, 'empfaenger' => 'Edeka', 'kategorie_id' => $this->einkauf, 'klasse_id' => $this->privat]); Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-07-20', 'betrag' => -50.0, 'empfaenger' => 'Kino', 'kategorie_id' => $this->freizeit, 'klasse_id' => $this->urlaub]); Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-07-31', 'betrag' => 3000.0, 'empfaenger' => 'Arbeitgeber', 'kategorie_id' => $this->gehalt]); // Umbuchung: kein Einnahme-/Ausgabevorgang. Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-07-15', 'betrag' => -200.0, 'empfaenger' => 'Sparen', 'gegenkonto_id' => $this->spar]); // Ohne Kategorie – muss als eigene Zeile erscheinen, nicht verschwinden. Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-07-18', 'betrag' => -7.5, 'empfaenger' => 'Unbekannt']); // Splitbuchung: zählt über ihre Zeilen, nicht doppelt. Buchung::anlegen( ['konto_id' => $this->giro, 'datum' => '2026-07-25', 'betrag' => -80.0, 'empfaenger' => 'Rewe'], [ ['kategorie_id' => $this->einkauf, 'betrag' => -60.0, 'notiz' => 'Lebensmittel'], ['kategorie_id' => $this->freizeit, 'betrag' => -20.0, 'notiz' => 'Zeitschrift'], ] ); // August, für die Spaltenaufteilung. Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-08-03', 'betrag' => -25.0, 'empfaenger' => 'Edeka', 'kategorie_id' => $this->einkauf]); // Vorjahr, für den Vergleichsbericht. Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2025-07-05', 'betrag' => -80.0, 'empfaenger' => 'Edeka', 'kategorie_id' => $this->einkauf]); } /** @param array $zusatz */ private function bericht(array $zusatz = []): array { return Auswertung::bericht($zusatz + [ 'zeitraum' => 'benutzerdefiniert', 'von' => '2026-07-01', 'bis' => '2026-07-31', ], self::HEUTE); } /** @return array|null */ private function abschnitt(array $bericht, string $art): ?array { foreach ($bericht['abschnitte'] as $abschnitt) { if ($abschnitt['schluessel'] === $art) { return $abschnitt; } } return null; } /** @return array|null */ private function zeile(array $abschnitt, string $name): ?array { foreach ($abschnitt['zeilen'] as $zeile) { if ($zeile['name'] === $name) { return $zeile; } } return null; } // ── Zeitraum ───────────────────────────────────────────────────────────── private function zeitraumVorgabenRechnenAusHeute(): void { $faelle = [ 'aktueller_monat' => ['2026-08-01', '2026-08-31'], 'letzter_monat' => ['2026-07-01', '2026-07-31'], 'aktuelles_quartal' => ['2026-07-01', '2026-09-30'], 'letztes_quartal' => ['2026-04-01', '2026-06-30'], 'aktuelles_jahr' => ['2026-01-01', '2026-12-31'], 'letztes_jahr' => ['2025-01-01', '2025-12-31'], 'letzte_12_monate' => ['2025-09-01', '2026-08-31'], ]; foreach ($faelle as $vorgabe => [$von, $bis]) { $z = Zeitraum::berechne($vorgabe, '', '', self::HEUTE); $this->assertEquals($von, $z['von'], "Zeitraum {$vorgabe} beginnt richtig"); $this->assertEquals($bis, $z['bis'], "Zeitraum {$vorgabe} endet richtig"); } $eigen = Zeitraum::berechne('benutzerdefiniert', '2026-03-01', '2026-03-15', self::HEUTE); $this->assertEquals('2026-03-01', $eigen['von'], 'Benutzerdefiniert übernimmt von'); $this->assertEquals('2026-03-15', $eigen['bis'], 'Benutzerdefiniert übernimmt bis'); // Verdrehte Eingaben werden getauscht, nicht abgewiesen. $verdreht = Zeitraum::berechne('benutzerdefiniert', '2026-03-15', '2026-03-01', self::HEUTE); $this->assertEquals('2026-03-01', $verdreht['von'], 'Verdrehter Zeitraum wird getauscht'); $unbekannt = Zeitraum::berechne('gibtesnicht', '', '', self::HEUTE); $this->assertEquals('aktuelles_jahr', $unbekannt['vorgabe'], 'Unbekannte Vorgabe fällt zurück'); // „Gesamter Zeitraum“ nimmt die Datumsgrenzen der Buchungen. $grenzen = Buchung::datumsgrenzen(); $this->assertEquals('2025-07-05', $grenzen['von'], 'Früheste Buchung erkannt'); $alles = Zeitraum::berechne('alles', '', '', self::HEUTE, $grenzen); $this->assertEquals('2025-07-05', $alles['von'], 'Gesamter Zeitraum beginnt bei der ersten Buchung'); $this->assertEquals(self::HEUTE, $alles['bis'], 'Gesamter Zeitraum endet heute, nicht in der Zukunft'); } private function spaltenTeilenDenZeitraum(): void { $monate = Zeitraum::spalten('monat', '2026-01-01', '2026-12-31'); $this->assertEquals(12, count($monate), 'Ein Jahr hat zwölf Monatsspalten'); $this->assertEquals('2026-01', $monate[0]['schluessel'], 'Erste Monatsspalte'); $this->assertEquals('2026-12-31', $monate[11]['bis'], 'Letzte Monatsspalte endet im Dezember'); $quartale = Zeitraum::spalten('quartal', '2026-01-01', '2026-12-31'); $this->assertEquals(4, count($quartale), 'Ein Jahr hat vier Quartalsspalten'); $this->assertEquals('2026-Q2', $quartale[1]['schluessel'], 'Zweites Quartal'); $jahre = Zeitraum::spalten('jahr', '2025-06-01', '2026-03-31'); $this->assertEquals(2, count($jahre), 'Zwei berührte Jahre, zwei Spalten'); // Die Randspalten werden auf den Zeitraum gestutzt. $teil = Zeitraum::spalten('monat', '2026-01-15', '2026-03-20'); $this->assertEquals('2026-01-15', $teil[0]['von'], 'Erste Spalte beginnt am Zeitraumanfang'); $this->assertEquals('2026-03-20', $teil[2]['bis'], 'Letzte Spalte endet am Zeitraumende'); $keine = Zeitraum::spalten('keine', '2026-01-01', '2026-12-31'); $this->assertEquals(1, count($keine), 'Ohne Aufteilung genau eine Spalte'); $this->assertEquals('gesamt', $keine[0]['schluessel'], 'Sammelspalte heißt gesamt'); $this->assertEquals('2026-07', Zeitraum::spaltenschluessel('monat', '2026-07-15'), 'Monatsschlüssel'); $this->assertEquals('2026-Q3', Zeitraum::spaltenschluessel('quartal', '2026-07-15'), 'Quartalsschlüssel'); $this->assertEquals('2026', Zeitraum::spaltenschluessel('jahr', '2026-07-15'), 'Jahresschlüssel'); } private function vorjahrBleibtAufDemMonatsletzten(): void { $normal = Zeitraum::vorjahr('2026-07-01', '2026-07-31'); $this->assertEquals('2025-07-01', $normal['von'], 'Vergleichszeitraum beginnt ein Jahr früher'); $this->assertEquals('2025-07-31', $normal['bis'], 'Vergleichszeitraum endet ein Jahr früher'); // Der 28.02. eines Normaljahres ist ein Monatsende und bleibt eines, // wenn das Vorjahr ein Schaltjahr war. $schalt = Zeitraum::vorjahr('2025-02-01', '2025-02-28'); $this->assertEquals('2024-02-29', $schalt['bis'], 'Monatsende bleibt Monatsende im Schaltjahr'); // Mitten im Monat wird nicht auf das Monatsende gezogen. $mitten = Zeitraum::vorjahr('2026-07-01', '2026-07-15'); $this->assertEquals('2025-07-15', $mitten['bis'], 'Ein Datum mitten im Monat bleibt es'); } private function filterWirdGeprueft(): void { $roh = Auswertung::normalisiere([ 'typ' => 'quatsch', 'zeilensumme' => 'quatsch', 'aufteilung' => 'quatsch', 'zeitraum' => 'quatsch', 'von' => 'kein Datum', 'konten' => ['3', 'x', 0, 3], 'unbekannt' => 'wird verworfen', ]); $this->assertEquals('summenbericht', $roh['typ'], 'Unbekannter Typ fällt auf Summenbericht'); $this->assertEquals('kategorie', $roh['zeilensumme'], 'Unbekannte Zeilen-Summe fällt auf Kategorie'); $this->assertEquals('keine', $roh['aufteilung'], 'Unbekannte Aufteilung fällt auf keine'); $this->assertEquals('', $roh['von'], 'Ungültiges Datum wird verworfen'); $this->assertEquals([3], $roh['konten'], 'Kontenliste wird bereinigt und entdoppelt'); $this->assert(!array_key_exists('unbekannt', $roh), 'Unbekannte Felder fallen weg'); // Ankreuzfelder: Das Formular schickt vor der Checkbox ein verstecktes // Feld mit „0“, sonst ließe sich ein Häkchen nie abwählen – ein nicht // angehaktes Feld sendet gar nichts und fiele auf die Vorgabe zurück. $this->assert(Auswertung::normalisiere([])['einrueckung'], 'Einrückung ist im Standard an'); $this->assert(!Auswertung::normalisiere(['einrueckung' => '0'])['einrueckung'], 'Eine abgewählte Einrückung bleibt abgewählt'); $this->assert(Auswertung::normalisiere(['einrueckung' => '1'])['einrueckung'], 'Ein gesetztes Häkchen wird übernommen'); $this->assert(!Auswertung::normalisiere([])['umbuchungen'], 'Umbuchungen sind im Standard aus'); // Zwei Berichte bestimmen ihre Zeilen selbst. $konto = Auswertung::normalisiere(['typ' => 'kontostandsbericht', 'zeilensumme' => 'empfaenger']); $this->assertEquals('konto', $konto['zeilensumme'], 'Kontostandsbericht führt immer Konten'); $budget = Auswertung::normalisiere(['typ' => 'budgetbericht', 'zeilensumme' => 'konto']); $this->assertEquals('kategorie', $budget['zeilensumme'], 'Budgetbericht führt immer Kategorien'); } // ── Summenbericht ──────────────────────────────────────────────────────── private function summenbericht(): void { $bericht = $this->bericht(['typ' => 'summenbericht']); $einnahmen = $this->abschnitt($bericht, 'einnahme'); $ausgaben = $this->abschnitt($bericht, 'ausgabe'); $this->assertEquals(3000.0, $einnahmen['summe'], 'Einnahmen des Juli'); $this->assertEquals(237.5, $ausgaben['summe'], 'Ausgaben des Juli, ohne Minus'); $this->assertEquals(2762.5, $bericht['gesamt']['summe'], 'Einnahmen minus Ausgaben'); // Die Splitzeilen zählen einzeln zu ihren Kategorien. $this->assertEquals(160.0, $this->zeile($ausgaben, 'A-Einkaufen')['summe'], 'Einkauf inklusive Splitzeile'); $this->assertEquals(70.0, $this->zeile($ausgaben, 'A-Freizeit')['summe'], 'Freizeit inklusive Splitzeile'); // Unkategorisiertes verschwindet nicht. $ohne = $this->zeile($ausgaben, Auswertung::OHNE_KATEGORIE); $this->assertNotEmpty($ohne, 'Zeile für unkategorisierte Buchungen ist da'); $this->assertEquals(7.5, $ohne['summe'], 'Unkategorisierte Ausgabe erscheint'); $this->assert($ohne['ohne'], 'Die Zeile ist als „ohne Zuordnung“ gekennzeichnet'); $this->assert(!$bericht['leer'], 'Der Bericht gilt nicht als leer'); $this->assertEquals('Summenbericht', $bericht['typname'], 'Typname steht im Bericht'); } private function summenbericht_umbuchungBleibtDraussen(): void { $bericht = $this->bericht(['typ' => 'summenbericht']); // 200 Umbuchung dürfen weder unter Ausgaben noch in der Gesamtsumme stehen. $this->assertEquals(237.5, $this->abschnitt($bericht, 'ausgabe')['summe'], 'Umbuchung zählt nicht als Ausgabe'); $konten = $this->bericht(['typ' => 'summenbericht', 'zeilensumme' => 'konto']); $this->assertNull($this->zeile($this->abschnitt($konten, 'ausgabe'), 'Auswertungssparbuch'), 'Das Zielkonto der Umbuchung taucht nicht auf'); } private function summenbericht_astsummen(): void { $ausgaben = $this->abschnitt($this->bericht(['typ' => 'summenbericht']), 'ausgabe'); $kopf = $this->zeile($ausgaben, '400 - Auswertungsleben:'); $this->assertNotEmpty($kopf, 'Die Elternkategorie steht als Kopfzeile da'); $this->assertEquals(0.0, $kopf['summe'], 'Die Kopfzeile zeigt nur eigene Buchungen – hier keine'); $this->assert($kopf['kopf'], 'Die Kopfzeile ist als solche gekennzeichnet'); $ast = $this->zeile($ausgaben, 'Gesamt 400 - Auswertungsleben'); $this->assertNotEmpty($ast, 'Nach den Kindern folgt die Astsumme'); $this->assertEquals(230.0, $ast['summe'], 'Die Astsumme zählt alle Nachfahren'); $this->assert($ast['astsumme'], 'Die Astsumme ist als solche gekennzeichnet'); // Reihenfolge: Kopf, Kinder, Astsumme. $namen = array_column($ausgaben['zeilen'], 'name'); $this->assert( array_search('400 - Auswertungsleben:', $namen, true) < array_search('A-Einkaufen', $namen, true), 'Die Kopfzeile steht vor ihren Kindern' ); $this->assert( array_search('A-Freizeit', $namen, true) < array_search('Gesamt 400 - Auswertungsleben', $namen, true), 'Die Astsumme steht hinter ihren Kindern' ); } private function summenbericht_ohneEinrueckung(): void { $ausgaben = $this->abschnitt( $this->bericht(['typ' => 'summenbericht', 'einrueckung' => false]), 'ausgabe' ); $namen = array_column($ausgaben['zeilen'], 'name'); $this->assert( in_array('400 - Auswertungsleben:A-Einkaufen', $namen, true), 'Ohne Einrückung steht der volle Pfad' ); $this->assert( !in_array('Gesamt 400 - Auswertungsleben', $namen, true), 'Ohne Einrückung gibt es keine Astsummen' ); $this->assertEquals(237.5, $ausgaben['summe'], 'Die Abschnittssumme bleibt gleich'); } private function summenbericht_spalten(): void { $bericht = Auswertung::bericht([ 'typ' => 'summenbericht', 'zeitraum' => 'benutzerdefiniert', 'von' => '2026-07-01', 'bis' => '2026-08-31', 'aufteilung' => 'monat', ], self::HEUTE); $this->assertEquals(2, count($bericht['spalten']), 'Zwei Monate, zwei Spalten'); $einkauf = $this->zeile($this->abschnitt($bericht, 'ausgabe'), 'A-Einkaufen'); $this->assertEquals(160.0, $einkauf['werte']['2026-07'], 'Juli-Wert in der Julispalte'); $this->assertEquals(25.0, $einkauf['werte']['2026-08'], 'August-Wert in der Augustspalte'); $this->assertEquals(185.0, $einkauf['summe'], 'Die Zeilensumme ist die Summe der Spalten'); } private function summenbericht_andereZeilensummen(): void { $klassen = $this->bericht(['typ' => 'summenbericht', 'zeilensumme' => 'klasse']); $ausgabe = $this->abschnitt($klassen, 'ausgabe'); $this->assertEquals(100.0, $this->zeile($ausgabe, 'Auswertung-Privat')['summe'], 'Klasse Privat'); $this->assertEquals(50.0, $this->zeile($ausgabe, 'Auswertung-Urlaub')['summe'], 'Klasse Urlaub'); $this->assertNotEmpty($this->zeile($ausgabe, Auswertung::OHNE_KLASSE), 'Buchungen ohne Klasse eigene Zeile'); $empfaenger = $this->bericht(['typ' => 'summenbericht', 'zeilensumme' => 'empfaenger']); $this->assertEquals(100.0, $this->zeile($this->abschnitt($empfaenger, 'ausgabe'), 'Edeka')['summe'], 'Empfänger Edeka'); $this->assertEquals(3000.0, $this->zeile($this->abschnitt($empfaenger, 'einnahme'), 'Arbeitgeber')['summe'], 'Empfänger Arbeitgeber'); $konten = $this->bericht(['typ' => 'summenbericht', 'zeilensumme' => 'konto']); $this->assertEquals(237.5, $this->zeile($this->abschnitt($konten, 'ausgabe'), 'Auswertungsgiro')['summe'], 'Alle Ausgaben auf dem Giro'); } private function summenbericht_einschraenkungen(): void { // Eine Elternkategorie schließt ihre Nachfahren ein. $ast = $this->bericht(['typ' => 'summenbericht', 'kategorien' => [$this->leben]]); $this->assertEquals(230.0, $this->abschnitt($ast, 'ausgabe')['summe'], 'Der Kategoriefilter nimmt die Unterkategorien mit'); $eine = $this->bericht(['typ' => 'summenbericht', 'kategorien' => [$this->freizeit]]); $this->assertEquals(70.0, $this->abschnitt($eine, 'ausgabe')['summe'], 'Eine Unterkategorie allein zählt nur sich'); $klasse = $this->bericht(['typ' => 'summenbericht', 'klassen' => [$this->privat]]); $this->assertEquals(100.0, $this->abschnitt($klasse, 'ausgabe')['summe'], 'Klassenfilter greift'); $konto = $this->bericht(['typ' => 'summenbericht', 'konten' => [$this->spar]]); $this->assert($konto['leer'], 'Auf dem Sparbuch gibt es keine kategorisierten Buchungen'); $suche = $this->bericht(['typ' => 'summenbericht', 'suche' => 'Edeka']); $this->assertEquals(100.0, $this->abschnitt($suche, 'ausgabe')['summe'], 'Die Suche filtert auf den Empfänger'); } // ── Weitere Berichtstypen ──────────────────────────────────────────────── private function buchungsbericht(): void { $bericht = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto']); // Fünf einfache Buchungen minus Umbuchung, plus zwei Splitzeilen. $this->assertEquals(6, $bericht['anzahl'], 'Splitzeilen zählen einzeln, die Umbuchung gar nicht'); $this->assertEquals(2762.5, $bericht['summe'], 'Die Summe entspricht dem Summenbericht'); $this->assertEquals(1, count($bericht['gruppen']), 'Alles auf einem Konto, also eine Gruppe'); $erste = $bericht['gruppen'][0]['buchungen'][0]; $this->assertEquals('2026-07-05', $erste['datum'], 'Chronologisch sortiert'); $this->assertEquals('400 - Auswertungsleben:A-Einkaufen', $erste['kategorie'], 'Kategorie als voller Pfad'); // Mit Umbuchungen: eine Buchung und 200 € mehr. $mit = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto', 'umbuchungen' => true]); $this->assertEquals(7, $mit['anzahl'], 'Mit Schalter kommt die Umbuchung dazu'); $this->assertEquals(2562.5, $mit['summe'], 'Die Umbuchung verschiebt die Summe'); $umbuchung = null; foreach ($mit['gruppen'][0]['buchungen'] as $buchung) { if ($buchung['empfaenger'] === 'Sparen') { $umbuchung = $buchung; } } $this->assertEquals('[Auswertungssparbuch]', $umbuchung['kategorie'], 'Die Umbuchung nennt ihr Gegenkonto in Klammern'); } private function vergleichsbericht(): void { $bericht = $this->bericht(['typ' => 'vergleichsbericht']); $this->assertEquals(2, count($bericht['spalten']), 'Zeitraum und Vorjahr'); $this->assertEquals('2025-07-01', $bericht['spalten'][1]['von'], 'Die Vergleichsspalte ist der Vorjahresmonat'); $ausgaben = $this->abschnitt($bericht, 'ausgabe'); $this->assertEquals(237.5, $ausgaben['werte']['jetzt'], 'Ausgaben im Zeitraum'); $this->assertEquals(80.0, $ausgaben['werte']['vorher'], 'Ausgaben im Vorjahr'); $this->assertEquals(157.5, $ausgaben['differenz'], 'Differenz beider Spalten'); $this->assertEquals(196.9, $ausgaben['prozent'], 'Veränderung in Prozent'); // Ohne Vorjahreswert gibt es keinen Prozentsatz. $einnahmen = $this->abschnitt($bericht, 'einnahme'); $this->assertEquals(0.0, $einnahmen['werte']['vorher'], 'Im Vorjahr keine Einnahmen'); $this->assertNull($einnahmen['prozent'], 'Ohne Vorjahreswert kein Prozentsatz'); } private function kontostandsbericht(): void { $bericht = Auswertung::bericht([ 'typ' => 'kontostandsbericht', 'zeitraum' => 'benutzerdefiniert', 'von' => '2026-07-01', 'bis' => '2026-08-31', 'aufteilung' => 'monat', ], self::HEUTE); $giro = null; $spar = null; foreach ($bericht['zeilen'] as $zeile) { if ($zeile['name'] === 'Auswertungsgiro') { $giro = $zeile; } if ($zeile['name'] === 'Auswertungssparbuch') { $spar = $zeile; } } // 1000 Anfangssaldo, −80 aus 2025, +2562,50 im Juli. $this->assertEquals(3482.5, $giro['werte']['2026-07'], 'Girostand am Monatsende Juli'); $this->assertEquals(3457.5, $giro['werte']['2026-08'], 'Girostand nach dem August'); // Die Gegenseite der Umbuchung ist dabei, obwohl sie auf dem Girokonto gebucht wurde. $this->assertEquals(700.0, $spar['werte']['2026-07'], 'Das Sparbuch bekommt die Umbuchung gutgeschrieben'); $this->assertEquals(4157.5, $bericht['gesamt']['werte']['2026-08'], 'Die Summe aller gezeigten Konten'); } private function budgetbericht(): void { $budget = Budget::anlegen('Auswertungsbudget 2026', 2026, '', true); Budget::positionSetzen($budget, $this->einkauf, 7, 120.0); Budget::positionSetzen($budget, $this->freizeit, 7, 100.0); $bericht = $this->bericht(['typ' => 'budgetbericht']); $zeilen = []; foreach ($bericht['zeilen'] as $zeile) { $zeilen[$zeile['name']] = $zeile; } // Vorzeichen wie im Budgetplaner: Ausgaben negativ. $this->assertEquals(-120.0, $zeilen['A-Einkaufen']['budget'], 'Budget der Einkaufskategorie'); $this->assertEquals(-160.0, $zeilen['A-Einkaufen']['ist'], 'Ist der Einkaufskategorie'); $this->assertEquals(-40.0, $zeilen['A-Einkaufen']['saldo'], 'Überschreitung ist negativ'); $this->assertEquals(30.0, $zeilen['A-Freizeit']['saldo'], 'Unterschreitung ist positiv'); // Die Elternkategorie summiert ihren Ast. $this->assertEquals(-220.0, $zeilen['400 - Auswertungsleben']['budget'], 'Astsumme des Budgets'); $this->assertEquals(-230.0, $zeilen['400 - Auswertungsleben']['ist'], 'Astsumme des Ist-Werts'); $this->assertEquals('Jul 2026', $bericht['monatsbereich'], 'Der Monatsbereich steht im Bericht'); $this->assertNotEmpty($bericht['budgets'], 'Das Budget des Jahres wurde gefunden'); // Ein Zeitraum mitten im Monat wird für den Vergleich auf volle Monate gezogen. $halb = Auswertung::bericht([ 'typ' => 'budgetbericht', 'zeitraum' => 'benutzerdefiniert', 'von' => '2026-07-10', 'bis' => '2026-07-20', ], self::HEUTE); $halbeZeilen = []; foreach ($halb['zeilen'] as $zeile) { $halbeZeilen[$zeile['name']] = $zeile; } $this->assertEquals(-160.0, $halbeZeilen['A-Einkaufen']['ist'], 'Der Budgetbericht rechnet über volle Monate, nicht über Teilzeiträume'); } // ── Drilldown und Export ───────────────────────────────────────────────── private function drilldown(): void { $filter = ['typ' => 'summenbericht', 'zeitraum' => 'benutzerdefiniert', 'von' => '2026-07-01', 'bis' => '2026-07-31']; $einkauf = Auswertung::zeilenbuchungen($filter, 'kategorie:' . $this->einkauf, '', false, self::HEUTE); $this->assertEquals(2, count($einkauf['buchungen']), 'Zwei Buchungen in der Einkaufskategorie'); $this->assertEquals(-160.0, $einkauf['summe'], 'Mit buchhalterischem Vorzeichen'); $this->assertEquals('400 - Auswertungsleben:A-Einkaufen', $einkauf['name'], 'Der Zeilenname als Überschrift'); // Die Astsumme zieht die Unterkategorien mit. $ast = Auswertung::zeilenbuchungen($filter, 'kategorie:' . $this->leben, '', true, self::HEUTE); $this->assertEquals(4, count($ast['buchungen']), 'Der Ast enthält alle Buchungen der Unterkategorien'); $this->assertEquals(-230.0, $ast['summe'], 'Die Astsumme stimmt mit dem Bericht überein'); // Was kein Filter ausdrücken könnte: die Zeile „Nicht kategorisiert“. $ohne = Auswertung::zeilenbuchungen($filter, 'kategorie:0', '', false, self::HEUTE); $this->assertEquals(1, count($ohne['buchungen']), 'Eine unkategorisierte Buchung'); $this->assertEquals(Auswertung::OHNE_KATEGORIE, $ohne['name'], 'Überschrift für die Zeile ohne Zuordnung'); // Eine Spalte schränkt den Zeitraum ein. $august = Auswertung::zeilenbuchungen( ['aufteilung' => 'monat', 'von' => '2026-07-01', 'bis' => '2026-08-31'] + $filter, 'kategorie:' . $this->einkauf, '2026-08', false, self::HEUTE ); $this->assertEquals(1, count($august['buchungen']), 'Nur die Augustbuchung'); $this->assertEquals(-25.0, $august['summe'], 'Der Wert der Augustzelle'); } private function csvExport(): void { $bericht = $this->bericht(['typ' => 'summenbericht']); $csv = BerichtExport::csv($bericht); $this->assert(str_starts_with($csv, "\u{FEFF}"), 'Das CSV beginnt mit einem BOM'); $this->assert(str_contains($csv, "\r\n"), 'Zeilenenden sind CRLF'); $this->assert(str_contains($csv, 'A-Einkaufen;160,00'), 'Dezimalkomma und Semikolon'); $this->assert(str_contains($csv, '01.07.2026 bis 31.07.2026 (in Euro)'), 'Der Zeitraum steht im Kopf'); $this->assert(str_contains($csv, 'Gesamt Einnahmen − Ausgaben;2762,50'), 'Die Schlusszeile ist dabei'); // Ein Feld mit Semikolon muss maskiert werden, sonst zerfällt die Zeile. // Bewusst ohne Kategorie: So verändert die Buchung keinen Kategorieast, // den ein späterer Test noch prüft. Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-07-06', 'betrag' => -5.0, 'empfaenger' => 'Müller; Söhne']); $mitTrenner = BerichtExport::csv($this->bericht(['typ' => 'summenbericht', 'zeilensumme' => 'empfaenger'])); $this->assert(str_contains($mitTrenner, '"Müller; Söhne"'), 'Semikolon im Feld wird maskiert'); $this->assertEquals( 'Summenbericht-2026-07-01.csv', BerichtExport::dateiname($bericht), 'Der Dateiname trägt Typ und Beginn des Zeitraums' ); $buchungen = BerichtExport::csv($this->bericht(['typ' => 'buchungsbericht'])); $this->assert(str_contains($buchungen, 'Datum;Konto;Empfänger'), 'Der Buchungsbericht hat eigene Spalten'); } // ── Gespeicherte Berichte ──────────────────────────────────────────────── private function gespeicherteBerichte(): void { $gruppe = Berichtsgruppe::anlegen('Auswertung-Bankgeschäfte'); $id = Bericht::anlegen('Auswertung-Lebenshaltung', [ 'typ' => 'summenbericht', 'zeitraum' => 'letzter_monat', 'aufteilung' => 'monat', 'kategorien' => [$this->leben], ], $gruppe); $gespeichert = Bericht::find($id); $this->assertEquals('summenbericht', $gespeichert['typ'], 'Der Typ steht auch in der eigenen Spalte'); $filter = Bericht::filter($gespeichert); $this->assertEquals('letzter_monat', $filter['zeitraum'], 'Der Zeitraum kommt zurück'); $this->assertEquals([$this->leben], $filter['kategorien'], 'Die Kategorieauswahl kommt zurück'); // Ein gespeicherter Zeitraum rechnet sich beim Öffnen neu aus. $bericht = Auswertung::bericht($filter, self::HEUTE); $this->assertEquals('2026-07-01', $bericht['zeitraum']['von'], '„Letzter Monat“ meint im August den Juli'); $this->assertEquals(230.0, $this->abschnitt($bericht, 'ausgabe')['summe'], 'Der gespeicherte Filter liefert dieselben Zahlen'); // Namen sind eindeutig. $doppelt = false; try { Bericht::anlegen('Auswertung-Lebenshaltung', ['typ' => 'summenbericht']); } catch (\RuntimeException) { $doppelt = true; } $this->assert($doppelt, 'Zwei Berichte gleichen Namens gehen nicht'); $nachGruppe = Bericht::nachGruppe(); $this->assertEquals(1, count($nachGruppe[$gruppe]['berichte']), 'Der Bericht hängt in seiner Gruppe'); // Beim Löschen der Gruppe bleibt der Bericht erhalten. Berichtsgruppe::loeschen($gruppe); $this->assertNull(Bericht::find($id)['gruppe_id'], 'Der Bericht verliert nur seine Gruppe'); $this->assertNotEmpty(Bericht::find($id), 'Der Bericht selbst bleibt'); // „Letzte Berichte“ füllt sich erst beim Öffnen. $this->assertEquals(0, count(Bericht::letzte()), 'Vor dem Öffnen ist die Liste leer'); Bericht::benutzt($id); $this->assertEquals(1, count(Bericht::letzte()), 'Nach dem Öffnen steht er in der Liste'); Bericht::loeschen($id); $this->assertNull(Bericht::find($id), 'Gelöscht ist gelöscht'); } }