Files
FPT/src/lib/report.ts
T

408 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { decisionsText, 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" });
}
const openTotal = open.open + open.unconfirmed;
figures.push({
label: "Offene Entscheide",
value: openTotal === 0 ? "keine" : decisionsText(open),
tone: openTotal === 0 ? "success" : undefined,
basis:
"noch nicht getroffene Übergangs-Entscheide sowie Pensionierungs-Vorgaben, die nie bestätigt wurden",
});
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.open + open.unconfirmed,
};
}
// --- 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,
};
}