'Summenbericht', 'buchungsbericht' => 'Buchungsbericht', 'vergleichsbericht' => 'Vergleichsbericht', 'kontostandsbericht' => 'Kontostandsbericht', 'budgetbericht' => 'Budgetbericht', ]; /** Wonach die Zeilen zusammengefasst werden. */ public const ZEILENSUMMEN = [ 'kategorie' => 'Kategorie', 'klasse' => 'Klasse', 'konto' => 'Konto', 'empfaenger' => 'Empfänger', ]; public const OHNE_KATEGORIE = 'Nicht kategorisiert'; public const OHNE_KLASSE = 'Ohne Klasse'; public const OHNE_EMPFAENGER = 'Ohne Empfänger'; /** * Bringt einen Filter in gültige Form – aus dem Formular, aus der Adresse * oder aus dem JSON eines gespeicherten Berichts. Unbekannte Felder fallen * weg, unbekannte Werte fallen auf die Vorgabe zurück. Kein * Datenbankzugriff: `Bericht::filter()` ruft das beim Lesen jeder Zeile. * * @param array $roh * @return array */ public static function normalisiere(array $roh): array { $typ = (string)($roh['typ'] ?? ''); if (!array_key_exists($typ, self::TYPEN)) { $typ = 'summenbericht'; } // Zwei Berichte bestimmen ihre Zeilen selbst: Der Kontostandsbericht // führt Konten, der Budgetbericht Kategorien. Eine andere Wahl wäre dort // nicht auswertbar, deshalb wird sie gar nicht erst übernommen. $zeilensumme = (string)($roh['zeilensumme'] ?? ''); $zeilensumme = match ($typ) { 'kontostandsbericht' => 'konto', 'budgetbericht' => 'kategorie', default => array_key_exists($zeilensumme, self::ZEILENSUMMEN) ? $zeilensumme : 'kategorie', }; $aufteilung = (string)($roh['aufteilung'] ?? ''); if (!array_key_exists($aufteilung, Zeitraum::AUFTEILUNGEN)) { $aufteilung = 'keine'; } $vorgabe = (string)($roh['zeitraum'] ?? ''); if (!array_key_exists($vorgabe, Zeitraum::VORGABEN)) { $vorgabe = 'aktuelles_jahr'; } return [ 'typ' => $typ, 'zeitraum' => $vorgabe, 'von' => self::datum($roh['von'] ?? ''), 'bis' => self::datum($roh['bis'] ?? ''), 'zeilensumme' => $zeilensumme, 'aufteilung' => $aufteilung, 'einrueckung' => self::bool($roh['einrueckung'] ?? true), 'umbuchungen' => self::bool($roh['umbuchungen'] ?? false), 'konten' => self::ids($roh['konten'] ?? []), 'kategorien' => self::ids($roh['kategorien'] ?? []), 'klassen' => self::ids($roh['klassen'] ?? []), 'suche' => mb_substr(trim((string)($roh['suche'] ?? '')), 0, 120), ]; } /** * Der fertige Bericht zu einem Filter. * * @param array $filter roh oder normalisiert * @return array */ public static function bericht(array $filter, ?string $heute = null): array { $filter = self::normalisiere($filter); $zeitraum = self::zeitraum($filter, $heute); $filter['von'] = $zeitraum['von']; $filter['bis'] = $zeitraum['bis']; $bericht = match ($filter['typ']) { 'buchungsbericht' => self::buchungsbericht($filter), 'vergleichsbericht' => self::vergleichsbericht($filter), 'kontostandsbericht' => self::kontostandsbericht($filter), 'budgetbericht' => self::budgetbericht($filter), default => self::summenbericht($filter), }; return $bericht + [ 'typ' => $filter['typ'], 'typname' => self::TYPEN[$filter['typ']], 'filter' => $filter, 'zeitraum' => $zeitraum, 'beschriftung' => Zeitraum::beschriftung($zeitraum['von'], $zeitraum['bis']), ]; } /** * Löst die Zeitraumvorgabe auf. Getrennt von normalisiere(), weil * „Gesamter Zeitraum“ die Datumsgrenzen der Buchungen braucht – und * normalisiere() beim Lesen jedes gespeicherten Berichts läuft. * * @param array $filter * @return array{vorgabe: string, von: string, bis: string} */ public static function zeitraum(array $filter, ?string $heute = null): array { $vorgabe = (string)($filter['zeitraum'] ?? 'aktuelles_jahr'); return Zeitraum::berechne( $vorgabe, (string)($filter['von'] ?? ''), (string)($filter['bis'] ?? ''), $heute, $vorgabe === 'alles' ? Buchung::datumsgrenzen() : ['von' => null, 'bis' => null] ); } // ── Summenbericht ──────────────────────────────────────────────────────── /** * Summen je Zeilendimension und Spalte, getrennt nach Einnahmen und * Ausgaben, mit Gesamtzeilen. * * @param array $filter * @return array */ private static function summenbericht(array $filter): array { $spalten = Zeitraum::spalten((string)$filter['aufteilung'], $filter['von'], $filter['bis']); $roh = self::verteile(self::bewegungen($filter), $filter, $spalten); $abschnitte = []; foreach (['einnahme' => 'Einnahmen', 'ausgabe' => 'Ausgaben'] as $art => $name) { $abschnitte[] = self::abschnitt($art, $name, $roh[$art] ?? [], $filter, $spalten); } // Die Schlusszeile rechnet mit den echten Vorzeichen, nicht mit den fürs // Auge gedrehten: Einnahmen plus (negative) Ausgaben. $gesamt = self::leereReihe($spalten); foreach ($roh as $art => $zeilen) { foreach ($zeilen as $werte) { foreach ($werte as $schluessel => $betrag) { $gesamt[$schluessel] += $betrag; } } } return [ 'darstellung' => 'summen', 'spalten' => $spalten, 'abschnitte' => $abschnitte, 'gesamt' => [ 'name' => 'Gesamt Einnahmen − Ausgaben', 'werte' => self::gerundet($gesamt), 'summe' => round(array_sum($gesamt), 2), ], 'leer' => $abschnitte[0]['zeilen'] === [] && $abschnitte[1]['zeilen'] === [], ]; } /** * Ein Abschnitt (Einnahmen oder Ausgaben) mit seinen Zeilen. Bei der * Zeilendimension *Kategorie* entsteht der eingerückte Baum samt * „Gesamt …“-Zeilen, sonst eine flache, alphabetisch sortierte Liste. * * @param array> $werte * @param array $filter * @param list> $spalten * @return array */ private static function abschnitt(string $art, string $name, array $werte, array $filter, array $spalten): array { // Ausgaben stehen im Bericht ohne Minus – so hält es der FinanzManager, // und die Überschrift sagt ja bereits, worum es geht. $faktor = $art === 'ausgabe' ? -1.0 : 1.0; $zeilen = $filter['zeilensumme'] === 'kategorie' ? self::kategoriezeilen($art, $werte, $filter, $spalten, $faktor) : self::flacheZeilen($werte, $filter, $spalten, $faktor); $summe = self::leereReihe($spalten); foreach ($werte as $reihe) { foreach ($reihe as $schluessel => $betrag) { $summe[$schluessel] += $betrag * $faktor; } } return [ 'schluessel' => $art, 'name' => $name, 'zeilen' => $zeilen, 'werte' => self::gerundet($summe), 'summe' => round(array_sum($summe), 2), 'vorzeichen' => $faktor, ]; } /** * Der Kategoriebaum als Zeilenfolge. Jede Kategorie zeigt ihre *eigenen* * Werte; Kategorien mit Nachfahren bekommen dahinter eine Zeile * „Gesamt “ mit der Summe des Astes. Genau so sieht der Summenbericht * des FinanzManagers aus. * * @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 { $baum = Kategorie::baumFlach($art); // Astsummen von unten nach oben: Erst die eigenen Werte, dann trägt // jede Kategorie ihre Summe an die Elternkategorie hoch. $ast = []; foreach ($baum as $kategorie) { $ast[(int)$kategorie['id']] = $werte[(int)$kategorie['id']] ?? []; } foreach (array_reverse($baum) as $kategorie) { $eltern = $kategorie['eltern_id'] === null ? 0 : (int)$kategorie['eltern_id']; if (!isset($ast[$eltern])) { continue; } foreach ($ast[(int)$kategorie['id']] as $schluessel => $betrag) { $ast[$eltern][$schluessel] = ($ast[$eltern][$schluessel] ?? 0.0) + $betrag; } } if (!$filter['einrueckung']) { // Ohne Einrückung zählt jede Kategorie nur sich selbst, dafür mit // vollem Pfad – sonst stünden gleichnamige Unterkategorien // ununterscheidbar untereinander. $flach = []; foreach ($baum as $kategorie) { $id = (int)$kategorie['id']; if (self::istLeer($werte[$id] ?? [])) { continue; } $flach[] = self::zeile( 'kategorie:' . $id, (string)$kategorie['pfad'], $werte[$id], $spalten, $faktor ); } return array_merge($flach, self::ohneZuordnung($werte, $spalten, $faktor, self::OHNE_KATEGORIE)); } $zeilen = []; $offen = []; foreach ($baum as $kategorie) { $id = (int)$kategorie['id']; $ebene = (int)$kategorie['ebene']; $zeilen = array_merge($zeilen, self::schliesseAeste($offen, $ebene, $ast, $spalten, $faktor)); // Ein Ast ohne jede Buchung wird gar nicht erst aufgeschlagen. if (self::istLeer($ast[$id])) { continue; } $zeilen[] = self::zeile( 'kategorie:' . $id, (string)$kategorie['name'] . ($kategorie['hat_kinder'] ? Kategorie::TRENNER : ''), $werte[$id] ?? [], $spalten, $faktor, ['ebene' => $ebene, 'kopf' => (bool)$kategorie['hat_kinder']] ); if ($kategorie['hat_kinder']) { $offen[] = ['id' => $id, 'ebene' => $ebene, 'name' => (string)$kategorie['name']]; } } $zeilen = array_merge($zeilen, self::schliesseAeste($offen, 0, $ast, $spalten, $faktor)); return array_merge($zeilen, self::ohneZuordnung($werte, $spalten, $faktor, self::OHNE_KATEGORIE)); } /** * Setzt die „Gesamt …“-Zeilen aller Äste, die mit der neuen Ebene enden. * * @param list $offen * @param array> $ast * @param list> $spalten * @return list> */ private static function schliesseAeste(array &$offen, int $ebene, array $ast, array $spalten, float $faktor): array { $zeilen = []; while ($offen !== [] && $offen[count($offen) - 1]['ebene'] >= $ebene) { $zweig = array_pop($offen); $zeilen[] = self::zeile( 'kategorie:' . $zweig['id'], 'Gesamt ' . $zweig['name'], $ast[$zweig['id']] ?? [], $spalten, $faktor, ['ebene' => $zweig['ebene'], 'astsumme' => true] ); } return $zeilen; } /** * Klassen, Konten und Empfänger als flache Liste, alphabetisch. * * @param array> $werte * @param array $filter * @param list> $spalten * @return list> */ private static function flacheZeilen(array $werte, array $filter, array $spalten, float $faktor): array { $namen = self::namen((string)$filter['zeilensumme']); $zeilen = []; foreach ($werte as $schluessel => $reihe) { if ($schluessel === '' || $schluessel === 0 || self::istLeer($reihe)) { continue; } $zeilen[] = self::zeile( $filter['zeilensumme'] . ':' . $schluessel, $namen[$schluessel] ?? (string)$schluessel, $reihe, $spalten, $faktor ); } usort($zeilen, static fn(array $a, array $b): int => strnatcasecmp($a['name'], $b['name'])); $ohne = match ((string)$filter['zeilensumme']) { 'klasse' => self::OHNE_KLASSE, 'empfaenger' => self::OHNE_EMPFAENGER, default => self::OHNE_KATEGORIE, }; return array_merge($zeilen, self::ohneZuordnung($werte, $spalten, $faktor, $ohne, (string)$filter['zeilensumme'])); } /** * Die Zeile für Bewegungen ohne Zuordnung. Sie steht am Ende und nie unter * den Tisch: Geld, das keiner Kategorie zugeordnet ist, verschwände sonst * lautlos aus der Summe. * * @param array> $werte * @param list> $spalten * @return list> */ private static function ohneZuordnung(array $werte, array $spalten, float $faktor, string $name, string $dimension = 'kategorie'): array { $reihe = $werte[0] ?? $werte[''] ?? []; if (self::istLeer($reihe)) { return []; } return [self::zeile($dimension . ':0', $name, $reihe, $spalten, $faktor, ['ohne' => true])]; } /** * @param array $werte * @param list> $spalten * @param array $zusatz * @return array */ private static function zeile(string $schluessel, string $name, array $werte, array $spalten, float $faktor, array $zusatz = []): array { $reihe = self::leereReihe($spalten); foreach ($werte as $spalte => $betrag) { if (array_key_exists($spalte, $reihe)) { $reihe[$spalte] = $betrag * $faktor; } } // $zusatz zuerst: Bei „+“ auf Arrays gewinnt der linke Operand, und die // Angaben des Aufrufers sollen die Vorgaben überschreiben. return $zusatz + [ 'schluessel' => $schluessel, 'name' => $name, 'ebene' => 0, 'kopf' => false, 'astsumme' => false, 'ohne' => false, 'werte' => self::gerundet($reihe), 'summe' => round(array_sum($reihe), 2), ]; } // ── Buchungsbericht ────────────────────────────────────────────────────── /** * Die Einzelbuchungen des Zeitraums, gruppiert nach der Zeilendimension. * Die Beträge behalten hier ihr buchhalterisches Vorzeichen – es ist eine * Buchungsliste, kein Summenbericht. * * @param array $filter * @return array */ private static function buchungsbericht(array $filter): array { $namen = self::namen((string)$filter['zeilensumme']); $gruppen = []; foreach (self::bewegungen($filter) as $zeile) { $schluessel = self::zeilenschluessel($zeile, (string)$filter['zeilensumme']); $gruppen[$schluessel] ??= [ 'schluessel' => $filter['zeilensumme'] . ':' . $schluessel, // Empfänger sind ihr eigener Name und stehen in keiner Tabelle – // deshalb der Schlüssel als Rückfallwert. 'name' => $schluessel === 0 || $schluessel === '' ? self::ohneName((string)$filter['zeilensumme']) : ($namen[$schluessel] ?? (string)$schluessel), 'zeilen' => [], 'summe' => 0.0, ]; $gruppen[$schluessel]['zeilen'][] = $zeile; $gruppen[$schluessel]['summe'] += (float)$zeile['betrag']; } foreach ($gruppen as &$gruppe) { $gruppe['summe'] = round($gruppe['summe'], 2); $gruppe['buchungen'] = self::alsBuchungsliste($gruppe['zeilen']); unset($gruppe['zeilen']); } unset($gruppe); uasort($gruppen, static fn(array $a, array $b): int => strnatcasecmp($a['name'], $b['name'])); $summe = 0.0; $anzahl = 0; foreach ($gruppen as $gruppe) { $summe += $gruppe['summe']; $anzahl += count($gruppe['buchungen']); } return [ 'darstellung' => 'buchungen', 'gruppen' => array_values($gruppen), 'summe' => round($summe, 2), 'anzahl' => $anzahl, 'leer' => $gruppen === [], ]; } /** * Der Drilldown: die Buchungen hinter einer Berichtszeile. * * Gefiltert wird nicht durch Übersetzen der Zeile in einen neuen Filter, * sondern durch Nachsieben derselben Bewegungen, aus denen die Zelle * entstanden ist. Nur so trifft der Drilldown auch das, was ein Filter gar * nicht ausdrücken könnte – „Nicht kategorisiert“ etwa, oder eine Astsumme. * * @param array $filter * @param string $zeile Zeilenschlüssel aus dem Bericht, z. B. „kategorie:12“ * @param string $spalte Spaltenschlüssel oder '' für den ganzen Zeitraum * @param bool $ast true bei einer „Gesamt …“-Zeile: mit Unterkategorien * @return array */ public static function zeilenbuchungen( array $filter, string $zeile, string $spalte = '', bool $ast = false, ?string $heute = null ): array { $filter = self::normalisiere($filter); $zeitraum = self::zeitraum($filter, $heute); $filter['von'] = $zeitraum['von']; $filter['bis'] = $zeitraum['bis']; $spaltentitel = Zeitraum::beschriftung($filter['von'], $filter['bis']); if ($spalte !== '') { foreach (Zeitraum::spalten((string)$filter['aufteilung'], $filter['von'], $filter['bis']) as $eintrag) { if ($eintrag['schluessel'] === $spalte) { $filter['von'] = $eintrag['von']; $filter['bis'] = $eintrag['bis']; $spaltentitel = $eintrag['titel']; break; } } } $teile = explode(':', $zeile, 2); $dimension = array_key_exists($teile[0], self::ZEILENSUMMEN) ? $teile[0] : 'kategorie'; $wert = $teile[1] ?? ''; // Bei einer Astsumme zählen die Unterkategorien mit – die Zeile zeigt ja // deren Summe. $astIds = $ast && $dimension === 'kategorie' && $wert !== '' && $wert !== '0' ? array_flip(self::mitNachfahren([(int)$wert])) : null; $bewegungen = array_values(array_filter( self::bewegungen($filter), static function (array $bewegung) use ($dimension, $wert, $astIds): bool { $schluessel = self::zeilenschluessel($bewegung, $dimension); if ($astIds !== null) { return isset($astIds[(int)$schluessel]); } return (string)$schluessel === $wert; } )); $liste = self::alsBuchungsliste($bewegungen); return [ 'zeile' => $zeile, 'dimension' => self::ZEILENSUMMEN[$dimension], 'name' => self::zeilenname($dimension, $wert, $ast), 'spaltentitel' => $spaltentitel, 'buchungen' => $liste, 'summe' => round(array_sum(array_column($liste, 'betrag')), 2), 'filter' => $filter, ]; } /** Wie die angeklickte Zeile heißt – für die Überschrift des Drilldowns. */ private static function zeilenname(string $dimension, string $wert, bool $ast): string { if ($wert === '' || $wert === '0') { return self::ohneName($dimension); } $namen = self::namen($dimension); $name = $namen[$dimension === 'empfaenger' ? $wert : (int)$wert] ?? $wert; return $ast ? 'Gesamt ' . $name : (string)$name; } /** * Bewegungen zu anzeigefertigen Buchungszeilen, chronologisch. Eine * Umbuchung führt keine Kategorie – dort steht das Gegenkonto in eckigen * Klammern, genau wie im Kontoblatt. * * @param list> $bewegungen * @return list> */ private static function alsBuchungsliste(array $bewegungen): array { $kontonamen = self::namen('konto'); $kategorien = self::namen('kategorie'); $klassen = self::namen('klasse'); $liste = []; foreach ($bewegungen as $zeile) { $liste[] = [ 'buchung_id' => (int)$zeile['buchung_id'], 'datum' => (string)$zeile['datum'], 'konto' => $kontonamen[(int)$zeile['konto_id']] ?? '', 'konto_id' => (int)$zeile['konto_id'], 'empfaenger' => (string)$zeile['empfaenger'], 'text' => (string)$zeile['text'], 'kategorie' => $zeile['kategorie_id'] === null ? ($zeile['gegenkonto_id'] === null ? self::OHNE_KATEGORIE : '[' . ($kontonamen[(int)$zeile['gegenkonto_id']] ?? '?') . ']') : ($kategorien[(int)$zeile['kategorie_id']] ?? ''), 'klasse' => $zeile['klasse_id'] === null ? '' : ($klassen[(int)$zeile['klasse_id']] ?? ''), 'gesplittet' => $zeile['split_id'] !== null, 'betrag' => round((float)$zeile['betrag'], 2), ]; } usort( $liste, static fn(array $a, array $b): int => [$a['datum'], $a['buchung_id']] <=> [$b['datum'], $b['buchung_id']] ); return $liste; } // ── Vergleichsbericht ──────────────────────────────────────────────────── /** * Der Zeitraum gegen denselben Zeitraum des Vorjahres, mit Differenz und * Veränderung in Prozent. Die Spalten-Aufteilung spielt hier keine Rolle: * Die Spalten sind durch den Vergleich vorgegeben. * * @param array $filter * @return array */ private static function vergleichsbericht(array $filter): array { $vorjahr = Zeitraum::vorjahr($filter['von'], $filter['bis']); $spalten = [ ['schluessel' => 'jetzt', 'titel' => Zeitraum::beschriftung($filter['von'], $filter['bis']), 'kurz' => 'Zeitraum', 'von' => $filter['von'], 'bis' => $filter['bis']], ['schluessel' => 'vorher', 'titel' => Zeitraum::beschriftung($vorjahr['von'], $vorjahr['bis']), 'kurz' => 'Vorjahr', 'von' => $vorjahr['von'], 'bis' => $vorjahr['bis']], ]; // Beide Zeiträume in einem Rutsch holen und über das Datum der Buchung // der richtigen Spalte zuordnen. $jetzt = self::verteileAuf(self::bewegungen($filter), $filter, 'jetzt'); $vorher = self::verteileAuf( self::bewegungen(['von' => $vorjahr['von'], 'bis' => $vorjahr['bis']] + $filter), $filter, 'vorher' ); $roh = $jetzt; foreach ($vorher as $art => $zeilen) { foreach ($zeilen as $schluessel => $werte) { foreach ($werte as $spalte => $betrag) { $roh[$art][$schluessel][$spalte] = ($roh[$art][$schluessel][$spalte] ?? 0.0) + $betrag; } } } $abschnitte = []; foreach (['einnahme' => 'Einnahmen', 'ausgabe' => 'Ausgaben'] as $art => $name) { $abschnitt = self::abschnitt($art, $name, $roh[$art] ?? [], $filter, $spalten); $abschnitt['zeilen'] = array_map(self::mitVeraenderung(...), $abschnitt['zeilen']); $abschnitt = self::mitVeraenderung($abschnitt); $abschnitte[] = $abschnitt; } $gesamt = self::leereReihe($spalten); foreach ($roh as $zeilen) { foreach ($zeilen as $werte) { foreach ($werte as $spalte => $betrag) { $gesamt[$spalte] += $betrag; } } } return [ 'darstellung' => 'summen', 'vergleich' => true, 'spalten' => $spalten, 'abschnitte' => $abschnitte, 'gesamt' => self::mitVeraenderung([ 'name' => 'Gesamt Einnahmen − Ausgaben', 'werte' => self::gerundet($gesamt), 'summe' => round(array_sum($gesamt), 2), ]), 'leer' => $abschnitte[0]['zeilen'] === [] && $abschnitte[1]['zeilen'] === [], ]; } /** * Differenz und Veränderung in Prozent zwischen den beiden Spalten. * Ohne Vorjahreswert gibt es keinen Prozentsatz – „unendlich viel mehr“ ist * keine Aussage, die in eine Spalte gehört. * * @param array $zeile * @return array */ private static function mitVeraenderung(array $zeile): array { $jetzt = (float)($zeile['werte']['jetzt'] ?? 0.0); $vorher = (float)($zeile['werte']['vorher'] ?? 0.0); $zeile['differenz'] = round($jetzt - $vorher, 2); $zeile['prozent'] = abs($vorher) < 0.005 ? null : round(($jetzt - $vorher) / abs($vorher) * 100, 1); return $zeile; } // ── Kontostandsbericht ─────────────────────────────────────────────────── /** * Salden je Konto zu den Enden der Spaltenzeiträume. Anders als die übrigen * Berichte zählt hier nicht die Bewegung im Zeitraum, sondern der Stand – * inklusive aller Buchungen davor und inklusive der Gegenseiten von * Umbuchungen. * * @param array $filter * @return array */ private static function kontostandsbericht(array $filter): array { $spalten = Zeitraum::spalten((string)$filter['aufteilung'], $filter['von'], $filter['bis']); $konten = self::gefilterteKonten($filter); $salden = []; foreach ($spalten as $spalte) { $salden[$spalte['schluessel']] = Buchung::saldenJeKonto($spalte['bis']); } $zeilen = []; $summe = self::leereReihe($spalten); foreach ($konten as $konto) { $id = (int)$konto['id']; $reihe = self::leereReihe($spalten); $leer = true; foreach ($spalten as $spalte) { $stand = (float)$konto['anfangssaldo'] + ($salden[$spalte['schluessel']][$id] ?? 0.0); $reihe[$spalte['schluessel']] = $stand; $summe[$spalte['schluessel']] += $stand; $leer = $leer && round($stand, 2) === 0.0; } // Konten, die im ganzen Zeitraum auf null standen, blähen die // Tabelle nur auf – dieselbe Regel wie in der Cockpit-Kachel. if ($leer) { continue; } $zeilen[] = [ 'schluessel' => 'konto:' . $id, 'name' => (string)$konto['name'], 'gruppe' => (string)($konto['gruppenname'] ?? ''), 'werte' => self::gerundet($reihe), 'summe' => round((float)end($reihe), 2), ]; } return [ 'darstellung' => 'kontostaende', 'spalten' => $spalten, 'zeilen' => $zeilen, 'gesamt' => [ 'name' => 'Summe', 'werte' => self::gerundet($summe), 'summe' => round((float)end($summe), 2), ], 'leer' => $zeilen === [], ]; } // ── Budgetbericht ──────────────────────────────────────────────────────── /** * Budget gegen Ist je Kategorie, mit Saldo. Gerechnet wird über die *vollen * Monate*, die der Zeitraum berührt: Ein Budget ist ein Monatswert, und ein * halber Monat hat kein halbes Budget. Der Berichtskopf nennt deshalb den * Monatsbereich, nicht das Tagesdatum. * * Vorzeichen wie im Budgetplaner (Ausgaben negativ) und Saldo als * `ist − budget`: positiv heißt besser als geplant. * * @param array $filter * @return array */ private static function budgetbericht(array $filter): array { $monate = Zeitraum::monate($filter['von'], $filter['bis']); $erster = $monate[0]; $letzter = $monate[count($monate) - 1]; // Auf volle Monate aufgezogen – sonst wären Budget und Ist nicht // vergleichbar und die Saldospalte bedeutungslos. $voll = [ 'von' => sprintf('%04d-%02d-01', $erster['jahr'], $erster['monat']), 'bis' => date('Y-m-t', strtotime(sprintf('%04d-%02d-01', $letzter['jahr'], $letzter['monat'])) ?: time()), ]; $ist = self::verteileAuf( self::bewegungen(['von' => $voll['von'], 'bis' => $voll['bis']] + $filter), ['zeilensumme' => 'kategorie'] + $filter, 'ist' ); // Je berührtem Jahr sein eigenes Budget: Ein Zeitraum über den // Jahreswechsel vergleicht sonst gegen die Planung des falschen Jahres. $geplant = []; $budgets = []; $summenBudget = []; foreach ($monate as $monat) { $budget = $budgets[$monat['jahr']] ??= Budget::aktivesFuerJahr($monat['jahr']); if ($budget === null) { continue; } $positionen = $geplant[$monat['jahr']] ??= Budget::positionen((int)$budget['id']); foreach ($positionen as $kategorieId => $werte) { $summenBudget[$kategorieId] = ($summenBudget[$kategorieId] ?? 0.0) + ($werte[$monat['monat']] ?? 0.0); } } $baum = Kategorie::baumFlach(); $zeilen = []; $gesamt = ['budget' => 0.0, 'ist' => 0.0]; // Astsummen: Eine Elternkategorie zeigt, was ihr Zweig geplant und // verbraucht hat – genau wie im Budgetplaner. $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; } foreach (array_reverse($baum) as $kategorie) { $eltern = $kategorie['eltern_id'] === null ? 0 : (int)$kategorie['eltern_id']; if (!isset($astBudget[$eltern])) { continue; } $astBudget[$eltern] += $astBudget[(int)$kategorie['id']]; $astIst[$eltern] += $astIst[(int)$kategorie['id']]; } foreach ($baum as $kategorie) { $id = (int)$kategorie['id']; if (round($astBudget[$id], 2) === 0.0 && round($astIst[$id], 2) === 0.0) { continue; } // Immer als Baum, unabhängig vom Einrückungsschalter: Die Werte sind // Astsummen, und flach untereinander sähe eine Summe wie ein // gleichrangiger Posten neben ihren eigenen Bestandteilen aus. $zeilen[] = [ 'schluessel' => 'kategorie:' . $id, 'name' => (string)$kategorie['name'], 'ebene' => (int)$kategorie['ebene'], 'kopf' => (bool)$kategorie['hat_kinder'], 'budget' => round($astBudget[$id], 2), 'ist' => round($astIst[$id], 2), 'saldo' => round($astIst[$id] - $astBudget[$id], 2), ]; // Nur Wurzeln in die Gesamtsumme, sonst zählte jeder Ast doppelt. if ($kategorie['eltern_id'] === null) { $gesamt['budget'] += $astBudget[$id]; $gesamt['ist'] += $astIst[$id]; } } return [ 'darstellung' => 'budget', 'zeilen' => $zeilen, 'monate' => $monate, 'monatsbereich' => self::monatsbereich($erster, $letzter), 'budgets' => array_filter($budgets), 'gesamt' => [ 'name' => 'Gesamt', 'budget' => round($gesamt['budget'], 2), 'ist' => round($gesamt['ist'], 2), 'saldo' => round($gesamt['ist'] - $gesamt['budget'], 2), ], 'leer' => $zeilen === [], ]; } /** @param array{jahr:int,monat:int} $erster @param array{jahr:int,monat:int} $letzter */ private static function monatsbereich(array $erster, array $letzter): string { $von = View::monat($erster['monat'], true) . ' ' . $erster['jahr']; $bis = View::monat($letzter['monat'], true) . ' ' . $letzter['jahr']; return $von === $bis ? $von : $von . ' – ' . $bis; } // ── Datenbeschaffung ───────────────────────────────────────────────────── /** * Die Bewegungen des Zeitraums: nicht gesplittete Buchungen mit ihrem * Betrag, gesplittete über ihre Splitzeilen. * * @param array $filter * @return list> */ private static function bewegungen(array $filter): array { $benutzer = Mandant::id(); $params = [$benutzer, $filter['von'], $filter['bis'], $benutzer, $filter['von'], $filter['bis']]; $sql = 'SELECT * FROM ( SELECT b.id AS buchung_id, NULL AS split_id, b.datum, b.betrag, b.konto_id, b.kategorie_id, b.klasse_id, b.gegenkonto_id, b.empfaenger, b.buchungstext AS text FROM buchungen b WHERE b.benutzer_id = ? AND b.gesplittet = 0 AND b.datum BETWEEN ? AND ? UNION ALL SELECT b.id AS buchung_id, s.id AS split_id, b.datum, s.betrag, b.konto_id, s.kategorie_id, s.klasse_id, s.gegenkonto_id, b.empfaenger, s.notiz AS text FROM buchung_splits s JOIN buchungen b ON b.id = s.buchung_id WHERE b.benutzer_id = ? AND b.gesplittet = 1 AND b.datum BETWEEN ? AND ? ) AS bewegungen'; $wo = []; // Umbuchungen sind Vermögensverschiebungen, keine Einnahmen oder // Ausgaben – nur der Buchungsbericht darf sie auf Wunsch mitführen. if (empty($filter['umbuchungen'])) { $wo[] = 'gegenkonto_id IS NULL'; } if (!empty($filter['konten'])) { $wo[] = 'konto_id IN (' . self::platzhalter($filter['konten']) . ')'; $params = array_merge($params, $filter['konten']); } if (!empty($filter['kategorien'])) { $ids = self::mitNachfahren($filter['kategorien']); $wo[] = 'kategorie_id IN (' . self::platzhalter($ids) . ')'; $params = array_merge($params, $ids); } if (!empty($filter['klassen'])) { $wo[] = 'klasse_id IN (' . self::platzhalter($filter['klassen']) . ')'; $params = array_merge($params, $filter['klassen']); } if (($filter['suche'] ?? '') !== '') { $wo[] = '(empfaenger LIKE ? OR text LIKE ?)'; $muster = '%' . $filter['suche'] . '%'; $params[] = $muster; $params[] = $muster; } if ($wo !== []) { $sql .= ' WHERE ' . implode(' AND ', $wo); } return Database::fetchAll($sql . ' ORDER BY datum, buchung_id, split_id', $params); } /** * Sortiert die Bewegungen in [Art][Zeile][Spalte] ein. * * @param list> $bewegungen * @param array $filter * @param list> $spalten * @return array>> */ 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')); $roh = []; foreach ($bewegungen as $zeile) { $spalte = Zeitraum::spaltenschluessel($aufteilung, (string)$zeile['datum']); if (!isset($gueltig[$spalte])) { continue; } $art = self::artDerBewegung($zeile, $arten); $schluessel = self::zeilenschluessel($zeile, (string)$filter['zeilensumme']); $roh[$art][$schluessel][$spalte] = ($roh[$art][$schluessel][$spalte] ?? 0.0) + (float)$zeile['betrag']; } return $roh; } /** * Wie verteile(), aber alles in eine einzige, fest benannte Spalte – für * Vergleichs- und Budgetbericht, deren Spalten nicht aus dem Kalender * kommen. * * @param list> $bewegungen * @param array $filter * @return array>> */ private static function verteileAuf(array $bewegungen, array $filter, string $spalte): array { $arten = self::kategorienArten(); $roh = []; foreach ($bewegungen as $zeile) { $art = self::artDerBewegung($zeile, $arten); $schluessel = self::zeilenschluessel($zeile, (string)$filter['zeilensumme']); $roh[$art][$schluessel][$spalte] = ($roh[$art][$schluessel][$spalte] ?? 0.0) + (float)$zeile['betrag']; } return $roh; } /** * 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. * * @param array $zeile * @param array $arten */ private static function artDerBewegung(array $zeile, array $arten): 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'; } /** * @param array $zeile * @return int|string */ private static function zeilenschluessel(array $zeile, string $dimension): int|string { return match ($dimension) { 'klasse' => (int)($zeile['klasse_id'] ?? 0), 'konto' => (int)($zeile['konto_id'] ?? 0), 'empfaenger' => trim((string)($zeile['empfaenger'] ?? '')), default => (int)($zeile['kategorie_id'] ?? 0), }; } private static function ohneName(string $dimension): string { return match ($dimension) { 'klasse' => self::OHNE_KLASSE, 'empfaenger' => self::OHNE_EMPFAENGER, 'konto' => 'Ohne Konto', default => self::OHNE_KATEGORIE, }; } /** * Anzeigenamen der Zeilendimension. Empfänger sind ihr eigener Name und * brauchen keine Tabelle. * * @return array */ private static function namen(string $dimension): array { return match ($dimension) { 'klasse' => array_column(Klasse::alle(), 'name', 'id'), 'konto' => array_column(Konto::alle(), 'name', 'id'), 'kategorie' => array_column(Kategorie::alle(), 'pfad', 'id'), default => [], }; } /** @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. * * @param list $ids * @return list */ private static function mitNachfahren(array $ids): array { $alle = Kategorie::alle(); $pfade = array_column($alle, 'pfad', 'id'); $gewaehlt = array_flip($ids); $ergebnis = []; foreach ($alle as $kategorie) { $id = (int)$kategorie['id']; if (isset($gewaehlt[$id])) { $ergebnis[$id] = $id; continue; } foreach ($ids as $elternId) { $elternPfad = $pfade[$elternId] ?? null; if ($elternPfad !== null && str_starts_with((string)$kategorie['pfad'], $elternPfad . Kategorie::TRENNER)) { $ergebnis[$id] = $id; break; } } } // Nie leer zurückgeben: Ein „IN ()“ wäre ein Syntaxfehler, und die // gewählte, inzwischen gelöschte Kategorie soll schlicht nichts treffen. return $ergebnis === [] ? [0] : array_values($ergebnis); } /** * Die Konten des Berichts – gefiltert, sonst alle, in der Reihenfolge der * Kontenübersicht. * * @param array $filter * @return list> */ private static function gefilterteKonten(array $filter): array { $konten = Konto::alle(); if (empty($filter['konten'])) { return $konten; } $gewaehlt = array_flip($filter['konten']); return array_values(array_filter( $konten, static fn(array $konto): bool => isset($gewaehlt[(int)$konto['id']]) )); } // ── Kleinkram ──────────────────────────────────────────────────────────── /** * @param list> $spalten * @return array */ private static function leereReihe(array $spalten): array { return array_fill_keys(array_column($spalten, 'schluessel'), 0.0); } /** * @param array $werte * @return array */ private static function gerundet(array $werte): array { return array_map(static fn(float $wert): float => round($wert, 2), $werte); } /** @param array $werte */ private static function istLeer(array $werte): bool { foreach ($werte as $betrag) { if (round($betrag, 2) !== 0.0) { return false; } } return true; } /** @param list $werte */ private static function platzhalter(array $werte): string { return implode(', ', array_fill(0, count($werte), '?')); } /** @return list */ private static function ids(mixed $wert): array { if (!is_array($wert)) { return []; } $ids = []; foreach ($wert as $eintrag) { if (is_scalar($eintrag) && (int)$eintrag > 0) { $ids[(int)$eintrag] = (int)$eintrag; } } return array_values($ids); } private static function bool(mixed $wert): bool { return in_array($wert, [true, 1, '1', 'true', 'ja', 'on'], true); } private static function datum(mixed $wert): string { $wert = trim((string)$wert); return preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert) === 1 ? $wert : ''; } }