Neuer Unterpunkt "Berichte" je Plan: Liste plus Assistent (Titel, Notiz, nominal ODER real, Plan-/Ist-Daten, bis zu drei Szenarien, gespeicherte Analysen). Layout immer gleich, Auswahl bestimmt nur die Bausteine. Die PDF-Datei wird ALS DATEI abgelegt (BYTEA in Postgres, nicht im Container-Dateisystem): Ein Bericht muss in drei Jahren byte-identisch wieder herunterladbar sein -- eine Neuerzeugung koennte das nach Aenderungen an Plan, Rechenkern oder Layout nicht garantieren. Kennzahlen je Szenario inkl. offener Entscheide. Deren Zaehlung liegt neu als reine Funktion in decisions.ts, die Matrix UND Bericht benutzen -- sonst nennen beide verschiedene Zahlen. Zu jeder Kennzahl ihre Grundlage als Verweis; die vollstaendigen Annahmen einmal je Szenario. Haftungsausschluss ist verpflichtend (per Test). Technik: pdfkit in der Node-Runtime statt Headless-Browser; @react-pdf/renderer bricht mit React 19. Als externes Paket deklariert, weil pdfkit Font-Metriken ueber Dateipfade laedt. Spezifikation 0.25 (3.11 neu), 9 Tests (212 -> 221). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// Liefert die GESPEICHERTE PDF-Datei. Sie wird nicht neu erzeugt -- das ist der Kern des
|
||||
// Audit-Trails: derselbe Bericht ergibt in drei Jahren dieselben Bytes.
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string; reportId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId, reportId } = await params;
|
||||
|
||||
const report = await prisma.report.findFirst({
|
||||
where: { id: reportId, planId, plan: { userId } },
|
||||
select: { title: true, pdf: true, createdAt: true },
|
||||
});
|
||||
if (!report) return NextResponse.json({ error: "Bericht nicht gefunden." }, { status: 404 });
|
||||
|
||||
const date = report.createdAt.toISOString().slice(0, 10);
|
||||
const safe = report.title.replace(/[^\w\s.-]/g, "").trim().replace(/\s+/g, "_") || "Bericht";
|
||||
|
||||
return new NextResponse(new Uint8Array(report.pdf), {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${date}_${safe}.pdf"`,
|
||||
"Content-Length": String(report.pdf.length),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string; reportId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId, reportId } = await params;
|
||||
|
||||
const report = await prisma.report.findFirst({ where: { id: reportId, planId, plan: { userId } } });
|
||||
if (!report) return NextResponse.json({ error: "Bericht nicht gefunden." }, { status: 404 });
|
||||
|
||||
await prisma.report.delete({ where: { id: reportId } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { planInclude, toPlanInput } from "@/lib/queries";
|
||||
import { buildReport, MAX_REPORT_SCENARIOS, type ReportConfig } from "@/lib/report";
|
||||
import { renderReportPdf } from "@/lib/report-pdf";
|
||||
import type { ActualsSetInput } from "@/lib/actuals";
|
||||
|
||||
// pdfkit braucht die Node-Runtime (Streams, Buffer) -- nicht Edge.
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const createSchema = z.object({
|
||||
title: z.string().min(1).max(160),
|
||||
metric: z.enum(["nominal", "real"]),
|
||||
source: z.enum(["PLAN", "ACTUAL"]),
|
||||
scenarioIds: z.array(z.string()).min(1).max(MAX_REPORT_SCENARIOS),
|
||||
analysisIds: z.array(z.string()).max(20).default([]),
|
||||
comment: z.string().max(500).nullish(),
|
||||
});
|
||||
|
||||
async function ownedPlan(planId: string, userId: string) {
|
||||
return prisma.plan.findFirst({ where: { id: planId, userId } });
|
||||
}
|
||||
|
||||
// Liste der Berichte -- ohne die PDF-Bytes (die kommen erst beim Herunterladen).
|
||||
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.report.findMany({
|
||||
where: { planId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
config: true,
|
||||
pdfBytes: true,
|
||||
createdAt: true,
|
||||
createdBy: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
reports: rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
config: r.config,
|
||||
pdfBytes: r.pdfBytes,
|
||||
createdAt: r.createdAt,
|
||||
author: r.createdBy.username,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Erzeugt den Bericht: rechnet, baut das Modell, rendert das PDF und legt beides ab.
|
||||
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 parsed = createSchema.safeParse(await request.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
const cfg = parsed.data;
|
||||
|
||||
const plan = await prisma.plan.findFirst({
|
||||
where: { id: planId, userId },
|
||||
include: {
|
||||
persons: { orderBy: { role: "asc" } },
|
||||
scenarios: { orderBy: [{ isBase: "desc" }, { createdAt: "asc" }], include: planInclude },
|
||||
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
|
||||
},
|
||||
});
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
// Nur Szenarien dieses Plans, in der Reihenfolge der Auswahl -- Basis zuerst.
|
||||
const chosen = cfg.scenarioIds
|
||||
.map((id) => plan.scenarios.find((s) => s.id === id))
|
||||
.filter((s): s is (typeof plan.scenarios)[number] => !!s)
|
||||
.sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
|
||||
if (chosen.length === 0) {
|
||||
return NextResponse.json({ error: "Kein gültiges Szenario ausgewählt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const analyses =
|
||||
cfg.analysisIds.length === 0
|
||||
? []
|
||||
: await prisma.savedAnalysis.findMany({
|
||||
where: { id: { in: cfg.analysisIds }, planId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { name: true, type: true, result: true },
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { username: true } });
|
||||
|
||||
const config: ReportConfig = {
|
||||
title: cfg.title,
|
||||
metric: cfg.metric,
|
||||
source: cfg.source,
|
||||
scenarioIds: chosen.map((s) => s.id),
|
||||
analysisIds: cfg.analysisIds,
|
||||
comment: cfg.comment ?? null,
|
||||
};
|
||||
|
||||
const model = buildReport({
|
||||
planName: plan.name,
|
||||
author: user?.username ?? "unbekannt",
|
||||
createdAt: new Date(),
|
||||
config,
|
||||
scenarios: chosen.map((s) => ({ name: s.name, isBase: s.isBase, plan: toPlanInput(s) })),
|
||||
household: {
|
||||
householdType: plan.householdType,
|
||||
startYear: plan.startYear,
|
||||
persons: plan.persons.map((p) => ({ name: p.name, age: p.age, role: p.role })),
|
||||
},
|
||||
actuals: plan.actuals.map(
|
||||
(a): ActualsSetInput => ({
|
||||
id: a.id,
|
||||
recordedOn: a.recordedOn.toISOString().slice(0, 10),
|
||||
year: a.year,
|
||||
comment: a.comment,
|
||||
cash: a.cash,
|
||||
values: a.values as Record<string, { value?: number; mortgage?: number }>,
|
||||
})
|
||||
),
|
||||
origins: plan.scenarios.flatMap((s) => s.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId }))),
|
||||
analyses: analyses.map((a) => ({ name: a.name, type: a.type, result: a.result })),
|
||||
});
|
||||
|
||||
const pdf = await renderReportPdf(model);
|
||||
|
||||
const created = await prisma.report.create({
|
||||
data: {
|
||||
planId,
|
||||
title: cfg.title,
|
||||
config: config as unknown as object,
|
||||
model: model as unknown as object,
|
||||
pdf: new Uint8Array(pdf),
|
||||
pdfBytes: pdf.length,
|
||||
createdById: userId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ report: { id: created.id, bytes: pdf.length } }, { status: 201 });
|
||||
}
|
||||
Reference in New Issue
Block a user