Phase 4: Auswertungen mit Berichtsmodul

Fünf Berichtstypen wie im FinanzManager – Summen-, Buchungs-, Vergleichs-,
Kontostands- und Budgetbericht – mit Filterleiste, gespeicherten Berichten in
Berichtsgruppen, Drilldown in die Buchungen und CSV-Export.

Ein Bericht speichert nur seine Frage; die Zahlen entstehen bei jedem Aufruf neu
aus den Buchungen. Gezählt werden Bewegungen (gesplittete Buchungen über ihre
Splitzeilen), Umbuchungen bleiben außen vor, Unkategorisiertes bekommt eine
eigene Zeile. Im Ausgabenabschnitt steht der Betrag ohne Minus wie im Original,
die Schlusszeile rechnet mit den echten Vorzeichen.

Einzelheiten in docs/auswertungen.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-11 17:38:30 +02:00
parent 048bbf3894
commit 90bba06585
28 changed files with 4690 additions and 11 deletions

View file

@ -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 &lt;Name&gt;* 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 `<details>`-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

11
PLAN.md
View file

@ -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.

View file

@ -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

186
docs/auswertungen.md Normal file
View file

@ -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 &lt;Name&gt;*
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

View file

@ -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.

View file

@ -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());

View file

@ -1030,6 +1030,217 @@ body.schlicht {
color: var(--text);
}
/* ── Auswertungen ────────────────────────────────────────────────────────── */
/* Aufklappmenü ohne JavaScript: <details> 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) {

View file

@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Bericht;
use Finanzmanager\Models\Berichtsgruppe;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Auswertung;
use Finanzmanager\Services\BerichtExport;
use Finanzmanager\Services\Zeitraum;
use Finanzmanager\View;
/**
* Der Bereich *Auswertungen*: die Übersicht mit Berichtsgruppen und
* Standardvorlagen, die Berichtsansicht mit ihrer Filterleiste, der Drilldown
* in die Buchungen und der CSV-Export.
*
* Ein Bericht wird nie gerechnet gespeichert, sondern immer nur seine Frage.
* Die Filterleiste schickt ihre Felder deshalb per GET: Jeder Zwischenstand ist
* eine Adresse, die man sich merken, weitergeben und mit „Speichern unter“
* festhalten kann.
*/
class AuswertungenController extends BaseController
{
public function index(): void
{
$this->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<string,mixed>|null $gespeichert
* @param array<string,mixed> $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<string,mixed> $gespeichert
* @return array<string,mixed>
*/
private function filterFuer(array $gespeichert): array
{
return $this->query('angepasst') === '1'
? $this->filterAusQuery()
: Bericht::filter($gespeichert);
}
/** @return array<string,mixed> */
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<string,mixed> $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<string,mixed> $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;
}
}

View file

@ -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',

190
src/Models/Bericht.php Normal file
View file

@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Services\Auswertung;
/**
* Ein gespeicherter Bericht: Name, Berichtstyp und die Einstellungen der
* Filterleiste. Mehr ist er nicht – die Zahlen rechnet `Auswertung` bei jedem
* Aufruf frisch aus den Buchungen, gespeichert wird ausschließlich die Frage.
*
* Der Filter liegt als JSON in einer Spalte. Gelesen wird er nie roh, sondern
* immer durch `Auswertung::normalisiere()`: Was dort nicht vorgesehen ist,
* fällt weg. So kann ein alter Bericht nach einer Programmänderung höchstens
* eine Einstellung verlieren, aber nichts Unerwartetes in die Abfrage tragen.
*/
class Bericht
{
/** @return list<array<string,mixed>> */
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<int, array{gruppe: array<string,mixed>|null, berichte: list<array<string,mixed>>}>
*/
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<array<string,mixed>>
*/
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<string,mixed> $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<string,mixed> $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<string,mixed> $bericht
* @return array<string,mixed>
*/
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<string,mixed> $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);
}
}

View file

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Berichtsgruppen bündeln gespeicherte Berichte zu Kacheln der Auswertungs-
* übersicht – im FinanzManager heißen sie „Bankgeschäfte“, „Planung“, „Steuern“.
* Reine Ordnung, keine Fachlogik: Ein Bericht funktioniert auch ohne Gruppe und
* steht dann unter „Nicht gruppiert“.
*/
class Berichtsgruppe
{
/** @return list<array<string,mixed>> */
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);
}
}

View file

@ -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
{

View file

@ -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);

1225
src/Services/Auswertung.php Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\View;
/**
* Ein fertiger Bericht als CSV – 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 macht aus der Zahl einen Text).
* Vorangestellt ein BOM, sonst zeigen Excel und LibreOffice beim Doppelklick
* „Übrige“ statt „Übrige“.
*/
class BerichtExport
{
private const BOM = "\u{FEFF}";
/** @param array<string,mixed> $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<string,mixed> $bericht
* @return list<list<string>>
*/
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<string,mixed> $zeile
* @param array<string,mixed> $bericht
* @return list<string>
*/
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<string,mixed> $bericht
* @return list<list<string>>
*/
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<string,mixed> $bericht
* @return list<list<string>>
*/
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<string,mixed> $bericht
* @return list<list<string>>
*/
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<string> $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',
]);
}
}

