Fix: Cash-Vorbelegung im Ist-Wizard zeigte den Phasen-Endwert
Deploy App / deploy (push) Successful in 1m4s
Deploy App / deploy (push) Successful in 1m4s
Der Wizard las cashBridge.cashEnd -- also den Stand am Phasenende statt am gewaehlten Stichtag. In einer Phase 2026-2036 erschien fuer 2031 der Wert von 2036. Ursache: computePlan wies den Cash-Bestand nur je PHASE aus. YearPoint traegt neu ein Feld `cash` (Stand am Jahresende), analog zu wealthNominal; der Wizard liest daraus. Die Vorbelegung der Elemente war nie betroffen -- die stammte schon immer aus dem Jahresverlauf. Zwei Regressionstests (208 -> 210). Spezifikation 0.22. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+4
-3
@@ -4,7 +4,7 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||
| **Version** | 0.21 |
|
||||
| **Version** | 0.22 |
|
||||
| **Datum** | 2026-07-18 |
|
||||
| **Status** | Lebendes Dokument |
|
||||
| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) |
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
| Version | Datum | Autor | Änderung |
|
||||
|---|---|---|---|
|
||||
| 0.22 | 2026-07-20 | Claude (Opus 4.8) | **Fehlerbehebung: Cash-Vorbelegung im Ist-Wizard.** Der Wizard für die effektiven Werte zeigte als geplanten Cash-Bestand den Stand am **Phasenende** statt am gewählten Stichtag -- in einer Phase von 2026 bis 2036 also für 2031 den Wert von 2036. Ursache: Der Dialog las `cashBridge.cashEnd`, weil `computePlan` den Cash-Bestand bisher nur **je Phase** auswies. `YearPoint` trägt neu ein Feld `cash` (Stand am Jahresende), analog zu `wealthNominal`; der Wizard liest daraus. Die Vorbelegung der ELEMENTE war nie betroffen -- die stammte schon immer aus dem Jahresverlauf. Zwei Regressionstests decken den gemeldeten Fall ab (208 -> 210). Rein additiv, die 43 Golden Tests laufen unverändert. |
|
||||
| 0.21 | 2026-07-20 | Claude (Opus 4.8) | **Effektive Werte / Plan-Ist-Vergleich** (Roadmap Nr. 5, neue Kapitel 3.9 und 9.29). Macht aus dem Planer ein Monitoring-Werkzeug. Neuer Knopf **«Effektive Werte»** auf Plan-Ebene: Liste der Erfassungen plus Wizard in zwei Schritten (Stichtag, dann alle Elemente **aller** Szenarien inkl. **Cash**, Einkommen und Ausgaben, vorbelegt mit dem Planwert für dieses Jahr). Ein Ist-Satz hängt am **Plan**, nicht am Szenario – die Wirklichkeit ist dieselbe, egal wogegen man sie hält; die Zuordnung läuft über die Herkunfts-Kette `sourceElementId`. Das exakte Datum steht in Liste und Zeitachse, für die Rechnung zählt nur die **Jahreszahl**. **Zweiter Rechenlauf:** `computePlan` nimmt neu `{ actuals }`; die Werte schnappen in **jedem** erfassten Jahr auf die Realität und laufen von dort planmässig weiter (Lücken fallen auf die Plandaten zurück). Ohne die Option verhält sich die Funktion exakt wie bisher – die 43 Golden Tests laufen unverändert. Der Sprung wird als eigene Brückenposition `actualsCorrection` geführt (Vermögens- **und** Cash-Brücke): Eine Planabweichung ist keine Rendite, und ohne diese Zeile ginge die Zerlegung im Ist-Jahr nicht mehr auf. **Matrix:** zweiter Umschalter «Plan» / «Effektiv»; im Ist-Modus steht neben dem Wert die **Abweichung** zum Plan, farbig – bewusst kein «beide», das wären mit nominal/real acht Zahlen je Zelle (Begründung 9.29). **Zeitachse:** Marker je erfasstem Jahr, der jüngste farbig, ältere blass. **Alle vier Analysewerkzeuge** erhalten eine einheitliche Leiste (nominal/real als **Einfach**auswahl, Plan/Effektiv); im Vermögensverlauf kommt die Planlinie **gestrichelt** als Referenz dazu, max. vier Serien. **Monte-Carlo:** Der Zielbetrag dreht mit real/nominal mit und wird entsprechend beschriftet; eine Zeile weist aus, ab welchem Jahr simuliert wird – die Jahre davor sind durch Ist-Werte belegt und werden nicht gewürfelt. Das Startjahr ist **abgeleitet, nicht eingebbar**: Ein frei gesetztes Jahr würde Jahre als sicher behandeln, die nie erfasst wurden. Ein Ist-Satz erzeugt **keine** Szenario-Version – er ist eine Beobachtung, keine Planänderung. Neue Tabelle `ActualsSet` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/plans/<id>/actuals`, neue Module `actuals.ts` und `dataview.ts`; 27 Tests ergänzt (181 → 208). |
|
||||
| 0.20 | 2026-07-19 | Claude (Opus 4.8) | **Raten über Lebensphasen übernehmen** (neues Kapitel 3.6.11) und **Rate in der Verlaufsgrafik**. (1) Ändert man ein **Ratenfeld**, fragt das Bearbeitungspanel neu nach der Reichweite: **nur diese Phase** (Vorgabe, bisheriges Verhalten), **diese + folgende** oder **alle Phasen**. Anlass war, dass eine geänderte Rendite bisher nur für die eine Phase galt und viermal eingetippt werden musste. Als Ratenfelder gelten `expectedReturn` (PK, 3a, Sonstiges Vermögen), `valueGrowth` und `interestRate` (Immobilie) sowie `teuerungsausgleich` (Einkommen, Ausgaben); AHV und Schulden haben keine. Die Rückfrage erscheint **inline und erst beim Speichern wirksam**, nicht als Modal – das Zahlenfeld löst bei jedem Tastendruck aus, ein Dialog erschiene bei «5.2» viermal. Sie erscheint nur bei **tatsächlich veränderten** Raten («nicht gesetzt» und 0 gelten als gleich). Beim Übertragen bleiben die **übrigen Werte der Zielphasen erhalten** – der Endpunkt ersetzt den ganzen Werte-Satz, ein blosses Kopieren des Entwurfs hätte dort Beträge, Sparraten und Bezüge gelöscht (durch Test abgesichert). Phasen, in denen der Wert schon stimmt, werden übersprungen. Kein neuer Schreibpfad: ein PUT je Zielphase über den bestehenden Endpunkt, alle in einer Bearbeitungssitzung und damit in **einer** Nebenversion. (2) `ElementYearPoint` führt neu ein Feld **`rate`** mit – additiv, es wird nur durchgereicht, was die Rechnung ohnehin benutzt; die 43 Golden Tests laufen unverändert. Die Verlaufsgrafik der Element-Detailansicht zeigt die Rate damit auf einer **zweiten Y-Achse rechts** in Prozent, als **Stufenlinie** (innerhalb einer Phase konstant, Sprung an der Phasengrenze). Neues Modul `ratefields.ts`; 17 Tests ergänzt (164 → 181). Keine DB- oder API-Änderung. |
|
||||
| 0.19 | 2026-07-19 | Claude (Opus 4.8) | **Versionierung und Änderungshistorie je Szenario** (neues Kapitel 3.8). Jedes Szenario trägt eine Version **A.B**: **B** entsteht automatisch, **A** manuell mit Pflichtkommentar. **Der zentrale Entwurfsentscheid:** FPT hat keinen Speichern-Knopf – jede Änderung schreibt sofort, ein Assistenten-Durchlauf macht ~14 Schreibvorgänge, ein Verteil-Klick einen je Zielelement. Eine Version je Schreibvorgang wäre ein Tastenprotokoll gewesen; stattdessen werden alle Schreibvorgänge innerhalb von **10 Minuten zu einer** Nebenversion zusammengefasst, inhaltlich unveränderte Stände erzeugen gar keine, und verschiedene Benutzer laufen nie in einer Version zusammen. Eine Version hält den **vollständigen** Zustand als JSON in der Form `PlanInput` – dadurch ist die **Versionsauswahl in allen vier Analysewerkzeugen** (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren) fast kostenlos; bei Monte-Carlo **je Szenario einzeln**, weil dort mehrere gleichzeitig laufen. **Wiederherstellen** ist ungefährlich gebaut: Es legt den zurückgesetzten Stand selbst als neue Version an («Wiederhergestellt aus A.B»), löscht also nichts, und **erhält die IDs** von Phasen und Elementen – sonst verlören alle Kind-Szenarien ihre Diff-Basis und zeigten schlagartig alles als «neu». Wo ein Bezug trotzdem bricht (der alte Stand kannte das Element noch nicht), **warnt der Dialog vorher namentlich**. Die destruktive Logik liegt als reine Funktion `planRestore` vor und ist dort getestet; `versioning-db.ts` führt sie nur aus. Ein **statischer Wächter-Test** liest alle Route-Dateien und verlangt, dass jeder schreibende Endpunkt eine Version auslöst – eine vergessene Stelle wäre eine stille Lücke. Neue Tabelle `ScenarioVersion` + `Scenario.currentMajor` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/scenarios/<id>/versions`. Neue Kapitel 3.8 und 9.28; 25 Tests ergänzt (139 → 164). |
|
||||
@@ -3276,7 +3277,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|
||||
| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise |
|
||||
| `montecarlo.test.ts` | 18 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf; Schwellen-Ablesung (probabilityAtLeast), Monotonie über Schwellen, Fall 2 systematisch unter 50 % |
|
||||
| `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder |
|
||||
| `actuals.test.ts` | 20 | Zuordnung über Kopie-Ketten, Sprung im Ist-Jahr, Weiterrechnen ab dem Ist-Wert, geschlossene Brücken, Fluss-Rückrechnung |
|
||||
| `actuals.test.ts` | 22 | Zuordnung über Kopie-Ketten, Sprung im Ist-Jahr, Weiterrechnen ab dem Ist-Wert, geschlossene Brücken, Fluss-Rückrechnung |
|
||||
| `dataview.test.ts` | 7 | beide Sichten in einem Zug, Rückfall ohne Ist-Werte, Abweichungsmessung |
|
||||
| `ratefields.test.ts` | 17 | Erkennung geänderter Raten, Reichweite (diese/folgende/alle), Erhalt der übrigen Werte in den Zielphasen |
|
||||
| `versioning.test.ts` | 22 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien |
|
||||
@@ -3286,7 +3287,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|
||||
| `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus |
|
||||
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
|
||||
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
|
||||
| **Total** | **208** | |
|
||||
| **Total** | **210** | |
|
||||
|
||||
## 8.2 Testfälle
|
||||
|
||||
|
||||
@@ -178,11 +178,10 @@ export function ActualsDialog({
|
||||
if (!base) return 0;
|
||||
const planYear = toPlanYear(year, base.plan.startYear);
|
||||
if (planYear === null) return 0;
|
||||
// Der Stand am Ende GENAU DIESES Jahres -- nicht am Ende der Phase. In einer Phase über
|
||||
// zehn Jahre, in der sich das Cash-Konto füllt, sind das zwei völlig verschiedene Zahlen.
|
||||
const computed = computePlan(base.plan);
|
||||
const ph = computed.phases.find(
|
||||
(p) => planYear <= computed.phases.slice(0, p.sequenceNumber).reduce((s, x) => s + x.durationYears, 0)
|
||||
);
|
||||
return Math.round(ph?.cashBridge.cashEnd ?? 0);
|
||||
return computed.yearly.find((y) => y.year === planYear)?.cash ?? 0;
|
||||
}, [base, year]);
|
||||
|
||||
function startWizard() {
|
||||
|
||||
@@ -220,6 +220,64 @@ describe("Berechnung mit Ist-Werten", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cash-Bestand je Jahr", () => {
|
||||
// Genau der gemeldete Fall: EINE lange Phase, in der sich das Cash-Konto kontinuierlich
|
||||
// füllt. Die Vorbelegung im Wizard muss den Stand des GEWÄHLTEN Jahres zeigen -- nicht den
|
||||
// Stand am Phasenende. Zuvor las der Dialog `cashBridge.cashEnd` und zeigte in der
|
||||
// Phasenmitte den Endwert an.
|
||||
function savingPlan(): PlanInput {
|
||||
return {
|
||||
id: "s",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 0,
|
||||
initialCash: 100,
|
||||
startYear: 2026,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }],
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 10, cashTransition: {} }],
|
||||
elements: [
|
||||
{
|
||||
id: "inc",
|
||||
category: "INCOME",
|
||||
name: "Lohn",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 1,
|
||||
phaseValues: { p1: { amount: 100000 } },
|
||||
transitionValues: {},
|
||||
sourceElementId: null,
|
||||
},
|
||||
{
|
||||
id: "exp",
|
||||
category: "EXPENSE",
|
||||
name: "Leben",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 2,
|
||||
phaseValues: { p1: { amount: 90000 } },
|
||||
transitionValues: {},
|
||||
sourceElementId: null,
|
||||
},
|
||||
],
|
||||
} as unknown as PlanInput;
|
||||
}
|
||||
|
||||
it("weist den Cash-Stand für JEDES Jahr aus, nicht nur je Phase", () => {
|
||||
const computed = computePlan(savingPlan());
|
||||
// 10'000 Überschuss pro Jahr auf 100 Startguthaben.
|
||||
expect(computed.yearly.find((y) => y.year === 1)!.cash).toBe(10100);
|
||||
expect(computed.yearly.find((y) => y.year === 5)!.cash).toBe(50100);
|
||||
expect(computed.yearly.find((y) => y.year === 10)!.cash).toBe(100100);
|
||||
});
|
||||
|
||||
it("liegt in der Phasenmitte deutlich unter dem Phasen-Endwert", () => {
|
||||
// Die eigentliche Regression: Mitte und Ende dürfen nicht dieselbe Zahl sein.
|
||||
const computed = computePlan(savingPlan());
|
||||
const mid = computed.yearly.find((y) => y.year === 5)!.cash;
|
||||
const phaseEnd = computed.phases[0].cashBridge.cashEnd;
|
||||
expect(phaseEnd).toBe(computed.yearly.find((y) => y.year === 10)!.cash);
|
||||
expect(mid).toBeLessThan(phaseEnd);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rebaseFlow", () => {
|
||||
it("trifft im Ist-Jahr genau den erfassten Betrag", () => {
|
||||
// Basis so, dass basis * (1+idx)^(t-1) === Ist-Wert.
|
||||
|
||||
@@ -198,6 +198,11 @@ export interface YearPoint {
|
||||
// Vermögensverlauf über ALLE Jahre statt nur über die Phasengrenzen.
|
||||
wealthNominal: number;
|
||||
wealthReal: number;
|
||||
// Cash-Bestand am Jahresende. Die Brücken führen Cash nur je PHASE (Anfang/Ende) -- für
|
||||
// eine Aussage zu einem einzelnen Jahr (Vorbelegung der effektiven Werte, Kap. 3.9.2)
|
||||
// reicht das nicht: In einer Phase über zehn Jahre ist der Phasen-Endwert etwas ganz
|
||||
// anderes als der Stand in der Mitte.
|
||||
cash: number;
|
||||
}
|
||||
|
||||
export interface PlanComputed {
|
||||
@@ -751,6 +756,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
expenseReal: Math.round(expenseReal),
|
||||
wealthNominal: 0,
|
||||
wealthReal: 0,
|
||||
cash: 0,
|
||||
};
|
||||
yearly.push(yearPoint);
|
||||
|
||||
@@ -907,6 +913,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
for (const d of debts) total += -d.owed;
|
||||
yearPoint.wealthNominal = Math.round(total);
|
||||
yearPoint.wealthReal = Math.round(total / (cumInfl[yearsBefore + t] || 1));
|
||||
yearPoint.cash = Math.round(cash);
|
||||
if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user