Finanzmanager/src/Services/Budgetplaner.php
fds2610-bot 20b2406219 Budgetplaner: drei Ansichten und Verteilen über das Zahnrad
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>
2026-08-11 08:22:35 +02:00

189 lines
7.1 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\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;
}
}