Finanzmanager/tests/15_CockpitTest.php
fds2610-bot fef2e67248 Fix #137: Spalte und Filter „abgeglichen“ im Buchungsbericht
Der Buchungsbericht zeigt vor der Spalte „Bar“ des Bargeld-Tools die
Spalte „✓“ (abgeglichen) und lässt sich nach abgeglichenen bzw. nicht
abgeglichenen Buchungen filtern. „Buchungen nicht abgeglichen“ unter den
offenen Vorgängen im Cockpit öffnet jetzt diesen Bericht mit dem Filter
„nicht abgeglichen“ statt der Kontenübersicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:03:54 +02:00

476 lines
20 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
use Finanzmanager\Database;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Auswertung;
use Finanzmanager\Services\Cockpit;
class CockpitTest extends TestCase
{
protected string $testName = 'Test 15: Cockpit-Kacheln';
/** Fester Stichtag – sonst hinge das Ergebnis am Tag des Testlaufs. */
private const HEUTE = '2026-08-11';
private int $giro = 0;
private int $sparkonto = 0;
private int $gehalt = 0;
private int $wohnen = 0;
private int $miete = 0;
private int $strom = 0;
private int $freizeit = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->buchungen();
$this->kachelauswahlMerktSich();
$this->monatsreiheTrenntEinnahmenUndAusgaben();
$this->monatsreiheLaesstUmbuchungenAus();
$this->verteilungFasstAufDerWurzelZusammen();
$this->verteilungKuerztAufDieGrenze();
$this->offeneVorgaengeZaehlenWasWartet();
$this->bargeldFehlerErscheintAlsOffenerVorgang();
$this->nichtAbgeglichenFuehrtInDenBuchungsbericht();
$this->legendenFuehrenZuDenBuchungen();
$this->vorgangspruefungenKennenJedenVorgang();
}
private function vorgangspruefungenKennenJedenVorgang(): void
{
foreach (Cockpit::offeneVorgaenge(self::HEUTE) as $vorgang) {
$this->assert(
isset(Cockpit::VORGANGSPRUEFUNGEN[$vorgang['schluessel']]),
'Der Tooltip kennt die Prüfung „' . $vorgang['schluessel'] . '“'
);
}
$this->assertEquals(8, count(Cockpit::VORGANGSPRUEFUNGEN), 'Acht Prüfungen sind aufgelistet');
}
private function stammdaten(): void
{
$this->giro = Konto::anlegen([
'name' => 'Cockpitgiro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1,
]);
$this->sparkonto = Konto::anlegen([
'name' => 'Cockpitsparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->gehalt = Kategorie::findenOderAnlegenNachPfad('100 - Einkommen:Gehalt', 'einnahme');
$this->wohnen = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen', 'ausgabe');
$this->miete = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Miete', 'ausgabe');
$this->strom = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Strom', 'ausgabe');
$this->freizeit = Kategorie::findenOderAnlegenNachPfad('500 - Freizeit', 'ausgabe');
}
private function buchungen(): void
{
// Zwei Monate des laufenden Jahres, beide im Zwölfmonatsfenster.
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-07-01', 'betrag' => 3000,
'empfaenger' => 'Arbeitgeber', 'kategorie_id' => $this->gehalt, 'abgeglichen' => 1,
]);
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-07-03', 'betrag' => -900,
'empfaenger' => 'Vermieter', 'kategorie_id' => $this->miete, 'abgeglichen' => 1,
]);
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-07-15', 'betrag' => -100,
'empfaenger' => 'Stadtwerke', 'kategorie_id' => $this->strom, 'abgeglichen' => 1,
]);
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-08-01', 'betrag' => 3000,
'empfaenger' => 'Arbeitgeber', 'kategorie_id' => $this->gehalt, 'abgeglichen' => 1,
]);
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-08-05', 'betrag' => -200,
'empfaenger' => 'Kino', 'kategorie_id' => $this->freizeit, 'abgeglichen' => 1,
]);
}
private function kachelauswahlMerktSich(): void
{
$this->assertEquals(
array_keys(Cockpit::KACHELN),
Cockpit::sichtbar(),
'Ohne gespeicherte Auswahl sind alle Kacheln sichtbar'
);
Cockpit::setzeSichtbar(['ausgaben', 'vermoegen', 'gibtesnicht']);
$this->assertEquals(
['vermoegen', 'ausgaben'],
Cockpit::sichtbar(),
'Gespeichert wird in Katalogreihenfolge, Unbekanntes fällt weg'
);
$this->assert(Cockpit::zeigt('vermoegen'), 'Die gewählte Kachel wird gezeigt');
$this->assert(!Cockpit::zeigt('kontostaende'), 'Die abgewählte nicht');
// Alles abwählen muss möglich bleiben – sonst käme man aus einer leeren
// Auswahl nie heraus, weil sie als „nie gespeichert“ gälte.
Cockpit::setzeSichtbar([]);
$this->assertEquals([], Cockpit::sichtbar(), 'Auch die leere Auswahl bleibt leer');
Cockpit::setzeSichtbar(array_keys(Cockpit::KACHELN));
}
private function monatsreiheTrenntEinnahmenUndAusgaben(): void
{
$reihe = Cockpit::monatsreihe(self::HEUTE);
$this->assertEquals(12, count($reihe['monate']), 'Zwölf Monatsspalten');
$this->assertEquals('2026-08', $reihe['monate'][11]['schluessel'], 'Die letzte Spalte ist der laufende Monat');
$this->assertEquals('2025-09', $reihe['monate'][0]['schluessel'], 'Die erste liegt elf Monate davor');
$this->assertEquals(3000.0, $reihe['einnahmen'][10], 'Juli: das Gehalt');
$this->assertEquals(1000.0, $reihe['ausgaben'][10], 'Juli: Miete und Strom, vorzeichenlos');
$this->assertEquals(2000.0, $reihe['salden'][10], 'Juli: Saldo ist Einnahmen minus Ausgaben');
$this->assertEquals(2800.0, $reihe['salden'][11], 'August: 3000 minus 200');
$this->assertEquals(6000.0, $reihe['summe_einnahmen'], 'Summe der Einnahmen über alle Monate');
$this->assertEquals(1200.0, $reihe['summe_ausgaben'], 'Summe der Ausgaben über alle Monate');
$this->assert($reihe['hatWerte'], 'Es gibt etwas zu zeichnen');
}
/** Wie überall in den Auswertungen: Umbuchungen sind weder Einnahme noch Ausgabe. */
private function monatsreiheLaesstUmbuchungenAus(): void
{
$vorher = Cockpit::monatsreihe(self::HEUTE);
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-08-06', 'betrag' => -500,
'empfaenger' => 'Aufs Sparbuch', 'gegenkonto_id' => $this->sparkonto,
]);
$nachher = Cockpit::monatsreihe(self::HEUTE);
$this->assertEquals(
$vorher['summe_ausgaben'],
$nachher['summe_ausgaben'],
'Die Umbuchung taucht in den Ausgaben nicht auf'
);
$this->assertEquals(
$vorher['salden'][11],
$nachher['salden'][11],
'Und ändert den Saldo des Monats nicht'
);
}
/**
* Die Kachel zeigt oberste Kategorien: „300 - Wohnen“ statt Miete und Strom
* einzeln – sonst hätte der Ring so viele Segmente wie es Kategorien gibt.
*/
private function verteilungFasstAufDerWurzelZusammen(): void
{
$verteilung = Cockpit::ausgaben(self::HEUTE);
$namen = array_column($verteilung['zeilen'], 'betrag', 'name');
$this->assertEquals(1000.0, $namen['300 - Wohnen'] ?? 0.0, 'Miete und Strom stehen unter ihrer Wurzel');
$this->assertEquals(200.0, $namen['500 - Freizeit'] ?? 0.0, 'Freizeit hat keine Kinder und steht für sich');
$this->assert(!isset($namen['300 - Wohnen:Miete']), 'Unterkategorien haben kein eigenes Segment');
$this->assertEquals(1200.0, $verteilung['summe'], 'Die Summe sind alle Ausgaben des Jahres');
$this->assertEquals('ausgabe', $verteilung['art'], 'Gefragt war nach Ausgaben');
$this->assert(!$verteilung['leer'], 'Die Kachel hat etwas zu zeigen');
$anteile = 0.0;
foreach ($verteilung['zeilen'] as $zeile) {
$this->assertGreaterThan(0.0, $zeile['betrag'], 'Jedes Segment trägt einen positiven Betrag');
$anteile += $zeile['anteil'];
}
$this->assertEquals(1.0, round($anteile, 6), 'Die Anteile ergeben zusammen den ganzen Ring');
// Einnahmen gehen genauso, nur andersherum.
$einnahmen = Auswertung::verteilung(
['typ' => 'summenbericht', 'zeitraum' => 'aktuelles_jahr'],
'einnahme',
8,
self::HEUTE
);
$this->assertEquals(6000.0, $einnahmen['summe'], 'Die Einnahmenseite zählt das Gehalt');
}
private function verteilungKuerztAufDieGrenze(): void
{
for ($nummer = 1; $nummer <= 12; $nummer++) {
$kategorie = Kategorie::findenOderAnlegenNachPfad('9' . $nummer . ' - Klein ' . $nummer, 'ausgabe');
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-06-10', 'betrag' => -$nummer,
'empfaenger' => 'Kleinbetrag ' . $nummer, 'kategorie_id' => $kategorie,
]);
}
$verteilung = Auswertung::verteilung(
['typ' => 'summenbericht', 'zeitraum' => 'aktuelles_jahr'],
'ausgabe',
5,
self::HEUTE
);
$this->assertEquals(5, count($verteilung['zeilen']), 'Nicht mehr Segmente als die Grenze erlaubt');
$letzte = $verteilung['zeilen'][4];
$this->assert(str_starts_with($letzte['name'], 'Übrige ('), 'Das letzte Segment sammelt den Rest');
// Gekürzt wird die Darstellung, nicht die Summe.
$gezeichnet = array_sum(array_column($verteilung['zeilen'], 'betrag'));
$this->assertEquals(
$verteilung['summe'],
round($gezeichnet, 2),
'Die Segmente ergeben zusammen die Gesamtsumme'
);
}
private function offeneVorgaengeZaehlenWasWartet(): void
{
$schluessel = static fn(array $vorgaenge): array => array_column($vorgaenge, 'anzahl', 'schluessel');
$vorher = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-08-07', 'betrag' => -42,
'empfaenger' => 'Unbekannt',
]);
$nachher = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
$this->assertEquals(
($vorher['ohne_kategorie'] ?? 0) + 1,
$nachher['ohne_kategorie'] ?? 0,
'Eine Buchung ohne Kategorie erscheint als offener Vorgang'
);
$this->assertGreaterThan(
0,
$nachher['nicht_abgeglichen'] ?? 0,
'Nicht abgeglichene Buchungen werden mitgezählt'
);
// Umbuchungen brauchen keine Kategorie – sie sind keine offene Arbeit.
$mitUmbuchung = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
$this->assertEquals(
$nachher['ohne_kategorie'] ?? 0,
$mitUmbuchung['ohne_kategorie'] ?? 0,
'Die Umbuchung von vorhin zählt nicht als unkategorisiert'
);
foreach (Cockpit::offeneVorgaenge(self::HEUTE) as $vorgang) {
$this->assertNotEmpty($vorgang['pfad'], 'Jeder Vorgang führt irgendwohin');
$this->assertNotEmpty($vorgang['text'], 'Und trägt einen Text');
}
}
/**
* Eine Buchung, deren letzter Abgleich mit dem Bargeld-Tool gescheitert
* ist, erscheint als eigener offener Vorgang – unabhängig vom Konto, denn
* die Prüfung hängt an der Kategorie.
*/
private function bargeldFehlerErscheintAlsOffenerVorgang(): void
{
$schluessel = static fn(array $vorgaenge): array => array_column($vorgaenge, 'anzahl', 'schluessel');
$vorher = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
$this->assert(
!isset($vorher['bargeld_fehler']),
'Ohne betroffene Buchung fehlt der Vorgang ganz'
);
$id = Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-08-09', 'betrag' => -15,
'empfaenger' => 'Bargeldabhebung', 'kategorie_id' => $this->freizeit, 'abgeglichen' => 1,
]);
Database::execute(
"UPDATE buchungen SET bargeld_status = 'fehler', bargeld_hinweis = 'Kasse nicht erreichbar.'
WHERE id = ?",
[$id]
);
$nachher = Cockpit::offeneVorgaenge(self::HEUTE);
$eintrag = null;
foreach ($nachher as $vorgang) {
if ($vorgang['schluessel'] === 'bargeld_fehler') {
$eintrag = $vorgang;
}
}
$this->assertNotEmpty($eintrag, 'Der offene Vorgang erscheint jetzt');
$this->assertEquals(1, $eintrag['anzahl'], 'Genau die eine betroffene Buchung wird gezählt');
$this->assertEquals(
'/auswertungen/bericht?typ=buchungsbericht&zeitraum=alles&umbuchungen=1&bargeld=fehler&angepasst=1',
$eintrag['pfad'],
'Der Weg führt in den Buchungsbericht, gefiltert auf fehlgeschlagene Prüfungen (#131)'
);
// Ein Status ungleich 'fehler' (z. B. 'gefunden') zählt nicht mit.
Database::execute("UPDATE buchungen SET bargeld_status = 'gefunden' WHERE id = ?", [$id]);
$ohne = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
$this->assert(!isset($ohne['bargeld_fehler']), 'Ein gefundener Treffer ist kein offener Vorgang mehr');
// Nicht gefunden: eigener Vorgang mit eigenem Filter im Buchungsbericht.
Database::execute("UPDATE buchungen SET bargeld_status = 'fehlt' WHERE id = ?", [$id]);
$fehlt = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
$this->assertEquals(1, $fehlt['bargeld_fehlt'] ?? 0, 'Eine im Bargeld-Tool nicht gefundene Buchung ist ein offener Vorgang');
$this->assert(!isset($fehlt['bargeld_fehler']), 'Nicht gefunden zählt nicht als gescheiterter Abruf');
$pfade = array_column(Cockpit::offeneVorgaenge(self::HEUTE), 'pfad', 'schluessel');
$this->assert(
str_contains($pfade['bargeld_fehlt'] ?? '', 'typ=buchungsbericht') && str_contains($pfade['bargeld_fehlt'] ?? '', 'bargeld=fehlt'),
'Der Weg führt in den Buchungsbericht, gefiltert auf „nicht gefunden“ (#131)'
);
Database::execute("UPDATE buchungen SET bargeld_status = 'fehler' WHERE id = ?", [$id]);
}
/**
* „Buchungen nicht abgeglichen“ öffnet den Buchungsbericht, gefiltert auf
* nicht abgeglichene Buchungen – und der zeigt genau die gezählten (#137).
*/
private function nichtAbgeglichenFuehrtInDenBuchungsbericht(): void
{
$pruefen = function (string $fall): void {
$vorgang = null;
foreach (Cockpit::offeneVorgaenge(self::HEUTE) as $eintrag) {
if ($eintrag['schluessel'] === 'nicht_abgeglichen') {
$vorgang = $eintrag;
}
}
$this->assertNotEmpty($vorgang, $fall . ': Der Vorgang erscheint');
$this->assert(str_starts_with($vorgang['pfad'], '/auswertungen/bericht?'), $fall . ': Der Weg führt in den Buchungsbericht');
parse_str((string)parse_url($vorgang['pfad'], PHP_URL_QUERY), $filter);
$this->assertEquals('buchungsbericht', $filter['typ'] ?? '', $fall . ': Typ Buchungsbericht');
$this->assertEquals('nein', $filter['abgeglichen'] ?? '', $fall . ': Gefiltert auf nicht abgeglichen');
$bericht = Auswertung::bericht($filter, self::HEUTE);
$ids = [];
foreach ($bericht['gruppen'] as $gruppe) {
foreach ($gruppe['buchungen'] as $buchung) {
$this->assert(!$buchung['abgeglichen'], $fall . ': Nur nicht abgeglichene Buchungen');
$ids[$buchung['buchung_id']] = true;
}
}
$this->assertEquals($vorgang['anzahl'], count($ids), $fall . ': Der Bericht zeigt genau die gezählten Buchungen');
};
// Eine künftige Buchung zählt der Vorgang nicht – der Bericht auch nicht.
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-12-24', 'betrag' => -40,
'empfaenger' => 'Zukunft', 'kategorie_id' => $this->freizeit,
]);
$pruefen('Aktive Konten');
// Ein aufgelöstes Konto zählt nicht mit – der Bericht beschränkt sich
// dann auf die aktiven Konten.
$alt = Konto::anlegen([
'name' => 'Cockpitaltkonto', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 0,
]);
Buchung::anlegen([
'konto_id' => $alt, 'datum' => '2026-08-01', 'betrag' => -5,
'empfaenger' => 'Altlast', 'kategorie_id' => $this->freizeit,
]);
$pruefen('Mit aufgelöstem Konto');
}
/**
* Jeder Legendeneintrag führt zu einer Buchungsliste, deren Summe genau
* seinen Wert trifft – auch „Nicht kategorisiert“ und das Sammelsegment.
*/
private function legendenFuehrenZuDenBuchungen(): void
{
// Eine unkategorisierte Gutschrift: Sie ist eine Einnahme und darf in
// der Ausgabenlegende „Nicht kategorisiert“ nicht auftauchen.
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-08-08', 'betrag' => 50,
'empfaenger' => 'Unbekannte Gutschrift',
]);
$ausgaben = Cockpit::ausgaben(self::HEUTE);
$namen = array_column($ausgaben['zeilen'], 'name');
$this->assert(in_array(Auswertung::OHNE_KATEGORIE, $namen, true), 'Die Kachel führt „Nicht kategorisiert“');
$this->assert(
(bool)array_filter($namen, static fn(string $name): bool => str_starts_with($name, 'Übrige (')),
'Und ein Sammelsegment „Übrige“'
);
foreach ($ausgaben['zeilen'] as $zeile) {
$this->assertNotEmpty($zeile['pfad'] ?? '', 'Legendeneintrag „' . $zeile['name'] . '“ hat einen Weg');
$liste = $this->folge($zeile['pfad']);
$this->assertEquals(
-$zeile['betrag'],
$liste['summe'],
'Die Buchungen hinter „' . $zeile['name'] . '“ ergeben den Legendenwert'
);
$this->assertEquals('ausgabe', $liste['art'], 'Gefiltert wird auf Ausgaben');
}
$ohne = null;
foreach ($ausgaben['zeilen'] as $zeile) {
if ($zeile['name'] === Auswertung::OHNE_KATEGORIE) {
$ohne = $this->folge($zeile['pfad']);
}
}
$empfaenger = array_column($ohne['buchungen'] ?? [], 'empfaenger');
$this->assert(in_array('Unbekannt', $empfaenger, true), 'Die unkategorisierte Ausgabe steht in der Liste');
$this->assert(!in_array('Unbekannte Gutschrift', $empfaenger, true), 'Die unkategorisierte Gutschrift nicht');
$wohnen = null;
foreach ($ausgaben['zeilen'] as $zeile) {
if ($zeile['name'] === '300 - Wohnen') {
$wohnen = $this->folge($zeile['pfad']);
}
}
$this->assertEquals(2, count($wohnen['buchungen'] ?? []), 'Die Wurzel zieht Miete und Strom mit');
// Einnahmen und Ausgaben: drei Legendeneinträge, drei Summen.
$reihe = Cockpit::monatsreihe(self::HEUTE);
$einnahmen = $this->folge($reihe['pfade']['einnahme']);
$this->assertEquals($reihe['summe_einnahmen'], $einnahmen['summe'], 'Die Einnahmen-Liste trifft die Legende');
$ausgabenListe = $this->folge($reihe['pfade']['ausgabe']);
$this->assertEquals(-$reihe['summe_ausgaben'], $ausgabenListe['summe'], 'Die Ausgaben-Liste trifft die Legende');
$saldo = $this->folge($reihe['pfade']['saldo']);
$this->assertEquals(
round($reihe['summe_einnahmen'] - $reihe['summe_ausgaben'], 2),
$saldo['summe'],
'Die Saldo-Liste trifft die Legende'
);
$this->assert(
!in_array('Aufs Sparbuch', array_column($saldo['buchungen'], 'empfaenger'), true),
'Umbuchungen bleiben draußen wie in der Kachel'
);
$this->assert(
str_contains($reihe['pfade']['saldo'], 'herkunft=cockpit'),
'Der Weg zurück führt ins Cockpit'
);
}
/**
* Ruft den Drilldown so auf, wie der Controller es mit dieser Adresse täte –
* nur mit festem Stichtag.
*
* @return array<string,mixed>
*/
private function folge(string $pfad): array
{
$this->assert(str_starts_with($pfad, '/auswertungen/buchungen?'), 'Der Weg führt in die Buchungsliste');
parse_str((string)parse_url($pfad, PHP_URL_QUERY), $query);
return Auswertung::zeilenbuchungen(
Auswertung::normalisiere($query),
(string)($query['zeile'] ?? ''),
(string)($query['spalte'] ?? ''),
($query['ast'] ?? '') === '1',
self::HEUTE,
(string)($query['art'] ?? '')
);
}
}