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>
237 lines
8.1 KiB
PHP
237 lines
8.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Models;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Mandant;
|
||
|
||
/**
|
||
* Ein Budget ist die Jahresplanung je Kategorie und Monat – der Budgetplaner
|
||
* des FinanzManagers. Mehrere Budgets pro Jahr sind möglich, genau eines ist
|
||
* das aktive.
|
||
*
|
||
* Vorzeichen wie in der Buchhaltung: Ausgaben negativ, Einnahmen positiv. Der
|
||
* Nutzer tippt trotzdem den nackten Betrag ein („400“ bei Lebensmitteln);
|
||
* positionSetzen() dreht ihn anhand der Kategorieart. So stimmen Budget und Ist
|
||
* ohne Umrechnung überein.
|
||
*/
|
||
class Budget
|
||
{
|
||
/** @return list<array<string,mixed>> */
|
||
public static function alle(): array
|
||
{
|
||
return Database::fetchAll(
|
||
'SELECT * FROM budgets WHERE benutzer_id = ? ORDER BY jahr DESC, name',
|
||
[Mandant::id()]
|
||
);
|
||
}
|
||
|
||
public static function find(int $id): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM budgets WHERE id = ? AND benutzer_id = ?',
|
||
[$id, Mandant::id()]
|
||
);
|
||
return $zeile === false ? null : $zeile;
|
||
}
|
||
|
||
public static function findeNachName(string $name): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM budgets WHERE name = ? AND benutzer_id = ?',
|
||
[trim($name), Mandant::id()]
|
||
);
|
||
return $zeile === false ? null : $zeile;
|
||
}
|
||
|
||
/** Das aktive Budget, ersatzweise das jüngste – oder null, wenn keines existiert. */
|
||
public static function aktives(): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM budgets WHERE benutzer_id = ? ORDER BY aktiv DESC, jahr DESC, name LIMIT 1',
|
||
[Mandant::id()]
|
||
);
|
||
return $zeile === false ? null : $zeile;
|
||
}
|
||
|
||
/**
|
||
* Das aktive Budget eines bestimmten Jahres, ersatzweise irgendeines aus
|
||
* diesem Jahr. Der Budgetbericht braucht das: Ein Zeitraum kann über den
|
||
* Jahreswechsel gehen, und dann gilt je Jahr dessen eigene Planung.
|
||
*/
|
||
public static function aktivesFuerJahr(int $jahr): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM budgets WHERE benutzer_id = ? AND jahr = ? ORDER BY aktiv DESC, name LIMIT 1',
|
||
[Mandant::id(), $jahr]
|
||
);
|
||
return $zeile === false ? null : $zeile;
|
||
}
|
||
|
||
public static function anlegen(string $name, int $jahr, string $beschreibung = '', bool $aktiv = false): int
|
||
{
|
||
$name = trim($name);
|
||
if ($name === '') {
|
||
throw new \InvalidArgumentException('Das Budget braucht einen Namen.');
|
||
}
|
||
if ($jahr < 1900 || $jahr > 2200) {
|
||
throw new \InvalidArgumentException('Das Jahr des Budgets ist ungültig.');
|
||
}
|
||
if (self::findeNachName($name) !== null) {
|
||
throw new \RuntimeException('Das Budget „' . $name . '“ gibt es bereits.');
|
||
}
|
||
|
||
Database::execute(
|
||
'INSERT INTO budgets (benutzer_id, name, jahr, beschreibung, aktiv) VALUES (?, ?, ?, ?, 0)',
|
||
[Mandant::id(), $name, $jahr, $beschreibung]
|
||
);
|
||
$id = Database::lastInsertId();
|
||
|
||
// Das erste Budget ist immer das aktive – sonst zeigt der Planer nichts.
|
||
if ($aktiv || self::anzahl() === 1) {
|
||
self::aktivieren($id);
|
||
}
|
||
|
||
return $id;
|
||
}
|
||
|
||
public static function speichern(int $id, string $name, int $jahr, string $beschreibung): void
|
||
{
|
||
$name = trim($name);
|
||
if ($name === '') {
|
||
throw new \InvalidArgumentException('Das Budget braucht einen Namen.');
|
||
}
|
||
|
||
$andere = self::findeNachName($name);
|
||
if ($andere !== null && (int)$andere['id'] !== $id) {
|
||
throw new \RuntimeException('Das Budget „' . $name . '“ gibt es bereits.');
|
||
}
|
||
|
||
Database::execute(
|
||
'UPDATE budgets SET name = ?, jahr = ?, beschreibung = ? WHERE id = ? AND benutzer_id = ?',
|
||
[$name, $jahr, $beschreibung, $id, Mandant::id()]
|
||
);
|
||
}
|
||
|
||
public static function aktivieren(int $id): void
|
||
{
|
||
Database::transaktion(static function () use ($id): void {
|
||
Database::execute('UPDATE budgets SET aktiv = 0 WHERE benutzer_id = ?', [Mandant::id()]);
|
||
Database::execute(
|
||
'UPDATE budgets SET aktiv = 1 WHERE id = ? AND benutzer_id = ?',
|
||
[$id, Mandant::id()]
|
||
);
|
||
});
|
||
}
|
||
|
||
public static function loeschen(int $id): void
|
||
{
|
||
$war = self::find($id);
|
||
Database::execute('DELETE FROM budgets WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
||
|
||
// War es das aktive, rückt das nächste nach – ohne aktives Budget stünde
|
||
// der Planer leer da, obwohl es noch Budgets gibt.
|
||
if ($war !== null && (int)$war['aktiv'] === 1) {
|
||
$naechstes = self::aktives();
|
||
if ($naechstes !== null) {
|
||
self::aktivieren((int)$naechstes['id']);
|
||
}
|
||
}
|
||
}
|
||
|
||
public static function anzahl(): int
|
||
{
|
||
return (int)Database::fetchValue(
|
||
'SELECT COUNT(*) FROM budgets WHERE benutzer_id = ?',
|
||
[Mandant::id()],
|
||
0
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Alle Positionen eines Budgets als [kategorie_id][monat] => Betrag.
|
||
*
|
||
* @return array<int, array<int, float>>
|
||
*/
|
||
public static function positionen(int $budgetId): array
|
||
{
|
||
$zeilen = Database::fetchAll(
|
||
'SELECT kategorie_id, monat, betrag FROM budget_positionen WHERE budget_id = ?',
|
||
[$budgetId]
|
||
);
|
||
|
||
$werte = [];
|
||
foreach ($zeilen as $zeile) {
|
||
$werte[(int)$zeile['kategorie_id']][(int)$zeile['monat']] = (float)$zeile['betrag'];
|
||
}
|
||
|
||
return $werte;
|
||
}
|
||
|
||
/**
|
||
* Setzt einen Monatswert. Der Betrag kommt ohne Vorzeichen aus dem Formular
|
||
* und bekommt es hier nach der Kategorieart. Ein leeres Feld (null) löscht
|
||
* die Position – „nicht geplant“ ist etwas anderes als „0,00 geplant“.
|
||
*/
|
||
public static function positionSetzen(int $budgetId, int $kategorieId, int $monat, ?float $betrag): void
|
||
{
|
||
if ($monat < 1 || $monat > 12) {
|
||
throw new \InvalidArgumentException('Monat muss zwischen 1 und 12 liegen.');
|
||
}
|
||
|
||
if ($betrag === null) {
|
||
Database::execute(
|
||
'DELETE FROM budget_positionen WHERE budget_id = ? AND kategorie_id = ? AND monat = ?',
|
||
[$budgetId, $kategorieId, $monat]
|
||
);
|
||
return;
|
||
}
|
||
|
||
$betrag = self::mitVorzeichen($kategorieId, $betrag);
|
||
|
||
Database::execute(
|
||
'INSERT INTO budget_positionen (budget_id, kategorie_id, monat, betrag)
|
||
VALUES (?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE betrag = VALUES(betrag)',
|
||
[$budgetId, $kategorieId, $monat, $betrag]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Legt eine Kopie samt aller Positionen an – so entsteht das Budget für das
|
||
* neue Jahr aus dem laufenden, statt 105 Kategorien noch einmal zu tippen.
|
||
*/
|
||
public static function kopieren(int $id, string $neuerName, int $neuesJahr): int
|
||
{
|
||
$vorlage = self::find($id);
|
||
if ($vorlage === null) {
|
||
throw new \RuntimeException('Das zu kopierende Budget gibt es nicht.');
|
||
}
|
||
|
||
return (int)Database::transaktion(static function () use ($vorlage, $neuerName, $neuesJahr): int {
|
||
$neu = self::anlegen($neuerName, $neuesJahr, (string)$vorlage['beschreibung']);
|
||
|
||
Database::execute(
|
||
'INSERT INTO budget_positionen (budget_id, kategorie_id, monat, betrag)
|
||
SELECT ?, kategorie_id, monat, betrag FROM budget_positionen WHERE budget_id = ?',
|
||
[$neu, (int)$vorlage['id']]
|
||
);
|
||
|
||
return $neu;
|
||
});
|
||
}
|
||
|
||
/** Ausgaben negativ, Einnahmen positiv – unabhängig davon, wie eingegeben wurde. */
|
||
private static function mitVorzeichen(int $kategorieId, float $betrag): float
|
||
{
|
||
$art = Database::fetchValue(
|
||
'SELECT art FROM kategorien WHERE id = ? AND benutzer_id = ?',
|
||
[$kategorieId, Mandant::id()]
|
||
);
|
||
|
||
$betrag = abs($betrag);
|
||
return $art === 'einnahme' ? $betrag : -$betrag;
|
||
}
|
||
}
|