252 lines
8.6 KiB
PHP
252 lines
8.6 KiB
PHP
<?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;
|
||
}
|
||
}
|