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
|
# Ä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)
|
## Korrektur – Σ-Kennzeichen ließ sich nicht abschalten (10.08.2026)
|
||||||
|
|
||||||
Das Kennzeichen *Zählt zur Summe der Kontostände* blieb beim Speichern eines Kontos
|
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
|
* *Ergebnis:* Der vollständige Altbestand ist migriert, Salden stimmen mit dem
|
||||||
FinanzManager überein (Abgleich gegen die Kontenübersicht als Test).
|
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`.
|
* Migrationen `budgets`, `budget_positionen`, `monatsabschluesse`.
|
||||||
* **Budgetplaner** wie im Original: Zeilen = Kategoriebaum (auf-/zuklappbar), Spalten =
|
* **Budgetplaner** wie im Original: Zeilen = Kategoriebaum (auf-/zuklappbar), Spalten =
|
||||||
Gesamt-Saldo, abgelaufene Monate zusammengefasst, aktueller Monat mit Budget/Ist/Saldo,
|
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.
|
Kopieren eines Budgets ins neue Jahr, Alarm bei Überschreitung, Gesamtzeile.
|
||||||
* **Monatsabschluss**: Prüflauf (nicht kategorisierte Buchungen, nicht abgeglichene Umsätze,
|
* **Monatsabschluss**: Prüflauf (nicht kategorisierte Buchungen, nicht abgeglichene Umsätze,
|
||||||
Splitsummen-Differenzen), Snapshot der Salden, Sperre des Zeitraums, Wiedereröffnung.
|
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.
|
* *Ergebnis:* Jahresbudget zum Jahreswechsel planbar, Monatsabschluss durchführbar.
|
||||||
|
Einzelheiten in `docs/planung.md`.
|
||||||
|
|
||||||
### Phase 4 — Auswertungen und Cockpit
|
### Phase 4 — Auswertungen und Cockpit
|
||||||
* Berichtsmodul mit gespeicherten Berichten und Berichtsgruppen (wie „Bankgeschäfte",
|
* 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,
|
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:
|
steht nicht im Export. Nach dem Import einmal durch die Kontenübersicht gehen (Vorgabe:
|
||||||
alle zählen mit).
|
alle zählen mit).
|
||||||
4. **Budgetdaten**: Export aus dem Budgetplaner (Symbol oben rechts in der Werkzeugleiste),
|
4. **Kontengruppen anlegen und Konten zuordnen**: Ebenfalls nicht im Export enthalten —
|
||||||
sonst wird das Jahresbudget 2027 einmalig neu erfasst.
|
ein `!Account`-Eintrag führt nur Name, Kontoart, Beschreibung und Kreditlimit, und auch
|
||||||
5. Zugangsdaten der Bankverbindungen (erst in Phase 5, Eingabe direkt in der Anwendung).
|
die `QSD`-Datei nennt keine Gruppen. Nach dem Import stehen deshalb alle Konten unter
|
||||||
6. **API-Key für den Kursdienst** (Leeway.tech oder FMP, erst in Phase 6, Eingabe im
|
„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).
|
Einstellungsdialog).
|
||||||
|
|
||||||
## 7. Verifikation
|
## 7. Verifikation
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,12 @@ Haushalt wird beides parallel genutzt.
|
||||||
|
|
||||||
## Stand
|
## 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;
|
Migrationsmechanik und Test-Harness; Konten, Kontengruppen, Kategorien und Klassen;
|
||||||
Kontoblatt, Buchungsmaske mit Splitbuchungen und Umbuchungen sowie der QIF-Import für
|
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
|
Stammdaten und Buchungen; Budgetplaner mit Monatswerten je Kategorie und der
|
||||||
Monatsabschluss (Phase 3).
|
Monatsabschluss samt Prüflauf, Snapshot und Sperre. Als Nächstes folgen nach `PLAN.md`
|
||||||
|
die Auswertungen und das Cockpit (Phase 4).
|
||||||
|
|
||||||
## Technik
|
## Technik
|
||||||
|
|
||||||
|
|
@ -83,4 +84,5 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf.
|
||||||
- `Changes.md` – Änderungsprotokoll
|
- `Changes.md` – Änderungsprotokoll
|
||||||
- `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen
|
- `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen
|
||||||
- `docs/buchungen.md` – Kontoblatt, Umbuchungen, Splitbuchungen, Salden
|
- `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
|
- `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 |
|
| | `D` | Beschreibung; `Kontonr. 12345` wird als Kontonummer übernommen |
|
||||||
| | `L` | Kreditlimit im US-Format |
|
| | `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
|
## Fallstricke
|
||||||
|
|
||||||
1. **Zeichensatz ISO-8859-1, Zeilenenden CRLF.** `QifParser::nachUtf8()` prüft erst, ob
|
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
|
Wird eine Gruppe gelöscht, bleiben ihre Konten erhalten und rutschen dorthin
|
||||||
(`ON DELETE SET NULL`).
|
(`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`):
|
Kontoarten (`Konto::ARTEN`) und ihre Herkunft aus dem QIF-Export (`Konto::QIF_ARTEN`):
|
||||||
|
|
||||||
| Kontoart | Bezeichnung | QIF-Typ |
|
| 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\Auth;
|
||||||
use Finanzmanager\Config;
|
use Finanzmanager\Config;
|
||||||
use Finanzmanager\Controllers\BuchungenController;
|
use Finanzmanager\Controllers\BuchungenController;
|
||||||
|
use Finanzmanager\Controllers\BudgetController;
|
||||||
use Finanzmanager\Controllers\CockpitController;
|
use Finanzmanager\Controllers\CockpitController;
|
||||||
use Finanzmanager\Controllers\EinstellungenController;
|
use Finanzmanager\Controllers\EinstellungenController;
|
||||||
use Finanzmanager\Controllers\ImportController;
|
use Finanzmanager\Controllers\ImportController;
|
||||||
use Finanzmanager\Controllers\KategorienController;
|
use Finanzmanager\Controllers\KategorienController;
|
||||||
use Finanzmanager\Controllers\KontenController;
|
use Finanzmanager\Controllers\KontenController;
|
||||||
use Finanzmanager\Controllers\LoginController;
|
use Finanzmanager\Controllers\LoginController;
|
||||||
|
use Finanzmanager\Controllers\MonatsabschlussController;
|
||||||
|
use Finanzmanager\Controllers\PlanungController;
|
||||||
use Finanzmanager\Controllers\PlatzhalterController;
|
use Finanzmanager\Controllers\PlatzhalterController;
|
||||||
use Finanzmanager\Controllers\VerwaltungController;
|
use Finanzmanager\Controllers\VerwaltungController;
|
||||||
use Finanzmanager\Models\Einstellung;
|
use Finanzmanager\Models\Einstellung;
|
||||||
|
|
@ -96,12 +99,40 @@ $router->post('/verwaltung/klassen/{id}/loeschen',
|
||||||
fn(array $p) => (new VerwaltungController())->klasseLoeschen($p));
|
fn(array $p) => (new VerwaltungController())->klasseLoeschen($p));
|
||||||
|
|
||||||
$router->get('/verwaltung/kontengruppen', fn() => (new VerwaltungController())->kontengruppen());
|
$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->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}',
|
$router->post('/verwaltung/kontengruppen/{id}',
|
||||||
fn(array $p) => (new VerwaltungController())->kontengruppeSpeichern($p));
|
fn(array $p) => (new VerwaltungController())->kontengruppeSpeichern($p));
|
||||||
$router->post('/verwaltung/kontengruppen/{id}/loeschen',
|
$router->post('/verwaltung/kontengruppen/{id}/loeschen',
|
||||||
fn(array $p) => (new VerwaltungController())->kontengruppeLoeschen($p));
|
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->get('/verwaltung/import', fn() => (new ImportController())->index());
|
||||||
$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen());
|
$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen());
|
||||||
$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren());
|
$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren());
|
||||||
|
|
|
||||||
|
|
@ -399,13 +399,88 @@ table.liste input[type="number"] {
|
||||||
|
|
||||||
table.liste input.schmal { width: 5rem; }
|
table.liste input.schmal { width: 5rem; }
|
||||||
|
|
||||||
/* Einrückung des Kategoriebaums. Die Ebene kommt als Custom Property aus der
|
/* ── Budgetplaner ────────────────────────────────────────────────────────────
|
||||||
Vorlage – so bleibt die Tabelle frei von verschachtelten Elementen. */
|
|
||||||
|
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 {
|
.baumname {
|
||||||
display: inline-block;
|
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 {
|
.nebentext {
|
||||||
color: var(--text-gedaempft);
|
color: var(--text-gedaempft);
|
||||||
font-size: .88rem;
|
font-size: .88rem;
|
||||||
|
|
@ -548,6 +623,24 @@ table.splittabelle input.rechts { text-align: right; }
|
||||||
font-weight: 500;
|
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="text"],
|
||||||
.formular input[type="password"],
|
.formular input[type="password"],
|
||||||
.formular input[type="number"],
|
.formular input[type="number"],
|
||||||
|
|
@ -601,21 +694,18 @@ table.splittabelle input.rechts { text-align: right; }
|
||||||
|
|
||||||
.knopf:hover { background: var(--flaeche-gedaempft); text-decoration: none; }
|
.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 {
|
.knopf.primaer {
|
||||||
background: var(--akzent);
|
background: var(--akzent);
|
||||||
border-color: var(--akzent);
|
border-color: var(--akzent);
|
||||||
color: var(--akzent-text);
|
color: var(--akzent-text);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-top: 1.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.knopf.primaer:hover { filter: brightness(1.08); }
|
.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 {
|
.knopf.klein {
|
||||||
padding: .25rem .6rem;
|
padding: .25rem .6rem;
|
||||||
font-size: .85rem;
|
font-size: .85rem;
|
||||||
|
|
@ -642,6 +732,12 @@ table.liste .knopf { margin-top: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.meldung-erfolg { background: var(--akzent-schwach); border-color: var(--akzent); }
|
.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-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); }
|
.meldung-warnung { background: color-mix(in srgb, var(--warnung) 15%, var(--flaeche)); border-color: var(--warnung); }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,38 @@
|
||||||
}
|
}
|
||||||
}, true);
|
}, 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.
|
// 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) {
|
document.querySelectorAll('.meldung-erfolg').forEach(function (element) {
|
||||||
window.setTimeout(function () {
|
window.setTimeout(function () {
|
||||||
element.style.transition = 'opacity .4s';
|
element.classList.add('verblasst');
|
||||||
element.style.opacity = '0';
|
|
||||||
window.setTimeout(function () { element.remove(); }, 400);
|
window.setTimeout(function () { element.remove(); }, 400);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,21 @@ abstract class BaseController
|
||||||
return is_string($wert) ? trim($wert) : $standard;
|
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
|
protected function query(string $feld, string $standard = ''): string
|
||||||
{
|
{
|
||||||
$wert = $_GET[$feld] ?? $standard;
|
$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',
|
'phase' => 'Phase 4',
|
||||||
'text' => 'Summen-, Buchungs-, Vergleichs- und Budgetbericht samt Grafiken.',
|
'text' => 'Summen-, Buchungs-, Vergleichs- und Budgetbericht samt Grafiken.',
|
||||||
],
|
],
|
||||||
'planung' => [
|
|
||||||
'titel' => 'Planung',
|
|
||||||
'phase' => 'Phase 3',
|
|
||||||
'text' => 'Budgetplaner für das Jahresbudget und der Monatsabschluss.',
|
|
||||||
],
|
|
||||||
'wertpapiere' => [
|
'wertpapiere' => [
|
||||||
'titel' => 'Wertpapiere',
|
'titel' => 'Wertpapiere',
|
||||||
'phase' => 'Phase 6',
|
'phase' => 'Phase 6',
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,37 @@ class VerwaltungController extends BaseController
|
||||||
|
|
||||||
$this->render('verwaltung/kontengruppen.php', [
|
$this->render('verwaltung/kontengruppen.php', [
|
||||||
'titel' => 'Kontengruppen',
|
'titel' => 'Kontengruppen',
|
||||||
'gruppen' => Kontengruppe::alle(),
|
'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(),
|
'konten' => Konto::alle(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -103,13 +133,14 @@ class VerwaltungController extends BaseController
|
||||||
$this->verlangeAnmeldung();
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Kontengruppe::anlegen(
|
$id = Kontengruppe::anlegen(
|
||||||
$this->post('name'),
|
$this->post('name'),
|
||||||
$this->post('beschreibung'),
|
$this->post('beschreibung'),
|
||||||
(int)$this->post('sortierung', '0')
|
(int)$this->post('sortierung', '0')
|
||||||
);
|
);
|
||||||
|
Kontengruppe::kontenZuordnen($id, $this->postIds('konten'));
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
View::redirect('/verwaltung/kontengruppen', $e->getMessage(), 'fehler');
|
View::redirect('/verwaltung/kontengruppen/neu', $e->getMessage(), 'fehler');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,15 +152,18 @@ class VerwaltungController extends BaseController
|
||||||
{
|
{
|
||||||
$this->verlangeAnmeldung();
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Kontengruppe::speichern(
|
Kontengruppe::speichern(
|
||||||
(int)$params['id'],
|
$id,
|
||||||
$this->post('name'),
|
$this->post('name'),
|
||||||
$this->post('beschreibung'),
|
$this->post('beschreibung'),
|
||||||
(int)$this->post('sortierung', '0')
|
(int)$this->post('sortierung', '0')
|
||||||
);
|
);
|
||||||
|
Kontengruppe::kontenZuordnen($id, $this->postIds('konten'));
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
View::redirect('/verwaltung/kontengruppen', $e->getMessage(), 'fehler');
|
View::redirect('/verwaltung/kontengruppen/' . $id, $e->getMessage(), 'fehler');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ class Buchung
|
||||||
public static function anlegen(array $daten, array $splits = [], array $adressen = []): int
|
public static function anlegen(array $daten, array $splits = [], array $adressen = []): int
|
||||||
{
|
{
|
||||||
$felder = self::normalisiere($daten, $splits);
|
$felder = self::normalisiere($daten, $splits);
|
||||||
|
Monatsabschluss::verlangeOffen($felder['datum']);
|
||||||
|
|
||||||
return (int)Database::transaktion(static function () use ($felder, $splits, $adressen): int {
|
return (int)Database::transaktion(static function () use ($felder, $splits, $adressen): int {
|
||||||
Database::execute(
|
Database::execute(
|
||||||
|
|
@ -91,12 +92,18 @@ class Buchung
|
||||||
*/
|
*/
|
||||||
public static function speichern(int $id, array $daten, array $splits = [], array $adressen = []): void
|
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.');
|
throw new \RuntimeException('Die Buchung gibt es nicht.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$felder = self::normalisiere($daten, $splits);
|
$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::transaktion(static function () use ($id, $felder, $splits, $adressen): void {
|
||||||
Database::execute(
|
Database::execute(
|
||||||
'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?,
|
'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?,
|
||||||
|
|
@ -120,6 +127,11 @@ class Buchung
|
||||||
|
|
||||||
public static function loeschen(int $id): void
|
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()]);
|
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
|
public static function find(int $id): ?array
|
||||||
{
|
{
|
||||||
$zeile = Database::fetchOne(
|
$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“. */
|
/** Die zugeordneten Konten rutschen per ON DELETE SET NULL zu „Nicht gruppiert“. */
|
||||||
public static function loeschen(int $id): void
|
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>
|
<label for="passwort_wdh">Passwort wiederholen</label>
|
||||||
<input type="password" id="passwort_wdh" name="passwort_wdh" autocomplete="new-password" required minlength="10">
|
<input type="password" id="passwort_wdh" name="passwort_wdh" autocomplete="new-password" required minlength="10">
|
||||||
|
|
||||||
|
<div class="knopfleiste">
|
||||||
<button type="submit" class="knopf primaer">Zugang einrichten</button>
|
<button type="submit" class="knopf primaer">Zugang einrichten</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<?php
|
<?php
|
||||||
$inhalt = (string)ob_get_clean();
|
$inhalt = (string)ob_get_clean();
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ ob_start();
|
||||||
<label for="passwort">Passwort</label>
|
<label for="passwort">Passwort</label>
|
||||||
<input type="password" id="passwort" name="passwort" autocomplete="current-password" required>
|
<input type="password" id="passwort" name="passwort" autocomplete="current-password" required>
|
||||||
|
|
||||||
|
<div class="knopfleiste">
|
||||||
<button type="submit" class="knopf primaer">Anmelden</button>
|
<button type="submit" class="knopf primaer">Anmelden</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<?php
|
<?php
|
||||||
$inhalt = (string)ob_get_clean();
|
$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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?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>
|
</tbody>
|
||||||
</table>
|
</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): ?>
|
<?php if ($buchungen !== null): ?>
|
||||||
<h3>Buchungen</h3>
|
<h3>Buchungen</h3>
|
||||||
<table class="liste">
|
<table class="liste">
|
||||||
|
|
@ -141,6 +153,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="knopfleiste">
|
<div class="knopfleiste">
|
||||||
<a href="/konten" class="knopf primaer">Kontenübersicht</a>
|
<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>
|
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="hinweis">
|
<p class="hinweis">
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<?php foreach ($kategorien as $kategorie): ?>
|
<?php foreach ($kategorien as $kategorie): ?>
|
||||||
<tr<?= (int)$kategorie['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
|
<tr<?= (int)$kategorie['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
|
||||||
<td>
|
<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'] ?>">
|
<a href="/verwaltung/kategorien/<?= (int)$kategorie['id'] ?>">
|
||||||
<?= View::h($kategorie['name']) ?>
|
<?= View::h($kategorie['name']) ?>
|
||||||
</a>
|
</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;
|
use Finanzmanager\View;
|
||||||
|
|
||||||
/** @var list<array<string,mixed>> $gruppen */
|
/** @var list<array<string,mixed>> $gruppen */
|
||||||
/** @var list<array<string,mixed>> $konten */
|
/** @var int $ohneGruppe */
|
||||||
/** @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;
|
|
||||||
}
|
|
||||||
|
|
||||||
include __DIR__ . '/../layout/kopf.php';
|
include __DIR__ . '/../layout/kopf.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="seitenaktionen">
|
<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="/konten" class="knopf">Zur Kontenübersicht</a>
|
||||||
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
|
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -29,19 +18,22 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<p class="hinweis">
|
<p class="hinweis">
|
||||||
Kontengruppen gliedern die Kontenübersicht. Konten ohne Gruppe erscheinen dort unter
|
Kontengruppen gliedern die Kontenübersicht. Konten ohne Gruppe erscheinen dort unter
|
||||||
„<?= View::h(Kontengruppe::OHNE_GRUPPE) ?>“ – derzeit <?= (int)$ohneGruppe ?>.
|
„<?= 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>
|
</p>
|
||||||
|
|
||||||
<?php if ($gruppen === []): ?>
|
<?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 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">
|
<table class="liste">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -51,28 +43,18 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($gruppen as $gruppe): ?>
|
<?php foreach ($gruppen as $gruppe): ?>
|
||||||
<?php $f = 'gruppe-' . (int)$gruppe['id']; ?>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
<a href="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>">
|
||||||
<input type="hidden" form="gruppe-loeschen-<?= (int)$gruppe['id'] ?>"
|
<?= View::h($gruppe['name']) ?>
|
||||||
name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
</a>
|
||||||
<input type="text" form="<?= $f ?>" name="name"
|
|
||||||
value="<?= View::h($gruppe['name']) ?>" required>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td><?= View::h($gruppe['beschreibung']) ?></td>
|
||||||
<input type="text" form="<?= $f ?>" name="beschreibung"
|
<td class="rechts"><?= (int)$gruppe['sortierung'] ?></td>
|
||||||
value="<?= View::h($gruppe['beschreibung']) ?>">
|
<td class="rechts"><?= (int)$gruppe['anzahl_konten'] ?></td>
|
||||||
</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 class="rechts nowrap">
|
<td class="rechts nowrap">
|
||||||
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button>
|
<a href="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>"
|
||||||
<button type="submit" form="gruppe-loeschen-<?= (int)$gruppe['id'] ?>"
|
class="knopf klein">Bearbeiten</a>
|
||||||
class="knopf klein gefahr">Löschen</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
@ -80,22 +62,4 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</table>
|
</table>
|
||||||
<?php endif; ?>
|
<?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'; ?>
|
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,51 @@ class KontenTest extends TestCase
|
||||||
$this->uebersichtGruppiertUndSummiert();
|
$this->uebersichtGruppiertUndSummiert();
|
||||||
$this->betragsEingabe();
|
$this->betragsEingabe();
|
||||||
$this->ankreuzfelderAusDemFormular();
|
$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
|
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 page.goto('/verwaltung/kategorien')
|
||||||
await expect(page.locator('tr', { hasText: oben }).first()).toContainText('Oberkategorie')
|
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`))
|
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 }) => {
|
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')
|
await expect(page.locator('.kachel h2').first()).toContainText('Trockenlauf')
|
||||||
const uebersicht = page.locator('.kachel table.liste').first()
|
const uebersicht = page.locator('.kachel table.liste').first()
|
||||||
await expect(uebersicht.locator('tr', { hasText: 'Klassen' })).toContainText('1')
|
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.
|
// Nichts gespeichert: die Klasse darf noch nicht in der Liste stehen.
|
||||||
await page.goto('/verwaltung/klassen')
|
await page.goto('/verwaltung/klassen')
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue