Phase 3: Budgetplaner und Monatsabschluss
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>
This commit is contained in:
parent
bfef887793
commit
851e8204e9
39 changed files with 3360 additions and 96 deletions
48
Changes.md
48
Changes.md
|
|
@ -1,5 +1,53 @@
|
|||
# Änderungen
|
||||
|
||||
## Phase 3 – Budgetplaner und Monatsabschluss (11.08.2026)
|
||||
|
||||
**Datenmodell**
|
||||
- Migration `004_budget_monatsabschluss.sql`: `budgets`, `budget_positionen`,
|
||||
`monatsabschluesse`
|
||||
- Ein Budget gilt für ein Jahr, mehrere je Jahr sind möglich, genau eines ist aktiv
|
||||
- Ein Monat ohne Zeile in `budget_positionen` ist *nicht geplant* – etwas anderes als
|
||||
*mit 0,00 geplant*
|
||||
|
||||
**Budgetplaner**
|
||||
- Zeilen sind der Kategoriebaum (auf- und zuklappbar), 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* zum Planen
|
||||
- Ausgaben werden ohne Vorzeichen eingetragen; das Vorzeichen 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 selbst nicht beplanbar
|
||||
- Wert auf alle 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, auch nach Wiedereröffnung
|
||||
- Abgeschlossene Monate sperren Buchungen (`Buchung::anlegen/speichern/loeschen`), in
|
||||
beide Richtungen: weder hinein- noch herausschieben. Abschaltbar über die Einstellung
|
||||
`monatsabschluss_sperrt_buchungen`
|
||||
- Wiedereröffnen bleibt am Datensatz vermerkt
|
||||
|
||||
**Vorarbeiten in der Oberfläche**
|
||||
- `.knopf.primaer` trug einen eigenen `margin-top` und stand dadurch in jeder Knopfleiste
|
||||
tiefer als seine Nachbarn – sichtbar etwa 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 in der Tabelle. Dort hängt auch die Zuordnung der Konten: ankreuzen statt
|
||||
jedes Konto einzeln zu öffnen (`Kontengruppe::kontenZuordnen()`).
|
||||
- Der QIF-Export enthält **keine** Kontengruppen – kein Feld im `!Account`-Eintrag, nichts
|
||||
in der `QSD`-Datei. Import-Oberfläche und Dokumentation sagen das jetzt und verweisen
|
||||
auf die Zuordnung.
|
||||
- Die Einrückung des Kategoriebaums lief über ein `style`-Attribut und wurde von der
|
||||
Content-Security-Policy (`style-src 'self'`) stillschweigend verworfen; sie steht jetzt
|
||||
als `data-ebene` im Markup. Aus demselben Grund blendete `app.js` Erfolgsmeldungen nie
|
||||
aus – das läuft jetzt über eine Klasse statt über `element.style`.
|
||||
|
||||
**Tests**
|
||||
- `11_BudgetTest`, `12_MonatsabschlussTest` (76 Prüfungen), E2E `planung.spec.js`
|
||||
- Einzelheiten in `docs/planung.md`
|
||||
|
||||
## Korrektur – Σ-Kennzeichen ließ sich nicht abschalten (10.08.2026)
|
||||
|
||||
Das Kennzeichen *Zählt zur Summe der Kontostände* blieb beim Speichern eines Kontos
|
||||
|
|
|
|||
21
PLAN.md
21
PLAN.md
|
|
@ -249,7 +249,7 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag
|
|||
* *Ergebnis:* Der vollständige Altbestand ist migriert, Salden stimmen mit dem
|
||||
FinanzManager überein (Abgleich gegen die Kontenübersicht als Test).
|
||||
|
||||
### Phase 3 — Budget und Monatsabschluss
|
||||
### Phase 3 — Budget und Monatsabschluss *(erledigt)*
|
||||
* Migrationen `budgets`, `budget_positionen`, `monatsabschluesse`.
|
||||
* **Budgetplaner** wie im Original: Zeilen = Kategoriebaum (auf-/zuklappbar), Spalten =
|
||||
Gesamt-Saldo, abgelaufene Monate zusammengefasst, aktueller Monat mit Budget/Ist/Saldo,
|
||||
|
|
@ -257,7 +257,12 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag
|
|||
Kopieren eines Budgets ins neue Jahr, Alarm bei Überschreitung, Gesamtzeile.
|
||||
* **Monatsabschluss**: Prüflauf (nicht kategorisierte Buchungen, nicht abgeglichene Umsätze,
|
||||
Splitsummen-Differenzen), Snapshot der Salden, Sperre des Zeitraums, Wiedereröffnung.
|
||||
* Entschieden während der Umsetzung: Budgetwerte tragen das Vorzeichen der Buchhaltung
|
||||
(Ausgaben negativ), eingegeben wird der nackte Betrag. Saldo ist **Ist minus Budget** –
|
||||
positiv heißt besser als geplant. Übergeordnete Kategorien summieren ihre Nachfahren und
|
||||
werden nicht selbst beplant.
|
||||
* *Ergebnis:* Jahresbudget zum Jahreswechsel planbar, Monatsabschluss durchführbar.
|
||||
Einzelheiten in `docs/planung.md`.
|
||||
|
||||
### Phase 4 — Auswertungen und Cockpit
|
||||
* Berichtsmodul mit gespeicherten Berichten und Berichtsgruppen (wie „Bankgeschäfte",
|
||||
|
|
@ -319,10 +324,16 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag
|
|||
3. **Σ-Kennzeichen je Konto prüfen**: Welche Konten in die Summe der Kontostände zählen,
|
||||
steht nicht im Export. Nach dem Import einmal durch die Kontenübersicht gehen (Vorgabe:
|
||||
alle zählen mit).
|
||||
4. **Budgetdaten**: Export aus dem Budgetplaner (Symbol oben rechts in der Werkzeugleiste),
|
||||
sonst wird das Jahresbudget 2027 einmalig neu erfasst.
|
||||
5. Zugangsdaten der Bankverbindungen (erst in Phase 5, Eingabe direkt in der Anwendung).
|
||||
6. **API-Key für den Kursdienst** (Leeway.tech oder FMP, erst in Phase 6, Eingabe im
|
||||
4. **Kontengruppen anlegen und Konten zuordnen**: Ebenfalls nicht im Export enthalten —
|
||||
ein `!Account`-Eintrag führt nur Name, Kontoart, Beschreibung und Kreditlimit, und auch
|
||||
die `QSD`-Datei nennt keine Gruppen. Nach dem Import stehen deshalb alle Konten unter
|
||||
„Nicht gruppiert“. Laut Screenshot der Kontenübersicht gibt es genau eine Gruppe
|
||||
(„Anlage“); sie wird unter *Verwaltung → Kontengruppen* angelegt, die Konten dort in
|
||||
einem Zug angekreuzt.
|
||||
5. **Budgetdaten**: Export aus dem Budgetplaner (Symbol oben rechts in der Werkzeugleiste),
|
||||
sonst wird das Jahresbudget 2027 einmalig im Budgetplaner erfasst (Phase 3 steht).
|
||||
6. Zugangsdaten der Bankverbindungen (erst in Phase 5, Eingabe direkt in der Anwendung).
|
||||
7. **API-Key für den Kursdienst** (Leeway.tech oder FMP, erst in Phase 6, Eingabe im
|
||||
Einstellungsdialog).
|
||||
|
||||
## 7. Verifikation
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ Haushalt wird beides parallel genutzt.
|
|||
|
||||
## Stand
|
||||
|
||||
Phase 0 bis 2 sind gebaut: Anmeldung, zentraler Einstellungsdialog, Debug-Log,
|
||||
Phase 0 bis 3 sind gebaut: Anmeldung, zentraler Einstellungsdialog, Debug-Log,
|
||||
Migrationsmechanik und Test-Harness; Konten, Kontengruppen, Kategorien und Klassen;
|
||||
Kontoblatt, Buchungsmaske mit Splitbuchungen und Umbuchungen sowie der QIF-Import für
|
||||
Stammdaten und Buchungen. Als Nächstes folgt nach `PLAN.md` der Budgetplaner mit dem
|
||||
Monatsabschluss (Phase 3).
|
||||
Stammdaten und Buchungen; Budgetplaner mit Monatswerten je Kategorie und der
|
||||
Monatsabschluss samt Prüflauf, Snapshot und Sperre. Als Nächstes folgen nach `PLAN.md`
|
||||
die Auswertungen und das Cockpit (Phase 4).
|
||||
|
||||
## Technik
|
||||
|
||||
|
|
@ -83,4 +84,5 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf.
|
|||
- `Changes.md` – Änderungsprotokoll
|
||||
- `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen
|
||||
- `docs/buchungen.md` – Kontoblatt, Umbuchungen, Splitbuchungen, Salden
|
||||
- `docs/planung.md` – Budgetplaner und Monatsabschluss
|
||||
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs
|
||||
|
|
|
|||
111
docs/planung.md
Normal file
111
docs/planung.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Planung
|
||||
|
||||
Budgetplaner und Monatsabschluss – die beiden Module des Bereichs *Planung*.
|
||||
|
||||
## Budgetplaner
|
||||
|
||||
Ein **Budget** gilt für genau ein Jahr und hält je Kategorie zwölf Monatswerte
|
||||
(`budgets`, `budget_positionen`). Mehrere Budgets pro Jahr sind möglich, genau eines ist
|
||||
das aktive; es öffnet sich beim Aufruf des Planers.
|
||||
|
||||
### Vorzeichen und Saldo
|
||||
|
||||
Budgetwerte werden mit dem Vorzeichen der Buchhaltung gespeichert: **Ausgaben negativ,
|
||||
Einnahmen positiv**. Eingegeben wird trotzdem der nackte Betrag – „400“ bei Lebensmitteln
|
||||
meint 400 € Ausgabe. `Budget::positionSetzen()` dreht das Vorzeichen anhand der
|
||||
Kategorieart; eine ausdrücklich negative Eingabe bei einer Ausgabe bleibt eine Ausgabe.
|
||||
|
||||
Daraus folgt eine Saldodefinition, die für Einnahmen und Ausgaben gleich funktioniert:
|
||||
|
||||
```
|
||||
saldo = ist − budget
|
||||
```
|
||||
|
||||
**Positiv heißt besser als geplant** (weniger ausgegeben oder mehr eingenommen), negativ
|
||||
ist die Überschreitung. Die Oberfläche färbt negative Salden rot – das ist der Alarm bei
|
||||
Budgetüberschreitung.
|
||||
|
||||
### Nicht geplant ist nicht null
|
||||
|
||||
Ein Monat ohne Zeile in `budget_positionen` ist **nicht geplant**, ein Monat mit dem Wert
|
||||
`0.00` ist **mit null geplant**. Der Planer zeigt das eine als leeres Feld, das andere als
|
||||
`0,00`; ein geleertes Feld löscht die Position wieder. Deshalb füllt `Budgetplaner` die
|
||||
Reihe `eigenBudget` nicht mit Nullen auf, während `budget` und `ist` als vollständige
|
||||
Monatsreihen vorliegen.
|
||||
|
||||
### Ist-Werte
|
||||
|
||||
`Budgetplaner::istWerte()` summiert je Kategorie und Monat:
|
||||
|
||||
* Buchungen ohne Splitzeilen über `buchungen.kategorie_id`
|
||||
* gesplittete Buchungen über `buchung_splits.kategorie_id`
|
||||
|
||||
Beides zusammen zählte doppelt, deshalb die Trennung über `gesplittet`. Umbuchungen tragen
|
||||
keine Kategorie und bleiben außen vor – wie im FinanzManager.
|
||||
|
||||
### Baum und Summen
|
||||
|
||||
Zeilen sind der Kategoriebaum in Baumreihenfolge. Eine übergeordnete Kategorie zeigt die
|
||||
**Summe ihrer Nachfahren** (aufaddiert von unten nach oben) und ist deshalb selbst nicht
|
||||
beplanbar: Zwei Werte in einer Zelle – eigener und aggregierter – wären nicht lesbar.
|
||||
Geplant wird auf den Blattkategorien. Die Gesamtzeile summiert nur die Wurzeln, sonst
|
||||
zählten Unterkategorien mehrfach.
|
||||
|
||||
### Ansichten
|
||||
|
||||
| Ansicht | Spalten |
|
||||
|---|---|
|
||||
| **Verlauf** (Standard) | Jahressumme, abgelaufene Monate zusammengefasst, laufender Monat mit Budget/Ist/Saldo, danach je Folgemonat eine Budgetspalte |
|
||||
| **Alle Monate** | Jahressumme und zwölf Budgetspalten – zum Planen des nächsten Jahres oder Nachbessern eines vergangenen |
|
||||
|
||||
Was als abgelaufen gilt, entscheidet `Budgetplaner::abgelaufenBis()`: im laufenden Jahr der
|
||||
Vormonat, in vergangenen Jahren der Dezember, in künftigen nichts.
|
||||
|
||||
Die ganze Tabelle steht in **einem** Formular und wird mit einem Knopf gespeichert – ohne
|
||||
JavaScript, damit mehrere geänderte Zeilen zusammen ankommen. Der Knopf **↦** in jeder
|
||||
Zeile überträgt den Wert des ersten Eingabemonats auf alle Folgemonate
|
||||
(`Budget::aufFolgemonateUebertragen()`).
|
||||
|
||||
`Budget::kopieren()` legt ein Budget samt aller Monatswerte im neuen Jahr an – der Weg zum
|
||||
Jahresbudget, ohne 105 Kategorien noch einmal zu tippen.
|
||||
|
||||
## Monatsabschluss
|
||||
|
||||
Der Abschluss friert einen Monat ein (`monatsabschluesse`).
|
||||
|
||||
**Prüflauf** (`Monatsabschluss::pruefen()`) zeigt, was im Zeitraum noch offen ist:
|
||||
|
||||
* Buchungen ohne Kategorie und ohne Gegenkonto
|
||||
* nicht abgeglichene Buchungen
|
||||
* Splitbuchungen, deren Zeilen nicht auf den Buchungsbetrag aufgehen
|
||||
|
||||
Keiner dieser Punkte hindert am Abschluss – sie sind Hinweise, der FinanzManager hält es
|
||||
genauso.
|
||||
|
||||
**Snapshot**: Beim Abschluss werden Kontosalden zum Monatsende (inklusive der
|
||||
Σ-Kennzeichen und der Summe der Kontostände) sowie die Kategoriesummen des Monats als JSON
|
||||
gespeichert. Der Snapshot bleibt auch nach einer Wiedereröffnung stehen: Er zeigt, mit
|
||||
welchen Zahlen einmal abgeschlossen wurde.
|
||||
|
||||
**Sperre**: `Monatsabschluss::verlangeOffen()` greift in `Buchung::anlegen()`,
|
||||
`speichern()` und `loeschen()`. Eine Buchung darf weder in einen abgeschlossenen Monat
|
||||
hinein noch aus einem heraus verschoben werden. Die Sperre lässt sich über die Einstellung
|
||||
`monatsabschluss_sperrt_buchungen` abschalten – etwa für einen Import, der ältere
|
||||
Zeiträume nachträgt. Der QIF-Import bricht daran ohnehin nicht ab: Er fängt den Fehler je
|
||||
Buchung ab und schreibt ihn ins Protokoll.
|
||||
|
||||
**Wiedereröffnen** setzt den Status auf `wiedereroeffnet` und vermerkt den Zeitpunkt. Ein
|
||||
erneuter Abschluss überschreibt den Snapshot mit dem neuen Stand.
|
||||
|
||||
`Monatsabschluss::naechsterOffener()` schlägt den ältesten Monat mit Buchungen vor, der
|
||||
noch offen ist: Abgeschlossen wird der Reihe nach, deshalb läuft die Suche vom Vormonat
|
||||
rückwärts und endet am ersten bereits abgeschlossenen Monat.
|
||||
|
||||
## Tests
|
||||
|
||||
* `tests/11_BudgetTest.php` – Vorzeichen, „nicht geplant“, Ist-Werte samt Splits,
|
||||
Baumsummen, Saldo, Folgemonate, Kopie
|
||||
* `tests/12_MonatsabschlussTest.php` – Zeiträume, Prüflauf, Snapshot, Sperre,
|
||||
Wiedereröffnung, Vorschlag
|
||||
* `tests/e2e/specs/planung.spec.js` – Budget anlegen, Wert eintragen und wiederfinden,
|
||||
Übertragen auf Folgemonate, Abschluss mit Snapshot und Wiedereröffnung
|
||||
|
|
@ -47,6 +47,12 @@ zweite Schreibweise ab, bei der ein einzelner `!Account` den Kontowechsel bedeut
|
|||
| | `D` | Beschreibung; `Kontonr. 12345` wird als Kontonummer übernommen |
|
||||
| | `L` | Kreditlimit im US-Format |
|
||||
|
||||
**Kontengruppen fehlen im Export.** Ein `!Account`-Eintrag führt nur die vier Felder oben –
|
||||
kein Feld nennt die Gruppe, und auch die `QSD`-Datei enthält weder Gruppennamen noch
|
||||
Zuordnung. Der Import kann sie deshalb nicht anlegen: nach dem Einlesen stehen alle Konten
|
||||
unter „Nicht gruppiert“. Die Gruppen werden einmalig unter *Verwaltung → Kontengruppen*
|
||||
angelegt; dort lassen sich die Konten einer Gruppe in einem Zug ankreuzen.
|
||||
|
||||
## Fallstricke
|
||||
|
||||
1. **Zeichensatz ISO-8859-1, Zeilenenden CRLF.** `QifParser::nachUtf8()` prüft erst, ob
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@ unter **Nicht gruppiert** – das ist keine eigene Gruppe, sondern `kontengruppe
|
|||
Wird eine Gruppe gelöscht, bleiben ihre Konten erhalten und rutschen dorthin
|
||||
(`ON DELETE SET NULL`).
|
||||
|
||||
Gruppen werden wie Konten und Kategorien auf einer eigenen Formularseite gepflegt
|
||||
(*Verwaltung → Kontengruppen*). Dort hängt auch die Zuordnung: Die Konten der Gruppe werden
|
||||
angekreuzt, `Kontengruppe::kontenZuordnen()` setzt sie in einem Zug. Ein Konto gehört zu
|
||||
genau einer Gruppe – wird es hier angehakt, verlässt es seine bisherige.
|
||||
|
||||
Der QIF-Export enthält **keine** Kontengruppen (siehe `docs/qif-import.md`); nach einem
|
||||
Import stehen deshalb alle Konten unter „Nicht gruppiert“ und werden einmalig zugeordnet.
|
||||
|
||||
Kontoarten (`Konto::ARTEN`) und ihre Herkunft aus dem QIF-Export (`Konto::QIF_ARTEN`):
|
||||
|
||||
| Kontoart | Bezeichnung | QIF-Typ |
|
||||
|
|
|
|||
69
migrations/004_budget_monatsabschluss.sql
Normal file
69
migrations/004_budget_monatsabschluss.sql
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
-- Phase 3: Budgetplaner und Monatsabschluss.
|
||||
--
|
||||
-- Ein Budget gilt für genau ein Jahr; mehrere Budgets pro Jahr sind erlaubt
|
||||
-- („2027-sparsam“ neben „2027-normal“), eines davon ist das aktive. Die Werte
|
||||
-- stehen monatsweise je Kategorie – das entspricht dem Budgetplaner des
|
||||
-- FinanzManagers, in dem jede Kategoriezeile zwölf Monatsspalten hat.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budgets (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
benutzer_id INT UNSIGNED NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
jahr SMALLINT UNSIGNED NOT NULL,
|
||||
beschreibung VARCHAR(255) NOT NULL DEFAULT '',
|
||||
-- Genau ein Budget je Benutzer ist aktiv; es öffnet sich beim Aufruf des
|
||||
-- Budgetplaners und liefert die Werte für Auswertungen (Phase 4).
|
||||
aktiv TINYINT(1) NOT NULL DEFAULT 0,
|
||||
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
geaendert_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_budget_name (benutzer_id, name),
|
||||
KEY idx_budget_jahr (benutzer_id, jahr),
|
||||
CONSTRAINT fk_budget_benutzer FOREIGN KEY (benutzer_id)
|
||||
REFERENCES benutzer (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Eine Zeile je Kategorie und Monat. Nicht geplante Monate haben keine Zeile –
|
||||
-- das hält die Tabelle klein und macht „kein Budget“ von „Budget 0,00“
|
||||
-- unterscheidbar. Das Vorzeichen folgt der Buchhaltung: Ausgaben negativ,
|
||||
-- Einnahmen positiv (Budget::positionSetzen dreht die Eingabe entsprechend).
|
||||
CREATE TABLE IF NOT EXISTS budget_positionen (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
budget_id INT UNSIGNED NOT NULL,
|
||||
kategorie_id INT UNSIGNED NOT NULL,
|
||||
monat TINYINT UNSIGNED NOT NULL,
|
||||
betrag DECIMAL(15,2) NOT NULL DEFAULT 0.00,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_budget_position (budget_id, kategorie_id, monat),
|
||||
KEY idx_position_kategorie (kategorie_id),
|
||||
CONSTRAINT fk_position_budget FOREIGN KEY (budget_id)
|
||||
REFERENCES budgets (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_position_kategorie FOREIGN KEY (kategorie_id)
|
||||
REFERENCES kategorien (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Ein abgeschlossener Monat sperrt Buchungen in seinem Zeitraum. Der Snapshot
|
||||
-- hält Kontosalden und Kategoriesummen als JSON fest, damit später
|
||||
-- nachvollziehbar bleibt, mit welchen Zahlen abgeschlossen wurde – auch wenn
|
||||
-- der Monat wiedereröffnet und nachgebucht wird.
|
||||
CREATE TABLE IF NOT EXISTS monatsabschluesse (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
benutzer_id INT UNSIGNED NOT NULL,
|
||||
jahr SMALLINT UNSIGNED NOT NULL,
|
||||
monat TINYINT UNSIGNED NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'abgeschlossen',
|
||||
notiz VARCHAR(255) NOT NULL DEFAULT '',
|
||||
snapshot LONGTEXT NULL,
|
||||
abgeschlossen_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
abgeschlossen_von VARCHAR(120) NOT NULL DEFAULT '',
|
||||
wiedereroeffnet_am DATETIME NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_monatsabschluss (benutzer_id, jahr, monat),
|
||||
CONSTRAINT fk_abschluss_benutzer FOREIGN KEY (benutzer_id)
|
||||
REFERENCES benutzer (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO einstellungen (schluessel, wert, bereich) VALUES
|
||||
('budget_alarm_ab_prozent', '100', 'buchhaltung'),
|
||||
('monatsabschluss_sperrt_buchungen', '1', 'buchhaltung')
|
||||
ON DUPLICATE KEY UPDATE schluessel = schluessel;
|
||||
|
|
@ -11,12 +11,15 @@ require dirname(__DIR__) . '/vendor/autoload.php';
|
|||
use Finanzmanager\Auth;
|
||||
use Finanzmanager\Config;
|
||||
use Finanzmanager\Controllers\BuchungenController;
|
||||
use Finanzmanager\Controllers\BudgetController;
|
||||
use Finanzmanager\Controllers\CockpitController;
|
||||
use Finanzmanager\Controllers\EinstellungenController;
|
||||
use Finanzmanager\Controllers\ImportController;
|
||||
use Finanzmanager\Controllers\KategorienController;
|
||||
use Finanzmanager\Controllers\KontenController;
|
||||
use Finanzmanager\Controllers\LoginController;
|
||||
use Finanzmanager\Controllers\MonatsabschlussController;
|
||||
use Finanzmanager\Controllers\PlanungController;
|
||||
use Finanzmanager\Controllers\PlatzhalterController;
|
||||
use Finanzmanager\Controllers\VerwaltungController;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
|
|
@ -96,12 +99,40 @@ $router->post('/verwaltung/klassen/{id}/loeschen',
|
|||
fn(array $p) => (new VerwaltungController())->klasseLoeschen($p));
|
||||
|
||||
$router->get('/verwaltung/kontengruppen', fn() => (new VerwaltungController())->kontengruppen());
|
||||
$router->get('/verwaltung/kontengruppen/neu', fn() => (new VerwaltungController())->kontengruppeNeu());
|
||||
$router->post('/verwaltung/kontengruppen/neu', fn() => (new VerwaltungController())->kontengruppeAnlegen());
|
||||
$router->get('/verwaltung/kontengruppen/{id}',
|
||||
fn(array $p) => (new VerwaltungController())->kontengruppeBearbeiten($p));
|
||||
$router->post('/verwaltung/kontengruppen/{id}',
|
||||
fn(array $p) => (new VerwaltungController())->kontengruppeSpeichern($p));
|
||||
$router->post('/verwaltung/kontengruppen/{id}/loeschen',
|
||||
fn(array $p) => (new VerwaltungController())->kontengruppeLoeschen($p));
|
||||
|
||||
// ── Planung: Budgetplaner und Monatsabschluss ───────────────────────────────
|
||||
$router->get('/planung', fn() => (new PlanungController())->index());
|
||||
|
||||
$router->get('/planung/budget', fn() => (new BudgetController())->planer());
|
||||
$router->get('/planung/budget/verwaltung', fn() => (new BudgetController())->verwaltung());
|
||||
$router->post('/planung/budget/neu', fn() => (new BudgetController())->anlegen());
|
||||
$router->post('/planung/budget/{id}/positionen',
|
||||
fn(array $p) => (new BudgetController())->positionenSpeichern($p));
|
||||
$router->get('/planung/budget/{id}/bearbeiten',
|
||||
fn(array $p) => (new BudgetController())->bearbeiten($p));
|
||||
$router->post('/planung/budget/{id}/bearbeiten',
|
||||
fn(array $p) => (new BudgetController())->speichern($p));
|
||||
$router->post('/planung/budget/kopieren', fn() => (new BudgetController())->kopieren());
|
||||
$router->post('/planung/budget/{id}/aktivieren',
|
||||
fn(array $p) => (new BudgetController())->aktivieren($p));
|
||||
$router->post('/planung/budget/{id}/loeschen',
|
||||
fn(array $p) => (new BudgetController())->loeschen($p));
|
||||
|
||||
$router->get('/planung/monatsabschluss', fn() => (new MonatsabschlussController())->index());
|
||||
$router->post('/planung/monatsabschluss', fn() => (new MonatsabschlussController())->abschliessen());
|
||||
$router->get('/planung/monatsabschluss/{id}',
|
||||
fn(array $p) => (new MonatsabschlussController())->snapshot($p));
|
||||
$router->post('/planung/monatsabschluss/{id}/wiedereroeffnen',
|
||||
fn(array $p) => (new MonatsabschlussController())->wiedereroeffnen($p));
|
||||
|
||||
$router->get('/verwaltung/import', fn() => (new ImportController())->index());
|
||||
$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen());
|
||||
$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren());
|
||||
|
|
|
|||
|
|
@ -399,13 +399,88 @@ table.liste input[type="number"] {
|
|||
|
||||
table.liste input.schmal { width: 5rem; }
|
||||
|
||||
/* Einrückung des Kategoriebaums. Die Ebene kommt als Custom Property aus der
|
||||
Vorlage – so bleibt die Tabelle frei von verschachtelten Elementen. */
|
||||
/* ── Budgetplaner ────────────────────────────────────────────────────────────
|
||||
|
||||
Zwölf Monatsspalten passen auf keinen Bildschirm: Die Tabelle scrollt in
|
||||
ihrem eigenen Rahmen, die Kategoriespalte bleibt dabei stehen. */
|
||||
.breittabelle {
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
table.budgetplaner { min-width: 100%; }
|
||||
|
||||
table.budgetplaner th,
|
||||
table.budgetplaner td {
|
||||
padding: .3rem .5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table.budgetplaner .spalte-kategorie {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background: var(--flaeche);
|
||||
min-width: 16rem;
|
||||
}
|
||||
|
||||
table.budgetplaner thead .spalte-kategorie,
|
||||
table.budgetplaner tfoot .spalte-kategorie { background: var(--flaeche-gedaempft); }
|
||||
|
||||
table.budgetplaner tbody tr:hover .spalte-kategorie { background: var(--flaeche-gedaempft); }
|
||||
|
||||
table.budgetplaner th.gruppenkopf {
|
||||
text-align: center;
|
||||
border-left: 1px solid var(--rand);
|
||||
}
|
||||
|
||||
/* Der laufende Monat ist die Spalte, auf die man zuerst schaut. */
|
||||
table.budgetplaner .laufend { background: color-mix(in srgb, var(--akzent) 8%, transparent); }
|
||||
|
||||
table.budgetplaner tr.summenzeile-kategorie td { font-weight: 600; }
|
||||
|
||||
table.budgetplaner input.betragsfeld {
|
||||
width: 6rem;
|
||||
text-align: right;
|
||||
font-family: var(--schrift-zahl);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
table.budgetplaner td.rechts {
|
||||
font-family: var(--schrift-zahl);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Aufklappen des Kategoriebaums; ohne JavaScript bleibt alles sichtbar. */
|
||||
.klappknopf {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-gedaempft);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0 .3rem 0 0;
|
||||
}
|
||||
|
||||
.klappknopf[aria-expanded="false"] { transform: rotate(-90deg); display: inline-block; }
|
||||
|
||||
tr.zugeklappt { display: none; }
|
||||
|
||||
/* Einrückung des Kategoriebaums. Die Ebene steht als data-Attribut an der
|
||||
Zeile – so bleibt die Tabelle frei von verschachtelten Elementen. Kein
|
||||
style-Attribut: Die Content-Security-Policy (style-src 'self') verwirft
|
||||
Inline-Styles, die Einrückung fiele stillschweigend aus. */
|
||||
.baumname {
|
||||
display: inline-block;
|
||||
padding-left: calc(var(--ebene, 0) * 1.4rem);
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.baumname[data-ebene="1"] { padding-left: 1.4rem; }
|
||||
.baumname[data-ebene="2"] { padding-left: 2.8rem; }
|
||||
.baumname[data-ebene="3"] { padding-left: 4.2rem; }
|
||||
.baumname[data-ebene="4"] { padding-left: 5.6rem; }
|
||||
.baumname[data-ebene="5"] { padding-left: 7rem; }
|
||||
.baumname[data-ebene="6"] { padding-left: 8.4rem; }
|
||||
|
||||
.nebentext {
|
||||
color: var(--text-gedaempft);
|
||||
font-size: .88rem;
|
||||
|
|
@ -548,6 +623,24 @@ table.splittabelle input.rechts { text-align: right; }
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Mehrfachauswahl im Formular – etwa die Konten einer Kontengruppe. Bei vielen
|
||||
Einträgen scrollt die Liste, statt das Formular auseinanderzuziehen. */
|
||||
.auswahlliste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .35rem;
|
||||
max-height: 22rem;
|
||||
overflow-y: auto;
|
||||
padding: .6rem .7rem;
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: var(--radius);
|
||||
background: var(--flaeche-gedaempft);
|
||||
}
|
||||
|
||||
.auswahlliste .ankreuzfeld { margin: 0; }
|
||||
|
||||
.auswahlliste .nebentext { margin-left: .35rem; }
|
||||
|
||||
.formular input[type="text"],
|
||||
.formular input[type="password"],
|
||||
.formular input[type="number"],
|
||||
|
|
@ -601,21 +694,18 @@ table.splittabelle input.rechts { text-align: right; }
|
|||
|
||||
.knopf:hover { background: var(--flaeche-gedaempft); text-decoration: none; }
|
||||
|
||||
/* Kein eigener Außenabstand: der primäre Knopf steht sonst in jeder Knopfleiste
|
||||
um genau diesen Betrag tiefer als seine Nachbarn. Den Abstand nach oben setzt
|
||||
allein die .knopfleiste. */
|
||||
.knopf.primaer {
|
||||
background: var(--akzent);
|
||||
border-color: var(--akzent);
|
||||
color: var(--akzent-text);
|
||||
font-weight: 500;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.knopf.primaer:hover { filter: brightness(1.08); }
|
||||
|
||||
/* Der Abstand oben gilt dem allein stehenden Knopf unter einem Formular –
|
||||
in Aktionsleisten und Tabellen stört er die Ausrichtung. */
|
||||
.seitenaktionen .knopf,
|
||||
table.liste .knopf { margin-top: 0; }
|
||||
|
||||
.knopf.klein {
|
||||
padding: .25rem .6rem;
|
||||
font-size: .85rem;
|
||||
|
|
@ -642,6 +732,12 @@ table.liste .knopf { margin-top: 0; }
|
|||
}
|
||||
|
||||
.meldung-erfolg { background: var(--akzent-schwach); border-color: var(--akzent); }
|
||||
|
||||
/* Erfolgsmeldungen blenden sich nach einigen Sekunden aus (app.js). */
|
||||
.meldung.verblasst {
|
||||
opacity: 0;
|
||||
transition: opacity .4s;
|
||||
}
|
||||
.meldung-fehler { background: color-mix(in srgb, var(--negativ) 12%, var(--flaeche)); border-color: var(--negativ); }
|
||||
.meldung-warnung { background: color-mix(in srgb, var(--warnung) 15%, var(--flaeche)); border-color: var(--warnung); }
|
||||
|
||||
|
|
|
|||
|
|
@ -27,11 +27,38 @@
|
|||
}
|
||||
}, true);
|
||||
|
||||
// Kategoriebaum im Budgetplaner auf- und zuklappen. Die Zeilen kennen ihre
|
||||
// Elternkategorie über data-eltern; von dort aus lässt sich der Teilbaum
|
||||
// einsammeln, ohne ihn im HTML zu verschachteln (in einer Tabelle ginge das
|
||||
// ohnehin nicht).
|
||||
document.querySelectorAll('.klappknopf').forEach(function (knopf) {
|
||||
knopf.setAttribute('aria-expanded', 'true');
|
||||
|
||||
knopf.addEventListener('click', function () {
|
||||
const wurzel = knopf.dataset.klappt;
|
||||
const offen = knopf.getAttribute('aria-expanded') !== 'false';
|
||||
knopf.setAttribute('aria-expanded', offen ? 'false' : 'true');
|
||||
|
||||
const zeilen = Array.from(document.querySelectorAll('tr.kategoriezeile'));
|
||||
const betroffen = new Set([wurzel]);
|
||||
|
||||
zeilen.forEach(function (zeile) {
|
||||
const eltern = zeile.dataset.eltern;
|
||||
if (eltern && betroffen.has(eltern)) {
|
||||
betroffen.add(zeile.dataset.kategorie);
|
||||
zeile.classList.toggle('zugeklappt', offen);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Meldungen nach kurzer Zeit ausblenden – Fehler bleiben stehen.
|
||||
// Über eine Klasse, nicht über element.style: Die Content-Security-Policy
|
||||
// (style-src 'self') verwirft auch per JavaScript gesetzte Inline-Styles,
|
||||
// die Meldung bliebe sonst einfach stehen.
|
||||
document.querySelectorAll('.meldung-erfolg').forEach(function (element) {
|
||||
window.setTimeout(function () {
|
||||
element.style.transition = 'opacity .4s';
|
||||
element.style.opacity = '0';
|
||||
element.classList.add('verblasst');
|
||||
window.setTimeout(function () { element.remove(); }, 400);
|
||||
}, 5000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,6 +35,21 @@ abstract class BaseController
|
|||
return is_string($wert) ? trim($wert) : $standard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrfachauswahl aus dem Formular (Ankreuzfelder mit Namen wie „konten[]“).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
protected function postIds(string $feld): array
|
||||
{
|
||||
$werte = $_POST[$feld] ?? [];
|
||||
if (!is_array($werte)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_map('intval', array_filter($werte, 'is_scalar')));
|
||||
}
|
||||
|
||||
protected function query(string $feld, string $standard = ''): string
|
||||
{
|
||||
$wert = $_GET[$feld] ?? $standard;
|
||||
|
|
|
|||
230
src/Controllers/BudgetController.php
Normal file
230
src/Controllers/BudgetController.php
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Controllers;
|
||||
|
||||
use Finanzmanager\Models\Budget;
|
||||
use Finanzmanager\Services\Budgetplaner;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Der Budgetplaner: Kategoriezeilen mal zwölf Monatsspalten, dazu die
|
||||
* Budgetverwaltung (anlegen, kopieren, aktivieren).
|
||||
*
|
||||
* Die Monatswerte werden nicht einzeln gespeichert, sondern die ganze Tabelle
|
||||
* auf einmal – ein Formular um die Tabelle herum, ein POST. Das kommt ohne
|
||||
* JavaScript aus und hält die Eingabe zusammen, wenn mehrere Zeilen geändert
|
||||
* wurden.
|
||||
*/
|
||||
class BudgetController extends BaseController
|
||||
{
|
||||
public function planer(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$budget = $this->gewaehltesBudget();
|
||||
if ($budget === null) {
|
||||
$this->render('planung/budget-leer.php', ['titel' => 'Budgetplaner']);
|
||||
return;
|
||||
}
|
||||
|
||||
$ansicht = $this->query('ansicht') === 'alle' ? 'alle' : 'verlauf';
|
||||
$tabelle = Budgetplaner::tabelle($budget);
|
||||
|
||||
$this->render('planung/budgetplaner.php', [
|
||||
'titel' => 'Budgetplaner',
|
||||
'budget' => $budget,
|
||||
'budgets' => Budget::alle(),
|
||||
'tabelle' => $tabelle,
|
||||
'ansicht' => $ansicht,
|
||||
'abgelaufenBis' => Budgetplaner::abgelaufenBis((int)$budget['jahr']),
|
||||
'laufenderMonat' => Budgetplaner::laufenderMonat((int)$budget['jahr']),
|
||||
'nurGeplante' => $this->query('nur_geplante') === '1',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die eingegebenen Monatswerte. Ein leeres Feld löscht die
|
||||
* Position („nicht geplant“), 0 bleibt eine ausdrückliche Null.
|
||||
*
|
||||
* @param array<string,string> $params
|
||||
*/
|
||||
public function positionenSpeichern(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$id = (int)$params['id'];
|
||||
$budget = Budget::find($id);
|
||||
if ($budget === null) {
|
||||
View::redirect('/planung/budget', 'Das Budget gibt es nicht.', 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
$werte = $_POST['betrag'] ?? [];
|
||||
$geaendert = 0;
|
||||
|
||||
if (is_array($werte)) {
|
||||
foreach ($werte as $kategorieId => $monate) {
|
||||
if (!is_array($monate)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($monate as $monat => $eingabe) {
|
||||
$betrag = self::betrag((string)$eingabe);
|
||||
Budget::positionSetzen($id, (int)$kategorieId, (int)$monat, $betrag);
|
||||
$geaendert++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// „Auf Folgemonate übertragen“ kommt als Knopfwert „kategorie:monat“ und
|
||||
// greift auf den soeben gespeicherten Stand.
|
||||
$meldung = $geaendert > 0 ? 'Budget gespeichert.' : 'Nichts zu speichern.';
|
||||
$uebertragen = $this->post('uebertragen');
|
||||
if ($uebertragen !== '' && preg_match('/^(\d+):(\d{1,2})$/', $uebertragen, $treffer) === 1) {
|
||||
Budget::aufFolgemonateUebertragen($id, (int)$treffer[1], (int)$treffer[2]);
|
||||
$meldung = 'Wert auf die Folgemonate übertragen.';
|
||||
}
|
||||
|
||||
View::redirect($this->zurueckZumPlaner($id), $meldung);
|
||||
}
|
||||
|
||||
public function verwaltung(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$this->render('planung/budget-verwaltung.php', [
|
||||
'titel' => 'Budgets',
|
||||
'budgets' => Budget::alle(),
|
||||
'jahr' => (int)date('Y'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function anlegen(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
try {
|
||||
$id = Budget::anlegen(
|
||||
$this->post('name'),
|
||||
(int)$this->post('jahr', date('Y')),
|
||||
$this->post('beschreibung')
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect('/planung/budget/verwaltung', $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
View::redirect('/planung/budget?budget=' . $id, 'Budget angelegt.');
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
public function bearbeiten(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$budget = Budget::find((int)$params['id']);
|
||||
if ($budget === null) {
|
||||
View::redirect('/planung/budget/verwaltung', 'Das Budget gibt es nicht.', 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->render('planung/budget-formular.php', [
|
||||
'titel' => 'Budget bearbeiten',
|
||||
'budget' => $budget,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
public function speichern(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
try {
|
||||
Budget::speichern(
|
||||
(int)$params['id'],
|
||||
$this->post('name'),
|
||||
(int)$this->post('jahr', date('Y')),
|
||||
$this->post('beschreibung')
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect('/planung/budget/verwaltung', $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
View::redirect('/planung/budget/verwaltung', 'Budget gespeichert.');
|
||||
}
|
||||
|
||||
public function kopieren(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
try {
|
||||
$neu = Budget::kopieren(
|
||||
(int)$this->post('vorlage'),
|
||||
$this->post('name'),
|
||||
(int)$this->post('jahr', date('Y'))
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect('/planung/budget/verwaltung', $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
View::redirect('/planung/budget?budget=' . $neu, 'Budget kopiert – die Werte stehen im neuen Jahr.');
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
public function aktivieren(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
Budget::aktivieren((int)$params['id']);
|
||||
View::redirect('/planung/budget/verwaltung', 'Budget ist jetzt das aktive.');
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
public function loeschen(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
Budget::loeschen((int)$params['id']);
|
||||
View::redirect('/planung/budget/verwaltung', 'Budget gelöscht.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Eingabe aus dem Planer: leeres Feld heißt „nicht geplant“ (null), sonst
|
||||
* derselbe Zahlenparser wie in der Kontenmaske.
|
||||
*/
|
||||
public static function betrag(string $eingabe): ?float
|
||||
{
|
||||
return trim($eingabe) === '' ? null : KontenController::zahl($eingabe);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function gewaehltesBudget(): ?array
|
||||
{
|
||||
$gewaehlt = (int)$this->query('budget');
|
||||
if ($gewaehlt > 0) {
|
||||
$budget = Budget::find($gewaehlt);
|
||||
if ($budget !== null) {
|
||||
return $budget;
|
||||
}
|
||||
}
|
||||
|
||||
return Budget::aktives();
|
||||
}
|
||||
|
||||
/** Nach dem Speichern zurück in dieselbe Ansicht, nicht auf das aktive Budget. */
|
||||
private function zurueckZumPlaner(int $id): string
|
||||
{
|
||||
$pfad = '/planung/budget?budget=' . $id;
|
||||
if ($this->post('ansicht') === 'alle') {
|
||||
$pfad .= '&ansicht=alle';
|
||||
}
|
||||
if ($this->post('nur_geplante') === '1') {
|
||||
$pfad .= '&nur_geplante=1';
|
||||
}
|
||||
|
||||
return $pfad;
|
||||
}
|
||||
}
|
||||
112
src/Controllers/MonatsabschlussController.php
Normal file
112
src/Controllers/MonatsabschlussController.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Controllers;
|
||||
|
||||
use Finanzmanager\Auth;
|
||||
use Finanzmanager\Models\Monatsabschluss;
|
||||
use Finanzmanager\Services\Budgetplaner;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Monatsabschluss: Prüflauf, Abschluss mit Snapshot, Wiedereröffnung.
|
||||
*
|
||||
* Der Prüflauf hält niemanden auf – er zeigt, was im Monat noch offen ist, und
|
||||
* überlässt die Entscheidung dem Nutzer. Genauso arbeitet der FinanzManager.
|
||||
*/
|
||||
class MonatsabschlussController extends BaseController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$vorschlag = Monatsabschluss::naechsterOffener();
|
||||
$jahr = (int)($this->query('jahr') ?: $vorschlag['jahr']);
|
||||
$monat = (int)($this->query('monat') ?: $vorschlag['monat']);
|
||||
|
||||
if ($monat < 1 || $monat > 12) {
|
||||
$monat = $vorschlag['monat'];
|
||||
}
|
||||
|
||||
$this->render('planung/monatsabschluss.php', [
|
||||
'titel' => 'Monatsabschluss',
|
||||
'jahr' => $jahr,
|
||||
'monat' => $monat,
|
||||
'pruefung' => Monatsabschluss::pruefen($jahr, $monat),
|
||||
'vorhanden' => Monatsabschluss::fuerMonat($jahr, $monat),
|
||||
'abschluesse' => Monatsabschluss::alle(),
|
||||
'monatsnamen' => Budgetplaner::MONATSNAMEN,
|
||||
]);
|
||||
}
|
||||
|
||||
public function abschliessen(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$jahr = (int)$this->post('jahr');
|
||||
$monat = (int)$this->post('monat');
|
||||
|
||||
try {
|
||||
$benutzer = Auth::benutzer();
|
||||
Monatsabschluss::abschliessen(
|
||||
$jahr,
|
||||
$monat,
|
||||
$this->post('notiz'),
|
||||
(string)($benutzer['anzeigename'] ?: $benutzer['benutzername'] ?? '')
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect(self::pfad($jahr, $monat), $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
View::redirect(
|
||||
self::pfad($jahr, $monat),
|
||||
sprintf('%s %d ist abgeschlossen.', Budgetplaner::MONATSNAMEN[$monat] ?? '', $jahr)
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
public function wiedereroeffnen(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$abschluss = Monatsabschluss::find((int)$params['id']);
|
||||
if ($abschluss === null) {
|
||||
View::redirect('/planung/monatsabschluss', 'Den Abschluss gibt es nicht.', 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
Monatsabschluss::wiedereroeffnen((int)$abschluss['id']);
|
||||
View::redirect(
|
||||
self::pfad((int)$abschluss['jahr'], (int)$abschluss['monat']),
|
||||
'Der Monat ist wieder offen. Der Snapshot des Abschlusses bleibt erhalten.'
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
public function snapshot(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$abschluss = Monatsabschluss::find((int)$params['id']);
|
||||
if ($abschluss === null) {
|
||||
View::redirect('/planung/monatsabschluss', 'Den Abschluss gibt es nicht.', 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
$daten = json_decode((string)$abschluss['snapshot'], true);
|
||||
|
||||
$this->render('planung/snapshot.php', [
|
||||
'titel' => 'Abschluss ' . ($abschluss['monat'] . '/' . $abschluss['jahr']),
|
||||
'abschluss' => $abschluss,
|
||||
'snapshot' => is_array($daten) ? $daten : [],
|
||||
'monatsnamen' => Budgetplaner::MONATSNAMEN,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function pfad(int $jahr, int $monat): string
|
||||
{
|
||||
return '/planung/monatsabschluss?jahr=' . $jahr . '&monat=' . $monat;
|
||||
}
|
||||
}
|
||||
31
src/Controllers/PlanungController.php
Normal file
31
src/Controllers/PlanungController.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Controllers;
|
||||
|
||||
use Finanzmanager\Models\Budget;
|
||||
use Finanzmanager\Models\Monatsabschluss;
|
||||
|
||||
/**
|
||||
* Einstieg in den Bereich Planung – von hier führen die Wege in den
|
||||
* Budgetplaner und in den Monatsabschluss.
|
||||
*/
|
||||
class PlanungController extends BaseController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$aktives = Budget::aktives();
|
||||
$letzter = Monatsabschluss::alle();
|
||||
|
||||
$this->render('planung/index.php', [
|
||||
'titel' => 'Planung',
|
||||
'budget' => $aktives,
|
||||
'anzahl' => Budget::anzahl(),
|
||||
'letzter' => $letzter === [] ? null : $letzter[0],
|
||||
'naechster' => Monatsabschluss::naechsterOffener(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,11 +22,6 @@ class PlatzhalterController extends BaseController
|
|||
'phase' => 'Phase 4',
|
||||
'text' => 'Summen-, Buchungs-, Vergleichs- und Budgetbericht samt Grafiken.',
|
||||
],
|
||||
'planung' => [
|
||||
'titel' => 'Planung',
|
||||
'phase' => 'Phase 3',
|
||||
'text' => 'Budgetplaner für das Jahresbudget und der Monatsabschluss.',
|
||||
],
|
||||
'wertpapiere' => [
|
||||
'titel' => 'Wertpapiere',
|
||||
'phase' => 'Phase 6',
|
||||
|
|
|
|||
|
|
@ -92,9 +92,39 @@ class VerwaltungController extends BaseController
|
|||
$this->verlangeAnmeldung();
|
||||
|
||||
$this->render('verwaltung/kontengruppen.php', [
|
||||
'titel' => 'Kontengruppen',
|
||||
'gruppen' => Kontengruppe::alle(),
|
||||
'konten' => Konto::alle(),
|
||||
'titel' => 'Kontengruppen',
|
||||
'gruppen' => Kontengruppe::alleMitKontenzahl(),
|
||||
'ohneGruppe' => Kontengruppe::anzahlOhneGruppe(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function kontengruppeNeu(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
$this->kontengruppeFormular(null);
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
public function kontengruppeBearbeiten(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$gruppe = Kontengruppe::find((int)$params['id']);
|
||||
if ($gruppe === null) {
|
||||
View::redirect('/verwaltung/kontengruppen', 'Kontengruppe nicht gefunden.', 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->kontengruppeFormular($gruppe);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed>|null $gruppe */
|
||||
private function kontengruppeFormular(?array $gruppe): void
|
||||
{
|
||||
$this->render('verwaltung/kontengruppe-formular.php', [
|
||||
'titel' => $gruppe === null ? 'Neue Kontengruppe' : 'Kontengruppe bearbeiten',
|
||||
'gruppe' => $gruppe,
|
||||
'konten' => Konto::alle(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -103,13 +133,14 @@ class VerwaltungController extends BaseController
|
|||
$this->verlangeAnmeldung();
|
||||
|
||||
try {
|
||||
Kontengruppe::anlegen(
|
||||
$id = Kontengruppe::anlegen(
|
||||
$this->post('name'),
|
||||
$this->post('beschreibung'),
|
||||
(int)$this->post('sortierung', '0')
|
||||
);
|
||||
Kontengruppe::kontenZuordnen($id, $this->postIds('konten'));
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect('/verwaltung/kontengruppen', $e->getMessage(), 'fehler');
|
||||
View::redirect('/verwaltung/kontengruppen/neu', $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -121,15 +152,18 @@ class VerwaltungController extends BaseController
|
|||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$id = (int)$params['id'];
|
||||
|
||||
try {
|
||||
Kontengruppe::speichern(
|
||||
(int)$params['id'],
|
||||
$id,
|
||||
$this->post('name'),
|
||||
$this->post('beschreibung'),
|
||||
(int)$this->post('sortierung', '0')
|
||||
);
|
||||
Kontengruppe::kontenZuordnen($id, $this->postIds('konten'));
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect('/verwaltung/kontengruppen', $e->getMessage(), 'fehler');
|
||||
View::redirect('/verwaltung/kontengruppen/' . $id, $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ class Buchung
|
|||
public static function anlegen(array $daten, array $splits = [], array $adressen = []): int
|
||||
{
|
||||
$felder = self::normalisiere($daten, $splits);
|
||||
Monatsabschluss::verlangeOffen($felder['datum']);
|
||||
|
||||
return (int)Database::transaktion(static function () use ($felder, $splits, $adressen): int {
|
||||
Database::execute(
|
||||
|
|
@ -91,12 +92,18 @@ class Buchung
|
|||
*/
|
||||
public static function speichern(int $id, array $daten, array $splits = [], array $adressen = []): void
|
||||
{
|
||||
if (self::find($id) === null) {
|
||||
$bisher = self::find($id);
|
||||
if ($bisher === null) {
|
||||
throw new \RuntimeException('Die Buchung gibt es nicht.');
|
||||
}
|
||||
|
||||
$felder = self::normalisiere($daten, $splits);
|
||||
|
||||
// Beide Seiten prüfen: Eine Buchung darf weder in einen abgeschlossenen
|
||||
// Monat hinein noch aus einem heraus verschoben werden.
|
||||
Monatsabschluss::verlangeOffen((string)$bisher['datum']);
|
||||
Monatsabschluss::verlangeOffen($felder['datum']);
|
||||
|
||||
Database::transaktion(static function () use ($id, $felder, $splits, $adressen): void {
|
||||
Database::execute(
|
||||
'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?,
|
||||
|
|
@ -120,6 +127,11 @@ class Buchung
|
|||
|
||||
public static function loeschen(int $id): void
|
||||
{
|
||||
$buchung = self::find($id);
|
||||
if ($buchung !== null) {
|
||||
Monatsabschluss::verlangeOffen((string)$buchung['datum']);
|
||||
}
|
||||
|
||||
Database::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
||||
}
|
||||
|
||||
|
|
|
|||
254
src/Models/Budget.php
Normal file
254
src/Models/Budget.php
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
<?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;
|
||||
}
|
||||
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trägt den Wert eines Monats in alle folgenden Monate des Jahres ein –
|
||||
* der „auf Folgemonate übertragen“-Knopf des FinanzManagers.
|
||||
*/
|
||||
public static function aufFolgemonateUebertragen(int $budgetId, int $kategorieId, int $abMonat): void
|
||||
{
|
||||
$betrag = Database::fetchValue(
|
||||
'SELECT betrag FROM budget_positionen WHERE budget_id = ? AND kategorie_id = ? AND monat = ?',
|
||||
[$budgetId, $kategorieId, $abMonat]
|
||||
);
|
||||
|
||||
Database::transaktion(static function () use ($budgetId, $kategorieId, $abMonat, $betrag): void {
|
||||
for ($monat = $abMonat + 1; $monat <= 12; $monat++) {
|
||||
if ($betrag === null) {
|
||||
Database::execute(
|
||||
'DELETE FROM budget_positionen WHERE budget_id = ? AND kategorie_id = ? AND monat = ?',
|
||||
[$budgetId, $kategorieId, $monat]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
Database::execute(
|
||||
'INSERT INTO budget_positionen (budget_id, kategorie_id, monat, betrag)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE betrag = VALUES(betrag)',
|
||||
[$budgetId, $kategorieId, $monat, (float)$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;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,34 @@ class Kontengruppe
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
|
|
@ -84,6 +112,34 @@ class Kontengruppe
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
|
|
|
|||
312
src/Models/Monatsabschluss.php
Normal file
312
src/Models/Monatsabschluss.php
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Models;
|
||||
|
||||
use Finanzmanager\Database;
|
||||
use Finanzmanager\Mandant;
|
||||
|
||||
/**
|
||||
* Der Monatsabschluss friert einen Monat ein: Er hält Kontosalden und
|
||||
* Kategoriesummen als Snapshot fest und sperrt Buchungen in diesem Zeitraum.
|
||||
*
|
||||
* Die Sperre ist eine fachliche, keine technische: Sie greift in
|
||||
* Buchung::anlegen/speichern/loeschen und lässt sich durch Wiedereröffnen des
|
||||
* Monats aufheben – der Vorgang bleibt in der Zeile vermerkt.
|
||||
*/
|
||||
class Monatsabschluss
|
||||
{
|
||||
public const STATUS_ABGESCHLOSSEN = 'abgeschlossen';
|
||||
public const STATUS_WIEDEREROEFFNET = 'wiedereroeffnet';
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public static function alle(?int $jahr = null): array
|
||||
{
|
||||
$sql = 'SELECT * FROM monatsabschluesse WHERE benutzer_id = ?';
|
||||
$params = [Mandant::id()];
|
||||
|
||||
if ($jahr !== null) {
|
||||
$sql .= ' AND jahr = ?';
|
||||
$params[] = $jahr;
|
||||
}
|
||||
|
||||
return Database::fetchAll($sql . ' ORDER BY jahr DESC, monat DESC', $params);
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$zeile = Database::fetchOne(
|
||||
'SELECT * FROM monatsabschluesse WHERE id = ? AND benutzer_id = ?',
|
||||
[$id, Mandant::id()]
|
||||
);
|
||||
return $zeile === false ? null : $zeile;
|
||||
}
|
||||
|
||||
public static function fuerMonat(int $jahr, int $monat): ?array
|
||||
{
|
||||
$zeile = Database::fetchOne(
|
||||
'SELECT * FROM monatsabschluesse WHERE benutzer_id = ? AND jahr = ? AND monat = ?',
|
||||
[Mandant::id(), $jahr, $monat]
|
||||
);
|
||||
return $zeile === false ? null : $zeile;
|
||||
}
|
||||
|
||||
/** Liegt das Datum in einem abgeschlossenen Monat? */
|
||||
public static function istGesperrt(string $datum): bool
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $datum, $treffer)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)Database::fetchValue(
|
||||
'SELECT COUNT(*) FROM monatsabschluesse
|
||||
WHERE benutzer_id = ? AND jahr = ? AND monat = ? AND status = ?',
|
||||
[Mandant::id(), (int)$treffer[1], (int)$treffer[2], self::STATUS_ABGESCHLOSSEN],
|
||||
0
|
||||
) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wirft, wenn in einem abgeschlossenen Monat gebucht werden soll. Die
|
||||
* Prüfung lässt sich in den Einstellungen abschalten – etwa während eines
|
||||
* Imports, der ältere Zeiträume nachträgt.
|
||||
*/
|
||||
public static function verlangeOffen(string $datum): void
|
||||
{
|
||||
if (Einstellung::get('monatsabschluss_sperrt_buchungen', '1') !== '1') {
|
||||
return;
|
||||
}
|
||||
if (!self::istGesperrt($datum)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
'Der Monat ' . substr($datum, 5, 2) . '/' . substr($datum, 0, 4)
|
||||
. ' ist abgeschlossen. Zum Buchen muss er wiedereröffnet werden.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüflauf vor dem Abschluss: Was im Monat noch klemmt. Jeder Punkt ist ein
|
||||
* Hinweis, kein Hindernis – abschließen lässt sich trotzdem, der
|
||||
* FinanzManager hält es genauso.
|
||||
*
|
||||
* @return array{buchungen: int, ohne_kategorie: list<array<string,mixed>>,
|
||||
* nicht_abgeglichen: int, splitdifferenzen: list<array<string,mixed>>}
|
||||
*/
|
||||
public static function pruefen(int $jahr, int $monat): array
|
||||
{
|
||||
[$von, $bis] = self::zeitraum($jahr, $monat);
|
||||
$p = [Mandant::id(), $von, $bis];
|
||||
|
||||
$ohneKategorie = Database::fetchAll(
|
||||
'SELECT b.id, b.datum, b.betrag, b.empfaenger, k.name AS konto
|
||||
FROM buchungen b
|
||||
JOIN konten k ON k.id = b.konto_id
|
||||
WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ?
|
||||
AND b.gesplittet = 0 AND b.kategorie_id IS NULL AND b.gegenkonto_id IS NULL
|
||||
ORDER BY b.datum, b.id',
|
||||
$p
|
||||
);
|
||||
|
||||
$splitdifferenzen = Database::fetchAll(
|
||||
'SELECT b.id, b.datum, b.betrag, b.empfaenger, k.name AS konto,
|
||||
COALESCE(SUM(s.betrag), 0) AS summe_splits
|
||||
FROM buchungen b
|
||||
JOIN konten k ON k.id = b.konto_id
|
||||
LEFT JOIN buchung_splits s ON s.buchung_id = b.id
|
||||
WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? AND b.gesplittet = 1
|
||||
GROUP BY b.id
|
||||
HAVING ABS(COALESCE(SUM(s.betrag), 0) - b.betrag) > 0.004
|
||||
ORDER BY b.datum, b.id',
|
||||
$p
|
||||
);
|
||||
|
||||
return [
|
||||
'buchungen' => (int)Database::fetchValue(
|
||||
'SELECT COUNT(*) FROM buchungen WHERE benutzer_id = ? AND datum BETWEEN ? AND ?',
|
||||
$p,
|
||||
0
|
||||
),
|
||||
'ohne_kategorie' => $ohneKategorie,
|
||||
'nicht_abgeglichen' => (int)Database::fetchValue(
|
||||
'SELECT COUNT(*) FROM buchungen
|
||||
WHERE benutzer_id = ? AND datum BETWEEN ? AND ? AND abgeglichen = 0',
|
||||
$p,
|
||||
0
|
||||
),
|
||||
'splitdifferenzen' => $splitdifferenzen,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schließt den Monat ab. Der Snapshot hält die Kontosalden zum Monatsende
|
||||
* und die Kategoriesummen des Monats fest.
|
||||
*/
|
||||
public static function abschliessen(int $jahr, int $monat, string $notiz = '', string $von = ''): int
|
||||
{
|
||||
self::pruefeMonat($jahr, $monat);
|
||||
|
||||
$vorhanden = self::fuerMonat($jahr, $monat);
|
||||
if ($vorhanden !== null && $vorhanden['status'] === self::STATUS_ABGESCHLOSSEN) {
|
||||
throw new \RuntimeException('Der Monat ist bereits abgeschlossen.');
|
||||
}
|
||||
|
||||
$snapshot = json_encode(self::snapshot($jahr, $monat), JSON_UNESCAPED_UNICODE);
|
||||
|
||||
Database::execute(
|
||||
'INSERT INTO monatsabschluesse
|
||||
(benutzer_id, jahr, monat, status, notiz, snapshot, abgeschlossen_am, abgeschlossen_von)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW(), ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status), notiz = VALUES(notiz), snapshot = VALUES(snapshot),
|
||||
abgeschlossen_am = NOW(), abgeschlossen_von = VALUES(abgeschlossen_von),
|
||||
wiedereroeffnet_am = NULL',
|
||||
[Mandant::id(), $jahr, $monat, self::STATUS_ABGESCHLOSSEN, $notiz, $snapshot, $von]
|
||||
);
|
||||
|
||||
$zeile = self::fuerMonat($jahr, $monat);
|
||||
return $zeile === null ? 0 : (int)$zeile['id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hebt die Sperre wieder auf. Der Snapshot bleibt stehen: Er zeigt, mit
|
||||
* welchen Zahlen der Monat einmal abgeschlossen wurde.
|
||||
*/
|
||||
public static function wiedereroeffnen(int $id): void
|
||||
{
|
||||
Database::execute(
|
||||
'UPDATE monatsabschluesse
|
||||
SET status = ?, wiedereroeffnet_am = NOW()
|
||||
WHERE id = ? AND benutzer_id = ?',
|
||||
[self::STATUS_WIEDEREROEFFNET, $id, Mandant::id()]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorschlag für den nächsten Abschluss: der älteste Monat mit Buchungen,
|
||||
* der noch offen ist. Abgeschlossen wird der Reihe nach, deshalb läuft die
|
||||
* Suche vom Vormonat rückwärts und endet am ersten bereits abgeschlossenen
|
||||
* Monat. Der laufende Monat kommt nie infrage – er ist noch nicht vorbei.
|
||||
*
|
||||
* @return array{jahr: int, monat: int}
|
||||
*/
|
||||
public static function naechsterOffener(?string $heute = null): array
|
||||
{
|
||||
$heute = $heute ?? date('Y-m-d');
|
||||
$jahr = (int)substr($heute, 0, 4);
|
||||
$monat = (int)substr($heute, 5, 2);
|
||||
|
||||
// Vom Vormonat aus rückwärts bis zu zwei Jahre suchen.
|
||||
[$jahr, $monat] = self::vormonat($jahr, $monat);
|
||||
|
||||
$kandidat = ['jahr' => $jahr, 'monat' => $monat];
|
||||
for ($i = 0; $i < 24; $i++) {
|
||||
$abschluss = self::fuerMonat($jahr, $monat);
|
||||
if ($abschluss !== null && $abschluss['status'] === self::STATUS_ABGESCHLOSSEN) {
|
||||
return $kandidat;
|
||||
}
|
||||
|
||||
$hatBuchungen = (int)Database::fetchValue(
|
||||
'SELECT COUNT(*) FROM buchungen WHERE benutzer_id = ? AND datum BETWEEN ? AND ?',
|
||||
array_merge([Mandant::id()], self::zeitraum($jahr, $monat)),
|
||||
0
|
||||
) > 0;
|
||||
|
||||
if ($hatBuchungen) {
|
||||
$kandidat = ['jahr' => $jahr, 'monat' => $monat];
|
||||
}
|
||||
|
||||
[$jahr, $monat] = self::vormonat($jahr, $monat);
|
||||
}
|
||||
|
||||
return $kandidat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kontosalden zum Monatsende und Kategoriesummen des Monats.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function snapshot(int $jahr, int $monat): array
|
||||
{
|
||||
[$von, $bis] = self::zeitraum($jahr, $monat);
|
||||
|
||||
$salden = Buchung::saldenJeKonto($bis);
|
||||
$konten = [];
|
||||
foreach (Konto::alle() as $konto) {
|
||||
$id = (int)$konto['id'];
|
||||
$konten[] = [
|
||||
'id' => $id,
|
||||
'name' => $konto['name'],
|
||||
'in_summe' => (int)$konto['in_summe'],
|
||||
'saldo' => round((float)$konto['anfangssaldo'] + ($salden[$id] ?? 0.0), 2),
|
||||
];
|
||||
}
|
||||
|
||||
$kategorien = Database::fetchAll(
|
||||
'SELECT kategorie_id, SUM(betrag) AS betrag FROM (
|
||||
SELECT b.kategorie_id AS kategorie_id, b.betrag AS betrag
|
||||
FROM buchungen b
|
||||
WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ?
|
||||
AND b.gesplittet = 0 AND b.kategorie_id IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT s.kategorie_id AS kategorie_id, s.betrag AS betrag
|
||||
FROM buchung_splits s
|
||||
JOIN buchungen b ON b.id = s.buchung_id
|
||||
WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ?
|
||||
AND b.gesplittet = 1 AND s.kategorie_id IS NOT NULL
|
||||
) AS bewegungen
|
||||
GROUP BY kategorie_id',
|
||||
[Mandant::id(), $von, $bis, Mandant::id(), $von, $bis]
|
||||
);
|
||||
|
||||
$summen = [];
|
||||
foreach ($kategorien as $zeile) {
|
||||
$summen[] = ['kategorie_id' => (int)$zeile['kategorie_id'], 'betrag' => round((float)$zeile['betrag'], 2)];
|
||||
}
|
||||
|
||||
$summeKonten = 0.0;
|
||||
foreach ($konten as $konto) {
|
||||
if ($konto['in_summe'] === 1) {
|
||||
$summeKonten += $konto['saldo'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'erstellt_am' => date('c'),
|
||||
'zeitraum' => ['von' => $von, 'bis' => $bis],
|
||||
'konten' => $konten,
|
||||
'summe_kontostand' => round($summeKonten, 2),
|
||||
'kategorien' => $summen,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{0: string, 1: string} erster und letzter Tag des Monats */
|
||||
public static function zeitraum(int $jahr, int $monat): array
|
||||
{
|
||||
self::pruefeMonat($jahr, $monat);
|
||||
|
||||
$von = sprintf('%04d-%02d-01', $jahr, $monat);
|
||||
$bis = date('Y-m-t', (int)strtotime($von));
|
||||
|
||||
return [$von, $bis];
|
||||
}
|
||||
|
||||
/** @return array{0: int, 1: int} */
|
||||
private static function vormonat(int $jahr, int $monat): array
|
||||
{
|
||||
return $monat === 1 ? [$jahr - 1, 12] : [$jahr, $monat - 1];
|
||||
}
|
||||
|
||||
private static function pruefeMonat(int $jahr, int $monat): void
|
||||
{
|
||||
if ($monat < 1 || $monat > 12) {
|
||||
throw new \InvalidArgumentException('Monat muss zwischen 1 und 12 liegen.');
|
||||
}
|
||||
if ($jahr < 1900 || $jahr > 2200) {
|
||||
throw new \InvalidArgumentException('Das Jahr ist ungültig.');
|
||||
}
|
||||
}
|
||||
}
|
||||
221
src/Services/Budgetplaner.php
Normal file
221
src/Services/Budgetplaner.php
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<?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.
|
||||
*
|
||||
* 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),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Summiert einen Monatsbereich einer Zeile – für die zusammengefassten
|
||||
* Spalten „Januar bis Vormonat“ und den Jahresüberblick.
|
||||
*
|
||||
* @param array<int, float> $reihe
|
||||
*/
|
||||
public static function summe(array $reihe, int $vonMonat, int $bisMonat): float
|
||||
{
|
||||
$summe = 0.0;
|
||||
for ($monat = max(1, $vonMonat); $monat <= min(12, $bisMonat); $monat++) {
|
||||
$summe += $reihe[$monat] ?? 0.0;
|
||||
}
|
||||
return $summe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bis zu welchem Monat das Jahr abgelaufen ist: im laufenden Jahr der
|
||||
* Vormonat, in vergangenen Jahren der Dezember, in künftigen gar nichts.
|
||||
*/
|
||||
public static function abgelaufenBis(int $jahr, ?string $heute = null): int
|
||||
{
|
||||
$heute = $heute ?? date('Y-m-d');
|
||||
$jetztJahr = (int)substr($heute, 0, 4);
|
||||
$jetztMonat = (int)substr($heute, 5, 2);
|
||||
|
||||
if ($jahr < $jetztJahr) {
|
||||
return 12;
|
||||
}
|
||||
if ($jahr > $jetztJahr) {
|
||||
return 0;
|
||||
}
|
||||
return $jetztMonat - 1;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,9 @@ ob_start();
|
|||
<label for="passwort_wdh">Passwort wiederholen</label>
|
||||
<input type="password" id="passwort_wdh" name="passwort_wdh" autocomplete="new-password" required minlength="10">
|
||||
|
||||
<button type="submit" class="knopf primaer">Zugang einrichten</button>
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Zugang einrichten</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php
|
||||
$inhalt = (string)ob_get_clean();
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ ob_start();
|
|||
<label for="passwort">Passwort</label>
|
||||
<input type="password" id="passwort" name="passwort" autocomplete="current-password" required>
|
||||
|
||||
<button type="submit" class="knopf primaer">Anmelden</button>
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Anmelden</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php
|
||||
$inhalt = (string)ob_get_clean();
|
||||
|
|
|
|||
37
src/Views/planung/budget-formular.php
Normal file
37
src/Views/planung/budget-formular.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,mixed> $budget */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/planung/budget/verwaltung" class="knopf">Zurück zu den Budgets</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/planung/budget/<?= (int)$budget['id'] ?>/bearbeiten" class="formular breit">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="<?= View::h($budget['name']) ?>" required autofocus>
|
||||
|
||||
<label for="jahr">Jahr</label>
|
||||
<input type="number" id="jahr" name="jahr" value="<?= (int)$budget['jahr'] ?>" step="1" required>
|
||||
<p class="feldhinweis">
|
||||
Das Jahr bestimmt, aus welchen Buchungen die Ist-Werte kommen. Die geplanten
|
||||
Monatswerte bleiben beim Wechsel erhalten.
|
||||
</p>
|
||||
|
||||
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="beschreibung" name="beschreibung" value="<?= View::h($budget['beschreibung']) ?>">
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Speichern</button>
|
||||
<a href="/planung/budget/verwaltung" class="knopf">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
40
src/Views/planung/budget-leer.php
Normal file
40
src/Views/planung/budget-leer.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var string $csrfToken */
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/planung" class="knopf">Zurück zur Planung</a>
|
||||
</div>
|
||||
|
||||
<div class="kachel einzeln">
|
||||
<h2>Noch kein Budget</h2>
|
||||
<p class="platzhalter">
|
||||
Ein Budget gilt für ein Jahr und hält je Kategorie zwölf Monatswerte. Ist und Saldo
|
||||
rechnet der Planer laufend aus den Buchungen – geplant wird nur der Budgetwert.
|
||||
</p>
|
||||
|
||||
<form method="post" action="/planung/budget/neu" class="formular">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="<?= (int)date('Y') + 1 ?>" required autofocus>
|
||||
<p class="feldhinweis">Im FinanzManager heißen die Budgets etwa „2026-ecclesia“.</p>
|
||||
|
||||
<label for="jahr">Jahr</label>
|
||||
<input type="number" id="jahr" name="jahr" value="<?= (int)date('Y') + 1 ?>" step="1" required>
|
||||
|
||||
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="beschreibung" name="beschreibung">
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Budget anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
123
src/Views/planung/budget-verwaltung.php
Normal file
123
src/Views/planung/budget-verwaltung.php
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var list<array<string,mixed>> $budgets */
|
||||
/** @var int $jahr */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/planung/budget" class="knopf primaer">Budgetplaner öffnen</a>
|
||||
<a href="/planung" class="knopf">Zurück zur Planung</a>
|
||||
</div>
|
||||
|
||||
<p class="hinweis">
|
||||
Mehrere Budgets je Jahr sind möglich; das aktive (★) öffnet der Planer beim Aufruf.
|
||||
Ein Budget für das nächste Jahr entsteht am schnellsten als Kopie des laufenden –
|
||||
dabei werden alle Monatswerte übernommen.
|
||||
</p>
|
||||
|
||||
<?php if ($budgets === []): ?>
|
||||
<p class="platzhalter">Noch kein Budget angelegt.</p>
|
||||
<?php else: ?>
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th class="rechts">Jahr</th><th>Beschreibung</th>
|
||||
<th class="rechts">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($budgets as $eintrag): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/planung/budget?budget=<?= (int)$eintrag['id'] ?>">
|
||||
<?= View::h($eintrag['name']) ?>
|
||||
</a>
|
||||
<?php if ((int)$eintrag['aktiv'] === 1): ?>
|
||||
<span class="marke" title="Aktives Budget">★ aktiv</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="rechts"><?= (int)$eintrag['jahr'] ?></td>
|
||||
<td class="nebentext"><?= View::h($eintrag['beschreibung']) ?></td>
|
||||
<td class="rechts nowrap">
|
||||
<a href="/planung/budget/<?= (int)$eintrag['id'] ?>/bearbeiten"
|
||||
class="knopf klein">Bearbeiten</a>
|
||||
<?php if ((int)$eintrag['aktiv'] !== 1): ?>
|
||||
<form method="post" action="/planung/budget/<?= (int)$eintrag['id'] ?>/aktivieren"
|
||||
class="inline">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<button type="submit" class="knopf klein">Aktiv setzen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<form method="post" action="/planung/budget/<?= (int)$eintrag['id'] ?>/loeschen"
|
||||
class="inline"
|
||||
data-bestaetigen="Budget „<?= View::h($eintrag['name']) ?>“ mit allen Monatswerten löschen?">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<button type="submit" class="knopf klein gefahr">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="kachelraster">
|
||||
<div class="kachel">
|
||||
<header><h2>Neues Budget</h2></header>
|
||||
<form method="post" action="/planung/budget/neu" class="formular">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
|
||||
<label for="jahr">Jahr</label>
|
||||
<input type="number" id="jahr" name="jahr" value="<?= $jahr + 1 ?>" step="1" required>
|
||||
|
||||
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="beschreibung" name="beschreibung">
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Budget anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php if ($budgets !== []): ?>
|
||||
<div class="kachel">
|
||||
<header><h2>Budget kopieren</h2></header>
|
||||
<form method="post" action="/planung/budget/kopieren" class="formular">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
|
||||
<label for="vorlage">Vorlage</label>
|
||||
<select id="vorlage" name="vorlage">
|
||||
<?php foreach ($budgets as $eintrag): ?>
|
||||
<option value="<?= (int)$eintrag['id'] ?>">
|
||||
<?= View::h($eintrag['name']) ?> (<?= (int)$eintrag['jahr'] ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<label for="kopie_name">Name der Kopie</label>
|
||||
<input type="text" id="kopie_name" name="name" required>
|
||||
|
||||
<label for="kopie_jahr">Jahr</label>
|
||||
<input type="number" id="kopie_jahr" name="jahr" value="<?= $jahr + 1 ?>" step="1" required>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Kopie anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="feldhinweis">
|
||||
Übernommen werden alle Monatswerte der Vorlage. Ist-Werte entstehen im neuen
|
||||
Jahr aus den Buchungen.
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
242
src/Views/planung/budgetplaner.php
Normal file
242
src/Views/planung/budgetplaner.php
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Services\Budgetplaner;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,mixed> $budget */
|
||||
/** @var list<array<string,mixed>> $budgets */
|
||||
/** @var array{zeilen: list<array<string,mixed>>, gesamt: array<string,mixed>, jahr: int} $tabelle */
|
||||
/** @var string $ansicht */
|
||||
/** @var int $abgelaufenBis */
|
||||
/** @var int $laufenderMonat */
|
||||
/** @var bool $nurGeplante */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$jahr = (int)$budget['jahr'];
|
||||
|
||||
// Welche Monate bekommen eine Eingabespalte? In der Verlaufsansicht sind das
|
||||
// der laufende Monat und alles danach – abgelaufene Monate fasst eine einzige
|
||||
// Spalte zusammen, wie im FinanzManager. „Alle Monate“ zeigt alle zwölf.
|
||||
$alleMonate = $ansicht === 'alle';
|
||||
$erster = $alleMonate ? 1 : max(1, $laufenderMonat === 0 ? 1 : $laufenderMonat);
|
||||
$eingabe = range($erster, 12);
|
||||
|
||||
$zelle = static function (float $wert): string {
|
||||
return View::betrag($wert, false);
|
||||
};
|
||||
|
||||
$link = static function (array $ersatz) use ($budget, $ansicht, $nurGeplante): string {
|
||||
$parameter = array_merge([
|
||||
'budget' => (int)$budget['id'],
|
||||
'ansicht' => $ansicht,
|
||||
'nur_geplante' => $nurGeplante ? '1' : null,
|
||||
], $ersatz);
|
||||
|
||||
return '/planung/budget?' . http_build_query(array_filter(
|
||||
$parameter,
|
||||
static fn($wert) => $wert !== null && $wert !== ''
|
||||
));
|
||||
};
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<form method="get" action="/planung/budget" class="inline">
|
||||
<select name="budget" aria-label="Budget wählen">
|
||||
<?php foreach ($budgets as $eintrag): ?>
|
||||
<option value="<?= (int)$eintrag['id'] ?>"
|
||||
<?= (int)$eintrag['id'] === (int)$budget['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($eintrag['name']) ?> (<?= (int)$eintrag['jahr'] ?>)<?= (int)$eintrag['aktiv'] === 1 ? ' ★' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<input type="hidden" name="ansicht" value="<?= View::h($ansicht) ?>">
|
||||
<?php if ($nurGeplante): ?>
|
||||
<input type="hidden" name="nur_geplante" value="1">
|
||||
<?php endif; ?>
|
||||
<button type="submit" class="knopf klein">Öffnen</button>
|
||||
</form>
|
||||
|
||||
<a href="<?= View::h($link(['ansicht' => $alleMonate ? 'verlauf' : 'alle'])) ?>" class="knopf">
|
||||
<?= $alleMonate ? 'Ansicht: Verlauf' : 'Ansicht: alle Monate' ?>
|
||||
</a>
|
||||
<a href="<?= View::h($link(['nur_geplante' => $nurGeplante ? null : '1'])) ?>" class="knopf">
|
||||
<?= $nurGeplante ? 'Alle Kategorien' : 'Nur geplante Kategorien' ?>
|
||||
</a>
|
||||
<span class="fuellung"></span>
|
||||
<a href="/planung/budget/verwaltung" class="knopf">Budgets verwalten</a>
|
||||
<a href="/planung" class="knopf">Zurück zur Planung</a>
|
||||
</div>
|
||||
|
||||
<p class="hinweis">
|
||||
Budget <strong><?= View::h($budget['name']) ?></strong> für <?= $jahr ?>.
|
||||
Ausgaben werden ohne Vorzeichen eingetragen – die Kategorieart bestimmt es.
|
||||
Ein leeres Feld heißt „nicht geplant“, eine 0 heißt „mit 0,00 geplant“.
|
||||
<strong>Saldo</strong> ist Ist minus Budget: negativ bedeutet über Plan.
|
||||
Übergeordnete Kategorien summieren ihre Unterkategorien und werden deshalb
|
||||
nicht selbst beplant.
|
||||
</p>
|
||||
|
||||
<form method="post" action="/planung/budget/<?= (int)$budget['id'] ?>/positionen">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="hidden" name="ansicht" value="<?= View::h($ansicht) ?>">
|
||||
<?php if ($nurGeplante): ?>
|
||||
<input type="hidden" name="nur_geplante" value="1">
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Budget speichern</button>
|
||||
</div>
|
||||
|
||||
<div class="breittabelle">
|
||||
<table class="liste budgetplaner">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2" class="spalte-kategorie">Kategorie</th>
|
||||
<th colspan="3" class="gruppenkopf">Jahr <?= $jahr ?></th>
|
||||
<?php if (!$alleMonate && $abgelaufenBis > 0): ?>
|
||||
<th colspan="3" class="gruppenkopf">
|
||||
Januar–<?= View::h(Budgetplaner::MONATSKUERZEL[$abgelaufenBis]) ?>
|
||||
</th>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($eingabe as $monat): ?>
|
||||
<th class="gruppenkopf<?= $monat === $laufenderMonat ? ' laufend' : '' ?>"
|
||||
colspan="<?= !$alleMonate && $monat === $laufenderMonat ? 3 : 1 ?>">
|
||||
<?= View::h(Budgetplaner::MONATSKUERZEL[$monat]) ?>
|
||||
</th>
|
||||
<?php endforeach; ?>
|
||||
<th rowspan="2" class="rechts">↦</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="rechts">Budget</th>
|
||||
<th class="rechts">Ist</th>
|
||||
<th class="rechts">Saldo</th>
|
||||
<?php if (!$alleMonate && $abgelaufenBis > 0): ?>
|
||||
<th class="rechts">Budget</th>
|
||||
<th class="rechts">Ist</th>
|
||||
<th class="rechts">Saldo</th>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($eingabe as $monat): ?>
|
||||
<?php if (!$alleMonate && $monat === $laufenderMonat): ?>
|
||||
<th class="rechts laufend">Budget</th>
|
||||
<th class="rechts laufend">Ist</th>
|
||||
<th class="rechts laufend">Saldo</th>
|
||||
<?php else: ?>
|
||||
<th class="rechts">Budget</th>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($tabelle['zeilen'] as $zeile): ?>
|
||||
<?php
|
||||
if ($nurGeplante && !$zeile['geplant']) {
|
||||
continue;
|
||||
}
|
||||
$id = (int)$zeile['id'];
|
||||
$blatt = !$zeile['hat_kinder'];
|
||||
$klassen = $blatt ? '' : ' summenzeile-kategorie';
|
||||
?>
|
||||
<tr class="kategoriezeile<?= $klassen ?>" data-kategorie="<?= $id ?>"
|
||||
data-eltern="<?= $zeile['eltern_id'] === null ? '' : (int)$zeile['eltern_id'] ?>">
|
||||
<td class="spalte-kategorie">
|
||||
<span class="baumname" data-ebene="<?= min(6, (int)$zeile['ebene']) ?>">
|
||||
<?php if (!$blatt): ?>
|
||||
<button type="button" class="klappknopf" data-klappt="<?= $id ?>"
|
||||
aria-label="Unterkategorien ein-/ausblenden">▾</button>
|
||||
<?php endif; ?>
|
||||
<?= View::h($zeile['name']) ?>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="rechts"><?= $zelle($zeile['jahrBudget']) ?></td>
|
||||
<td class="rechts"><?= $zelle($zeile['jahrIst']) ?></td>
|
||||
<td class="rechts <?= View::betragKlasse($zeile['jahrSaldo']) ?>">
|
||||
<?= $zelle($zeile['jahrSaldo']) ?>
|
||||
</td>
|
||||
|
||||
<?php if (!$alleMonate && $abgelaufenBis > 0): ?>
|
||||
<?php
|
||||
$budgetBis = Budgetplaner::summe($zeile['budget'], 1, $abgelaufenBis);
|
||||
$istBis = Budgetplaner::summe($zeile['ist'], 1, $abgelaufenBis);
|
||||
?>
|
||||
<td class="rechts nebentext"><?= $zelle($budgetBis) ?></td>
|
||||
<td class="rechts nebentext"><?= $zelle($istBis) ?></td>
|
||||
<td class="rechts <?= View::betragKlasse($istBis - $budgetBis) ?>">
|
||||
<?= $zelle($istBis - $budgetBis) ?>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($eingabe as $monat): ?>
|
||||
<td class="rechts<?= $monat === $laufenderMonat ? ' laufend' : '' ?>">
|
||||
<?php if ($blatt): ?>
|
||||
<input type="text" inputmode="decimal" class="betragsfeld"
|
||||
name="betrag[<?= $id ?>][<?= $monat ?>]"
|
||||
value="<?= isset($zeile['eigenBudget'][$monat])
|
||||
? View::h(View::betrag(abs($zeile['eigenBudget'][$monat]), false)) : '' ?>"
|
||||
aria-label="<?= View::h($zeile['name'] . ' ' . Budgetplaner::MONATSNAMEN[$monat]) ?>">
|
||||
<?php else: ?>
|
||||
<?= $zelle($zeile['budget'][$monat]) ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<?php if (!$alleMonate && $monat === $laufenderMonat): ?>
|
||||
<td class="rechts laufend"><?= $zelle($zeile['ist'][$monat]) ?></td>
|
||||
<td class="rechts laufend <?= View::betragKlasse($zeile['saldo'][$monat]) ?>">
|
||||
<?= $zelle($zeile['saldo'][$monat]) ?>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<td class="rechts">
|
||||
<?php if ($blatt): ?>
|
||||
<button type="submit" name="uebertragen" class="knopf klein"
|
||||
value="<?= $id ?>:<?= $erster ?>"
|
||||
title="Wert aus <?= View::h(Budgetplaner::MONATSNAMEN[$erster]) ?> auf alle Folgemonate übertragen">↦</button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="summenzeile">
|
||||
<td class="spalte-kategorie">Gesamt</td>
|
||||
<td class="rechts"><?= $zelle($tabelle['gesamt']['jahrBudget']) ?></td>
|
||||
<td class="rechts"><?= $zelle($tabelle['gesamt']['jahrIst']) ?></td>
|
||||
<td class="rechts <?= View::betragKlasse($tabelle['gesamt']['jahrSaldo']) ?>">
|
||||
<?= $zelle($tabelle['gesamt']['jahrSaldo']) ?>
|
||||
</td>
|
||||
<?php if (!$alleMonate && $abgelaufenBis > 0): ?>
|
||||
<?php
|
||||
$budgetBis = Budgetplaner::summe($tabelle['gesamt']['budget'], 1, $abgelaufenBis);
|
||||
$istBis = Budgetplaner::summe($tabelle['gesamt']['ist'], 1, $abgelaufenBis);
|
||||
?>
|
||||
<td class="rechts"><?= $zelle($budgetBis) ?></td>
|
||||
<td class="rechts"><?= $zelle($istBis) ?></td>
|
||||
<td class="rechts <?= View::betragKlasse($istBis - $budgetBis) ?>">
|
||||
<?= $zelle($istBis - $budgetBis) ?>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($eingabe as $monat): ?>
|
||||
<td class="rechts<?= $monat === $laufenderMonat ? ' laufend' : '' ?>">
|
||||
<?= $zelle($tabelle['gesamt']['budget'][$monat]) ?>
|
||||
</td>
|
||||
<?php if (!$alleMonate && $monat === $laufenderMonat): ?>
|
||||
<td class="rechts laufend"><?= $zelle($tabelle['gesamt']['ist'][$monat]) ?></td>
|
||||
<td class="rechts laufend <?= View::betragKlasse($tabelle['gesamt']['saldo'][$monat]) ?>">
|
||||
<?= $zelle($tabelle['gesamt']['saldo'][$monat]) ?>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Budget speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
56
src/Views/planung/index.php
Normal file
56
src/Views/planung/index.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Services\Budgetplaner;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,mixed>|null $budget */
|
||||
/** @var int $anzahl */
|
||||
/** @var array<string,mixed>|null $letzter */
|
||||
/** @var array{jahr: int, monat: int} $naechster */
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="kachelraster">
|
||||
<div class="kachel">
|
||||
<header><h2>Budgetplaner</h2></header>
|
||||
<p class="kennzahl"><?= $budget === null ? '–' : (int)$budget['jahr'] ?></p>
|
||||
<p class="platzhalter">
|
||||
<?php if ($budget === null): ?>
|
||||
Noch kein Budget angelegt. Der Planer stellt je Kategorie zwölf Monatswerte
|
||||
ein und rechnet Ist und Saldo aus den Buchungen.
|
||||
<?php else: ?>
|
||||
Aktives Budget: <strong><?= View::h($budget['name']) ?></strong>.
|
||||
<?= (int)$anzahl ?> Budget<?= $anzahl === 1 ? '' : 's' ?> insgesamt.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<a href="/planung/budget" class="knopf primaer">Budgetplaner öffnen</a>
|
||||
<a href="/planung/budget/verwaltung" class="knopf">Budgets verwalten</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>Monatsabschluss</h2></header>
|
||||
<p class="kennzahl">
|
||||
<?= View::h(Budgetplaner::MONATSKUERZEL[$naechster['monat']] ?? '–') ?>
|
||||
<?= (int)$naechster['jahr'] ?>
|
||||
</p>
|
||||
<p class="platzhalter">
|
||||
<?php if ($letzter === null): ?>
|
||||
Noch kein Monat abgeschlossen. Der Prüflauf zeigt vorher, was im Zeitraum
|
||||
noch offen ist.
|
||||
<?php else: ?>
|
||||
Zuletzt abgeschlossen:
|
||||
<strong><?= View::h(Budgetplaner::MONATSNAMEN[(int)$letzter['monat']] ?? '') ?>
|
||||
<?= (int)$letzter['jahr'] ?></strong>
|
||||
<?= $letzter['status'] === 'wiedereroeffnet' ? '(wieder geöffnet)' : '' ?>.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<a href="/planung/monatsabschluss" class="knopf primaer">Monatsabschluss</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
184
src/Views/planung/monatsabschluss.php
Normal file
184
src/Views/planung/monatsabschluss.php
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Models\Monatsabschluss;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var int $jahr */
|
||||
/** @var int $monat */
|
||||
/** @var array{buchungen: int, ohne_kategorie: list<array<string,mixed>>,
|
||||
* nicht_abgeglichen: int, splitdifferenzen: list<array<string,mixed>>} $pruefung */
|
||||
/** @var array<string,mixed>|null $vorhanden */
|
||||
/** @var list<array<string,mixed>> $abschluesse */
|
||||
/** @var array<int,string> $monatsnamen */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$abgeschlossen = $vorhanden !== null && $vorhanden['status'] === Monatsabschluss::STATUS_ABGESCHLOSSEN;
|
||||
$offen = count($pruefung['ohne_kategorie']) + count($pruefung['splitdifferenzen']);
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<form method="get" action="/planung/monatsabschluss" class="inline filterleiste">
|
||||
<select name="monat" aria-label="Monat">
|
||||
<?php foreach ($monatsnamen as $nummer => $name): ?>
|
||||
<option value="<?= (int)$nummer ?>" <?= $nummer === $monat ? 'selected' : '' ?>>
|
||||
<?= View::h($name) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<input type="number" name="jahr" value="<?= $jahr ?>" step="1" class="schmal" aria-label="Jahr">
|
||||
<button type="submit" class="knopf klein">Anzeigen</button>
|
||||
</form>
|
||||
<span class="fuellung"></span>
|
||||
<a href="/planung" class="knopf">Zurück zur Planung</a>
|
||||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header>
|
||||
<h2>Prüflauf <?= View::h($monatsnamen[$monat] ?? '') ?> <?= $jahr ?></h2>
|
||||
<?php if ($abgeschlossen): ?>
|
||||
<span class="phasenmarke">abgeschlossen</span>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
|
||||
<table class="liste">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Buchungen im Zeitraum</td>
|
||||
<td class="rechts"><?= (int)$pruefung['buchungen'] ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ohne Kategorie oder Gegenkonto</td>
|
||||
<td class="rechts <?= count($pruefung['ohne_kategorie']) > 0 ? 'betrag negativ' : 'nebentext' ?>">
|
||||
<?= count($pruefung['ohne_kategorie']) ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>nicht abgeglichen</td>
|
||||
<td class="rechts nebentext"><?= (int)$pruefung['nicht_abgeglichen'] ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Splitbuchungen mit Differenz</td>
|
||||
<td class="rechts <?= count($pruefung['splitdifferenzen']) > 0 ? 'betrag negativ' : 'nebentext' ?>">
|
||||
<?= count($pruefung['splitdifferenzen']) ?>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if ($pruefung['ohne_kategorie'] !== []): ?>
|
||||
<h3>Nicht kategorisiert</h3>
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr><th>Datum</th><th>Konto</th><th>Empfänger</th><th class="rechts">Betrag</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($pruefung['ohne_kategorie'] as $zeile): ?>
|
||||
<tr>
|
||||
<td><?= View::datum($zeile['datum']) ?></td>
|
||||
<td><?= View::h($zeile['konto']) ?></td>
|
||||
<td><?= View::h($zeile['empfaenger']) ?></td>
|
||||
<td class="rechts <?= View::betragKlasse($zeile['betrag']) ?>">
|
||||
<?= View::betrag($zeile['betrag']) ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($pruefung['splitdifferenzen'] !== []): ?>
|
||||
<h3>Splitsummen weichen ab</h3>
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th><th>Konto</th><th>Empfänger</th>
|
||||
<th class="rechts">Buchung</th><th class="rechts">Summe der Splits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($pruefung['splitdifferenzen'] as $zeile): ?>
|
||||
<tr>
|
||||
<td><?= View::datum($zeile['datum']) ?></td>
|
||||
<td><?= View::h($zeile['konto']) ?></td>
|
||||
<td><?= View::h($zeile['empfaenger']) ?></td>
|
||||
<td class="rechts"><?= View::betrag($zeile['betrag']) ?></td>
|
||||
<td class="rechts betrag negativ"><?= View::betrag($zeile['summe_splits']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($abgeschlossen): ?>
|
||||
<p class="hinweis">
|
||||
Abgeschlossen am <?= View::datum(substr((string)$vorhanden['abgeschlossen_am'], 0, 10)) ?>
|
||||
<?= $vorhanden['abgeschlossen_von'] !== '' ? 'von ' . View::h($vorhanden['abgeschlossen_von']) : '' ?>.
|
||||
Buchungen in diesem Zeitraum sind gesperrt.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<a href="/planung/monatsabschluss/<?= (int)$vorhanden['id'] ?>" class="knopf">Snapshot ansehen</a>
|
||||
<form method="post" action="/planung/monatsabschluss/<?= (int)$vorhanden['id'] ?>/wiedereroeffnen"
|
||||
class="inline"
|
||||
data-bestaetigen="Monat wieder öffnen? Buchungen sind danach wieder änderbar.">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<button type="submit" class="knopf gefahr">Monat wiedereröffnen</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<form method="post" action="/planung/monatsabschluss" class="formular"
|
||||
<?= $offen > 0 ? 'data-bestaetigen="Es sind noch Punkte offen. Monat trotzdem abschließen?"' : '' ?>>
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="hidden" name="jahr" value="<?= $jahr ?>">
|
||||
<input type="hidden" name="monat" value="<?= $monat ?>">
|
||||
|
||||
<label for="notiz">Notiz <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="notiz" name="notiz" maxlength="255">
|
||||
<p class="feldhinweis">
|
||||
Der Abschluss hält Kontosalden und Kategoriesummen als Snapshot fest und sperrt
|
||||
Buchungen im Zeitraum. Offene Punkte hindern nicht daran – sie sind ein Hinweis.
|
||||
</p>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Monat abschließen</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($abschluesse !== []): ?>
|
||||
<h3>Bisherige Abschlüsse</h3>
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Monat</th><th>Status</th><th>Abgeschlossen am</th><th>Von</th>
|
||||
<th>Notiz</th><th class="rechts">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($abschluesse as $eintrag): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<?= View::h($monatsnamen[(int)$eintrag['monat']] ?? '') ?> <?= (int)$eintrag['jahr'] ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($eintrag['status'] === Monatsabschluss::STATUS_ABGESCHLOSSEN): ?>
|
||||
<span class="marke">abgeschlossen</span>
|
||||
<?php else: ?>
|
||||
<span class="marke">wieder geöffnet</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= View::datum(substr((string)$eintrag['abgeschlossen_am'], 0, 10)) ?></td>
|
||||
<td class="nebentext"><?= View::h($eintrag['abgeschlossen_von']) ?></td>
|
||||
<td class="nebentext"><?= View::h($eintrag['notiz']) ?></td>
|
||||
<td class="rechts nowrap">
|
||||
<a href="/planung/monatsabschluss/<?= (int)$eintrag['id'] ?>" class="knopf klein">Snapshot</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
84
src/Views/planung/snapshot.php
Normal file
84
src/Views/planung/snapshot.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,mixed> $abschluss */
|
||||
/** @var array<string,mixed> $snapshot */
|
||||
/** @var array<int,string> $monatsnamen */
|
||||
|
||||
$namen = [];
|
||||
foreach (Kategorie::alle() as $kategorie) {
|
||||
$namen[(int)$kategorie['id']] = (string)$kategorie['pfad'];
|
||||
}
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/planung/monatsabschluss?jahr=<?= (int)$abschluss['jahr'] ?>&monat=<?= (int)$abschluss['monat'] ?>"
|
||||
class="knopf">Zurück zum Monatsabschluss</a>
|
||||
</div>
|
||||
|
||||
<p class="hinweis">
|
||||
Stand von <?= View::h($monatsnamen[(int)$abschluss['monat']] ?? '') ?> <?= (int)$abschluss['jahr'] ?>
|
||||
zum Zeitpunkt des Abschlusses. Die Zahlen bleiben stehen, auch wenn der Monat später
|
||||
wiedereröffnet und nachgebucht wird – so lässt sich vergleichen, was sich geändert hat.
|
||||
</p>
|
||||
|
||||
<?php if ($snapshot === []): ?>
|
||||
<p class="platzhalter">Zu diesem Abschluss ist kein Snapshot gespeichert.</p>
|
||||
<?php else: ?>
|
||||
<h3>Kontosalden</h3>
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr><th>Konto</th><th class="rechts">Saldo</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($snapshot['konten'] ?? [] as $konto): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<?= View::h($konto['name']) ?>
|
||||
<?php if ((int)$konto['in_summe'] !== 1): ?>
|
||||
<span class="marke" title="Zählt nicht zur Summe der Kontostände">ohne Σ</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="rechts <?= View::betragKlasse($konto['saldo']) ?>">
|
||||
<?= View::betrag($konto['saldo']) ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="summenzeile">
|
||||
<td>Summe Kontostände</td>
|
||||
<td class="rechts <?= View::betragKlasse($snapshot['summe_kontostand'] ?? 0) ?>">
|
||||
<?= View::betrag($snapshot['summe_kontostand'] ?? 0) ?>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<h3>Kategoriesummen des Monats</h3>
|
||||
<?php if (($snapshot['kategorien'] ?? []) === []): ?>
|
||||
<p class="platzhalter">Keine kategorisierten Buchungen im Zeitraum.</p>
|
||||
<?php else: ?>
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr><th>Kategorie</th><th class="rechts">Betrag</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($snapshot['kategorien'] as $zeile): ?>
|
||||
<tr>
|
||||
<td><?= View::h($namen[(int)$zeile['kategorie_id']] ?? '(gelöschte Kategorie)') ?></td>
|
||||
<td class="rechts <?= View::betragKlasse($zeile['betrag']) ?>">
|
||||
<?= View::betrag($zeile['betrag']) ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
|
|
@ -59,9 +59,21 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<tr>
|
||||
<td>Kontengruppen</td>
|
||||
<td class="rechts nebentext" colspan="3">
|
||||
nicht im QIF-Export enthalten – <a href="/verwaltung/kontengruppen">hier zuordnen</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="hinweis">
|
||||
Der FinanzManager exportiert je Konto nur Name, Kontoart, Beschreibung und
|
||||
Kreditlimit. Die Zuordnung zu einer Kontengruppe steht weder im QIF noch in der
|
||||
<code>QSD</code>-Datei; sie wird deshalb einmalig in der Verwaltung nachgetragen.
|
||||
</p>
|
||||
|
||||
<?php if ($buchungen !== null): ?>
|
||||
<h3>Buchungen</h3>
|
||||
<table class="liste">
|
||||
|
|
@ -141,6 +153,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php else: ?>
|
||||
<div class="knopfleiste">
|
||||
<a href="/konten" class="knopf primaer">Kontenübersicht</a>
|
||||
<a href="/verwaltung/kontengruppen" class="knopf">Kontengruppen</a>
|
||||
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
|
||||
</div>
|
||||
<p class="hinweis">
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php foreach ($kategorien as $kategorie): ?>
|
||||
<tr<?= (int)$kategorie['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
|
||||
<td>
|
||||
<span class="baumname" style="--ebene: <?= (int)$kategorie['ebene'] ?>">
|
||||
<span class="baumname" data-ebene="<?= min(6, (int)$kategorie['ebene']) ?>">
|
||||
<a href="/verwaltung/kategorien/<?= (int)$kategorie['id'] ?>">
|
||||
<?= View::h($kategorie['name']) ?>
|
||||
</a>
|
||||
|
|
|
|||
88
src/Views/verwaltung/kontengruppe-formular.php
Normal file
88
src/Views/verwaltung/kontengruppe-formular.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,mixed>|null $gruppe */
|
||||
/** @var list<array<string,mixed>> $konten */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$neu = $gruppe === null;
|
||||
$ziel = $neu ? '/verwaltung/kontengruppen/neu' : '/verwaltung/kontengruppen/' . (int)$gruppe['id'];
|
||||
$wert = static fn(string $feld, string $standard = ''): string
|
||||
=> View::h((string)($gruppe[$feld] ?? $standard));
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/verwaltung/kontengruppen" class="knopf">Zurück zu den Kontengruppen</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
|
||||
<label for="name">Name der Kontengruppe</label>
|
||||
<input type="text" id="name" name="name" value="<?= $wert('name') ?>" required autofocus>
|
||||
|
||||
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="beschreibung" name="beschreibung" value="<?= $wert('beschreibung') ?>">
|
||||
|
||||
<label for="sortierung">Sortierung</label>
|
||||
<input type="number" id="sortierung" name="sortierung" value="<?= $wert('sortierung', '0') ?>" step="1">
|
||||
<p class="feldhinweis">Kleinere Werte stehen in der Kontenübersicht weiter oben.</p>
|
||||
|
||||
<h3>Konten in dieser Gruppe</h3>
|
||||
<?php if ($konten === []): ?>
|
||||
<p class="feldhinweis">Noch keine Konten angelegt.</p>
|
||||
<?php else: ?>
|
||||
<p class="feldhinweis">
|
||||
Ein Konto gehört zu genau einer Gruppe. Wird hier eines angehakt, das derzeit in
|
||||
einer anderen Gruppe steht, wechselt es hierher.
|
||||
</p>
|
||||
<div class="auswahlliste">
|
||||
<?php foreach ($konten as $konto): ?>
|
||||
<?php
|
||||
$eigene = !$neu && (int)($konto['kontengruppe_id'] ?? 0) === (int)$gruppe['id'];
|
||||
$fremde = !$eigene && $konto['gruppenname'] !== null;
|
||||
?>
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="konten[]" value="<?= (int)$konto['id'] ?>"
|
||||
<?= $eigene ? 'checked' : '' ?>>
|
||||
<span>
|
||||
<?= View::h($konto['name']) ?>
|
||||
<span class="nebentext">
|
||||
<?= View::h(Konto::bezeichnungDerArt((string)$konto['kontoart'])) ?>
|
||||
<?= $fremde ? '· derzeit: ' . View::h((string)$konto['gruppenname']) : '' ?>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">
|
||||
<?= $neu ? 'Kontengruppe anlegen' : 'Speichern' ?>
|
||||
</button>
|
||||
<a href="/verwaltung/kontengruppen" class="knopf">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (!$neu): ?>
|
||||
<form method="post" action="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>/loeschen"
|
||||
class="formular breit"
|
||||
data-bestaetigen="Kontengruppe „<?= View::h($gruppe['name']) ?>“ löschen? Die Konten bleiben erhalten.">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h3>Kontengruppe löschen</h3>
|
||||
<p class="feldhinweis">
|
||||
Die zugeordneten Konten bleiben bestehen und stehen anschließend unter
|
||||
„<?= View::h(\Finanzmanager\Models\Kontengruppe::OHNE_GRUPPE) ?>“.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf gefahr">Kontengruppe löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
|
|
@ -4,24 +4,13 @@ use Finanzmanager\Models\Kontengruppe;
|
|||
use Finanzmanager\View;
|
||||
|
||||
/** @var list<array<string,mixed>> $gruppen */
|
||||
/** @var list<array<string,mixed>> $konten */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$anzahlJeGruppe = [];
|
||||
$ohneGruppe = 0;
|
||||
foreach ($konten as $konto) {
|
||||
if ($konto['kontengruppe_id'] === null) {
|
||||
$ohneGruppe++;
|
||||
continue;
|
||||
}
|
||||
$id = (int)$konto['kontengruppe_id'];
|
||||
$anzahlJeGruppe[$id] = ($anzahlJeGruppe[$id] ?? 0) + 1;
|
||||
}
|
||||
/** @var int $ohneGruppe */
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/verwaltung/kontengruppen/neu" class="knopf primaer">Neue Kontengruppe</a>
|
||||
<a href="/konten" class="knopf">Zur Kontenübersicht</a>
|
||||
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
|
||||
</div>
|
||||
|
|
@ -29,19 +18,22 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<p class="hinweis">
|
||||
Kontengruppen gliedern die Kontenübersicht. Konten ohne Gruppe erscheinen dort unter
|
||||
„<?= View::h(Kontengruppe::OHNE_GRUPPE) ?>“ – derzeit <?= (int)$ohneGruppe ?>.
|
||||
Der QIF-Export des FinanzManagers enthält keine Kontengruppen; sie werden hier
|
||||
angelegt und die Konten ihnen zugeordnet.
|
||||
</p>
|
||||
|
||||
<?php if ($gruppen === []): ?>
|
||||
<p class="platzhalter">Noch keine Kontengruppen angelegt.</p>
|
||||
<div class="kachel einzeln">
|
||||
<h2>Noch keine Kontengruppen</h2>
|
||||
<p class="platzhalter">
|
||||
Ohne Gruppen steht in der Kontenübersicht nur der Block
|
||||
„<?= View::h(Kontengruppe::OHNE_GRUPPE) ?>“.
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<a href="/verwaltung/kontengruppen/neu" class="knopf primaer">Kontengruppe anlegen</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($gruppen as $gruppe): ?>
|
||||
<form id="gruppe-<?= (int)$gruppe['id'] ?>" method="post"
|
||||
action="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>"></form>
|
||||
<form id="gruppe-loeschen-<?= (int)$gruppe['id'] ?>" method="post"
|
||||
action="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>/loeschen"
|
||||
data-bestaetigen="Kontengruppe „<?= View::h($gruppe['name']) ?>“ löschen? Die Konten bleiben erhalten."></form>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<table class="liste">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -51,28 +43,18 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($gruppen as $gruppe): ?>
|
||||
<?php $f = 'gruppe-' . (int)$gruppe['id']; ?>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="hidden" form="gruppe-loeschen-<?= (int)$gruppe['id'] ?>"
|
||||
name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="text" form="<?= $f ?>" name="name"
|
||||
value="<?= View::h($gruppe['name']) ?>" required>
|
||||
<a href="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>">
|
||||
<?= View::h($gruppe['name']) ?>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" form="<?= $f ?>" name="beschreibung"
|
||||
value="<?= View::h($gruppe['beschreibung']) ?>">
|
||||
</td>
|
||||
<td class="rechts">
|
||||
<input type="number" form="<?= $f ?>" name="sortierung" step="1" class="schmal"
|
||||
value="<?= (int)$gruppe['sortierung'] ?>">
|
||||
</td>
|
||||
<td class="rechts"><?= (int)($anzahlJeGruppe[(int)$gruppe['id']] ?? 0) ?></td>
|
||||
<td><?= View::h($gruppe['beschreibung']) ?></td>
|
||||
<td class="rechts"><?= (int)$gruppe['sortierung'] ?></td>
|
||||
<td class="rechts"><?= (int)$gruppe['anzahl_konten'] ?></td>
|
||||
<td class="rechts nowrap">
|
||||
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button>
|
||||
<button type="submit" form="gruppe-loeschen-<?= (int)$gruppe['id'] ?>"
|
||||
class="knopf klein gefahr">Löschen</button>
|
||||
<a href="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>"
|
||||
class="knopf klein">Bearbeiten</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
|
@ -80,22 +62,4 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/verwaltung/kontengruppen/neu" class="formular breit">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h3>Neue Kontengruppe</h3>
|
||||
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
|
||||
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="beschreibung" name="beschreibung">
|
||||
|
||||
<label for="sortierung">Sortierung</label>
|
||||
<input type="number" id="sortierung" name="sortierung" value="0" step="1">
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Kontengruppe anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,51 @@ class KontenTest extends TestCase
|
|||
$this->uebersichtGruppiertUndSummiert();
|
||||
$this->betragsEingabe();
|
||||
$this->ankreuzfelderAusDemFormular();
|
||||
$this->kontenEinerGruppeZuordnen();
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Kontengruppen stehen nicht im QIF-Export; sie werden nach dem Import
|
||||
* angelegt und die Konten in einem Zug angekreuzt. Abgehakte Konten müssen
|
||||
* dabei zurück nach „Nicht gruppiert“ fallen, Konten anderer Gruppen aber
|
||||
* unberührt bleiben.
|
||||
*/
|
||||
private function kontenEinerGruppeZuordnen(): void
|
||||
{
|
||||
$anlage = Kontengruppe::anlegen('Anlage', '', 1);
|
||||
$liquide = Kontengruppe::anlegen('Liquide Mittel', '', 2);
|
||||
|
||||
$haus = Konto::anlegen(['name' => 'Whg Besigheim', 'kontoart' => 'vermoegen', 'aktiv' => 1]);
|
||||
$auto = Konto::anlegen(['name' => 'Skoda Kamiq', 'kontoart' => 'vermoegen', 'aktiv' => 1]);
|
||||
$giro = Konto::anlegen(['name' => 'VR Weil Girokonto', 'kontoart' => 'bank', 'aktiv' => 1]);
|
||||
|
||||
Kontengruppe::kontenZuordnen($liquide, [$giro]);
|
||||
Kontengruppe::kontenZuordnen($anlage, [$haus, $auto]);
|
||||
|
||||
$this->assertEquals($anlage, (int)Konto::find($haus)['kontengruppe_id'], 'Konto landet in der Gruppe');
|
||||
$this->assertEquals($anlage, (int)Konto::find($auto)['kontengruppe_id'], 'Auch das zweite Konto');
|
||||
$this->assertEquals(
|
||||
$liquide,
|
||||
(int)Konto::find($giro)['kontengruppe_id'],
|
||||
'Konten anderer Gruppen bleiben, wo sie sind'
|
||||
);
|
||||
|
||||
// Zweiter Durchgang ohne das Auto: es fällt zurück zu „Nicht gruppiert“.
|
||||
Kontengruppe::kontenZuordnen($anlage, [$haus]);
|
||||
$this->assertNull(Konto::find($auto)['kontengruppe_id'], 'Abgehaktes Konto steht wieder ohne Gruppe');
|
||||
$this->assertEquals($anlage, (int)Konto::find($haus)['kontengruppe_id'], 'Das andere bleibt zugeordnet');
|
||||
|
||||
// Leere Auswahl leert die Gruppe, ohne sie zu löschen.
|
||||
Kontengruppe::kontenZuordnen($anlage, []);
|
||||
$this->assertNull(Konto::find($haus)['kontengruppe_id'], 'Leere Auswahl räumt die Gruppe');
|
||||
$this->assertNotEmpty(Kontengruppe::find($anlage), 'Die Gruppe selbst bleibt bestehen');
|
||||
|
||||
$mitZahl = [];
|
||||
foreach (Kontengruppe::alleMitKontenzahl() as $gruppe) {
|
||||
$mitZahl[$gruppe['name']] = (int)$gruppe['anzahl_konten'];
|
||||
}
|
||||
$this->assertEquals(0, $mitZahl['Anlage'], 'Kontenzahl der geleerten Gruppe');
|
||||
$this->assertEquals(1, $mitZahl['Liquide Mittel'], 'Kontenzahl der anderen Gruppe');
|
||||
}
|
||||
|
||||
private function anlegenUndFinden(): void
|
||||
|
|
|
|||
305
tests/11_BudgetTest.php
Normal file
305
tests/11_BudgetTest.php
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Controllers\BudgetController;
|
||||
use Finanzmanager\Models\Budget;
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Services\Budgetplaner;
|
||||
|
||||
class BudgetTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 11: Budgetplaner';
|
||||
|
||||
private const JAHR = 2026;
|
||||
|
||||
private int $konto = 0;
|
||||
private int $lebensmittel = 0;
|
||||
private int $essenAuswaerts = 0;
|
||||
private int $leben = 0;
|
||||
private int $gehalt = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
$this->stammdaten();
|
||||
|
||||
$this->vorzeichenFolgtDerKategorieart();
|
||||
$this->leeresFeldLoeschtDiePosition();
|
||||
$this->istWerteAusBuchungenUndSplits();
|
||||
$this->tabelleSummiertDenBaum();
|
||||
$this->saldoZeigtUeberschreitung();
|
||||
$this->folgemonateUebertragen();
|
||||
$this->kopieFuerDasNaechsteJahr();
|
||||
$this->zeitraeumeDerAnsicht();
|
||||
}
|
||||
|
||||
private function stammdaten(): void
|
||||
{
|
||||
$this->konto = Konto::anlegen([
|
||||
'name' => 'Budgetgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||
]);
|
||||
|
||||
$this->leben = Kategorie::findenOderAnlegenNachPfad('400 - Leben', 'ausgabe');
|
||||
$this->lebensmittel = Kategorie::findenOderAnlegenNachPfad('400 - Leben:Lebensmittel', 'ausgabe');
|
||||
$this->essenAuswaerts = Kategorie::findenOderAnlegenNachPfad('400 - Leben:Essen auswärts', 'ausgabe');
|
||||
$this->gehalt = Kategorie::findenOderAnlegenNachPfad('100 - Gehalt', 'einnahme');
|
||||
}
|
||||
|
||||
private function budgetAnlegen(string $name = 'Test 2026', int $jahr = self::JAHR): int
|
||||
{
|
||||
return Budget::anlegen($name . '-' . bin2hex(random_bytes(3)), $jahr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Nutzer tippt „400“ ein und meint 400 € Ausgabe. Das Vorzeichen kommt
|
||||
* aus der Kategorieart – sonst müsste er es bei jeder Zeile mitdenken.
|
||||
*/
|
||||
private function vorzeichenFolgtDerKategorieart(): void
|
||||
{
|
||||
$budget = $this->budgetAnlegen();
|
||||
|
||||
Budget::positionSetzen($budget, $this->lebensmittel, 1, 400.0);
|
||||
Budget::positionSetzen($budget, $this->gehalt, 1, 3000.0);
|
||||
|
||||
$positionen = Budget::positionen($budget);
|
||||
$this->assertEquals(-400.0, $positionen[$this->lebensmittel][1], 'Ausgabe wird negativ gespeichert');
|
||||
$this->assertEquals(3000.0, $positionen[$this->gehalt][1], 'Einnahme bleibt positiv');
|
||||
|
||||
// Auch eine ausdrücklich negative Eingabe darf die Ausgabe nicht umdrehen.
|
||||
Budget::positionSetzen($budget, $this->lebensmittel, 2, -400.0);
|
||||
$this->assertEquals(
|
||||
-400.0,
|
||||
Budget::positionen($budget)[$this->lebensmittel][2],
|
||||
'Negative Eingabe bei einer Ausgabe bleibt negativ'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* „Nicht geplant“ und „mit 0,00 geplant“ sind zweierlei: Das eine zeigt der
|
||||
* Planer als leeres Feld, das andere als Null.
|
||||
*/
|
||||
private function leeresFeldLoeschtDiePosition(): void
|
||||
{
|
||||
$budget = $this->budgetAnlegen();
|
||||
|
||||
Budget::positionSetzen($budget, $this->lebensmittel, 3, 250.0);
|
||||
$this->assert(
|
||||
isset(Budget::positionen($budget)[$this->lebensmittel][3]),
|
||||
'Gesetzter Wert steht in den Positionen'
|
||||
);
|
||||
|
||||
Budget::positionSetzen($budget, $this->lebensmittel, 3, 0.0);
|
||||
$this->assertEquals(
|
||||
0.0,
|
||||
Budget::positionen($budget)[$this->lebensmittel][3],
|
||||
'Eine ausdrückliche Null bleibt erhalten'
|
||||
);
|
||||
|
||||
Budget::positionSetzen($budget, $this->lebensmittel, 3, null);
|
||||
$this->assert(
|
||||
!isset(Budget::positionen($budget)[$this->lebensmittel][3]),
|
||||
'Leeres Feld entfernt die Position'
|
||||
);
|
||||
|
||||
// Und derselbe Weg über die Formulareingabe.
|
||||
$this->assertNull(BudgetController::betrag(' '), 'Leere Eingabe heißt: nicht geplant');
|
||||
$this->assertEquals(0.0, BudgetController::betrag('0'), 'Die Null kommt als Null an');
|
||||
$this->assertEquals(1234.56, BudgetController::betrag('1.234,56'), 'Deutsches Zahlenformat');
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Ist-Wert kommt aus den Buchungen – bei Splitbuchungen aus den Zeilen,
|
||||
* sonst aus der Buchung selbst. Beides zusammen zählte doppelt.
|
||||
*/
|
||||
private function istWerteAusBuchungenUndSplits(): void
|
||||
{
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->konto, 'datum' => '2026-01-15', 'betrag' => -120.50,
|
||||
'kategorie_id' => $this->lebensmittel, 'empfaenger' => 'Supermarkt',
|
||||
]);
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->konto, 'datum' => '2026-02-03', 'betrag' => -80.00,
|
||||
'kategorie_id' => $this->lebensmittel, 'empfaenger' => 'Supermarkt',
|
||||
]);
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->konto, 'datum' => '2026-01-31', 'betrag' => 3200.00,
|
||||
'kategorie_id' => $this->gehalt, 'empfaenger' => 'Arbeitgeber',
|
||||
], []);
|
||||
|
||||
// Splitbuchung: 60 auf Lebensmittel, 40 auf Essen auswärts.
|
||||
Buchung::anlegen(
|
||||
[
|
||||
'konto_id' => $this->konto, 'datum' => '2026-01-20', 'betrag' => -100.00,
|
||||
'empfaenger' => 'Tankstelle mit Bistro',
|
||||
],
|
||||
[
|
||||
['kategorie_id' => $this->lebensmittel, 'betrag' => -60.00],
|
||||
['kategorie_id' => $this->essenAuswaerts, 'betrag' => -40.00],
|
||||
]
|
||||
);
|
||||
|
||||
// Ein anderes Jahr darf nicht hineinrutschen.
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->konto, 'datum' => '2025-01-10', 'betrag' => -999.00,
|
||||
'kategorie_id' => $this->lebensmittel, 'empfaenger' => 'Vorjahr',
|
||||
]);
|
||||
|
||||
$ist = Budgetplaner::istWerte(self::JAHR);
|
||||
|
||||
$this->assertEquals(-180.50, round($ist[$this->lebensmittel][1], 2), 'Januar: Buchung plus Splitzeile');
|
||||
$this->assertEquals(-80.0, round($ist[$this->lebensmittel][2], 2), 'Februar: einzelne Buchung');
|
||||
$this->assertEquals(-40.0, round($ist[$this->essenAuswaerts][1], 2), 'Splitzeile der zweiten Kategorie');
|
||||
$this->assertEquals(3200.0, round($ist[$this->gehalt][1], 2), 'Einnahme im Januar');
|
||||
$this->assert(!isset($ist[$this->lebensmittel][13]), 'Es gibt keinen 13. Monat');
|
||||
}
|
||||
|
||||
/** Elternkategorien zeigen die Summe ihrer Unterkategorien. */
|
||||
private function tabelleSummiertDenBaum(): void
|
||||
{
|
||||
$budget = $this->budgetAnlegen();
|
||||
Budget::positionSetzen($budget, $this->lebensmittel, 1, 200.0);
|
||||
Budget::positionSetzen($budget, $this->essenAuswaerts, 1, 50.0);
|
||||
|
||||
$tabelle = Budgetplaner::tabelle(Budget::find($budget));
|
||||
|
||||
$zeilen = [];
|
||||
foreach ($tabelle['zeilen'] as $zeile) {
|
||||
$zeilen[(int)$zeile['id']] = $zeile;
|
||||
}
|
||||
|
||||
$this->assertEquals(
|
||||
-250.0,
|
||||
round($zeilen[$this->leben]['budget'][1], 2),
|
||||
'Die Elternkategorie summiert die Budgets ihrer Kinder'
|
||||
);
|
||||
$this->assertEquals(
|
||||
-220.50,
|
||||
round($zeilen[$this->leben]['ist'][1], 2),
|
||||
'Und ebenso die Ist-Werte (180,50 + 40,00)'
|
||||
);
|
||||
$this->assertEquals(
|
||||
0.0,
|
||||
round($zeilen[$this->leben]['eigenBudget'][1] ?? 0.0, 2),
|
||||
'Der eigene Wert der Elternkategorie bleibt davon unberührt'
|
||||
);
|
||||
|
||||
// Die Gesamtzeile summiert nur die Wurzeln, sonst zählten Kinder doppelt.
|
||||
$this->assertEquals(
|
||||
-250.0 + 0.0,
|
||||
round($tabelle['gesamt']['budget'][1], 2),
|
||||
'Gesamt: nur die Wurzelkategorien'
|
||||
);
|
||||
$this->assertEquals(
|
||||
round(-220.50 + 3200.0, 2),
|
||||
round($tabelle['gesamt']['ist'][1], 2),
|
||||
'Gesamt-Ist über Einnahmen und Ausgaben'
|
||||
);
|
||||
}
|
||||
|
||||
/** Saldo = Ist minus Budget: negativ heißt über Plan. */
|
||||
private function saldoZeigtUeberschreitung(): void
|
||||
{
|
||||
$budget = $this->budgetAnlegen();
|
||||
Budget::positionSetzen($budget, $this->lebensmittel, 1, 150.0); // geplant: -150
|
||||
Budget::positionSetzen($budget, $this->lebensmittel, 2, 200.0); // geplant: -200
|
||||
Budget::positionSetzen($budget, $this->gehalt, 1, 3000.0);
|
||||
|
||||
$tabelle = Budgetplaner::tabelle(Budget::find($budget));
|
||||
$zeilen = [];
|
||||
foreach ($tabelle['zeilen'] as $zeile) {
|
||||
$zeilen[(int)$zeile['id']] = $zeile;
|
||||
}
|
||||
|
||||
// Januar: 180,50 ausgegeben bei 150 geplant → 30,50 über Plan.
|
||||
$this->assertEquals(
|
||||
-30.50,
|
||||
round($zeilen[$this->lebensmittel]['saldo'][1], 2),
|
||||
'Überschreitung ergibt einen negativen Saldo'
|
||||
);
|
||||
|
||||
// Februar: 80 ausgegeben bei 200 geplant → 120 übrig.
|
||||
$this->assertEquals(
|
||||
120.0,
|
||||
round($zeilen[$this->lebensmittel]['saldo'][2], 2),
|
||||
'Unterschreitung ergibt einen positiven Saldo'
|
||||
);
|
||||
|
||||
// Einnahmen: 3200 statt 3000 geplant → 200 besser als geplant.
|
||||
$this->assertEquals(
|
||||
200.0,
|
||||
round($zeilen[$this->gehalt]['saldo'][1], 2),
|
||||
'Mehr eingenommen als geplant ist ebenfalls positiv'
|
||||
);
|
||||
}
|
||||
|
||||
private function folgemonateUebertragen(): void
|
||||
{
|
||||
$budget = $this->budgetAnlegen();
|
||||
Budget::positionSetzen($budget, $this->lebensmittel, 3, 300.0);
|
||||
Budget::positionSetzen($budget, $this->lebensmittel, 12, 999.0);
|
||||
|
||||
Budget::aufFolgemonateUebertragen($budget, $this->lebensmittel, 3);
|
||||
|
||||
$positionen = Budget::positionen($budget)[$this->lebensmittel];
|
||||
$this->assert(!isset($positionen[2]), 'Der Vormonat bleibt unangetastet');
|
||||
$this->assertEquals(-300.0, $positionen[3], 'Der Ausgangsmonat bleibt stehen');
|
||||
$this->assertEquals(-300.0, $positionen[4], 'April übernimmt den Wert');
|
||||
$this->assertEquals(-300.0, $positionen[12], 'Auch ein vorhandener Dezemberwert wird überschrieben');
|
||||
$this->assertEquals(10, count($positionen), 'März bis Dezember sind gefüllt');
|
||||
}
|
||||
|
||||
private function kopieFuerDasNaechsteJahr(): void
|
||||
{
|
||||
$budget = $this->budgetAnlegen('Vorlage');
|
||||
Budget::positionSetzen($budget, $this->lebensmittel, 1, 400.0);
|
||||
Budget::positionSetzen($budget, $this->gehalt, 1, 3000.0);
|
||||
|
||||
$kopie = Budget::kopieren($budget, 'Kopie-' . bin2hex(random_bytes(3)), self::JAHR + 1);
|
||||
|
||||
$this->assertEquals(self::JAHR + 1, (int)Budget::find($kopie)['jahr'], 'Die Kopie liegt im neuen Jahr');
|
||||
$this->assertEquals(
|
||||
-400.0,
|
||||
Budget::positionen($kopie)[$this->lebensmittel][1],
|
||||
'Die Monatswerte sind mitgekommen'
|
||||
);
|
||||
|
||||
// Die Vorlage bleibt unverändert – die Kopie ist eigenständig.
|
||||
Budget::positionSetzen($kopie, $this->lebensmittel, 1, 500.0);
|
||||
$this->assertEquals(
|
||||
-400.0,
|
||||
Budget::positionen($budget)[$this->lebensmittel][1],
|
||||
'Änderungen an der Kopie schlagen nicht auf die Vorlage durch'
|
||||
);
|
||||
|
||||
$doppelt = false;
|
||||
try {
|
||||
Budget::anlegen(Budget::find($kopie)['name'], self::JAHR);
|
||||
} catch (\Throwable) {
|
||||
$doppelt = true;
|
||||
}
|
||||
$this->assert($doppelt, 'Zwei Budgets mit demselben Namen gehen nicht');
|
||||
}
|
||||
|
||||
/** Welche Monate gelten als abgelaufen, welcher läuft gerade? */
|
||||
private function zeitraeumeDerAnsicht(): void
|
||||
{
|
||||
$this->assertEquals(7, Budgetplaner::abgelaufenBis(2026, '2026-08-11'), 'Im August sind sieben Monate durch');
|
||||
$this->assertEquals(0, Budgetplaner::abgelaufenBis(2026, '2026-01-15'), 'Im Januar ist noch nichts abgelaufen');
|
||||
$this->assertEquals(12, Budgetplaner::abgelaufenBis(2025, '2026-08-11'), 'Ein vergangenes Jahr ist ganz durch');
|
||||
$this->assertEquals(0, Budgetplaner::abgelaufenBis(2027, '2026-08-11'), 'Ein künftiges Jahr ist offen');
|
||||
|
||||
$this->assertEquals(8, Budgetplaner::laufenderMonat(2026, '2026-08-11'), 'Der laufende Monat ist der August');
|
||||
$this->assertEquals(0, Budgetplaner::laufenderMonat(2027, '2026-08-11'), 'Andere Jahre haben keinen laufenden Monat');
|
||||
|
||||
$reihe = [];
|
||||
for ($monat = 1; $monat <= 12; $monat++) {
|
||||
$reihe[$monat] = 10.0;
|
||||
}
|
||||
$this->assertEquals(70.0, Budgetplaner::summe($reihe, 1, 7), 'Summe über die abgelaufenen Monate');
|
||||
$this->assertEquals(120.0, Budgetplaner::summe($reihe, 1, 99), 'Über den Dezember hinaus wird nicht gezählt');
|
||||
$this->assertEquals(0.0, Budgetplaner::summe($reihe, 5, 4), 'Ein leerer Bereich ergibt null');
|
||||
}
|
||||
}
|
||||
257
tests/12_MonatsabschlussTest.php
Normal file
257
tests/12_MonatsabschlussTest.php
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Monatsabschluss;
|
||||
|
||||
class MonatsabschlussTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 12: Monatsabschluss';
|
||||
|
||||
private int $giro = 0;
|
||||
private int $tagesgeld = 0;
|
||||
private int $auslagen = 0;
|
||||
private int $kategorie = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
$this->stammdaten();
|
||||
|
||||
$this->zeitraumEinesMonats();
|
||||
$this->pruefungFindetOffenePunkte();
|
||||
$this->snapshotHaeltSaldenFest();
|
||||
$this->abschlussSperrtBuchungen();
|
||||
$this->wiedereroeffnenGibtDenMonatFrei();
|
||||
$this->vorschlagFuerDenNaechstenMonat();
|
||||
}
|
||||
|
||||
private function stammdaten(): void
|
||||
{
|
||||
$this->giro = Konto::anlegen([
|
||||
'name' => 'Abschlussgiro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1,
|
||||
]);
|
||||
$this->tagesgeld = Konto::anlegen([
|
||||
'name' => 'Abschlusstagesgeld', 'kontoart' => 'sparkonto', 'anfangssaldo' => 5000, 'aktiv' => 1,
|
||||
]);
|
||||
// Zählt nicht zur Summe – durchlaufende Auslagen sind kein Vermögen.
|
||||
$this->auslagen = Konto::anlegen([
|
||||
'name' => 'Abschlussauslagen', 'kontoart' => 'sonstiges', 'anfangssaldo' => 300,
|
||||
'aktiv' => 1, 'in_summe' => 0,
|
||||
]);
|
||||
|
||||
$this->kategorie = Kategorie::findenOderAnlegenNachPfad('500 - Abschlusstest', 'ausgabe');
|
||||
}
|
||||
|
||||
private function zeitraumEinesMonats(): void
|
||||
{
|
||||
$this->assertEquals(['2026-02-01', '2026-02-28'], Monatsabschluss::zeitraum(2026, 2), 'Februar 2026');
|
||||
$this->assertEquals(['2024-02-01', '2024-02-29'], Monatsabschluss::zeitraum(2024, 2), 'Schaltjahr');
|
||||
$this->assertEquals(['2026-12-01', '2026-12-31'], Monatsabschluss::zeitraum(2026, 12), 'Dezember');
|
||||
|
||||
$ungueltig = false;
|
||||
try {
|
||||
Monatsabschluss::zeitraum(2026, 13);
|
||||
} catch (\Throwable) {
|
||||
$ungueltig = true;
|
||||
}
|
||||
$this->assert($ungueltig, 'Ein 13. Monat wird abgewiesen');
|
||||
}
|
||||
|
||||
private function pruefungFindetOffenePunkte(): void
|
||||
{
|
||||
// Sauber kategorisiert und abgeglichen.
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => '2026-03-05', 'betrag' => -50.0,
|
||||
'kategorie_id' => $this->kategorie, 'empfaenger' => 'Sauber', 'abgeglichen' => 1,
|
||||
]);
|
||||
|
||||
// Ohne Kategorie und ohne Gegenkonto – der Klassiker nach einem Bankabruf.
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => '2026-03-06', 'betrag' => -25.0,
|
||||
'empfaenger' => 'Unbekannt',
|
||||
]);
|
||||
|
||||
// Umbuchung: hat keine Kategorie, ist aber vollständig zugeordnet.
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => '2026-03-07', 'betrag' => -200.0,
|
||||
'gegenkonto_id' => $this->tagesgeld, 'empfaenger' => 'Sparen',
|
||||
]);
|
||||
|
||||
$pruefung = Monatsabschluss::pruefen(2026, 3);
|
||||
|
||||
$this->assertEquals(3, $pruefung['buchungen'], 'Alle Buchungen des Monats werden gezählt');
|
||||
$this->assertEquals(1, count($pruefung['ohne_kategorie']), 'Nur die unzugeordnete Buchung fehlt');
|
||||
$this->assertEquals(
|
||||
'Unbekannt',
|
||||
$pruefung['ohne_kategorie'][0]['empfaenger'],
|
||||
'Und zwar die richtige'
|
||||
);
|
||||
$this->assertEquals(2, $pruefung['nicht_abgeglichen'], 'Zwei Buchungen sind nicht abgeglichen');
|
||||
$this->assertEquals([], $pruefung['splitdifferenzen'], 'Keine Splitdifferenzen');
|
||||
|
||||
// Ein anderer Monat bleibt außen vor.
|
||||
$this->assertEquals(0, Monatsabschluss::pruefen(2026, 4)['buchungen'], 'April ist leer');
|
||||
}
|
||||
|
||||
private function snapshotHaeltSaldenFest(): void
|
||||
{
|
||||
$snapshot = Monatsabschluss::snapshot(2026, 3);
|
||||
|
||||
$salden = [];
|
||||
foreach ($snapshot['konten'] as $konto) {
|
||||
$salden[$konto['name']] = $konto['saldo'];
|
||||
}
|
||||
|
||||
// 1000 Anfangssaldo - 50 - 25 - 200 (Umbuchung) = 725
|
||||
$this->assertEquals(725.0, $salden['Abschlussgiro'], 'Kontosaldo zum Monatsende');
|
||||
// 5000 + 200 aus der Umbuchung
|
||||
$this->assertEquals(5200.0, $salden['Abschlusstagesgeld'], 'Die Gegenseite der Umbuchung zählt mit');
|
||||
$this->assertEquals(300.0, $salden['Abschlussauslagen'], 'Konto ohne Bewegung behält seinen Anfangssaldo');
|
||||
|
||||
// Das Auslagenkonto zählt nicht zur Summe der Kontostände.
|
||||
$this->assertEquals(5925.0, $snapshot['summe_kontostand'], 'Summe nur über Konten mit Σ-Kennzeichen');
|
||||
|
||||
$kategorien = [];
|
||||
foreach ($snapshot['kategorien'] as $zeile) {
|
||||
$kategorien[$zeile['kategorie_id']] = $zeile['betrag'];
|
||||
}
|
||||
$this->assertEquals(-50.0, $kategorien[$this->kategorie], 'Kategoriesumme des Monats');
|
||||
|
||||
$this->assertEquals('2026-03-01', $snapshot['zeitraum']['von'], 'Zeitraum im Snapshot');
|
||||
$this->assertEquals('2026-03-31', $snapshot['zeitraum']['bis'], 'Zeitraum im Snapshot');
|
||||
}
|
||||
|
||||
private function abschlussSperrtBuchungen(): void
|
||||
{
|
||||
$id = Monatsabschluss::abschliessen(2026, 3, 'Testabschluss', 'Testnutzer');
|
||||
$this->assertGreaterThan(0, $id, 'Der Abschluss wird gespeichert');
|
||||
|
||||
$this->assert(Monatsabschluss::istGesperrt('2026-03-15'), 'Der abgeschlossene Monat ist gesperrt');
|
||||
$this->assert(!Monatsabschluss::istGesperrt('2026-04-01'), 'Der Folgemonat bleibt offen');
|
||||
|
||||
$abgelehnt = false;
|
||||
try {
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => '2026-03-20', 'betrag' => -10.0,
|
||||
'kategorie_id' => $this->kategorie, 'empfaenger' => 'Zu spät',
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
$abgelehnt = true;
|
||||
}
|
||||
$this->assert($abgelehnt, 'In einem abgeschlossenen Monat lässt sich nicht mehr buchen');
|
||||
|
||||
// Eine bestehende Buchung darf auch nicht aus dem Monat herausgeschoben werden.
|
||||
$vorhanden = Buchung::kontoblatt($this->giro, ['von' => '2026-03-01', 'bis' => '2026-03-31']);
|
||||
$erste = (int)$vorhanden[0]['id'];
|
||||
$this->assertGreaterThan(0, $erste, 'Eine Buchung des Monats ist auffindbar');
|
||||
|
||||
$verschoben = false;
|
||||
try {
|
||||
Buchung::speichern($erste, [
|
||||
'konto_id' => $this->giro, 'datum' => '2026-04-02', 'betrag' => -50.0,
|
||||
'kategorie_id' => $this->kategorie, 'empfaenger' => 'Verschoben',
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
$verschoben = true;
|
||||
}
|
||||
$this->assert($verschoben, 'Eine Buchung lässt sich nicht aus dem gesperrten Monat schieben');
|
||||
|
||||
$geloescht = false;
|
||||
try {
|
||||
Buchung::loeschen($erste);
|
||||
} catch (\Throwable) {
|
||||
$geloescht = true;
|
||||
}
|
||||
$this->assert($geloescht, 'Löschen im gesperrten Monat wird abgewiesen');
|
||||
|
||||
// Der Zweitabschluss desselben Monats ist ein Fehler, kein zweiter Datensatz.
|
||||
$doppelt = false;
|
||||
try {
|
||||
Monatsabschluss::abschliessen(2026, 3);
|
||||
} catch (\Throwable) {
|
||||
$doppelt = true;
|
||||
}
|
||||
$this->assert($doppelt, 'Ein Monat lässt sich nicht zweimal abschließen');
|
||||
|
||||
// Die Sperre ist abschaltbar – etwa für einen Import, der Altdaten nachträgt.
|
||||
Einstellung::set('monatsabschluss_sperrt_buchungen', '0');
|
||||
$trotzdem = true;
|
||||
try {
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => '2026-03-21', 'betrag' => -5.0,
|
||||
'kategorie_id' => $this->kategorie, 'empfaenger' => 'Nachtrag',
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
$trotzdem = false;
|
||||
}
|
||||
$this->assert($trotzdem, 'Mit abgeschalteter Sperre geht die Buchung durch');
|
||||
Einstellung::set('monatsabschluss_sperrt_buchungen', '1');
|
||||
}
|
||||
|
||||
private function wiedereroeffnenGibtDenMonatFrei(): void
|
||||
{
|
||||
$abschluss = Monatsabschluss::fuerMonat(2026, 3);
|
||||
$this->assertNotEmpty($abschluss, 'Der Abschluss ist auffindbar');
|
||||
|
||||
Monatsabschluss::wiedereroeffnen((int)$abschluss['id']);
|
||||
|
||||
$this->assert(!Monatsabschluss::istGesperrt('2026-03-15'), 'Nach der Wiedereröffnung ist der Monat offen');
|
||||
|
||||
$danach = Monatsabschluss::fuerMonat(2026, 3);
|
||||
$this->assertEquals(
|
||||
Monatsabschluss::STATUS_WIEDEREROEFFNET,
|
||||
$danach['status'],
|
||||
'Der Vorgang bleibt am Datensatz vermerkt'
|
||||
);
|
||||
$this->assertNotEmpty($danach['snapshot'], 'Der Snapshot des Abschlusses bleibt erhalten');
|
||||
|
||||
$ging = true;
|
||||
try {
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => '2026-03-25', 'betrag' => -15.0,
|
||||
'kategorie_id' => $this->kategorie, 'empfaenger' => 'Nachgebucht',
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
$ging = false;
|
||||
}
|
||||
$this->assert($ging, 'Im wiedereröffneten Monat lässt sich wieder buchen');
|
||||
|
||||
// Und danach wieder abschließen – mit dem neuen Stand.
|
||||
Monatsabschluss::abschliessen(2026, 3, 'Zweiter Anlauf');
|
||||
$this->assert(Monatsabschluss::istGesperrt('2026-03-15'), 'Der zweite Abschluss sperrt erneut');
|
||||
Monatsabschluss::wiedereroeffnen((int)Monatsabschluss::fuerMonat(2026, 3)['id']);
|
||||
}
|
||||
|
||||
private function vorschlagFuerDenNaechstenMonat(): void
|
||||
{
|
||||
// Buchungen im Januar und Februar 2026, März ist wiedereröffnet.
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => '2026-01-10', 'betrag' => -30.0,
|
||||
'kategorie_id' => $this->kategorie, 'empfaenger' => 'Januar',
|
||||
]);
|
||||
|
||||
$vorschlag = Monatsabschluss::naechsterOffener('2026-04-15');
|
||||
$this->assertEquals(2026, $vorschlag['jahr'], 'Der Vorschlag bleibt im Jahr');
|
||||
$this->assertEquals(
|
||||
1,
|
||||
$vorschlag['monat'],
|
||||
'Vorgeschlagen wird der älteste offene Monat mit Buchungen – abgeschlossen wird der Reihe nach'
|
||||
);
|
||||
|
||||
// Ist ein Monat abgeschlossen, endet die Suche dort: Was davor liegt,
|
||||
// ist erledigt.
|
||||
Monatsabschluss::abschliessen(2026, 1, 'Fest');
|
||||
$nach = Monatsabschluss::naechsterOffener('2026-04-15');
|
||||
$this->assertEquals(3, $nach['monat'], 'Danach ist der März der älteste offene Monat mit Buchungen');
|
||||
|
||||
// Ohne Buchungen bleibt es beim Vormonat als Vorschlag.
|
||||
Monatsabschluss::abschliessen(2026, 3, 'Auch fest');
|
||||
$leer = Monatsabschluss::naechsterOffener('2026-04-15');
|
||||
$this->assertEquals(3, $leer['monat'], 'Ist alles abgeschlossen, steht der Vormonat da');
|
||||
}
|
||||
}
|
||||
134
tests/e2e/specs/planung.spec.js
Normal file
134
tests/e2e/specs/planung.spec.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test')
|
||||
|
||||
const BENUTZER = process.env.E2E_USER || 'e2e'
|
||||
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
|
||||
|
||||
// Eindeutige Namen je Lauf: die Tests arbeiten auf einer bestehenden Instanz
|
||||
// und dürfen sich nicht an Resten eines früheren Laufs stoßen.
|
||||
const MARKE = Date.now().toString().slice(-6)
|
||||
|
||||
// Ein Jahr weit in der Zukunft: dort sind alle zwölf Monate Folgemonate, die
|
||||
// Spaltenaufteilung des Planers bleibt damit über die Jahre stabil.
|
||||
const JAHR = new Date().getFullYear() + 5
|
||||
|
||||
async function anmelden(page) {
|
||||
await page.goto('/login')
|
||||
|
||||
if (page.url().includes('/einrichtung')) {
|
||||
throw new Error(
|
||||
'Instanz ist noch nicht eingerichtet. Zuerst einen Benutzer anlegen: ' +
|
||||
'docker compose exec -u www-data php php scripts/e2e-benutzer.php'
|
||||
)
|
||||
}
|
||||
|
||||
await page.fill('#benutzername', BENUTZER)
|
||||
await page.fill('#passwort', PASSWORT)
|
||||
await page.click('button[type="submit"]')
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt ein Budget an und liefert dessen Id. Nach dem Anlegen steht sie in der
|
||||
* URL des Planers – die Auswahlliste enthält alle Budgets der Instanz und
|
||||
* taugt deshalb nicht zum Wiederfinden.
|
||||
*/
|
||||
async function budgetAnlegen(page, name) {
|
||||
await page.goto('/planung/budget/verwaltung')
|
||||
await page.fill('#name', name)
|
||||
await page.fill('#jahr', String(JAHR))
|
||||
await page.click('.formular button:has-text("Budget anlegen")')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
await expect(page).toHaveURL(/budget=\d+/)
|
||||
|
||||
return new URL(page.url()).searchParams.get('budget')
|
||||
}
|
||||
|
||||
/** Die erste Kategoriezeile mit Eingabefeldern – eine Blattkategorie. */
|
||||
function ersteBeplanbareZeile(page) {
|
||||
return page.locator('tr.kategoriezeile:not(.summenzeile-kategorie)').first()
|
||||
}
|
||||
|
||||
test.describe('Planung', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await anmelden(page)
|
||||
})
|
||||
|
||||
test('Planungsübersicht führt zu Budget und Monatsabschluss', async ({ page }) => {
|
||||
await page.click('.seitenleiste a[href="/planung"]')
|
||||
|
||||
await expect(page.locator('h1')).toContainText('Planung')
|
||||
await expect(page.locator('a[href="/planung/budget"]').first()).toBeVisible()
|
||||
await expect(page.locator('a[href="/planung/monatsabschluss"]').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Budget anlegen, Monatswert eintragen und wiederfinden', async ({ page }) => {
|
||||
const id = await budgetAnlegen(page, `E2E-Budget ${MARKE}`)
|
||||
|
||||
await page.goto(`/planung/budget?budget=${id}&ansicht=alle`)
|
||||
|
||||
const zeile = ersteBeplanbareZeile(page)
|
||||
const kategorie = (await zeile.locator('.baumname').innerText()).trim()
|
||||
|
||||
await zeile.locator('input.betragsfeld').first().fill('250,00')
|
||||
await page.click('button:has-text("Budget speichern")')
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('Budget gespeichert')
|
||||
|
||||
// Nach dem Speichern steht der Wert wieder im Feld – und in der Jahressumme.
|
||||
const wieder = page.locator('tr.kategoriezeile', { hasText: kategorie }).first()
|
||||
await expect(wieder.locator('input.betragsfeld').first()).toHaveValue('250,00')
|
||||
await expect(wieder).toContainText('250,00')
|
||||
})
|
||||
|
||||
test('Wert auf die Folgemonate übertragen', async ({ page }) => {
|
||||
const id = await budgetAnlegen(page, `E2E-Folgemonate ${MARKE}`)
|
||||
|
||||
await page.goto(`/planung/budget?budget=${id}&ansicht=alle`)
|
||||
|
||||
const zeile = ersteBeplanbareZeile(page)
|
||||
const kategorie = (await zeile.locator('.baumname').innerText()).trim()
|
||||
|
||||
await zeile.locator('input.betragsfeld').first().fill('100,00')
|
||||
await zeile.locator('button[name="uebertragen"]').click()
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('Folgemonate')
|
||||
|
||||
const wieder = page.locator('tr.kategoriezeile', { hasText: kategorie }).first()
|
||||
const felder = wieder.locator('input.betragsfeld')
|
||||
await expect(felder.nth(0)).toHaveValue('100,00')
|
||||
await expect(felder.nth(11)).toHaveValue('100,00')
|
||||
// Zwölf Monate à 100,00 stehen als Jahressumme in der Zeile.
|
||||
await expect(wieder).toContainText('1.200,00')
|
||||
})
|
||||
|
||||
test('Monat abschließen, Snapshot ansehen und wiedereröffnen', async ({ page }) => {
|
||||
// Ein Monat weit in der Vergangenheit, den kein anderer Test anfasst.
|
||||
const jahr = new Date().getFullYear() - 9
|
||||
|
||||
// Rückfragen bejahen: Wiedereröffnen und Abschließen fragen beide nach.
|
||||
page.on('dialog', d => d.accept())
|
||||
|
||||
await page.goto(`/planung/monatsabschluss?jahr=${jahr}&monat=6`)
|
||||
await expect(page.locator('h2').first()).toContainText('Prüflauf')
|
||||
|
||||
// Ein früherer Lauf kann den Monat abgeschlossen zurückgelassen haben.
|
||||
if (await page.locator('button:has-text("Monat wiedereröffnen")').count()) {
|
||||
await page.click('button:has-text("Monat wiedereröffnen")')
|
||||
await expect(page.locator('button:has-text("Monat abschließen")')).toBeVisible()
|
||||
}
|
||||
|
||||
await page.fill('#notiz', `E2E ${MARKE}`)
|
||||
await page.click('button:has-text("Monat abschließen")')
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('abgeschlossen')
|
||||
|
||||
// Der abgeschlossene Monat weist die Sperre aus und bietet den Snapshot an.
|
||||
await expect(page.locator('.phasenmarke')).toContainText('abgeschlossen')
|
||||
await page.click('a:has-text("Snapshot ansehen")')
|
||||
await expect(page.locator('h1')).toContainText('Abschluss')
|
||||
await expect(page.locator('.summenzeile')).toContainText('Summe Kontostände')
|
||||
|
||||
await page.goto(`/planung/monatsabschluss?jahr=${jahr}&monat=6`)
|
||||
await page.click('button:has-text("Monat wiedereröffnen")')
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('wieder offen')
|
||||
await expect(page.locator('button:has-text("Monat abschließen")')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
@ -111,9 +111,11 @@ test.describe('Stammdaten', () => {
|
|||
|
||||
await page.goto('/verwaltung/kategorien')
|
||||
await expect(page.locator('tr', { hasText: oben }).first()).toContainText('Oberkategorie')
|
||||
// Die Unterkategorie ist um eine Ebene eingerückt.
|
||||
// Die Unterkategorie ist um eine Ebene eingerückt. Die Ebene steht als
|
||||
// data-Attribut da: Ein style-Attribut würde die Content-Security-Policy
|
||||
// verwerfen, die Einrückung fiele stillschweigend aus.
|
||||
await expect(page.locator(`tr:has-text("${unten}") .baumname`))
|
||||
.toHaveAttribute('style', /--ebene: 1/)
|
||||
.toHaveAttribute('data-ebene', '1')
|
||||
})
|
||||
|
||||
test('Register filtern nach Einnahmen und Ausgaben', async ({ page }) => {
|
||||
|
|
@ -201,7 +203,12 @@ test.describe('Stammdaten', () => {
|
|||
await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf')
|
||||
const uebersicht = page.locator('.kachel table.liste').first()
|
||||
await expect(uebersicht.locator('tr', { hasText: 'Klassen' })).toContainText('1')
|
||||
await expect(uebersicht.locator('tr', { hasText: 'Konten' })).toContainText('1')
|
||||
// Genau die Zeile „Konten“ – „Kontengruppen“ steht direkt darunter und
|
||||
// enthält denselben Wortanfang, deshalb über die erste Zelle filtern.
|
||||
const kontenzeile = uebersicht.locator('tr').filter({
|
||||
has: page.getByRole('cell', { name: 'Konten', exact: true }),
|
||||
})
|
||||
await expect(kontenzeile).toContainText('1')
|
||||
|
||||
// Nichts gespeichert: die Klasse darf noch nicht in der Liste stehen.
|
||||
await page.goto('/verwaltung/klassen')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue