Files
FPT/src/lib/retirement.ts
T

326 lines
13 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.
// 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<string, unknown>)[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(" ");
}