Fünf Berichtstypen wie im FinanzManager – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht – mit Filterleiste, gespeicherten Berichten in Berichtsgruppen, Drilldown in die Buchungen und CSV-Export. Ein Bericht speichert nur seine Frage; die Zahlen entstehen bei jedem Aufruf neu aus den Buchungen. Gezählt werden Bewegungen (gesplittete Buchungen über ihre Splitzeilen), Umbuchungen bleiben außen vor, Unkategorisiertes bekommt eine eigene Zeile. Im Ausgabenabschnitt steht der Betrag ohne Minus wie im Original, die Schlusszeile rechnet mit den echten Vorzeichen. Einzelheiten in docs/auswertungen.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
637 lines
32 KiB
PHP
637 lines
32 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 $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();
|
||
|
||
$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<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');
|
||
}
|
||
|
||
// ── 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');
|
||
}
|
||
}
|