Merge pull request 'Fix n.n.: Einnahme/Ausgabe entscheidet der Geldfluss, nicht die Kategorie-Art' (#86) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/86
This commit is contained in:
fds2610 2026-09-28 09:08:30 +02:00
commit 1a8b64e71d
3 changed files with 124 additions and 42 deletions

View file

@ -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

View file

@ -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 <Name>“ 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<string|int, array<string,float>> $werte
* @param array<string,mixed> $filter
* @param list<array<string,string>> $spalten
* @return list<array<string,mixed>>
*/
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<string,mixed> $zeile
* @param array<int,string> $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<int,string> 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<string,mixed> $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.

View file

@ -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