Effektive Werte: Plan-Ist-Vergleich (Roadmap 5)
Deploy App / deploy (push) Successful in 1m57s

Neuer Knopf auf Plan-Ebene: Liste plus Wizard in zwei Schritten. Ein
Ist-Satz haengt am PLAN, nicht am Szenario -- die Zuordnung laeuft ueber
die Herkunfts-Kette sourceElementId.

computePlan nimmt neu { actuals }: Die Werte schnappen in jedem erfassten
Jahr auf die Realitaet und laufen von dort planmaessig weiter. Luecken
fallen auf die Plandaten zurueck. Ohne die Option unveraendert -- die 43
Golden Tests laufen durch.

Der Sprung ist keine Rendite: eigene Brueckenposition actualsCorrection
in Vermoegens- und Cash-Bruecke, sonst ginge die Zerlegung nicht auf.

Matrix: Umschalter Plan/Effektiv, im Ist-Modus mit farbiger Abweichung
statt acht Zahlen je Zelle. Zeitachse: Marker je Jahr, juengster farbig.

Vier Analysewerkzeuge mit einheitlicher Leiste (nominal/real als
Einfachauswahl, Plan/Effektiv). MC: Zielbetrag dreht mit, Startjahr
abgeleitet statt eingebbar.

Neue Tabelle ActualsSet (gegen echtes Postgres verifiziert), Module
actuals.ts und dataview.ts. Spezifikation 0.21, 27 Tests (181 -> 208).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 20:35:07 +02:00
parent 4343d50aa4
commit e117e5a4e5
25 changed files with 1991 additions and 74 deletions
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
// Löscht einen Ist-Satz. Ein Ist-Satz ist eine Beobachtung, keine Planänderung -- deshalb
// gibt es hier weder Versionierung noch Wiederherstellung.
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string; setId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId, setId } = await params;
const set = await prisma.actualsSet.findFirst({
where: { id: setId, planId, plan: { userId } },
});
if (!set) return NextResponse.json({ error: "Datensatz nicht gefunden." }, { status: 404 });
await prisma.actualsSet.delete({ where: { id: setId } });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
// Ein Ist-Wert je Wurzel-Element. Beide Felder optional: Wer eine Zahl nicht kennt, lässt sie
// weg -- die Lücke fällt in der Berechnung auf die Plandaten zurück.
const valueSchema = z.object({
value: z.number().min(-1_000_000_000).max(1_000_000_000).optional(),
mortgage: z.number().min(0).max(1_000_000_000).optional(),
});
const createSchema = z.object({
recordedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Datum im Format JJJJ-MM-TT"),
comment: z.string().max(500).optional(),
cash: z.number().min(-1_000_000_000).max(1_000_000_000).nullable().optional(),
values: z.record(z.string(), valueSchema),
});
async function ownedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({ where: { id: planId, userId } });
}
// Alle Ist-Sätze eines Plans, neueste zuerst.
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await ownedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const rows = await prisma.actualsSet.findMany({
where: { planId },
orderBy: [{ year: "desc" }, { recordedOn: "desc" }],
include: { createdBy: { select: { username: true } } },
});
return NextResponse.json({
sets: rows.map((r) => ({
id: r.id,
recordedOn: r.recordedOn.toISOString().slice(0, 10),
year: r.year,
comment: r.comment,
cash: r.cash,
values: r.values,
author: r.createdBy.username,
createdAt: r.createdAt,
})),
});
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await ownedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { recordedOn, comment, cash, values } = parsed.data;
// Für die Berechnung zählt nur die Jahreszahl -- der Rechenkern arbeitet in ganzen Jahren
// ab Planbeginn. Das exakte Datum bleibt für Liste und Zeitachse erhalten.
const year = Number(recordedOn.slice(0, 4));
const created = await prisma.actualsSet.create({
data: {
planId,
recordedOn: new Date(`${recordedOn}T00:00:00.000Z`),
year,
comment: comment?.trim() || null,
cash: typeof cash === "number" ? cash : null,
values,
createdById: userId,
},
});
return NextResponse.json({ set: { id: created.id, year: created.year } }, { status: 201 });
}