// Ableitung der Lebensabschnitts-"Teile" aus den fixen Pensionierungszeitpunkten. // // Die Pensionsalter der Personen sind Fixpunkte auf der Lebenslinie. Zwischen Planbeginn und // diesen Punkten entstehen Abschnitte mit konstantem Erwerbsstatus -- Erwerb (alle arbeiten), // Misch (eine Person pensioniert, eine arbeitet) oder Pension (alle pensioniert). Genau diese // Abschnitte muss der Assistent respektieren: Eine Phase darf keinen Pensionierungspunkt // überspannen (die Berechnung leitet den Phasentyp am Phasenbeginn ab und kappt entsprechend, // siehe SPEZIFIKATION 2.3 / maxPhaseDuration). // // Reine Funktion, ohne UI und ohne I/O -- nur vom Assistenten verwendet. export type SegmentType = "ERWERB" | "MIXED" | "PENSION"; // Ein Zeitpunkt, an dem ZWINGEND eine neue Lebensphase beginnen muss. // // Bis 0.35 gab es genau einen pro Person: das Erwerbsende. Seit 0.36 sind es bis zu vier -- // jeder Beginn eines Renten- oder Kapitalbezugs ist einer. Der Grund ist derselbe wie beim // Erwerbsende: Die Rechnung leitet Erwerbsstatus und Bezuege am PHASENBEGINN ab. Faellt ein // Bezug mitten in eine Phase, waere der halbe Phasenertrag falsch. export type FixpointKind = "RETIREMENT" | "AHV" | "PENSION_FUND" | "PILLAR_3A"; export interface Fixpoint { // Jahre ab Planbeginn (1-basiert wie die Phasengrenzen: Jahr 5 = Ende des 5. Planjahres). year: number; age: number; role: string; kind: FixpointKind; label: string; } export interface PlanSegment { type: SegmentType; fromYear: number; // Jahre ab Planbeginn (Beginn des Abschnitts) // Feste Länge in Jahren (durch den nächsten Fixpunkt bestimmt) ODER null für den letzten, // OFFENEN Abschnitt (Dauer frei wählbar bzw. durch den Planungshorizont bestimmt). fixedYears: number | null; // Welche Ereignisse den Abschnitt beenden -- für die Beschriftung der Zeitachse. endedBy: Fixpoint[]; } export interface FixpointPerson { // Optional, damit Aufrufer, die nur Abschnitte brauchen (Zeitachse), nichts erfinden muessen. role?: string; name?: string | null; age: number; retirementAge: number; // Bezugsalter aus dem Pensionierungs-Entscheid. Fehlen sie, gilt das Erwerbsende. ahvStartAge?: number; pkWithdrawalAge?: number; pillar3aAges?: number[]; } const KIND_LABEL: Record = { RETIREMENT: "Erwerbsende", AHV: "AHV-Rente", PENSION_FUND: "Pensionskasse", PILLAR_3A: "Säule 3a", }; // Alle Fixpunkte, sortiert und ohne Duplikate im selben Jahr (mehrere Ereignisse im gleichen // Jahr brauchen nur EINE Phasengrenze -- sie wird dann mehrfach beschriftet). export function planFixpoints(persons: FixpointPerson[]): Fixpoint[] { const out: Fixpoint[] = []; const add = (person: FixpointPerson, age: number | undefined, kind: FixpointKind) => { if (typeof age !== "number") return; const year = Math.round(age - person.age); // Ereignisse vor oder bei Planbeginn sind keine Grenze -- sie sind bereits Geschichte. if (year <= 0) return; out.push({ year, age: Math.round(age), role: person.role ?? "PERSON_A", kind, label: `${KIND_LABEL[kind]}${person.name ? " " + person.name : ""} (${Math.round(age)})`, }); }; for (const p of persons) { add(p, p.retirementAge, "RETIREMENT"); add(p, p.ahvStartAge, "AHV"); add(p, p.pkWithdrawalAge, "PENSION_FUND"); for (const a of p.pillar3aAges ?? []) add(p, a, "PILLAR_3A"); } return out.sort((a, b) => a.year - b.year || a.kind.localeCompare(b.kind)); } // Die distinkten Fixpunkt-Jahre sind die Abschnittsgrenzen; der Abschnitt nach dem letzten // ist offen. export function planSegments(persons: FixpointPerson[]): PlanSegment[] { if (persons.length === 0) return [{ type: "PENSION", fromYear: 0, fixedYears: null, endedBy: [] }]; const fixpoints = planFixpoints(persons); const retYears = persons.map((p) => Math.max(0, p.retirementAge - p.age)); const years = [...new Set(fixpoints.map((f) => f.year))].sort((a, b) => a - b); const boundaries = [0, ...years]; const segments: PlanSegment[] = []; for (let i = 0; i < boundaries.length; i++) { const from = boundaries[i]; const next = i + 1 < boundaries.length ? boundaries[i + 1] : null; // Eine Person arbeitet in einem Abschnitt, der bei Jahr `from` beginnt, genau dann, wenn // ihr Erwerbsende echt später liegt. const working = retYears.filter((r) => r > from).length; const type: SegmentType = working === persons.length ? "ERWERB" : working > 0 ? "MIXED" : "PENSION"; segments.push({ type, fromYear: from, fixedYears: next === null ? null : next - from, endedBy: next === null ? [] : fixpoints.filter((f) => f.year === next), }); } return segments; } // Vorgeschlagene Dauer des offenen Abschnitts. Mit erfasstem Planungshorizont ergibt sie sich // daraus; ohne ihn bis etwa Alter 90, mindestens aber 5 Jahre. export function defaultOpenDuration( persons: { age: number }[], fromYear: number, horizonYears?: number | null ): number { if (typeof horizonYears === "number" && horizonYears > fromYear) return horizonYears - fromYear; const oldestNow = persons.length > 0 ? Math.max(...persons.map((p) => p.age)) : 65; return Math.max(5, 90 - (oldestNow + fromYear)); } // --- Phasendauer ändern: die Folgephase gleicht aus -------------------------------------- // // Wird eine Phase verlängert, muss die NÄCHSTE um denselben Betrag kürzer werden -- sonst // verschieben sich alle folgenden Phasengrenzen, und eine davon überspannt am Ende eine // Pensionierung. Genau das passierte bis 0.32: Die Kappung prüfte nur die BEARBEITETE Phase // («Phase 1 darf höchstens bis zur Pensionierung laufen»), nicht die Folgen für Phase 2. // // Dieselbe Mechanik wie beim Verschieben des Pensionsalters (lib/retirement.ts): Die // Gesamtdauer des Plans bleibt gleich, es wird nur Zeit umverteilt. Nur die LETZTE Phase hat // keine Nachfolgerin -- sie verlängert oder verkürzt den Plan tatsächlich. export interface DurationChange { ok: boolean; // Die Folgephase, die den Ausgleich trägt (null bei der letzten Phase). neighbourId: string | null; neighbourName: string | null; neighbourNewDuration: number | null; delta: number; // Änderung der bearbeiteten Phase in Jahren error: string | null; } export function planDurationChange( phases: { id: string; name: string; sequenceNumber: number; durationYears: number }[], phaseId: string, newDuration: number ): DurationChange { const sorted = [...phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); const i = sorted.findIndex((p) => p.id === phaseId); const none: DurationChange = { ok: false, neighbourId: null, neighbourName: null, neighbourNewDuration: null, delta: 0, error: null, }; if (i < 0) return { ...none, error: "Diese Lebensphase gibt es nicht." }; const delta = Math.round(newDuration) - sorted[i].durationYears; if (newDuration < 1) return { ...none, delta, error: "Eine Lebensphase muss mindestens ein Jahr dauern." }; if (delta === 0) return { ...none, ok: true }; const next = sorted[i + 1]; // Letzte Phase: kein Ausgleich nötig, der Plan wird einfach länger oder kürzer. if (!next) return { ...none, ok: true, delta }; const neighbourNewDuration = next.durationYears - delta; if (neighbourNewDuration < 1) { return { ...none, delta, error: `«${next.name}» dauert nur ${next.durationYears} Jahr${next.durationYears === 1 ? "" : "e"} und müsste den ` + `Ausgleich tragen. Verlängere diese Phase um höchstens ${next.durationYears - 1} Jahr` + `${next.durationYears - 1 === 1 ? "" : "e"} – oder passe zuerst die Folgephase an.`, }; } return { ok: true, neighbourId: next.id, neighbourName: next.name, neighbourNewDuration, delta, error: null, }; }