140 lines
4.3 KiB
TypeScript
140 lines
4.3 KiB
TypeScript
import { type NextRequest, NextResponse } from "next/server";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { errorResponse, requireRole, ApiError } from "@/lib/api";
|
|
import { readFileBytes } from "@/lib/files";
|
|
import {
|
|
renderPartTravelers,
|
|
type OperationCardData,
|
|
type PartCoverData,
|
|
} from "@/lib/pdf";
|
|
|
|
/**
|
|
* Admin-only. Return a single PDF containing:
|
|
* Page 1 - cover sheet (part header, file manifest, operation summary)
|
|
* Page 2..N - one traveler card per operation, sequence order.
|
|
*
|
|
* Matches the physical print workflow: staple the stack, the cover goes on
|
|
* top for the project binder and the cards go out to the floor.
|
|
*/
|
|
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
|
try {
|
|
await requireRole("admin");
|
|
const { id } = await ctx.params;
|
|
|
|
const part = await prisma.part.findUnique({
|
|
where: { id },
|
|
include: {
|
|
assembly: {
|
|
include: {
|
|
project: { select: { code: true, name: true } },
|
|
drawingFile: { select: { path: true, originalName: true } },
|
|
},
|
|
},
|
|
stepFile: { select: { originalName: true, sizeBytes: true, sha256: true } },
|
|
drawingFile: {
|
|
select: { originalName: true, sizeBytes: true, sha256: true, path: true },
|
|
},
|
|
cutFile: { select: { originalName: true, sizeBytes: true, sha256: true } },
|
|
operations: {
|
|
orderBy: { sequence: "asc" },
|
|
include: { machine: { select: { name: true, kind: true } } },
|
|
},
|
|
},
|
|
});
|
|
if (!part) throw new ApiError(404, "not_found", "Part not found");
|
|
|
|
if (part.operations.length === 0) {
|
|
throw new ApiError(400, "no_operations", "This part has no operations to print");
|
|
}
|
|
|
|
const project = part.assembly.project;
|
|
const assembly = {
|
|
code: part.assembly.code,
|
|
name: part.assembly.name,
|
|
qty: part.assembly.qty,
|
|
};
|
|
const partHeader = {
|
|
code: part.code,
|
|
name: part.name,
|
|
material: part.material,
|
|
qty: part.qty,
|
|
};
|
|
|
|
// Pull drawing PDFs off disk so pdf-lib can inline them behind the cover /
|
|
// op cards. Missing / unreadable files are logged and skipped — the
|
|
// traveler still prints without them rather than 500ing.
|
|
const drawingPdfBytes = await tryReadPdf(part.drawingFile?.path ?? null);
|
|
const assemblyDrawingPdfBytes = await tryReadPdf(
|
|
part.assembly.drawingFile?.path ?? null,
|
|
);
|
|
|
|
const cover: PartCoverData = {
|
|
project,
|
|
assembly,
|
|
part: { ...partHeader, notes: part.notes },
|
|
files: [
|
|
{ label: "STEP / 3D", file: part.stepFile },
|
|
{ label: "Drawing PDF", file: part.drawingFile },
|
|
{ label: "Cut file", file: part.cutFile },
|
|
],
|
|
operations: part.operations.map((op) => ({
|
|
sequence: op.sequence,
|
|
name: op.name,
|
|
machineName: op.machine?.name ?? null,
|
|
qcRequired: op.qcRequired,
|
|
qrToken: op.qrToken,
|
|
})),
|
|
};
|
|
|
|
const cards: OperationCardData[] = part.operations.map((op) => ({
|
|
project,
|
|
assembly,
|
|
part: partHeader,
|
|
operation: {
|
|
id: op.id,
|
|
sequence: op.sequence,
|
|
name: op.name,
|
|
qrToken: op.qrToken,
|
|
machineName: op.machine?.name ?? null,
|
|
machineKind: op.machine?.kind ?? null,
|
|
settings: op.settings,
|
|
materialNotes: op.materialNotes,
|
|
instructions: op.instructions,
|
|
qcRequired: op.qcRequired,
|
|
plannedMinutes: op.plannedMinutes,
|
|
plannedUnits: op.plannedUnits,
|
|
},
|
|
}));
|
|
|
|
const pdf = await renderPartTravelers({
|
|
cover,
|
|
cards,
|
|
drawingPdfBytes,
|
|
assemblyDrawingPdfBytes,
|
|
});
|
|
const safeName = `${part.code}-travelers.pdf`.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
|
|
return new NextResponse(pdf as unknown as BodyInit, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "application/pdf",
|
|
"Content-Disposition": `inline; filename="${safeName}"`,
|
|
"Cache-Control": "private, no-store",
|
|
},
|
|
});
|
|
} catch (err) {
|
|
return errorResponse(err);
|
|
}
|
|
}
|
|
|
|
async function tryReadPdf(path: string | null): Promise<Uint8Array | null> {
|
|
if (!path) return null;
|
|
try {
|
|
const buf = await readFileBytes(path);
|
|
return new Uint8Array(buf);
|
|
} catch (err) {
|
|
console.warn("[travelers.pdf] could not read drawing file", { path, err });
|
|
return null;
|
|
}
|
|
}
|