Bericht: vollstaendige Fehlerabsicherung + robustes Laden von pdfkit
Deploy App / deploy (push) Successful in 1m2s
Deploy App / deploy (push) Successful in 1m2s
Der gemeldete 500 kam OHNE JSON-Koerper -- der Client konnte deshalb nur "Fehler 500" zeigen. Ursache: Der Absturz lag ausserhalb der bisherigen try/catch-Bloecke. Jetzt liegt der GESAMTE Handler in einem try/catch, jede Ursache wird protokolliert und im Klartext zurueckgegeben. Zusaetzlich zwei Risiken entfernt: - Die verschachtelte Abfrage (Plan -> Szenarien -> Plan -> Personen) war ein Ringbezug; Szenarien werden jetzt einzeln nachgeladen. - pdfkit wird ueber einen Resolver geholt, der statischen Import, .default und createRequire durchprobiert -- unabhaengig davon, wie der Bundler CJS-Interop aufloest. Neuer Test mit realistischem Plan (Immobilie, Schuld, AHV, PK, 3a, Kopie-Szenario) -- laeuft lokal durch, schliesst die Datenform als Ursache aus. 223 Tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -67,24 +67,40 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||||
const cfg = parsed.data;
|
const cfg = parsed.data;
|
||||||
|
|
||||||
|
// Der GESAMTE weitere Ablauf liegt in einem try/catch. Ein unbehandelter Fehler ergäbe ein
|
||||||
|
// nacktes 500 ohne JSON-Körper -- der Client könnte dann nur «Fehler 500» melden, und die
|
||||||
|
// Ursache bliebe im Dunkeln.
|
||||||
|
try {
|
||||||
const plan = await prisma.plan.findFirst({
|
const plan = await prisma.plan.findFirst({
|
||||||
where: { id: planId, userId },
|
where: { id: planId, userId },
|
||||||
include: {
|
include: {
|
||||||
persons: { orderBy: { role: "asc" } },
|
persons: { orderBy: { role: "asc" } },
|
||||||
scenarios: { orderBy: [{ isBase: "desc" }, { createdAt: "asc" }], include: planInclude },
|
scenarios: { orderBy: [{ isBase: "desc" }, { createdAt: "asc" }], select: { id: true, name: true, isBase: true } },
|
||||||
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
|
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
// Nur Szenarien dieses Plans, in der Reihenfolge der Auswahl -- Basis zuerst.
|
// Szenarien einzeln nachladen statt über eine verschachtelte Abfrage: Plan -> Szenarien ->
|
||||||
const chosen = cfg.scenarioIds
|
// Plan -> Personen wäre ein Ringbezug, und ein paar Abfragen mehr sind hier belanglos.
|
||||||
.map((id) => plan.scenarios.find((s) => s.id === id))
|
const wanted = plan.scenarios
|
||||||
.filter((s): s is (typeof plan.scenarios)[number] => !!s)
|
.filter((s) => cfg.scenarioIds.includes(s.id))
|
||||||
.sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
|
.sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
|
||||||
if (chosen.length === 0) {
|
if (wanted.length === 0) {
|
||||||
return NextResponse.json({ error: "Kein gültiges Szenario ausgewählt." }, { status: 400 });
|
return NextResponse.json({ error: "Kein gültiges Szenario ausgewählt." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const chosen = (
|
||||||
|
await Promise.all(wanted.map((s) => prisma.scenario.findUnique({ where: { id: s.id }, include: planInclude })))
|
||||||
|
).filter((s): s is NonNullable<typeof s> => !!s);
|
||||||
|
if (chosen.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Szenarien konnten nicht geladen werden." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element-Herkunft aller Szenarien -- Grundlage der Ist-Zuordnung.
|
||||||
|
const allElements = await prisma.financialElement.findMany({
|
||||||
|
where: { scenario: { planId } },
|
||||||
|
select: { id: true, sourceElementId: true },
|
||||||
|
});
|
||||||
|
|
||||||
const analyses =
|
const analyses =
|
||||||
cfg.analysisIds.length === 0
|
cfg.analysisIds.length === 0
|
||||||
@@ -127,26 +143,12 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
values: a.values as Record<string, { value?: number; mortgage?: number }>,
|
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 }))),
|
origins: allElements,
|
||||||
analyses: analyses.map((a) => ({ name: a.name, type: a.type, result: a.result })),
|
analyses: analyses.map((a) => ({ name: a.name, type: a.type, result: a.result })),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rendern und Ablegen sind die beiden Schritte, die zur Laufzeit scheitern können
|
const pdf = await renderReportPdf(model);
|
||||||
// (Schriftdaten, Grösse des Datensatzes). Ein nacktes 500 wäre hier nicht diagnostizierbar,
|
|
||||||
// deshalb wird die Ursache protokolliert UND zurückgegeben -- es ist ein Ein-Personen-
|
|
||||||
// Werkzeug, und der Nutzer ist der Einzige, der den Fehler melden kann.
|
|
||||||
let pdf: Buffer;
|
|
||||||
try {
|
|
||||||
pdf = await renderReportPdf(model);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[reports] PDF-Erzeugung fehlgeschlagen", err);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `PDF-Erzeugung fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}` },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const created = await prisma.report.create({
|
const created = await prisma.report.create({
|
||||||
data: {
|
data: {
|
||||||
planId,
|
planId,
|
||||||
@@ -161,10 +163,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
});
|
});
|
||||||
return NextResponse.json({ report: { id: created.id, bytes: pdf.length } }, { status: 201 });
|
return NextResponse.json({ report: { id: created.id, bytes: pdf.length } }, { status: 201 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[reports] Speichern fehlgeschlagen", err);
|
// Jede Ursache landet hier -- Datenbank, Modellaufbau, Schriftdaten, Speichern.
|
||||||
return NextResponse.json(
|
console.error("[reports] Bericht fehlgeschlagen", err);
|
||||||
{ error: `Bericht konnte nicht gespeichert werden: ${err instanceof Error ? err.message : String(err)}` },
|
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
||||||
{ status: 500 }
|
return NextResponse.json({ error: `Bericht fehlgeschlagen -- ${message}` }, { status: 500 });
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-7
@@ -7,15 +7,42 @@
|
|||||||
// Die eingebauten Schriften (Helvetica) decken WinAnsi ab und damit alle deutschen
|
// Die eingebauten Schriften (Helvetica) decken WinAnsi ab und damit alle deutschen
|
||||||
// Umlaute; ein Font-Embedding ist nicht nötig.
|
// Umlaute; ein Font-Embedding ist nicht nötig.
|
||||||
|
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import PDFDocumentModule from "pdfkit";
|
import PDFDocumentModule from "pdfkit";
|
||||||
import type { KeyFigure, ReportChart, ReportModel, ReportTable } from "@/lib/report";
|
import type { KeyFigure, ReportChart, ReportModel, ReportTable } from "@/lib/report";
|
||||||
|
|
||||||
// pdfkit ist CommonJS und wird als EXTERNES Paket geladen (siehe next.config.ts). Je nach
|
// pdfkit ist CommonJS, wird als EXTERNES Paket geladen (siehe next.config.ts) und liest seine
|
||||||
// Interop des Bundlers kommt der Konstruktor direkt oder unter `.default` an. Trifft man die
|
// Schriftmetriken zur Laufzeit über Dateipfade. Je nach Interop des Bundlers kommt der
|
||||||
// falsche Form, gelingt der Import trotzdem -- und erst `new PDFDocument()` scheitert zur
|
// Konstruktor direkt, unter `.default` -- oder gar nicht. Der Import gelingt dabei immer;
|
||||||
// Laufzeit. Deshalb hier beide Formen akzeptieren.
|
// erst `new PDFDocument()` scheitert. Deshalb wird die brauchbare Form hier einmal ermittelt,
|
||||||
const PDFDocument = ((PDFDocumentModule as unknown as { default?: typeof PDFDocumentModule }).default ??
|
// mit `createRequire` als letzter Rückfallebene (lädt garantiert aus node_modules).
|
||||||
PDFDocumentModule) as typeof PDFDocumentModule;
|
type PdfCtor = typeof PDFDocumentModule;
|
||||||
|
|
||||||
|
let ctor: PdfCtor | null = null;
|
||||||
|
function pdfConstructor(): PdfCtor {
|
||||||
|
if (ctor) return ctor;
|
||||||
|
const candidates: unknown[] = [
|
||||||
|
PDFDocumentModule,
|
||||||
|
(PDFDocumentModule as unknown as { default?: unknown })?.default,
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
candidates.push(createRequire(import.meta.url)("pdfkit"));
|
||||||
|
} catch {
|
||||||
|
// Kein createRequire verfügbar -- dann müssen die statischen Formen genügen.
|
||||||
|
}
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (typeof c === "function") {
|
||||||
|
ctor = c as PdfCtor;
|
||||||
|
return ctor;
|
||||||
|
}
|
||||||
|
const d = (c as { default?: unknown })?.default;
|
||||||
|
if (typeof d === "function") {
|
||||||
|
ctor = d as PdfCtor;
|
||||||
|
return ctor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("pdfkit konnte nicht geladen werden (kein Konstruktor gefunden).");
|
||||||
|
}
|
||||||
|
|
||||||
const A4 = { width: 595.28, height: 841.89 };
|
const A4 = { width: 595.28, height: 841.89 };
|
||||||
const M = 56; // Seitenrand
|
const M = 56; // Seitenrand
|
||||||
@@ -34,7 +61,7 @@ const COLORS = {
|
|||||||
|
|
||||||
const SERIES_COLORS = ["#4f46e5", "#0ea5e9", "#16a34a"];
|
const SERIES_COLORS = ["#4f46e5", "#0ea5e9", "#16a34a"];
|
||||||
|
|
||||||
type Doc = InstanceType<typeof PDFDocument>;
|
type Doc = InstanceType<PdfCtor>;
|
||||||
|
|
||||||
// --- Grundbausteine ----------------------------------------------------------------------
|
// --- Grundbausteine ----------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -224,6 +251,7 @@ function lineChart(doc: Doc, chart: ReportChart) {
|
|||||||
// --- Bericht -----------------------------------------------------------------------------
|
// --- Bericht -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export function renderReportPdf(model: ReportModel): Promise<Buffer> {
|
export function renderReportPdf(model: ReportModel): Promise<Buffer> {
|
||||||
|
const PDFDocument = pdfConstructor();
|
||||||
const doc = new PDFDocument({
|
const doc = new PDFDocument({
|
||||||
size: "A4",
|
size: "A4",
|
||||||
margins: { top: M, bottom: M, left: M, right: M },
|
margins: { top: M, bottom: M, left: M, right: M },
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { buildReport } from "@/lib/report";
|
||||||
|
import { renderReportPdf } from "@/lib/report-pdf";
|
||||||
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
|
// Realistischer Plan: Paar, Immobilie mit Hypothek, Schuld, AHV, PK, 3a, ETF -- also
|
||||||
|
// deutlich mehr Element-Arten als im ersten Test.
|
||||||
|
function realistic(): PlanInput {
|
||||||
|
return {
|
||||||
|
id: "s1", name: "Basisszenario", householdType: "COUPLE",
|
||||||
|
inflationRateDefault: 1.5, initialCash: 25000, startYear: 2026,
|
||||||
|
persons: [
|
||||||
|
{ id: "A", role: "PERSON_A", name: "Anna", age: 45, retirementAge: 65 },
|
||||||
|
{ id: "B", role: "PERSON_B", name: "Beat", age: 43, retirementAge: 64 },
|
||||||
|
],
|
||||||
|
phases: [
|
||||||
|
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} },
|
||||||
|
{ id: "p2", sequenceNumber: 2, name: "Mischphase", durationYears: 2, cashTransition: {} },
|
||||||
|
{ id: "p3", sequenceNumber: 3, name: "Pension", durationYears: 20, cashTransition: {} },
|
||||||
|
],
|
||||||
|
elements: [
|
||||||
|
{ id: "i1", category: "INCOME", name: "Lohn Anna", ownerRole: "PERSON_A", orderIndex: 1,
|
||||||
|
phaseValues: { p1: { amount: 130000, teuerungsausgleich: 1 } }, transitionValues: {}, sourceElementId: null },
|
||||||
|
{ id: "i2", category: "INCOME", name: "Lohn Beat", ownerRole: "PERSON_B", orderIndex: 2,
|
||||||
|
phaseValues: { p1: { amount: 95000, teuerungsausgleich: 1 } }, transitionValues: {}, sourceElementId: null },
|
||||||
|
{ id: "e1", category: "EXPENSE", name: "Lebenshaltung", ownerRole: "HOUSEHOLD", orderIndex: 3,
|
||||||
|
phaseValues: { p1: { amount: 105000 }, p3: { amount: 90000 } }, transitionValues: {}, sourceElementId: null },
|
||||||
|
{ id: "ahvA", category: "AHV", name: "AHV Anna", ownerRole: "PERSON_A", orderIndex: 4,
|
||||||
|
phaseValues: { p1: { gapYears: 0 } }, transitionValues: {}, sourceElementId: null },
|
||||||
|
{ id: "ahvB", category: "AHV", name: "AHV Beat", ownerRole: "PERSON_B", orderIndex: 5,
|
||||||
|
phaseValues: { p1: { gapYears: 2 } }, transitionValues: {}, sourceElementId: null },
|
||||||
|
{ id: "pkA", category: "PENSION_FUND", name: "PK Anna", ownerRole: "PERSON_A", orderIndex: 6,
|
||||||
|
phaseValues: { p1: { currentValue: 340000, expectedReturn: 2, annualContribution: 14000 } },
|
||||||
|
transitionValues: {}, sourceElementId: null },
|
||||||
|
{ id: "s3a", category: "PILLAR_3A", name: "Säule 3a", ownerRole: "PERSON_A", orderIndex: 7,
|
||||||
|
phaseValues: { p1: { currentValue: 90000, expectedReturn: 2.5, annualContribution: 7056 } },
|
||||||
|
transitionValues: {}, sourceElementId: null },
|
||||||
|
{ id: "haus", category: "REAL_ESTATE", name: "Wohneigentum", ownerRole: "HOUSEHOLD", orderIndex: 8,
|
||||||
|
phaseValues: { p1: { purchasePrice: 1100000, mortgage: 750000, amortization: 12000,
|
||||||
|
valueGrowth: 1, interestRate: 1.8, interestHandling: "ADD" } },
|
||||||
|
transitionValues: {}, sourceElementId: null },
|
||||||
|
{ id: "debt", category: "OTHER_DEBT", name: "Privatdarlehen", ownerRole: "HOUSEHOLD", orderIndex: 9,
|
||||||
|
phaseValues: { p1: { startValue: 40000, annualRepayment: 5000 } }, transitionValues: {}, sourceElementId: null },
|
||||||
|
{ id: "etf", category: "OTHER_ASSET", name: "ETF-Depot", ownerRole: "HOUSEHOLD", orderIndex: 10,
|
||||||
|
phaseValues: { p1: { startValue: 160000, expectedReturn: 5, annualContribution: 10000 },
|
||||||
|
p3: { expectedReturn: 4, annualWithdrawal: 30000 } },
|
||||||
|
transitionValues: {}, sourceElementId: null },
|
||||||
|
],
|
||||||
|
} as unknown as PlanInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Reproduktion: realistischer Plan", () => {
|
||||||
|
it("baut Modell und PDF ohne Absturz", async () => {
|
||||||
|
const p = realistic();
|
||||||
|
// Kind-Szenario wie beim Kopieren: eigene IDs, Herkunftsverweise.
|
||||||
|
const child = {
|
||||||
|
...p, id: "s2", name: "Tiefere Rendite",
|
||||||
|
elements: p.elements.map((e) => ({ ...e, id: e.id + "-k", sourceElementId: e.id })),
|
||||||
|
} as unknown as PlanInput;
|
||||||
|
|
||||||
|
const m = buildReport({
|
||||||
|
planName: "Meine Planung", author: "kelle", createdAt: new Date(),
|
||||||
|
config: { title: "Bericht", metric: "nominal", source: "PLAN", scenarioIds: ["s1", "s2"], analysisIds: [], comment: null },
|
||||||
|
scenarios: [
|
||||||
|
{ name: "Basisszenario", isBase: true, plan: p },
|
||||||
|
{ name: "Tiefere Rendite", isBase: false, plan: child },
|
||||||
|
],
|
||||||
|
household: { householdType: "COUPLE", startYear: 2026,
|
||||||
|
persons: [{ name: "Anna", age: 45, role: "PERSON_A" }, { name: "Beat", age: 43, role: "PERSON_B" }] },
|
||||||
|
actuals: [], origins: [], analyses: [],
|
||||||
|
});
|
||||||
|
expect(m.scenarios).toHaveLength(2);
|
||||||
|
const pdf = await renderReportPdf(m);
|
||||||
|
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user