283
src/Services/Zeitraum.php Normal file
View file

@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\View;
/**
* Der Zeitraum eines Berichts und seine Aufteilung in Spalten.
*
* Die Vorgaben („Letzter Monat“, „Aktuelles Jahr“ …) rechnen sich bei jedem
* Aufruf neu aus dem Tagesdatum aus – ein gespeicherter Bericht mit der Vorgabe
* „Letzter Monat“ zeigt im August den Juli, nicht für immer den Monat seiner
* Entstehung. Nur „Benutzerdefiniert“ hält von und bis fest.
*
* Alles hier ist reine Kalenderrechnung ohne Datenbankzugriff: gut testbar und
* mit `$heute` von außen steuerbar, damit Tests nicht am echten Datum hängen.
*/
class Zeitraum
{
/** Vorgaben der Filterleiste, in Anzeigereihenfolge. */
public const VORGABEN = [
'aktueller_monat' => '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<array{schluessel: string, titel: string, kurz: string, von: string, bis: string}>
*/
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;
}
}

View file

@ -0,0 +1,249 @@
<?php
use Finanzmanager\View;
/** @var array<string,mixed> $bericht */
/** @var array<string,mixed>|null $gespeichert */
/** @var list<array<string,mixed>> $gruppen */
/** @var list<array<string,mixed>> $konten */
/** @var list<array<string,mixed>> $kategorien */
/** @var list<array<string,mixed>> $klassen */
/** @var array<string,string> $vorgaben */
/** @var array<string,string> $aufteilungen */
/** @var array<string,string> $zeilensummen */
/** @var array<string,string> $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';
?>
<div class="tableiste">
<a class="tab" href="/auswertungen">Hauptansicht</a>
<span class="tab aktiv"><?= View::h($bericht['name'] ?? $bericht['typname']) ?></span>
</div>
<div class="seitenaktionen">
<a href="/auswertungen/csv?<?= View::h($query) ?><?= $id > 0 ? '&amp;bericht_id=' . $id : '' ?>"
class="knopf">Als CSV</a>
<details class="menue">
<summary class="knopf primaer">Speichern unter</summary>
<div class="menueliste breit">
<form method="post" action="/auswertungen/bericht/speichern" class="speichernform">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<?php include __DIR__ . '/filterfelder.php'; ?>
<label>Name
<input type="text" name="name" required maxlength="120"
value="<?= View::h($gespeichert['name'] ?? '') ?>">
</label>
<label>Berichtsgruppe
<select name="gruppe_id">
<option value="0">Nicht gruppiert</option>
<?php foreach ($gruppen as $gruppe): ?>
<option value="<?= (int)$gruppe['id'] ?>"
<?= (int)($gespeichert['gruppe_id'] ?? 0) === (int)$gruppe['id'] ? 'selected' : '' ?>>
<?= View::h($gruppe['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label>… oder neue Gruppe
<input type="text" name="neue_gruppe" maxlength="120" placeholder="z. B. Bankgeschäfte">
</label>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Als neuen Bericht speichern</button>
</div>
</form>
</div>
</details>
<?php if ($id > 0): ?>
<form method="post" action="/auswertungen/bericht/speichern" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="bericht_id" value="<?= $id ?>">
<input type="hidden" name="name" value="<?= View::h($gespeichert['name']) ?>">
<input type="hidden" name="gruppe_id" value="<?= (int)($gespeichert['gruppe_id'] ?? 0) ?>">
<?php include __DIR__ . '/filterfelder.php'; ?>
<button type="submit" class="knopf">Speichern</button>
</form>
<form method="post" action="/auswertungen/bericht/<?= $id ?>/loeschen" class="inline"
data-bestaetigen="Diesen Bericht löschen?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf gefahr">Löschen</button>
</form>
<?php endif; ?>
<a href="/auswertungen" class="knopf">Zur Übersicht</a>
</div>
<form method="get" action="<?= View::h($ziel) ?>" class="filterleiste berichtsfilter">
<input type="hidden" name="angepasst" value="1">
<div class="filterzeile">
<label>Zeitraum
<select name="zeitraum">
<?php foreach ($vorgaben as $wert => $bezeichnung): ?>
<option value="<?= View::h($wert) ?>"
<?= $bericht['zeitraum']['vorgabe'] === $wert ? 'selected' : '' ?>>
<?= View::h($bezeichnung) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php /* Immer gefüllt, auch bei einer Vorgabe: So sieht man, welchen
Zeitraum sie gerade bedeutet, und ein Wechsel auf
„Benutzerdefiniert“ beginnt nicht bei null. */ ?>
<label>von
<input type="date" name="von" value="<?= View::h($bericht['zeitraum']['von']) ?>">
</label>
<label>bis
<input type="date" name="bis" value="<?= View::h($bericht['zeitraum']['bis']) ?>">
</label>
<label>Bericht anzeigen als
<select name="typ">
<?php foreach ($typen as $wert => $bezeichnung): ?>
<option value="<?= View::h($wert) ?>" <?= $bericht['typ'] === $wert ? 'selected' : '' ?>>
<?= View::h($bezeichnung) ?>
</option>
<?php endforeach; ?>
</select>
</label>
</div>
<div class="filterzeile">
<label>Zeilen-Summe
<select name="zeilensumme" <?= $zeilenWaehlbar ? '' : 'disabled' ?>>
<?php foreach ($zeilensummen as $wert => $bezeichnung): ?>
<option value="<?= View::h($wert) ?>"
<?= $filter['zeilensumme'] === $wert ? 'selected' : '' ?>>
<?= View::h($bezeichnung) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label>Spalten-Aufteilung
<select name="aufteilung" <?= $spaltenWaehlbar ? '' : 'disabled' ?>>
<?php foreach ($aufteilungen as $wert => $bezeichnung): ?>
<option value="<?= View::h($wert) ?>"
<?= $filter['aufteilung'] === $wert ? 'selected' : '' ?>>
<?= View::h($bezeichnung) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php /* Vor jedem Ankreuzfeld ein verstecktes Feld gleichen Namens mit
„0“: Ein nicht angehaktes Feld sendet gar nichts, und der Wert
fiele sonst auf seine Vorgabe zurück statt auf „aus“. Bei
doppeltem Namen gewinnt der zuletzt gesendete Wert. */ ?>
<?php if ($baumbericht && $bericht['typ'] !== 'budgetbericht'): ?>
<label class="ankreuz">
<input type="hidden" name="einrueckung" value="0">
<input type="checkbox" name="einrueckung" value="1"
<?= $filter['einrueckung'] ? 'checked' : '' ?>>
mit Einrückung
</label>
<?php endif; ?>
<?php if ($bericht['typ'] === 'buchungsbericht'): ?>
<label class="ankreuz">
<input type="hidden" name="umbuchungen" value="0">
<input type="checkbox" name="umbuchungen" value="1"
<?= $filter['umbuchungen'] ? 'checked' : '' ?>>
Umbuchungen mitzählen
</label>
<?php endif; ?>
<label>Suche
<input type="search" name="suche" value="<?= View::h($filter['suche']) ?>"
placeholder="Empfänger oder Text">
</label>
<button type="submit" class="knopf primaer">Anzeigen</button>
</div>
<details class="einschraenkung" <?= $filter['konten'] || $filter['kategorien'] || $filter['klassen'] ? 'open' : '' ?>>
<summary>Einschränken auf Konten, Kategorien und Klassen</summary>
<div class="filterzeile">
<label>Konten
<select name="konten[]" multiple size="6">
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"
<?= in_array((int)$konto['id'], $filter['konten'], true) ? 'selected' : '' ?>>
<?= View::h($konto['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label>Kategorien
<select name="kategorien[]" multiple size="6">
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= in_array((int)$kategorie['id'], $filter['kategorien'], true) ? 'selected' : '' ?>>
<?= View::h(str_repeat(' ', (int)$kategorie['ebene']) . $kategorie['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label>Klassen
<select name="klassen[]" multiple size="6">
<?php foreach ($klassen as $klasse): ?>
<option value="<?= (int)$klasse['id'] ?>"
<?= in_array((int)$klasse['id'], $filter['klassen'], true) ? 'selected' : '' ?>>
<?= View::h($klasse['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
</div>
<p class="hinweis klein">
Eine gewählte Kategorie schließt ihre Unterkategorien ein. Mehrfachauswahl mit
gedrückter Strg-Taste; nichts ausgewählt heißt alles.
</p>
</details>
</form>
<div class="berichtskopf">
<h2><?= View::h($bericht['name'] ?? $bericht['typname']) ?></h2>
<p><?= View::h($bericht['beschriftung']) ?> (in Euro)</p>
</div>
<?php if ($bericht['leer']): ?>
<p class="platzhalter">
Für diesen Zeitraum gibt es keine Buchungen – oder die Einschränkung lässt nichts übrig.
</p>
<?php else: ?>
<?php
$teil = match ($bericht['darstellung']) {
'buchungen' => 'teil-buchungen.php',
'kontostaende' => 'teil-kontostaende.php',
'budget' => 'teil-budget.php',
default => 'teil-summen.php',
};
include __DIR__ . '/' . $teil;
?>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,88 @@
<?php
use Finanzmanager\View;
/**
* Der Drilldown: die Buchungen hinter einer Berichtszeile.
*
* @var array<string,mixed> $zeile
* @var string $zurueck
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a class="tab" href="/auswertungen">Hauptansicht</a>
<a class="tab" href="<?= View::h($zurueck) ?>">Bericht</a>
<span class="tab aktiv">Buchungen</span>
</div>
<div class="seitenaktionen">
<a href="<?= View::h($zurueck) ?>" class="knopf primaer">Zurück zum Bericht</a>
</div>
<div class="berichtskopf">
<h2><?= View::h($zeile['name']) ?></h2>
<p><?= View::h($zeile['dimension']) ?> · <?= View::h($zeile['spaltentitel']) ?></p>
</div>
<?php if ($zeile['buchungen'] === []): ?>
<p class="platzhalter">Zu dieser Zeile gibt es keine Buchungen mehr – vermutlich wurde
zwischenzeitlich etwas geändert.</p>
<?php else: ?>
<table class="liste">
<thead>
<tr>
<th>Datum</th>
<th>Konto</th>
<th>Empfänger</th>
<th>Buchungstext</th>
<th>Kategorie</th>
<th>Klasse</th>
<th class="rechts">Betrag</th>
</tr>
</thead>
<tbody>
<?php foreach ($zeile['buchungen'] as $buchung): ?>
<tr>
<td class="nowrap"><?= View::datum($buchung['datum']) ?></td>
<td>
<a href="/konten/<?= (int)$buchung['konto_id'] ?>"><?= View::h($buchung['konto']) ?></a>
</td>
<td>
<a href="/buchungen/<?= (int)$buchung['buchung_id'] ?>">
<?= View::h($buchung['empfaenger'] !== '' ? $buchung['empfaenger'] : '(ohne Empfänger)') ?>
</a>
</td>
<td><?= View::h($buchung['text']) ?></td>
<td>
<?= View::h($buchung['kategorie']) ?>
<?php if ($buchung['gesplittet']): ?>
<span class="marke" title="Zeile einer Splitbuchung">Split</span>
<?php endif; ?>
</td>
<td><?= View::h($buchung['klasse']) ?></td>
<td class="rechts <?= View::betragKlasse($buchung['betrag']) ?>">
<?= View::betrag($buchung['betrag'], false) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="summenzeile">
<td colspan="6">Summe (<?= count($zeile['buchungen']) ?> Buchungen)</td>
<td class="rechts <?= View::betragKlasse($zeile['summe']) ?>">
<?= View::betrag($zeile['summe'], false) ?>
</td>
</tr>
</tfoot>
</table>
<p class="hinweis klein">
Die Beträge stehen mit ihrem buchhalterischen Vorzeichen – im Summenbericht erscheint
dieselbe Summe unter „Ausgaben“ ohne Minus.
</p>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,22 @@
<?php
use Finanzmanager\View;
/**
* Der eingestellte Filter als versteckte Felder – damit ein POST-Formular
* („Speichern unter“) mit genau dem Bericht arbeitet, der gerade auf dem
* Schirm steht.
*
* @var array<string,mixed> $filter
*/
?>
<?php foreach ($filter as $feld => $wert): ?>
<?php if (is_array($wert)): ?>
<?php foreach ($wert as $einzeln): ?>
<input type="hidden" name="<?= View::h($feld) ?>[]" value="<?= View::h($einzeln) ?>">
<?php endforeach; ?>
<?php else: ?>
<input type="hidden" name="<?= View::h($feld) ?>"
value="<?= View::h(is_bool($wert) ? ($wert ? '1' : '0') : $wert) ?>">
<?php endif; ?>
<?php endforeach; ?>

View file

@ -0,0 +1,123 @@
<?php
use Finanzmanager\Services\Auswertung;
use Finanzmanager\View;
/**
* Verwaltung der Berichtsgruppen – die Kacheln der Auswertungsübersicht.
*
* @var array<int, array{gruppe: array<string,mixed>|null, berichte: list<array<string,mixed>>}> $gruppen
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a class="tab" href="/auswertungen">Hauptansicht</a>
<span class="tab aktiv">Berichtsgruppen</span>
</div>
<div class="seitenaktionen">
<a href="/auswertungen" class="knopf">Zur Übersicht</a>
</div>
<p class="hinweis">
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“.
</p>
<div class="kachel einzeln">
<h2>Neue Berichtsgruppe</h2>
<form method="post" action="/auswertungen/gruppen/neu" class="zeilenform">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label>Name
<input type="text" name="name" required maxlength="120" placeholder="z. B. Bankgeschäfte">
</label>
<label>Sortierung
<input type="number" name="sortierung" value="0" class="schmal">
</label>
<button type="submit" class="knopf primaer">Anlegen</button>
</form>
</div>
<?php
$echte = array_filter($gruppen, static fn(array $eintrag): bool => $eintrag['gruppe'] !== null);
?>
<?php if ($echte === []): ?>
<p class="platzhalter">Noch keine Berichtsgruppe angelegt.</p>
<?php else: ?>
<table class="liste">
<thead>
<tr>
<th>Name</th>
<th class="rechts">Sortierung</th>
<th>Berichte</th>
<th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($echte as $eintrag): ?>
<?php $gruppe = $eintrag['gruppe']; ?>
<tr>
<td colspan="4">
<form method="post" action="/auswertungen/gruppen/<?= (int)$gruppe['id'] ?>"
class="zeilenform">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="text" name="name" value="<?= View::h($gruppe['name']) ?>"
required maxlength="120">
<input type="number" name="sortierung" class="schmal"
value="<?= (int)$gruppe['sortierung'] ?>">
<span class="berichtstyp">
<?php if ($eintrag['berichte'] === []): ?>
keine Berichte
<?php else: ?>
<?= count($eintrag['berichte']) ?> Bericht<?= count($eintrag['berichte']) === 1 ? '' : 'e' ?>:
<?= View::h(implode(', ', array_column($eintrag['berichte'], 'name'))) ?>
<?php endif; ?>
</span>
<button type="submit" class="knopf klein">Speichern</button>
</form>
<form method="post" action="/auswertungen/gruppen/<?= (int)$gruppe['id'] ?>/loeschen"
class="inline" data-bestaetigen="Gruppe löschen? Die Berichte bleiben erhalten.">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf klein gefahr">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php
$ohne = $gruppen[0]['berichte'] ?? [];
?>
<?php if ($ohne !== []): ?>
<h2>Nicht gruppiert</h2>
<table class="liste">
<thead>
<tr><th>Bericht</th><th>Typ</th><th class="rechts">Aktion</th></tr>
</thead>
<tbody>
<?php foreach ($ohne as $bericht): ?>
<tr>
<td>
<a href="/auswertungen/bericht/<?= (int)$bericht['id'] ?>">
<?= View::h($bericht['name']) ?>
</a>
</td>
<td><?= View::h(Auswertung::TYPEN[$bericht['typ']] ?? $bericht['typ']) ?></td>
<td class="rechts">
<a href="/auswertungen/bericht/<?= (int)$bericht['id'] ?>" class="knopf klein">Öffnen</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,99 @@
<?php
use Finanzmanager\Services\Auswertung;
use Finanzmanager\View;
/** @var array<int, array{gruppe: array<string,mixed>|null, berichte: list<array<string,mixed>>}> $gruppen */
/** @var list<array<string,mixed>> $letzte */
/** @var array<string,string> $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';
?>
<div class="tableiste">
<span class="tab aktiv">Hauptansicht</span>
</div>
<div class="seitenaktionen">
<a href="<?= View::h($vorlage('summenbericht')) ?>" class="knopf primaer">Neuer Bericht</a>
<a href="/auswertungen/gruppen" class="knopf">Berichtsgruppen</a>
<?php if ($letzte !== []): ?>
<details class="menue">
<summary class="knopf">Letzte Berichte</summary>
<div class="menueliste">
<?php foreach ($letzte as $bericht): ?>
<a class="menueeintrag" href="/auswertungen/bericht/<?= (int)$bericht['id'] ?>">
<?= View::h($bericht['name']) ?>
</a>
<?php endforeach; ?>
</div>
</details>
<?php endif; ?>
</div>
<div class="kachelraster">
<section class="kachel">
<header><h2>Standardvorlagen</h2></header>
<p class="kachelhinweis">
Ein Bericht ohne Namen: Filter einstellen, ansehen, und wenn er sich lohnt,
mit „Speichern unter“ in eine Berichtsgruppe legen.
</p>
<ul class="berichtsliste">
<?php foreach ($typen as $typ => $bezeichnung): ?>
<li>
<a href="<?= View::h($vorlage($typ)) ?>">
<span class="berichtssymbol" aria-hidden="true">▤</span>
<?= View::h($bezeichnung) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</section>
<?php foreach ($gruppen as $id => $eintrag): ?>
<section class="kachel">
<header>
<h2><?= View::h($eintrag['gruppe']['name'] ?? 'Nicht gruppiert') ?></h2>
</header>
<?php if ($eintrag['berichte'] === []): ?>
<p class="platzhalter">
Noch kein Bericht in dieser Gruppe. Beim „Speichern unter“ lässt sich
die Gruppe auswählen.
</p>
<?php else: ?>
<ul class="berichtsliste">
<?php foreach ($eintrag['berichte'] as $bericht): ?>
<li>
<a href="/auswertungen/bericht/<?= (int)$bericht['id'] ?>">
<span class="berichtssymbol" aria-hidden="true">▤</span>
<?= View::h($bericht['name']) ?>
</a>
<span class="berichtstyp">
<?= View::h(Auswertung::TYPEN[$bericht['typ']] ?? $bericht['typ']) ?>
</span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</section>
<?php endforeach; ?>
</div>
<?php if ($anzahl === 0): ?>
<p class="hinweis">
Gespeicherte Berichte erscheinen hier als Kacheln – gruppiert wie im FinanzManager
unter „Bankgeschäfte“ oder „Steuern“. Bis dahin führt jeder Weg über die
Standardvorlagen.
</p>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,78 @@
<?php
use Finanzmanager\View;
/**
* Buchungsbericht: die Einzelbuchungen, gruppiert nach der Zeilen-Summe.
* Die Beträge behalten hier ihr Vorzeichen – es ist eine Buchungsliste.
*
* @var array<string,mixed> $bericht
*/
?>
<table class="liste bericht">
<thead>
<tr>
<th>Datum</th>
<th>Konto</th>
<th>Empfänger</th>
<th>Buchungstext</th>
<th>Kategorie</th>
<th>Klasse</th>
<th class="rechts">Betrag</th>
</tr>
</thead>
<?php foreach ($bericht['gruppen'] as $gruppe): ?>
<tbody>
<tr class="gruppenzeile">
<th colspan="7"><?= View::h($gruppe['name']) ?></th>
</tr>
<?php foreach ($gruppe['buchungen'] as $buchung): ?>
<tr>
<td class="nowrap"><?= View::datum($buchung['datum']) ?></td>
<td><?= View::h($buchung['konto']) ?></td>
<td>
<a href="/buchungen/<?= (int)$buchung['buchung_id'] ?>">
<?= View::h($buchung['empfaenger'] !== '' ? $buchung['empfaenger'] : '(ohne Empfänger)') ?>
</a>
</td>
<td><?= View::h($buchung['text']) ?></td>
<td>
<?= View::h($buchung['kategorie']) ?>
<?php if ($buchung['gesplittet']): ?>
<span class="marke" title="Zeile einer Splitbuchung">Split</span>
<?php endif; ?>
</td>
<td><?= View::h($buchung['klasse']) ?></td>
<td class="rechts <?= View::betragKlasse($buchung['betrag']) ?>">
<?= View::betrag($buchung['betrag'], false) ?>
</td>
</tr>
<?php endforeach; ?>
<tr class="gruppensumme">
<td colspan="6">Gesamt <?= View::h($gruppe['name']) ?>
(<?= count($gruppe['buchungen']) ?>)</td>
<td class="rechts <?= View::betragKlasse($gruppe['summe']) ?>">
<?= View::betrag($gruppe['summe'], false) ?>
</td>
</tr>
</tbody>
<?php endforeach; ?>
<tfoot>
<tr class="summenzeile">
<td colspan="6">Gesamt (<?= (int)$bericht['anzahl'] ?> Buchungen)</td>
<td class="rechts <?= View::betragKlasse($bericht['summe']) ?>">
<?= View::betrag($bericht['summe'], false) ?>
</td>
</tr>
</tfoot>
</table>
<p class="hinweis klein">
Eine Splitbuchung erscheint mit jeder ihrer Zeilen einzeln – so stimmt die Summe mit
dem Summenbericht überein. Der Empfänger führt in die Buchungsmaske.
</p>

View file

@ -0,0 +1,76 @@
<?php
use Finanzmanager\View;
/**
* Budgetbericht: Budget gegen Ist je Kategorie, mit Saldo.
*
* @var array<string,mixed> $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,
]);
?>
<?php if ($bericht['budgets'] === []): ?>
<p class="hinweis">
Für <?= View::h($bericht['monatsbereich']) ?> ist kein Budget angelegt – die
Budgetspalte bleibt deshalb leer. Der Budgetplaner legt eines an.
<a href="/planung/budget">Zum Budgetplaner</a>
</p>
<?php endif; ?>
<table class="liste bericht">
<thead>
<tr>
<th>Kategoriebeschreibung</th>
<th class="rechts">Budget</th>
<th class="rechts">Ist</th>
<th class="rechts">Saldo</th>
</tr>
</thead>
<tbody>
<?php foreach ($bericht['zeilen'] as $zeile): ?>
<tr class="<?= $zeile['kopf'] ? 'astsumme' : '' ?>">
<td class="baumname" data-ebene="<?= min((int)$zeile['ebene'], 4) ?>">
<a href="<?= View::h($drilldown($zeile['schluessel'])) ?>" class="zeilenlink">
<?= View::h($zeile['name']) ?>
</a>
</td>
<td class="rechts <?= View::betragKlasse($zeile['budget']) ?>">
<?= View::betrag($zeile['budget'], false) ?>
</td>
<td class="rechts <?= View::betragKlasse($zeile['ist']) ?>">
<?= View::betrag($zeile['ist'], false) ?>
</td>
<td class="rechts <?= View::betragKlasse($zeile['saldo']) ?>">
<?= View::betrag($zeile['saldo'], false) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="summenzeile">
<td><?= View::h($bericht['gesamt']['name']) ?></td>
<td class="rechts <?= View::betragKlasse($bericht['gesamt']['budget']) ?>">
<?= View::betrag($bericht['gesamt']['budget'], false) ?>
</td>
<td class="rechts <?= View::betragKlasse($bericht['gesamt']['ist']) ?>">
<?= View::betrag($bericht['gesamt']['ist'], false) ?>
</td>
<td class="rechts <?= View::betragKlasse($bericht['gesamt']['saldo']) ?>">
<?= View::betrag($bericht['gesamt']['saldo'], false) ?>
</td>
</tr>
</tfoot>
</table>
<p class="hinweis klein">
Gerechnet über die vollen Monate <?= View::h($bericht['monatsbereich']) ?>: 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.
</p>

View file

@ -0,0 +1,57 @@
<?php
use Finanzmanager\View;
/**
* Kontostandsbericht: Salden je Konto zu den Enden der Spaltenzeiträume.
* Anders als in den übrigen Berichten wird hier die Null ausgeschrieben – ein
* Konto, das auf null steht, ist eine Aussage, keine fehlende Angabe.
*
* @var array<string,mixed> $bericht
*/
?>
<div class="breittabelle">
<table class="liste bericht">
<thead>
<tr>
<th>Konto</th>
<th>Kontengruppe</th>
<?php foreach ($bericht['spalten'] as $spalte): ?>
<th class="rechts"><?= View::h($spalte['titel']) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ($bericht['zeilen'] as $zeile): ?>
<tr>
<td>
<a href="/konten/<?= (int)substr($zeile['schluessel'], strlen('konto:')) ?>">
<?= View::h($zeile['name']) ?>
</a>
</td>
<td><?= View::h($zeile['gruppe'] !== '' ? $zeile['gruppe'] : '–') ?></td>
<?php foreach ($bericht['spalten'] as $spalte): ?>
<?php $wert = (float)$zeile['werte'][$spalte['schluessel']]; ?>
<td class="rechts <?= View::betragKlasse($wert) ?>"><?= View::betrag($wert, false) ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="summenzeile">
<td colspan="2"><?= View::h($bericht['gesamt']['name']) ?></td>
<?php foreach ($bericht['spalten'] as $spalte): ?>
<?php $wert = (float)$bericht['gesamt']['werte'][$spalte['schluessel']]; ?>
<td class="rechts <?= View::betragKlasse($wert) ?>"><?= View::betrag($wert, false) ?></td>
<?php endforeach; ?>
</tr>
</tfoot>
</table>
</div>
<p class="hinweis klein">
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.
</p>

View file

@ -0,0 +1,147 @@
<?php
use Finanzmanager\View;
/**
* Summen- und Vergleichsbericht: Zeilen sind die Zeilen-Summe, Spalten der
* Kalender (oder beim Vergleich Zeitraum und Vorjahr).
*
* @var array<string,mixed> $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);
};
?>
<div class="breittabelle">
<table class="liste bericht">
<thead>
<tr>
<th><?= View::h($bericht['filter']['zeilensumme'] === 'kategorie'
? 'Kategoriebeschreibung' : 'Bezeichnung') ?></th>
<?php foreach ($bericht['spalten'] as $spalte): ?>
<th class="rechts"><?= View::h($spalte['titel']) ?></th>
<?php endforeach; ?>
<?php if ($vergleich): ?>
<th class="rechts">Differenz</th>
<th class="rechts">Veränderung</th>
<?php elseif (count($bericht['spalten']) > 1): ?>
<th class="rechts">Summe</th>
<?php endif; ?>
</tr>
</thead>
<?php foreach ($bericht['abschnitte'] as $abschnitt): ?>
<tbody>
<tr class="abschnittszeile">
<th colspan="<?= count($bericht['spalten']) + ($vergleich ? 3 : (count($bericht['spalten']) > 1 ? 2 : 1)) ?>">
<?= View::h($abschnitt['name']) ?>
</th>
</tr>
<?php foreach ($abschnitt['zeilen'] as $zeile): ?>
<tr class="<?= $zeile['astsumme'] ? 'astsumme' : '' ?> <?= $zeile['ohne'] ? 'ohnezuordnung' : '' ?>">
<td class="baumname" data-ebene="<?= min((int)$zeile['ebene'], 4) ?>">
<a href="<?= View::h($drilldown($zeile['schluessel'], 'gesamt', (bool)$zeile['astsumme'])) ?>"
class="zeilenlink"><?= View::h($zeile['name']) ?></a>
</td>
<?php foreach ($bericht['spalten'] as $spalte): ?>
<?php $wert = (float)$zeile['werte'][$spalte['schluessel']]; ?>
<td class="rechts <?= View::betragKlasse($wert) ?>">
<?php if (abs($wert) >= 0.005): ?>
<a class="wertlink"
href="<?= View::h($drilldown($zeile['schluessel'], $spalte['schluessel'], (bool)$zeile['astsumme'])) ?>">
<?= View::betrag($wert, false) ?>
</a>
<?php endif; ?>
</td>
<?php endforeach; ?>
<?php if ($vergleich): ?>
<td class="rechts <?= View::betragKlasse($zeile['differenz']) ?>">
<?= $zelle((float)$zeile['differenz']) ?>
</td>
<td class="rechts <?= $zeile['prozent'] !== null && $zeile['prozent'] < 0 ? 'betrag negativ' : 'betrag' ?>">
<?= $zeile['prozent'] === null ? '–' : View::h(number_format((float)$zeile['prozent'], 1, ',', '.')) . '&nbsp;%' ?>
</td>
<?php elseif (count($bericht['spalten']) > 1): ?>
<td class="rechts <?= View::betragKlasse($zeile['summe']) ?>">
<?= $zelle((float)$zeile['summe']) ?>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<tr class="gruppensumme">
<td>Gesamt <?= View::h($abschnitt['name']) ?></td>
<?php foreach ($bericht['spalten'] as $spalte): ?>
<td class="rechts <?= View::betragKlasse($abschnitt['werte'][$spalte['schluessel']]) ?>">
<?= View::betrag($abschnitt['werte'][$spalte['schluessel']], false) ?>
</td>
<?php endforeach; ?>
<?php if ($vergleich): ?>
<td class="rechts <?= View::betragKlasse($abschnitt['differenz']) ?>">
<?= View::betrag($abschnitt['differenz'], false) ?>
</td>
<td class="rechts">
<?= $abschnitt['prozent'] === null ? '–' : View::h(number_format((float)$abschnitt['prozent'], 1, ',', '.')) . '&nbsp;%' ?>
</td>
<?php elseif (count($bericht['spalten']) > 1): ?>
<td class="rechts <?= View::betragKlasse($abschnitt['summe']) ?>">
<?= View::betrag($abschnitt['summe'], false) ?>
</td>
<?php endif; ?>
</tr>
</tbody>
<?php endforeach; ?>
<tfoot>
<tr class="summenzeile">
<td><?= View::h($bericht['gesamt']['name']) ?></td>
<?php foreach ($bericht['spalten'] as $spalte): ?>
<td class="rechts <?= View::betragKlasse($bericht['gesamt']['werte'][$spalte['schluessel']]) ?>">
<?= View::betrag($bericht['gesamt']['werte'][$spalte['schluessel']], false) ?>
</td>
<?php endforeach; ?>
<?php if ($vergleich): ?>
<td class="rechts <?= View::betragKlasse($bericht['gesamt']['differenz']) ?>">
<?= View::betrag($bericht['gesamt']['differenz'], false) ?>
</td>
<td class="rechts">
<?= $bericht['gesamt']['prozent'] === null ? '–' : View::h(number_format((float)$bericht['gesamt']['prozent'], 1, ',', '.')) . '&nbsp;%' ?>
</td>
<?php elseif (count($bericht['spalten']) > 1): ?>
<td class="rechts <?= View::betragKlasse($bericht['gesamt']['summe']) ?>">
<?= View::betrag($bericht['gesamt']['summe'], false) ?>
</td>
<?php endif; ?>
</tr>
</tfoot>
</table>
</div>
<p class="hinweis klein">
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.
</p>

View file

@ -47,7 +47,14 @@ include __DIR__ . '/../layout/kopf.php';
<section class="kachel">
<header><h2>Einnahmen und Ausgaben</h2></header>
<p class="platzhalter">Monatsvergleich aus den kategorisierten Buchungen (Phase 4).</p>
<p class="platzhalter">
Der Monatsvergleich als Grafik folgt noch. Die Zahlen dazu stehen bereits im
Summenbericht – mit Spalten-Aufteilung nach Monat.
</p>
<div class="knopfleiste">
<a href="/auswertungen/bericht?typ=summenbericht&amp;zeitraum=aktuelles_jahr&amp;aufteilung=monat&amp;angepasst=1"
class="knopf">Summenbericht öffnen</a>
</div>
</section>
<section class="kachel">
@ -94,7 +101,8 @@ include __DIR__ . '/../layout/kopf.php';
<li class="erledigt">Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten</li>
<li class="erledigt">Phase 2 – Buchungen, Kontoblatt, Splitbuchungen</li>
<li class="erledigt">Phase 3 – Budgetplaner und Monatsabschluss</li>
<li>Phase 4 – Auswertungen und Cockpit-Kacheln</li>
<li class="erledigt">Phase 4 – Berichtsmodul mit fünf Berichtstypen</li>
<li>Phase 4 – die übrigen Grafiken und Cockpit-Kacheln</li>
</ul>
</section>
</div>

637
tests/14_AuswertungTest.php Normal file
View file

@ -0,0 +1,637 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Bericht;
use Finanzmanager\Models\Berichtsgruppe;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Budget;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Auswertung;
use Finanzmanager\Services\BerichtExport;
use Finanzmanager\Services\Zeitraum;
class AuswertungTest extends TestCase
{
protected string $testName = 'Test 14: Auswertungen und Berichte';
/** Fester Stichtag – sonst hinge das Ergebnis am Tag des Testlaufs. */
private const HEUTE = '2026-08-11';
private int $giro = 0;
private int $spar = 0;
private int $leben = 0;
private int $einkauf = 0;
private int $freizeit = 0;
private int $gehalt = 0;
private int $privat = 0;
private int $urlaub = 0;
public function run(): void
{
$this->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<string,mixed> $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<string,mixed>|null */
private function abschnitt(array $bericht, string $art): ?array
{
foreach ($bericht['abschnitte'] as $abschnitt) {
if ($abschnitt['schluessel'] === $art) {
return $abschnitt;
}
}
return null;
}
/** @return array<string,mixed>|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');
}
}

View file

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