diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 19ada00..36835d6 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -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//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//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 diff --git a/next.config.ts b/next.config.ts index 68a6c64..f059420 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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; diff --git a/package-lock.json b/package-lock.json index a1270ac..7a5640a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 250142c..3bc4ff2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20260721090000_reports/migration.sql b/prisma/migrations/20260721090000_reports/migration.sql new file mode 100644 index 0000000..0cf36ea --- /dev/null +++ b/prisma/migrations/20260721090000_reports/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d14157f..24f1d81 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/plans/[planId]/reports/[reportId]/route.ts b/src/app/api/plans/[planId]/reports/[reportId]/route.ts new file mode 100644 index 0000000..e54bd2b --- /dev/null +++ b/src/app/api/plans/[planId]/reports/[reportId]/route.ts @@ -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 }); +} diff --git a/src/app/api/plans/[planId]/reports/route.ts b/src/app/api/plans/[planId]/reports/route.ts new file mode 100644 index 0000000..f2454ad --- /dev/null +++ b/src/app/api/plans/[planId]/reports/route.ts @@ -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, + }) + ), + 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 }); +} diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 266197a..5ef3a06 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -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>({}); const [savedAnalysisId, setSavedAnalysisId] = useState(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) => ( {subItem("analyses", "Analysen", BarChart3)} + {subItem("reports", "Berichte", FileSpreadsheet)} ); })} @@ -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 && ( + ({ id: s.id, name: s.name, isBase: s.isBase }))} + hasActuals={(activePlan._count?.actuals ?? 0) > 0} + /> + )} + {!showSpec && !showSystemParams && !loading && !planNav && selectedScenarioId === null && ( = { // 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 { diff --git a/src/components/ReportsView.tsx b/src/components/ReportsView.tsx new file mode 100644 index 0000000..e670329 --- /dev/null +++ b/src/components/ReportsView.tsx @@ -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(null); + const [analyses, setAnalyses] = useState([]); + const [error, setError] = useState(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("nominal"); + const [source, setSource] = useState("PLAN"); + const [scenarioIds, setScenarioIds] = useState([]); + const [analysisIds, setAnalysisIds] = useState([]); + + 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 ( +
+
+

Neuer Bericht

+

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

+
+ +
+
+ + setTitle(e.target.value)} + className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg" + /> +
+
+ + 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" + /> +
+
+ +
+ + + +
+ +
+
+ Szenarien (max. {MAX_REPORT_SCENARIOS}) + +
+
+ {scenarios.map((s) => { + const checked = scenarioIds.includes(s.id); + const full = !checked && scenarioIds.length >= MAX_REPORT_SCENARIOS; + return ( + + ); + })} +
+
+ +
+
+ Gespeicherte Analysen einbinden + +
+ {analyses.length === 0 ? ( +

+ Noch keine Analysen gespeichert. Unter «Analysen» kannst du Ergebnisse festhalten. +

+ ) : ( +
+ {analyses.map((a) => ( + + ))} +
+ )} +
+ +
+ + +
+
+ ); + } + + return ( +
+
+

Berichte

+ +
+ +

+ Ein Bericht ist ein festes Dokument: Die erzeugte PDF-Datei wird abgelegt + und lässt sich jederzeit unverändert wieder herunterladen – auch wenn du den Plan danach weiterentwickelst. +

+ + {error &&

{error}

} + {!reports && !error &&

Wird geladen…

} + + {reports && reports.length === 0 && ( +

+ Noch kein Bericht erstellt. +

+ )} + + {reports && reports.length > 0 && ( +
+ {reports.map((r) => ( +
+ +
+
{r.title}
+
+ {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 +
+
+ + PDF + + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/lib/decisions.ts b/src/lib/decisions.ts new file mode 100644 index 0000000..8615efa --- /dev/null +++ b/src/lib/decisions.ts @@ -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; +} diff --git a/src/lib/migrations.test.ts b/src/lib/migrations.test.ts index 134561c..60f5b20 100644 --- a/src/lib/migrations.test.ts +++ b/src/lib/migrations.test.ts @@ -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"]) { diff --git a/src/lib/report-pdf.ts b/src/lib/report-pdf.ts new file mode 100644 index 0000000..1b3d16a --- /dev/null +++ b/src/lib/report-pdf.ts @@ -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; + +// --- 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 { + 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((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; +} diff --git a/src/lib/report.test.ts b/src/lib/report.test.ts new file mode 100644 index 0000000..aa49fa6 --- /dev/null +++ b/src/lib/report.test.ts @@ -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[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) => + 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); +}); diff --git a/src/lib/report.ts b/src/lib/report.ts new file mode 100644 index 0000000..f7b72ba --- /dev/null +++ b/src/lib/report.ts @@ -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, + }; +} diff --git a/src/lib/versioning-coverage.test.ts b/src/lib/versioning-coverage.test.ts index dad81c5..06afc9c 100644 --- a/src/lib/versioning-coverage.test.ts +++ b/src/lib/versioning-coverage.test.ts @@ -34,6 +34,8 @@ const EXEMPT: Record = { "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", () => {