Finanzmanager/tests/15_CockpitTest.php
fds2610-bot c6d5babb41 Fix #131: Offene Vorgänge des Bargeld-Tools im Buchungsbericht
Die offenen Vorgänge „im Bargeld-Tool nicht gefunden“ und „Bargeld-Tool-
Prüfung fehlgeschlagen“ führen in den Buchungsbericht mit dem Filter
„Bargeld-Tool“ – dort steht die Spalte Kategorie/Gegenkonto mit. Die
eigene Liste unter /verwaltung/bargeld/fehler entfällt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:51:41 +02:00

425 lines
18 KiB
PHP
Raw 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->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]);
}
/**
* 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'] ?? '')
);
}
}