734 lines
37 KiB
PHP
734 lines
37 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Models\Bericht;
|
||
use Finanzmanager\Models\Berichtsgruppe;
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Budget;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Klasse;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Services\Auswertung;
|
||
use Finanzmanager\Services\BerichtExport;
|
||
use Finanzmanager\Services\Zeitraum;
|
||
|
||
class AuswertungTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 14: Auswertungen und Berichte';
|
||
|
||
/** Fester Stichtag – sonst hinge das Ergebnis am Tag des Testlaufs. */
|
||
private const HEUTE = '2026-08-11';
|
||
|
||
private int $giro = 0;
|
||
private int $spar = 0;
|
||
private int $leben = 0;
|
||
private int $einkauf = 0;
|
||
private int $freizeit = 0;
|
||
private int $gehalt = 0;
|
||
private int $ruecklage = 0;
|
||
private int $privat = 0;
|
||
private int $urlaub = 0;
|
||
|
||
public function run(): void
|
||
{
|
||
$this->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();
|
||
|
||
// Bucht auf dem Girokonto – nach dem Kontostandsbericht, damit dessen
|
||
// auf den Cent genaue Salden nicht mit verschoben werden.
|
||
$this->summenbericht_geldflussEntscheidet();
|
||
|
||
$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']);
|
||
|
||
// Als „Einnahme“ angelegt, aber wie eine Rückstellung gedacht: mal auf-,
|
||
// mal abgebaut. Genau der Fall, in dem Kategorie-Art und Geldfluss
|
||
// auseinanderfallen können.
|
||
$this->ruecklage = Kategorie::anlegen(['name' => 'Auswertungsrücklage', '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<string,mixed> $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<string,mixed>|null */
|
||
private function abschnitt(array $bericht, string $art): ?array
|
||
{
|
||
foreach ($bericht['abschnitte'] as $abschnitt) {
|
||
if ($abschnitt['schluessel'] === $art) {
|
||
return $abschnitt;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** @return array<string,mixed>|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');
|
||
}
|
||
|
||
/**
|
||
* „Auswertungsrücklage“ ist als Einnahme-Kategorie angelegt, wird hier
|
||
* aber in beide Richtungen bebucht – wie eine Rückstellung, die auf- und
|
||
* wieder abgebaut wird. Entscheidend ist der Geldfluss der einzelnen
|
||
* Bewegung, nicht die Art der Kategorie.
|
||
*/
|
||
private function summenbericht_geldflussEntscheidet(): void
|
||
{
|
||
// Splitbuchung: 25 Einkauf, 15 Aufbau der Rücklage – beides Ausgaben,
|
||
// obwohl die Rücklage als „Einnahme“-Kategorie angelegt ist.
|
||
Buchung::anlegen(
|
||
['konto_id' => $this->giro, 'datum' => '2026-06-10', 'betrag' => -40.0, 'empfaenger' => 'Finanzamt'],
|
||
[
|
||
['kategorie_id' => $this->einkauf, 'betrag' => -25.0, 'notiz' => 'Einkauf'],
|
||
['kategorie_id' => $this->ruecklage, 'betrag' => -15.0, 'notiz' => 'Rücklage aufbauen'],
|
||
]
|
||
);
|
||
|
||
// Auflösung eines Teils der Rücklage – hier ist es tatsächlich eine
|
||
// Einnahme.
|
||
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-06-20', 'betrag' => 6.0,
|
||
'empfaenger' => 'Finanzamt', 'kategorie_id' => $this->ruecklage]);
|
||
|
||
$bericht = Auswertung::bericht([
|
||
'typ' => 'summenbericht', 'zeitraum' => 'benutzerdefiniert',
|
||
'von' => '2026-06-01', 'bis' => '2026-06-30',
|
||
], self::HEUTE);
|
||
|
||
$ausgaben = $this->abschnitt($bericht, 'ausgabe');
|
||
$einnahmen = $this->abschnitt($bericht, 'einnahme');
|
||
|
||
$this->assertEquals(
|
||
15.0,
|
||
$this->zeile($ausgaben, 'Auswertungsrücklage')['summe'],
|
||
'Der negative Split zählt als Ausgabe, unabhängig von der Kategorie-Art'
|
||
);
|
||
$this->assertEquals(
|
||
6.0,
|
||
$this->zeile($einnahmen, 'Auswertungsrücklage')['summe'],
|
||
'Der positive Betrag auf derselben Kategorie zählt als Einnahme'
|
||
);
|
||
$this->assertEquals(40.0, $ausgaben['summe'], 'Ausgabensumme enthält Einkauf und Rücklagenaufbau');
|
||
$this->assertEquals(6.0, $einnahmen['summe'], 'Einnahmensumme enthält nur die Auflösung');
|
||
$this->assertEquals(-34.0, $bericht['gesamt']['summe'], 'Gesamtsumme rechnet mit den echten Vorzeichen');
|
||
|
||
// Der Drilldown filtert ebenfalls nach dem Geldfluss der Bewegung.
|
||
$filter = ['typ' => 'summenbericht', 'zeitraum' => 'benutzerdefiniert',
|
||
'von' => '2026-06-01', 'bis' => '2026-06-30'];
|
||
$alsAusgabe = Auswertung::zeilenbuchungen(
|
||
$filter, 'kategorie:' . $this->ruecklage, '', false, self::HEUTE, 'ausgabe'
|
||
);
|
||
$this->assertEquals(1, count($alsAusgabe['buchungen']), 'Nur der negative Split gehört zu den Ausgaben');
|
||
$this->assertEquals(-15.0, $alsAusgabe['summe'], 'Mit buchhalterischem Vorzeichen');
|
||
|
||
$alsEinnahme = Auswertung::zeilenbuchungen(
|
||
$filter, 'kategorie:' . $this->ruecklage, '', false, self::HEUTE, 'einnahme'
|
||
);
|
||
$this->assertEquals(1, count($alsEinnahme['buchungen']), 'Nur die Auflösung gehört zu den Einnahmen');
|
||
$this->assertEquals(6.0, $alsEinnahme['summe'], 'Die Einnahme behält ihr Vorzeichen');
|
||
}
|
||
|
||
// ── Weitere Berichtstypen ────────────────────────────────────────────────
|
||
|
||
private function buchungsbericht(): void
|
||
{
|
||
$bericht = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto']);
|
||
|
||
// Fünf einfache Buchungen minus Umbuchung macht vier, plus die
|
||
// Splitbuchung als eine zusammengefasste Zeile.
|
||
$this->assertEquals(5, $bericht['anzahl'], 'Bei Konto/Empfänger zählt eine Splitbuchung nur einmal');
|
||
$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');
|
||
|
||
$split = null;
|
||
foreach ($bericht['gruppen'][0]['buchungen'] as $buchung) {
|
||
if ($buchung['empfaenger'] === 'Rewe') {
|
||
$split = $buchung;
|
||
}
|
||
}
|
||
$this->assertEquals(-80.0, $split['betrag'],
|
||
'Die Splitbuchung zeigt ihren Gesamtbetrag, nicht nur die erste Splitzeile');
|
||
$this->assertEquals('', $split['kategorie'], 'Keine einzelne Kategorie bei der zusammengefassten Zeile');
|
||
$this->assert($split['split_gesamt'], 'Als zusammengefasste Splitbuchung markiert');
|
||
|
||
// Bei Kategorie/Klasse bleiben die Splitzeilen einzeln – sonst
|
||
// stimmten die Gruppensummen nicht mehr mit dem Summenbericht überein.
|
||
$nachKategorie = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'kategorie']);
|
||
$splitzeilen = 0;
|
||
foreach ($nachKategorie['gruppen'] as $gruppe) {
|
||
foreach ($gruppe['buchungen'] as $buchung) {
|
||
if ($buchung['empfaenger'] === 'Rewe') {
|
||
$splitzeilen++;
|
||
$this->assert(!$buchung['split_gesamt'], 'Nach Kategorie keine Zusammenfassung');
|
||
}
|
||
}
|
||
}
|
||
$this->assertEquals(2, $splitzeilen, 'Nach Kategorie erscheint jede Splitzeile einzeln');
|
||
|
||
// Mit Umbuchungen: eine Buchung und 200 € mehr.
|
||
$mit = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto', 'umbuchungen' => true]);
|
||
$this->assertEquals(6, $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');
|
||
}
|
||
}
|