Damit ist der geplante Funktionsumfang beisammen. Phase 4 abgeschlossen - Konfigurierbare Cockpit-Kacheln; abgewählte werden nicht berechnet - Einnahmen/Ausgaben je Monat als Balkenpaare mit Saldolinie, aufgesetzt auf den Summenbericht statt auf eine eigene Abfrage - Meine Ausgaben als Ring auf der obersten Kategorieebene - Offene Vorgänge aus allen Modulen Phase 5 – Online-Banking - nemiah/php-fints als Fork auf Codeberg, psr/log direkt eingebaut: die Anwendung hängt an keinem fremden Update-Zyklus - PIN AES-256-GCM-verschlüsselt; Schlüssel aus APP_KEY oder aus data/schluessel/app_key, den der Entrypoint einmalig erzeugt - TAN-Dialog über mehrere Requests (FinTs::persist plus serialisierte Aktion in der Sitzung) – ein wartender Prozess wäre mit php-fpm der falsche Weg - Abgleich statt Direktbuchung: verbinden, übernehmen oder verwerfen - Regeln mit vier Vergleichsarten, Betragsgrenzen und Split-Schema Phase 6 – Wertpapiere - Depotbuchung und Geldbuchung als zwei Seiten eines Geschäfts; betrag ist der Bruttowert, Nebenkosten stehen daneben - Einstand nach gleitendem Durchschnitt, realisierte Gewinne, Erträge - Kursabruf über Leeway oder FMP, täglicher Lauf per Cron - QIF-Import für !Type:Invst Dabei behoben: Der Import buchte den Geldfluss jedes Wertpapiergeschäfts als Abgang – auch bei Verkäufen und Dividenden; und er erkannte das Verrechnungskonto nicht, wenn dem Konto im L-Feld eine Kategorie vorangeht (bei Verkäufen also immer). Phase 7 – Nebenmodule und Zahlungsverkehr - Buchungsvorlagen inklusive QIF-Import, Splits werden mitskaliert - Regelmäßige Buchungen mit Fälligkeitslauf, Finanzkalender, Erinnerungen - Adressen mit IBAN-Prüfung, Verträge mit Kündigungsfrist, Sparziele - Kredite mit gerechnetem Tilgungsplan - Überweisungen über FinTS; die Buchung entsteht erst nach Annahme durch die Bank. Die pain.001-Nachricht erzeugt SepaXml selbst Nebenbei: .dockerignore ergänzt (ohne sie brach der Build an data/mariadb ab), PlatzhalterController entfernt, zwei Inline-Handler beseitigt, die die Content-Security-Policy ohnehin blockierte. 888 PHP-Prüfungen und 45 E2E-Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1367 lines
52 KiB
PHP
1367 lines
52 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Services;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Mandant;
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Budget;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Klasse;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\View;
|
||
|
||
/**
|
||
* Die Rechenmaschine hinter den Auswertungen: aus einem Filter wird eine
|
||
* Tabelle. Fünf Berichtstypen wie im FinanzManager – Summen-, Buchungs-,
|
||
* Vergleichs-, Kontostands- und Budgetbericht.
|
||
*
|
||
* **Was gezählt wird.** Grundlage sind die *Bewegungen*: nicht gesplittete
|
||
* Buchungen mit ihrem Betrag, gesplittete über ihre Splitzeilen. Sonst zählte
|
||
* eine Splitbuchung doppelt oder gar nicht. Umbuchungen bleiben außen vor – sie
|
||
* verschieben Vermögen zwischen eigenen Konten und sind weder Einnahme noch
|
||
* Ausgabe; der Buchungsbericht kann sie auf Wunsch mitführen.
|
||
*
|
||
* **Aggregiert wird in PHP**, nicht in SQL. Die Datenbank liefert die
|
||
* Bewegungen des Zeitraums, das Einsortieren in Zeilen und Spalten passiert
|
||
* hier. Bei Zehntausenden Buchungen ist das Millisekundensache, und Baumsummen
|
||
* über Kategorien, Vorzeichenregeln und Spaltenraster wären in SQL erheblich
|
||
* schwerer nachzulesen.
|
||
*
|
||
* **Vorzeichen.** Intern gilt durchgehend die Buchhaltung: Ausgaben negativ.
|
||
* 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.
|
||
*/
|
||
class Auswertung
|
||
{
|
||
/** Die Berichtstypen, in der Reihenfolge der Standardvorlagen. */
|
||
public const TYPEN = [
|
||
'summenbericht' => '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<string,mixed> $roh
|
||
* @return array<string,mixed>
|
||
*/
|
||
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<string,mixed> $filter roh oder normalisiert
|
||
* @return array<string,mixed>
|
||
*/
|
||
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<string,mixed> $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]
|
||
);
|
||
}
|
||
|
||
// ── Verteilung ───────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Wie sich Einnahmen oder Ausgaben eines Zeitraums auf die obersten
|
||
* Kategorien verteilen – die Zahlen hinter der Cockpit-Kachel *Meine
|
||
* Ausgaben*.
|
||
*
|
||
* Zusammengefasst wird auf der obersten Ebene: „400 - Leben“ statt fünfzehn
|
||
* Unterkategorien. Ein Ring mit hundert Segmenten sagt nichts; wer es genauer
|
||
* braucht, klickt sich in den Summenbericht.
|
||
*
|
||
* Die Beträge kommen vorzeichenlos zurück – gedreht wie im Ausgabenabschnitt
|
||
* der Berichte, damit die Kachel 636,39 zeigt und nicht −636,39.
|
||
*
|
||
* @param array<string,mixed> $filter roh oder normalisiert
|
||
* @param int $grenze So viele Segmente einzeln, der Rest wird gesammelt.
|
||
* @return array{
|
||
* art: string,
|
||
* zeilen: list<array{id:int, name:string, betrag:float, anteil:float}>,
|
||
* summe: float,
|
||
* zeitraum: array{vorgabe:string, von:string, bis:string},
|
||
* beschriftung: string,
|
||
* leer: bool
|
||
* }
|
||
*/
|
||
public static function verteilung(
|
||
array $filter,
|
||
string $art = 'ausgabe',
|
||
int $grenze = 8,
|
||
?string $heute = null
|
||
): array {
|
||
$art = $art === 'einnahme' ? 'einnahme' : 'ausgabe';
|
||
$filter = self::normalisiere($filter);
|
||
$zeitraum = self::zeitraum($filter, $heute);
|
||
|
||
$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) {
|
||
continue;
|
||
}
|
||
|
||
$kategorie = (int)($bewegung['kategorie_id'] ?? 0);
|
||
$wurzel = $wurzeln[$kategorie] ?? 0;
|
||
|
||
$summen[$wurzel] = ($summen[$wurzel] ?? 0.0) + (float)$bewegung['betrag'] * $faktor;
|
||
}
|
||
|
||
return self::segmente($summen, $art, $grenze, $zeitraum);
|
||
}
|
||
|
||
/**
|
||
* Aus den Rohsummen je Wurzelkategorie werden die Segmente der Kachel:
|
||
* absteigend sortiert, auf `$grenze` gekürzt, mit Anteil an der Summe.
|
||
*
|
||
* Negative Werte fallen heraus – eine Erstattung, die eine Kategorie unter
|
||
* null drückt, hätte im Ring kein sinnvolles Segment. In der Summe steht sie
|
||
* weiter drin, sonst ergänzten sich die Anteile nicht mehr zu hundert.
|
||
*
|
||
* @param array<int,float> $summen
|
||
* @param array{vorgabe:string, von:string, bis:string} $zeitraum
|
||
* @return array<string,mixed>
|
||
*/
|
||
private static function segmente(array $summen, string $art, int $grenze, array $zeitraum): array
|
||
{
|
||
$namen = array_column(Kategorie::alle(), 'pfad', 'id');
|
||
$gesamt = round(array_sum($summen), 2);
|
||
|
||
arsort($summen);
|
||
|
||
$zeilen = [];
|
||
foreach ($summen as $id => $betrag) {
|
||
$betrag = round($betrag, 2);
|
||
if ($betrag <= 0.0) {
|
||
continue;
|
||
}
|
||
$zeilen[] = [
|
||
'id' => (int)$id,
|
||
'name' => $id === 0 ? self::OHNE_KATEGORIE : (string)($namen[$id] ?? self::OHNE_KATEGORIE),
|
||
'betrag' => $betrag,
|
||
'anteil' => 0.0,
|
||
];
|
||
}
|
||
|
||
if (count($zeilen) > $grenze) {
|
||
$rest = array_slice($zeilen, $grenze - 1);
|
||
$zeilen = array_slice($zeilen, 0, $grenze - 1);
|
||
$zeilen[] = [
|
||
'id' => 0,
|
||
'name' => 'Übrige (' . count($rest) . ')',
|
||
'betrag' => round(array_sum(array_column($rest, 'betrag')), 2),
|
||
'anteil' => 0.0,
|
||
];
|
||
}
|
||
|
||
$bezug = array_sum(array_column($zeilen, 'betrag'));
|
||
foreach ($zeilen as $nummer => $zeile) {
|
||
$zeilen[$nummer]['anteil'] = $bezug > 0.0 ? $zeile['betrag'] / $bezug : 0.0;
|
||
}
|
||
|
||
return [
|
||
'art' => $art,
|
||
'zeilen' => $zeilen,
|
||
'summe' => $gesamt,
|
||
'zeitraum' => $zeitraum,
|
||
'beschriftung' => Zeitraum::beschriftung($zeitraum['von'], $zeitraum['bis']),
|
||
'leer' => $zeilen === [],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Jede Kategorie auf ihre oberste Elternkategorie abgebildet. Über den Pfad
|
||
* statt über die eltern_id-Kette: Der Pfad trägt die Hierarchie ohnehin und
|
||
* erspart das Nachlaufen bei tiefen Bäumen.
|
||
*
|
||
* @return array<int,int> kategorie_id => id der Wurzel
|
||
*/
|
||
private static function wurzeln(): array
|
||
{
|
||
$alle = Kategorie::alle();
|
||
$nachPfad = [];
|
||
foreach ($alle as $kategorie) {
|
||
$nachPfad[(string)$kategorie['pfad']] = (int)$kategorie['id'];
|
||
}
|
||
|
||
$wurzeln = [];
|
||
foreach ($alle as $kategorie) {
|
||
$id = (int)$kategorie['id'];
|
||
$wurzel = explode(Kategorie::TRENNER, (string)$kategorie['pfad'])[0];
|
||
|
||
$wurzeln[$id] = $nachPfad[$wurzel] ?? $id;
|
||
}
|
||
|
||
return $wurzeln;
|
||
}
|
||
|
||
// ── Summenbericht ────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Summen je Zeilendimension und Spalte, getrennt nach Einnahmen und
|
||
* Ausgaben, mit Gesamtzeilen.
|
||
*
|
||
* @param array<string,mixed> $filter
|
||
* @return array<string,mixed>
|
||
*/
|
||
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<string|int, array<string,float>> $werte
|
||
* @param array<string,mixed> $filter
|
||
* @param list<array<string,string>> $spalten
|
||
* @return array<string,mixed>
|
||
*/
|
||
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 <Name>“ mit der Summe des Astes. Genau so sieht der Summenbericht
|
||
* des FinanzManagers aus.
|
||
*
|
||
* @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
|
||
{
|
||
$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<array{id:int, ebene:int, name:string}> $offen
|
||
* @param array<int, array<string,float>> $ast
|
||
* @param list<array<string,string>> $spalten
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
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<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 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<string|int, array<string,float>> $werte
|
||
* @param list<array<string,string>> $spalten
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
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<string,float> $werte
|
||
* @param list<array<string,string>> $spalten
|
||
* @param array<string,mixed> $zusatz
|
||
* @return array<string,mixed>
|
||
*/
|
||
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<string,mixed> $filter
|
||
* @return array<string,mixed>
|
||
*/
|
||
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<string,mixed> $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<string,mixed>
|
||
*/
|
||
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<array<string,mixed>> $bewegungen
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
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<string,mixed> $filter
|
||
* @return array<string,mixed>
|
||
*/
|
||
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<string,mixed> $zeile
|
||
* @return array<string,mixed>
|
||
*/
|
||
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<string,mixed> $filter
|
||
* @return array<string,mixed>
|
||
*/
|
||
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<string,mixed> $filter
|
||
* @return array<string,mixed>
|
||
*/
|
||
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<string,mixed> $filter
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
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<array<string,mixed>> $bewegungen
|
||
* @param array<string,mixed> $filter
|
||
* @param list<array<string,string>> $spalten
|
||
* @return array<string, array<string|int, array<string,float>>>
|
||
*/
|
||
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<array<string,mixed>> $bewegungen
|
||
* @param array<string,mixed> $filter
|
||
* @return array<string, array<string|int, array<string,float>>>
|
||
*/
|
||
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<string,mixed> $zeile
|
||
* @param array<int,string> $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<string,mixed> $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<int|string, string>
|
||
*/
|
||
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<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.
|
||
*
|
||
* @param list<int> $ids
|
||
* @return list<int>
|
||
*/
|
||
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<string,mixed> $filter
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
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<array<string,string>> $spalten
|
||
* @return array<string,float>
|
||
*/
|
||
private static function leereReihe(array $spalten): array
|
||
{
|
||
return array_fill_keys(array_column($spalten, 'schluessel'), 0.0);
|
||
}
|
||
|
||
/**
|
||
* @param array<string,float> $werte
|
||
* @return array<string,float>
|
||
*/
|
||
private static function gerundet(array $werte): array
|
||
{
|
||
return array_map(static fn(float $wert): float => round($wert, 2), $werte);
|
||
}
|
||
|
||
/** @param array<string,float> $werte */
|
||
private static function istLeer(array $werte): bool
|
||
{
|
||
foreach ($werte as $betrag) {
|
||
if (round($betrag, 2) !== 0.0) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** @param list<mixed> $werte */
|
||
private static function platzhalter(array $werte): string
|
||
{
|
||
return implode(', ', array_fill(0, count($werte), '?'));
|
||
}
|
||
|
||
/** @return list<int> */
|
||
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 : '';
|
||
}
|
||
}
|