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}/) + }) +})