Fünf Berichtstypen wie im FinanzManager – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht – mit Filterleiste, gespeicherten Berichten in Berichtsgruppen, Drilldown in die Buchungen und CSV-Export. Ein Bericht speichert nur seine Frage; die Zahlen entstehen bei jedem Aufruf neu aus den Buchungen. Gezählt werden Bewegungen (gesplittete Buchungen über ihre Splitzeilen), Umbuchungen bleiben außen vor, Unkategorisiertes bekommt eine eigene Zeile. Im Ausgabenabschnitt steht der Betrag ohne Minus wie im Original, die Schlusszeile rechnet mit den echten Vorzeichen. Einzelheiten in docs/auswertungen.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1225 lines
46 KiB
PHP
1225 lines
46 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]
|
||
);
|
||
}
|
||
|
||
// ── 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 : '';
|
||
}
|
||
}
|