2f762175d2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
326 lines
13 KiB
TypeScript
326 lines
13 KiB
TypeScript
// 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(" ");
|
||
}
|