Neuer Unterpunkt "Berichte" je Plan: Liste plus Assistent (Titel, Notiz, nominal ODER real, Plan-/Ist-Daten, bis zu drei Szenarien, gespeicherte Analysen). Layout immer gleich, Auswahl bestimmt nur die Bausteine. Die PDF-Datei wird ALS DATEI abgelegt (BYTEA in Postgres, nicht im Container-Dateisystem): Ein Bericht muss in drei Jahren byte-identisch wieder herunterladbar sein -- eine Neuerzeugung koennte das nach Aenderungen an Plan, Rechenkern oder Layout nicht garantieren. Kennzahlen je Szenario inkl. offener Entscheide. Deren Zaehlung liegt neu als reine Funktion in decisions.ts, die Matrix UND Bericht benutzen -- sonst nennen beide verschiedene Zahlen. Zu jeder Kennzahl ihre Grundlage als Verweis; die vollstaendigen Annahmen einmal je Szenario. Haftungsausschluss ist verpflichtend (per Test). Technik: pdfkit in der Node-Runtime statt Headless-Browser; @react-pdf/renderer bricht mit React 19. Als externes Paket deklariert, weil pdfkit Font-Metriken ueber Dateipfade laedt. Spezifikation 0.25 (3.11 neu), 9 Tests (212 -> 221). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+114
-3
@@ -4,7 +4,7 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||
| **Version** | 0.24 |
|
||||
| **Version** | 0.25 |
|
||||
| **Datum** | 2026-07-18 |
|
||||
| **Status** | Lebendes Dokument |
|
||||
| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) |
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
| Version | Datum | Autor | Änderung |
|
||||
|---|---|---|---|
|
||||
| 0.25 | 2026-07-21 | Claude (Opus 4.8) | **PDF-Berichte** (Roadmap Nr. 11, neues Kapitel 3.11). Neuer Unterpunkt **«Berichte»** auf Plan-Ebene: Liste der erzeugten Berichte plus Assistent zum Anlegen (Titel, Notiz, nominal **oder** real, Plan- oder effektive Daten, bis zu **drei** Szenarien, beliebige gespeicherte Analysen). Das **Layout ist immer gleich**; die Auswahl bestimmt nur, welche Bausteine erscheinen: Deckblatt mit Zusammenfassung und drei Kernaussagen, dann je Szenario Kennzahlen, Vermögensverlauf, Lebensphasen und Annahmen, danach Vergleich, Plan/Ist, Analysen und die Hinweise. **Die PDF-Datei wird als Datei abgelegt** (BYTEA in Postgres, nicht im Container-Dateisystem, das jeder Deploy neu baut): Ein Bericht muss in drei Jahren byte-identisch wieder herunterladbar sein – eine Neuerzeugung könnte das nach Änderungen an Plan, Rechenkern oder Layout nicht garantieren. **Kennzahlen je Szenario:** Endvermögen, Kapitalreichweite, Vermögen und Vorsorgekapital bei Pensionierung, AHV- und PK-Rente sowie die **offenen Entscheide** – die einzige unmittelbar handlungsleitende Zahl. Damit Bericht und Matrix nie verschiedene Zahlen nennen, liegt deren Zählung neu als reine Funktion in `decisions.ts`, die beide benutzen. **Zu jeder Kennzahl steht ihre Grundlage** als kurzer Verweis; die vollständigen Annahmen (Startwerte, Renditen, Raten je Element) stehen **einmal** je Szenario, statt bei jeder Kennzahl wiederholt zu werden. Ein **Haftungsausschluss** ist verpflichtend und durch einen Test gesichert – ein formal gesetztes PDF wird sonst als Beratung gelesen. **Technik:** `pdfkit` in der Node-Runtime statt Headless-Browser (kein Chromium im Image); `@react-pdf/renderer` schied aus, weil es mit React 19 / Next 16 bricht. Diagramme entstehen als **echte Vektoren** aus den gespeicherten Zahlen – genau dafür wurden die Analysen in 0.24 als Zahlen und nicht als Bilder abgelegt. `pdfkit` ist als externes Paket deklariert, weil es Font-Metriken über Dateipfade lädt und gebündelt erst in der Produktion bräche. Neue Tabelle `Report`, Endpunkte unter `/api/plans/<id>/reports`, neue Module `report.ts`, `report-pdf.ts`, `decisions.ts`; 9 Tests ergänzt (212 → 221). |
|
||||
| 0.24 | 2026-07-20 | Claude (Opus 4.8) | **Navigation auf Plan-Ebene und gespeicherte Analysen** (neues Kapitel 3.10). Die Seitenleiste ist neu zweistufig: Unter jedem Plan liegen die Unterpunkte **Szenarien**, **Effektive Werte** und **Analysen**; ein Klick auf den Plan-Namen öffnet ein **Plan-Dashboard** (Kennzahlen – Haushaltsdaten direkt, gerechnete Werte ausdrücklich «laut Basisszenario», dazu die Ist-Abweichung, falls erfasst). Die **Szenario-Liste** zeigt je Szenario Version, Elementzahl, Endvermögen und Ruinalter mit den Aktionen Historie und Matrix; das Basisszenario ist hervorgehoben, der Szenario-Baum in der Seitenleiste bleibt daneben erhalten. Die **Analysen-Ansicht** bietet vier umklappende Kacheln (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren – Umklappen auch per Antippen für Touch). **Grafiken** öffnen neu nicht mehr alle drei Diagramme, sondern lassen zuerst **eines** wählen; der Szenario-Vergleich zieht mit zu den Grafiken, der CSV-Export auf die Matrix. **Gespeicherte Analysen:** Jede Grafik, MC-Simulation und Einflussfaktoren-Berechnung kann festgehalten werden – als **Zahlen, nicht als Bild** (read-only, es wird nichts neu gerechnet). Das hält den Datensatz klein und macht ihn druckfähig: Der spätere PDF-Bericht (Roadmap Nr. 11) zeichnet daraus vektoriell in Druckauflösung, was ein Screenshot nicht könnte. Bewusst **nicht** gespeichert wird `finalWealthSorted` (megabyteweise). Jedes Werkzeug schreibt sein Ergebnis in dieselbe generische Form, sodass eine Nur-Lese-Ansicht genügt. Neue Tabelle `SavedAnalysis`, neue Endpunkte unter `/api/plans/<id>/analyses` und `/dashboard`; neue Komponenten `PlanViews`, `SavedAnalysisView`, `SaveAnalysisButton`, neues Modul `analyses.ts`. Kein Eingriff in den Rechenkern; Testbestand 212 (Migration um `SavedAnalysis` erweitert). |
|
||||
| 0.23 | 2026-07-20 | Claude (Opus 4.8) | **V7: Der Haushalt liegt am Plan, die Annahmen am Szenario.** **Haushaltsform**, **Personen** (Name, Alter) und **Planstartjahr** wandern vom Szenario auf den **Plan**; das **Pensionsalter** bleibt szenario-eigen (es ist der Kern jedes Früh-/Spätpensionierungs-Szenarios), ebenso Inflation und Cash-Anfangswert. Begründung: Diese Angaben beschreiben den Haushalt, nicht eine Planungsvariante – unterscheiden sie sich, ist es ein anderer **Plan**, kein anderes Szenario. Neue Tabelle `PlanPerson` (Rolle, Name, Alter je Plan); `Person` behält nur noch Rolle und Pensionsalter; `Plan` bekommt `householdType` und `startYear`. **Der Rechenkern bleibt unberührt:** `toPlanInput()` fügt Plan- und Szenario-Ebene wieder zu einem unveränderten `PlanInput` zusammen, die 43 Golden Tests laufen durch. **Nebeneffekt, der ein reales Problem löst:** Weil das Startjahr nun plan-weit ist, landet ein erfasster Ist-Satz für 2031 in **allen** Szenarien zwingend auf demselben Planjahr – vorher war das nicht garantiert. Das **Wiederherstellen einer Szenario-Version** setzt folgerichtig nur noch das Szenario-Eigene zurück; plan-weite Angaben über eine Version *eines* Szenarios zu überschreiben, hätte die übrigen stillschweigend mitverändert. Der Profil-Dialog kennzeichnet neu je Feld, ob es **plan-weit** oder **nur dieses Szenario** gilt. Die Migration übernimmt die Werte aus dem **Basisszenario**; zwei neue Tests spielen dafür echte V6-Daten ein und prüfen die Übernahme inkl. abweichender Nebenszenarien (210 → 212). |
|
||||
| 0.22 | 2026-07-20 | Claude (Opus 4.8) | **Fehlerbehebung: Cash-Vorbelegung im Ist-Wizard.** Der Wizard für die effektiven Werte zeigte als geplanten Cash-Bestand den Stand am **Phasenende** statt am gewählten Stichtag -- in einer Phase von 2026 bis 2036 also für 2031 den Wert von 2036. Ursache: Der Dialog las `cashBridge.cashEnd`, weil `computePlan` den Cash-Bestand bisher nur **je Phase** auswies. `YearPoint` trägt neu ein Feld `cash` (Stand am Jahresende), analog zu `wealthNominal`; der Wizard liest daraus. Die Vorbelegung der ELEMENTE war nie betroffen -- die stammte schon immer aus dem Jahresverlauf. Zwei Regressionstests decken den gemeldeten Fall ab (208 -> 210). Rein additiv, die 43 Golden Tests laufen unverändert. |
|
||||
@@ -1545,6 +1546,111 @@ Version, und Löschen entfernt ihn ersatzlos.
|
||||
Referenz: `src/components/PlanViews.tsx`, `src/components/SavedAnalysisView.tsx`,
|
||||
`src/components/SaveAnalysisButton.tsx`, `src/lib/analyses.ts`.
|
||||
|
||||
## 3.11 PDF-Berichte
|
||||
|
||||
Roadmap Nr. 11. Neuer Unterpunkt **«Berichte»** auf Plan-Ebene: eine Liste der erzeugten
|
||||
Berichte, von dort aus lässt sich ein neuer anlegen.
|
||||
|
||||
### 3.11.1 Was ein Bericht ist
|
||||
|
||||
Ein Bericht ist ein **festes Dokument**, kein Blick auf den aktuellen Stand. Die erzeugte
|
||||
PDF-Datei wird **als Datei abgelegt** und lässt sich jederzeit unverändert wieder
|
||||
herunterladen – auch nachdem der Plan weiterentwickelt wurde. Das ist der Zweck: ein
|
||||
verlässlicher Audit-Trail. Wer heute einen Bericht verschickt, muss in drei Jahren exakt
|
||||
dasselbe Dokument vorweisen können.
|
||||
|
||||
Deshalb wird **nicht** die Definition gespeichert und das PDF bei Bedarf neu erzeugt: Eine
|
||||
Neuerzeugung könnte nach Änderungen an Plan, Rechenkern oder Layout nicht mehr dieselben
|
||||
Bytes liefern.
|
||||
|
||||
Abgelegt werden drei Dinge: die gewählten **Parameter**, das eingefrorene **Berichtsmodell**
|
||||
(alle Zahlen, für die Liste und zur Nachvollziehbarkeit) und die **PDF-Datei** selbst. In der
|
||||
Datenbank, nicht im Dateisystem – der Anwendungscontainer wird bei jedem Deploy neu gebaut,
|
||||
nur das Datenbank-Volume überlebt.
|
||||
|
||||
### 3.11.2 Auswahl beim Anlegen
|
||||
|
||||
| Angabe | Möglichkeiten |
|
||||
|---|---|
|
||||
| Titel und Notiz | frei; die Notiz erscheint auf dem Deckblatt |
|
||||
| **Werte** | nominal **oder** real – eine Leitgrösse für den ganzen Bericht |
|
||||
| **Grundlage** | Plandaten oder **effektive Werte** (nur wenn erfasst) |
|
||||
| **Szenarien** | 1 bis **3** (Konstante `MAX_REPORT_SCENARIOS`) |
|
||||
| **Analysen** | beliebig viele der gespeicherten Auswertungen |
|
||||
|
||||
Zwei bewusste Begrenzungen:
|
||||
|
||||
**Nur eine Leitgrösse.** «Beides» würde jede Tabellenspalte verdoppeln – dasselbe Problem wie
|
||||
in der Matrix ([9.29](#929-acht-zahlen-je-zelle--und-wie-wir-sie-vermeiden)).
|
||||
|
||||
**Höchstens drei Szenarien.** Darüber wird die Vergleichstabelle unlesbar; vergleichbare
|
||||
Werkzeuge stellen bewusst nur zwei gegenüber. Das Basisszenario führt den Bericht an und
|
||||
liefert die Zusammenfassung.
|
||||
|
||||
### 3.11.3 Aufbau des Dokuments
|
||||
|
||||
Das Layout ist **immer gleich**; die Auswahl bestimmt nur, welche Bausteine erscheinen.
|
||||
|
||||
| Seite | Inhalt |
|
||||
|---|---|
|
||||
| 1 | Deckblatt · **Das Wichtigste in Kürze** (Kennzahlen + drei Kernaussagen) · Ausgangslage |
|
||||
| je Szenario | Kennzahlen · Vermögensverlauf · Lebensphasen · **Annahmen** |
|
||||
| bei ≥ 2 Szenarien | Vergleichstabelle |
|
||||
| bei Ist-Daten | Plan gegenüber effektiven Werten inkl. Abweichung |
|
||||
| bei gewählten Analysen | die eingefrorenen Auswertungen |
|
||||
| Schluss | **Wichtige Hinweise** (Haftungsausschluss) |
|
||||
|
||||
Die **Zusammenfassung zuerst** ist kein Geschmacksentscheid: Überkomplexität ist die
|
||||
häufigste Kritik an Beraterberichten, weshalb sich die einseitige Übersicht als eigenes
|
||||
Format etabliert hat.
|
||||
|
||||
### 3.11.4 Kennzahlen und ihre Grundlage
|
||||
|
||||
Je Szenario: **Endvermögen** · **Kapital reicht bis** · **Vermögen bei Pensionierung** ·
|
||||
**Vorsorgekapital bei Pensionierung** (PK + 3a) · **AHV-Rente** · **PK-Rente** ·
|
||||
**offene Entscheide**.
|
||||
|
||||
Die letzte ist die einzige unmittelbar handlungsleitende Zahl – sie zählt die noch nicht
|
||||
getroffenen Übergangs-Entscheide. Damit Bericht und Matrix nie verschiedene Zahlen nennen,
|
||||
liegt die Zählung neu als reine Funktion in `lib/decisions.ts`, die **beide** benutzen.
|
||||
|
||||
**Zu jeder Kennzahl steht ihre Grundlage** – aber als **kurzer Verweis**, nicht als
|
||||
wiederholte Tabelle. Die vollständigen Annahmen (Inflation, Pensionsalter, Startwerte,
|
||||
Renditen, Sparraten je Element) stehen **einmal** je Szenario in einem eigenen Abschnitt.
|
||||
Sie bei jeder Kennzahl zu wiederholen würde den Bericht aufblähen, ohne etwas hinzuzufügen.
|
||||
|
||||
### 3.11.5 Haftungsausschluss
|
||||
|
||||
Ein formal gesetztes PDF wird als Beratung gelesen. Der Bericht schliesst deshalb
|
||||
**verpflichtend** mit Hinweisen: dass es sich um eine Projektion auf Basis eigener Annahmen
|
||||
handelt, keine Anlage-, Steuer- oder Vorsorgeberatung; dass Abweichungen erheblich sein
|
||||
können; dass laufende Einkommens- und Vermögenssteuern **nicht** modelliert sind
|
||||
([9.14](#914-keine-steuerschätzung)); und dass Monte-Carlo-Wahrscheinlichkeiten die Streuung
|
||||
**um** die Annahmen messen, nicht deren Richtigkeit. Ein Test stellt sicher, dass der
|
||||
Abschnitt nicht wegfallen kann.
|
||||
|
||||
### 3.11.6 Technik
|
||||
|
||||
Erzeugt wird mit **pdfkit** in der Node-Runtime. Bewusst **kein Headless-Browser**: Chromium
|
||||
würde das Image um Hunderte Megabyte und Systembibliotheken aufblähen. `@react-pdf/renderer`
|
||||
schied aus, weil es React-Interna nutzt, die mit React 19 / Next 16 brechen.
|
||||
|
||||
Weil das Layout ohnehin fix ist, kostet programmatisches Setzen nichts – und die Diagramme
|
||||
entstehen als **echte Vektoren** aus den gespeicherten Zahlen. Genau dafür wurden die
|
||||
gespeicherten Analysen als Zahlen und nicht als Bilder abgelegt
|
||||
([3.10.3](#3103-gespeicherte-analysen)): Ein Bildschirm-Abbild wäre im Druck unscharf.
|
||||
|
||||
`pdfkit` ist in `next.config.ts` als **externes Paket** deklariert. Es lädt seine
|
||||
Font-Metriken zur Laufzeit über Dateipfade; gebündelt stimmt `__dirname` nicht mehr und der
|
||||
Bericht bräche erst in der Produktion.
|
||||
|
||||
Die Trennung ist zweistufig: `report.ts` baut aus Plan und Konfiguration ein reines
|
||||
**Berichtsmodell** (ohne PDF-Kenntnisse, testbar), `report-pdf.ts` zeichnet es. Damit ist
|
||||
das, was eingefroren wird, dasselbe, was geprüft wird.
|
||||
|
||||
Referenz: `src/lib/report.ts`, `src/lib/report-pdf.ts`, `src/lib/decisions.ts`,
|
||||
`src/components/ReportsView.tsx`.
|
||||
|
||||
---
|
||||
|
||||
# 4. Berechnungsmodell
|
||||
@@ -2747,7 +2853,7 @@ Referenz: `src/lib/livesim.ts`, `src/lib/sensitivity.ts` (`applyElementDriver`,
|
||||
FPT/
|
||||
├── prisma/
|
||||
│ ├── schema.prisma Datenmodell
|
||||
│ └── migrations/ 16 Migrationen (chronologisch, siehe 5.4.6)
|
||||
│ └── migrations/ 17 Migrationen (chronologisch, siehe 5.4.6)
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── api/ Route Handlers (siehe Kapitel 6)
|
||||
@@ -2789,6 +2895,9 @@ PlanComputed ← an den Client geliefert
|
||||
| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. |
|
||||
| `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`; zusätzlich `applyElementDriver` / `tunableElements` für die einzeln regelbaren Element-Renditen. Rein, läuft im Browser. |
|
||||
| `actuals.ts` | Effektive Werte: Zuordnung auf die Szenario-Elemente über die Herkunfts-Kette, Einspielen in den Rechenkern, Bestand/Fluss. Rein. |
|
||||
| `report.ts` | Berichtsmodell: Kennzahlen, Annahmen, Vergleich, Plan/Ist -- rein, ohne PDF-Kenntnisse (Kap. 3.11). |
|
||||
| `report-pdf.ts` | Zeichnet das Berichtsmodell mit pdfkit. Nur serverseitig. |
|
||||
| `decisions.ts` | Offene Übergangs-Entscheide -- von Matrix UND Bericht benutzt, damit beide dieselbe Zahl nennen. |
|
||||
| `analyses.ts` | Gespeicherte Analysen: Typen, generische Ergebnis-Form, Speicher-Helfer (Kap. 3.10). |
|
||||
| `dataview.ts` | Bündelt Plan-Sicht und Ist-Sicht für Matrix, Grafiken und Analysewerkzeuge. Rein. |
|
||||
| `ratefields.ts` | Ratenfelder je Kategorie, Reichweite der Übernahme über Lebensphasen, Aufbau der Schreibvorgänge (Kap. 3.6.11). Rein. |
|
||||
@@ -3008,6 +3117,7 @@ sondern zu leeren Werten.
|
||||
| `20260719210000_scenario_versioning` | Tabelle `ScenarioVersion` (Snapshot als JSONB, A.B eindeutig je Szenario) und `Scenario.currentMajor` |
|
||||
| `20260720090000_actuals` | Tabelle `ActualsSet` (effektive Werte je Plan, Werte als JSONB, Cash separat) |
|
||||
| `20260720140000_plan_level_profile` | **V7**: Haushaltsform, Personen (Name/Alter) und Startjahr vom Szenario auf den Plan; neue Tabelle `PlanPerson`; `Person` behält nur das Pensionsalter. Datenübernahme aus dem Basisszenario. |
|
||||
| `20260721090000_reports` | Tabelle `Report`: gewählte Parameter, eingefrorenes Modell und die PDF-Datei als `BYTEA` |
|
||||
| `20260720160000_saved_analyses` | Tabelle `SavedAnalysis` (Eingaben + Ergebnis als JSONB, denormalisierte Kerndaten) |
|
||||
|
||||
**Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um – dadurch
|
||||
@@ -3367,12 +3477,13 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|
||||
| `ratefields.test.ts` | 17 | Erkennung geänderter Raten, Reichweite (diese/folgende/alle), Erhalt der übrigen Werte in den Zielphasen |
|
||||
| `versioning.test.ts` | 22 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien |
|
||||
| `versioning-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus |
|
||||
| `report.test.ts` | 9 | Zusammenfassung, Basis-Angabe zu JEDER Kennzahl, Szenario-Deckelung, Plan/Ist-Block, Haftungsausschluss, gültige PDF-Datei |
|
||||
| `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung |
|
||||
| `phaseplan.test.ts` | 8 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen |
|
||||
| `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus |
|
||||
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
|
||||
| `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) |
|
||||
| **Total** | **212** | |
|
||||
| **Total** | **221** | |
|
||||
|
||||
## 8.2 Testfälle
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
// pdfkit lädt seine Font-Metriken (.afm) zur Laufzeit über Dateipfade. Gebündelt stimmt
|
||||
// `__dirname` nicht mehr und die Dateien werden nicht gefunden -- der PDF-Bericht bräche
|
||||
// erst in der Produktion. Als externes Paket wird es zur Laufzeit aus node_modules
|
||||
// geladen (die der Docker-Build vollständig mitkopiert).
|
||||
serverExternalPackages: ["pdfkit"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+197
@@ -15,6 +15,7 @@
|
||||
"jose": "^6.2.3",
|
||||
"lucide-react": "^1.24.0",
|
||||
"next": "16.2.10",
|
||||
"pdfkit": "^0.19.1",
|
||||
"pg": "^8.22.0",
|
||||
"prisma": "^7.8.0",
|
||||
"react": "19.2.4",
|
||||
@@ -29,6 +30,7 @@
|
||||
"@electric-sql/pglite": "^0.4.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -1320,6 +1322,30 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -2553,6 +2579,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pdfkit": {
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
|
||||
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
@@ -3685,6 +3721,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.42",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
|
||||
@@ -3736,6 +3792,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserify-zlib": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "~1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.5",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz",
|
||||
@@ -3988,6 +4062,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -4390,6 +4473,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@@ -5344,6 +5433,23 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -6466,6 +6572,12 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-md5": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -6876,6 +6988,25 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -8284,6 +8415,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -8354,6 +8491,20 @@
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
|
||||
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^1.0.0",
|
||||
"@noble/hashes": "^1.6.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"js-md5": "^0.8.3",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||
@@ -8488,6 +8639,14 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
|
||||
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
|
||||
"dependencies": {
|
||||
"browserify-zlib": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -9048,6 +9207,12 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
@@ -9741,6 +9906,12 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@@ -10054,6 +10225,32 @@
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie/node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"jose": "^6.2.3",
|
||||
"lucide-react": "^1.24.0",
|
||||
"next": "16.2.10",
|
||||
"pdfkit": "^0.19.1",
|
||||
"pg": "^8.22.0",
|
||||
"prisma": "^7.8.0",
|
||||
"react": "19.2.4",
|
||||
@@ -31,6 +32,7 @@
|
||||
"@electric-sql/pglite": "^0.4.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- PDF-Berichte (Roadmap Nr. 11, SPEZIFIKATION 3.11).
|
||||
--
|
||||
-- Die fertige PDF-Datei wird ALS BYTES abgelegt, nicht nur ihre Definition. Grund ist der
|
||||
-- Audit-Trail: Ein Bericht, den man heute erzeugt und verschickt, muss in drei Jahren
|
||||
-- byte-identisch wieder herunterladbar sein -- auch wenn sich Plan, Rechenkern oder Layout
|
||||
-- zwischenzeitlich geaendert haben. Eine Neuerzeugung koennte das nicht garantieren.
|
||||
--
|
||||
-- Der App-Container wird bei jedem Deploy neu gebaut; nur das DB-Volume ueberlebt. Deshalb
|
||||
-- Postgres und nicht das Dateisystem.
|
||||
|
||||
CREATE TABLE "Report" (
|
||||
"id" TEXT NOT NULL,
|
||||
"planId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
|
||||
-- Gewaehlte Parameter (Metrik, Quelle, Szenarien, Analysen) -- fuer die Liste und
|
||||
-- damit nachvollziehbar bleibt, wie der Bericht zustande kam.
|
||||
"config" JSONB NOT NULL DEFAULT '{}',
|
||||
-- Das eingefrorene Berichtsmodell (alle Zahlen). Erlaubt eine Zusammenfassung in der
|
||||
-- Liste, ohne das PDF zu oeffnen.
|
||||
"model" JSONB NOT NULL DEFAULT '{}',
|
||||
-- Die Datei selbst.
|
||||
"pdf" BYTEA NOT NULL,
|
||||
"pdfBytes" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
"createdById" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Report_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "Report_planId_createdAt_idx" ON "Report"("planId", "createdAt");
|
||||
|
||||
ALTER TABLE "Report" ADD CONSTRAINT "Report_planId_fkey"
|
||||
FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Report" ADD CONSTRAINT "Report_createdById_fkey"
|
||||
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -36,6 +36,7 @@ model User {
|
||||
versions ScenarioVersion[]
|
||||
actuals ActualsSet[]
|
||||
analyses SavedAnalysis[]
|
||||
reports Report[]
|
||||
}
|
||||
|
||||
enum HouseholdType {
|
||||
@@ -113,6 +114,33 @@ model Plan {
|
||||
actuals ActualsSet[]
|
||||
persons PlanPerson[]
|
||||
analyses SavedAnalysis[]
|
||||
reports Report[]
|
||||
}
|
||||
|
||||
// Ein erzeugter PDF-Bericht (Roadmap Nr. 11). Die FERTIGE DATEI wird abgelegt, nicht nur
|
||||
// ihre Definition: Ein Bericht, den man heute verschickt, muss in drei Jahren unveraendert
|
||||
// wieder herunterladbar sein -- eine Neuerzeugung koennte das nach Aenderungen an Plan,
|
||||
// Rechenkern oder Layout nicht garantieren.
|
||||
model Report {
|
||||
id String @id @default(cuid())
|
||||
planId String
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
|
||||
title String
|
||||
|
||||
// Gewaehlte Parameter und das eingefrorene Berichtsmodell (alle Zahlen).
|
||||
config Json @default("{}")
|
||||
model Json @default("{}")
|
||||
|
||||
pdf Bytes
|
||||
pdfBytes Int @default(0)
|
||||
|
||||
createdById String
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([planId, createdAt])
|
||||
}
|
||||
|
||||
// Eine festgehaltene Analyse (Roadmap Nr. 5 / Analysen-Ansicht). Haelt EINGABEN und ERGEBNIS
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// Liefert die GESPEICHERTE PDF-Datei. Sie wird nicht neu erzeugt -- das ist der Kern des
|
||||
// Audit-Trails: derselbe Bericht ergibt in drei Jahren dieselben Bytes.
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string; reportId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId, reportId } = await params;
|
||||
|
||||
const report = await prisma.report.findFirst({
|
||||
where: { id: reportId, planId, plan: { userId } },
|
||||
select: { title: true, pdf: true, createdAt: true },
|
||||
});
|
||||
if (!report) return NextResponse.json({ error: "Bericht nicht gefunden." }, { status: 404 });
|
||||
|
||||
const date = report.createdAt.toISOString().slice(0, 10);
|
||||
const safe = report.title.replace(/[^\w\s.-]/g, "").trim().replace(/\s+/g, "_") || "Bericht";
|
||||
|
||||
return new NextResponse(new Uint8Array(report.pdf), {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${date}_${safe}.pdf"`,
|
||||
"Content-Length": String(report.pdf.length),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string; reportId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId, reportId } = await params;
|
||||
|
||||
const report = await prisma.report.findFirst({ where: { id: reportId, planId, plan: { userId } } });
|
||||
if (!report) return NextResponse.json({ error: "Bericht nicht gefunden." }, { status: 404 });
|
||||
|
||||
await prisma.report.delete({ where: { id: reportId } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { planInclude, toPlanInput } from "@/lib/queries";
|
||||
import { buildReport, MAX_REPORT_SCENARIOS, type ReportConfig } from "@/lib/report";
|
||||
import { renderReportPdf } from "@/lib/report-pdf";
|
||||
import type { ActualsSetInput } from "@/lib/actuals";
|
||||
|
||||
// pdfkit braucht die Node-Runtime (Streams, Buffer) -- nicht Edge.
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const createSchema = z.object({
|
||||
title: z.string().min(1).max(160),
|
||||
metric: z.enum(["nominal", "real"]),
|
||||
source: z.enum(["PLAN", "ACTUAL"]),
|
||||
scenarioIds: z.array(z.string()).min(1).max(MAX_REPORT_SCENARIOS),
|
||||
analysisIds: z.array(z.string()).max(20).default([]),
|
||||
comment: z.string().max(500).nullish(),
|
||||
});
|
||||
|
||||
async function ownedPlan(planId: string, userId: string) {
|
||||
return prisma.plan.findFirst({ where: { id: planId, userId } });
|
||||
}
|
||||
|
||||
// Liste der Berichte -- ohne die PDF-Bytes (die kommen erst beim Herunterladen).
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
|
||||
const plan = await ownedPlan(planId, userId);
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const rows = await prisma.report.findMany({
|
||||
where: { planId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
config: true,
|
||||
pdfBytes: true,
|
||||
createdAt: true,
|
||||
createdBy: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
reports: rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
config: r.config,
|
||||
pdfBytes: r.pdfBytes,
|
||||
createdAt: r.createdAt,
|
||||
author: r.createdBy.username,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Erzeugt den Bericht: rechnet, baut das Modell, rendert das PDF und legt beides ab.
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
|
||||
const parsed = createSchema.safeParse(await request.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
const cfg = parsed.data;
|
||||
|
||||
const plan = await prisma.plan.findFirst({
|
||||
where: { id: planId, userId },
|
||||
include: {
|
||||
persons: { orderBy: { role: "asc" } },
|
||||
scenarios: { orderBy: [{ isBase: "desc" }, { createdAt: "asc" }], include: planInclude },
|
||||
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
|
||||
},
|
||||
});
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
// Nur Szenarien dieses Plans, in der Reihenfolge der Auswahl -- Basis zuerst.
|
||||
const chosen = cfg.scenarioIds
|
||||
.map((id) => plan.scenarios.find((s) => s.id === id))
|
||||
.filter((s): s is (typeof plan.scenarios)[number] => !!s)
|
||||
.sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
|
||||
if (chosen.length === 0) {
|
||||
return NextResponse.json({ error: "Kein gültiges Szenario ausgewählt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const analyses =
|
||||
cfg.analysisIds.length === 0
|
||||
? []
|
||||
: await prisma.savedAnalysis.findMany({
|
||||
where: { id: { in: cfg.analysisIds }, planId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { name: true, type: true, result: true },
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { username: true } });
|
||||
|
||||
const config: ReportConfig = {
|
||||
title: cfg.title,
|
||||
metric: cfg.metric,
|
||||
source: cfg.source,
|
||||
scenarioIds: chosen.map((s) => s.id),
|
||||
analysisIds: cfg.analysisIds,
|
||||
comment: cfg.comment ?? null,
|
||||
};
|
||||
|
||||
const model = buildReport({
|
||||
planName: plan.name,
|
||||
author: user?.username ?? "unbekannt",
|
||||
createdAt: new Date(),
|
||||
config,
|
||||
scenarios: chosen.map((s) => ({ name: s.name, isBase: s.isBase, plan: toPlanInput(s) })),
|
||||
household: {
|
||||
householdType: plan.householdType,
|
||||
startYear: plan.startYear,
|
||||
persons: plan.persons.map((p) => ({ name: p.name, age: p.age, role: p.role })),
|
||||
},
|
||||
actuals: plan.actuals.map(
|
||||
(a): ActualsSetInput => ({
|
||||
id: a.id,
|
||||
recordedOn: a.recordedOn.toISOString().slice(0, 10),
|
||||
year: a.year,
|
||||
comment: a.comment,
|
||||
cash: a.cash,
|
||||
values: a.values as Record<string, { value?: number; mortgage?: number }>,
|
||||
})
|
||||
),
|
||||
origins: plan.scenarios.flatMap((s) => s.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId }))),
|
||||
analyses: analyses.map((a) => ({ name: a.name, type: a.type, result: a.result })),
|
||||
});
|
||||
|
||||
const pdf = await renderReportPdf(model);
|
||||
|
||||
const created = await prisma.report.create({
|
||||
data: {
|
||||
planId,
|
||||
title: cfg.title,
|
||||
config: config as unknown as object,
|
||||
model: model as unknown as object,
|
||||
pdf: new Uint8Array(pdf),
|
||||
pdfBytes: pdf.length,
|
||||
createdById: userId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ report: { id: created.id, bytes: pdf.length } }, { status: 201 });
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Copy,
|
||||
Dices,
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
FolderKanban,
|
||||
CalendarClock,
|
||||
GitBranch,
|
||||
@@ -33,6 +34,7 @@ import { LiveSimDialog } from "@/components/LiveSimDialog";
|
||||
import { ActualsDialog } from "@/components/ActualsDialog";
|
||||
import { PlanDashboardView, ScenarioListView, AnalysesView } from "@/components/PlanViews";
|
||||
import { SavedAnalysisView } from "@/components/SavedAnalysisView";
|
||||
import { ReportsView } from "@/components/ReportsView";
|
||||
import { buildViews } from "@/lib/dataview";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
|
||||
@@ -105,7 +107,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [showActuals, setShowActuals] = useState(false);
|
||||
// Plan-Ebene: Dashboard / Szenarien-Liste / Analysen. Null = kein Plan-View aktiv.
|
||||
const [planNav, setPlanNav] = useState<{ planId: string; tab: "dashboard" | "scenarios" | "actuals" | "analyses" } | null>(null);
|
||||
const [planNav, setPlanNav] = useState<{ planId: string; tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports" } | null>(null);
|
||||
// Welche Szenario-Bäume in der Seitenleiste aufgeklappt sind. Standard: eingeklappt.
|
||||
const [expandedTrees, setExpandedTrees] = useState<Record<string, boolean>>({});
|
||||
const [savedAnalysisId, setSavedAnalysisId] = useState<string | null>(null);
|
||||
@@ -182,7 +184,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
|
||||
// Plan-Ebene öffnen (Dashboard / Szenarien / Analysen). Räumt die Szenario- und Wissens-
|
||||
// Ansichten weg -- es kann immer nur eine Hauptansicht aktiv sein.
|
||||
function openPlanTab(planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses") {
|
||||
function openPlanTab(planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports") {
|
||||
setPlanNav({ planId, tab });
|
||||
setSelectedScenarioId(null);
|
||||
setShowSpec(false);
|
||||
@@ -347,7 +349,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
|
||||
{plans.map((p) => {
|
||||
const navHere = planNav?.planId === p.id;
|
||||
const subItem = (tab: "dashboard" | "scenarios" | "actuals" | "analyses", label: string, Icon: typeof FolderKanban) => (
|
||||
const subItem = (tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports", label: string, Icon: typeof FolderKanban) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPlanTab(p.id, tab)}
|
||||
@@ -427,6 +429,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
Effektive Werte
|
||||
</button>
|
||||
{subItem("analyses", "Analysen", BarChart3)}
|
||||
{subItem("reports", "Berichte", FileSpreadsheet)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -510,7 +513,11 @@ function AppShellInner({ username }: { username: string }) {
|
||||
: showSpec
|
||||
? "So rechnet FPT"
|
||||
: planNav
|
||||
? `${activePlan?.name ?? "Plan"} · ${planNav.tab === "dashboard" ? "Dashboard" : planNav.tab === "scenarios" ? "Szenarien" : "Analysen"}`
|
||||
? `${activePlan?.name ?? "Plan"} · ${
|
||||
{ dashboard: "Dashboard", scenarios: "Szenarien", actuals: "Effektive Werte", analyses: "Analysen", reports: "Berichte" }[
|
||||
planNav.tab
|
||||
]
|
||||
}`
|
||||
: selectedScenarioId && detail
|
||||
? `${detail.meta.planName} · ${detail.meta.name}`
|
||||
: "Übersicht"}
|
||||
@@ -567,6 +574,14 @@ function AppShellInner({ username }: { username: string }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showSpec && !showSystemParams && planNav?.tab === "reports" && activePlan && (
|
||||
<ReportsView
|
||||
planId={planNav.planId}
|
||||
scenarios={activePlan.scenarios.map((s) => ({ id: s.id, name: s.name, isBase: s.isBase }))}
|
||||
hasActuals={(activePlan._count?.actuals ?? 0) > 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showSpec && !showSystemParams && !loading && !planNav && selectedScenarioId === null && (
|
||||
<DashboardHome
|
||||
username={username}
|
||||
|
||||
+12
-40
@@ -27,6 +27,12 @@ import { Timeline } from "@/components/Timeline";
|
||||
import { Sparkline } from "@/components/Sparkline";
|
||||
import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs";
|
||||
import { capitalPot } from "@/lib/distribution";
|
||||
import {
|
||||
isRetirementTransition,
|
||||
openTransitionCount,
|
||||
transitionInactive as inactiveAtTransition,
|
||||
TRANSITION_CATEGORIES,
|
||||
} from "@/lib/decisions";
|
||||
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
|
||||
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
|
||||
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
||||
@@ -75,15 +81,6 @@ const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
|
||||
|
||||
// Kategorien mit einem Übergangs-Entscheid. AHV ist dabei ein Sonderfall: nur beim
|
||||
// Pensions-Übergang ist die Beitragskarriere zu prüfen (siehe transitionInactive).
|
||||
const TRANSITION_CATEGORIES: ElementCategory[] = [
|
||||
"AHV",
|
||||
"PENSION_FUND",
|
||||
"PILLAR_3A",
|
||||
"REAL_ESTATE",
|
||||
"OTHER_ASSET",
|
||||
"OTHER_DEBT",
|
||||
];
|
||||
|
||||
const VALUE_CATEGORIES: ElementCategory[] = [
|
||||
"PENSION_FUND",
|
||||
"PILLAR_3A",
|
||||
@@ -244,12 +241,8 @@ export function PlanView({
|
||||
return computed.phases.find((p) => p.id === phaseId)?.elements.find((e) => e.elementId === elementId);
|
||||
}
|
||||
|
||||
function isRetirementTransition(element: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
|
||||
if (!element.ownerRole || element.ownerRole === "HOUSEHOLD") return false;
|
||||
const before = fromPhase.persons.find((p) => p.role === element.ownerRole);
|
||||
const after = toPhase.persons.find((p) => p.role === element.ownerRole);
|
||||
return !!before?.working && !!after && !after.working;
|
||||
}
|
||||
// isRetirementTransition/transitionInactive/openTransitionCount kommen aus lib/decisions --
|
||||
// der Bericht zaehlt mit derselben Regel (sonst meldet er andere Zahlen als die Matrix).
|
||||
|
||||
// Beitragskarriere des Element-Besitzers (nur für AHV relevant).
|
||||
function careerFor(element: ElementInput) {
|
||||
@@ -315,20 +308,8 @@ export function PlanView({
|
||||
// Am Übergang nichts (mehr) zu tun: verkauft/getilgt ODER PK/3a nach der Pensionierung
|
||||
// (Besitzer ist zu Beginn der Von-Phase bereits pensioniert -> bereits bezogen/verrentet)
|
||||
// ODER AHV ausserhalb des Pensions-Übergangs.
|
||||
function transitionInactive(el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed): boolean {
|
||||
const ce = computedElement(fromPhase.id, el.id);
|
||||
if (ce && ce.status !== "ACTIVE") return true;
|
||||
if (el.category === "AHV") {
|
||||
return !(toPhase && isRetirementTransition(el, fromPhase, toPhase));
|
||||
}
|
||||
if (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") {
|
||||
if (el.ownerRole && el.ownerRole !== "HOUSEHOLD") {
|
||||
const owner = fromPhase.persons.find((p) => p.role === el.ownerRole);
|
||||
if (owner && !owner.working) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const transitionInactive = (el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed) =>
|
||||
inactiveAtTransition(computed, el, fromPhase, toPhase);
|
||||
|
||||
function cashTransitionFor(phaseId: string): CashTransitionData {
|
||||
return plan.phases.find((p) => p.id === phaseId)?.cashTransition ?? {};
|
||||
@@ -336,17 +317,8 @@ export function PlanView({
|
||||
|
||||
// Anzahl offener (noch nicht getroffener) Übergangs-Entscheide an einer Grenze.
|
||||
// Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zählt mit.
|
||||
function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number {
|
||||
let n = isCashTransitionAnswered(cashTransitionFor(fromPhase.id)) ? 0 : 1;
|
||||
for (const el of plan.elements) {
|
||||
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
|
||||
if (transitionInactive(el, fromPhase, toPhase)) continue;
|
||||
const td = el.transitionValues[fromPhase.id] ?? {};
|
||||
const retire = toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false;
|
||||
if (!isTransitionAnswered(el.category, retire, td)) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
const transitionOpenCount = (fromPhase: PhaseComputed, toPhase: PhaseComputed) =>
|
||||
openTransitionCount(plan, computed, fromPhase, toPhase);
|
||||
|
||||
// Ist der Übergangs-Entscheid dieses Elements noch offen?
|
||||
function transitionUnanswered(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowLeft, Download, FileText, Plus, Trash2 } from "lucide-react";
|
||||
import { Button, useConfirm, useToast } from "@/components/ui";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { ANALYSIS_TYPE_LABEL, type AnalysisType, type SavedAnalysisMeta } from "@/lib/analyses";
|
||||
import { MAX_REPORT_SCENARIOS, type ReportMetric, type ReportSource } from "@/lib/report";
|
||||
|
||||
interface StoredReport {
|
||||
id: string;
|
||||
title: string;
|
||||
config: { metric?: string; source?: string; scenarioIds?: string[]; analysisIds?: string[]; comment?: string | null };
|
||||
pdfBytes: number;
|
||||
createdAt: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
const dt = (iso: string) =>
|
||||
new Date(iso).toLocaleString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
export function ReportsView({
|
||||
planId,
|
||||
scenarios,
|
||||
hasActuals,
|
||||
}: {
|
||||
planId: string;
|
||||
scenarios: { id: string; name: string; isBase: boolean }[];
|
||||
hasActuals: boolean;
|
||||
}) {
|
||||
const [reports, setReports] = useState<StoredReport[] | null>(null);
|
||||
const [analyses, setAnalyses] = useState<SavedAnalysisMeta[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<"list" | "new">("list");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
|
||||
// Formular
|
||||
const [title, setTitle] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
const [metric, setMetric] = useState<ReportMetric>("nominal");
|
||||
const [source, setSource] = useState<ReportSource>("PLAN");
|
||||
const [scenarioIds, setScenarioIds] = useState<string[]>([]);
|
||||
const [analysisIds, setAnalysisIds] = useState<string[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [r, a] = await Promise.all([
|
||||
api.get<{ reports: StoredReport[] }>(`/api/plans/${planId}/reports`),
|
||||
api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`),
|
||||
]);
|
||||
setReports(r.reports);
|
||||
setAnalyses(a.analyses);
|
||||
}, [planId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [r, a] = await Promise.all([
|
||||
api.get<{ reports: StoredReport[] }>(`/api/plans/${planId}/reports`),
|
||||
api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setReports(r.reports);
|
||||
setAnalyses(a.analyses);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [planId]);
|
||||
|
||||
function startNew() {
|
||||
const base = scenarios.find((s) => s.isBase) ?? scenarios[0];
|
||||
setTitle(`Finanzplanung ${new Date().getFullYear()}`);
|
||||
setComment("");
|
||||
setMetric("nominal");
|
||||
setSource("PLAN");
|
||||
setScenarioIds(base ? [base.id] : []);
|
||||
setAnalysisIds([]);
|
||||
setMode("new");
|
||||
}
|
||||
|
||||
function toggleScenario(id: string) {
|
||||
setScenarioIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : prev.length >= MAX_REPORT_SCENARIOS ? prev : [...prev, id]
|
||||
);
|
||||
}
|
||||
|
||||
async function create() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.post(`/api/plans/${planId}/reports`, {
|
||||
title: title.trim() || "Finanzplanung",
|
||||
metric,
|
||||
source,
|
||||
scenarioIds,
|
||||
analysisIds,
|
||||
comment: comment.trim() || undefined,
|
||||
});
|
||||
await load();
|
||||
setMode("list");
|
||||
toast("success", "Bericht erstellt.");
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Bericht konnte nicht erstellt werden.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(r: StoredReport) {
|
||||
const ok = await confirm({
|
||||
title: "Bericht löschen?",
|
||||
message: `«${r.title}» wird endgültig entfernt. Die Datei lässt sich danach nicht mehr herunterladen.`,
|
||||
confirmLabel: "Löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.delete(`/api/plans/${planId}/reports/${r.id}`);
|
||||
await load();
|
||||
toast("success", "Bericht gelöscht.");
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "new") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-fg">Neuer Bericht</h2>
|
||||
<p className="text-sm text-muted">
|
||||
Der Bericht wird als PDF erzeugt und abgelegt. Er hält den Stand von heute fest – spätere Änderungen am
|
||||
Plan verändern ihn nicht mehr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted">Titel</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted">Notiz auf dem Deckblatt (optional)</label>
|
||||
<input
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="z. B. «Stand nach Beratungsgespräch»"
|
||||
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span className="font-medium">Werte</span>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
|
||||
{(["nominal", "real"] as ReportMetric[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMetric(m)}
|
||||
className={`rounded-md px-2 py-0.5 font-medium ${metric === m ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"}`}
|
||||
>
|
||||
{m === "real" ? "Real" : "Nominal"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<InfoBubble text="Der Bericht führt durchgängig EINE Leitgrösse. Beides nebeneinander würde jede Tabellenspalte verdoppeln." />
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span className="font-medium">Grundlage</span>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
|
||||
{(["PLAN", "ACTUAL"] as ReportSource[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={s === "ACTUAL" && !hasActuals}
|
||||
onClick={() => setSource(s)}
|
||||
title={s === "ACTUAL" && !hasActuals ? "Für diesen Plan sind keine effektiven Werte erfasst." : undefined}
|
||||
className={`rounded-md px-2 py-0.5 font-medium disabled:opacity-40 ${
|
||||
source === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{s === "ACTUAL" ? "Effektiv" : "Plan"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Szenarien (max. {MAX_REPORT_SCENARIOS})
|
||||
<InfoBubble text="Mehr als drei Szenarien machen die Vergleichstabelle unlesbar. Das Basisszenario führt den Bericht an und liefert die Zusammenfassung." />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{scenarios.map((s) => {
|
||||
const checked = scenarioIds.includes(s.id);
|
||||
const full = !checked && scenarioIds.length >= MAX_REPORT_SCENARIOS;
|
||||
return (
|
||||
<label
|
||||
key={s.id}
|
||||
className={`flex items-center gap-2 rounded-xl border p-3 text-sm ${
|
||||
checked ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
|
||||
} ${full ? "opacity-40" : ""}`}
|
||||
>
|
||||
<input type="checkbox" checked={checked} disabled={full} onChange={() => toggleScenario(s.id)} />
|
||||
<span className="font-medium text-fg">{s.name}</span>
|
||||
{s.isBase && <span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">Basis</span>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Gespeicherte Analysen einbinden
|
||||
<InfoBubble text="Übernommen werden die beim Speichern eingefrorenen Zahlen – es wird nichts neu gerechnet." />
|
||||
</div>
|
||||
{analyses.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
|
||||
Noch keine Analysen gespeichert. Unter «Analysen» kannst du Ergebnisse festhalten.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{analyses.map((a) => (
|
||||
<label key={a.id} className="flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={analysisIds.includes(a.id)}
|
||||
onChange={() =>
|
||||
setAnalysisIds((prev) => (prev.includes(a.id) ? prev.filter((x) => x !== a.id) : [...prev, a.id]))
|
||||
}
|
||||
/>
|
||||
<span className="font-medium text-fg">{a.name}</span>
|
||||
<span className="text-faint">{ANALYSIS_TYPE_LABEL[a.type as AnalysisType]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={() => setMode("list")}>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||
</Button>
|
||||
<Button disabled={busy || scenarioIds.length === 0} onClick={create}>
|
||||
{busy ? "Wird erstellt…" : "Bericht erstellen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-fg">Berichte</h2>
|
||||
<Button onClick={startNew} disabled={scenarios.length === 0}>
|
||||
<Plus className="h-4 w-4" /> Neuer Bericht
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
|
||||
Ein Bericht ist ein <strong className="text-fg">festes Dokument</strong>: Die erzeugte PDF-Datei wird abgelegt
|
||||
und lässt sich jederzeit unverändert wieder herunterladen – auch wenn du den Plan danach weiterentwickelst.
|
||||
</p>
|
||||
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{!reports && !error && <p className="text-xs text-muted">Wird geladen…</p>}
|
||||
|
||||
{reports && reports.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
|
||||
Noch kein Bericht erstellt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{reports && reports.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{reports.map((r) => (
|
||||
<div key={r.id} className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface-2 p-3">
|
||||
<FileText className="h-4 w-4 shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-fg">{r.title}</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{dt(r.createdAt)} · {r.author} · {r.config.metric === "real" ? "real" : "nominal"} ·{" "}
|
||||
{r.config.source === "ACTUAL" ? "effektiv" : "Plan"} · {(r.config.scenarioIds ?? []).length} Szenario
|
||||
{(r.config.scenarioIds ?? []).length === 1 ? "" : "s"} · {Math.round(r.pdfBytes / 1024)} KB
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`/api/plans/${planId}/reports/${r.id}`}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface hover:text-fg"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" /> PDF
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(r)}
|
||||
className="rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Offene Übergangs-Entscheide (SPEZIFIKATION 3.5.4).
|
||||
//
|
||||
// Die Zählung lag bisher lokal in `PlanView`. Sie wird jetzt auch vom Bericht gebraucht --
|
||||
// und zwei Umsetzungen derselben Regel driften garantiert auseinander. Deshalb hier als
|
||||
// reine Funktion, die beide benutzen.
|
||||
|
||||
import { isCashTransitionAnswered, isTransitionAnswered } from "@/components/ElementDetail";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
|
||||
|
||||
export const TRANSITION_CATEGORIES: ElementCategory[] = [
|
||||
"AHV",
|
||||
"PENSION_FUND",
|
||||
"PILLAR_3A",
|
||||
"REAL_ESTATE",
|
||||
"OTHER_ASSET",
|
||||
"OTHER_DEBT",
|
||||
];
|
||||
|
||||
// Wechselt der Besitzer dieses Elements an DIESER Grenze in die Pension? Entscheidet, ob
|
||||
// z. B. ein PK-Bezug überhaupt zur Wahl steht.
|
||||
export function isRetirementTransition(
|
||||
element: ElementInput,
|
||||
fromPhase: PhaseComputed,
|
||||
toPhase: PhaseComputed
|
||||
): boolean {
|
||||
if (!element.ownerRole || element.ownerRole === "HOUSEHOLD") return false;
|
||||
const before = fromPhase.persons.find((p) => p.role === element.ownerRole);
|
||||
const after = toPhase.persons.find((p) => p.role === element.ownerRole);
|
||||
return !!before?.working && !!after && !after.working;
|
||||
}
|
||||
|
||||
// Steht an dieser Grenze für dieses Element gar kein Entscheid an (bereits verkauft/getilgt,
|
||||
// AHV ausserhalb des Pensions-Übergangs, PK/3a einer bereits pensionierten Person)?
|
||||
export function transitionInactive(
|
||||
computed: PlanComputed,
|
||||
el: ElementInput,
|
||||
fromPhase: PhaseComputed,
|
||||
toPhase?: PhaseComputed
|
||||
): boolean {
|
||||
const ce = computed.phases.find((p) => p.id === fromPhase.id)?.elements.find((e) => e.elementId === el.id);
|
||||
if (ce && ce.status !== "ACTIVE") return true;
|
||||
if (el.category === "AHV") {
|
||||
return !(toPhase && isRetirementTransition(el, fromPhase, toPhase));
|
||||
}
|
||||
if (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") {
|
||||
if (el.ownerRole && el.ownerRole !== "HOUSEHOLD") {
|
||||
const owner = fromPhase.persons.find((p) => p.role === el.ownerRole);
|
||||
if (owner && !owner.working) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Offene Entscheide an EINER Phasengrenze. Der Cash-Entscheid zählt mit.
|
||||
export function openTransitionCount(
|
||||
plan: PlanInput,
|
||||
computed: PlanComputed,
|
||||
fromPhase: PhaseComputed,
|
||||
toPhase: PhaseComputed
|
||||
): number {
|
||||
const cash = plan.phases.find((p) => p.id === fromPhase.id)?.cashTransition ?? {};
|
||||
let n = isCashTransitionAnswered(cash) ? 0 : 1;
|
||||
for (const el of plan.elements) {
|
||||
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
|
||||
if (transitionInactive(computed, el, fromPhase, toPhase)) continue;
|
||||
const td = el.transitionValues[fromPhase.id] ?? {};
|
||||
if (!isTransitionAnswered(el.category, isRetirementTransition(el, fromPhase, toPhase), td)) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Summe über alle Phasengrenzen -- die aktionierbarste Kennzahl des ganzen Werkzeugs.
|
||||
export function totalOpenDecisions(plan: PlanInput, computed: PlanComputed): number {
|
||||
let n = 0;
|
||||
for (let i = 0; i < computed.phases.length - 1; i++) {
|
||||
n += openTransitionCount(plan, computed, computed.phases[i], computed.phases[i + 1]);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -127,6 +127,19 @@ describe("Datenbank-Migrationen", () => {
|
||||
);
|
||||
expect(cashCol.rows[0].is_nullable).toBe("YES");
|
||||
|
||||
// --- Berichte: die PDF-Datei selbst muss abgelegt werden (Audit-Trail). ---
|
||||
expect(tables, "Tabelle Report fehlt").toContain("Report");
|
||||
const repCols = await cols("Report");
|
||||
for (const c of ["planId", "title", "config", "model", "pdf", "pdfBytes", "createdById"]) {
|
||||
expect(repCols, `Report.${c} fehlt`).toContain(c);
|
||||
}
|
||||
const pdfCol = await db.query<{ data_type: string; is_nullable: string }>(
|
||||
`SELECT data_type, is_nullable FROM information_schema.columns
|
||||
WHERE table_name='Report' AND column_name='pdf'`
|
||||
);
|
||||
expect(pdfCol.rows[0].data_type).toBe("bytea");
|
||||
expect(pdfCol.rows[0].is_nullable).toBe("NO");
|
||||
|
||||
// --- Gespeicherte Analysen ---
|
||||
expect(tables, "Tabelle SavedAnalysis fehlt").toContain("SavedAnalysis");
|
||||
for (const c of ["planId", "name", "type", "metric", "source", "inputs", "result", "createdById"]) {
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
// Zeichnet ein ReportModel als PDF. Läuft ausschliesslich serverseitig (Node-Runtime).
|
||||
//
|
||||
// pdfkit statt Headless-Browser: kein 300-MB-Chromium im Container, echte Vektorausgabe,
|
||||
// und weil das Layout bewusst IMMER gleich ist, kostet programmatisches Setzen nichts.
|
||||
// @react-pdf/renderer schied aus -- es bricht mit React 19 / Next 16.
|
||||
//
|
||||
// Die eingebauten Schriften (Helvetica) decken WinAnsi ab und damit alle deutschen
|
||||
// Umlaute; ein Font-Embedding ist nicht nötig.
|
||||
|
||||
import PDFDocument from "pdfkit";
|
||||
import type { KeyFigure, ReportChart, ReportModel, ReportTable } from "@/lib/report";
|
||||
|
||||
const A4 = { width: 595.28, height: 841.89 };
|
||||
const M = 56; // Seitenrand
|
||||
const CONTENT = A4.width - 2 * M;
|
||||
|
||||
const COLORS = {
|
||||
text: "#1f2937",
|
||||
muted: "#6b7280",
|
||||
faint: "#9ca3af",
|
||||
accent: "#4f46e5",
|
||||
danger: "#dc2626",
|
||||
success: "#16a34a",
|
||||
line: "#e5e7eb",
|
||||
band: "#f9fafb",
|
||||
};
|
||||
|
||||
const SERIES_COLORS = ["#4f46e5", "#0ea5e9", "#16a34a"];
|
||||
|
||||
type Doc = InstanceType<typeof PDFDocument>;
|
||||
|
||||
// --- Grundbausteine ----------------------------------------------------------------------
|
||||
|
||||
function ensureSpace(doc: Doc, needed: number) {
|
||||
if (doc.y + needed > A4.height - M - 24) doc.addPage();
|
||||
}
|
||||
|
||||
function h1(doc: Doc, text: string) {
|
||||
ensureSpace(doc, 40);
|
||||
doc.fillColor(COLORS.text).font("Helvetica-Bold").fontSize(16).text(text, M, doc.y);
|
||||
doc.moveDown(0.4);
|
||||
}
|
||||
|
||||
function h2(doc: Doc, text: string) {
|
||||
ensureSpace(doc, 34);
|
||||
doc.moveDown(0.5);
|
||||
doc.fillColor(COLORS.text).font("Helvetica-Bold").fontSize(11.5).text(text, M, doc.y);
|
||||
doc
|
||||
.moveTo(M, doc.y + 2)
|
||||
.lineTo(M + CONTENT, doc.y + 2)
|
||||
.strokeColor(COLORS.line)
|
||||
.lineWidth(0.8)
|
||||
.stroke();
|
||||
doc.moveDown(0.5);
|
||||
}
|
||||
|
||||
function body(doc: Doc, text: string, opts: { color?: string; size?: number } = {}) {
|
||||
ensureSpace(doc, 26);
|
||||
doc
|
||||
.fillColor(opts.color ?? COLORS.muted)
|
||||
.font("Helvetica")
|
||||
.fontSize(opts.size ?? 9.5)
|
||||
.text(text, M, doc.y, { width: CONTENT, align: "left" });
|
||||
doc.moveDown(0.3);
|
||||
}
|
||||
|
||||
// Kennzahlen als Kästchen, drei je Reihe. Die Basis-Zeile darunter beantwortet die Frage
|
||||
// «mit welcher Annahme wurde das gerechnet» -- ohne den Annahmen-Abschnitt zu wiederholen.
|
||||
function keyFigures(doc: Doc, figures: KeyFigure[]) {
|
||||
const perRow = 3;
|
||||
const gap = 10;
|
||||
const w = (CONTENT - gap * (perRow - 1)) / perRow;
|
||||
|
||||
for (let i = 0; i < figures.length; i += perRow) {
|
||||
const row = figures.slice(i, i + perRow);
|
||||
const h = 56;
|
||||
ensureSpace(doc, h + 8);
|
||||
const top = doc.y;
|
||||
row.forEach((f, j) => {
|
||||
const x = M + j * (w + gap);
|
||||
doc.roundedRect(x, top, w, h, 4).fillColor(COLORS.band).fill();
|
||||
doc.roundedRect(x, top, w, h, 4).strokeColor(COLORS.line).lineWidth(0.8).stroke();
|
||||
doc.fillColor(COLORS.faint).font("Helvetica").fontSize(7).text(f.label.toUpperCase(), x + 8, top + 7, { width: w - 16 });
|
||||
doc
|
||||
.fillColor(f.tone === "danger" ? COLORS.danger : f.tone === "success" ? COLORS.success : COLORS.text)
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(12)
|
||||
.text(f.value, x + 8, top + 19, { width: w - 16, lineBreak: false });
|
||||
if (f.basis) {
|
||||
doc.fillColor(COLORS.faint).font("Helvetica").fontSize(6.5).text(f.basis, x + 8, top + 36, { width: w - 16, height: 16 });
|
||||
}
|
||||
});
|
||||
doc.y = top + h + 8;
|
||||
}
|
||||
}
|
||||
|
||||
function table(doc: Doc, t: ReportTable, opts: { firstColWidth?: number } = {}) {
|
||||
const cols = t.columns.length;
|
||||
const firstW = opts.firstColWidth ?? Math.max(120, CONTENT * 0.32);
|
||||
const restW = (CONTENT - firstW) / Math.max(1, cols - 1);
|
||||
const colX = (i: number) => (i === 0 ? M : M + firstW + (i - 1) * restW);
|
||||
const colW = (i: number) => (i === 0 ? firstW : restW);
|
||||
|
||||
const header = () => {
|
||||
const top = doc.y;
|
||||
doc.rect(M, top, CONTENT, 18).fillColor(COLORS.band).fill();
|
||||
t.columns.forEach((c, i) => {
|
||||
doc
|
||||
.fillColor(COLORS.muted)
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(7.5)
|
||||
.text(c, colX(i) + 4, top + 5.5, { width: colW(i) - 8, align: i === 0 ? "left" : "right", lineBreak: false });
|
||||
});
|
||||
doc.y = top + 18;
|
||||
};
|
||||
|
||||
ensureSpace(doc, 40);
|
||||
header();
|
||||
|
||||
for (const row of t.rows) {
|
||||
if (doc.y + 16 > A4.height - M - 24) {
|
||||
doc.addPage();
|
||||
header();
|
||||
}
|
||||
const top = doc.y;
|
||||
row.forEach((cell, i) => {
|
||||
doc
|
||||
.fillColor(i === 0 ? COLORS.text : COLORS.muted)
|
||||
.font(i === 0 ? "Helvetica-Bold" : "Helvetica")
|
||||
.fontSize(8)
|
||||
.text(String(cell), colX(i) + 4, top + 4, { width: colW(i) - 8, align: i === 0 ? "left" : "right", lineBreak: false });
|
||||
});
|
||||
doc
|
||||
.moveTo(M, top + 15)
|
||||
.lineTo(M + CONTENT, top + 15)
|
||||
.strokeColor(COLORS.line)
|
||||
.lineWidth(0.5)
|
||||
.stroke();
|
||||
doc.y = top + 16;
|
||||
}
|
||||
doc.moveDown(0.5);
|
||||
}
|
||||
|
||||
function definitionList(doc: Doc, rows: [string, string][]) {
|
||||
for (const [k, v] of rows) {
|
||||
ensureSpace(doc, 16);
|
||||
const top = doc.y;
|
||||
doc.fillColor(COLORS.muted).font("Helvetica").fontSize(8.5).text(k, M, top, { width: CONTENT * 0.38, lineBreak: false });
|
||||
doc
|
||||
.fillColor(COLORS.text)
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(8.5)
|
||||
.text(v, M + CONTENT * 0.38, top, { width: CONTENT * 0.62 });
|
||||
doc.y = Math.max(doc.y, top + 13);
|
||||
}
|
||||
doc.moveDown(0.4);
|
||||
}
|
||||
|
||||
// Liniendiagramm als echte Vektoren -- genau der Grund, warum die gespeicherten Analysen
|
||||
// Zahlen und keine Bilder sind (druckscharf in jeder Auflösung).
|
||||
function lineChart(doc: Doc, chart: ReportChart) {
|
||||
const h = 170;
|
||||
ensureSpace(doc, h + 40);
|
||||
const top = doc.y + 4;
|
||||
const plotX = M + 46;
|
||||
const plotW = CONTENT - 46;
|
||||
const plotH = h - 26;
|
||||
|
||||
const all = chart.series.flatMap((s) => s.points);
|
||||
if (all.length === 0) return;
|
||||
const xs = all.map((p) => p.x);
|
||||
const ys = all.map((p) => p.y);
|
||||
const xMin = Math.min(...xs);
|
||||
const xMax = Math.max(...xs);
|
||||
const yMin = Math.min(0, Math.min(...ys));
|
||||
const yMax = Math.max(...ys, 1);
|
||||
const sx = (x: number) => plotX + ((x - xMin) / Math.max(1, xMax - xMin)) * plotW;
|
||||
const sy = (y: number) => top + plotH - ((y - yMin) / Math.max(1, yMax - yMin)) * plotH;
|
||||
|
||||
// Gitter und y-Beschriftung (kompakt, in Tausend/Millionen).
|
||||
const fmt = (v: number) =>
|
||||
Math.abs(v) >= 1_000_000 ? `${(v / 1_000_000).toFixed(1)} Mio` : `${Math.round(v / 1000)}k`;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const val = yMin + ((yMax - yMin) * i) / 4;
|
||||
const y = sy(val);
|
||||
doc.moveTo(plotX, y).lineTo(plotX + plotW, y).strokeColor(COLORS.line).lineWidth(0.5).stroke();
|
||||
doc.fillColor(COLORS.faint).font("Helvetica").fontSize(6.5).text(fmt(val), M, y - 3, { width: 42, align: "right" });
|
||||
}
|
||||
|
||||
chart.series.forEach((s, i) => {
|
||||
const color = SERIES_COLORS[i % SERIES_COLORS.length];
|
||||
doc.strokeColor(color).lineWidth(1.4);
|
||||
if (s.dashed) doc.dash(3, { space: 2 });
|
||||
s.points.forEach((p, k) => (k === 0 ? doc.moveTo(sx(p.x), sy(p.y)) : doc.lineTo(sx(p.x), sy(p.y))));
|
||||
doc.stroke();
|
||||
doc.undash();
|
||||
});
|
||||
|
||||
// x-Achse
|
||||
doc.fillColor(COLORS.faint).font("Helvetica").fontSize(6.5);
|
||||
doc.text(`${chart.xLabel} ${xMin}`, plotX, top + plotH + 4, { width: 80 });
|
||||
doc.text(`${xMax}`, plotX + plotW - 40, top + plotH + 4, { width: 40, align: "right" });
|
||||
|
||||
// Legende
|
||||
let lx = plotX;
|
||||
const ly = top + plotH + 14;
|
||||
chart.series.forEach((s, i) => {
|
||||
const color = SERIES_COLORS[i % SERIES_COLORS.length];
|
||||
doc.rect(lx, ly + 2, 8, 2).fillColor(color).fill();
|
||||
doc.fillColor(COLORS.muted).font("Helvetica").fontSize(7).text(s.label, lx + 12, ly, { width: 140, lineBreak: false });
|
||||
lx += 12 + Math.min(140, doc.widthOfString(s.label)) + 14;
|
||||
});
|
||||
|
||||
doc.y = ly + 16;
|
||||
}
|
||||
|
||||
// --- Bericht -----------------------------------------------------------------------------
|
||||
|
||||
export function renderReportPdf(model: ReportModel): Promise<Buffer> {
|
||||
const doc = new PDFDocument({
|
||||
size: "A4",
|
||||
margins: { top: M, bottom: M, left: M, right: M },
|
||||
// Nötig für die Fusszeile: Seitenzahlen lassen sich erst nachträglich setzen, wenn die
|
||||
// Gesamtzahl feststeht -- dafür müssen die Seiten gepuffert bleiben.
|
||||
bufferPages: true,
|
||||
info: { Title: model.meta.title, Author: "FPT", Subject: `Finanzplanung ${model.meta.planName}` },
|
||||
});
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
const done = new Promise<Buffer>((resolve, reject) => {
|
||||
doc.on("data", (c: Buffer) => chunks.push(c));
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
doc.on("error", reject);
|
||||
});
|
||||
|
||||
const created = new Date(model.meta.createdAt).toLocaleDateString("de-CH", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
// --- Seite 1: Deckblatt + Zusammenfassung ---
|
||||
doc.fillColor(COLORS.accent).font("Helvetica-Bold").fontSize(9).text("FINANZPLANUNG", M, M);
|
||||
doc.fillColor(COLORS.text).font("Helvetica-Bold").fontSize(22).text(model.meta.title, M, doc.y + 4, { width: CONTENT });
|
||||
doc.fillColor(COLORS.muted).font("Helvetica").fontSize(10).text(model.meta.planName, M, doc.y + 2);
|
||||
doc
|
||||
.fillColor(COLORS.faint)
|
||||
.fontSize(8.5)
|
||||
.text(
|
||||
`Erstellt am ${created} von ${model.meta.author} · Werte ${model.meta.metricLabel} · Grundlage: ${model.meta.sourceLabel}`,
|
||||
M,
|
||||
doc.y + 4,
|
||||
{ width: CONTENT }
|
||||
);
|
||||
if (model.meta.comment) {
|
||||
doc.fillColor(COLORS.muted).font("Helvetica-Oblique").fontSize(9).text(model.meta.comment, M, doc.y + 6, { width: CONTENT });
|
||||
}
|
||||
doc.moveDown(1);
|
||||
|
||||
h2(doc, "Das Wichtigste in Kürze");
|
||||
keyFigures(doc, model.summary.figures);
|
||||
for (const s of model.summary.statements) {
|
||||
ensureSpace(doc, 22);
|
||||
doc.fillColor(COLORS.accent).font("Helvetica-Bold").fontSize(9).text("•", M, doc.y, { width: 10, lineBreak: false });
|
||||
doc.fillColor(COLORS.text).font("Helvetica").fontSize(9).text(s, M + 12, doc.y, { width: CONTENT - 12 });
|
||||
doc.moveDown(0.25);
|
||||
}
|
||||
|
||||
// --- Ausgangslage ---
|
||||
h2(doc, "Ausgangslage");
|
||||
definitionList(doc, model.household.rows);
|
||||
|
||||
// --- Je Szenario ---
|
||||
for (const s of model.scenarios) {
|
||||
doc.addPage();
|
||||
h1(doc, `Szenario «${s.name}»${s.isBase ? " (Basis)" : ""}`);
|
||||
keyFigures(doc, s.keyFigures);
|
||||
|
||||
h2(doc, s.chart.title);
|
||||
lineChart(doc, s.chart);
|
||||
|
||||
h2(doc, "Lebensphasen");
|
||||
table(doc, s.phases);
|
||||
|
||||
h2(doc, "Annahmen, mit denen gerechnet wurde");
|
||||
body(
|
||||
doc,
|
||||
"Alle Kennzahlen oben beruhen auf diesen Werten. Ändert sich eine Annahme, ändert sich das Ergebnis."
|
||||
);
|
||||
for (const g of s.assumptions) {
|
||||
ensureSpace(doc, 24);
|
||||
doc.fillColor(COLORS.text).font("Helvetica-Bold").fontSize(9).text(g.title, M, doc.y);
|
||||
doc.moveDown(0.2);
|
||||
definitionList(doc, g.rows);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Vergleich ---
|
||||
if (model.comparison) {
|
||||
doc.addPage();
|
||||
h1(doc, "Szenario-Vergleich");
|
||||
body(doc, "Dieselben Kennzahlen über alle gewählten Szenarien -- unter identischen Annahmen gerechnet.");
|
||||
table(doc, model.comparison);
|
||||
}
|
||||
|
||||
// --- Plan/Ist ---
|
||||
if (model.actuals) {
|
||||
h2(doc, "Plan gegenüber effektiven Werten");
|
||||
definitionList(doc, model.actuals.rows);
|
||||
body(doc, model.actuals.note, { size: 8 });
|
||||
}
|
||||
|
||||
// --- Gespeicherte Analysen ---
|
||||
if (model.analyses.length > 0) {
|
||||
doc.addPage();
|
||||
h1(doc, "Analysen");
|
||||
body(doc, "Festgehaltene Auswertungen zum Zeitpunkt ihrer Erstellung. Es wurde nichts neu gerechnet.");
|
||||
for (const a of model.analyses) {
|
||||
h2(doc, a.name);
|
||||
if (a.params.length > 0) definitionList(doc, a.params);
|
||||
if (a.table) table(doc, a.table);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Anhang ---
|
||||
doc.addPage();
|
||||
h1(doc, "Wichtige Hinweise");
|
||||
for (const d of model.disclaimer) {
|
||||
ensureSpace(doc, 30);
|
||||
doc.fillColor(COLORS.text).font("Helvetica").fontSize(9).text(d, M, doc.y, { width: CONTENT });
|
||||
doc.moveDown(0.5);
|
||||
}
|
||||
|
||||
// Fusszeile mit Seitenzahlen auf allen Seiten.
|
||||
const range = doc.bufferedPageRange();
|
||||
for (let i = range.start; i < range.start + range.count; i++) {
|
||||
doc.switchToPage(i);
|
||||
doc
|
||||
.fillColor(COLORS.faint)
|
||||
.font("Helvetica")
|
||||
.fontSize(7)
|
||||
.text(
|
||||
`${model.meta.planName} · ${model.meta.title} · ${created}`,
|
||||
M,
|
||||
A4.height - M + 12,
|
||||
{ width: CONTENT - 40, lineBreak: false }
|
||||
);
|
||||
doc.text(`${i - range.start + 1} / ${range.count}`, M + CONTENT - 40, A4.height - M + 12, {
|
||||
width: 40,
|
||||
align: "right",
|
||||
lineBreak: false,
|
||||
});
|
||||
}
|
||||
|
||||
doc.end();
|
||||
return done;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildReport, MAX_REPORT_SCENARIOS } from "@/lib/report";
|
||||
import { renderReportPdf } from "@/lib/report-pdf";
|
||||
import type { ActualsSetInput } from "@/lib/actuals";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Paar, 45/43, 20 Jahre Erwerb + 20 Jahre Pension, PK + ETF-Depot.
|
||||
function plan(name: string, ret = 4): PlanInput {
|
||||
return {
|
||||
id: "s1",
|
||||
name,
|
||||
householdType: "COUPLE",
|
||||
inflationRateDefault: 1.5,
|
||||
initialCash: 20000,
|
||||
startYear: 2026,
|
||||
persons: [
|
||||
{ id: "A", role: "PERSON_A", name: "Anna", age: 45, retirementAge: 65 },
|
||||
{ id: "B", role: "PERSON_B", name: "Beat", age: 43, retirementAge: 64 },
|
||||
],
|
||||
phases: [
|
||||
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} },
|
||||
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 20, cashTransition: {} },
|
||||
],
|
||||
elements: [
|
||||
{
|
||||
id: "inc", category: "INCOME", name: "Lohn", ownerRole: "PERSON_A", orderIndex: 1,
|
||||
phaseValues: { p1: { amount: 140000, teuerungsausgleich: 1 } }, transitionValues: {}, sourceElementId: null,
|
||||
},
|
||||
{
|
||||
id: "exp", category: "EXPENSE", name: "Lebenshaltung", ownerRole: "HOUSEHOLD", orderIndex: 2,
|
||||
phaseValues: { p1: { amount: 90000 }, p2: { amount: 80000 } }, transitionValues: {}, sourceElementId: null,
|
||||
},
|
||||
{
|
||||
id: "pk", category: "PENSION_FUND", name: "Pensionskasse", ownerRole: "PERSON_A", orderIndex: 3,
|
||||
phaseValues: { p1: { currentValue: 320000, expectedReturn: 2, annualContribution: 12000 } },
|
||||
transitionValues: {}, sourceElementId: null,
|
||||
},
|
||||
{
|
||||
id: "etf", category: "OTHER_ASSET", name: "ETF-Depot", ownerRole: "HOUSEHOLD", orderIndex: 4,
|
||||
phaseValues: {
|
||||
p1: { startValue: 180000, expectedReturn: ret, annualContribution: 9000 },
|
||||
p2: { expectedReturn: ret, annualWithdrawal: 24000 },
|
||||
},
|
||||
transitionValues: {}, sourceElementId: null,
|
||||
},
|
||||
],
|
||||
} as unknown as PlanInput;
|
||||
}
|
||||
|
||||
function input(over: Partial<Parameters<typeof buildReport>[0]> = {}) {
|
||||
return {
|
||||
planName: "Meine Planung",
|
||||
author: "kelle",
|
||||
createdAt: new Date("2026-07-21T10:00:00Z"),
|
||||
config: {
|
||||
title: "Finanzplanung 2026",
|
||||
metric: "nominal" as const,
|
||||
source: "PLAN" as const,
|
||||
scenarioIds: ["s1"],
|
||||
analysisIds: [] as string[],
|
||||
comment: null,
|
||||
},
|
||||
scenarios: [{ name: "Basisszenario", isBase: true, plan: plan("Basisszenario") }],
|
||||
household: {
|
||||
householdType: "COUPLE" as const,
|
||||
startYear: 2026,
|
||||
persons: [
|
||||
{ name: "Anna", age: 45, role: "PERSON_A" },
|
||||
{ name: "Beat", age: 43, role: "PERSON_B" },
|
||||
],
|
||||
},
|
||||
actuals: [] as ActualsSetInput[],
|
||||
origins: [] as { id: string; sourceElementId?: string | null }[],
|
||||
analyses: [] as { name: string; type: string; result: unknown }[],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildReport", () => {
|
||||
it("stellt die Zusammenfassung aus dem Basisszenario zusammen", () => {
|
||||
const m = buildReport(input());
|
||||
expect(m.summary.figures.length).toBeGreaterThan(3);
|
||||
// Die drei Kernaussagen: Endvermögen, Reichweite, offene Entscheide.
|
||||
expect(m.summary.statements).toHaveLength(3);
|
||||
expect(m.summary.statements[0]).toContain("Endvermögen");
|
||||
});
|
||||
|
||||
it("nennt zu JEDER Kennzahl die zugrunde liegende Annahme", () => {
|
||||
// Der Kern der Anforderung: kein Ergebnis ohne seine Grundlage.
|
||||
const m = buildReport(input());
|
||||
for (const f of m.scenarios[0].keyFigures) {
|
||||
expect(f.basis, `Kennzahl «${f.label}» ohne Basis-Angabe`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("führt die Annahmen zentral je Szenario -- mit den echten Startwerten", () => {
|
||||
const m = buildReport(input());
|
||||
const flat = m.scenarios[0].assumptions.flatMap((g) => g.rows.map(([k, v]) => `${k}: ${v}`)).join(" | ");
|
||||
expect(flat).toContain("Inflation");
|
||||
expect(flat).toContain("Pensionsalter Anna");
|
||||
// Startwert und Rendite des Depots müssen ablesbar sein.
|
||||
expect(flat).toMatch(/ETF-Depot.*Start.*Rendite 4 %/);
|
||||
});
|
||||
|
||||
it("deckelt die Szenarien und vergleicht erst ab zwei", () => {
|
||||
const one = buildReport(input());
|
||||
expect(one.comparison).toBeUndefined();
|
||||
|
||||
const many = buildReport(
|
||||
input({
|
||||
scenarios: [
|
||||
{ name: "Basis", isBase: true, plan: plan("Basis") },
|
||||
{ name: "B", isBase: false, plan: plan("B", 3) },
|
||||
{ name: "C", isBase: false, plan: plan("C", 2) },
|
||||
{ name: "D", isBase: false, plan: plan("D", 1) },
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(many.scenarios).toHaveLength(MAX_REPORT_SCENARIOS);
|
||||
expect(many.comparison!.rows).toHaveLength(MAX_REPORT_SCENARIOS);
|
||||
});
|
||||
|
||||
it("zeigt den Plan/Ist-Block nur, wenn effektive Werte gewählt UND vorhanden sind", () => {
|
||||
const sets: ActualsSetInput[] = [
|
||||
{ id: "a1", recordedOn: "2030-08-18", year: 2030, cash: null, values: { etf: { value: 400000 } } },
|
||||
];
|
||||
const p = plan("Basisszenario");
|
||||
const origins = p.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId ?? null }));
|
||||
|
||||
// Gewählt, aber keine Sätze -> kein Block.
|
||||
expect(buildReport(input({ config: { ...input().config, source: "ACTUAL" } })).actuals).toBeUndefined();
|
||||
|
||||
// Sätze vorhanden, aber Plandaten gewählt -> kein Block.
|
||||
expect(buildReport(input({ actuals: sets, origins })).actuals).toBeUndefined();
|
||||
|
||||
// Beides -> Block mit Abweichung.
|
||||
const m = buildReport(input({ config: { ...input().config, source: "ACTUAL" }, actuals: sets, origins }));
|
||||
expect(m.actuals).toBeDefined();
|
||||
expect(m.actuals!.rows.map(([k]) => k)).toContain("Abweichung");
|
||||
});
|
||||
|
||||
it("rechnet real, wenn real gewählt ist", () => {
|
||||
const nominal = buildReport(input());
|
||||
const real = buildReport(input({ config: { ...input().config, metric: "real" } }));
|
||||
const val = (m: ReturnType<typeof buildReport>) =>
|
||||
m.scenarios[0].keyFigures.find((f) => f.label.startsWith("Endvermögen"))!.value;
|
||||
// Bei positiver Inflation liegt der Realwert unter dem nominalen.
|
||||
expect(val(real)).not.toBe(val(nominal));
|
||||
expect(real.meta.metricLabel).toContain("real");
|
||||
});
|
||||
|
||||
it("trägt immer einen Haftungsausschluss", () => {
|
||||
// Ein formal aussehendes PDF wird als Beratung gelesen -- das muss dagegenstehen.
|
||||
const m = buildReport(input());
|
||||
expect(m.disclaimer.length).toBeGreaterThan(2);
|
||||
expect(m.disclaimer.join(" ")).toContain("keine Anlage-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderReportPdf", () => {
|
||||
it("erzeugt eine gültige, vollständige PDF-Datei", async () => {
|
||||
const m = buildReport(
|
||||
input({
|
||||
scenarios: [
|
||||
{ name: "Basisszenario", isBase: true, plan: plan("Basisszenario") },
|
||||
{ name: "Tiefere Rendite", isBase: false, plan: plan("Tiefere Rendite", 2) },
|
||||
],
|
||||
analyses: [
|
||||
{
|
||||
name: "Monte-Carlo Basis",
|
||||
type: "MONTE_CARLO",
|
||||
result: {
|
||||
params: [{ label: "Zielbetrag", value: "3'000'000" }],
|
||||
table: { columns: ["Szenario", "Realismus"], rows: [["Basis", "63 %"]] },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const pdf = await renderReportPdf(m);
|
||||
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
|
||||
// Ohne sauberes Dateiende lässt sich das PDF nicht öffnen.
|
||||
expect(pdf.subarray(-1024).toString("latin1")).toContain("%%EOF");
|
||||
expect(pdf.length).toBeGreaterThan(5000);
|
||||
}, 30000);
|
||||
|
||||
it("kommt auch mit einem leeren Plan zurecht, statt zu werfen", async () => {
|
||||
// Robustheit: Ein Plan ohne Phasen darf keinen Absturz erzeugen.
|
||||
const leer = { ...plan("Leer"), phases: [], elements: [] } as unknown as PlanInput;
|
||||
const m = buildReport(input({ scenarios: [{ name: "Leer", isBase: true, plan: leer }] }));
|
||||
const pdf = await renderReportPdf(m);
|
||||
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
// PDF-Bericht (Roadmap Nr. 11) -- das MODELL, ohne PDF-Kenntnisse.
|
||||
//
|
||||
// Diese Datei baut aus Plan, Berechnung und Konfiguration eine vollständig aufgelöste,
|
||||
// druckfertige Struktur aus Zahlen und Texten. Das Zeichnen macht `report-pdf.ts`.
|
||||
// Die Trennung hat zwei Gründe: Das Modell ist ohne PDF-Bibliothek testbar, und es ist
|
||||
// zugleich das, was eingefroren wird -- der Bericht ändert sich nicht mehr, wenn der Plan
|
||||
// später weiterentwickelt wird.
|
||||
//
|
||||
// Grundsätze aus der Recherche (siehe SPEZIFIKATION 3.11):
|
||||
// * Zusammenfassung zuerst, Details danach -- Überkomplexität ist die häufigste Kritik
|
||||
// an Beraterberichten.
|
||||
// * Annahmen EINMAL zentral, nicht bei jeder Kennzahl wiederholt; die Kennzahlen
|
||||
// verweisen darauf.
|
||||
// * Transparenz zählt so viel wie das Ergebnis: Systemparameter und Methodik gehören
|
||||
// in den Anhang, ein Haftungsausschluss ist Pflicht.
|
||||
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { totalOpenDecisions } from "@/lib/decisions";
|
||||
import { num } from "@/lib/elements";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { resolveActuals, type ActualsSetInput, type ElementOrigin } from "@/lib/actuals";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
export type ReportMetric = "nominal" | "real";
|
||||
export type ReportSource = "PLAN" | "ACTUAL";
|
||||
|
||||
// Höchstens drei Szenarien je Bericht: Darüber wird die Vergleichstabelle unlesbar, und
|
||||
// vergleichbare Werkzeuge stellen bewusst nur zwei gegenüber.
|
||||
export const MAX_REPORT_SCENARIOS = 3;
|
||||
|
||||
export interface ReportConfig {
|
||||
title: string;
|
||||
metric: ReportMetric;
|
||||
source: ReportSource;
|
||||
scenarioIds: string[];
|
||||
analysisIds: string[];
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export interface KeyFigure {
|
||||
label: string;
|
||||
value: string;
|
||||
// Kurzer Hinweis auf die zugrunde liegende Annahme -- der Verweis auf den zentralen
|
||||
// Annahmen-Abschnitt, ohne ihn zu wiederholen.
|
||||
basis?: string;
|
||||
tone?: "danger" | "success";
|
||||
}
|
||||
|
||||
export interface ReportTable {
|
||||
columns: string[];
|
||||
rows: (string | number)[][];
|
||||
}
|
||||
|
||||
export interface ReportChart {
|
||||
title: string;
|
||||
series: { label: string; dashed?: boolean; points: { x: number; y: number }[] }[];
|
||||
xLabel: string;
|
||||
}
|
||||
|
||||
export interface ReportScenario {
|
||||
name: string;
|
||||
isBase: boolean;
|
||||
keyFigures: KeyFigure[];
|
||||
phases: ReportTable;
|
||||
chart: ReportChart;
|
||||
assumptions: { title: string; rows: [string, string][] }[];
|
||||
openDecisions: number;
|
||||
}
|
||||
|
||||
export interface ReportModel {
|
||||
meta: {
|
||||
planName: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
author: string;
|
||||
metricLabel: string;
|
||||
sourceLabel: string;
|
||||
comment?: string | null;
|
||||
};
|
||||
household: { rows: [string, string][] };
|
||||
summary: { figures: KeyFigure[]; statements: string[] };
|
||||
scenarios: ReportScenario[];
|
||||
comparison?: ReportTable;
|
||||
actuals?: { rows: [string, string][]; note: string };
|
||||
analyses: { name: string; type: string; params: [string, string][]; table?: ReportTable }[];
|
||||
disclaimer: string[];
|
||||
}
|
||||
|
||||
const DISCLAIMER = [
|
||||
"Dieser Bericht ist eine rechnerische PROJEKTION auf Basis der von dir erfassten Annahmen. Er ist keine Anlage-, Steuer- oder Vorsorgeberatung und ersetzt keine Fachberatung.",
|
||||
"Alle Zukunftswerte beruhen auf Annahmen zu Renditen, Inflation, Lohnentwicklung und Lebensdauer. Treffen diese nicht ein, weicht das tatsächliche Ergebnis ab – möglicherweise erheblich.",
|
||||
"Die Berechnung vereinfacht bewusst: Steuern werden nur dort berücksichtigt, wo ausgewiesen (Kapitalbezugs- und Grundstückgewinnsteuer). Eine laufende Einkommens- und Vermögenssteuer ist NICHT modelliert.",
|
||||
"Wahrscheinlichkeiten aus der Monte-Carlo-Simulation messen die Streuung UM die getroffenen Annahmen – nicht, ob die Annahmen selbst zutreffen.",
|
||||
];
|
||||
|
||||
// --- Hilfsgrössen -----------------------------------------------------------------------
|
||||
|
||||
function pick(computed: PlanComputed, metric: ReportMetric, phaseIndex: number): number {
|
||||
const p = computed.phases[phaseIndex];
|
||||
if (!p) return 0;
|
||||
return Math.round(metric === "real" ? p.endWealthReal : p.endWealthNominal);
|
||||
}
|
||||
|
||||
// Vermögen im Jahr der Pensionierung von Person A -- ein Meilenstein, den man in fast jedem
|
||||
// Beratungsbericht findet.
|
||||
function wealthAtRetirement(plan: PlanInput, computed: PlanComputed, metric: ReportMetric): number | null {
|
||||
const a = plan.persons.find((p) => p.role === "PERSON_A") ?? plan.persons[0];
|
||||
if (!a) return null;
|
||||
const point = computed.yearly.find((y) => y.age >= a.retirementAge);
|
||||
if (!point) return null;
|
||||
return Math.round(metric === "real" ? point.wealthReal : point.wealthNominal);
|
||||
}
|
||||
|
||||
// Laufende Jahresrente (AHV bzw. verrentete PK) in der letzten Phase.
|
||||
function annualPensionOf(computed: PlanComputed, category: "AHV" | "PENSION_FUND"): number {
|
||||
const last = computed.phases[computed.phases.length - 1];
|
||||
if (!last) return 0;
|
||||
return Math.round(
|
||||
last.elements.filter((e) => e.category === category).reduce((s, e) => s + Math.max(0, e.startValue), 0)
|
||||
);
|
||||
}
|
||||
|
||||
// Das grösste Vorsorgekapital zum Pensionierungszeitpunkt (PK + 3a), nominal.
|
||||
function capitalAtRetirement(plan: PlanInput, computed: PlanComputed): number {
|
||||
const a = plan.persons.find((p) => p.role === "PERSON_A") ?? plan.persons[0];
|
||||
if (!a) return 0;
|
||||
const year = computed.yearly.find((y) => y.age >= a.retirementAge)?.year;
|
||||
if (!year) return 0;
|
||||
let total = 0;
|
||||
for (const ph of computed.phases) {
|
||||
for (const el of ph.elements) {
|
||||
if (el.category !== "PENSION_FUND" && el.category !== "PILLAR_3A") continue;
|
||||
const pt = el.yearly.find((y) => y.year === year);
|
||||
if (pt) total += Math.max(0, pt.value);
|
||||
}
|
||||
}
|
||||
return Math.round(total);
|
||||
}
|
||||
|
||||
// --- Annahmen ----------------------------------------------------------------------------
|
||||
// Zentral je Szenario, damit die Kennzahlen nur noch darauf verweisen müssen.
|
||||
|
||||
function assumptionsOf(plan: PlanInput): { title: string; rows: [string, string][] }[] {
|
||||
const out: { title: string; rows: [string, string][] }[] = [];
|
||||
const firstPhaseId = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber)[0]?.id;
|
||||
|
||||
out.push({
|
||||
title: "Plan-weit",
|
||||
rows: [
|
||||
["Inflation", `${plan.inflationRateDefault} % pro Jahr`],
|
||||
["Cash zu Planbeginn", formatChf(Math.round(plan.initialCash || 0))],
|
||||
...plan.persons.map(
|
||||
(p) =>
|
||||
[
|
||||
`Pensionsalter ${p.name || (p.role === "PERSON_A" ? "Person A" : "Person B")}`,
|
||||
`${p.retirementAge} Jahre`,
|
||||
] as [string, string]
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
// Startwerte und Raten je Element -- genau die Zahlen, mit denen gerechnet wurde.
|
||||
const rows: [string, string][] = [];
|
||||
for (const el of [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex)) {
|
||||
const pd = firstPhaseId ? el.phaseValues[firstPhaseId] ?? {} : {};
|
||||
const parts: string[] = [];
|
||||
if (typeof pd.amount === "number") parts.push(`${formatChf(Math.round(pd.amount))}/Jahr`);
|
||||
if (typeof pd.startValue === "number") parts.push(`Start ${formatChf(Math.round(pd.startValue))}`);
|
||||
if (typeof pd.currentValue === "number") parts.push(`Start ${formatChf(Math.round(pd.currentValue))}`);
|
||||
if (typeof pd.purchasePrice === "number") parts.push(`Kaufpreis ${formatChf(Math.round(pd.purchasePrice))}`);
|
||||
if (typeof pd.mortgage === "number") parts.push(`Hypothek ${formatChf(Math.round(pd.mortgage))}`);
|
||||
if (pd.expectedReturn != null) parts.push(`Rendite ${num(pd.expectedReturn)} %`);
|
||||
if (pd.valueGrowth != null) parts.push(`Wertsteigerung ${num(pd.valueGrowth)} %`);
|
||||
if (pd.interestRate != null) parts.push(`Zins ${num(pd.interestRate)} %`);
|
||||
if (pd.teuerungsausgleich != null) parts.push(`Anpassung ${num(pd.teuerungsausgleich)} %/Jahr`);
|
||||
if (pd.annualContribution != null && num(pd.annualContribution) !== 0)
|
||||
parts.push(`Sparrate ${formatChf(Math.round(num(pd.annualContribution)))}`);
|
||||
if (pd.annualWithdrawal != null && num(pd.annualWithdrawal) !== 0)
|
||||
parts.push(`Bezug ${formatChf(Math.round(num(pd.annualWithdrawal)))}`);
|
||||
if (parts.length > 0) rows.push([el.name, parts.join(" · ")]);
|
||||
}
|
||||
if (rows.length > 0) out.push({ title: "Elemente (Werte der ersten Lebensphase)", rows });
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Szenario ----------------------------------------------------------------------------
|
||||
|
||||
function buildScenario(
|
||||
plan: PlanInput,
|
||||
computed: PlanComputed,
|
||||
name: string,
|
||||
isBase: boolean,
|
||||
metric: ReportMetric
|
||||
): ReportScenario {
|
||||
const lastIndex = computed.phases.length - 1;
|
||||
const end = pick(computed, metric, lastIndex);
|
||||
const atRet = wealthAtRetirement(plan, computed, metric);
|
||||
const ahv = annualPensionOf(computed, "AHV");
|
||||
const pkPension = annualPensionOf(computed, "PENSION_FUND");
|
||||
const capital = capitalAtRetirement(plan, computed);
|
||||
const open = totalOpenDecisions(plan, computed);
|
||||
|
||||
const figures: KeyFigure[] = [
|
||||
{
|
||||
label: `Endvermögen (${metric === "real" ? "real" : "nominal"})`,
|
||||
value: formatChf(end),
|
||||
basis: `Renditen und Inflation ${plan.inflationRateDefault} % laut Annahmen`,
|
||||
},
|
||||
{
|
||||
label: "Kapital reicht",
|
||||
value: computed.ruinAge === null ? "bis Planende" : `bis Alter ${computed.ruinAge}`,
|
||||
tone: computed.ruinAge === null ? "success" : "danger",
|
||||
basis: "Gesamtvermögen inkl. Cash, abzüglich Schulden",
|
||||
},
|
||||
];
|
||||
if (atRet !== null) {
|
||||
figures.push({
|
||||
label: "Vermögen bei Pensionierung",
|
||||
value: formatChf(atRet),
|
||||
basis: "Stand im Jahr, in dem Person A das Pensionsalter erreicht",
|
||||
});
|
||||
}
|
||||
if (capital > 0) {
|
||||
figures.push({
|
||||
label: "Vorsorgekapital bei Pensionierung",
|
||||
value: formatChf(capital),
|
||||
basis: "PK und Säule 3a, vor Bezug/Verrentung",
|
||||
});
|
||||
}
|
||||
if (ahv > 0) {
|
||||
figures.push({
|
||||
label: "AHV-Rente pro Jahr",
|
||||
value: formatChf(ahv),
|
||||
basis: "amtliche Rentenformel (Skala 44) aus der Beitragskarriere",
|
||||
});
|
||||
}
|
||||
if (pkPension > 0) {
|
||||
figures.push({ label: "PK-Rente pro Jahr", value: formatChf(pkPension), basis: "Umwandlungssatz laut Systemparametern" });
|
||||
}
|
||||
figures.push({
|
||||
label: "Offene Entscheide",
|
||||
value: open === 0 ? "keine" : String(open),
|
||||
tone: open === 0 ? "success" : undefined,
|
||||
basis: "noch nicht getroffene Übergangs-Entscheide zwischen den Lebensphasen",
|
||||
});
|
||||
|
||||
const phases: ReportTable = {
|
||||
columns: ["Lebensphase", "Dauer", "Einkommen", "Ausgaben", "Vermögen am Ende"],
|
||||
rows: computed.phases.map((p) => [
|
||||
p.name,
|
||||
`${p.durationYears} J.`,
|
||||
formatChf(Math.round(p.incomeStart)),
|
||||
formatChf(Math.round(p.expenseStart)),
|
||||
formatChf(Math.round(metric === "real" ? p.endWealthReal : p.endWealthNominal)),
|
||||
]),
|
||||
};
|
||||
|
||||
return {
|
||||
name,
|
||||
isBase,
|
||||
keyFigures: figures,
|
||||
phases,
|
||||
chart: {
|
||||
title: `Vermögensverlauf (${metric === "real" ? "real" : "nominal"})`,
|
||||
xLabel: "Alter",
|
||||
series: [
|
||||
{
|
||||
label: name,
|
||||
points: computed.yearly.map((y) => ({ x: y.age, y: metric === "real" ? y.wealthReal : y.wealthNominal })),
|
||||
},
|
||||
],
|
||||
},
|
||||
assumptions: assumptionsOf(plan),
|
||||
openDecisions: open,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Gesamtmodell ------------------------------------------------------------------------
|
||||
|
||||
export interface ReportScenarioInput {
|
||||
name: string;
|
||||
isBase: boolean;
|
||||
plan: PlanInput;
|
||||
}
|
||||
|
||||
export interface BuildReportInput {
|
||||
planName: string;
|
||||
author: string;
|
||||
createdAt: Date;
|
||||
config: ReportConfig;
|
||||
scenarios: ReportScenarioInput[];
|
||||
household: { householdType: "SINGLE" | "COUPLE"; startYear: number | null; persons: { name: string | null; age: number; role: string }[] };
|
||||
actuals: ActualsSetInput[];
|
||||
origins: ElementOrigin[];
|
||||
analyses: { name: string; type: string; result: unknown }[];
|
||||
}
|
||||
|
||||
export function buildReport(input: BuildReportInput): ReportModel {
|
||||
const { config } = input;
|
||||
const chosen = input.scenarios.slice(0, MAX_REPORT_SCENARIOS);
|
||||
|
||||
// Je Szenario die massgebende Rechnung: mit oder ohne effektive Werte.
|
||||
const computedByScenario = chosen.map((s) => {
|
||||
const resolved = config.source === "ACTUAL" ? resolveActuals(input.actuals, s.plan, input.origins) : [];
|
||||
return {
|
||||
...s,
|
||||
computed: computePlan(s.plan, undefined, resolved.length > 0 ? { actuals: resolved } : undefined),
|
||||
planOnly: computePlan(s.plan),
|
||||
usedActuals: resolved.length,
|
||||
};
|
||||
});
|
||||
|
||||
const scenarios = computedByScenario.map((s) => buildScenario(s.plan, s.computed, s.name, s.isBase, config.metric));
|
||||
|
||||
// Leitszenario für die Zusammenfassung: das Basisszenario, sonst das erste.
|
||||
const lead = computedByScenario.find((s) => s.isBase) ?? computedByScenario[0];
|
||||
const leadScenario = scenarios.find((s) => s.isBase) ?? scenarios[0];
|
||||
|
||||
const statements: string[] = [];
|
||||
if (lead) {
|
||||
const end = pick(lead.computed, config.metric, lead.computed.phases.length - 1);
|
||||
statements.push(
|
||||
`Nach ${lead.computed.phases.reduce((s, p) => s + p.durationYears, 0)} Planjahren ergibt sich im Szenario «${lead.name}» ein Endvermögen von ${formatChf(end)} (${config.metric === "real" ? "real, heutige Kaufkraft" : "nominal"}).`
|
||||
);
|
||||
statements.push(
|
||||
lead.computed.ruinAge === null
|
||||
? "Das Kapital reicht über den gesamten Planungszeitraum."
|
||||
: `Achtung: Das Gesamtvermögen fällt im Alter ${lead.computed.ruinAge} unter null – die Planung trägt nicht bis ans Ende.`
|
||||
);
|
||||
const open = leadScenario?.openDecisions ?? 0;
|
||||
statements.push(
|
||||
open === 0
|
||||
? "Alle Übergangs-Entscheide zwischen den Lebensphasen sind getroffen."
|
||||
: `${open} Übergangs-Entscheid${open === 1 ? " ist" : "e sind"} noch offen – bis dahin rechnet das Tool mit Vorgabewerten.`
|
||||
);
|
||||
}
|
||||
|
||||
// Vergleichstabelle nur, wenn es überhaupt etwas zu vergleichen gibt.
|
||||
const comparison: ReportTable | undefined =
|
||||
chosen.length > 1
|
||||
? {
|
||||
columns: ["Szenario", `Endvermögen (${config.metric === "real" ? "real" : "nominal"})`, "Kapital reicht", "Offene Entscheide"],
|
||||
rows: computedByScenario.map((s, i) => [
|
||||
s.name + (s.isBase ? " (Basis)" : ""),
|
||||
formatChf(pick(s.computed, config.metric, s.computed.phases.length - 1)),
|
||||
s.computed.ruinAge === null ? "bis Planende" : `bis Alter ${s.computed.ruinAge}`,
|
||||
scenarios[i].openDecisions,
|
||||
]),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Plan/Ist nur, wenn effektive Werte gewählt UND vorhanden sind.
|
||||
let actualsBlock: ReportModel["actuals"];
|
||||
if (config.source === "ACTUAL" && lead && lead.usedActuals > 0) {
|
||||
const withActuals = pick(lead.computed, config.metric, lead.computed.phases.length - 1);
|
||||
const planOnly = pick(lead.planOnly, config.metric, lead.planOnly.phases.length - 1);
|
||||
const years = resolveActuals(input.actuals, lead.plan, input.origins).map((r) => r.year);
|
||||
actualsBlock = {
|
||||
rows: [
|
||||
["Erfasste Stichtage", years.join(", ")],
|
||||
["Endvermögen laut Plan", formatChf(planOnly)],
|
||||
["Endvermögen mit effektiven Werten", formatChf(withActuals)],
|
||||
["Abweichung", `${withActuals - planOnly >= 0 ? "+" : "−"}${formatChf(Math.abs(withActuals - planOnly))}`],
|
||||
],
|
||||
note: "Die Berechnung springt in jedem erfassten Jahr auf die tatsächlichen Werte und läuft von dort mit den Planannahmen weiter. Elemente ohne erfassten Wert bleiben auf ihrer Planlinie.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
meta: {
|
||||
planName: input.planName,
|
||||
title: config.title,
|
||||
createdAt: input.createdAt.toISOString(),
|
||||
author: input.author,
|
||||
metricLabel: config.metric === "real" ? "real (heutige Kaufkraft)" : "nominal",
|
||||
sourceLabel: config.source === "ACTUAL" ? "Plandaten mit effektiven Werten" : "Plandaten",
|
||||
comment: config.comment ?? null,
|
||||
},
|
||||
household: {
|
||||
rows: [
|
||||
["Haushaltsform", input.household.householdType === "COUPLE" ? "Paar" : "Einzelperson"],
|
||||
...input.household.persons.map(
|
||||
(p) => [p.name || (p.role === "PERSON_A" ? "Person A" : "Person B"), `${p.age} Jahre`] as [string, string]
|
||||
),
|
||||
["Planstart", input.household.startYear ? String(input.household.startYear) : "nicht gesetzt"],
|
||||
],
|
||||
},
|
||||
summary: { figures: leadScenario ? leadScenario.keyFigures : [], statements },
|
||||
scenarios,
|
||||
comparison,
|
||||
actuals: actualsBlock,
|
||||
analyses: input.analyses.map((a) => {
|
||||
const r = (a.result ?? {}) as { params?: { label: string; value: string }[]; table?: ReportTable };
|
||||
return {
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
params: (r.params ?? []).map((p) => [p.label, p.value] as [string, string]),
|
||||
table: r.table,
|
||||
};
|
||||
}),
|
||||
disclaimer: DISCLAIMER,
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,8 @@ const EXEMPT: Record<string, string> = {
|
||||
"plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)",
|
||||
"plans/[planId]/analyses": "gespeicherte Analysen sind read-only Momentaufnahmen – kein Szenario betroffen",
|
||||
"plans/[planId]/analyses/[analysisId]": "dito (Öffnen/Löschen einer Analyse)",
|
||||
"plans/[planId]/reports": "Berichte sind erzeugte Dokumente – sie verändern kein Szenario",
|
||||
"plans/[planId]/reports/[reportId]": "dito (Herunterladen/Löschen eines Berichts)",
|
||||
};
|
||||
|
||||
describe("Versionierung: Abdeckung der Schreibpfade", () => {
|
||||
|
||||
Reference in New Issue
Block a user