// Pensionsalter anpassen (Roadmap Nr. 44). // // Bis hierher war das Pensionsalter faktisch unantastbar: Es bestimmt, wo eine Phase endet // (`maxPhaseDuration` kappt jede Phasendauer beim nächsten Pensionierungsereignis), also // liegt JEDE Pensionierung zwangsläufig auf einer Phasengrenze. Genau das macht die // Anpassung überhaupt erst möglich: «Pensionsalter ändern» heisst «diese eine Grenze // verschieben» -- die Phase davor wird länger, die danach kürzer, die Gesamtdauer des Plans // bleibt gleich. // // Reines Modul ohne I/O: Es entscheidet nur, WAS geschehen soll. Das Schreiben übernimmt der // Aufrufer (Panel: API-Aufrufe; Tornado/Live-Simulation: reine Plan-Kopie). import type { CashTransitionData, TransitionData } from "@/lib/elements"; import type { PersonRole, PlanInput } from "@/lib/types"; export interface RetirementBoundary { role: PersonRole; personId: string; retirementAge: number; // Planjahr, in dem die Pensionierung liegt (1-basiert: Ende von Phase `phaseIndex`). planYear: number; phaseIndex: number; // Phase VOR der Grenze // Um so viele Jahre lässt sich das Pensionsalter senken bzw. erhöhen, ohne dass eine // angrenzende Phase unter 1 Jahr fällt. minDelta: number; maxDelta: number; // Bei genau diesen Werten verschwindet eine Phase (Zusammenlegung, siehe unten). mergeDeltaDown: number | null; mergeDeltaUp: number | null; // Warum ist keine Anpassung möglich? blocked: string | null; } // Kumulierte Jahresgrenzen: Index i = Planjahr, an dem Phase i endet. function boundaries(plan: PlanInput): number[] { const sorted = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); const out: number[] = []; let acc = 0; for (const p of sorted) { acc += p.durationYears; out.push(acc); } return out; } export function sortedPhases(plan: PlanInput) { return [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); } // --- Planungshorizont ----------------------------------------------------------------------- // // Bis 0.35 ergab sich das Planende stillschweigend als Summe der Phasendauern. Zwei Szenarien // konnten dadurch unbemerkt verschieden weit rechnen -- und waren dann nicht vergleichbar, // obwohl genau das ihr Zweck ist. // // Seit 0.36 ist der Horizont eine Zahl in JAHREN am Szenario. Vorher stand er als Endalter je // PERSON -- bei einem Paar zwei Zahlen, die auseinanderlaufen konnten und aus denen sich das // Planende erst per Maximum ergab. Eine Laufzeit ist eine Eigenschaft der PLANUNG, nicht der // Person; die Endalter sind die Ableitung davon, nicht umgekehrt. export function planEndYear(plan: PlanInput): number | null { const y = plan.planningHorizonYears; return typeof y === "number" && y > 0 ? y : null; } // Alter, das eine Person am Ende der Planung erreicht. Read-only-Anzeige neben dem Horizont. export function endAgeOf(plan: PlanInput, role: PersonRole): number | null { const years = planEndYear(plan); const person = plan.persons.find((p) => p.role === role); return years !== null && person ? person.age + years : null; } export interface HorizonChange { lastPhaseId: string; oldDuration: number; newDuration: number; blocked: string | null; } // Was muesste an der letzten Phase geschehen, damit der Plan genau bis zum Horizont laeuft? // Dieselbe Richtung wie beim Pensionsalter: Zahl stellen, Struktur folgt. export function planHorizonChange(plan: PlanInput, horizonYears: number): HorizonChange | null { const phases = sortedPhases(plan); const last = phases[phases.length - 1]; if (!last) return null; const before = phases.slice(0, -1).reduce((s, p) => s + p.durationYears, 0); const newDuration = horizonYears - before; const change: HorizonChange = { lastPhaseId: last.id, oldDuration: last.durationYears, newDuration, blocked: null, }; if (newDuration < 1) { change.blocked = "Der Horizont liegt vor dem Ende der zweitletzten Lebensphase. Die letzte Phase muss mindestens ein Jahr dauern – kürze zuerst eine frühere Phase oder verlängere den Horizont."; } return change; } // Analysiert für JEDE Person, ob und wie weit sich ihr Pensionsalter verschieben lässt. export function retirementBoundaries(plan: PlanInput): RetirementBoundary[] { const phases = sortedPhases(plan); const bounds = boundaries(plan); const total = bounds[bounds.length - 1] ?? 0; return plan.persons.map((p) => { const planYear = p.retirementAge - p.age; const base: RetirementBoundary = { role: p.role, personId: p.id, retirementAge: p.retirementAge, planYear, phaseIndex: -1, minDelta: 0, maxDelta: 0, mergeDeltaDown: null, mergeDeltaUp: null, blocked: null, }; if (planYear <= 0) { return { ...base, blocked: "Diese Person ist bei Planbeginn bereits pensioniert – es gibt keine Grenze zu verschieben." }; } if (planYear >= total) { return { ...base, blocked: "Die Pensionierung liegt am oder nach dem Planende – dafür müsstest du zuerst eine Lebensphase anhängen." }; } const idx = bounds.indexOf(planYear); if (idx < 0 || idx >= phases.length - 1) { return { ...base, blocked: "Die Pensionierung liegt nicht auf einer Phasengrenze. Passe zuerst die Lebensphasen an.", }; } // Teilen sich zwei Personen dieselbe Grenze, liesse sich eine davon nicht verschieben, // ohne die andere mitzunehmen -- dafür müsste eine Phase eingefügt werden. const shared = plan.persons.some((q) => q.id !== p.id && q.retirementAge - q.age === planYear); if (shared) { return { ...base, phaseIndex: idx, blocked: "Beide Personen werden zum selben Zeitpunkt pensioniert. Um sie zu trennen, füge zuerst eine Lebensphase ein.", }; } const before = phases[idx].durationYears; const after = phases[idx + 1].durationYears; return { ...base, phaseIndex: idx, // Die Phase davor darf auf 1 schrumpfen, die danach ebenso. minDelta: -(before - 1), maxDelta: after - 1, // Ein Schritt weiter lässt die jeweilige Phase ganz verschwinden. mergeDeltaDown: -before, mergeDeltaUp: after, }; }); } export interface ShiftResult { plan: PlanInput; // Index der Phase, die dabei entfällt (Zusammenlegung) -- sonst null. removedPhaseIndex: number | null; removedPhaseId: string | null; // Phase, in deren Übergang die Entscheide der entfallenden Phase gezogen wurden. mergedIntoPhaseId: string | null; } // Verschiebt die Pensionierungs-Grenze einer Person um `delta` Jahre. // // `delta > 0` = später in Pension: die Phase davor wird länger, die danach kürzer. // Fällt eine der beiden auf 0, verschwindet sie (Zusammenlegung) -- das muss der Aufrufer // vorher bestätigen lassen, weil dabei Übergangs-Entscheide zusammengeführt werden. export function shiftRetirement(plan: PlanInput, role: PersonRole, delta: number): ShiftResult | null { const info = retirementBoundaries(plan).find((b) => b.role === role); if (!info || info.blocked || info.phaseIndex < 0) return null; if (delta === 0) return { plan, removedPhaseIndex: null, removedPhaseId: null, mergedIntoPhaseId: null }; const allowedDown = info.mergeDeltaDown ?? info.minDelta; const allowedUp = info.mergeDeltaUp ?? info.maxDelta; if (delta < allowedDown || delta > allowedUp) return null; const phases = sortedPhases(plan); const i = info.phaseIndex; const beforeNew = phases[i].durationYears + delta; const afterNew = phases[i + 1].durationYears - delta; if (beforeNew < 0 || afterNew < 0) return null; const nextPhases = phases.map((p, k) => k === i ? { ...p, durationYears: beforeNew } : k === i + 1 ? { ...p, durationYears: afterNew } : { ...p } ); // Genau eine der beiden kann 0 werden -- diese Phase entfällt. let removedIndex: number | null = null; if (beforeNew === 0) removedIndex = i; else if (afterNew === 0) removedIndex = i + 1; const removedPhaseId = removedIndex === null ? null : nextPhases[removedIndex].id; // Die überlebende Grenze ist die des Vorgängers -- dorthin wandern die Entscheide. const mergedIntoPhaseId = removedIndex === null || removedIndex === 0 ? null : nextPhases[removedIndex - 1].id; let kept = removedIndex === null ? nextPhases : nextPhases.filter((_, k) => k !== removedIndex); let elements = plan.elements; if (removedPhaseId && mergedIntoPhaseId) { kept = kept.map((p) => p.id === mergedIntoPhaseId ? { ...p, cashTransition: mergeCashTransition( p.cashTransition ?? {}, nextPhases[removedIndex!].cashTransition ?? {} ), } : p ); elements = plan.elements.map((e) => { const removedTd = e.transitionValues?.[removedPhaseId]; if (!removedTd) return e; const rest = { ...e.transitionValues }; delete rest[removedPhaseId]; return { ...e, transitionValues: { ...rest, [mergedIntoPhaseId]: mergeTransition(e.transitionValues[mergedIntoPhaseId] ?? {}, removedTd), }, }; }); } return { plan: { ...plan, persons: plan.persons.map((p) => (p.role === role ? { ...p, retirementAge: p.retirementAge + delta } : p)), // Sequenznummern lückenlos neu vergeben, damit die Kette intakt bleibt. phases: kept.map((p, k) => ({ ...p, sequenceNumber: k + 1 })), elements, }, removedPhaseIndex: removedIndex, removedPhaseId, mergedIntoPhaseId, }; } // --- Zusammenlegung zweier Übergänge ---------------------------------------------------- // // Fällt eine Phase weg, verschwindet auch einer der beiden Übergänge -- die dort getroffenen // Entscheide dürfen aber nicht stillschweigend untergehen. Die überlebende Grenze ist immer // die des VORGÄNGERS der entfallenden Phase; deren Daten werden in ihn hineingezogen. // // Regel (mit dem Nutzer abgestimmt): // * Element-Entscheide: Was am überlebenden Übergang schon entschieden ist, bleibt. Nur // leere Felder werden aus dem entfallenden Übergang aufgefüllt -- ein bewusster // Entscheid soll nie von einem anderen überschrieben werden. // * Einmalige Cash-Beträge: werden ADDIERT. Beide Ereignisse finden ja weiterhin statt, // nur zum selben Zeitpunkt. // Feldweises Auffüllen: `keep` gewinnt, `removed` füllt die Lücken. export function mergeTransition(keep: TransitionData, removed: TransitionData): TransitionData { const out: TransitionData = { ...keep }; for (const [k, v] of Object.entries(removed) as [keyof TransitionData, unknown][]) { if (v === undefined || v === null) continue; if (out[k] === undefined || out[k] === null) (out as Record)[k] = v; } return out; } export function mergeCashTransition(keep: CashTransitionData, removed: CashTransitionData): CashTransitionData { // "NONE" bedeutet: bewusst nichts. Beträge daraus zählen nicht mit. const active = (c: CashTransitionData) => c.mode && c.mode !== "NONE"; const inflow = (c: CashTransitionData) => active(c) && (c.mode === "INFLOW" || c.mode === "BOTH") ? c.inflowAmount ?? 0 : 0; const outflow = (c: CashTransitionData) => active(c) && (c.mode === "OUTFLOW" || c.mode === "BOTH") ? c.outflowAmount ?? 0 : 0; const inA = inflow(keep); const inB = inflow(removed); const outA = outflow(keep); const outB = outflow(removed); const inSum = inA + inB; const outSum = outA + outB; const join = (a: string | undefined, b: string | undefined) => [a, b].filter((x) => x && x.trim()).join(" + ") || undefined; return { mode: inSum > 0 && outSum > 0 ? "BOTH" : inSum > 0 ? "INFLOW" : outSum > 0 ? "OUTFLOW" : "NONE", inflowLabel: inSum > 0 ? join(inA > 0 ? keep.inflowLabel : undefined, inB > 0 ? removed.inflowLabel : undefined) : undefined, inflowAmount: inSum > 0 ? inSum : undefined, // Zwei Zuflüsse mit verschiedenen Steuersätzen ergeben zusammen einen betragsgewichteten // Mischsatz -- nur so bleibt der Netto-Zufluss derselbe wie vor der Zusammenlegung. inflowTaxRate: inSum > 0 ? Math.round((((keep.inflowTaxRate ?? 0) * inA + (removed.inflowTaxRate ?? 0) * inB) / inSum) * 100) / 100 : undefined, outflowLabel: outSum > 0 ? join(outA > 0 ? keep.outflowLabel : undefined, outB > 0 ? removed.outflowLabel : undefined) : undefined, outflowAmount: outSum > 0 ? outSum : undefined, }; } // Erklärtext für die Hilfebox: was ist möglich, und warum nicht mehr. export function limitsText(b: RetirementBoundary, personLabel: string): string { if (b.blocked) return b.blocked; const down = Math.abs(b.minDelta); const up = b.maxDelta; const parts = [ `Das Pensionsalter von ${personLabel} lässt sich um maximal ${down} Jahr${down === 1 ? "" : "e"} senken und ` + `${up} Jahr${up === 1 ? "" : "e"} erhöhen, weil die angrenzenden Lebensphasen mindestens 1 Jahr dauern müssen.`, ]; if (b.mergeDeltaDown !== null || b.mergeDeltaUp !== null) { parts.push( "Ein Jahr darüber hinaus fällt die angrenzende Lebensphase ganz weg – das ist möglich, wird aber vorher " + "bestätigt, weil dabei zwei Übergänge zusammengelegt werden." ); } parts.push("Brauchst du mehr Spielraum, passe zuerst die Dauer der Lebensphasen an."); return parts.join(" "); }