2f762175d2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
7.7 KiB
TypeScript
188 lines
7.7 KiB
TypeScript
// 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<FixpointKind, string> = {
|
||
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,
|
||
};
|
||
}
|