From a2c02b224eaad242cea1e46db0bb02d284768169 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 28 Sep 2026 08:59:52 +0200 Subject: [PATCH] Fix n.n.: Einnahme/Ausgabe entscheidet der Geldfluss, nicht die Kategorie-Art Co-Authored-By: Claude Sonnet 5 --- Changes.md | 23 ++++++++++++ src/Services/Auswertung.php | 72 ++++++++++++++++--------------------- tests/14_AuswertungTest.php | 71 ++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 42 deletions(-) diff --git a/Changes.md b/Changes.md index 2d6b12b..2826323 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,28 @@ # Änderungen +## Einnahme/Ausgabe entscheidet der Geldfluss, nicht die Kategorie-Art (28.09.2026) + +Cockpit und Berichte zählten eine Bewegung nach der Art ihrer Kategorie zu Einnahmen +oder Ausgaben – nicht nach ihrem Vorzeichen. Eine Kategorie wie eine Rückstellung, die +in beide Richtungen bebucht wird (z. B. „010 - Rückst. USt“, angelegt als Einnahme), +ließ dadurch auch negative Beträge in den Einnahmen auftauchen (n.n., Buchung 29285). + +- `Auswertung::artDerBewegung()` entscheidet jetzt allein über das Vorzeichen des + Betrags. Die zugewiesene Kategorie-Art spielt für die Auswertung keine Rolle mehr – + sie bleibt reine Verwaltungs-Metadaten (Reiter in der Kategorienverwaltung, + Vorzeichen im Budgetplaner). +- `Auswertung::kategoriezeilen()` baut das Zeilengerüst von Summen- und + Vergleichsbericht jetzt aus allen Kategorien statt nur denen der passenden Art – + sonst wäre eine Bewegung wie oben zwar in der Abschnittssumme, aber in keiner Zeile + aufgetaucht. +- `Auswertung::budgetbericht()` summiert den Ist-Wert einer Kategorie jetzt aus beiden + Töpfen (Einnahme und Ausgabe), da eine Kategorie nun in beiden auftauchen kann. +- Betroffen waren dadurch alle Cockpit-Kacheln, die auf dem Summenbericht aufsetzen + (*Einnahmen und Ausgaben*, *Meine Ausgaben*) sowie deren Legenden-Drilldown, + Summen-, Vergleichs- und Budgetbericht unter *Auswertungen*. +- Neuer Test `summenbericht_geldflussEntscheidet()` in `tests/14_AuswertungTest.php` + mit einer Kategorie, die in beide Richtungen bebucht wird. + ## Cockpit: Legendeneinträge öffnen die Buchungen dahinter (28.09.2026) In allen Cockpit-Kacheln mit Legende führt ein Klick auf einen Legendeneintrag zu den diff --git a/src/Services/Auswertung.php b/src/Services/Auswertung.php index 815abd4..646793e 100644 --- a/src/Services/Auswertung.php +++ b/src/Services/Auswertung.php @@ -34,6 +34,13 @@ use Finanzmanager\View; * Erst beim Zusammenstellen der Abschnitte dreht der Ausgabenabschnitt sein * Vorzeichen, damit dort – wie im FinanzManager – 636,39 statt −636,39 steht. * Die Schlusszeile „Einnahmen − Ausgaben“ rechnet wieder mit den echten Werten. + * + * **Einnahme oder Ausgabe entscheidet allein der Geldfluss**, also das + * Vorzeichen der Bewegung – nicht die Art der zugewiesenen Kategorie. Die + * Kategorie-Art ist reine Verwaltungs-Metadaten (Reiter in der + * Kategorienverwaltung, Vorzeichen im Budgetplaner) und wirkt sich auf + * Berichte und Cockpit nicht mehr aus. So bleibt etwa eine Rückstellung, die + * in beide Richtungen bebucht wird, in jeder Richtung an der richtigen Stelle. */ class Auswertung { @@ -200,13 +207,12 @@ class Auswertung $filter['von'] = $zeitraum['von']; $filter['bis'] = $zeitraum['bis']; - $arten = self::kategorienArten(); $wurzeln = self::wurzeln(); $faktor = $art === 'ausgabe' ? -1.0 : 1.0; $summen = []; foreach (self::bewegungen($filter) as $bewegung) { - if (self::artDerBewegung($bewegung, $arten) !== $art) { + if (self::artDerBewegung($bewegung) !== $art) { continue; } @@ -369,7 +375,7 @@ class Auswertung $faktor = $art === 'ausgabe' ? -1.0 : 1.0; $zeilen = $filter['zeilensumme'] === 'kategorie' - ? self::kategoriezeilen($art, $werte, $filter, $spalten, $faktor) + ? self::kategoriezeilen($werte, $filter, $spalten, $faktor) : self::flacheZeilen($werte, $filter, $spalten, $faktor); $summe = self::leereReihe($spalten); @@ -395,14 +401,20 @@ class Auswertung * „Gesamt “ mit der Summe des Astes. Genau so sieht der Summenbericht * des FinanzManagers aus. * + * Der Baum läuft über *alle* Kategorien, unabhängig von ihrer eigenen Art: + * Einnahme und Ausgabe entscheidet die einzelne Bewegung, nicht die + * Kategorie. Äste ohne Bewegung dieser Art bleiben einfach leer und fallen + * weiter unten heraus – eine sonst als Ausgabe geführte Kategorie kann so + * mit einer einzelnen Gutschrift trotzdem im Einnahmen-Abschnitt auftauchen. + * * @param array> $werte * @param array $filter * @param list> $spalten * @return list> */ - private static function kategoriezeilen(string $art, array $werte, array $filter, array $spalten, float $faktor): array + private static function kategoriezeilen(array $werte, array $filter, array $spalten, float $faktor): array { - $baum = Kategorie::baumFlach($art); + $baum = Kategorie::baumFlach(); // Astsummen von unten nach oben: Erst die eigenen Werte, dann trägt // jede Kategorie ihre Summe an die Elternkategorie hoch. @@ -720,12 +732,10 @@ class Auswertung } } - $arten = self::kategorienArten(); - $bewegungen = array_values(array_filter( self::bewegungen($filter), - static function (array $bewegung) use ($dimension, $wert, $astIds, $alle, $art, $arten): bool { - if ($art !== '' && self::artDerBewegung($bewegung, $arten) !== $art) { + static function (array $bewegung) use ($dimension, $wert, $astIds, $alle, $art): bool { + if ($art !== '' && self::artDerBewegung($bewegung) !== $art) { return false; } if ($alle) { @@ -1079,13 +1089,15 @@ class Auswertung $gesamt = ['budget' => 0.0, 'ist' => 0.0]; // Astsummen: Eine Elternkategorie zeigt, was ihr Zweig geplant und - // verbraucht hat – genau wie im Budgetplaner. + // verbraucht hat – genau wie im Budgetplaner. Das Ist steht je nach + // Vorzeichen der einzelnen Bewegung im Einnahme- oder Ausgabe-Topf; + // eine Kategorie kann durchaus in beiden stehen. $astBudget = []; $astIst = []; foreach ($baum as $kategorie) { $id = (int)$kategorie['id']; $astBudget[$id] = $summenBudget[$id] ?? 0.0; - $astIst[$id] = $ist[self::art($kategorie)][$id]['ist'] ?? 0.0; + $astIst[$id] = ($ist['einnahme'][$id]['ist'] ?? 0.0) + ($ist['ausgabe'][$id]['ist'] ?? 0.0); } foreach (array_reverse($baum) as $kategorie) { $eltern = $kategorie['eltern_id'] === null ? 0 : (int)$kategorie['eltern_id']; @@ -1225,7 +1237,6 @@ class Auswertung */ private static function verteile(array $bewegungen, array $filter, array $spalten): array { - $arten = self::kategorienArten(); $aufteilung = (string)$filter['aufteilung']; $gueltig = array_flip(array_column($spalten, 'schluessel')); @@ -1236,7 +1247,7 @@ class Auswertung continue; } - $art = self::artDerBewegung($zeile, $arten); + $art = self::artDerBewegung($zeile); $schluessel = self::zeilenschluessel($zeile, (string)$filter['zeilensumme']); $roh[$art][$schluessel][$spalte] = ($roh[$art][$schluessel][$spalte] ?? 0.0) + (float)$zeile['betrag']; @@ -1256,11 +1267,9 @@ class Auswertung */ private static function verteileAuf(array $bewegungen, array $filter, string $spalte): array { - $arten = self::kategorienArten(); - $roh = []; foreach ($bewegungen as $zeile) { - $art = self::artDerBewegung($zeile, $arten); + $art = self::artDerBewegung($zeile); $schluessel = self::zeilenschluessel($zeile, (string)$filter['zeilensumme']); $roh[$art][$schluessel][$spalte] = ($roh[$art][$schluessel][$spalte] ?? 0.0) + (float)$zeile['betrag']; @@ -1270,20 +1279,15 @@ class Auswertung } /** - * Einnahme oder Ausgabe? Es entscheidet die *Art der Kategorie*, nicht das - * Vorzeichen: Eine Erstattung ist eine negative Ausgabe und gehört unter - * die Ausgaben, nicht unter die Einnahmen. Nur wo keine Kategorie steht, - * bleibt das Vorzeichen als einziger Anhaltspunkt. + * Einnahme oder Ausgabe? Es entscheidet allein der Geldfluss, also das + * Vorzeichen der Bewegung – nicht die Art der zugewiesenen Kategorie. Eine + * Kategorie wie eine Rückstellung kann in beide Richtungen bebucht werden; + * die Bewegung zählt dann jedes Mal dort, wo ihr Vorzeichen sie hinstellt. * * @param array $zeile - * @param array $arten */ - private static function artDerBewegung(array $zeile, array $arten): string + private static function artDerBewegung(array $zeile): string { - if ($zeile['kategorie_id'] !== null && isset($arten[(int)$zeile['kategorie_id']])) { - return $arten[(int)$zeile['kategorie_id']]; - } - return (float)$zeile['betrag'] >= 0 ? 'einnahme' : 'ausgabe'; } @@ -1327,22 +1331,6 @@ class Auswertung }; } - /** @return array kategorie_id => 'einnahme'|'ausgabe' */ - private static function kategorienArten(): array - { - $arten = []; - foreach (Kategorie::alle() as $kategorie) { - $arten[(int)$kategorie['id']] = (string)$kategorie['art']; - } - return $arten; - } - - /** @param array $kategorie */ - private static function art(array $kategorie): string - { - return (string)($kategorie['art'] ?? 'ausgabe'); - } - /** * Die gewählten Kategorien samt aller Unterkategorien. Wer „400 - Leben“ * filtert, meint den ganzen Ast – alles andere wäre eine Überraschung. diff --git a/tests/14_AuswertungTest.php b/tests/14_AuswertungTest.php index d178775..7de934e 100644 --- a/tests/14_AuswertungTest.php +++ b/tests/14_AuswertungTest.php @@ -26,6 +26,7 @@ class AuswertungTest extends TestCase private int $einkauf = 0; private int $freizeit = 0; private int $gehalt = 0; + private int $ruecklage = 0; private int $privat = 0; private int $urlaub = 0; @@ -53,6 +54,10 @@ class AuswertungTest extends TestCase $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(); @@ -74,6 +79,11 @@ class AuswertungTest extends TestCase $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'); } @@ -405,6 +415,67 @@ class AuswertungTest extends TestCase $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