Abgelaufene Monate werden nicht mehr zusammengefasst – alle zwölf Monate stehen einzeln da und bleiben beplanbar. Statt Verlauf und alle Monate gibt es Alles, Nur Budget und Nur IST; die Budgetspalte ist überall dort editierbar, wo sie steht. Das Zahnrad vor jedem Feld verteilt dessen Wert auf das ganze oder das restliche Jahr. Es arbeitet auf den Eingabefeldern statt auf der Datenbank: Das Ergebnis ist vor dem Speichern zu sehen und geht mit demselben Knopf weg wie jede andere Änderung. Nebenbei: app.js war doppelt eingebunden, jeder Handler lief zweimal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
189 lines
7.1 KiB
PHP
189 lines
7.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Services;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Mandant;
|
||
use Finanzmanager\Models\Budget;
|
||
use Finanzmanager\Models\Kategorie;
|
||
|
||
/**
|
||
* Stellt die Tabelle des Budgetplaners zusammen: Zeilen sind der Kategoriebaum,
|
||
* Spalten die zwölf Monate mit Budget, Ist und Saldo. Zusammengefasst wird
|
||
* nichts – auch abgelaufene Monate stehen einzeln da und bleiben beplanbar;
|
||
* welche der drei Wertspalten je Monat erscheinen, entscheidet die Ansicht.
|
||
*
|
||
* Vorzeichen durchgehend wie in der Buchhaltung (Ausgaben negativ). Daraus
|
||
* folgt eine Definition, die für Einnahmen wie Ausgaben gleich funktioniert:
|
||
*
|
||
* saldo = ist - budget
|
||
*
|
||
* Positiv heißt „besser als geplant“ – bei Ausgaben weniger ausgegeben, bei
|
||
* Einnahmen mehr eingenommen. Negativ ist die Überschreitung, die der Planer
|
||
* rot ausweist.
|
||
*
|
||
* Elternkategorien zeigen die Summe ihrer Nachfahren zusätzlich zu eigenen
|
||
* Buchungen; im FinanzManager verhält sich der Baum genauso.
|
||
*/
|
||
class Budgetplaner
|
||
{
|
||
public const MONATSNAMEN = [
|
||
1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April',
|
||
5 => 'Mai', 6 => 'Juni', 7 => 'Juli', 8 => 'August',
|
||
9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember',
|
||
];
|
||
|
||
public const MONATSKUERZEL = [
|
||
1 => 'Jan', 2 => 'Feb', 3 => 'Mär', 4 => 'Apr', 5 => 'Mai', 6 => 'Jun',
|
||
7 => 'Jul', 8 => 'Aug', 9 => 'Sep', 10 => 'Okt', 11 => 'Nov', 12 => 'Dez',
|
||
];
|
||
|
||
/**
|
||
* Ist-Werte je Kategorie und Monat aus den Buchungen eines Jahres.
|
||
*
|
||
* Gesplittete Buchungen zählen über ihre Splitzeilen, alle anderen über die
|
||
* Buchung selbst – sonst würde eine Splitbuchung doppelt oder gar nicht
|
||
* erfasst. Umbuchungen tragen keine Kategorie und bleiben damit außen vor,
|
||
* wie im FinanzManager auch.
|
||
*
|
||
* @return array<int, array<int, float>> [kategorie_id][monat] => Betrag
|
||
*/
|
||
public static function istWerte(int $jahr): array
|
||
{
|
||
$zeilen = Database::fetchAll(
|
||
'SELECT kategorie_id, monat, SUM(betrag) AS betrag FROM (
|
||
SELECT b.kategorie_id AS kategorie_id, MONTH(b.datum) AS monat, b.betrag AS betrag
|
||
FROM buchungen b
|
||
WHERE b.benutzer_id = ? AND YEAR(b.datum) = ?
|
||
AND b.gesplittet = 0 AND b.kategorie_id IS NOT NULL
|
||
UNION ALL
|
||
SELECT s.kategorie_id AS kategorie_id, MONTH(b.datum) AS monat, s.betrag AS betrag
|
||
FROM buchung_splits s
|
||
JOIN buchungen b ON b.id = s.buchung_id
|
||
WHERE b.benutzer_id = ? AND YEAR(b.datum) = ?
|
||
AND b.gesplittet = 1 AND s.kategorie_id IS NOT NULL
|
||
) AS bewegungen
|
||
GROUP BY kategorie_id, monat',
|
||
[Mandant::id(), $jahr, Mandant::id(), $jahr]
|
||
);
|
||
|
||
$werte = [];
|
||
foreach ($zeilen as $zeile) {
|
||
$werte[(int)$zeile['kategorie_id']][(int)$zeile['monat']] = (float)$zeile['betrag'];
|
||
}
|
||
|
||
return $werte;
|
||
}
|
||
|
||
/**
|
||
* Die vollständige Tabelle: eine Zeile je Kategorie in Baumreihenfolge,
|
||
* dazu die Gesamtzeile.
|
||
*
|
||
* @param array<string,mixed> $budget
|
||
* @return array{zeilen: list<array<string,mixed>>, gesamt: array<string,mixed>, jahr: int}
|
||
*/
|
||
public static function tabelle(array $budget): array
|
||
{
|
||
$jahr = (int)$budget['jahr'];
|
||
$geplant = Budget::positionen((int)$budget['id']);
|
||
$ist = self::istWerte($jahr);
|
||
|
||
$kategorien = Kategorie::baumFlach();
|
||
|
||
// Erst die eigenen Werte je Kategorie, dann von unten nach oben die
|
||
// Summen der Nachfahren aufaddieren.
|
||
$zeilen = [];
|
||
foreach ($kategorien as $kategorie) {
|
||
$id = (int)$kategorie['id'];
|
||
|
||
$zeilen[$id] = $kategorie + [
|
||
'budget' => self::monatsreihe($geplant[$id] ?? []),
|
||
'ist' => self::monatsreihe($ist[$id] ?? []),
|
||
// Ungefüllt statt mit Nullen aufgefüllt: Nur so bleibt „nicht
|
||
// geplant“ von „mit 0,00 geplant“ unterscheidbar. Der Planer
|
||
// zeigt in dem einen Fall ein leeres Feld, im anderen eine Null.
|
||
'eigenBudget' => $geplant[$id] ?? [],
|
||
];
|
||
}
|
||
|
||
foreach (array_reverse(array_keys($zeilen)) as $id) {
|
||
$eltern = $zeilen[$id]['eltern_id'] === null ? null : (int)$zeilen[$id]['eltern_id'];
|
||
if ($eltern === null || !isset($zeilen[$eltern])) {
|
||
continue;
|
||
}
|
||
for ($monat = 1; $monat <= 12; $monat++) {
|
||
$zeilen[$eltern]['budget'][$monat] += $zeilen[$id]['budget'][$monat];
|
||
$zeilen[$eltern]['ist'][$monat] += $zeilen[$id]['ist'][$monat];
|
||
}
|
||
}
|
||
|
||
$gesamtBudget = self::monatsreihe([]);
|
||
$gesamtIst = self::monatsreihe([]);
|
||
|
||
foreach ($zeilen as &$zeile) {
|
||
$zeile['saldo'] = self::differenz($zeile['ist'], $zeile['budget']);
|
||
$zeile['jahrBudget'] = array_sum($zeile['budget']);
|
||
$zeile['jahrIst'] = array_sum($zeile['ist']);
|
||
$zeile['jahrSaldo'] = $zeile['jahrIst'] - $zeile['jahrBudget'];
|
||
$zeile['geplant'] = $zeile['jahrBudget'] !== 0.0 || $zeile['jahrIst'] !== 0.0;
|
||
|
||
// Nur Wurzeln summieren, sonst zählten Unterkategorien mehrfach.
|
||
if ($zeile['eltern_id'] === null) {
|
||
for ($monat = 1; $monat <= 12; $monat++) {
|
||
$gesamtBudget[$monat] += $zeile['budget'][$monat];
|
||
$gesamtIst[$monat] += $zeile['ist'][$monat];
|
||
}
|
||
}
|
||
}
|
||
unset($zeile);
|
||
|
||
return [
|
||
'jahr' => $jahr,
|
||
'zeilen' => array_values($zeilen),
|
||
'gesamt' => [
|
||
'budget' => $gesamtBudget,
|
||
'ist' => $gesamtIst,
|
||
'saldo' => self::differenz($gesamtIst, $gesamtBudget),
|
||
'jahrBudget' => array_sum($gesamtBudget),
|
||
'jahrIst' => array_sum($gesamtIst),
|
||
'jahrSaldo' => array_sum($gesamtIst) - array_sum($gesamtBudget),
|
||
],
|
||
];
|
||
}
|
||
|
||
/** Der Monat, der im laufenden Jahr gerade läuft – sonst 0. */
|
||
public static function laufenderMonat(int $jahr, ?string $heute = null): int
|
||
{
|
||
$heute = $heute ?? date('Y-m-d');
|
||
return $jahr === (int)substr($heute, 0, 4) ? (int)substr($heute, 5, 2) : 0;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, float> $werte
|
||
* @return array<int, float>
|
||
*/
|
||
private static function monatsreihe(array $werte): array
|
||
{
|
||
$reihe = [];
|
||
for ($monat = 1; $monat <= 12; $monat++) {
|
||
$reihe[$monat] = (float)($werte[$monat] ?? 0.0);
|
||
}
|
||
return $reihe;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, float> $ist
|
||
* @param array<int, float> $budget
|
||
* @return array<int, float>
|
||
*/
|
||
private static function differenz(array $ist, array $budget): array
|
||
{
|
||
$saldo = [];
|
||
for ($monat = 1; $monat <= 12; $monat++) {
|
||
$saldo[$monat] = $ist[$monat] - $budget[$monat];
|
||
}
|
||
return $saldo;
|
||
}
|
||
}
|