Merge pull request 'Phase 4: Auswertungen mit Berichtsmodul' (#8) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/8
This commit is contained in:
commit
6ff515afa1
28 changed files with 4690 additions and 11 deletions
90
Changes.md
90
Changes.md
|
|
@ -1,5 +1,95 @@
|
||||||
# Änderungen
|
# Änderungen
|
||||||
|
|
||||||
|
## Phase 4 – Auswertungen mit Berichtsmodul (11.08.2026)
|
||||||
|
|
||||||
|
**Datenmodell**
|
||||||
|
- Migration `005_berichte.sql`: `berichte`, `berichtsgruppen`
|
||||||
|
- Ein Bericht ist Name, Typ und Filter – die Zahlen entstehen bei jedem Aufruf neu aus den
|
||||||
|
Buchungen. Gespeichert wird ausschließlich die Frage.
|
||||||
|
- Der Filter liegt als JSON in einer Spalte und läuft beim Lesen immer durch
|
||||||
|
`Auswertung::normalisiere()`: Unbekannte Felder fallen weg, unbekannte Werte auf die
|
||||||
|
Vorgabe zurück. Keine eigene Spalte je Filterfeld – das sind Anzeigeparameter ohne
|
||||||
|
Fremdschlüssel, und jede neue Filtermöglichkeit bräuchte sonst eine Migration.
|
||||||
|
- Keine neuen Einstellungen: Zeitraum, Zeilen-Summe und Einrückung gehören zum einzelnen
|
||||||
|
Bericht, nicht in den Einstellungsdialog.
|
||||||
|
|
||||||
|
**Fünf Berichtstypen** (`src/Services/Auswertung.php`)
|
||||||
|
- **Summenbericht**: Summen je Zeilen-Summe und Spalte, getrennt nach Einnahmen und
|
||||||
|
Ausgaben, mit Schlusszeile *Gesamt Einnahmen − Ausgaben*
|
||||||
|
- **Buchungsbericht**: die Einzelbuchungen, gruppiert mit Zwischensummen
|
||||||
|
- **Vergleichsbericht**: Zeitraum gegen denselben Zeitraum des Vorjahres, mit Differenz
|
||||||
|
und Veränderung in Prozent
|
||||||
|
- **Kontostandsbericht**: Salden je Konto zu den Enden der Spaltenzeiträume
|
||||||
|
- **Budgetbericht**: Budget gegen Ist je Kategorie, mit Saldo
|
||||||
|
|
||||||
|
**Was gezählt wird**
|
||||||
|
- Bewegungen statt Buchungen: nicht gesplittete Buchungen mit ihrem Betrag, gesplittete
|
||||||
|
über ihre Splitzeilen – sonst zählte eine Splitbuchung doppelt oder gar nicht.
|
||||||
|
- **Umbuchungen bleiben außen vor** (weder Einnahme noch Ausgabe, wie im Budgetplaner).
|
||||||
|
Nur der Buchungsbericht führt sie auf Wunsch mit; sie erscheinen dort mit dem Gegenkonto
|
||||||
|
in eckigen Klammern.
|
||||||
|
- Unkategorisierte Buchungen stehen als eigene Zeile *Nicht kategorisiert* am Ende ihres
|
||||||
|
Abschnitts, statt lautlos aus der Summe zu fallen.
|
||||||
|
- Einnahme oder Ausgabe entscheidet die **Art der Kategorie**, nicht das Vorzeichen: Eine
|
||||||
|
Erstattung ist eine negative Ausgabe und gehört unter die Ausgaben.
|
||||||
|
- Aggregiert wird in PHP. Die Datenbank liefert die Bewegungen, das Einsortieren in Zeilen
|
||||||
|
und Spalten passiert im Service – Baumsummen, Vorzeichenregeln und Spaltenraster wären
|
||||||
|
in SQL erheblich schwerer nachzulesen.
|
||||||
|
|
||||||
|
**Vorzeichen**
|
||||||
|
- Intern durchgehend Buchhaltung (Ausgaben negativ), in der Anzeige dreht der
|
||||||
|
Ausgabenabschnitt sein Vorzeichen – wie im FinanzManager steht dort 636,39 statt
|
||||||
|
−636,39. Die Schlusszeile rechnet mit den echten Werten.
|
||||||
|
- Buchungs- und Budgetbericht sowie der Drilldown behalten das Vorzeichen: Sie haben keine
|
||||||
|
Abschnitte, die es erklären.
|
||||||
|
|
||||||
|
**Zeitraum und Spalten** (`src/Services/Zeitraum.php`, reine Kalenderrechnung ohne Datenbank)
|
||||||
|
- Neun Vorgaben von *Aktueller Monat* bis *Gesamter Zeitraum*; sie rechnen sich bei jedem
|
||||||
|
Aufruf neu aus dem Tagesdatum aus. Ein Bericht mit *Letzter Monat* zeigt im August den
|
||||||
|
Juli, nicht für immer den Monat seiner Entstehung.
|
||||||
|
- Spalten-Aufteilung nach Monat, Quartal oder Jahr; die Randspalten werden auf den
|
||||||
|
Zeitraum gestutzt.
|
||||||
|
- *Gesamter Zeitraum* nimmt die Datumsgrenzen der Buchungen (`Buchung::datumsgrenzen()`)
|
||||||
|
und endet spätestens heute – ein Ende in der Zukunft brächte nur leere Spalten.
|
||||||
|
- Beim Vorjahresvergleich bleibt ein Monatsende ein Monatsende, sonst träfe der 28.02. auf
|
||||||
|
einen 29.02.
|
||||||
|
|
||||||
|
**Summenbericht als Baum**
|
||||||
|
- Jede Kategorie zeigt ihre eigenen Werte, Kategorien mit Nachfahren bekommen dahinter
|
||||||
|
*Gesamt <Name>* mit der Astsumme. Eine Elternkategorie ohne eigene Buchungen steht als
|
||||||
|
bloße Kopfzeile da – genau wie im Original.
|
||||||
|
- *ohne Einrückung* schaltet auf eine flache Liste mit vollem Pfad und ohne Astsummen.
|
||||||
|
- Ein Kategoriefilter schließt die Unterkategorien ein: Wer „400 - Leben“ wählt, meint den
|
||||||
|
ganzen Ast.
|
||||||
|
|
||||||
|
**Oberfläche**
|
||||||
|
- Übersicht mit Kachel *Standardvorlagen* und je einer Kachel pro Berichtsgruppe,
|
||||||
|
Menü *Letzte Berichte*, Verwaltung der Gruppen unter `/auswertungen/gruppen`.
|
||||||
|
- Filterleiste sendet per GET: Jeder Zwischenstand ist eine Adresse, die man sich merken,
|
||||||
|
weitergeben und mit *Speichern unter* festhalten kann.
|
||||||
|
- *Speichern unter* kann die Berichtsgruppe gleich mit anlegen – wer sie erst anderswo
|
||||||
|
erzeugen müsste, verlöre den eingestellten Filter.
|
||||||
|
- Eine gelöschte Gruppe nimmt ihre Berichte nicht mit: Sie rücken unter *Nicht gruppiert*.
|
||||||
|
- Die Aufklappmenüs sind `<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)
|
## Cockpit: Vermögensentwicklung als gestapeltes Balkendiagramm (11.08.2026)
|
||||||
|
|
||||||
- Die Kachel *Vermögensentwicklung* war ein Platzhalter und zeigt jetzt die Kontostände
|
- Die Kachel *Vermögensentwicklung* war ein Platzhalter und zeigt jetzt die Kontostände
|
||||||
|
|
|
||||||
11
PLAN.md
11
PLAN.md
|
|
@ -269,13 +269,22 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag
|
||||||
„Planung", „Steuern"): **Summenbericht, Buchungsbericht, Vergleichsbericht,
|
„Planung", „Steuern"): **Summenbericht, Buchungsbericht, Vergleichsbericht,
|
||||||
Kontostandsbericht, Budgetbericht**, jeweils mit Zeitraum, Spalten-Aufteilung,
|
Kontostandsbericht, Budgetbericht**, jeweils mit Zeitraum, Spalten-Aufteilung,
|
||||||
Zeilen-Summe (Kategorie/Klasse/Konto/Empfänger), Filter auf Konten/Kategorien/Klassen.
|
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-
|
* 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
|
Kachel mit den letzten zwölf Monaten, gestapelt je Konto — `docs/cockpit.md`)*, Ausgaben
|
||||||
nach Kategorie.
|
nach Kategorie.
|
||||||
* Export CSV; Drilldown von der Berichtszeile in die zugrunde liegenden Buchungen.
|
|
||||||
* **Cockpit** mit konfigurierbaren Kacheln (Vermögensentwicklung, Einnahmen/Ausgaben,
|
* **Cockpit** mit konfigurierbaren Kacheln (Vermögensentwicklung, Einnahmen/Ausgaben,
|
||||||
Meine Ausgaben, Kontostände, offene Vorgänge).
|
Meine Ausgaben, Kontostände, offene Vorgänge).
|
||||||
* *Ergebnis:* Die bisher genutzten Berichte sind nachgebaut und liefern dieselben Zahlen.
|
* *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)
|
### Phase 5 — Online-Banking (FinTS)
|
||||||
* Fork von `nemiah/php-fints` auf Codeberg, Einbindung über VCS-Repository.
|
* Fork von `nemiah/php-fints` auf Codeberg, Einbindung über VCS-Repository.
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -14,9 +14,14 @@ Phase 0 bis 3 sind gebaut: Anmeldung, zentraler Einstellungsdialog, Debug-Log,
|
||||||
Migrationsmechanik und Test-Harness; Konten, Kontengruppen, Kategorien und Klassen;
|
Migrationsmechanik und Test-Harness; Konten, Kontengruppen, Kategorien und Klassen;
|
||||||
Kontoblatt, Buchungsmaske mit Splitbuchungen und Umbuchungen sowie der QIF-Import für
|
Kontoblatt, Buchungsmaske mit Splitbuchungen und Umbuchungen sowie der QIF-Import für
|
||||||
Stammdaten und Buchungen; Budgetplaner mit Monatswerten je Kategorie und der
|
Stammdaten und Buchungen; Budgetplaner mit Monatswerten je Kategorie und der
|
||||||
Monatsabschluss samt Prüflauf, Snapshot und Sperre. Aus Phase 4 steht die erste
|
Monatsabschluss samt Prüflauf, Snapshot und Sperre.
|
||||||
Cockpit-Kachel: die Vermögensentwicklung der letzten zwölf Monate als gestapeltes
|
|
||||||
Balkendiagramm. Als Nächstes folgen nach `PLAN.md` die übrigen Auswertungen.
|
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
|
## Technik
|
||||||
|
|
||||||
|
|
@ -86,5 +91,6 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf.
|
||||||
- `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen
|
- `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen
|
||||||
- `docs/buchungen.md` – Kontoblatt, Umbuchungen, Splitbuchungen, Salden
|
- `docs/buchungen.md` – Kontoblatt, Umbuchungen, Splitbuchungen, Salden
|
||||||
- `docs/planung.md` – Budgetplaner und Monatsabschluss
|
- `docs/planung.md` – Budgetplaner und Monatsabschluss
|
||||||
|
- `docs/auswertungen.md` – Berichtsmodul, Filterleiste, Drilldown, CSV
|
||||||
- `docs/cockpit.md` – Startseite und die Kachel Vermögensentwicklung
|
- `docs/cockpit.md` – Startseite und die Kachel Vermögensentwicklung
|
||||||
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs
|
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs
|
||||||
|
|
|
||||||
186
docs/auswertungen.md
Normal file
186
docs/auswertungen.md
Normal 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 <Name>*
|
||||||
|
mit der Summe des Astes. Eine Elternkategorie ohne eigene Buchungen steht deshalb als
|
||||||
|
bloße Kopfzeile da – genau wie im Original.
|
||||||
|
|
||||||
|
Der Schalter *mit Einrückung* schaltet auf eine flache Liste mit vollem Kategoriepfad um.
|
||||||
|
Dort zählt jede Kategorie nur sich selbst, und es gibt keine Astsummen; sonst stünde eine
|
||||||
|
Summe wie ein gleichrangiger Posten neben ihren eigenen Bestandteilen.
|
||||||
|
|
||||||
|
Bei *Klasse*, *Konto* und *Empfänger* ist die Liste flach und alphabetisch. Die Aufteilung
|
||||||
|
in Einnahmen und Ausgaben bleibt: Eine Klasse mit beidem erscheint in beiden Abschnitten.
|
||||||
|
|
||||||
|
### Vergleichsbericht
|
||||||
|
|
||||||
|
Verglichen wird gegen **denselben Zeitraum ein Jahr früher**. Die Spalten-Aufteilung
|
||||||
|
spielt hier keine Rolle – die Spalten gibt der Vergleich vor.
|
||||||
|
|
||||||
|
Ohne Vorjahreswert bleibt die Prozentspalte leer: „unendlich viel mehr“ ist keine Aussage,
|
||||||
|
die in eine Spalte gehört. Ein Monatsende bleibt beim Rückrechnen ein Monatsende, damit
|
||||||
|
der 28.02. nicht auf einen 29.02. trifft und der Vergleichszeitraum einen Tag zu lang wird.
|
||||||
|
|
||||||
|
### Kontostandsbericht
|
||||||
|
|
||||||
|
Anders als die übrigen Berichte zählt hier nicht die Bewegung im Zeitraum, sondern der
|
||||||
|
**Stand**: alle Buchungen bis zum Stichtag, auch die vor dem Berichtszeitraum, und die
|
||||||
|
Gegenseiten von Umbuchungen. Konten, die im ganzen Zeitraum auf null standen, fallen weg –
|
||||||
|
dieselbe Regel wie in der Cockpit-Kachel. Die Summe zählt alle gezeigten Konten,
|
||||||
|
unabhängig vom Σ-Kennzeichen der Kontenübersicht.
|
||||||
|
|
||||||
|
Die Null wird hier ausgeschrieben: Ein Konto, das auf null steht, ist eine Aussage.
|
||||||
|
|
||||||
|
### Budgetbericht
|
||||||
|
|
||||||
|
Gerechnet wird über die **vollen Monate**, die der Zeitraum berührt. Ein Budget ist ein
|
||||||
|
Monatswert, und ein halber Monat hat kein halbes Budget; anders wären Budget und Ist nicht
|
||||||
|
vergleichbar und die Saldospalte bedeutungslos. Der Berichtskopf nennt deshalb den
|
||||||
|
Monatsbereich.
|
||||||
|
|
||||||
|
Geht der Zeitraum über den Jahreswechsel, gilt je Jahr dessen eigenes aktives Budget
|
||||||
|
(`Budget::aktivesFuerJahr()`). Vorzeichen und Saldo folgen dem Budgetplaner: Ausgaben
|
||||||
|
negativ, Saldo ist `ist − budget`, positiv heißt besser als geplant. Übergeordnete
|
||||||
|
Kategorien zeigen die Summe ihres Astes.
|
||||||
|
|
||||||
|
## Filterleiste
|
||||||
|
|
||||||
|
| Feld | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| Zeitraum | Vorgabe oder *Benutzerdefiniert*; von/bis sind immer gefüllt und zeigen, was die Vorgabe gerade bedeutet |
|
||||||
|
| Bericht anzeigen als | wechselt den Berichtstyp, ohne den übrigen Filter zu verlieren |
|
||||||
|
| Zeilen-Summe | Kategorie, Klasse, Konto oder Empfänger |
|
||||||
|
| Spalten-Aufteilung | Keine, Monat, Quartal, Jahr |
|
||||||
|
| mit Einrückung | Kategoriebaum oder flache Liste mit Pfad |
|
||||||
|
| Suche | Empfänger oder Buchungstext |
|
||||||
|
| Einschränken | Konten, Kategorien, Klassen (Mehrfachauswahl) |
|
||||||
|
|
||||||
|
Eine gewählte Kategorie **schließt ihre Unterkategorien ein** – wer „400 - Leben“ filtert,
|
||||||
|
meint den ganzen Ast. Nichts ausgewählt heißt alles.
|
||||||
|
|
||||||
|
Zwei Berichte bestimmen ihre Zeilen selbst und sperren das Feld: der Kontostandsbericht
|
||||||
|
führt immer Konten, der Budgetbericht immer Kategorien. Zwei bestimmen ihre Spalten
|
||||||
|
selbst: Vergleichs- und Budgetbericht.
|
||||||
|
|
||||||
|
Die Filterleiste sendet per **GET**. Jeder Zwischenstand ist damit eine Adresse, die man
|
||||||
|
sich merken, weitergeben und mit *Speichern unter* festhalten kann.
|
||||||
|
|
||||||
|
## Gespeicherte Berichte und Gruppen
|
||||||
|
|
||||||
|
Ein gespeicherter Bericht ist Name, Typ und Filter – mehr nicht. Der Filter liegt als JSON
|
||||||
|
in einer Spalte und wird beim Lesen immer durch `Auswertung::normalisiere()` geschickt:
|
||||||
|
Was dort nicht vorgesehen ist, fällt weg. Ein alter Bericht kann nach einer
|
||||||
|
Programmänderung höchstens eine Einstellung verlieren, aber nichts Unerwartetes in die
|
||||||
|
Abfrage tragen.
|
||||||
|
|
||||||
|
*Speichern unter* legt unter neuem Namen an und kann die Berichtsgruppe gleich mit
|
||||||
|
anlegen – wer sie erst anderswo erzeugen müsste, verlöre den eingestellten Filter.
|
||||||
|
*Speichern* überschreibt den geöffneten Bericht.
|
||||||
|
|
||||||
|
**Berichtsgruppen** sind die Kacheln der Übersicht, wie „Bankgeschäfte“ oder „Steuern“ im
|
||||||
|
Original. Wird eine Gruppe gelöscht, bleiben ihre Berichte erhalten und rücken unter
|
||||||
|
*Nicht gruppiert*: Ein Bericht ist Arbeit, eine Gruppe nicht.
|
||||||
|
|
||||||
|
Leere Gruppen bleiben in der Übersicht stehen. Sie sind angelegt worden, um befüllt zu
|
||||||
|
werden, und ohne Kachel gäbe es keinen Ort, an den ein Bericht könnte.
|
||||||
|
|
||||||
|
## Drilldown
|
||||||
|
|
||||||
|
Jeder Zeilenname und jede Zahl im Summen-, Vergleichs- und Budgetbericht ist ein Link auf
|
||||||
|
die Buchungen dahinter. Eine Zahl schränkt zusätzlich auf ihre Spalte ein.
|
||||||
|
|
||||||
|
Gefiltert wird nicht, indem die Zeile in einen neuen Filter übersetzt wird, sondern indem
|
||||||
|
**dieselben Bewegungen nachgesiebt** werden, aus denen die Zelle entstanden ist. Nur so
|
||||||
|
trifft der Drilldown auch das, was ein Filter gar nicht ausdrücken könnte: die Zeile
|
||||||
|
*Nicht kategorisiert* etwa, oder eine Astsumme über mehrere Unterkategorien.
|
||||||
|
|
||||||
|
## CSV-Export
|
||||||
|
|
||||||
|
Dieselben Zeilen, dieselben Zahlen, dieselben Vorzeichen wie am Bildschirm – wer den
|
||||||
|
Export mit der Anzeige vergleicht, soll nicht rechnen müssen.
|
||||||
|
|
||||||
|
Deutsches Tabellenkalkulations-CSV: Semikolon als Trenner, Komma als Dezimalzeichen, kein
|
||||||
|
Tausenderpunkt (der machte aus der Zahl einen Text), CRLF als Zeilenende und ein
|
||||||
|
vorangestelltes BOM – sonst zeigen Excel und LibreOffice beim Doppelklick „Übrige“ statt
|
||||||
|
„Übrige“. Die Einrückung des Kategoriebaums steht als führende Leerzeichen im Namensfeld,
|
||||||
|
sonst wäre die Hierarchie verloren.
|
||||||
|
|
||||||
|
PDF gibt es bewusst nicht (siehe `PLAN.md`); Papier läuft über die Druckfunktion des
|
||||||
|
Browsers.
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `src/Services/Auswertung.php` | die Rechenmaschine: Bewegungen, die fünf Berichtstypen, Drilldown |
|
||||||
|
| `src/Services/Zeitraum.php` | Zeitraumvorgaben und Spaltenraster, reine Kalenderrechnung ohne Datenbank |
|
||||||
|
| `src/Services/BerichtExport.php` | CSV |
|
||||||
|
| `src/Models/Bericht.php` | gespeicherte Berichte, Filter-JSON |
|
||||||
|
| `src/Models/Berichtsgruppe.php` | Gruppen |
|
||||||
|
| `src/Controllers/AuswertungenController.php` | Übersicht, Berichtsansicht, Drilldown, Export |
|
||||||
|
| `src/Views/auswertungen/` | Übersicht, Filterleiste, ein Teiltemplate je Darstellung |
|
||||||
|
| `migrations/005_berichte.sql` | `berichte`, `berichtsgruppen` |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
* `tests/14_AuswertungTest.php` – Zeitraumvorgaben und Spaltenraster, Filterprüfung,
|
||||||
|
alle fünf Berichtstypen, Astsummen, Splitbuchungen, Ausschluss der Umbuchungen,
|
||||||
|
unkategorisierte Buchungen, Einschränkungen, Drilldown, CSV, gespeicherte Berichte
|
||||||
|
* `tests/e2e/specs/auswertungen.spec.js` – Übersicht und Standardvorlagen, die fünf
|
||||||
|
Tabellen, Spalten-Aufteilung, Drilldown und zurück, Speichern/Wiederfinden/Löschen,
|
||||||
|
CSV-Download
|
||||||
52
migrations/005_berichte.sql
Normal file
52
migrations/005_berichte.sql
Normal 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.
|
||||||
|
|
@ -10,6 +10,7 @@ require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
use Finanzmanager\Auth;
|
use Finanzmanager\Auth;
|
||||||
use Finanzmanager\Config;
|
use Finanzmanager\Config;
|
||||||
|
use Finanzmanager\Controllers\AuswertungenController;
|
||||||
use Finanzmanager\Controllers\BuchungenController;
|
use Finanzmanager\Controllers\BuchungenController;
|
||||||
use Finanzmanager\Controllers\BudgetController;
|
use Finanzmanager\Controllers\BudgetController;
|
||||||
use Finanzmanager\Controllers\CockpitController;
|
use Finanzmanager\Controllers\CockpitController;
|
||||||
|
|
@ -134,6 +135,26 @@ $router->get('/planung/monatsabschluss/{id}',
|
||||||
$router->post('/planung/monatsabschluss/{id}/wiedereroeffnen',
|
$router->post('/planung/monatsabschluss/{id}/wiedereroeffnen',
|
||||||
fn(array $p) => (new MonatsabschlussController())->wiedereroeffnen($p));
|
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->get('/verwaltung/import', fn() => (new ImportController())->index());
|
||||||
$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen());
|
$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen());
|
||||||
$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren());
|
$router->post('/verwaltung/import/ausfuehren', fn() => (new ImportController())->ausfuehren());
|
||||||
|
|
|
||||||
|
|
@ -1030,6 +1030,217 @@ body.schlicht {
|
||||||
color: var(--text);
|
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 ─────────────────────────────────────────────────────── */
|
/* ── Schmale Fenster ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
|
|
|
||||||
297
src/Controllers/AuswertungenController.php
Normal file
297
src/Controllers/AuswertungenController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,11 +17,6 @@ class PlatzhalterController extends BaseController
|
||||||
'phase' => 'Phase 7',
|
'phase' => 'Phase 7',
|
||||||
'text' => 'Überweisungen und Terminüberweisungen, abgesendet über FinTS.',
|
'text' => 'Überweisungen und Terminüberweisungen, abgesendet über FinTS.',
|
||||||
],
|
],
|
||||||
'auswertungen' => [
|
|
||||||
'titel' => 'Auswertungen',
|
|
||||||
'phase' => 'Phase 4',
|
|
||||||
'text' => 'Summen-, Buchungs-, Vergleichs- und Budgetbericht samt Grafiken.',
|
|
||||||
],
|
|
||||||
'wertpapiere' => [
|
'wertpapiere' => [
|
||||||
'titel' => 'Wertpapiere',
|
'titel' => 'Wertpapiere',
|
||||||
'phase' => 'Phase 6',
|
'phase' => 'Phase 6',
|
||||||
|
|
|
||||||
190
src/Models/Bericht.php
Normal file
190
src/Models/Bericht.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/Models/Berichtsgruppe.php
Normal file
110
src/Models/Berichtsgruppe.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -302,6 +302,25 @@ class Buchung
|
||||||
return $bewegungen;
|
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)? */
|
/** Wie viele Buchungen hat das Konto (beide Sichten)? */
|
||||||
public static function anzahlFuerKonto(int $kontoId): int
|
public static function anzahlFuerKonto(int $kontoId): int
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,20 @@ class Budget
|
||||||
return $zeile === false ? null : $zeile;
|
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
|
public static function anlegen(string $name, int $jahr, string $beschreibung = '', bool $aktiv = false): int
|
||||||
{
|
{
|
||||||
$name = trim($name);
|
$name = trim($name);
|
||||||
|
|
|
||||||
1225
src/Services/Auswertung.php
Normal file
1225
src/Services/Auswertung.php
Normal file
File diff suppressed because it is too large
Load diff
218
src/Services/BerichtExport.php
Normal file
218
src/Services/BerichtExport.php
Normal 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
283
src/Services/Zeitraum.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
249
src/Views/auswertungen/bericht.php
Normal file
249
src/Views/auswertungen/bericht.php
Normal 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 ? '&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'; ?>
|
||||||
88
src/Views/auswertungen/drilldown.php
Normal file
88
src/Views/auswertungen/drilldown.php
Normal 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'; ?>
|
||||||
22
src/Views/auswertungen/filterfelder.php
Normal file
22
src/Views/auswertungen/filterfelder.php
Normal 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; ?>
|
||||||
123
src/Views/auswertungen/gruppen.php
Normal file
123
src/Views/auswertungen/gruppen.php
Normal 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'; ?>
|
||||||
99
src/Views/auswertungen/index.php
Normal file
99
src/Views/auswertungen/index.php
Normal 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'; ?>
|
||||||
78
src/Views/auswertungen/teil-buchungen.php
Normal file
78
src/Views/auswertungen/teil-buchungen.php
Normal 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>
|
||||||
76
src/Views/auswertungen/teil-budget.php
Normal file
76
src/Views/auswertungen/teil-budget.php
Normal 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>
|
||||||
57
src/Views/auswertungen/teil-kontostaende.php
Normal file
57
src/Views/auswertungen/teil-kontostaende.php
Normal 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>
|
||||||
147
src/Views/auswertungen/teil-summen.php
Normal file
147
src/Views/auswertungen/teil-summen.php
Normal 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, ',', '.')) . ' %' ?>
|
||||||
|
</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, ',', '.')) . ' %' ?>
|
||||||
|
</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, ',', '.')) . ' %' ?>
|
||||||
|
</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>
|
||||||
|
|
@ -47,7 +47,14 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<section class="kachel">
|
<section class="kachel">
|
||||||
<header><h2>Einnahmen und Ausgaben</h2></header>
|
<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&zeitraum=aktuelles_jahr&aufteilung=monat&angepasst=1"
|
||||||
|
class="knopf">Summenbericht öffnen</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="kachel">
|
<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 1 – Konten, Kategorien, Klassen, QIF-Stammdaten</li>
|
||||||
<li class="erledigt">Phase 2 – Buchungen, Kontoblatt, Splitbuchungen</li>
|
<li class="erledigt">Phase 2 – Buchungen, Kontoblatt, Splitbuchungen</li>
|
||||||
<li class="erledigt">Phase 3 – Budgetplaner und Monatsabschluss</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>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
637
tests/14_AuswertungTest.php
Normal file
637
tests/14_AuswertungTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
169
tests/e2e/specs/auswertungen.spec.js
Normal file
169
tests/e2e/specs/auswertungen.spec.js
Normal 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}/)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Reference in a new issue