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