Neuer Unterpunkt "Berichte" je Plan: Liste plus Assistent (Titel, Notiz, nominal ODER real, Plan-/Ist-Daten, bis zu drei Szenarien, gespeicherte Analysen). Layout immer gleich, Auswahl bestimmt nur die Bausteine. Die PDF-Datei wird ALS DATEI abgelegt (BYTEA in Postgres, nicht im Container-Dateisystem): Ein Bericht muss in drei Jahren byte-identisch wieder herunterladbar sein -- eine Neuerzeugung koennte das nach Aenderungen an Plan, Rechenkern oder Layout nicht garantieren. Kennzahlen je Szenario inkl. offener Entscheide. Deren Zaehlung liegt neu als reine Funktion in decisions.ts, die Matrix UND Bericht benutzen -- sonst nennen beide verschiedene Zahlen. Zu jeder Kennzahl ihre Grundlage als Verweis; die vollstaendigen Annahmen einmal je Szenario. Haftungsausschluss ist verpflichtend (per Test). Technik: pdfkit in der Node-Runtime statt Headless-Browser; @react-pdf/renderer bricht mit React 19. Als externes Paket deklariert, weil pdfkit Font-Metriken ueber Dateipfade laedt. Spezifikation 0.25 (3.11 neu), 9 Tests (212 -> 221). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user