From 90bba065850403b84b1f7c402e84d71f57d89b8d Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 11 Aug 2026 17:38:30 +0200 Subject: [PATCH] Phase 4: Auswertungen mit Berichtsmodul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fünf Berichtstypen wie im FinanzManager – Summen-, Buchungs-, Vergleichs-, Kontostands- und Budgetbericht – mit Filterleiste, gespeicherten Berichten in Berichtsgruppen, Drilldown in die Buchungen und CSV-Export. Ein Bericht speichert nur seine Frage; die Zahlen entstehen bei jedem Aufruf neu aus den Buchungen. Gezählt werden Bewegungen (gesplittete Buchungen über ihre Splitzeilen), Umbuchungen bleiben außen vor, Unkategorisiertes bekommt eine eigene Zeile. Im Ausgabenabschnitt steht der Betrag ohne Minus wie im Original, die Schlusszeile rechnet mit den echten Vorzeichen. Einzelheiten in docs/auswertungen.md. Co-Authored-By: Claude Opus 5 --- Changes.md | 90 ++ PLAN.md | 11 +- README.md | 12 +- docs/auswertungen.md | 186 +++ migrations/005_berichte.sql | 52 + public/index.php | 21 + public/static/app.css | 211 +++ src/Controllers/AuswertungenController.php | 297 +++++ src/Controllers/PlatzhalterController.php | 5 - src/Models/Bericht.php | 190 +++ src/Models/Berichtsgruppe.php | 110 ++ src/Models/Buchung.php | 19 + src/Models/Budget.php | 14 + src/Services/Auswertung.php | 1225 ++++++++++++++++++ src/Services/BerichtExport.php | 218 ++++ src/Services/Zeitraum.php | 283 ++++ src/Views/auswertungen/bericht.php | 249 ++++ src/Views/auswertungen/drilldown.php | 88 ++ src/Views/auswertungen/filterfelder.php | 22 + src/Views/auswertungen/gruppen.php | 123 ++ src/Views/auswertungen/index.php | 99 ++ src/Views/auswertungen/teil-buchungen.php | 78 ++ src/Views/auswertungen/teil-budget.php | 76 ++ src/Views/auswertungen/teil-kontostaende.php | 57 + src/Views/auswertungen/teil-summen.php | 147 +++ src/Views/cockpit/index.php | 12 +- tests/14_AuswertungTest.php | 637 +++++++++ tests/e2e/specs/auswertungen.spec.js | 169 +++ 28 files changed, 4690 insertions(+), 11 deletions(-) create mode 100644 docs/auswertungen.md create mode 100644 migrations/005_berichte.sql create mode 100644 src/Controllers/AuswertungenController.php create mode 100644 src/Models/Bericht.php create mode 100644 src/Models/Berichtsgruppe.php create mode 100644 src/Services/Auswertung.php create mode 100644 src/Services/BerichtExport.php create mode 100644 src/Services/Zeitraum.php create mode 100644 src/Views/auswertungen/bericht.php create mode 100644 src/Views/auswertungen/drilldown.php create mode 100644 src/Views/auswertungen/filterfelder.php create mode 100644 src/Views/auswertungen/gruppen.php create mode 100644 src/Views/auswertungen/index.php create mode 100644 src/Views/auswertungen/teil-buchungen.php create mode 100644 src/Views/auswertungen/teil-budget.php create mode 100644 src/Views/auswertungen/teil-kontostaende.php create mode 100644 src/Views/auswertungen/teil-summen.php create mode 100644 tests/14_AuswertungTest.php create mode 100644 tests/e2e/specs/auswertungen.spec.js diff --git a/Changes.md b/Changes.md index 9e07578..576f8dc 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,95 @@ # Änderungen +## Phase 4 – Auswertungen mit Berichtsmodul (11.08.2026) + +**Datenmodell** +- Migration `005_berichte.sql`: `berichte`, `berichtsgruppen` +- Ein Bericht ist Name, Typ und Filter – die Zahlen entstehen bei jedem Aufruf neu aus den + Buchungen. Gespeichert wird ausschließlich die Frage. +- Der Filter liegt als JSON in einer Spalte und läuft beim Lesen immer durch + `Auswertung::normalisiere()`: Unbekannte Felder fallen weg, unbekannte Werte auf die + Vorgabe zurück. Keine eigene Spalte je Filterfeld – das sind Anzeigeparameter ohne + Fremdschlüssel, und jede neue Filtermöglichkeit bräuchte sonst eine Migration. +- Keine neuen Einstellungen: Zeitraum, Zeilen-Summe und Einrückung gehören zum einzelnen + Bericht, nicht in den Einstellungsdialog. + +**Fünf Berichtstypen** (`src/Services/Auswertung.php`) +- **Summenbericht**: Summen je Zeilen-Summe und Spalte, getrennt nach Einnahmen und + Ausgaben, mit Schlusszeile *Gesamt Einnahmen − Ausgaben* +- **Buchungsbericht**: die Einzelbuchungen, gruppiert mit Zwischensummen +- **Vergleichsbericht**: Zeitraum gegen denselben Zeitraum des Vorjahres, mit Differenz + und Veränderung in Prozent +- **Kontostandsbericht**: Salden je Konto zu den Enden der Spaltenzeiträume +- **Budgetbericht**: Budget gegen Ist je Kategorie, mit Saldo + +**Was gezählt wird** +- Bewegungen statt Buchungen: nicht gesplittete Buchungen mit ihrem Betrag, gesplittete + über ihre Splitzeilen – sonst zählte eine Splitbuchung doppelt oder gar nicht. +- **Umbuchungen bleiben außen vor** (weder Einnahme noch Ausgabe, wie im Budgetplaner). + Nur der Buchungsbericht führt sie auf Wunsch mit; sie erscheinen dort mit dem Gegenkonto + in eckigen Klammern. +- Unkategorisierte Buchungen stehen als eigene Zeile *Nicht kategorisiert* am Ende ihres + Abschnitts, statt lautlos aus der Summe zu fallen. +- Einnahme oder Ausgabe entscheidet die **Art der Kategorie**, nicht das Vorzeichen: Eine + Erstattung ist eine negative Ausgabe und gehört unter die Ausgaben. +- Aggregiert wird in PHP. Die Datenbank liefert die Bewegungen, das Einsortieren in Zeilen + und Spalten passiert im Service – Baumsummen, Vorzeichenregeln und Spaltenraster wären + in SQL erheblich schwerer nachzulesen. + +**Vorzeichen** +- Intern durchgehend Buchhaltung (Ausgaben negativ), in der Anzeige dreht der + Ausgabenabschnitt sein Vorzeichen – wie im FinanzManager steht dort 636,39 statt + −636,39. Die Schlusszeile rechnet mit den echten Werten. +- Buchungs- und Budgetbericht sowie der Drilldown behalten das Vorzeichen: Sie haben keine + Abschnitte, die es erklären. + +**Zeitraum und Spalten** (`src/Services/Zeitraum.php`, reine Kalenderrechnung ohne Datenbank) +- Neun Vorgaben von *Aktueller Monat* bis *Gesamter Zeitraum*; sie rechnen sich bei jedem + Aufruf neu aus dem Tagesdatum aus. Ein Bericht mit *Letzter Monat* zeigt im August den + Juli, nicht für immer den Monat seiner Entstehung. +- Spalten-Aufteilung nach Monat, Quartal oder Jahr; die Randspalten werden auf den + Zeitraum gestutzt. +- *Gesamter Zeitraum* nimmt die Datumsgrenzen der Buchungen (`Buchung::datumsgrenzen()`) + und endet spätestens heute – ein Ende in der Zukunft brächte nur leere Spalten. +- Beim Vorjahresvergleich bleibt ein Monatsende ein Monatsende, sonst träfe der 28.02. auf + einen 29.02. + +**Summenbericht als Baum** +- Jede Kategorie zeigt ihre eigenen Werte, Kategorien mit Nachfahren bekommen dahinter + *Gesamt <Name>* mit der Astsumme. Eine Elternkategorie ohne eigene Buchungen steht als + bloße Kopfzeile da – genau wie im Original. +- *ohne Einrückung* schaltet auf eine flache Liste mit vollem Pfad und ohne Astsummen. +- Ein Kategoriefilter schließt die Unterkategorien ein: Wer „400 - Leben“ wählt, meint den + ganzen Ast. + +**Oberfläche** +- Übersicht mit Kachel *Standardvorlagen* und je einer Kachel pro Berichtsgruppe, + Menü *Letzte Berichte*, Verwaltung der Gruppen unter `/auswertungen/gruppen`. +- Filterleiste sendet per GET: Jeder Zwischenstand ist eine Adresse, die man sich merken, + weitergeben und mit *Speichern unter* festhalten kann. +- *Speichern unter* kann die Berichtsgruppe gleich mit anlegen – wer sie erst anderswo + erzeugen müsste, verlöre den eingestellten Filter. +- Eine gelöschte Gruppe nimmt ihre Berichte nicht mit: Sie rücken unter *Nicht gruppiert*. +- Die Aufklappmenüs sind `
`-Elemente, kein JavaScript. +- Der Platzhalter für `/auswertungen` entfällt aus dem `PlatzhalterController`. + +**Drilldown** +- Zeilenname und jede Zahl führen in die Buchungen dahinter, die Zahl zusätzlich + eingeschränkt auf ihre Spalte. +- Gefiltert wird durch Nachsieben derselben Bewegungen, aus denen die Zelle entstand – + nicht durch Übersetzen der Zeile in einen neuen Filter. Nur so trifft der Drilldown auch + *Nicht kategorisiert* oder eine Astsumme über mehrere Unterkategorien. + +**CSV-Export** (`src/Services/BerichtExport.php`) +- Dieselben Zeilen und Vorzeichen wie am Bildschirm, je Berichtstyp eigene Spalten. +- Semikolon, Dezimalkomma, kein Tausenderpunkt, CRLF und BOM – sonst zeigt die + Tabellenkalkulation „Übrige“ statt „Übrige“ und liest Beträge als Text. + +**Tests** +- `tests/14_AuswertungTest.php` (142 Prüfungen) und + `tests/e2e/specs/auswertungen.spec.js` (7 Abläufe) +- Einzelheiten in `docs/auswertungen.md` + ## Cockpit: Vermögensentwicklung als gestapeltes Balkendiagramm (11.08.2026) - Die Kachel *Vermögensentwicklung* war ein Platzhalter und zeigt jetzt die Kontostände diff --git a/PLAN.md b/PLAN.md index 6472e7c..a2efa35 100644 --- a/PLAN.md +++ b/PLAN.md @@ -269,13 +269,22 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag „Planung", „Steuern"): **Summenbericht, Buchungsbericht, Vergleichsbericht, Kontostandsbericht, Budgetbericht**, jeweils mit Zeitraum, Spalten-Aufteilung, Zeilen-Summe (Kategorie/Klasse/Konto/Empfänger), Filter auf Konten/Kategorien/Klassen. + *(erledigt — `docs/auswertungen.md`)* +* Export CSV; Drilldown von der Berichtszeile in die zugrunde liegenden Buchungen. + *(erledigt)* +* Entschieden während der Umsetzung: Umbuchungen zählen in den Auswertungen nicht mit + (weder Einnahme noch Ausgabe, wie im Budgetplaner); allein der Buchungsbericht führt sie + auf Wunsch mit. Unkategorisierte Buchungen erscheinen als eigene Zeile, statt aus der + Summe zu fallen. Im Ausgabenabschnitt steht der Betrag ohne Minus wie im Original, die + Schlusszeile rechnet mit den echten Vorzeichen. Der Budgetbericht rechnet über volle + Monate — ein halber Monat hat kein halbes Budget. * Grafiken als SVG: Einnahmen/Ausgaben je Monat, Vermögensentwicklung *(erledigt: Cockpit- Kachel mit den letzten zwölf Monaten, gestapelt je Konto — `docs/cockpit.md`)*, Ausgaben nach Kategorie. -* Export CSV; Drilldown von der Berichtszeile in die zugrunde liegenden Buchungen. * **Cockpit** mit konfigurierbaren Kacheln (Vermögensentwicklung, Einnahmen/Ausgaben, Meine Ausgaben, Kontostände, offene Vorgänge). * *Ergebnis:* Die bisher genutzten Berichte sind nachgebaut und liefern dieselben Zahlen. + Offen bleiben die beiden übrigen Grafiken und die Cockpit-Kacheln. ### Phase 5 — Online-Banking (FinTS) * Fork von `nemiah/php-fints` auf Codeberg, Einbindung über VCS-Repository. diff --git a/README.md b/README.md index be34ba7..f59fe5f 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,14 @@ Phase 0 bis 3 sind gebaut: Anmeldung, zentraler Einstellungsdialog, Debug-Log, Migrationsmechanik und Test-Harness; Konten, Kontengruppen, Kategorien und Klassen; Kontoblatt, Buchungsmaske mit Splitbuchungen und Umbuchungen sowie der QIF-Import für Stammdaten und Buchungen; Budgetplaner mit Monatswerten je Kategorie und der -Monatsabschluss samt Prüflauf, Snapshot und Sperre. Aus Phase 4 steht die erste -Cockpit-Kachel: die Vermögensentwicklung der letzten zwölf Monate als gestapeltes -Balkendiagramm. Als Nächstes folgen nach `PLAN.md` die übrigen Auswertungen. +Monatsabschluss samt Prüflauf, Snapshot und Sperre. + +Aus Phase 4 steht das Berichtsmodul: Summen-, Buchungs-, Vergleichs-, Kontostands- und +Budgetbericht mit Zeitraumvorgaben, Spalten-Aufteilung, Zeilen-Summe nach +Kategorie/Klasse/Konto/Empfänger, Drilldown in die Buchungen, CSV-Export sowie +gespeicherten Berichten in Berichtsgruppen. Dazu die Cockpit-Kachel Vermögensentwicklung +als gestapeltes Balkendiagramm der letzten zwölf Monate. Als Nächstes folgen nach +`PLAN.md` die übrigen Grafiken und Cockpit-Kacheln. ## Technik @@ -86,5 +91,6 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf. - `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen - `docs/buchungen.md` – Kontoblatt, Umbuchungen, Splitbuchungen, Salden - `docs/planung.md` – Budgetplaner und Monatsabschluss +- `docs/auswertungen.md` – Berichtsmodul, Filterleiste, Drilldown, CSV - `docs/cockpit.md` – Startseite und die Kachel Vermögensentwicklung - `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs diff --git a/docs/auswertungen.md b/docs/auswertungen.md new file mode 100644 index 0000000..4cdd8c4 --- /dev/null +++ b/docs/auswertungen.md @@ -0,0 +1,186 @@ +# Auswertungen + +Der Bereich *Auswertungen* baut die Berichte des FinanzManagers nach: fünf Berichtstypen, +eine Filterleiste, gespeicherte Berichte in Gruppen, Drilldown in die Buchungen und +CSV-Export. + +Ein Bericht wird **nie gerechnet gespeichert**, sondern immer nur seine Frage. Die Zahlen +entstehen bei jedem Aufruf neu aus den Buchungen. Ein Bericht mit der Zeitraumvorgabe +„Letzter Monat“ zeigt im August den Juli und nicht für immer den Monat seiner Entstehung. + +## Was gezählt wird + +Grundlage sind die **Bewegungen**: nicht gesplittete Buchungen mit ihrem Betrag, +gesplittete über ihre Splitzeilen. Sonst zählte eine Splitbuchung doppelt oder gar nicht. + +**Umbuchungen bleiben außen vor.** Sie verschieben Vermögen zwischen eigenen Konten und +sind weder Einnahme noch Ausgabe – dieselbe Regel wie im Budgetplaner. Allein der +Buchungsbericht kann sie über den Schalter *Umbuchungen mitzählen* mitführen; dann weicht +seine Summe naturgemäß vom Summenbericht ab. + +**Nicht kategorisierte Buchungen verschwinden nicht.** Sie stehen als eigene Zeile +*Nicht kategorisiert* am Ende ihres Abschnitts. Geld, das keiner Kategorie zugeordnet ist, +fiele sonst lautlos aus der Summe. + +Ob eine Bewegung zu den Einnahmen oder den Ausgaben zählt, entscheidet die **Art ihrer +Kategorie**, nicht das Vorzeichen: Eine Erstattung ist eine negative Ausgabe und gehört +unter die Ausgaben. Nur wo keine Kategorie steht, bleibt das Vorzeichen als einziger +Anhaltspunkt. + +Aggregiert wird in PHP, nicht in SQL. Die Datenbank liefert die Bewegungen des Zeitraums, +das Einsortieren in Zeilen und Spalten passiert in `Auswertung`. Bei Zehntausenden +Buchungen ist das Millisekundensache, und Baumsummen über Kategorien, Vorzeichenregeln und +Spaltenraster wären in SQL erheblich schwerer nachzulesen. + +## Vorzeichen + +Intern gilt durchgehend die Buchhaltung: Ausgaben negativ. In der **Anzeige** dreht der +Ausgabenabschnitt sein Vorzeichen – dort steht 636,39 statt −636,39, wie im FinanzManager; +die Überschrift sagt ja bereits, worum es geht. Die Schlusszeile *Gesamt Einnahmen − +Ausgaben* rechnet wieder mit den echten Werten. + +Zwei Ausnahmen führen das Vorzeichen mit, weil sie keine Abschnitte haben: der +**Buchungsbericht** (eine Buchungsliste, kein Summenbericht) und der **Budgetbericht** +(Vorzeichen wie im Budgetplaner). Der Drilldown zeigt ebenfalls die echten Vorzeichen. + +## Die fünf Berichtstypen + +| Typ | Zeilen | Spalten | +|---|---|---| +| **Summenbericht** | Zeilen-Summe, nach Einnahmen und Ausgaben getrennt | Zeitraum, je nach Spalten-Aufteilung | +| **Buchungsbericht** | Einzelbuchungen, gruppiert nach Zeilen-Summe | feste Buchungsspalten | +| **Vergleichsbericht** | wie Summenbericht | Zeitraum, Vorjahreszeitraum, Differenz, Veränderung | +| **Kontostandsbericht** | Konten | Salden zu den Enden der Spaltenzeiträume | +| **Budgetbericht** | Kategoriebaum | Budget, Ist, Saldo | + +### Summenbericht + +Bei der Zeilen-Summe *Kategorie* entsteht der eingerückte Baum: Jede Kategorie zeigt ihre +**eigenen** Werte, Kategorien mit Nachfahren bekommen dahinter eine Zeile *Gesamt <Name>* +mit der Summe des Astes. Eine Elternkategorie ohne eigene Buchungen steht deshalb als +bloße Kopfzeile da – genau wie im Original. + +Der Schalter *mit Einrückung* schaltet auf eine flache Liste mit vollem Kategoriepfad um. +Dort zählt jede Kategorie nur sich selbst, und es gibt keine Astsummen; sonst stünde eine +Summe wie ein gleichrangiger Posten neben ihren eigenen Bestandteilen. + +Bei *Klasse*, *Konto* und *Empfänger* ist die Liste flach und alphabetisch. Die Aufteilung +in Einnahmen und Ausgaben bleibt: Eine Klasse mit beidem erscheint in beiden Abschnitten. + +### Vergleichsbericht + +Verglichen wird gegen **denselben Zeitraum ein Jahr früher**. Die Spalten-Aufteilung +spielt hier keine Rolle – die Spalten gibt der Vergleich vor. + +Ohne Vorjahreswert bleibt die Prozentspalte leer: „unendlich viel mehr“ ist keine Aussage, +die in eine Spalte gehört. Ein Monatsende bleibt beim Rückrechnen ein Monatsende, damit +der 28.02. nicht auf einen 29.02. trifft und der Vergleichszeitraum einen Tag zu lang wird. + +### Kontostandsbericht + +Anders als die übrigen Berichte zählt hier nicht die Bewegung im Zeitraum, sondern der +**Stand**: alle Buchungen bis zum Stichtag, auch die vor dem Berichtszeitraum, und die +Gegenseiten von Umbuchungen. Konten, die im ganzen Zeitraum auf null standen, fallen weg – +dieselbe Regel wie in der Cockpit-Kachel. Die Summe zählt alle gezeigten Konten, +unabhängig vom Σ-Kennzeichen der Kontenübersicht. + +Die Null wird hier ausgeschrieben: Ein Konto, das auf null steht, ist eine Aussage. + +### Budgetbericht + +Gerechnet wird über die **vollen Monate**, die der Zeitraum berührt. Ein Budget ist ein +Monatswert, und ein halber Monat hat kein halbes Budget; anders wären Budget und Ist nicht +vergleichbar und die Saldospalte bedeutungslos. Der Berichtskopf nennt deshalb den +Monatsbereich. + +Geht der Zeitraum über den Jahreswechsel, gilt je Jahr dessen eigenes aktives Budget +(`Budget::aktivesFuerJahr()`). Vorzeichen und Saldo folgen dem Budgetplaner: Ausgaben +negativ, Saldo ist `ist − budget`, positiv heißt besser als geplant. Übergeordnete +Kategorien zeigen die Summe ihres Astes. + +## Filterleiste + +| Feld | Wirkung | +|---|---| +| Zeitraum | Vorgabe oder *Benutzerdefiniert*; von/bis sind immer gefüllt und zeigen, was die Vorgabe gerade bedeutet | +| Bericht anzeigen als | wechselt den Berichtstyp, ohne den übrigen Filter zu verlieren | +| Zeilen-Summe | Kategorie, Klasse, Konto oder Empfänger | +| Spalten-Aufteilung | Keine, Monat, Quartal, Jahr | +| mit Einrückung | Kategoriebaum oder flache Liste mit Pfad | +| Suche | Empfänger oder Buchungstext | +| Einschränken | Konten, Kategorien, Klassen (Mehrfachauswahl) | + +Eine gewählte Kategorie **schließt ihre Unterkategorien ein** – wer „400 - Leben“ filtert, +meint den ganzen Ast. Nichts ausgewählt heißt alles. + +Zwei Berichte bestimmen ihre Zeilen selbst und sperren das Feld: der Kontostandsbericht +führt immer Konten, der Budgetbericht immer Kategorien. Zwei bestimmen ihre Spalten +selbst: Vergleichs- und Budgetbericht. + +Die Filterleiste sendet per **GET**. Jeder Zwischenstand ist damit eine Adresse, die man +sich merken, weitergeben und mit *Speichern unter* festhalten kann. + +## Gespeicherte Berichte und Gruppen + +Ein gespeicherter Bericht ist Name, Typ und Filter – mehr nicht. Der Filter liegt als JSON +in einer Spalte und wird beim Lesen immer durch `Auswertung::normalisiere()` geschickt: +Was dort nicht vorgesehen ist, fällt weg. Ein alter Bericht kann nach einer +Programmänderung höchstens eine Einstellung verlieren, aber nichts Unerwartetes in die +Abfrage tragen. + +*Speichern unter* legt unter neuem Namen an und kann die Berichtsgruppe gleich mit +anlegen – wer sie erst anderswo erzeugen müsste, verlöre den eingestellten Filter. +*Speichern* überschreibt den geöffneten Bericht. + +**Berichtsgruppen** sind die Kacheln der Übersicht, wie „Bankgeschäfte“ oder „Steuern“ im +Original. Wird eine Gruppe gelöscht, bleiben ihre Berichte erhalten und rücken unter +*Nicht gruppiert*: Ein Bericht ist Arbeit, eine Gruppe nicht. + +Leere Gruppen bleiben in der Übersicht stehen. Sie sind angelegt worden, um befüllt zu +werden, und ohne Kachel gäbe es keinen Ort, an den ein Bericht könnte. + +## Drilldown + +Jeder Zeilenname und jede Zahl im Summen-, Vergleichs- und Budgetbericht ist ein Link auf +die Buchungen dahinter. Eine Zahl schränkt zusätzlich auf ihre Spalte ein. + +Gefiltert wird nicht, indem die Zeile in einen neuen Filter übersetzt wird, sondern indem +**dieselben Bewegungen nachgesiebt** werden, aus denen die Zelle entstanden ist. Nur so +trifft der Drilldown auch das, was ein Filter gar nicht ausdrücken könnte: die Zeile +*Nicht kategorisiert* etwa, oder eine Astsumme über mehrere Unterkategorien. + +## CSV-Export + +Dieselben Zeilen, dieselben Zahlen, dieselben Vorzeichen wie am Bildschirm – wer den +Export mit der Anzeige vergleicht, soll nicht rechnen müssen. + +Deutsches Tabellenkalkulations-CSV: Semikolon als Trenner, Komma als Dezimalzeichen, kein +Tausenderpunkt (der machte aus der Zahl einen Text), CRLF als Zeilenende und ein +vorangestelltes BOM – sonst zeigen Excel und LibreOffice beim Doppelklick „Übrige“ statt +„Übrige“. Die Einrückung des Kategoriebaums steht als führende Leerzeichen im Namensfeld, +sonst wäre die Hierarchie verloren. + +PDF gibt es bewusst nicht (siehe `PLAN.md`); Papier läuft über die Druckfunktion des +Browsers. + +## Dateien + +| Datei | Inhalt | +|---|---| +| `src/Services/Auswertung.php` | die Rechenmaschine: Bewegungen, die fünf Berichtstypen, Drilldown | +| `src/Services/Zeitraum.php` | Zeitraumvorgaben und Spaltenraster, reine Kalenderrechnung ohne Datenbank | +| `src/Services/BerichtExport.php` | CSV | +| `src/Models/Bericht.php` | gespeicherte Berichte, Filter-JSON | +| `src/Models/Berichtsgruppe.php` | Gruppen | +| `src/Controllers/AuswertungenController.php` | Übersicht, Berichtsansicht, Drilldown, Export | +| `src/Views/auswertungen/` | Übersicht, Filterleiste, ein Teiltemplate je Darstellung | +| `migrations/005_berichte.sql` | `berichte`, `berichtsgruppen` | + +## Tests + +* `tests/14_AuswertungTest.php` – Zeitraumvorgaben und Spaltenraster, Filterprüfung, + alle fünf Berichtstypen, Astsummen, Splitbuchungen, Ausschluss der Umbuchungen, + unkategorisierte Buchungen, Einschränkungen, Drilldown, CSV, gespeicherte Berichte +* `tests/e2e/specs/auswertungen.spec.js` – Übersicht und Standardvorlagen, die fünf + Tabellen, Spalten-Aufteilung, Drilldown und zurück, Speichern/Wiederfinden/Löschen, + CSV-Download diff --git a/migrations/005_berichte.sql b/migrations/005_berichte.sql new file mode 100644 index 0000000..3e919a1 --- /dev/null +++ b/migrations/005_berichte.sql @@ -0,0 +1,52 @@ +-- Phase 4: Auswertungen. +-- +-- Ein gespeicherter Bericht ist ein Name plus die Einstellungen der Filterleiste +-- (Berichtstyp, Zeitraum, Zeilen-Summe, Spalten-Aufteilung, Einschränkung auf +-- Konten/Kategorien/Klassen). Berichtsgruppen bündeln sie zu Kacheln, wie im +-- FinanzManager unter „Bankgeschäfte“, „Planung“ oder „Steuern“. + +CREATE TABLE IF NOT EXISTS berichtsgruppen ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + benutzer_id INT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + sortierung INT NOT NULL DEFAULT 0, + erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_berichtsgruppe_name (benutzer_id, name), + CONSTRAINT fk_berichtsgruppe_benutzer FOREIGN KEY (benutzer_id) + REFERENCES benutzer (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Die Filtereinstellungen stehen als JSON in einer einzigen Spalte, nicht als +-- eigene Spalten je Feld: Sie sind reine Anzeigeparameter ohne Fremdschlüssel +-- und ohne Abfragen darauf, und jede neue Filtermöglichkeit bräuchte sonst eine +-- Migration. Auswertung::normalisiere() prüft den Inhalt beim Lesen – ein Feld, +-- das dort nicht vorgesehen ist, wird verworfen. +CREATE TABLE IF NOT EXISTS berichte ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + benutzer_id INT UNSIGNED NOT NULL, + gruppe_id INT UNSIGNED NULL, + name VARCHAR(120) NOT NULL, + typ VARCHAR(30) NOT NULL DEFAULT 'summenbericht', + filter LONGTEXT NULL, + sortierung INT NOT NULL DEFAULT 0, + zuletzt_am DATETIME NULL, + 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_bericht_name (benutzer_id, name), + KEY idx_bericht_gruppe (gruppe_id), + -- Zuletzt geöffnete Berichte stehen in der Übersicht obenauf. + KEY idx_bericht_zuletzt (benutzer_id, zuletzt_am), + CONSTRAINT fk_bericht_benutzer FOREIGN KEY (benutzer_id) + REFERENCES benutzer (id) ON DELETE CASCADE, + -- Wird eine Gruppe gelöscht, bleiben ihre Berichte erhalten und rücken in + -- die Kachel „Nicht gruppiert“ – ein Bericht ist Arbeit, eine Gruppe nicht. + CONSTRAINT fk_bericht_gruppe FOREIGN KEY (gruppe_id) + REFERENCES berichtsgruppen (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Keine neuen Einstellungen: Zeitraum, Zeilen-Summe und Einrückung sind keine +-- Programmvorgaben, sondern gehören zum einzelnen Bericht und stehen deshalb in +-- dessen Filter-JSON. Im Einstellungsdialog wären sie ein Wert, den niemand +-- bedient. diff --git a/public/index.php b/public/index.php index be62498..ba7be2c 100644 --- a/public/index.php +++ b/public/index.php @@ -10,6 +10,7 @@ require dirname(__DIR__) . '/vendor/autoload.php'; use Finanzmanager\Auth; use Finanzmanager\Config; +use Finanzmanager\Controllers\AuswertungenController; use Finanzmanager\Controllers\BuchungenController; use Finanzmanager\Controllers\BudgetController; use Finanzmanager\Controllers\CockpitController; @@ -134,6 +135,26 @@ $router->get('/planung/monatsabschluss/{id}', $router->post('/planung/monatsabschluss/{id}/wiedereroeffnen', fn(array $p) => (new MonatsabschlussController())->wiedereroeffnen($p)); +// ── Auswertungen: Berichte, Drilldown, CSV ────────────────────────────────── +$router->get('/auswertungen', fn() => (new AuswertungenController())->index()); + +// Die festen Pfade stehen vor „/auswertungen/bericht/{id}“, sonst schluckt das +// Muster auch „/auswertungen/bericht/speichern“. +$router->get('/auswertungen/csv', fn() => (new AuswertungenController())->csv()); +$router->get('/auswertungen/buchungen', fn() => (new AuswertungenController())->drilldown()); +$router->get('/auswertungen/bericht', fn() => (new AuswertungenController())->bericht()); +$router->post('/auswertungen/bericht/speichern', fn() => (new AuswertungenController())->speichern()); +$router->get('/auswertungen/bericht/{id}', fn(array $p) => (new AuswertungenController())->gespeichert($p)); +$router->post('/auswertungen/bericht/{id}/loeschen', + fn(array $p) => (new AuswertungenController())->loeschen($p)); + +$router->get('/auswertungen/gruppen', fn() => (new AuswertungenController())->gruppen()); +$router->post('/auswertungen/gruppen/neu', fn() => (new AuswertungenController())->gruppeAnlegen()); +$router->post('/auswertungen/gruppen/{id}', + fn(array $p) => (new AuswertungenController())->gruppeSpeichern($p)); +$router->post('/auswertungen/gruppen/{id}/loeschen', + fn(array $p) => (new AuswertungenController())->gruppeLoeschen($p)); + $router->get('/verwaltung/import', fn() => (new ImportController())->index()); $router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen()); $router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren()); diff --git a/public/static/app.css b/public/static/app.css index 3599ee2..d4ed617 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1030,6 +1030,217 @@ body.schlicht { color: var(--text); } +/* ── Auswertungen ────────────────────────────────────────────────────────── */ + +/* Aufklappmenü ohne JavaScript:
in der Aktionsleiste. Ein eigenes + Menü mit Skript wäre mehr Code für dasselbe Verhalten – und die + Content-Security-Policy verbietet Inline-Handler ohnehin. */ +.menue { position: relative; display: inline-block; } + +.menue > summary { + list-style: none; + user-select: none; +} + +.menue > summary::-webkit-details-marker { display: none; } + +.menueliste { + position: absolute; + z-index: 20; + top: calc(100% + .3rem); + left: 0; + min-width: 14rem; + padding: .35rem; + background: var(--flaeche); + border: 1px solid var(--rand-kraeftig); + border-radius: var(--radius); + box-shadow: var(--schatten); +} + +.menueliste.breit { min-width: 22rem; padding: .9rem; } + +.menueliste > a { + display: block; + padding: .4rem .55rem; + border-radius: 4px; + color: var(--text); + font-size: .92rem; +} + +.menueliste > a:hover { background: var(--flaeche-gedaempft); text-decoration: none; } + +.speichernform { + display: flex; + flex-direction: column; + gap: .2rem; +} + +.speichernform label { font-size: .88rem; font-weight: 500; margin-top: .5rem; } + +.speichernform input[type="text"], +.speichernform select { + font: inherit; + font-size: .92rem; + padding: .4rem .5rem; + border: 1px solid var(--rand-kraeftig); + border-radius: 4px; + background: var(--flaeche); + color: var(--text); + width: 100%; +} + +.speichernform .knopfleiste { margin-top: .9rem; } + +/* Die Berichtsliste einer Gruppenkachel. */ +.berichtsliste { + list-style: none; + margin: .3rem 0 0; + padding: 0; +} + +.berichtsliste li { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: .5rem; + padding: .25rem 0; + border-bottom: 1px solid var(--rand); +} + +.berichtsliste li:last-child { border-bottom: none; } + +.berichtssymbol { color: var(--text-gedaempft); margin-right: .35rem; } + +.berichtstyp { + color: var(--text-gedaempft); + font-size: .82rem; + white-space: nowrap; +} + +/* Die Filterleiste des Berichts steht mehrzeilig – anders als die einzeilige + des Kontoblatts, von der sie das Aussehen erbt. */ +.berichtsfilter { + flex-direction: column; + align-items: stretch; + gap: .6rem; +} + +.filterzeile { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: .5rem .9rem; +} + +.filterzeile > label { + display: flex; + flex-direction: column; + gap: .15rem; + font-size: .82rem; + color: var(--text-gedaempft); +} + +.filterzeile label.ankreuz { + flex-direction: row; + align-items: center; + gap: .35rem; + padding-bottom: .35rem; +} + +.filterzeile select[multiple] { + min-width: 13rem; + max-width: 18rem; + font-size: .88rem; + /* Vorformatierter Text: Nur so bleibt die Einrückung des Kategoriebaums in + den Optionen stehen – der Browser faltet Leerzeichen sonst zusammen. */ + white-space: pre; +} + +.einschraenkung > summary { + cursor: pointer; + font-size: .85rem; + color: var(--text-gedaempft); + padding: .2rem 0; +} + +.einschraenkung[open] > summary { margin-bottom: .5rem; } + +.hinweis.klein { font-size: .82rem; margin-top: .6rem; } + +.berichtskopf { + text-align: center; + margin: 1.4rem 0 .8rem; +} + +.berichtskopf h2 { margin: 0; font-size: 1.15rem; } + +.berichtskopf p { + margin: .15rem 0 0; + color: var(--text-gedaempft); + font-size: .88rem; +} + +table.bericht { font-size: .9rem; } +table.bericht td { padding: .28rem .7rem; } + +/* Abschnittsüberschrift „Einnahmen“ / „Ausgaben“. */ +table.bericht tr.abschnittszeile th { + text-align: left; + padding-top: .8rem; + background: var(--flaeche-gedaempft); + font-size: .95rem; +} + +/* „Gesamt 400 - Leben“ – die Summe eines Kategorieastes. */ +table.bericht tr.astsumme td { + font-weight: 600; + background: color-mix(in srgb, var(--akzent) 6%, transparent); +} + +table.bericht tr.ohnezuordnung td { color: var(--text-gedaempft); font-style: italic; } + +/* Zeilen- und Wertlinks führen in den Drilldown. Sie sollen die Tabelle nicht + blau einfärben – erst beim Überfahren zeigt sich, dass es weitergeht. */ +table.bericht .zeilenlink, +table.bericht .wertlink { + color: inherit; +} + +table.bericht .zeilenlink:hover, +table.bericht .wertlink:hover { + color: var(--akzent); + text-decoration: underline; +} + +/* Zeilenweise Formulare: Name, Sortierung und Knopf nebeneinander. */ +.zeilenform { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .5rem; + margin: 0; +} + +.zeilenform label { + display: flex; + align-items: center; + gap: .35rem; + font-size: .88rem; +} + +.zeilenform input[type="text"], +.zeilenform input[type="number"] { + font: inherit; + font-size: .92rem; + padding: .35rem .5rem; + border: 1px solid var(--rand-kraeftig); + border-radius: 4px; + background: var(--flaeche); + color: var(--text); +} + +.zeilenform input.schmal { width: 5rem; } + /* ── Schmale Fenster ─────────────────────────────────────────────────────── */ @media (max-width: 820px) { diff --git a/src/Controllers/AuswertungenController.php b/src/Controllers/AuswertungenController.php new file mode 100644 index 0000000..8f2c359 --- /dev/null +++ b/src/Controllers/AuswertungenController.php @@ -0,0 +1,297 @@ +verlangeAnmeldung(); + + $this->render('auswertungen/index.php', [ + 'titel' => 'Auswertungen', + 'gruppen' => Bericht::nachGruppe(), + 'letzte' => Bericht::letzte(), + 'typen' => Auswertung::TYPEN, + 'anzahl' => Bericht::anzahl(), + ]); + } + + /** Ein Bericht aus einer Standardvorlage – die Filter stehen in der Adresse. */ + public function bericht(): void + { + $this->verlangeAnmeldung(); + $this->zeige(null, $this->filterAusQuery()); + } + + /** Ein gespeicherter Bericht. */ + public function gespeichert(array $params): void + { + $this->verlangeAnmeldung(); + + $gespeichert = Bericht::find((int)$params['id']); + if ($gespeichert === null) { + View::redirect('/auswertungen', 'Diesen Bericht gibt es nicht.', 'fehler'); + } + + Bericht::benutzt((int)$gespeichert['id']); + + $this->zeige($gespeichert, $this->filterFuer($gespeichert)); + } + + /** + * „Speichern“ überschreibt den geöffneten Bericht, „Speichern unter“ legt + * unter neuem Namen an. Beides landet hier – der Unterschied steckt allein + * darin, ob eine Id mitkommt. + */ + public function speichern(): void + { + $this->verlangeAnmeldung(); + + $filter = Auswertung::normalisiere($_POST); + $name = $this->post('name'); + $gruppe = (int)$this->post('gruppe_id'); + $id = (int)$this->post('bericht_id'); + + // Eine neue Gruppe darf direkt im Speichern-Dialog entstehen; wer sie + // erst anlegen müsste, verlöre den eingestellten Filter. + $neueGruppe = $this->post('neue_gruppe'); + if ($neueGruppe !== '') { + try { + $gruppe = Berichtsgruppe::findenOderAnlegen($neueGruppe); + } catch (\Throwable $e) { + View::redirect('/auswertungen', $e->getMessage(), 'fehler'); + } + } + + try { + if ($id > 0 && Bericht::find($id) !== null) { + Bericht::speichern($id, $name, $filter, $gruppe); + } else { + $id = Bericht::anlegen($name, $filter, $gruppe); + } + } catch (\Throwable $e) { + View::redirect( + '/auswertungen/bericht?' . $this->alsQuery($filter), + $e->getMessage(), + 'fehler' + ); + } + + View::redirect('/auswertungen/bericht/' . $id, 'Bericht „' . $name . '“ gespeichert.'); + } + + public function loeschen(array $params): void + { + $this->verlangeAnmeldung(); + + $bericht = Bericht::find((int)$params['id']); + if ($bericht === null) { + View::redirect('/auswertungen', 'Diesen Bericht gibt es nicht.', 'fehler'); + } + + Bericht::loeschen((int)$bericht['id']); + View::redirect('/auswertungen', 'Bericht „' . $bericht['name'] . '“ gelöscht.'); + } + + /** Die Buchungen hinter einer Berichtszeile. */ + public function drilldown(): void + { + $this->verlangeAnmeldung(); + + $filter = $this->filterAusQuery(); + + $zeile = Auswertung::zeilenbuchungen( + $filter, + $this->query('zeile'), + $this->query('spalte'), + $this->query('ast') === '1' + ); + + // Nicht „daten“ nennen: View::render() extrahiert mit EXTR_SKIP, und + // eine gleichnamige lokale Variable dort bliebe unangetastet – die View + // bekäme dann das gesamte Datenarray statt dieses einen Eintrags. + $this->render('auswertungen/drilldown.php', [ + 'titel' => $zeile['name'], + 'zeile' => $zeile, + 'zurueck' => $this->berichtsadresse($filter, $this->query('bericht_id')), + ]); + } + + /** Derselbe Bericht als CSV-Datei. */ + public function csv(): void + { + $this->verlangeAnmeldung(); + + $filter = $this->filterAusQuery(); + $bericht = Auswertung::bericht($filter); + + $id = (int)$this->query('bericht_id'); + if ($id > 0) { + $gespeichert = Bericht::find($id); + if ($gespeichert !== null) { + $bericht['name'] = (string)$gespeichert['name']; + } + } + + $inhalt = BerichtExport::csv($bericht); + + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . BerichtExport::dateiname($bericht) . '"'); + header('Content-Length: ' . strlen($inhalt)); + echo $inhalt; + } + + // ── Berichtsgruppen ────────────────────────────────────────────────────── + + public function gruppen(): void + { + $this->verlangeAnmeldung(); + + $this->render('auswertungen/gruppen.php', [ + 'titel' => 'Berichtsgruppen', + 'gruppen' => Bericht::nachGruppe(), + ]); + } + + public function gruppeAnlegen(): void + { + $this->verlangeAnmeldung(); + + try { + Berichtsgruppe::anlegen($this->post('name'), (int)$this->post('sortierung')); + } catch (\Throwable $e) { + View::redirect('/auswertungen/gruppen', $e->getMessage(), 'fehler'); + } + + View::redirect('/auswertungen/gruppen', 'Berichtsgruppe angelegt.'); + } + + public function gruppeSpeichern(array $params): void + { + $this->verlangeAnmeldung(); + + try { + Berichtsgruppe::speichern((int)$params['id'], $this->post('name'), (int)$this->post('sortierung')); + } catch (\Throwable $e) { + View::redirect('/auswertungen/gruppen', $e->getMessage(), 'fehler'); + } + + View::redirect('/auswertungen/gruppen', 'Berichtsgruppe gespeichert.'); + } + + public function gruppeLoeschen(array $params): void + { + $this->verlangeAnmeldung(); + + Berichtsgruppe::loeschen((int)$params['id']); + View::redirect('/auswertungen/gruppen', 'Berichtsgruppe gelöscht; ihre Berichte bleiben erhalten.'); + } + + // ── Innereien ──────────────────────────────────────────────────────────── + + /** + * Stellt die Berichtsansicht dar. + * + * @param array|null $gespeichert + * @param array $filter + */ + private function zeige(?array $gespeichert, array $filter): void + { + $bericht = Auswertung::bericht($filter); + + if ($gespeichert !== null) { + $bericht['name'] = (string)$gespeichert['name']; + } + + $this->render('auswertungen/bericht.php', [ + 'titel' => ($gespeichert['name'] ?? $bericht['typname']) + . ($gespeichert === null ? '' : ' – ' . $bericht['typname']), + 'bericht' => $bericht, + 'gespeichert' => $gespeichert, + 'gruppen' => Berichtsgruppe::alle(), + 'konten' => Konto::alle(), + 'kategorien' => Kategorie::baumFlach(), + 'klassen' => Klasse::alle(), + 'vorgaben' => Zeitraum::VORGABEN, + 'aufteilungen' => Zeitraum::AUFTEILUNGEN, + 'zeilensummen' => Auswertung::ZEILENSUMMEN, + 'typen' => Auswertung::TYPEN, + 'query' => $this->alsQuery($bericht['filter']), + ]); + } + + /** + * Der Filter für einen gespeicherten Bericht: aus der Datenbank, sofern die + * Filterleiste nicht gerade etwas anderes gesendet hat. Das Formular setzt + * dazu ein Feld `angepasst` – ohne dieses Kennzeichen wäre nicht zu + * unterscheiden, ob ein fehlender Parameter „nicht gesendet“ oder + * „abgewählt“ bedeutet. + * + * @param array $gespeichert + * @return array + */ + private function filterFuer(array $gespeichert): array + { + return $this->query('angepasst') === '1' + ? $this->filterAusQuery() + : Bericht::filter($gespeichert); + } + + /** @return array */ + private function filterAusQuery(): array + { + return Auswertung::normalisiere($_GET); + } + + /** + * Der Filter als Query-String – für den CSV-Link, den Drilldown und den Weg + * zurück. + * + * @param array $filter + */ + private function alsQuery(array $filter): string + { + return http_build_query(array_map( + static fn(mixed $wert): mixed => is_bool($wert) ? ($wert ? '1' : '0') : $wert, + $filter + ['angepasst' => '1'] + )); + } + + /** + * Zurück zum Bericht: zum gespeicherten, wenn es einen gibt, sonst zur + * Vorlage mit denselben Filtern. + * + * @param array $filter + */ + private function berichtsadresse(array $filter, string $id): string + { + $query = $this->alsQuery($filter); + + return (int)$id > 0 + ? '/auswertungen/bericht/' . (int)$id . '?' . $query + : '/auswertungen/bericht?' . $query; + } +} diff --git a/src/Controllers/PlatzhalterController.php b/src/Controllers/PlatzhalterController.php index 314f03e..5f66366 100644 --- a/src/Controllers/PlatzhalterController.php +++ b/src/Controllers/PlatzhalterController.php @@ -17,11 +17,6 @@ class PlatzhalterController extends BaseController 'phase' => 'Phase 7', 'text' => 'Überweisungen und Terminüberweisungen, abgesendet über FinTS.', ], - 'auswertungen' => [ - 'titel' => 'Auswertungen', - 'phase' => 'Phase 4', - 'text' => 'Summen-, Buchungs-, Vergleichs- und Budgetbericht samt Grafiken.', - ], 'wertpapiere' => [ 'titel' => 'Wertpapiere', 'phase' => 'Phase 6', diff --git a/src/Models/Bericht.php b/src/Models/Bericht.php new file mode 100644 index 0000000..f5fdc3e --- /dev/null +++ b/src/Models/Bericht.php @@ -0,0 +1,190 @@ +> */ + public static function alle(): array + { + return Database::fetchAll( + 'SELECT * FROM berichte WHERE benutzer_id = ? ORDER BY sortierung, name', + [Mandant::id()] + ); + } + + /** + * Alle Berichte nach Gruppe, in der Reihenfolge der Übersicht. Berichte ohne + * Gruppe stehen unter dem Schlüssel 0. + * + * @return array|null, berichte: list>}> + */ + public static function nachGruppe(): array + { + $gruppen = []; + foreach (Berichtsgruppe::alle() as $gruppe) { + $gruppen[(int)$gruppe['id']] = ['gruppe' => $gruppe, 'berichte' => []]; + } + + foreach (self::alle() as $bericht) { + $id = $bericht['gruppe_id'] === null ? 0 : (int)$bericht['gruppe_id']; + + // Eine gelöschte Gruppe kann noch in keinem Eintrag stehen; der + // Bericht landet dann bei „Nicht gruppiert“ statt zu verschwinden. + if (!isset($gruppen[$id])) { + $gruppen[$id] = ['gruppe' => null, 'berichte' => []]; + } + $gruppen[$id]['berichte'][] = $bericht; + } + + // Leere Gruppen bleiben stehen – sie sind angelegt worden, um befüllt zu + // werden, und ohne Kachel gäbe es keinen Ort, an den ein Bericht könnte. + return $gruppen; + } + + public static function find(int $id): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM berichte 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 berichte WHERE name = ? AND benutzer_id = ?', + [trim($name), Mandant::id()] + ); + return $zeile === false ? null : $zeile; + } + + /** + * Die zuletzt geöffneten Berichte – das Menü „Letzte Berichte“. + * + * @return list> + */ + public static function letzte(int $anzahl = 5): array + { + return Database::fetchAll( + 'SELECT * FROM berichte WHERE benutzer_id = ? AND zuletzt_am IS NOT NULL + ORDER BY zuletzt_am DESC LIMIT ' . max(1, $anzahl), + [Mandant::id()] + ); + } + + /** @param array $filter */ + public static function anlegen(string $name, array $filter, ?int $gruppeId = null): int + { + $name = self::pruefeName($name); + $filter = Auswertung::normalisiere($filter); + + if (self::findeNachName($name) !== null) { + throw new \RuntimeException('Den Bericht „' . $name . '“ gibt es bereits.'); + } + + Database::execute( + 'INSERT INTO berichte (benutzer_id, gruppe_id, name, typ, filter) VALUES (?, ?, ?, ?, ?)', + [Mandant::id(), self::gruppe($gruppeId), $name, $filter['typ'], self::alsJson($filter)] + ); + + return Database::lastInsertId(); + } + + /** @param array $filter */ + public static function speichern(int $id, string $name, array $filter, ?int $gruppeId = null): void + { + $name = self::pruefeName($name); + $filter = Auswertung::normalisiere($filter); + + $andere = self::findeNachName($name); + if ($andere !== null && (int)$andere['id'] !== $id) { + throw new \RuntimeException('Den Bericht „' . $name . '“ gibt es bereits.'); + } + + Database::execute( + 'UPDATE berichte SET gruppe_id = ?, name = ?, typ = ?, filter = ? + WHERE id = ? AND benutzer_id = ?', + [self::gruppe($gruppeId), $name, $filter['typ'], self::alsJson($filter), $id, Mandant::id()] + ); + } + + public static function loeschen(int $id): void + { + Database::execute('DELETE FROM berichte WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); + } + + /** Merkt sich das Öffnen für die Liste „Letzte Berichte“. */ + public static function benutzt(int $id): void + { + Database::execute( + 'UPDATE berichte SET zuletzt_am = NOW() WHERE id = ? AND benutzer_id = ?', + [$id, Mandant::id()] + ); + } + + /** + * Der geprüfte Filter eines gespeicherten Berichts. + * + * @param array $bericht + * @return array + */ + public static function filter(array $bericht): array + { + $roh = json_decode((string)($bericht['filter'] ?? ''), true); + + return Auswertung::normalisiere( + (is_array($roh) ? $roh : []) + ['typ' => (string)($bericht['typ'] ?? '')] + ); + } + + public static function anzahl(): int + { + return (int)Database::fetchValue( + 'SELECT COUNT(*) FROM berichte WHERE benutzer_id = ?', + [Mandant::id()], + 0 + ); + } + + /** Prüft, dass die Gruppe dem Benutzer gehört – sonst gilt „nicht gruppiert“. */ + private static function gruppe(?int $gruppeId): ?int + { + if ($gruppeId === null || $gruppeId === 0) { + return null; + } + return Berichtsgruppe::find($gruppeId) === null ? null : $gruppeId; + } + + /** @param array $filter */ + private static function alsJson(array $filter): string + { + return (string)json_encode($filter, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + private static function pruefeName(string $name): string + { + $name = trim($name); + if ($name === '') { + throw new \InvalidArgumentException('Der Bericht braucht einen Namen.'); + } + return mb_substr($name, 0, 120); + } +} diff --git a/src/Models/Berichtsgruppe.php b/src/Models/Berichtsgruppe.php new file mode 100644 index 0000000..6b1ddd3 --- /dev/null +++ b/src/Models/Berichtsgruppe.php @@ -0,0 +1,110 @@ +> */ + public static function alle(): array + { + return Database::fetchAll( + 'SELECT * FROM berichtsgruppen WHERE benutzer_id = ? ORDER BY sortierung, name', + [Mandant::id()] + ); + } + + public static function find(int $id): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM berichtsgruppen 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 berichtsgruppen WHERE name = ? AND benutzer_id = ?', + [trim($name), Mandant::id()] + ); + return $zeile === false ? null : $zeile; + } + + public static function anlegen(string $name, int $sortierung = 0): int + { + $name = self::pruefeName($name); + if (self::findeNachName($name) !== null) { + throw new \RuntimeException('Die Berichtsgruppe „' . $name . '“ gibt es bereits.'); + } + + Database::execute( + 'INSERT INTO berichtsgruppen (benutzer_id, name, sortierung) VALUES (?, ?, ?)', + [Mandant::id(), $name, $sortierung] + ); + + return Database::lastInsertId(); + } + + public static function findenOderAnlegen(string $name): int + { + $vorhanden = self::findeNachName($name); + return $vorhanden === null ? self::anlegen($name) : (int)$vorhanden['id']; + } + + public static function speichern(int $id, string $name, int $sortierung): void + { + $name = self::pruefeName($name); + + $andere = self::findeNachName($name); + if ($andere !== null && (int)$andere['id'] !== $id) { + throw new \RuntimeException('Die Berichtsgruppe „' . $name . '“ gibt es bereits.'); + } + + Database::execute( + 'UPDATE berichtsgruppen SET name = ?, sortierung = ? WHERE id = ? AND benutzer_id = ?', + [$name, $sortierung, $id, Mandant::id()] + ); + } + + /** + * Die Gruppe verschwindet, ihre Berichte bleiben: Der Fremdschlüssel setzt + * gruppe_id auf NULL, sie rücken damit unter „Nicht gruppiert“. + */ + public static function loeschen(int $id): void + { + Database::execute( + 'DELETE FROM berichtsgruppen WHERE id = ? AND benutzer_id = ?', + [$id, Mandant::id()] + ); + } + + public static function anzahl(): int + { + return (int)Database::fetchValue( + 'SELECT COUNT(*) FROM berichtsgruppen WHERE benutzer_id = ?', + [Mandant::id()], + 0 + ); + } + + private static function pruefeName(string $name): string + { + $name = trim($name); + if ($name === '') { + throw new \InvalidArgumentException('Die Berichtsgruppe braucht einen Namen.'); + } + return mb_substr($name, 0, 120); + } +} diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index d589c70..1a96dc2 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -302,6 +302,25 @@ class Buchung return $bewegungen; } + /** + * Frühestes und spätestes Buchungsdatum – die Grenzen für den Zeitraum + * „Gesamter Zeitraum“ in den Auswertungen. Ohne Buchungen beides null. + * + * @return array{von: ?string, bis: ?string} + */ + public static function datumsgrenzen(): array + { + $zeile = Database::fetchOne( + 'SELECT MIN(datum) AS von, MAX(datum) AS bis FROM buchungen WHERE benutzer_id = ?', + [Mandant::id()] + ); + + return [ + 'von' => ($zeile['von'] ?? null) === null ? null : (string)$zeile['von'], + 'bis' => ($zeile['bis'] ?? null) === null ? null : (string)$zeile['bis'], + ]; + } + /** Wie viele Buchungen hat das Konto (beide Sichten)? */ public static function anzahlFuerKonto(int $kontoId): int { diff --git a/src/Models/Budget.php b/src/Models/Budget.php index 7374c0c..bf90580 100644 --- a/src/Models/Budget.php +++ b/src/Models/Budget.php @@ -56,6 +56,20 @@ class Budget return $zeile === false ? null : $zeile; } + /** + * Das aktive Budget eines bestimmten Jahres, ersatzweise irgendeines aus + * diesem Jahr. Der Budgetbericht braucht das: Ein Zeitraum kann über den + * Jahreswechsel gehen, und dann gilt je Jahr dessen eigene Planung. + */ + public static function aktivesFuerJahr(int $jahr): ?array + { + $zeile = Database::fetchOne( + 'SELECT * FROM budgets WHERE benutzer_id = ? AND jahr = ? ORDER BY aktiv DESC, name LIMIT 1', + [Mandant::id(), $jahr] + ); + return $zeile === false ? null : $zeile; + } + public static function anlegen(string $name, int $jahr, string $beschreibung = '', bool $aktiv = false): int { $name = trim($name); diff --git a/src/Services/Auswertung.php b/src/Services/Auswertung.php new file mode 100644 index 0000000..b193b5f --- /dev/null +++ b/src/Services/Auswertung.php @@ -0,0 +1,1225 @@ + 'Summenbericht', + 'buchungsbericht' => 'Buchungsbericht', + 'vergleichsbericht' => 'Vergleichsbericht', + 'kontostandsbericht' => 'Kontostandsbericht', + 'budgetbericht' => 'Budgetbericht', + ]; + + /** Wonach die Zeilen zusammengefasst werden. */ + public const ZEILENSUMMEN = [ + 'kategorie' => 'Kategorie', + 'klasse' => 'Klasse', + 'konto' => 'Konto', + 'empfaenger' => 'Empfänger', + ]; + + public const OHNE_KATEGORIE = 'Nicht kategorisiert'; + public const OHNE_KLASSE = 'Ohne Klasse'; + public const OHNE_EMPFAENGER = 'Ohne Empfänger'; + + /** + * Bringt einen Filter in gültige Form – aus dem Formular, aus der Adresse + * oder aus dem JSON eines gespeicherten Berichts. Unbekannte Felder fallen + * weg, unbekannte Werte fallen auf die Vorgabe zurück. Kein + * Datenbankzugriff: `Bericht::filter()` ruft das beim Lesen jeder Zeile. + * + * @param array $roh + * @return array + */ + public static function normalisiere(array $roh): array + { + $typ = (string)($roh['typ'] ?? ''); + if (!array_key_exists($typ, self::TYPEN)) { + $typ = 'summenbericht'; + } + + // Zwei Berichte bestimmen ihre Zeilen selbst: Der Kontostandsbericht + // führt Konten, der Budgetbericht Kategorien. Eine andere Wahl wäre dort + // nicht auswertbar, deshalb wird sie gar nicht erst übernommen. + $zeilensumme = (string)($roh['zeilensumme'] ?? ''); + $zeilensumme = match ($typ) { + 'kontostandsbericht' => 'konto', + 'budgetbericht' => 'kategorie', + default => array_key_exists($zeilensumme, self::ZEILENSUMMEN) ? $zeilensumme : 'kategorie', + }; + + $aufteilung = (string)($roh['aufteilung'] ?? ''); + if (!array_key_exists($aufteilung, Zeitraum::AUFTEILUNGEN)) { + $aufteilung = 'keine'; + } + + $vorgabe = (string)($roh['zeitraum'] ?? ''); + if (!array_key_exists($vorgabe, Zeitraum::VORGABEN)) { + $vorgabe = 'aktuelles_jahr'; + } + + return [ + 'typ' => $typ, + 'zeitraum' => $vorgabe, + 'von' => self::datum($roh['von'] ?? ''), + 'bis' => self::datum($roh['bis'] ?? ''), + 'zeilensumme' => $zeilensumme, + 'aufteilung' => $aufteilung, + 'einrueckung' => self::bool($roh['einrueckung'] ?? true), + 'umbuchungen' => self::bool($roh['umbuchungen'] ?? false), + 'konten' => self::ids($roh['konten'] ?? []), + 'kategorien' => self::ids($roh['kategorien'] ?? []), + 'klassen' => self::ids($roh['klassen'] ?? []), + 'suche' => mb_substr(trim((string)($roh['suche'] ?? '')), 0, 120), + ]; + } + + /** + * Der fertige Bericht zu einem Filter. + * + * @param array $filter roh oder normalisiert + * @return array + */ + public static function bericht(array $filter, ?string $heute = null): array + { + $filter = self::normalisiere($filter); + $zeitraum = self::zeitraum($filter, $heute); + + $filter['von'] = $zeitraum['von']; + $filter['bis'] = $zeitraum['bis']; + + $bericht = match ($filter['typ']) { + 'buchungsbericht' => self::buchungsbericht($filter), + 'vergleichsbericht' => self::vergleichsbericht($filter), + 'kontostandsbericht' => self::kontostandsbericht($filter), + 'budgetbericht' => self::budgetbericht($filter), + default => self::summenbericht($filter), + }; + + return $bericht + [ + 'typ' => $filter['typ'], + 'typname' => self::TYPEN[$filter['typ']], + 'filter' => $filter, + 'zeitraum' => $zeitraum, + 'beschriftung' => Zeitraum::beschriftung($zeitraum['von'], $zeitraum['bis']), + ]; + } + + /** + * Löst die Zeitraumvorgabe auf. Getrennt von normalisiere(), weil + * „Gesamter Zeitraum“ die Datumsgrenzen der Buchungen braucht – und + * normalisiere() beim Lesen jedes gespeicherten Berichts läuft. + * + * @param array $filter + * @return array{vorgabe: string, von: string, bis: string} + */ + public static function zeitraum(array $filter, ?string $heute = null): array + { + $vorgabe = (string)($filter['zeitraum'] ?? 'aktuelles_jahr'); + + return Zeitraum::berechne( + $vorgabe, + (string)($filter['von'] ?? ''), + (string)($filter['bis'] ?? ''), + $heute, + $vorgabe === 'alles' ? Buchung::datumsgrenzen() : ['von' => null, 'bis' => null] + ); + } + + // ── Summenbericht ──────────────────────────────────────────────────────── + + /** + * Summen je Zeilendimension und Spalte, getrennt nach Einnahmen und + * Ausgaben, mit Gesamtzeilen. + * + * @param array $filter + * @return array + */ + private static function summenbericht(array $filter): array + { + $spalten = Zeitraum::spalten((string)$filter['aufteilung'], $filter['von'], $filter['bis']); + $roh = self::verteile(self::bewegungen($filter), $filter, $spalten); + + $abschnitte = []; + foreach (['einnahme' => 'Einnahmen', 'ausgabe' => 'Ausgaben'] as $art => $name) { + $abschnitte[] = self::abschnitt($art, $name, $roh[$art] ?? [], $filter, $spalten); + } + + // Die Schlusszeile rechnet mit den echten Vorzeichen, nicht mit den fürs + // Auge gedrehten: Einnahmen plus (negative) Ausgaben. + $gesamt = self::leereReihe($spalten); + foreach ($roh as $art => $zeilen) { + foreach ($zeilen as $werte) { + foreach ($werte as $schluessel => $betrag) { + $gesamt[$schluessel] += $betrag; + } + } + } + + return [ + 'darstellung' => 'summen', + 'spalten' => $spalten, + 'abschnitte' => $abschnitte, + 'gesamt' => [ + 'name' => 'Gesamt Einnahmen − Ausgaben', + 'werte' => self::gerundet($gesamt), + 'summe' => round(array_sum($gesamt), 2), + ], + 'leer' => $abschnitte[0]['zeilen'] === [] && $abschnitte[1]['zeilen'] === [], + ]; + } + + /** + * Ein Abschnitt (Einnahmen oder Ausgaben) mit seinen Zeilen. Bei der + * Zeilendimension *Kategorie* entsteht der eingerückte Baum samt + * „Gesamt …“-Zeilen, sonst eine flache, alphabetisch sortierte Liste. + * + * @param array> $werte + * @param array $filter + * @param list> $spalten + * @return array + */ + private static function abschnitt(string $art, string $name, array $werte, array $filter, array $spalten): array + { + // Ausgaben stehen im Bericht ohne Minus – so hält es der FinanzManager, + // und die Überschrift sagt ja bereits, worum es geht. + $faktor = $art === 'ausgabe' ? -1.0 : 1.0; + + $zeilen = $filter['zeilensumme'] === 'kategorie' + ? self::kategoriezeilen($art, $werte, $filter, $spalten, $faktor) + : self::flacheZeilen($werte, $filter, $spalten, $faktor); + + $summe = self::leereReihe($spalten); + foreach ($werte as $reihe) { + foreach ($reihe as $schluessel => $betrag) { + $summe[$schluessel] += $betrag * $faktor; + } + } + + return [ + 'schluessel' => $art, + 'name' => $name, + 'zeilen' => $zeilen, + 'werte' => self::gerundet($summe), + 'summe' => round(array_sum($summe), 2), + 'vorzeichen' => $faktor, + ]; + } + + /** + * Der Kategoriebaum als Zeilenfolge. Jede Kategorie zeigt ihre *eigenen* + * Werte; Kategorien mit Nachfahren bekommen dahinter eine Zeile + * „Gesamt “ mit der Summe des Astes. Genau so sieht der Summenbericht + * des FinanzManagers aus. + * + * @param array> $werte + * @param array $filter + * @param list> $spalten + * @return list> + */ + private static function kategoriezeilen(string $art, array $werte, array $filter, array $spalten, float $faktor): array + { + $baum = Kategorie::baumFlach($art); + + // Astsummen von unten nach oben: Erst die eigenen Werte, dann trägt + // jede Kategorie ihre Summe an die Elternkategorie hoch. + $ast = []; + foreach ($baum as $kategorie) { + $ast[(int)$kategorie['id']] = $werte[(int)$kategorie['id']] ?? []; + } + foreach (array_reverse($baum) as $kategorie) { + $eltern = $kategorie['eltern_id'] === null ? 0 : (int)$kategorie['eltern_id']; + if (!isset($ast[$eltern])) { + continue; + } + foreach ($ast[(int)$kategorie['id']] as $schluessel => $betrag) { + $ast[$eltern][$schluessel] = ($ast[$eltern][$schluessel] ?? 0.0) + $betrag; + } + } + + if (!$filter['einrueckung']) { + // Ohne Einrückung zählt jede Kategorie nur sich selbst, dafür mit + // vollem Pfad – sonst stünden gleichnamige Unterkategorien + // ununterscheidbar untereinander. + $flach = []; + foreach ($baum as $kategorie) { + $id = (int)$kategorie['id']; + if (self::istLeer($werte[$id] ?? [])) { + continue; + } + $flach[] = self::zeile( + 'kategorie:' . $id, + (string)$kategorie['pfad'], + $werte[$id], + $spalten, + $faktor + ); + } + return array_merge($flach, self::ohneZuordnung($werte, $spalten, $faktor, self::OHNE_KATEGORIE)); + } + + $zeilen = []; + $offen = []; + + foreach ($baum as $kategorie) { + $id = (int)$kategorie['id']; + $ebene = (int)$kategorie['ebene']; + + $zeilen = array_merge($zeilen, self::schliesseAeste($offen, $ebene, $ast, $spalten, $faktor)); + + // Ein Ast ohne jede Buchung wird gar nicht erst aufgeschlagen. + if (self::istLeer($ast[$id])) { + continue; + } + + $zeilen[] = self::zeile( + 'kategorie:' . $id, + (string)$kategorie['name'] . ($kategorie['hat_kinder'] ? Kategorie::TRENNER : ''), + $werte[$id] ?? [], + $spalten, + $faktor, + ['ebene' => $ebene, 'kopf' => (bool)$kategorie['hat_kinder']] + ); + + if ($kategorie['hat_kinder']) { + $offen[] = ['id' => $id, 'ebene' => $ebene, 'name' => (string)$kategorie['name']]; + } + } + + $zeilen = array_merge($zeilen, self::schliesseAeste($offen, 0, $ast, $spalten, $faktor)); + + return array_merge($zeilen, self::ohneZuordnung($werte, $spalten, $faktor, self::OHNE_KATEGORIE)); + } + + /** + * Setzt die „Gesamt …“-Zeilen aller Äste, die mit der neuen Ebene enden. + * + * @param list $offen + * @param array> $ast + * @param list> $spalten + * @return list> + */ + private static function schliesseAeste(array &$offen, int $ebene, array $ast, array $spalten, float $faktor): array + { + $zeilen = []; + + while ($offen !== [] && $offen[count($offen) - 1]['ebene'] >= $ebene) { + $zweig = array_pop($offen); + $zeilen[] = self::zeile( + 'kategorie:' . $zweig['id'], + 'Gesamt ' . $zweig['name'], + $ast[$zweig['id']] ?? [], + $spalten, + $faktor, + ['ebene' => $zweig['ebene'], 'astsumme' => true] + ); + } + + return $zeilen; + } + + /** + * Klassen, Konten und Empfänger als flache Liste, alphabetisch. + * + * @param array> $werte + * @param array $filter + * @param list> $spalten + * @return list> + */ + private static function flacheZeilen(array $werte, array $filter, array $spalten, float $faktor): array + { + $namen = self::namen((string)$filter['zeilensumme']); + $zeilen = []; + + foreach ($werte as $schluessel => $reihe) { + if ($schluessel === '' || $schluessel === 0 || self::istLeer($reihe)) { + continue; + } + $zeilen[] = self::zeile( + $filter['zeilensumme'] . ':' . $schluessel, + $namen[$schluessel] ?? (string)$schluessel, + $reihe, + $spalten, + $faktor + ); + } + + usort($zeilen, static fn(array $a, array $b): int => strnatcasecmp($a['name'], $b['name'])); + + $ohne = match ((string)$filter['zeilensumme']) { + 'klasse' => self::OHNE_KLASSE, + 'empfaenger' => self::OHNE_EMPFAENGER, + default => self::OHNE_KATEGORIE, + }; + + return array_merge($zeilen, self::ohneZuordnung($werte, $spalten, $faktor, $ohne, (string)$filter['zeilensumme'])); + } + + /** + * Die Zeile für Bewegungen ohne Zuordnung. Sie steht am Ende und nie unter + * den Tisch: Geld, das keiner Kategorie zugeordnet ist, verschwände sonst + * lautlos aus der Summe. + * + * @param array> $werte + * @param list> $spalten + * @return list> + */ + private static function ohneZuordnung(array $werte, array $spalten, float $faktor, string $name, string $dimension = 'kategorie'): array + { + $reihe = $werte[0] ?? $werte[''] ?? []; + if (self::istLeer($reihe)) { + return []; + } + + return [self::zeile($dimension . ':0', $name, $reihe, $spalten, $faktor, ['ohne' => true])]; + } + + /** + * @param array $werte + * @param list> $spalten + * @param array $zusatz + * @return array + */ + private static function zeile(string $schluessel, string $name, array $werte, array $spalten, float $faktor, array $zusatz = []): array + { + $reihe = self::leereReihe($spalten); + foreach ($werte as $spalte => $betrag) { + if (array_key_exists($spalte, $reihe)) { + $reihe[$spalte] = $betrag * $faktor; + } + } + + // $zusatz zuerst: Bei „+“ auf Arrays gewinnt der linke Operand, und die + // Angaben des Aufrufers sollen die Vorgaben überschreiben. + return $zusatz + [ + 'schluessel' => $schluessel, + 'name' => $name, + 'ebene' => 0, + 'kopf' => false, + 'astsumme' => false, + 'ohne' => false, + 'werte' => self::gerundet($reihe), + 'summe' => round(array_sum($reihe), 2), + ]; + } + + // ── Buchungsbericht ────────────────────────────────────────────────────── + + /** + * Die Einzelbuchungen des Zeitraums, gruppiert nach der Zeilendimension. + * Die Beträge behalten hier ihr buchhalterisches Vorzeichen – es ist eine + * Buchungsliste, kein Summenbericht. + * + * @param array $filter + * @return array + */ + private static function buchungsbericht(array $filter): array + { + $namen = self::namen((string)$filter['zeilensumme']); + + $gruppen = []; + foreach (self::bewegungen($filter) as $zeile) { + $schluessel = self::zeilenschluessel($zeile, (string)$filter['zeilensumme']); + + $gruppen[$schluessel] ??= [ + 'schluessel' => $filter['zeilensumme'] . ':' . $schluessel, + // Empfänger sind ihr eigener Name und stehen in keiner Tabelle – + // deshalb der Schlüssel als Rückfallwert. + 'name' => $schluessel === 0 || $schluessel === '' + ? self::ohneName((string)$filter['zeilensumme']) + : ($namen[$schluessel] ?? (string)$schluessel), + 'zeilen' => [], + 'summe' => 0.0, + ]; + + $gruppen[$schluessel]['zeilen'][] = $zeile; + $gruppen[$schluessel]['summe'] += (float)$zeile['betrag']; + } + + foreach ($gruppen as &$gruppe) { + $gruppe['summe'] = round($gruppe['summe'], 2); + $gruppe['buchungen'] = self::alsBuchungsliste($gruppe['zeilen']); + unset($gruppe['zeilen']); + } + unset($gruppe); + + uasort($gruppen, static fn(array $a, array $b): int => strnatcasecmp($a['name'], $b['name'])); + + $summe = 0.0; + $anzahl = 0; + foreach ($gruppen as $gruppe) { + $summe += $gruppe['summe']; + $anzahl += count($gruppe['buchungen']); + } + + return [ + 'darstellung' => 'buchungen', + 'gruppen' => array_values($gruppen), + 'summe' => round($summe, 2), + 'anzahl' => $anzahl, + 'leer' => $gruppen === [], + ]; + } + + /** + * Der Drilldown: die Buchungen hinter einer Berichtszeile. + * + * Gefiltert wird nicht durch Übersetzen der Zeile in einen neuen Filter, + * sondern durch Nachsieben derselben Bewegungen, aus denen die Zelle + * entstanden ist. Nur so trifft der Drilldown auch das, was ein Filter gar + * nicht ausdrücken könnte – „Nicht kategorisiert“ etwa, oder eine Astsumme. + * + * @param array $filter + * @param string $zeile Zeilenschlüssel aus dem Bericht, z. B. „kategorie:12“ + * @param string $spalte Spaltenschlüssel oder '' für den ganzen Zeitraum + * @param bool $ast true bei einer „Gesamt …“-Zeile: mit Unterkategorien + * @return array + */ + public static function zeilenbuchungen( + array $filter, + string $zeile, + string $spalte = '', + bool $ast = false, + ?string $heute = null + ): array { + $filter = self::normalisiere($filter); + $zeitraum = self::zeitraum($filter, $heute); + $filter['von'] = $zeitraum['von']; + $filter['bis'] = $zeitraum['bis']; + + $spaltentitel = Zeitraum::beschriftung($filter['von'], $filter['bis']); + + if ($spalte !== '') { + foreach (Zeitraum::spalten((string)$filter['aufteilung'], $filter['von'], $filter['bis']) as $eintrag) { + if ($eintrag['schluessel'] === $spalte) { + $filter['von'] = $eintrag['von']; + $filter['bis'] = $eintrag['bis']; + $spaltentitel = $eintrag['titel']; + break; + } + } + } + + $teile = explode(':', $zeile, 2); + $dimension = array_key_exists($teile[0], self::ZEILENSUMMEN) ? $teile[0] : 'kategorie'; + $wert = $teile[1] ?? ''; + + // Bei einer Astsumme zählen die Unterkategorien mit – die Zeile zeigt ja + // deren Summe. + $astIds = $ast && $dimension === 'kategorie' && $wert !== '' && $wert !== '0' + ? array_flip(self::mitNachfahren([(int)$wert])) + : null; + + $bewegungen = array_values(array_filter( + self::bewegungen($filter), + static function (array $bewegung) use ($dimension, $wert, $astIds): bool { + $schluessel = self::zeilenschluessel($bewegung, $dimension); + + if ($astIds !== null) { + return isset($astIds[(int)$schluessel]); + } + return (string)$schluessel === $wert; + } + )); + + $liste = self::alsBuchungsliste($bewegungen); + + return [ + 'zeile' => $zeile, + 'dimension' => self::ZEILENSUMMEN[$dimension], + 'name' => self::zeilenname($dimension, $wert, $ast), + 'spaltentitel' => $spaltentitel, + 'buchungen' => $liste, + 'summe' => round(array_sum(array_column($liste, 'betrag')), 2), + 'filter' => $filter, + ]; + } + + /** Wie die angeklickte Zeile heißt – für die Überschrift des Drilldowns. */ + private static function zeilenname(string $dimension, string $wert, bool $ast): string + { + if ($wert === '' || $wert === '0') { + return self::ohneName($dimension); + } + + $namen = self::namen($dimension); + $name = $namen[$dimension === 'empfaenger' ? $wert : (int)$wert] ?? $wert; + + return $ast ? 'Gesamt ' . $name : (string)$name; + } + + /** + * Bewegungen zu anzeigefertigen Buchungszeilen, chronologisch. Eine + * Umbuchung führt keine Kategorie – dort steht das Gegenkonto in eckigen + * Klammern, genau wie im Kontoblatt. + * + * @param list> $bewegungen + * @return list> + */ + private static function alsBuchungsliste(array $bewegungen): array + { + $kontonamen = self::namen('konto'); + $kategorien = self::namen('kategorie'); + $klassen = self::namen('klasse'); + + $liste = []; + foreach ($bewegungen as $zeile) { + $liste[] = [ + 'buchung_id' => (int)$zeile['buchung_id'], + 'datum' => (string)$zeile['datum'], + 'konto' => $kontonamen[(int)$zeile['konto_id']] ?? '', + 'konto_id' => (int)$zeile['konto_id'], + 'empfaenger' => (string)$zeile['empfaenger'], + 'text' => (string)$zeile['text'], + 'kategorie' => $zeile['kategorie_id'] === null + ? ($zeile['gegenkonto_id'] === null + ? self::OHNE_KATEGORIE + : '[' . ($kontonamen[(int)$zeile['gegenkonto_id']] ?? '?') . ']') + : ($kategorien[(int)$zeile['kategorie_id']] ?? ''), + 'klasse' => $zeile['klasse_id'] === null ? '' : ($klassen[(int)$zeile['klasse_id']] ?? ''), + 'gesplittet' => $zeile['split_id'] !== null, + 'betrag' => round((float)$zeile['betrag'], 2), + ]; + } + + usort( + $liste, + static fn(array $a, array $b): int => [$a['datum'], $a['buchung_id']] <=> [$b['datum'], $b['buchung_id']] + ); + + return $liste; + } + + // ── Vergleichsbericht ──────────────────────────────────────────────────── + + /** + * Der Zeitraum gegen denselben Zeitraum des Vorjahres, mit Differenz und + * Veränderung in Prozent. Die Spalten-Aufteilung spielt hier keine Rolle: + * Die Spalten sind durch den Vergleich vorgegeben. + * + * @param array $filter + * @return array + */ + private static function vergleichsbericht(array $filter): array + { + $vorjahr = Zeitraum::vorjahr($filter['von'], $filter['bis']); + + $spalten = [ + ['schluessel' => 'jetzt', 'titel' => Zeitraum::beschriftung($filter['von'], $filter['bis']), + 'kurz' => 'Zeitraum', 'von' => $filter['von'], 'bis' => $filter['bis']], + ['schluessel' => 'vorher', 'titel' => Zeitraum::beschriftung($vorjahr['von'], $vorjahr['bis']), + 'kurz' => 'Vorjahr', 'von' => $vorjahr['von'], 'bis' => $vorjahr['bis']], + ]; + + // Beide Zeiträume in einem Rutsch holen und über das Datum der Buchung + // der richtigen Spalte zuordnen. + $jetzt = self::verteileAuf(self::bewegungen($filter), $filter, 'jetzt'); + $vorher = self::verteileAuf( + self::bewegungen(['von' => $vorjahr['von'], 'bis' => $vorjahr['bis']] + $filter), + $filter, + 'vorher' + ); + + $roh = $jetzt; + foreach ($vorher as $art => $zeilen) { + foreach ($zeilen as $schluessel => $werte) { + foreach ($werte as $spalte => $betrag) { + $roh[$art][$schluessel][$spalte] = ($roh[$art][$schluessel][$spalte] ?? 0.0) + $betrag; + } + } + } + + $abschnitte = []; + foreach (['einnahme' => 'Einnahmen', 'ausgabe' => 'Ausgaben'] as $art => $name) { + $abschnitt = self::abschnitt($art, $name, $roh[$art] ?? [], $filter, $spalten); + + $abschnitt['zeilen'] = array_map(self::mitVeraenderung(...), $abschnitt['zeilen']); + $abschnitt = self::mitVeraenderung($abschnitt); + + $abschnitte[] = $abschnitt; + } + + $gesamt = self::leereReihe($spalten); + foreach ($roh as $zeilen) { + foreach ($zeilen as $werte) { + foreach ($werte as $spalte => $betrag) { + $gesamt[$spalte] += $betrag; + } + } + } + + return [ + 'darstellung' => 'summen', + 'vergleich' => true, + 'spalten' => $spalten, + 'abschnitte' => $abschnitte, + 'gesamt' => self::mitVeraenderung([ + 'name' => 'Gesamt Einnahmen − Ausgaben', + 'werte' => self::gerundet($gesamt), + 'summe' => round(array_sum($gesamt), 2), + ]), + 'leer' => $abschnitte[0]['zeilen'] === [] && $abschnitte[1]['zeilen'] === [], + ]; + } + + /** + * Differenz und Veränderung in Prozent zwischen den beiden Spalten. + * Ohne Vorjahreswert gibt es keinen Prozentsatz – „unendlich viel mehr“ ist + * keine Aussage, die in eine Spalte gehört. + * + * @param array $zeile + * @return array + */ + private static function mitVeraenderung(array $zeile): array + { + $jetzt = (float)($zeile['werte']['jetzt'] ?? 0.0); + $vorher = (float)($zeile['werte']['vorher'] ?? 0.0); + + $zeile['differenz'] = round($jetzt - $vorher, 2); + $zeile['prozent'] = abs($vorher) < 0.005 ? null : round(($jetzt - $vorher) / abs($vorher) * 100, 1); + + return $zeile; + } + + // ── Kontostandsbericht ─────────────────────────────────────────────────── + + /** + * Salden je Konto zu den Enden der Spaltenzeiträume. Anders als die übrigen + * Berichte zählt hier nicht die Bewegung im Zeitraum, sondern der Stand – + * inklusive aller Buchungen davor und inklusive der Gegenseiten von + * Umbuchungen. + * + * @param array $filter + * @return array + */ + private static function kontostandsbericht(array $filter): array + { + $spalten = Zeitraum::spalten((string)$filter['aufteilung'], $filter['von'], $filter['bis']); + $konten = self::gefilterteKonten($filter); + + $salden = []; + foreach ($spalten as $spalte) { + $salden[$spalte['schluessel']] = Buchung::saldenJeKonto($spalte['bis']); + } + + $zeilen = []; + $summe = self::leereReihe($spalten); + + foreach ($konten as $konto) { + $id = (int)$konto['id']; + $reihe = self::leereReihe($spalten); + $leer = true; + + foreach ($spalten as $spalte) { + $stand = (float)$konto['anfangssaldo'] + ($salden[$spalte['schluessel']][$id] ?? 0.0); + + $reihe[$spalte['schluessel']] = $stand; + $summe[$spalte['schluessel']] += $stand; + $leer = $leer && round($stand, 2) === 0.0; + } + + // Konten, die im ganzen Zeitraum auf null standen, blähen die + // Tabelle nur auf – dieselbe Regel wie in der Cockpit-Kachel. + if ($leer) { + continue; + } + + $zeilen[] = [ + 'schluessel' => 'konto:' . $id, + 'name' => (string)$konto['name'], + 'gruppe' => (string)($konto['gruppenname'] ?? ''), + 'werte' => self::gerundet($reihe), + 'summe' => round((float)end($reihe), 2), + ]; + } + + return [ + 'darstellung' => 'kontostaende', + 'spalten' => $spalten, + 'zeilen' => $zeilen, + 'gesamt' => [ + 'name' => 'Summe', + 'werte' => self::gerundet($summe), + 'summe' => round((float)end($summe), 2), + ], + 'leer' => $zeilen === [], + ]; + } + + // ── Budgetbericht ──────────────────────────────────────────────────────── + + /** + * Budget gegen Ist je Kategorie, mit Saldo. Gerechnet wird über die *vollen + * Monate*, die der Zeitraum berührt: Ein Budget ist ein Monatswert, und ein + * halber Monat hat kein halbes Budget. Der Berichtskopf nennt deshalb den + * Monatsbereich, nicht das Tagesdatum. + * + * Vorzeichen wie im Budgetplaner (Ausgaben negativ) und Saldo als + * `ist − budget`: positiv heißt besser als geplant. + * + * @param array $filter + * @return array + */ + private static function budgetbericht(array $filter): array + { + $monate = Zeitraum::monate($filter['von'], $filter['bis']); + $erster = $monate[0]; + $letzter = $monate[count($monate) - 1]; + + // Auf volle Monate aufgezogen – sonst wären Budget und Ist nicht + // vergleichbar und die Saldospalte bedeutungslos. + $voll = [ + 'von' => sprintf('%04d-%02d-01', $erster['jahr'], $erster['monat']), + 'bis' => date('Y-m-t', strtotime(sprintf('%04d-%02d-01', $letzter['jahr'], $letzter['monat'])) ?: time()), + ]; + + $ist = self::verteileAuf( + self::bewegungen(['von' => $voll['von'], 'bis' => $voll['bis']] + $filter), + ['zeilensumme' => 'kategorie'] + $filter, + 'ist' + ); + + // Je berührtem Jahr sein eigenes Budget: Ein Zeitraum über den + // Jahreswechsel vergleicht sonst gegen die Planung des falschen Jahres. + $geplant = []; + $budgets = []; + $summenBudget = []; + foreach ($monate as $monat) { + $budget = $budgets[$monat['jahr']] ??= Budget::aktivesFuerJahr($monat['jahr']); + if ($budget === null) { + continue; + } + $positionen = $geplant[$monat['jahr']] ??= Budget::positionen((int)$budget['id']); + + foreach ($positionen as $kategorieId => $werte) { + $summenBudget[$kategorieId] = ($summenBudget[$kategorieId] ?? 0.0) + ($werte[$monat['monat']] ?? 0.0); + } + } + + $baum = Kategorie::baumFlach(); + $zeilen = []; + $gesamt = ['budget' => 0.0, 'ist' => 0.0]; + + // Astsummen: Eine Elternkategorie zeigt, was ihr Zweig geplant und + // verbraucht hat – genau wie im Budgetplaner. + $astBudget = []; + $astIst = []; + foreach ($baum as $kategorie) { + $id = (int)$kategorie['id']; + $astBudget[$id] = $summenBudget[$id] ?? 0.0; + $astIst[$id] = $ist[self::art($kategorie)][$id]['ist'] ?? 0.0; + } + foreach (array_reverse($baum) as $kategorie) { + $eltern = $kategorie['eltern_id'] === null ? 0 : (int)$kategorie['eltern_id']; + if (!isset($astBudget[$eltern])) { + continue; + } + $astBudget[$eltern] += $astBudget[(int)$kategorie['id']]; + $astIst[$eltern] += $astIst[(int)$kategorie['id']]; + } + + foreach ($baum as $kategorie) { + $id = (int)$kategorie['id']; + + if (round($astBudget[$id], 2) === 0.0 && round($astIst[$id], 2) === 0.0) { + continue; + } + + // Immer als Baum, unabhängig vom Einrückungsschalter: Die Werte sind + // Astsummen, und flach untereinander sähe eine Summe wie ein + // gleichrangiger Posten neben ihren eigenen Bestandteilen aus. + $zeilen[] = [ + 'schluessel' => 'kategorie:' . $id, + 'name' => (string)$kategorie['name'], + 'ebene' => (int)$kategorie['ebene'], + 'kopf' => (bool)$kategorie['hat_kinder'], + 'budget' => round($astBudget[$id], 2), + 'ist' => round($astIst[$id], 2), + 'saldo' => round($astIst[$id] - $astBudget[$id], 2), + ]; + + // Nur Wurzeln in die Gesamtsumme, sonst zählte jeder Ast doppelt. + if ($kategorie['eltern_id'] === null) { + $gesamt['budget'] += $astBudget[$id]; + $gesamt['ist'] += $astIst[$id]; + } + } + + return [ + 'darstellung' => 'budget', + 'zeilen' => $zeilen, + 'monate' => $monate, + 'monatsbereich' => self::monatsbereich($erster, $letzter), + 'budgets' => array_filter($budgets), + 'gesamt' => [ + 'name' => 'Gesamt', + 'budget' => round($gesamt['budget'], 2), + 'ist' => round($gesamt['ist'], 2), + 'saldo' => round($gesamt['ist'] - $gesamt['budget'], 2), + ], + 'leer' => $zeilen === [], + ]; + } + + /** @param array{jahr:int,monat:int} $erster @param array{jahr:int,monat:int} $letzter */ + private static function monatsbereich(array $erster, array $letzter): string + { + $von = View::monat($erster['monat'], true) . ' ' . $erster['jahr']; + $bis = View::monat($letzter['monat'], true) . ' ' . $letzter['jahr']; + + return $von === $bis ? $von : $von . ' – ' . $bis; + } + + // ── Datenbeschaffung ───────────────────────────────────────────────────── + + /** + * Die Bewegungen des Zeitraums: nicht gesplittete Buchungen mit ihrem + * Betrag, gesplittete über ihre Splitzeilen. + * + * @param array $filter + * @return list> + */ + private static function bewegungen(array $filter): array + { + $benutzer = Mandant::id(); + $params = [$benutzer, $filter['von'], $filter['bis'], $benutzer, $filter['von'], $filter['bis']]; + + $sql = 'SELECT * FROM ( + SELECT b.id AS buchung_id, NULL AS split_id, b.datum, b.betrag, b.konto_id, + b.kategorie_id, b.klasse_id, b.gegenkonto_id, b.empfaenger, + b.buchungstext AS text + FROM buchungen b + WHERE b.benutzer_id = ? AND b.gesplittet = 0 AND b.datum BETWEEN ? AND ? + UNION ALL + SELECT b.id AS buchung_id, s.id AS split_id, b.datum, s.betrag, b.konto_id, + s.kategorie_id, s.klasse_id, s.gegenkonto_id, b.empfaenger, + s.notiz AS text + FROM buchung_splits s + JOIN buchungen b ON b.id = s.buchung_id + WHERE b.benutzer_id = ? AND b.gesplittet = 1 AND b.datum BETWEEN ? AND ? + ) AS bewegungen'; + + $wo = []; + + // Umbuchungen sind Vermögensverschiebungen, keine Einnahmen oder + // Ausgaben – nur der Buchungsbericht darf sie auf Wunsch mitführen. + if (empty($filter['umbuchungen'])) { + $wo[] = 'gegenkonto_id IS NULL'; + } + + if (!empty($filter['konten'])) { + $wo[] = 'konto_id IN (' . self::platzhalter($filter['konten']) . ')'; + $params = array_merge($params, $filter['konten']); + } + + if (!empty($filter['kategorien'])) { + $ids = self::mitNachfahren($filter['kategorien']); + $wo[] = 'kategorie_id IN (' . self::platzhalter($ids) . ')'; + $params = array_merge($params, $ids); + } + + if (!empty($filter['klassen'])) { + $wo[] = 'klasse_id IN (' . self::platzhalter($filter['klassen']) . ')'; + $params = array_merge($params, $filter['klassen']); + } + + if (($filter['suche'] ?? '') !== '') { + $wo[] = '(empfaenger LIKE ? OR text LIKE ?)'; + $muster = '%' . $filter['suche'] . '%'; + $params[] = $muster; + $params[] = $muster; + } + + if ($wo !== []) { + $sql .= ' WHERE ' . implode(' AND ', $wo); + } + + return Database::fetchAll($sql . ' ORDER BY datum, buchung_id, split_id', $params); + } + + /** + * Sortiert die Bewegungen in [Art][Zeile][Spalte] ein. + * + * @param list> $bewegungen + * @param array $filter + * @param list> $spalten + * @return array>> + */ + private static function verteile(array $bewegungen, array $filter, array $spalten): array + { + $arten = self::kategorienArten(); + $aufteilung = (string)$filter['aufteilung']; + $gueltig = array_flip(array_column($spalten, 'schluessel')); + + $roh = []; + foreach ($bewegungen as $zeile) { + $spalte = Zeitraum::spaltenschluessel($aufteilung, (string)$zeile['datum']); + if (!isset($gueltig[$spalte])) { + continue; + } + + $art = self::artDerBewegung($zeile, $arten); + $schluessel = self::zeilenschluessel($zeile, (string)$filter['zeilensumme']); + + $roh[$art][$schluessel][$spalte] = ($roh[$art][$schluessel][$spalte] ?? 0.0) + (float)$zeile['betrag']; + } + + return $roh; + } + + /** + * Wie verteile(), aber alles in eine einzige, fest benannte Spalte – für + * Vergleichs- und Budgetbericht, deren Spalten nicht aus dem Kalender + * kommen. + * + * @param list> $bewegungen + * @param array $filter + * @return array>> + */ + private static function verteileAuf(array $bewegungen, array $filter, string $spalte): array + { + $arten = self::kategorienArten(); + + $roh = []; + foreach ($bewegungen as $zeile) { + $art = self::artDerBewegung($zeile, $arten); + $schluessel = self::zeilenschluessel($zeile, (string)$filter['zeilensumme']); + + $roh[$art][$schluessel][$spalte] = ($roh[$art][$schluessel][$spalte] ?? 0.0) + (float)$zeile['betrag']; + } + + return $roh; + } + + /** + * Einnahme oder Ausgabe? Es entscheidet die *Art der Kategorie*, nicht das + * Vorzeichen: Eine Erstattung ist eine negative Ausgabe und gehört unter + * die Ausgaben, nicht unter die Einnahmen. Nur wo keine Kategorie steht, + * bleibt das Vorzeichen als einziger Anhaltspunkt. + * + * @param array $zeile + * @param array $arten + */ + private static function artDerBewegung(array $zeile, array $arten): string + { + if ($zeile['kategorie_id'] !== null && isset($arten[(int)$zeile['kategorie_id']])) { + return $arten[(int)$zeile['kategorie_id']]; + } + + return (float)$zeile['betrag'] >= 0 ? 'einnahme' : 'ausgabe'; + } + + /** + * @param array $zeile + * @return int|string + */ + private static function zeilenschluessel(array $zeile, string $dimension): int|string + { + return match ($dimension) { + 'klasse' => (int)($zeile['klasse_id'] ?? 0), + 'konto' => (int)($zeile['konto_id'] ?? 0), + 'empfaenger' => trim((string)($zeile['empfaenger'] ?? '')), + default => (int)($zeile['kategorie_id'] ?? 0), + }; + } + + private static function ohneName(string $dimension): string + { + return match ($dimension) { + 'klasse' => self::OHNE_KLASSE, + 'empfaenger' => self::OHNE_EMPFAENGER, + 'konto' => 'Ohne Konto', + default => self::OHNE_KATEGORIE, + }; + } + + /** + * Anzeigenamen der Zeilendimension. Empfänger sind ihr eigener Name und + * brauchen keine Tabelle. + * + * @return array + */ + private static function namen(string $dimension): array + { + return match ($dimension) { + 'klasse' => array_column(Klasse::alle(), 'name', 'id'), + 'konto' => array_column(Konto::alle(), 'name', 'id'), + 'kategorie' => array_column(Kategorie::alle(), 'pfad', 'id'), + default => [], + }; + } + + /** @return array kategorie_id => 'einnahme'|'ausgabe' */ + private static function kategorienArten(): array + { + $arten = []; + foreach (Kategorie::alle() as $kategorie) { + $arten[(int)$kategorie['id']] = (string)$kategorie['art']; + } + return $arten; + } + + /** @param array $kategorie */ + private static function art(array $kategorie): string + { + return (string)($kategorie['art'] ?? 'ausgabe'); + } + + /** + * Die gewählten Kategorien samt aller Unterkategorien. Wer „400 - Leben“ + * filtert, meint den ganzen Ast – alles andere wäre eine Überraschung. + * + * @param list $ids + * @return list + */ + private static function mitNachfahren(array $ids): array + { + $alle = Kategorie::alle(); + $pfade = array_column($alle, 'pfad', 'id'); + $gewaehlt = array_flip($ids); + + $ergebnis = []; + foreach ($alle as $kategorie) { + $id = (int)$kategorie['id']; + if (isset($gewaehlt[$id])) { + $ergebnis[$id] = $id; + continue; + } + + foreach ($ids as $elternId) { + $elternPfad = $pfade[$elternId] ?? null; + if ($elternPfad !== null && str_starts_with((string)$kategorie['pfad'], $elternPfad . Kategorie::TRENNER)) { + $ergebnis[$id] = $id; + break; + } + } + } + + // Nie leer zurückgeben: Ein „IN ()“ wäre ein Syntaxfehler, und die + // gewählte, inzwischen gelöschte Kategorie soll schlicht nichts treffen. + return $ergebnis === [] ? [0] : array_values($ergebnis); + } + + /** + * Die Konten des Berichts – gefiltert, sonst alle, in der Reihenfolge der + * Kontenübersicht. + * + * @param array $filter + * @return list> + */ + private static function gefilterteKonten(array $filter): array + { + $konten = Konto::alle(); + if (empty($filter['konten'])) { + return $konten; + } + + $gewaehlt = array_flip($filter['konten']); + + return array_values(array_filter( + $konten, + static fn(array $konto): bool => isset($gewaehlt[(int)$konto['id']]) + )); + } + + // ── Kleinkram ──────────────────────────────────────────────────────────── + + /** + * @param list> $spalten + * @return array + */ + private static function leereReihe(array $spalten): array + { + return array_fill_keys(array_column($spalten, 'schluessel'), 0.0); + } + + /** + * @param array $werte + * @return array + */ + private static function gerundet(array $werte): array + { + return array_map(static fn(float $wert): float => round($wert, 2), $werte); + } + + /** @param array $werte */ + private static function istLeer(array $werte): bool + { + foreach ($werte as $betrag) { + if (round($betrag, 2) !== 0.0) { + return false; + } + } + return true; + } + + /** @param list $werte */ + private static function platzhalter(array $werte): string + { + return implode(', ', array_fill(0, count($werte), '?')); + } + + /** @return list */ + private static function ids(mixed $wert): array + { + if (!is_array($wert)) { + return []; + } + + $ids = []; + foreach ($wert as $eintrag) { + if (is_scalar($eintrag) && (int)$eintrag > 0) { + $ids[(int)$eintrag] = (int)$eintrag; + } + } + + return array_values($ids); + } + + private static function bool(mixed $wert): bool + { + return in_array($wert, [true, 1, '1', 'true', 'ja', 'on'], true); + } + + private static function datum(mixed $wert): string + { + $wert = trim((string)$wert); + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert) === 1 ? $wert : ''; + } +} diff --git a/src/Services/BerichtExport.php b/src/Services/BerichtExport.php new file mode 100644 index 0000000..f649517 --- /dev/null +++ b/src/Services/BerichtExport.php @@ -0,0 +1,218 @@ + $bericht Rückgabe von Auswertung::bericht() */ + public static function csv(array $bericht): string + { + $zeilen = match ($bericht['darstellung']) { + 'buchungen' => self::buchungen($bericht), + 'kontostaende' => self::kontostaende($bericht), + 'budget' => self::budget($bericht), + default => self::summen($bericht), + }; + + $kopf = [ + [$bericht['name'] ?? $bericht['typname']], + [$bericht['beschriftung'] . ' (in Euro)'], + [], + ]; + + $text = ''; + foreach (array_merge($kopf, $zeilen) as $zeile) { + $text .= self::zeile($zeile); + } + + return self::BOM . $text; + } + + /** Dateiname ohne Pfad, aus Berichtsname und Zeitraum. */ + public static function dateiname(array $bericht): string + { + $roh = ($bericht['name'] ?? $bericht['typname']) . '-' . ($bericht['zeitraum']['von'] ?? ''); + + $sauber = preg_replace('/[^A-Za-z0-9_.-]+/', '-', self::umlaute($roh)) ?? 'bericht'; + + return trim($sauber, '-') . '.csv'; + } + + /** + * @param array $bericht + * @return list> + */ + private static function summen(array $bericht): array + { + $spalten = array_column($bericht['spalten'], 'titel'); + $extra = empty($bericht['vergleich']) ? [] : ['Differenz', 'Veränderung in %']; + + $zeilen = [array_merge(['Bezeichnung'], $spalten, $extra, ['Summe'])]; + + foreach ($bericht['abschnitte'] as $abschnitt) { + $zeilen[] = [$abschnitt['name']]; + + foreach ($abschnitt['zeilen'] as $zeile) { + $zeilen[] = self::wertzeile( + // Die Einrückung des Baums als führende Leerzeichen: In einer + // Tabellenkalkulation ist die Hierarchie sonst verloren. + str_repeat(' ', (int)$zeile['ebene']) . $zeile['name'], + $zeile, + $bericht + ); + } + + $zeilen[] = self::wertzeile('Gesamt ' . $abschnitt['name'], $abschnitt, $bericht); + $zeilen[] = []; + } + + $zeilen[] = self::wertzeile($bericht['gesamt']['name'], $bericht['gesamt'], $bericht); + + return $zeilen; + } + + /** + * @param array $zeile + * @param array $bericht + * @return list + */ + private static function wertzeile(string $name, array $zeile, array $bericht): array + { + $werte = array_map(self::zahl(...), array_values($zeile['werte'])); + + if (!empty($bericht['vergleich'])) { + $werte[] = self::zahl((float)($zeile['differenz'] ?? 0)); + $werte[] = $zeile['prozent'] === null ? '' : self::zahl((float)$zeile['prozent'], 1); + } + + $werte[] = self::zahl((float)$zeile['summe']); + + return array_merge([$name], $werte); + } + + /** + * @param array $bericht + * @return list> + */ + private static function buchungen(array $bericht): array + { + $zeilen = [['Gruppe', 'Datum', 'Konto', 'Empfänger', 'Buchungstext', 'Kategorie', 'Klasse', 'Betrag']]; + + foreach ($bericht['gruppen'] as $gruppe) { + foreach ($gruppe['buchungen'] as $buchung) { + $zeilen[] = [ + $gruppe['name'], + View::datum($buchung['datum']), + $buchung['konto'], + $buchung['empfaenger'], + $buchung['text'], + $buchung['kategorie'], + $buchung['klasse'], + self::zahl($buchung['betrag']), + ]; + } + $zeilen[] = ['Gesamt ' . $gruppe['name'], '', '', '', '', '', '', self::zahl($gruppe['summe'])]; + } + + $zeilen[] = []; + $zeilen[] = ['Gesamt', '', '', '', '', '', '', self::zahl($bericht['summe'])]; + + return $zeilen; + } + + /** + * @param array $bericht + * @return list> + */ + private static function kontostaende(array $bericht): array + { + $zeilen = [array_merge(['Konto', 'Kontengruppe'], array_column($bericht['spalten'], 'titel'))]; + + foreach ($bericht['zeilen'] as $zeile) { + $zeilen[] = array_merge( + [$zeile['name'], $zeile['gruppe']], + array_map(self::zahl(...), array_values($zeile['werte'])) + ); + } + + $zeilen[] = array_merge( + [$bericht['gesamt']['name'], ''], + array_map(self::zahl(...), array_values($bericht['gesamt']['werte'])) + ); + + return $zeilen; + } + + /** + * @param array $bericht + * @return list> + */ + private static function budget(array $bericht): array + { + $zeilen = [['Kategorie', 'Budget', 'Ist', 'Saldo']]; + + foreach ($bericht['zeilen'] as $zeile) { + $zeilen[] = [ + str_repeat(' ', (int)$zeile['ebene']) . $zeile['name'], + self::zahl($zeile['budget']), + self::zahl($zeile['ist']), + self::zahl($zeile['saldo']), + ]; + } + + $gesamt = $bericht['gesamt']; + $zeilen[] = [$gesamt['name'], self::zahl($gesamt['budget']), self::zahl($gesamt['ist']), self::zahl($gesamt['saldo'])]; + + return $zeilen; + } + + /** @param list $felder */ + private static function zeile(array $felder): string + { + $maskiert = array_map( + // Nur maskieren, wo es sein muss: Ein Feld in Anführungszeichen + // liest sich schlechter, wenn es keinen Trenner enthält. + static function (string $feld): string { + if (preg_match('/[;"\r\n]/', $feld) !== 1) { + return $feld; + } + return '"' . str_replace('"', '""', $feld) . '"'; + }, + array_map('strval', $felder) + ); + + // CRLF: Das erwarten Tabellenkalkulationen unter Windows, und der Rest + // kommt damit ebenfalls zurecht. + return implode(';', $maskiert) . "\r\n"; + } + + private static function zahl(float $wert, int $stellen = 2): string + { + return number_format($wert, $stellen, ',', ''); + } + + /** Umlaute im Dateinamen ausschreiben, statt sie zu Bindestrichen zu zermahlen. */ + private static function umlaute(string $text): string + { + return strtr($text, [ + 'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', + 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ß' => 'ss', + ]); + } +} diff --git a/src/Services/Zeitraum.php b/src/Services/Zeitraum.php new file mode 100644 index 0000000..eb5280d --- /dev/null +++ b/src/Services/Zeitraum.php @@ -0,0 +1,283 @@ + 'Aktueller Monat', + 'letzter_monat' => 'Letzter Monat', + 'aktuelles_quartal' => 'Aktuelles Quartal', + 'letztes_quartal' => 'Letztes Quartal', + 'aktuelles_jahr' => 'Aktuelles Jahr', + 'letztes_jahr' => 'Letztes Jahr', + 'letzte_12_monate' => 'Letzte zwölf Monate', + 'alles' => 'Gesamter Zeitraum', + 'benutzerdefiniert' => 'Benutzerdefiniert', + ]; + + /** Spalten-Aufteilung der Berichte. */ + public const AUFTEILUNGEN = [ + 'keine' => 'Keine', + 'monat' => 'Monat', + 'quartal' => 'Quartal', + 'jahr' => 'Jahr', + ]; + + /** Untergrenze für „Gesamter Zeitraum“, falls es noch keine Buchung gibt. */ + private const FRUEHESTENS = '2000-01-01'; + + /** + * Von und bis zu einer Vorgabe. `$von`/`$bis` gelten nur bei + * „Benutzerdefiniert“ und werden sonst überschrieben – sie bleiben im + * Formular stehen, damit ein Wechsel zurück nichts verliert. + * + * @param array{von: ?string, bis: ?string} $grenzen Datumsgrenzen der + * Buchungen, nur für „Gesamter Zeitraum“ gebraucht. + * @return array{vorgabe: string, von: string, bis: string} + */ + public static function berechne( + string $vorgabe, + string $von = '', + string $bis = '', + ?string $heute = null, + array $grenzen = ['von' => null, 'bis' => null] + ): array { + $heute = $heute ?? date('Y-m-d'); + $zeit = strtotime($heute) ?: time(); + + if (!array_key_exists($vorgabe, self::VORGABEN)) { + $vorgabe = 'aktuelles_jahr'; + } + + [$neuVon, $neuBis] = match ($vorgabe) { + 'aktueller_monat' => self::monat($zeit), + 'letzter_monat' => self::monat(strtotime('first day of last month', $zeit) ?: $zeit), + 'aktuelles_quartal' => self::quartal($zeit), + 'letztes_quartal' => self::quartal(strtotime('-3 month', self::quartalsBeginn($zeit)) ?: $zeit), + 'aktuelles_jahr' => self::jahr((int)date('Y', $zeit)), + 'letztes_jahr' => self::jahr((int)date('Y', $zeit) - 1), + // Elf Monate zurück plus der laufende: dieselbe Zählweise wie die + // Cockpit-Kachel, damit beide denselben Ausschnitt meinen. + 'letzte_12_monate' => [ + date('Y-m-01', strtotime('-11 month', strtotime(date('Y-m-01', $zeit))) ?: $zeit), + date('Y-m-t', $zeit), + ], + 'alles' => [ + $grenzen['von'] ?? self::FRUEHESTENS, + // Ohne Buchungen endet „alles“ heute – ein Zeitraum, der in der + // Zukunft endet, brächte nur leere Spalten. + self::spaeter($grenzen['bis'] ?? $heute, $heute), + ], + default => [self::datum($von, date('Y-01-01', $zeit)), self::datum($bis, $heute)], + }; + + // Verdrehte Eingaben tauschen statt abweisen: ein leerer Bericht wäre + // schwerer zu verstehen als der offensichtlich gemeinte Zeitraum. + if ($neuVon > $neuBis) { + [$neuVon, $neuBis] = [$neuBis, $neuVon]; + } + + return ['vorgabe' => $vorgabe, 'von' => $neuVon, 'bis' => $neuBis]; + } + + /** + * Derselbe Zeitraum ein Jahr früher – die Vergleichsspalte des + * Vergleichsberichts. + * + * @return array{von: string, bis: string} + */ + public static function vorjahr(string $von, string $bis): array + { + return [ + 'von' => date('Y-m-d', strtotime($von . ' -1 year') ?: time()), + // Über den Monatsletzten: Der 29.02. eines Schaltjahrs landete sonst + // im Vorjahr auf dem 01.03. + 'bis' => self::monatsletzterWennEnde($bis), + ]; + } + + /** + * Zerlegt den Zeitraum in Spalten. Die erste und die letzte Spalte sind an + * den Zeitraum geschnitten: Ein Bericht vom 15.01. bis 20.03. hat im + * Monatsraster eine halbe Januar-, eine volle Februar- und eine + * angebrochene Märzspalte. + * + * @return list + */ + public static function spalten(string $aufteilung, string $von, string $bis): array + { + if (!array_key_exists($aufteilung, self::AUFTEILUNGEN) || $aufteilung === 'keine') { + return [[ + 'schluessel' => 'gesamt', + 'titel' => View::datum($von) . ' – ' . View::datum($bis), + 'kurz' => View::datum($von) . '–' . View::datum($bis), + 'von' => $von, + 'bis' => $bis, + ]]; + } + + $spalten = []; + $zeiger = strtotime($von) ?: time(); + $ende = strtotime($bis) ?: $zeiger; + + while ($zeiger <= $ende) { + $spalte = match ($aufteilung) { + 'monat' => self::monatsSpalte($zeiger), + 'quartal' => self::quartalsSpalte($zeiger), + default => self::jahresSpalte($zeiger), + }; + + // An den Rändern auf den Berichtszeitraum stutzen. + $spalte['von'] = self::spaeter($spalte['von'], $von); + $spalte['bis'] = $spalte['bis'] > $bis ? $bis : $spalte['bis']; + + $spalten[] = $spalte; + $zeiger = strtotime($spalte['bis'] . ' +1 day') ?: $ende + 1; + } + + return $spalten; + } + + /** + * In welche Spalte ein Datum fällt – der Schlüssel, unter dem summiert wird. + * Gerechnet statt gesucht: Bei hundert Spalten und zehntausend Buchungen + * wäre eine Suche je Buchung spürbar. + */ + public static function spaltenschluessel(string $aufteilung, string $datum): string + { + return match ($aufteilung) { + 'monat' => substr($datum, 0, 7), + 'quartal' => substr($datum, 0, 4) . '-Q' . (int)ceil((int)substr($datum, 5, 2) / 3), + 'jahr' => substr($datum, 0, 4), + default => 'gesamt', + }; + } + + /** Die Monate, die ein Zeitraum berührt – der Budgetbericht plant monatsweise. */ + public static function monate(string $von, string $bis): array + { + $monate = []; + $zeiger = strtotime(substr($von, 0, 8) . '01') ?: time(); + $ende = strtotime(substr($bis, 0, 8) . '01') ?: $zeiger; + + while ($zeiger <= $ende) { + $monate[] = ['jahr' => (int)date('Y', $zeiger), 'monat' => (int)date('n', $zeiger)]; + $zeiger = strtotime('+1 month', $zeiger) ?: $ende + 1; + } + + return $monate; + } + + /** „01.07.2026 – 31.07.2026“ für den Berichtskopf. */ + public static function beschriftung(string $von, string $bis): string + { + return View::datum($von) . ' bis ' . View::datum($bis); + } + + // ── Kalenderrechnung ───────────────────────────────────────────────────── + + /** @return array{0: string, 1: string} */ + private static function monat(int $zeit): array + { + return [date('Y-m-01', $zeit), date('Y-m-t', $zeit)]; + } + + /** @return array{0: string, 1: string} */ + private static function quartal(int $zeit): array + { + $beginn = self::quartalsBeginn($zeit); + return [date('Y-m-01', $beginn), date('Y-m-t', strtotime('+2 month', $beginn) ?: $beginn)]; + } + + /** @return array{0: string, 1: string} */ + private static function jahr(int $jahr): array + { + return [sprintf('%04d-01-01', $jahr), sprintf('%04d-12-31', $jahr)]; + } + + private static function quartalsBeginn(int $zeit): int + { + $monat = (int)(floor(((int)date('n', $zeit) - 1) / 3) * 3) + 1; + return strtotime(sprintf('%s-%02d-01', date('Y', $zeit), $monat)) ?: $zeit; + } + + /** @return array{schluessel: string, titel: string, kurz: string, von: string, bis: string} */ + private static function monatsSpalte(int $zeit): array + { + $nr = (int)date('n', $zeit); + return [ + 'schluessel' => date('Y-m', $zeit), + 'titel' => View::monat($nr) . ' ' . date('Y', $zeit), + 'kurz' => View::monat($nr, true) . ' ' . date('y', $zeit), + 'von' => date('Y-m-01', $zeit), + 'bis' => date('Y-m-t', $zeit), + ]; + } + + /** @return array{schluessel: string, titel: string, kurz: string, von: string, bis: string} */ + private static function quartalsSpalte(int $zeit): array + { + $beginn = self::quartalsBeginn($zeit); + $nummer = (int)ceil((int)date('n', $zeit) / 3); + + return [ + 'schluessel' => date('Y', $zeit) . '-Q' . $nummer, + 'titel' => $nummer . '. Quartal ' . date('Y', $zeit), + 'kurz' => 'Q' . $nummer . ' ' . date('y', $zeit), + 'von' => date('Y-m-01', $beginn), + 'bis' => date('Y-m-t', strtotime('+2 month', $beginn) ?: $beginn), + ]; + } + + /** @return array{schluessel: string, titel: string, kurz: string, von: string, bis: string} */ + private static function jahresSpalte(int $zeit): array + { + return [ + 'schluessel' => date('Y', $zeit), + 'titel' => date('Y', $zeit), + 'kurz' => date('Y', $zeit), + 'von' => date('Y-01-01', $zeit), + 'bis' => date('Y-12-31', $zeit), + ]; + } + + /** + * Ein Jahr zurück, aber ein Monatsende bleibt ein Monatsende – sonst rutschte + * der 29.02. auf den 01.03. und der Vergleichszeitraum wäre einen Tag zu lang. + */ + private static function monatsletzterWennEnde(string $datum): string + { + $zeit = strtotime($datum . ' -1 year') ?: time(); + + return $datum === date('Y-m-t', strtotime($datum) ?: $zeit) + ? date('Y-m-t', $zeit) + : date('Y-m-d', $zeit); + } + + private static function spaeter(string $a, string $b): string + { + return $a > $b ? $a : $b; + } + + private static function datum(string $wert, string $ersatz): string + { + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $wert) === 1 ? $wert : $ersatz; + } +} diff --git a/src/Views/auswertungen/bericht.php b/src/Views/auswertungen/bericht.php new file mode 100644 index 0000000..d7d1a79 --- /dev/null +++ b/src/Views/auswertungen/bericht.php @@ -0,0 +1,249 @@ + $bericht */ +/** @var array|null $gespeichert */ +/** @var list> $gruppen */ +/** @var list> $konten */ +/** @var list> $kategorien */ +/** @var list> $klassen */ +/** @var array $vorgaben */ +/** @var array $aufteilungen */ +/** @var array $zeilensummen */ +/** @var array $typen */ +/** @var string $query */ +/** @var string $csrfToken */ + +$filter = $bericht['filter']; +$id = $gespeichert === null ? 0 : (int)$gespeichert['id']; + +$ziel = $id > 0 ? '/auswertungen/bericht/' . $id : '/auswertungen/bericht'; + +// Zwei Berichte bestimmen ihre Zeilen selbst, zwei ihre Spalten – die +// betreffenden Felder stehen dann still, statt eine Wahl vorzutäuschen. +$zeilenWaehlbar = !in_array($bericht['typ'], ['kontostandsbericht', 'budgetbericht'], true); +$spaltenWaehlbar = !in_array($bericht['typ'], ['vergleichsbericht', 'budgetbericht'], true); +$baumbericht = $filter['zeilensumme'] === 'kategorie' && $bericht['typ'] !== 'buchungsbericht'; + +include __DIR__ . '/../layout/kopf.php'; +?> + + + +
+ Als CSV + + + + 0): ?> +
+ + + + + + +
+ +
+ + +
+ + + Zur Übersicht +
+ +
+ + +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
+ +
> + Einschränken auf Konten, Kategorien und Klassen +
+ + + + + +
+

+ Eine gewählte Kategorie schließt ihre Unterkategorien ein. Mehrfachauswahl mit + gedrückter Strg-Taste; nichts ausgewählt heißt alles. +

+
+
+ +
+

+

(in Euro)

+
+ + +

+ Für diesen Zeitraum gibt es keine Buchungen – oder die Einschränkung lässt nichts übrig. +

+ + 'teil-buchungen.php', + 'kontostaende' => 'teil-kontostaende.php', + 'budget' => 'teil-budget.php', + default => 'teil-summen.php', + }; + include __DIR__ . '/' . $teil; + ?> + + + diff --git a/src/Views/auswertungen/drilldown.php b/src/Views/auswertungen/drilldown.php new file mode 100644 index 0000000..3d52e78 --- /dev/null +++ b/src/Views/auswertungen/drilldown.php @@ -0,0 +1,88 @@ + $zeile + * @var string $zurueck + */ + +include __DIR__ . '/../layout/kopf.php'; +?> + +
+ Hauptansicht + Bericht + Buchungen +
+ + + +
+

+

·

+
+ + +

Zu dieser Zeile gibt es keine Buchungen mehr – vermutlich wurde + zwischenzeitlich etwas geändert.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DatumKontoEmpfängerBuchungstextKategorieKlasseBetrag
+ + + + + + + + + Split + + + +
Summe ( Buchungen) + +
+ +

+ Die Beträge stehen mit ihrem buchhalterischen Vorzeichen – im Summenbericht erscheint + dieselbe Summe unter „Ausgaben“ ohne Minus. +

+ + + diff --git a/src/Views/auswertungen/filterfelder.php b/src/Views/auswertungen/filterfelder.php new file mode 100644 index 0000000..1fcbfa6 --- /dev/null +++ b/src/Views/auswertungen/filterfelder.php @@ -0,0 +1,22 @@ + $filter + */ +?> + $wert): ?> + + + + + + + + diff --git a/src/Views/auswertungen/gruppen.php b/src/Views/auswertungen/gruppen.php new file mode 100644 index 0000000..da55cc3 --- /dev/null +++ b/src/Views/auswertungen/gruppen.php @@ -0,0 +1,123 @@ +|null, berichte: list>}> $gruppen + * @var string $csrfToken + */ + +include __DIR__ . '/../layout/kopf.php'; +?> + +
+ Hauptansicht + Berichtsgruppen +
+ + + +

+ Gruppen ordnen die gespeicherten Berichte zu Kacheln – wie „Bankgeschäfte“ oder + „Steuern“ im FinanzManager. Wird eine Gruppe gelöscht, bleiben ihre Berichte erhalten + und rücken unter „Nicht gruppiert“. +

+ +
+

Neue Berichtsgruppe

+
+ + + + +
+
+ + $eintrag['gruppe'] !== null); +?> + + +

Noch keine Berichtsgruppe angelegt.

+ + + + + + + + + + + + + + + + + + +
NameSortierungBerichteAktion
+
+ + + + + + keine Berichte + + Bericht: + + + + +
+ +
+ + +
+
+ + + + +

Nicht gruppiert

+ + + + + + + + + + + + + +
BerichtTypAktion
+ + + + + Öffnen +
+ + + diff --git a/src/Views/auswertungen/index.php b/src/Views/auswertungen/index.php new file mode 100644 index 0000000..4ef2775 --- /dev/null +++ b/src/Views/auswertungen/index.php @@ -0,0 +1,99 @@ +|null, berichte: list>}> $gruppen */ +/** @var list> $letzte */ +/** @var array $typen */ +/** @var int $anzahl */ +/** @var string $csrfToken */ + +/** Die Standardvorlagen öffnen einen ungespeicherten Bericht über das laufende Jahr. */ +$vorlage = static fn(string $typ): string => '/auswertungen/bericht?' . http_build_query([ + 'typ' => $typ, + 'zeitraum' => 'aktuelles_jahr', + 'angepasst' => '1', +]); + +include __DIR__ . '/../layout/kopf.php'; +?> + +
+ Hauptansicht +
+ +
+ Neuer Bericht + Berichtsgruppen + + + +
+ +
+
+

Standardvorlagen

+

+ Ein Bericht ohne Namen: Filter einstellen, ansehen, und wenn er sich lohnt, + mit „Speichern unter“ in eine Berichtsgruppe legen. +

+ +
+ + $eintrag): ?> +
+
+

+
+ +

+ Noch kein Bericht in dieser Gruppe. Beim „Speichern unter“ lässt sich + die Gruppe auswählen. +

+ + + +
+ +
+ + +

+ Gespeicherte Berichte erscheinen hier als Kacheln – gruppiert wie im FinanzManager + unter „Bankgeschäfte“ oder „Steuern“. Bis dahin führt jeder Weg über die + Standardvorlagen. +

+ + + diff --git a/src/Views/auswertungen/teil-buchungen.php b/src/Views/auswertungen/teil-buchungen.php new file mode 100644 index 0000000..6cfa086 --- /dev/null +++ b/src/Views/auswertungen/teil-buchungen.php @@ -0,0 +1,78 @@ + $bericht + */ +?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DatumKontoEmpfängerBuchungstextKategorieKlasseBetrag
+ + + + + + + Split + + + +
Gesamt + () + +
Gesamt ( Buchungen) + +
+ +

+ Eine Splitbuchung erscheint mit jeder ihrer Zeilen einzeln – so stimmt die Summe mit + dem Summenbericht überein. Der Empfänger führt in die Buchungsmaske. +

diff --git a/src/Views/auswertungen/teil-budget.php b/src/Views/auswertungen/teil-budget.php new file mode 100644 index 0000000..c417403 --- /dev/null +++ b/src/Views/auswertungen/teil-budget.php @@ -0,0 +1,76 @@ + $bericht + * @var string $query + * @var int $id + */ + +$drilldown = static fn(string $zeile): string => '/auswertungen/buchungen?' . $query . '&' . http_build_query([ + 'zeile' => $zeile, 'ast' => '1', 'bericht_id' => $id, +]); +?> + + +

+ Für ist kein Budget angelegt – die + Budgetspalte bleibt deshalb leer. Der Budgetplaner legt eines an. + Zum Budgetplaner +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KategoriebeschreibungBudgetIstSaldo
+ + + + + + + + + +
+ + + + + +
+ +

+ Gerechnet über die vollen Monate : Ein Budget ist + ein Monatswert, und ein halber Monat hat kein halbes Budget. Vorzeichen wie im + Budgetplaner – Ausgaben negativ –, Saldo ist Ist minus Budget: positiv heißt besser als + geplant. Übergeordnete Kategorien zeigen die Summe ihres Astes. +

diff --git a/src/Views/auswertungen/teil-kontostaende.php b/src/Views/auswertungen/teil-kontostaende.php new file mode 100644 index 0000000..d4646a7 --- /dev/null +++ b/src/Views/auswertungen/teil-kontostaende.php @@ -0,0 +1,57 @@ + $bericht + */ +?> + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KontoKontengruppe
+ + + +
+
+ +

+ Der Stand enthält alle Buchungen bis zum Stichtag, auch die vor dem Berichtszeitraum, + und die Gegenseiten von Umbuchungen. Konten, die durchgehend auf null standen, fehlen. + Die Summe zählt alle gezeigten Konten – unabhängig vom Σ-Kennzeichen der Kontenübersicht. +

diff --git a/src/Views/auswertungen/teil-summen.php b/src/Views/auswertungen/teil-summen.php new file mode 100644 index 0000000..f0274a6 --- /dev/null +++ b/src/Views/auswertungen/teil-summen.php @@ -0,0 +1,147 @@ + $bericht + * @var string $query + * @var int $id + */ + +$vergleich = !empty($bericht['vergleich']); + +/** Der Weg von einer Zelle in die Buchungen dahinter. */ +$drilldown = static function (string $zeile, string $spalte, bool $ast) use ($query, $id): string { + return '/auswertungen/buchungen?' . $query . '&' . http_build_query([ + 'zeile' => $zeile, + 'spalte' => $spalte === 'gesamt' ? '' : $spalte, + 'ast' => $ast ? '1' : '0', + 'bericht_id' => $id, + ]); +}; + +/** + * Eine Wertzelle. Die Null bleibt leer: In einem Bericht mit zwölf Spalten sind + * die meisten Zellen leer, und ausgeschriebene Nullen verdecken das Wenige, das + * dasteht. + */ +$zelle = static function (float $wert): string { + return abs($wert) < 0.005 ? '' : View::betrag($wert, false); +}; +?> + +
+ + + + + + + + + + + 1): ?> + + + + + + + + + + + + + + + + + + + + + + + + 1): ?> + + + + + + + + + + + + + + 1): ?> + + + + + + + + + + + + + + + + 1): ?> + + + + +
DifferenzVeränderungSumme
+ +
+ + + = 0.005): ?> + + + + + + + + + + +
Gesamt + + + + + + + +
+ + + + + + + +
+
+ +

+ Ausgaben stehen ohne Minus – der Abschnitt sagt bereits, worum es geht. Die Schlusszeile + rechnet mit den echten Vorzeichen. Ein Klick auf eine Zahl zeigt die Buchungen dahinter. +

diff --git a/src/Views/cockpit/index.php b/src/Views/cockpit/index.php index d8ba280..44d0619 100644 --- a/src/Views/cockpit/index.php +++ b/src/Views/cockpit/index.php @@ -47,7 +47,14 @@ include __DIR__ . '/../layout/kopf.php';

Einnahmen und Ausgaben

-

Monatsvergleich aus den kategorisierten Buchungen (Phase 4).

+

+ Der Monatsvergleich als Grafik folgt noch. Die Zahlen dazu stehen bereits im + Summenbericht – mit Spalten-Aufteilung nach Monat. +

+
@@ -94,7 +101,8 @@ include __DIR__ . '/../layout/kopf.php';
  • Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten
  • Phase 2 – Buchungen, Kontoblatt, Splitbuchungen
  • Phase 3 – Budgetplaner und Monatsabschluss
  • -
  • Phase 4 – Auswertungen und Cockpit-Kacheln
  • +
  • Phase 4 – Berichtsmodul mit fünf Berichtstypen
  • +
  • Phase 4 – die übrigen Grafiken und Cockpit-Kacheln
  • diff --git a/tests/14_AuswertungTest.php b/tests/14_AuswertungTest.php new file mode 100644 index 0000000..0c790e2 --- /dev/null +++ b/tests/14_AuswertungTest.php @@ -0,0 +1,637 @@ +mandantAnlegen(); + $this->stammdaten(); + $this->buchungen(); + + $this->zeitraumVorgabenRechnenAusHeute(); + $this->spaltenTeilenDenZeitraum(); + $this->vorjahrBleibtAufDemMonatsletzten(); + $this->filterWirdGeprueft(); + + $this->summenbericht(); + $this->summenbericht_umbuchungBleibtDraussen(); + $this->summenbericht_astsummen(); + $this->summenbericht_ohneEinrueckung(); + $this->summenbericht_spalten(); + $this->summenbericht_andereZeilensummen(); + $this->summenbericht_einschraenkungen(); + + $this->buchungsbericht(); + $this->vergleichsbericht(); + $this->kontostandsbericht(); + $this->budgetbericht(); + + $this->drilldown(); + $this->csvExport(); + $this->gespeicherteBerichte(); + } + + // ── Aufbau ─────────────────────────────────────────────────────────────── + + private function stammdaten(): void + { + $this->giro = Konto::anlegen([ + 'name' => 'Auswertungsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1, + ]); + $this->spar = Konto::anlegen([ + 'name' => 'Auswertungssparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 500, 'aktiv' => 1, + ]); + + $this->leben = Kategorie::anlegen(['name' => '400 - Auswertungsleben', 'art' => 'ausgabe']); + $this->einkauf = Kategorie::anlegen(['name' => 'A-Einkaufen', 'art' => 'ausgabe', 'eltern_id' => $this->leben]); + $this->freizeit = Kategorie::anlegen(['name' => 'A-Freizeit', 'art' => 'ausgabe', 'eltern_id' => $this->leben]); + $this->gehalt = Kategorie::anlegen(['name' => 'Auswertungsgehalt', 'art' => 'einnahme']); + + $this->privat = Klasse::anlegen('Auswertung-Privat'); + $this->urlaub = Klasse::anlegen('Auswertung-Urlaub'); + } + + /** + * Juli 2026: 3.000 Einnahmen, 237,50 Ausgaben (davon 7,50 ohne Kategorie), + * dazu eine Umbuchung über 200 und ein Vorjahresfall im Juli 2025. + */ + private function buchungen(): void + { + Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-07-05', 'betrag' => -100.0, + 'empfaenger' => 'Edeka', 'kategorie_id' => $this->einkauf, 'klasse_id' => $this->privat]); + Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-07-20', 'betrag' => -50.0, + 'empfaenger' => 'Kino', 'kategorie_id' => $this->freizeit, 'klasse_id' => $this->urlaub]); + Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-07-31', 'betrag' => 3000.0, + 'empfaenger' => 'Arbeitgeber', 'kategorie_id' => $this->gehalt]); + + // Umbuchung: kein Einnahme-/Ausgabevorgang. + Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-07-15', 'betrag' => -200.0, + 'empfaenger' => 'Sparen', 'gegenkonto_id' => $this->spar]); + + // Ohne Kategorie – muss als eigene Zeile erscheinen, nicht verschwinden. + Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-07-18', 'betrag' => -7.5, + 'empfaenger' => 'Unbekannt']); + + // Splitbuchung: zählt über ihre Zeilen, nicht doppelt. + Buchung::anlegen( + ['konto_id' => $this->giro, 'datum' => '2026-07-25', 'betrag' => -80.0, 'empfaenger' => 'Rewe'], + [ + ['kategorie_id' => $this->einkauf, 'betrag' => -60.0, 'notiz' => 'Lebensmittel'], + ['kategorie_id' => $this->freizeit, 'betrag' => -20.0, 'notiz' => 'Zeitschrift'], + ] + ); + + // August, für die Spaltenaufteilung. + Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-08-03', 'betrag' => -25.0, + 'empfaenger' => 'Edeka', 'kategorie_id' => $this->einkauf]); + + // Vorjahr, für den Vergleichsbericht. + Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2025-07-05', 'betrag' => -80.0, + 'empfaenger' => 'Edeka', 'kategorie_id' => $this->einkauf]); + } + + /** @param array $zusatz */ + private function bericht(array $zusatz = []): array + { + return Auswertung::bericht($zusatz + [ + 'zeitraum' => 'benutzerdefiniert', 'von' => '2026-07-01', 'bis' => '2026-07-31', + ], self::HEUTE); + } + + /** @return array|null */ + private function abschnitt(array $bericht, string $art): ?array + { + foreach ($bericht['abschnitte'] as $abschnitt) { + if ($abschnitt['schluessel'] === $art) { + return $abschnitt; + } + } + return null; + } + + /** @return array|null */ + private function zeile(array $abschnitt, string $name): ?array + { + foreach ($abschnitt['zeilen'] as $zeile) { + if ($zeile['name'] === $name) { + return $zeile; + } + } + return null; + } + + // ── Zeitraum ───────────────────────────────────────────────────────────── + + private function zeitraumVorgabenRechnenAusHeute(): void + { + $faelle = [ + 'aktueller_monat' => ['2026-08-01', '2026-08-31'], + 'letzter_monat' => ['2026-07-01', '2026-07-31'], + 'aktuelles_quartal' => ['2026-07-01', '2026-09-30'], + 'letztes_quartal' => ['2026-04-01', '2026-06-30'], + 'aktuelles_jahr' => ['2026-01-01', '2026-12-31'], + 'letztes_jahr' => ['2025-01-01', '2025-12-31'], + 'letzte_12_monate' => ['2025-09-01', '2026-08-31'], + ]; + + foreach ($faelle as $vorgabe => [$von, $bis]) { + $z = Zeitraum::berechne($vorgabe, '', '', self::HEUTE); + $this->assertEquals($von, $z['von'], "Zeitraum {$vorgabe} beginnt richtig"); + $this->assertEquals($bis, $z['bis'], "Zeitraum {$vorgabe} endet richtig"); + } + + $eigen = Zeitraum::berechne('benutzerdefiniert', '2026-03-01', '2026-03-15', self::HEUTE); + $this->assertEquals('2026-03-01', $eigen['von'], 'Benutzerdefiniert übernimmt von'); + $this->assertEquals('2026-03-15', $eigen['bis'], 'Benutzerdefiniert übernimmt bis'); + + // Verdrehte Eingaben werden getauscht, nicht abgewiesen. + $verdreht = Zeitraum::berechne('benutzerdefiniert', '2026-03-15', '2026-03-01', self::HEUTE); + $this->assertEquals('2026-03-01', $verdreht['von'], 'Verdrehter Zeitraum wird getauscht'); + + $unbekannt = Zeitraum::berechne('gibtesnicht', '', '', self::HEUTE); + $this->assertEquals('aktuelles_jahr', $unbekannt['vorgabe'], 'Unbekannte Vorgabe fällt zurück'); + + // „Gesamter Zeitraum“ nimmt die Datumsgrenzen der Buchungen. + $grenzen = Buchung::datumsgrenzen(); + $this->assertEquals('2025-07-05', $grenzen['von'], 'Früheste Buchung erkannt'); + $alles = Zeitraum::berechne('alles', '', '', self::HEUTE, $grenzen); + $this->assertEquals('2025-07-05', $alles['von'], 'Gesamter Zeitraum beginnt bei der ersten Buchung'); + $this->assertEquals(self::HEUTE, $alles['bis'], 'Gesamter Zeitraum endet heute, nicht in der Zukunft'); + } + + private function spaltenTeilenDenZeitraum(): void + { + $monate = Zeitraum::spalten('monat', '2026-01-01', '2026-12-31'); + $this->assertEquals(12, count($monate), 'Ein Jahr hat zwölf Monatsspalten'); + $this->assertEquals('2026-01', $monate[0]['schluessel'], 'Erste Monatsspalte'); + $this->assertEquals('2026-12-31', $monate[11]['bis'], 'Letzte Monatsspalte endet im Dezember'); + + $quartale = Zeitraum::spalten('quartal', '2026-01-01', '2026-12-31'); + $this->assertEquals(4, count($quartale), 'Ein Jahr hat vier Quartalsspalten'); + $this->assertEquals('2026-Q2', $quartale[1]['schluessel'], 'Zweites Quartal'); + + $jahre = Zeitraum::spalten('jahr', '2025-06-01', '2026-03-31'); + $this->assertEquals(2, count($jahre), 'Zwei berührte Jahre, zwei Spalten'); + + // Die Randspalten werden auf den Zeitraum gestutzt. + $teil = Zeitraum::spalten('monat', '2026-01-15', '2026-03-20'); + $this->assertEquals('2026-01-15', $teil[0]['von'], 'Erste Spalte beginnt am Zeitraumanfang'); + $this->assertEquals('2026-03-20', $teil[2]['bis'], 'Letzte Spalte endet am Zeitraumende'); + + $keine = Zeitraum::spalten('keine', '2026-01-01', '2026-12-31'); + $this->assertEquals(1, count($keine), 'Ohne Aufteilung genau eine Spalte'); + $this->assertEquals('gesamt', $keine[0]['schluessel'], 'Sammelspalte heißt gesamt'); + + $this->assertEquals('2026-07', Zeitraum::spaltenschluessel('monat', '2026-07-15'), 'Monatsschlüssel'); + $this->assertEquals('2026-Q3', Zeitraum::spaltenschluessel('quartal', '2026-07-15'), 'Quartalsschlüssel'); + $this->assertEquals('2026', Zeitraum::spaltenschluessel('jahr', '2026-07-15'), 'Jahresschlüssel'); + } + + private function vorjahrBleibtAufDemMonatsletzten(): void + { + $normal = Zeitraum::vorjahr('2026-07-01', '2026-07-31'); + $this->assertEquals('2025-07-01', $normal['von'], 'Vergleichszeitraum beginnt ein Jahr früher'); + $this->assertEquals('2025-07-31', $normal['bis'], 'Vergleichszeitraum endet ein Jahr früher'); + + // Der 28.02. eines Normaljahres ist ein Monatsende und bleibt eines, + // wenn das Vorjahr ein Schaltjahr war. + $schalt = Zeitraum::vorjahr('2025-02-01', '2025-02-28'); + $this->assertEquals('2024-02-29', $schalt['bis'], 'Monatsende bleibt Monatsende im Schaltjahr'); + + // Mitten im Monat wird nicht auf das Monatsende gezogen. + $mitten = Zeitraum::vorjahr('2026-07-01', '2026-07-15'); + $this->assertEquals('2025-07-15', $mitten['bis'], 'Ein Datum mitten im Monat bleibt es'); + } + + private function filterWirdGeprueft(): void + { + $roh = Auswertung::normalisiere([ + 'typ' => 'quatsch', 'zeilensumme' => 'quatsch', 'aufteilung' => 'quatsch', + 'zeitraum' => 'quatsch', 'von' => 'kein Datum', 'konten' => ['3', 'x', 0, 3], + 'unbekannt' => 'wird verworfen', + ]); + + $this->assertEquals('summenbericht', $roh['typ'], 'Unbekannter Typ fällt auf Summenbericht'); + $this->assertEquals('kategorie', $roh['zeilensumme'], 'Unbekannte Zeilen-Summe fällt auf Kategorie'); + $this->assertEquals('keine', $roh['aufteilung'], 'Unbekannte Aufteilung fällt auf keine'); + $this->assertEquals('', $roh['von'], 'Ungültiges Datum wird verworfen'); + $this->assertEquals([3], $roh['konten'], 'Kontenliste wird bereinigt und entdoppelt'); + $this->assert(!array_key_exists('unbekannt', $roh), 'Unbekannte Felder fallen weg'); + + // Ankreuzfelder: Das Formular schickt vor der Checkbox ein verstecktes + // Feld mit „0“, sonst ließe sich ein Häkchen nie abwählen – ein nicht + // angehaktes Feld sendet gar nichts und fiele auf die Vorgabe zurück. + $this->assert(Auswertung::normalisiere([])['einrueckung'], 'Einrückung ist im Standard an'); + $this->assert(!Auswertung::normalisiere(['einrueckung' => '0'])['einrueckung'], + 'Eine abgewählte Einrückung bleibt abgewählt'); + $this->assert(Auswertung::normalisiere(['einrueckung' => '1'])['einrueckung'], + 'Ein gesetztes Häkchen wird übernommen'); + $this->assert(!Auswertung::normalisiere([])['umbuchungen'], 'Umbuchungen sind im Standard aus'); + + // Zwei Berichte bestimmen ihre Zeilen selbst. + $konto = Auswertung::normalisiere(['typ' => 'kontostandsbericht', 'zeilensumme' => 'empfaenger']); + $this->assertEquals('konto', $konto['zeilensumme'], 'Kontostandsbericht führt immer Konten'); + + $budget = Auswertung::normalisiere(['typ' => 'budgetbericht', 'zeilensumme' => 'konto']); + $this->assertEquals('kategorie', $budget['zeilensumme'], 'Budgetbericht führt immer Kategorien'); + } + + // ── Summenbericht ──────────────────────────────────────────────────────── + + private function summenbericht(): void + { + $bericht = $this->bericht(['typ' => 'summenbericht']); + + $einnahmen = $this->abschnitt($bericht, 'einnahme'); + $ausgaben = $this->abschnitt($bericht, 'ausgabe'); + + $this->assertEquals(3000.0, $einnahmen['summe'], 'Einnahmen des Juli'); + $this->assertEquals(237.5, $ausgaben['summe'], 'Ausgaben des Juli, ohne Minus'); + $this->assertEquals(2762.5, $bericht['gesamt']['summe'], 'Einnahmen minus Ausgaben'); + + // Die Splitzeilen zählen einzeln zu ihren Kategorien. + $this->assertEquals(160.0, $this->zeile($ausgaben, 'A-Einkaufen')['summe'], 'Einkauf inklusive Splitzeile'); + $this->assertEquals(70.0, $this->zeile($ausgaben, 'A-Freizeit')['summe'], 'Freizeit inklusive Splitzeile'); + + // Unkategorisiertes verschwindet nicht. + $ohne = $this->zeile($ausgaben, Auswertung::OHNE_KATEGORIE); + $this->assertNotEmpty($ohne, 'Zeile für unkategorisierte Buchungen ist da'); + $this->assertEquals(7.5, $ohne['summe'], 'Unkategorisierte Ausgabe erscheint'); + $this->assert($ohne['ohne'], 'Die Zeile ist als „ohne Zuordnung“ gekennzeichnet'); + + $this->assert(!$bericht['leer'], 'Der Bericht gilt nicht als leer'); + $this->assertEquals('Summenbericht', $bericht['typname'], 'Typname steht im Bericht'); + } + + private function summenbericht_umbuchungBleibtDraussen(): void + { + $bericht = $this->bericht(['typ' => 'summenbericht']); + + // 200 Umbuchung dürfen weder unter Ausgaben noch in der Gesamtsumme stehen. + $this->assertEquals(237.5, $this->abschnitt($bericht, 'ausgabe')['summe'], + 'Umbuchung zählt nicht als Ausgabe'); + + $konten = $this->bericht(['typ' => 'summenbericht', 'zeilensumme' => 'konto']); + $this->assertNull($this->zeile($this->abschnitt($konten, 'ausgabe'), 'Auswertungssparbuch'), + 'Das Zielkonto der Umbuchung taucht nicht auf'); + } + + private function summenbericht_astsummen(): void + { + $ausgaben = $this->abschnitt($this->bericht(['typ' => 'summenbericht']), 'ausgabe'); + + $kopf = $this->zeile($ausgaben, '400 - Auswertungsleben:'); + $this->assertNotEmpty($kopf, 'Die Elternkategorie steht als Kopfzeile da'); + $this->assertEquals(0.0, $kopf['summe'], 'Die Kopfzeile zeigt nur eigene Buchungen – hier keine'); + $this->assert($kopf['kopf'], 'Die Kopfzeile ist als solche gekennzeichnet'); + + $ast = $this->zeile($ausgaben, 'Gesamt 400 - Auswertungsleben'); + $this->assertNotEmpty($ast, 'Nach den Kindern folgt die Astsumme'); + $this->assertEquals(230.0, $ast['summe'], 'Die Astsumme zählt alle Nachfahren'); + $this->assert($ast['astsumme'], 'Die Astsumme ist als solche gekennzeichnet'); + + // Reihenfolge: Kopf, Kinder, Astsumme. + $namen = array_column($ausgaben['zeilen'], 'name'); + $this->assert( + array_search('400 - Auswertungsleben:', $namen, true) + < array_search('A-Einkaufen', $namen, true), + 'Die Kopfzeile steht vor ihren Kindern' + ); + $this->assert( + array_search('A-Freizeit', $namen, true) + < array_search('Gesamt 400 - Auswertungsleben', $namen, true), + 'Die Astsumme steht hinter ihren Kindern' + ); + } + + private function summenbericht_ohneEinrueckung(): void + { + $ausgaben = $this->abschnitt( + $this->bericht(['typ' => 'summenbericht', 'einrueckung' => false]), + 'ausgabe' + ); + + $namen = array_column($ausgaben['zeilen'], 'name'); + $this->assert( + in_array('400 - Auswertungsleben:A-Einkaufen', $namen, true), + 'Ohne Einrückung steht der volle Pfad' + ); + $this->assert( + !in_array('Gesamt 400 - Auswertungsleben', $namen, true), + 'Ohne Einrückung gibt es keine Astsummen' + ); + $this->assertEquals(237.5, $ausgaben['summe'], 'Die Abschnittssumme bleibt gleich'); + } + + private function summenbericht_spalten(): void + { + $bericht = Auswertung::bericht([ + 'typ' => 'summenbericht', 'zeitraum' => 'benutzerdefiniert', + 'von' => '2026-07-01', 'bis' => '2026-08-31', 'aufteilung' => 'monat', + ], self::HEUTE); + + $this->assertEquals(2, count($bericht['spalten']), 'Zwei Monate, zwei Spalten'); + + $einkauf = $this->zeile($this->abschnitt($bericht, 'ausgabe'), 'A-Einkaufen'); + $this->assertEquals(160.0, $einkauf['werte']['2026-07'], 'Juli-Wert in der Julispalte'); + $this->assertEquals(25.0, $einkauf['werte']['2026-08'], 'August-Wert in der Augustspalte'); + $this->assertEquals(185.0, $einkauf['summe'], 'Die Zeilensumme ist die Summe der Spalten'); + } + + private function summenbericht_andereZeilensummen(): void + { + $klassen = $this->bericht(['typ' => 'summenbericht', 'zeilensumme' => 'klasse']); + $ausgabe = $this->abschnitt($klassen, 'ausgabe'); + $this->assertEquals(100.0, $this->zeile($ausgabe, 'Auswertung-Privat')['summe'], 'Klasse Privat'); + $this->assertEquals(50.0, $this->zeile($ausgabe, 'Auswertung-Urlaub')['summe'], 'Klasse Urlaub'); + $this->assertNotEmpty($this->zeile($ausgabe, Auswertung::OHNE_KLASSE), 'Buchungen ohne Klasse eigene Zeile'); + + $empfaenger = $this->bericht(['typ' => 'summenbericht', 'zeilensumme' => 'empfaenger']); + $this->assertEquals(100.0, + $this->zeile($this->abschnitt($empfaenger, 'ausgabe'), 'Edeka')['summe'], 'Empfänger Edeka'); + $this->assertEquals(3000.0, + $this->zeile($this->abschnitt($empfaenger, 'einnahme'), 'Arbeitgeber')['summe'], 'Empfänger Arbeitgeber'); + + $konten = $this->bericht(['typ' => 'summenbericht', 'zeilensumme' => 'konto']); + $this->assertEquals(237.5, + $this->zeile($this->abschnitt($konten, 'ausgabe'), 'Auswertungsgiro')['summe'], 'Alle Ausgaben auf dem Giro'); + } + + private function summenbericht_einschraenkungen(): void + { + // Eine Elternkategorie schließt ihre Nachfahren ein. + $ast = $this->bericht(['typ' => 'summenbericht', 'kategorien' => [$this->leben]]); + $this->assertEquals(230.0, $this->abschnitt($ast, 'ausgabe')['summe'], + 'Der Kategoriefilter nimmt die Unterkategorien mit'); + + $eine = $this->bericht(['typ' => 'summenbericht', 'kategorien' => [$this->freizeit]]); + $this->assertEquals(70.0, $this->abschnitt($eine, 'ausgabe')['summe'], + 'Eine Unterkategorie allein zählt nur sich'); + + $klasse = $this->bericht(['typ' => 'summenbericht', 'klassen' => [$this->privat]]); + $this->assertEquals(100.0, $this->abschnitt($klasse, 'ausgabe')['summe'], 'Klassenfilter greift'); + + $konto = $this->bericht(['typ' => 'summenbericht', 'konten' => [$this->spar]]); + $this->assert($konto['leer'], 'Auf dem Sparbuch gibt es keine kategorisierten Buchungen'); + + $suche = $this->bericht(['typ' => 'summenbericht', 'suche' => 'Edeka']); + $this->assertEquals(100.0, $this->abschnitt($suche, 'ausgabe')['summe'], 'Die Suche filtert auf den Empfänger'); + } + + // ── Weitere Berichtstypen ──────────────────────────────────────────────── + + private function buchungsbericht(): void + { + $bericht = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto']); + + // Fünf einfache Buchungen minus Umbuchung, plus zwei Splitzeilen. + $this->assertEquals(6, $bericht['anzahl'], 'Splitzeilen zählen einzeln, die Umbuchung gar nicht'); + $this->assertEquals(2762.5, $bericht['summe'], 'Die Summe entspricht dem Summenbericht'); + $this->assertEquals(1, count($bericht['gruppen']), 'Alles auf einem Konto, also eine Gruppe'); + + $erste = $bericht['gruppen'][0]['buchungen'][0]; + $this->assertEquals('2026-07-05', $erste['datum'], 'Chronologisch sortiert'); + $this->assertEquals('400 - Auswertungsleben:A-Einkaufen', $erste['kategorie'], 'Kategorie als voller Pfad'); + + // Mit Umbuchungen: eine Buchung und 200 € mehr. + $mit = $this->bericht(['typ' => 'buchungsbericht', 'zeilensumme' => 'konto', 'umbuchungen' => true]); + $this->assertEquals(7, $mit['anzahl'], 'Mit Schalter kommt die Umbuchung dazu'); + $this->assertEquals(2562.5, $mit['summe'], 'Die Umbuchung verschiebt die Summe'); + + $umbuchung = null; + foreach ($mit['gruppen'][0]['buchungen'] as $buchung) { + if ($buchung['empfaenger'] === 'Sparen') { + $umbuchung = $buchung; + } + } + $this->assertEquals('[Auswertungssparbuch]', $umbuchung['kategorie'], + 'Die Umbuchung nennt ihr Gegenkonto in Klammern'); + } + + private function vergleichsbericht(): void + { + $bericht = $this->bericht(['typ' => 'vergleichsbericht']); + + $this->assertEquals(2, count($bericht['spalten']), 'Zeitraum und Vorjahr'); + $this->assertEquals('2025-07-01', $bericht['spalten'][1]['von'], 'Die Vergleichsspalte ist der Vorjahresmonat'); + + $ausgaben = $this->abschnitt($bericht, 'ausgabe'); + $this->assertEquals(237.5, $ausgaben['werte']['jetzt'], 'Ausgaben im Zeitraum'); + $this->assertEquals(80.0, $ausgaben['werte']['vorher'], 'Ausgaben im Vorjahr'); + $this->assertEquals(157.5, $ausgaben['differenz'], 'Differenz beider Spalten'); + $this->assertEquals(196.9, $ausgaben['prozent'], 'Veränderung in Prozent'); + + // Ohne Vorjahreswert gibt es keinen Prozentsatz. + $einnahmen = $this->abschnitt($bericht, 'einnahme'); + $this->assertEquals(0.0, $einnahmen['werte']['vorher'], 'Im Vorjahr keine Einnahmen'); + $this->assertNull($einnahmen['prozent'], 'Ohne Vorjahreswert kein Prozentsatz'); + } + + private function kontostandsbericht(): void + { + $bericht = Auswertung::bericht([ + 'typ' => 'kontostandsbericht', 'zeitraum' => 'benutzerdefiniert', + 'von' => '2026-07-01', 'bis' => '2026-08-31', 'aufteilung' => 'monat', + ], self::HEUTE); + + $giro = null; + $spar = null; + foreach ($bericht['zeilen'] as $zeile) { + if ($zeile['name'] === 'Auswertungsgiro') { + $giro = $zeile; + } + if ($zeile['name'] === 'Auswertungssparbuch') { + $spar = $zeile; + } + } + + // 1000 Anfangssaldo, −80 aus 2025, +2562,50 im Juli. + $this->assertEquals(3482.5, $giro['werte']['2026-07'], 'Girostand am Monatsende Juli'); + $this->assertEquals(3457.5, $giro['werte']['2026-08'], 'Girostand nach dem August'); + + // Die Gegenseite der Umbuchung ist dabei, obwohl sie auf dem Girokonto gebucht wurde. + $this->assertEquals(700.0, $spar['werte']['2026-07'], 'Das Sparbuch bekommt die Umbuchung gutgeschrieben'); + + $this->assertEquals(4157.5, $bericht['gesamt']['werte']['2026-08'], 'Die Summe aller gezeigten Konten'); + } + + private function budgetbericht(): void + { + $budget = Budget::anlegen('Auswertungsbudget 2026', 2026, '', true); + Budget::positionSetzen($budget, $this->einkauf, 7, 120.0); + Budget::positionSetzen($budget, $this->freizeit, 7, 100.0); + + $bericht = $this->bericht(['typ' => 'budgetbericht']); + + $zeilen = []; + foreach ($bericht['zeilen'] as $zeile) { + $zeilen[$zeile['name']] = $zeile; + } + + // Vorzeichen wie im Budgetplaner: Ausgaben negativ. + $this->assertEquals(-120.0, $zeilen['A-Einkaufen']['budget'], 'Budget der Einkaufskategorie'); + $this->assertEquals(-160.0, $zeilen['A-Einkaufen']['ist'], 'Ist der Einkaufskategorie'); + $this->assertEquals(-40.0, $zeilen['A-Einkaufen']['saldo'], 'Überschreitung ist negativ'); + + $this->assertEquals(30.0, $zeilen['A-Freizeit']['saldo'], 'Unterschreitung ist positiv'); + + // Die Elternkategorie summiert ihren Ast. + $this->assertEquals(-220.0, $zeilen['400 - Auswertungsleben']['budget'], 'Astsumme des Budgets'); + $this->assertEquals(-230.0, $zeilen['400 - Auswertungsleben']['ist'], 'Astsumme des Ist-Werts'); + + $this->assertEquals('Jul 2026', $bericht['monatsbereich'], 'Der Monatsbereich steht im Bericht'); + $this->assertNotEmpty($bericht['budgets'], 'Das Budget des Jahres wurde gefunden'); + + // Ein Zeitraum mitten im Monat wird für den Vergleich auf volle Monate gezogen. + $halb = Auswertung::bericht([ + 'typ' => 'budgetbericht', 'zeitraum' => 'benutzerdefiniert', + 'von' => '2026-07-10', 'bis' => '2026-07-20', + ], self::HEUTE); + $halbeZeilen = []; + foreach ($halb['zeilen'] as $zeile) { + $halbeZeilen[$zeile['name']] = $zeile; + } + $this->assertEquals(-160.0, $halbeZeilen['A-Einkaufen']['ist'], + 'Der Budgetbericht rechnet über volle Monate, nicht über Teilzeiträume'); + } + + // ── Drilldown und Export ───────────────────────────────────────────────── + + private function drilldown(): void + { + $filter = ['typ' => 'summenbericht', 'zeitraum' => 'benutzerdefiniert', + 'von' => '2026-07-01', 'bis' => '2026-07-31']; + + $einkauf = Auswertung::zeilenbuchungen($filter, 'kategorie:' . $this->einkauf, '', false, self::HEUTE); + $this->assertEquals(2, count($einkauf['buchungen']), 'Zwei Buchungen in der Einkaufskategorie'); + $this->assertEquals(-160.0, $einkauf['summe'], 'Mit buchhalterischem Vorzeichen'); + $this->assertEquals('400 - Auswertungsleben:A-Einkaufen', $einkauf['name'], 'Der Zeilenname als Überschrift'); + + // Die Astsumme zieht die Unterkategorien mit. + $ast = Auswertung::zeilenbuchungen($filter, 'kategorie:' . $this->leben, '', true, self::HEUTE); + $this->assertEquals(4, count($ast['buchungen']), 'Der Ast enthält alle Buchungen der Unterkategorien'); + $this->assertEquals(-230.0, $ast['summe'], 'Die Astsumme stimmt mit dem Bericht überein'); + + // Was kein Filter ausdrücken könnte: die Zeile „Nicht kategorisiert“. + $ohne = Auswertung::zeilenbuchungen($filter, 'kategorie:0', '', false, self::HEUTE); + $this->assertEquals(1, count($ohne['buchungen']), 'Eine unkategorisierte Buchung'); + $this->assertEquals(Auswertung::OHNE_KATEGORIE, $ohne['name'], 'Überschrift für die Zeile ohne Zuordnung'); + + // Eine Spalte schränkt den Zeitraum ein. + $august = Auswertung::zeilenbuchungen( + ['aufteilung' => 'monat', 'von' => '2026-07-01', 'bis' => '2026-08-31'] + $filter, + 'kategorie:' . $this->einkauf, + '2026-08', + false, + self::HEUTE + ); + $this->assertEquals(1, count($august['buchungen']), 'Nur die Augustbuchung'); + $this->assertEquals(-25.0, $august['summe'], 'Der Wert der Augustzelle'); + } + + private function csvExport(): void + { + $bericht = $this->bericht(['typ' => 'summenbericht']); + $csv = BerichtExport::csv($bericht); + + $this->assert(str_starts_with($csv, "\u{FEFF}"), 'Das CSV beginnt mit einem BOM'); + $this->assert(str_contains($csv, "\r\n"), 'Zeilenenden sind CRLF'); + $this->assert(str_contains($csv, 'A-Einkaufen;160,00'), 'Dezimalkomma und Semikolon'); + $this->assert(str_contains($csv, '01.07.2026 bis 31.07.2026 (in Euro)'), 'Der Zeitraum steht im Kopf'); + $this->assert(str_contains($csv, 'Gesamt Einnahmen − Ausgaben;2762,50'), 'Die Schlusszeile ist dabei'); + + // Ein Feld mit Semikolon muss maskiert werden, sonst zerfällt die Zeile. + // Bewusst ohne Kategorie: So verändert die Buchung keinen Kategorieast, + // den ein späterer Test noch prüft. + Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '2026-07-06', 'betrag' => -5.0, + 'empfaenger' => 'Müller; Söhne']); + $mitTrenner = BerichtExport::csv($this->bericht(['typ' => 'summenbericht', 'zeilensumme' => 'empfaenger'])); + $this->assert(str_contains($mitTrenner, '"Müller; Söhne"'), 'Semikolon im Feld wird maskiert'); + + $this->assertEquals( + 'Summenbericht-2026-07-01.csv', + BerichtExport::dateiname($bericht), + 'Der Dateiname trägt Typ und Beginn des Zeitraums' + ); + + $buchungen = BerichtExport::csv($this->bericht(['typ' => 'buchungsbericht'])); + $this->assert(str_contains($buchungen, 'Datum;Konto;Empfänger'), 'Der Buchungsbericht hat eigene Spalten'); + } + + // ── Gespeicherte Berichte ──────────────────────────────────────────────── + + private function gespeicherteBerichte(): void + { + $gruppe = Berichtsgruppe::anlegen('Auswertung-Bankgeschäfte'); + $id = Bericht::anlegen('Auswertung-Lebenshaltung', [ + 'typ' => 'summenbericht', 'zeitraum' => 'letzter_monat', 'aufteilung' => 'monat', + 'kategorien' => [$this->leben], + ], $gruppe); + + $gespeichert = Bericht::find($id); + $this->assertEquals('summenbericht', $gespeichert['typ'], 'Der Typ steht auch in der eigenen Spalte'); + + $filter = Bericht::filter($gespeichert); + $this->assertEquals('letzter_monat', $filter['zeitraum'], 'Der Zeitraum kommt zurück'); + $this->assertEquals([$this->leben], $filter['kategorien'], 'Die Kategorieauswahl kommt zurück'); + + // Ein gespeicherter Zeitraum rechnet sich beim Öffnen neu aus. + $bericht = Auswertung::bericht($filter, self::HEUTE); + $this->assertEquals('2026-07-01', $bericht['zeitraum']['von'], + '„Letzter Monat“ meint im August den Juli'); + $this->assertEquals(230.0, $this->abschnitt($bericht, 'ausgabe')['summe'], + 'Der gespeicherte Filter liefert dieselben Zahlen'); + + // Namen sind eindeutig. + $doppelt = false; + try { + Bericht::anlegen('Auswertung-Lebenshaltung', ['typ' => 'summenbericht']); + } catch (\RuntimeException) { + $doppelt = true; + } + $this->assert($doppelt, 'Zwei Berichte gleichen Namens gehen nicht'); + + $nachGruppe = Bericht::nachGruppe(); + $this->assertEquals(1, count($nachGruppe[$gruppe]['berichte']), 'Der Bericht hängt in seiner Gruppe'); + + // Beim Löschen der Gruppe bleibt der Bericht erhalten. + Berichtsgruppe::loeschen($gruppe); + $this->assertNull(Bericht::find($id)['gruppe_id'], 'Der Bericht verliert nur seine Gruppe'); + $this->assertNotEmpty(Bericht::find($id), 'Der Bericht selbst bleibt'); + + // „Letzte Berichte“ füllt sich erst beim Öffnen. + $this->assertEquals(0, count(Bericht::letzte()), 'Vor dem Öffnen ist die Liste leer'); + Bericht::benutzt($id); + $this->assertEquals(1, count(Bericht::letzte()), 'Nach dem Öffnen steht er in der Liste'); + + Bericht::loeschen($id); + $this->assertNull(Bericht::find($id), 'Gelöscht ist gelöscht'); + } +} diff --git a/tests/e2e/specs/auswertungen.spec.js b/tests/e2e/specs/auswertungen.spec.js new file mode 100644 index 0000000..2a8c7a6 --- /dev/null +++ b/tests/e2e/specs/auswertungen.spec.js @@ -0,0 +1,169 @@ +// @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) + +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(/\/$/) +} + +/** Öffnet einen ungespeicherten Bericht des gewünschten Typs. */ +async function vorlage(page, typ, weitere = {}) { + const parameter = new URLSearchParams({ + typ, zeitraum: 'aktuelles_jahr', angepasst: '1', ...weitere, + }) + await page.goto(`/auswertungen/bericht?${parameter}`) +} + +test.describe('Auswertungen', () => { + test.beforeEach(async ({ page }) => { + await anmelden(page) + }) + + test('Übersicht führt über die Standardvorlagen in den Summenbericht', async ({ page }) => { + await page.click('.seitenleiste a[href="/auswertungen"]') + await expect(page.locator('h1')).toContainText('Auswertungen') + + // Alle fünf Standardvorlagen stehen bereit. + const vorlagen = page.locator('.kachel', { hasText: 'Standardvorlagen' }).locator('.berichtsliste li') + await expect(vorlagen).toHaveCount(5) + + await page.click('.berichtsliste a:has-text("Summenbericht")') + await expect(page.locator('.berichtskopf h2')).toContainText('Summenbericht') + await expect(page.locator('.berichtskopf p')).toContainText('in Euro') + }) + + test('Die fünf Berichtstypen zeigen jeweils ihre eigene Tabelle', async ({ page }) => { + await vorlage(page, 'summenbericht') + await expect(page.locator('thead th').first()).toContainText('Kategoriebeschreibung') + + await vorlage(page, 'buchungsbericht') + await expect(page.locator('thead th').first()).toContainText('Datum') + + await vorlage(page, 'vergleichsbericht') + await expect(page.locator('thead th', { hasText: 'Veränderung' })).toHaveCount(1) + + await vorlage(page, 'kontostandsbericht') + await expect(page.locator('thead th').nth(1)).toContainText('Kontengruppe') + + await vorlage(page, 'budgetbericht') + await expect(page.locator('thead th', { hasText: 'Saldo' })).toHaveCount(1) + }) + + test('Spalten-Aufteilung macht aus einer Spalte zwölf Monate', async ({ page }) => { + await vorlage(page, 'summenbericht') + await expect(page.locator('table.bericht thead th')).toHaveCount(2) + + await page.selectOption('select[name="aufteilung"]', 'monat') + await page.click('button:has-text("Anzeigen")') + + // Bezeichnung, zwölf Monate, Summe. + await expect(page.locator('table.bericht thead th')).toHaveCount(14) + await expect(page.locator('thead th', { hasText: 'Januar' })).toHaveCount(1) + }) + + test('Zeilen-Summe wechselt die Dimension der Zeilen', async ({ page }) => { + await vorlage(page, 'summenbericht', { zeilensumme: 'empfaenger' }) + await expect(page.locator('table.bericht thead th').first()).toContainText('Bezeichnung') + + // Beim Kontostandsbericht ist die Wahl festgelegt und das Feld gesperrt. + await vorlage(page, 'kontostandsbericht') + await expect(page.locator('select[name="zeilensumme"]')).toBeDisabled() + }) + + test('Ein Klick auf eine Zahl führt in die Buchungen dahinter und zurück', async ({ page }) => { + await vorlage(page, 'summenbericht') + + const zelle = page.locator('table.bericht .wertlink').first() + const betrag = (await zelle.innerText()).trim() + await zelle.click() + + await expect(page.locator('h1')).not.toBeEmpty() + await expect(page.locator('.berichtskopf p')).toContainText('Kategorie') + + // Die Summe des Drilldowns ist derselbe Betrag – im Bericht ohne + // Vorzeichen, hier mit. + await expect(page.locator('tfoot .summenzeile')).toContainText(betrag.replace('-', '')) + + await page.click('a:has-text("Zurück zum Bericht")') + await expect(page.locator('.berichtskopf h2')).toContainText('Summenbericht') + }) + + test('Bericht speichern, in der Übersicht wiederfinden und löschen', async ({ page }) => { + page.on('dialog', d => d.accept()) + + const name = `E2E-Bericht ${MARKE}` + const gruppe = `E2E-Gruppe ${MARKE}` + + await vorlage(page, 'summenbericht', { zeitraum: 'letzter_monat', aufteilung: 'monat' }) + + await page.click('summary:has-text("Speichern unter")') + await page.fill('input[name="name"]', name) + await page.fill('input[name="neue_gruppe"]', gruppe) + await page.click('button:has-text("Als neuen Bericht speichern")') + + await expect(page.locator('.meldung-erfolg')).toContainText('gespeichert') + await expect(page).toHaveURL(/\/auswertungen\/bericht\/\d+/) + + // Der gespeicherte Filter kommt beim erneuten Öffnen zurück. + await expect(page.locator('select[name="zeitraum"]')).toHaveValue('letzter_monat') + await expect(page.locator('select[name="aufteilung"]')).toHaveValue('monat') + + // In der Übersicht steht er in seiner eigenen Gruppenkachel. + await page.goto('/auswertungen') + const kachel = page.locator('.kachel', { hasText: gruppe }) + await expect(kachel.locator('.berichtsliste a', { hasText: name })).toHaveCount(1) + + await kachel.locator('.berichtsliste a', { hasText: name }).click() + await page.click('button:has-text("Löschen")') + await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht') + await expect(page.locator('.berichtsliste a', { hasText: name })).toHaveCount(0) + + // Die Gruppe aufräumen; ihre Berichte sind bereits weg. Ihr Name steht + // dort in einem Eingabefeld, nicht als Text – deshalb über den Wert + // suchen und nicht über hasText. + await page.goto('/auswertungen/gruppen') + const zeile = page.locator('tr').filter({ has: page.locator(`input[value="${gruppe}"]`) }) + await zeile.locator('button:has-text("Löschen")').click() + await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht') + }) + + test('CSV-Ausgabe kommt als Datei mit deutschem Zahlenformat', async ({ page }) => { + await vorlage(page, 'summenbericht') + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.click('a:has-text("Als CSV")'), + ]) + + expect(download.suggestedFilename()).toMatch(/\.csv$/) + + const strom = await download.createReadStream() + const stuecke = [] + for await (const stueck of strom) { + stuecke.push(stueck) + } + const inhalt = Buffer.concat(stuecke).toString('utf8') + + expect(inhalt.startsWith('')).toBe(true) + expect(inhalt).toContain('Bezeichnung;') + expect(inhalt).toMatch(/;\d+,\d{2}/) + }) +})