Files
FPT/src/lib/phaseplan.ts
T

188 lines
7.7 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.
// 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,
};
}