Finanzmanager/src/Services/Vermoegensentwicklung.php
fds2610-bot 048bbf3894 Cockpit: Übrige Konten aufklappbar, Balken rücken auseinander
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:22:06 +02:00

252 lines
8.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Konto;
/**
* Die Kontostände der letzten zwölf Monate, je Konto und Monatsende – Grundlage
* der Cockpit-Kachel *Vermögensentwicklung*.
*
* Einziges Kriterium ist der Schalter „nur aktive“ an der Kachel: eingeschaltet
* zählen die geführten Konten, ausgeschaltet alle. Das Σ-Kennzeichen (`in_summe`)
* bleibt hier ausdrücklich außen vor – stillgelegte Konten tragen es meist nicht,
* und dann fiele beim Ausschalten genau das weg, was man sehen will.
*/
class Vermoegensentwicklung
{
public const MONATE = 12;
/** So viele Farbtöne hat die Palette in `app.css`. */
public const SERIEN = 12;
/**
* @param int $ebene 0 zeigt die größten Konten, jede weitere Ebene die
* jeweils nächstkleineren aus „Übrige Konten“.
*
* @return array{
* monate: list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>,
* konten: list<array{id:int, name:string, werte:list<float>}>,
* summen: list<float>,
* hatWerte: bool,
* ebene: int,
* ebenen: int
* }
*/
public static function reihe(
bool $nurAktive = true,
int $anzahl = self::MONATE,
?string $heute = null,
int $ebene = 0
): array {
$monate = self::monate($anzahl, $heute);
$erster = $monate[0];
$letzter = $monate[count($monate) - 1];
// Alles vor dem Zeitraum steckt im Anfangsstand; innerhalb zählt Monat
// für Monat dazu. So genügen zwei Abfragen statt einer je Stichtag.
$vorlauf = date('Y-m-d', strtotime($erster['bis'] . ' first day of this month -1 day'));
$anfang = Buchung::saldenJeKonto($vorlauf);
$bewegungen = Buchung::bewegungenJeKontoUndMonat(
date('Y-m-01', strtotime($erster['bis'])),
$letzter['bis']
);
$konten = [];
$summen = array_fill(0, count($monate), 0.0);
foreach (Konto::alle($nurAktive) as $konto) {
$id = (int)$konto['id'];
$stand = (float)$konto['anfangssaldo'] + ($anfang[$id] ?? 0.0);
$werte = [];
$bewegt = false;
foreach ($monate as $nummer => $monat) {
$schluessel = sprintf('%04d-%02d', $monat['jahr'], $monat['monat']);
$stand += $bewegungen[$id][$schluessel] ?? 0.0;
$werte[] = round($stand, 2);
$summen[$nummer] += $stand;
$bewegt = $bewegt || round($stand, 2) !== 0.0;
}
// Konten, die im ganzen Zeitraum auf null standen, blähen nur die
// Legende auf – sie zeichnen ohnehin keinen Balken.
if ($bewegt) {
$konten[] = ['id' => $id, 'name' => (string)$konto['name'], 'werte' => $werte];
}
}
$ebenen = self::ebenen(count($konten));
$ebene = max(0, min($ebene, $ebenen - 1));
if (count($konten) > self::SERIEN) {
$konten = self::zusammenfassen($konten, count($monate), $ebene);
}
$hatWerte = false;
foreach ($summen as $nummer => $summe) {
$summen[$nummer] = round($summe, 2);
$hatWerte = $hatWerte || $summen[$nummer] !== 0.0;
}
return [
'monate' => $monate,
'konten' => $konten,
'summen' => array_values($summen),
'hatWerte' => $hatWerte || $konten !== [],
'ebene' => $ebene,
'ebenen' => $ebenen,
];
}
/**
* Wie viele Ebenen es braucht, um `$konten` Konten einzeln zu zeigen: Jede
* Ebene zeigt so viele Konten, wie Farbtöne da sind – eine weniger, wenn
* noch ein Stück „Übrige Konten“ dazukommt.
*/
public static function ebenen(int $konten): int
{
if ($konten <= self::SERIEN) {
return 1;
}
return 1 + (int)ceil(($konten - self::SERIEN) / (self::SERIEN - 1));
}
/**
* Mehr Konten als Farbtöne: Die größten behalten ihre eigene Farbe, der Rest
* wird zu einem Balkenstück zusammengefasst. Sonst bekämen zwei Konten
* denselben Ton – und wer die Legende liest, ordnete falsch zu.
*
* `$ebene` blättert in diesem Rest weiter: Ebene 1 überspringt die größten
* Konten und zeigt die nächstkleineren einzeln, Ebene 2 die darauf folgenden
* und so fort. Passt der Rest in die Palette, entfällt das Sammelstück.
*
* @param list<array{id:int, name:string, werte:list<float>}> $konten
* @return list<array{id:int, name:string, werte:list<float>}>
*/
private static function zusammenfassen(array $konten, int $spalten, int $ebene = 0): array
{
$groesse = [];
foreach ($konten as $nummer => $konto) {
$groesse[$nummer] = max(array_map('abs', $konto['werte']));
}
arsort($groesse);
$rangfolge = array_slice(array_keys($groesse), $ebene * (self::SERIEN - 1));
// Passt der Rest vollständig in die Palette, braucht es kein Sammelstück
// mehr – dann ist das die letzte Ebene.
$sammelt = count($rangfolge) > self::SERIEN;
// Die größten behalten, aber in ihrer ursprünglichen Reihenfolge – der
// Stapel soll nicht bei jedem Aufruf anders sortiert sein.
$eigene = $sammelt ? array_slice($rangfolge, 0, self::SERIEN - 1) : $rangfolge;
sort($eigene);
$rest = array_fill(0, $spalten, 0.0);
$restKonten = 0;
$ergebnis = [];
foreach ($konten as $nummer => $konto) {
if (in_array($nummer, $eigene, true)) {
$ergebnis[] = $konto;
continue;
}
// Was auf einer früheren Ebene stand, ist hier gar nicht mehr dabei –
// sonst wüchse das Sammelstück mit jeder Ebene statt zu schrumpfen.
if (!in_array($nummer, $rangfolge, true)) {
continue;
}
foreach ($konto['werte'] as $spalte => $wert) {
$rest[$spalte] += $wert;
}
$restKonten++;
}
if ($restKonten > 0) {
$ergebnis[] = [
'id' => 0,
'name' => 'Übrige Konten (' . $restKonten . ')',
'werte' => array_map(static fn(float $wert): float => round($wert, 2), $rest),
];
}
return $ergebnis;
}
/**
* Die letzten `$anzahl` Monate bis einschließlich des laufenden, jeweils mit
* dem Monatsletzten als Stichtag.
*
* @return list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>
*/
public static function monate(int $anzahl = self::MONATE, ?string $heute = null): array
{
$heute = $heute ?? date('Y-m-d');
$start = strtotime(substr($heute, 0, 8) . '01');
$monate = [];
for ($rueckwaerts = $anzahl - 1; $rueckwaerts >= 0; $rueckwaerts--) {
$zeit = strtotime('-' . $rueckwaerts . ' month', $start);
$jahr = (int)date('Y', $zeit);
$nr = (int)date('n', $zeit);
$monate[] = [
'jahr' => $jahr,
'monat' => $nr,
'bis' => date('Y-m-t', $zeit),
'kuerzel' => Budgetplaner::MONATSKUERZEL[$nr],
'jahrKurz' => date('y', $zeit),
];
}
return $monate;
}
/**
* Skala für die Achse: Nulllinie inbegriffen, Schritte in 1/2/2,5/5 mal
* Zehnerpotenz – die Beschriftung soll lesbare Zahlen zeigen, nicht die
* krummen Maxima der Konten.
*
* @param list<float> $werte
* @return array{min: float, max: float, schritt: float}
*/
public static function skala(array $werte): array
{
$max = max(0.0, ...($werte === [] ? [0.0] : $werte));
$min = min(0.0, ...($werte === [] ? [0.0] : $werte));
if ($max - $min < 0.01) {
return ['min' => 0.0, 'max' => 1.0, 'schritt' => 1.0];
}
$schritt = self::schritt(($max - $min) / 4);
return [
'min' => floor($min / $schritt) * $schritt,
'max' => ceil($max / $schritt) * $schritt,
'schritt' => $schritt,
];
}
private static function schritt(float $roh): float
{
$potenz = 10 ** floor(log10(max($roh, 0.01)));
foreach ([1, 2, 2.5, 5, 10] as $faktor) {
if ($roh <= $faktor * $potenz) {
return $faktor * $potenz;
}
}
return 10 * $potenz;
}
}