Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf, Snapshot und Buchungssperre. Dazu drei Vorarbeiten in der Oberfläche. Budgetplaner - Migration 004: budgets, budget_positionen, monatsabschluesse - Zeilen sind der Kategoriebaum, Spalten die Monate; die ganze Tabelle wird in einem Formular gespeichert, damit mehrere Änderungen zusammen ankommen - Zwei Ansichten: Verlauf (Jahressumme, abgelaufene Monate zusammengefasst, laufender Monat mit Budget/Ist/Saldo, danach Folgemonate) und alle Monate - Ausgaben ohne Vorzeichen eingeben, es kommt aus der Kategorieart. Saldo ist Ist minus Budget: positiv heißt besser als geplant, negativ ist die Überschreitung und wird rot ausgewiesen - Übergeordnete Kategorien summieren ihre Nachfahren und sind nicht beplanbar - Ein Monat ohne Position ist "nicht geplant" und damit etwas anderes als "mit 0,00 geplant" - Wert auf Folgemonate übertragen, Budget als Kopie ins neue Jahr Monatsabschluss - Prüflauf über nicht kategorisierte Buchungen, nicht abgeglichene Umsätze und Splitsummen-Differenzen; er hält niemanden auf, sondern zeigt den Stand - Snapshot der Kontosalden und Kategoriesummen als JSON, bleibt auch nach einer Wiedereröffnung stehen - Abgeschlossene Monate sperren Buchungen in beide Richtungen: weder hinein noch heraus verschieben. Abschaltbar über monatsabschluss_sperrt_buchungen Vorarbeiten - .knopf.primaer trug einen eigenen margin-top und stand dadurch in jeder Knopfleiste tiefer als seine Nachbarn - sichtbar an "Abbrechen" neben "Konto anlegen". Der Abstand liegt jetzt allein bei der Knopfleiste - Kontengruppen werden wie Konten und Kategorien auf einer eigenen Formularseite gepflegt statt inline; dort hängt auch die Zuordnung der Konten (ankreuzen statt jedes Konto einzeln zu öffnen) - Der QIF-Export enthält keine Kontengruppen: kein Feld im !Account-Eintrag, nichts in der QSD-Datei. Import-Oberfläche und Doku sagen das jetzt - Die Baum-Einrückung lief über ein style-Attribut und wurde von der Content-Security-Policy verworfen; sie steht jetzt als data-ebene im Markup. Aus demselben Grund blendete app.js Erfolgsmeldungen nie aus Tests: 11_BudgetTest, 12_MonatsabschlussTest (76 Prüfungen), E2E planung.spec.js. Einzelheiten in docs/planung.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
4.9 KiB
PHP
151 lines
4.9 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Models;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Mandant;
|
||
|
||
/**
|
||
* Kontengruppen bündeln Konten in der Kontenübersicht („Anlage“, „Girokonten“ …).
|
||
* Konten ohne Gruppe erscheinen unter „Nicht gruppiert“ – das ist keine echte
|
||
* Gruppe, sondern kontengruppe_id IS NULL.
|
||
*/
|
||
class Kontengruppe
|
||
{
|
||
public const OHNE_GRUPPE = 'Nicht gruppiert';
|
||
|
||
/** @return list<array<string,mixed>> */
|
||
public static function alle(): array
|
||
{
|
||
return Database::fetchAll(
|
||
'SELECT * FROM kontengruppen WHERE benutzer_id = ? ORDER BY sortierung, name',
|
||
[Mandant::id()]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Wie alle(), zusätzlich mit der Zahl der zugeordneten Konten – die Liste
|
||
* zeigt sie in einer eigenen Spalte.
|
||
*
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
public static function alleMitKontenzahl(): array
|
||
{
|
||
return Database::fetchAll(
|
||
'SELECT g.*, COUNT(k.id) AS anzahl_konten
|
||
FROM kontengruppen g
|
||
LEFT JOIN konten k ON k.kontengruppe_id = g.id
|
||
WHERE g.benutzer_id = ?
|
||
GROUP BY g.id
|
||
ORDER BY g.sortierung, g.name',
|
||
[Mandant::id()]
|
||
);
|
||
}
|
||
|
||
/** Konten, die unter „Nicht gruppiert“ stehen. */
|
||
public static function anzahlOhneGruppe(): int
|
||
{
|
||
return (int)Database::fetchValue(
|
||
'SELECT COUNT(*) FROM konten WHERE benutzer_id = ? AND kontengruppe_id IS NULL',
|
||
[Mandant::id()]
|
||
);
|
||
}
|
||
|
||
public static function find(int $id): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM kontengruppen 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 kontengruppen WHERE name = ? AND benutzer_id = ?',
|
||
[trim($name), Mandant::id()]
|
||
);
|
||
return $zeile === false ? null : $zeile;
|
||
}
|
||
|
||
public static function anlegen(string $name, string $beschreibung = '', int $sortierung = 0): int
|
||
{
|
||
$name = trim($name);
|
||
if ($name === '') {
|
||
throw new \InvalidArgumentException('Die Kontengruppe braucht einen Namen.');
|
||
}
|
||
if (self::findeNachName($name) !== null) {
|
||
throw new \RuntimeException('Die Kontengruppe „' . $name . '“ gibt es bereits.');
|
||
}
|
||
|
||
Database::execute(
|
||
'INSERT INTO kontengruppen (benutzer_id, name, beschreibung, sortierung) VALUES (?, ?, ?, ?)',
|
||
[Mandant::id(), $name, $beschreibung, $sortierung]
|
||
);
|
||
return Database::lastInsertId();
|
||
}
|
||
|
||
public static function findenOderAnlegen(string $name): int
|
||
{
|
||
$vorhanden = self::findeNachName($name);
|
||
return $vorhanden !== null ? (int)$vorhanden['id'] : self::anlegen($name);
|
||
}
|
||
|
||
public static function speichern(int $id, string $name, string $beschreibung, int $sortierung): void
|
||
{
|
||
$name = trim($name);
|
||
if ($name === '') {
|
||
throw new \InvalidArgumentException('Die Kontengruppe braucht einen Namen.');
|
||
}
|
||
|
||
$andere = self::findeNachName($name);
|
||
if ($andere !== null && (int)$andere['id'] !== $id) {
|
||
throw new \RuntimeException('Die Kontengruppe „' . $name . '“ gibt es bereits.');
|
||
}
|
||
|
||
Database::execute(
|
||
'UPDATE kontengruppen SET name = ?, beschreibung = ?, sortierung = ? WHERE id = ? AND benutzer_id = ?',
|
||
[$name, $beschreibung, $sortierung, $id, Mandant::id()]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Legt fest, welche Konten zur Gruppe gehören. Konten, die vorher in der
|
||
* Gruppe standen und jetzt fehlen, rutschen zurück zu „Nicht gruppiert“ –
|
||
* Konten anderer Gruppen bleiben unberührt.
|
||
*
|
||
* @param list<int> $kontoIds
|
||
*/
|
||
public static function kontenZuordnen(int $id, array $kontoIds): void
|
||
{
|
||
Database::execute(
|
||
'UPDATE konten SET kontengruppe_id = NULL
|
||
WHERE kontengruppe_id = ? AND benutzer_id = ?',
|
||
[$id, Mandant::id()]
|
||
);
|
||
|
||
$kontoIds = array_values(array_unique(array_map('intval', $kontoIds)));
|
||
if ($kontoIds === []) {
|
||
return;
|
||
}
|
||
|
||
$platzhalter = implode(', ', array_fill(0, count($kontoIds), '?'));
|
||
Database::execute(
|
||
'UPDATE konten SET kontengruppe_id = ?
|
||
WHERE benutzer_id = ? AND id IN (' . $platzhalter . ')',
|
||
array_merge([$id, Mandant::id()], $kontoIds)
|
||
);
|
||
}
|
||
|
||
/** Die zugeordneten Konten rutschen per ON DELETE SET NULL zu „Nicht gruppiert“. */
|
||
public static function loeschen(int $id): void
|
||
{
|
||
Database::execute(
|
||
'DELETE FROM kontengruppen WHERE id = ? AND benutzer_id = ?',
|
||
[$id, Mandant::id()]
|
||
);
|
||
}
|
||
}
|