Files
mrp-qrcode/app/api/v1/projects/[id]/travelers.pdf/route.ts
T
jason 04ae88ca0d
Build and Push Docker Image / build (push) Successful in 45s
QoL changes and additions
2026-04-22 13:16:42 -05:00

185 lines
6.1 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 {
renderProjectTravelers,
type OperationCardData,
type PartCoverData,
} from "@/lib/pdf";
/**
* Admin-only. Bulk traveler PDF for an entire project — one megafile
* containing every part's cover + drawings + op cards, prefixed with a
* project-level summary sheet.
*
* Query params:
* ?scope=all (default) every part with at least one operation
* ?scope=incomplete only parts that still have pending / partial /
* in_progress / qc_failed ops (i.e. what's left to run)
*
* Parts with zero operations are always skipped — they'd render an empty
* card anyway. Drawings are inlined best-effort (missing files are logged
* and the PDF still renders).
*/
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
await requireRole("admin");
const { id } = await ctx.params;
const scope = (new URL(req.url).searchParams.get("scope") ?? "all").toLowerCase();
if (scope !== "all" && scope !== "incomplete") {
throw new ApiError(400, "bad_scope", "scope must be 'all' or 'incomplete'");
}
const project = await prisma.project.findUnique({
where: { id },
select: { id: true, code: true, name: true, customerCode: true, dueDate: true },
});
if (!project) throw new ApiError(404, "not_found", "Project not found");
const parts = await prisma.part.findMany({
where: {
assembly: { projectId: id },
...(scope === "incomplete"
? {
operations: {
some: {
status: { in: ["pending", "partial", "in_progress", "qc_failed"] },
},
},
}
: {}),
},
orderBy: [{ assembly: { code: "asc" } }, { code: "asc" }],
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 } } },
},
},
});
const eligible = parts.filter((p) => p.operations.length > 0);
if (eligible.length === 0) {
throw new ApiError(
400,
"no_parts",
scope === "incomplete"
? "No parts with outstanding operations in this project"
: "No parts with operations in this project",
);
}
const bundles = await Promise.all(
eligible.map(async (part) => {
const projectHead = { code: part.assembly.project.code, name: part.assembly.project.name };
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,
};
const drawingPdfBytes = await tryReadPdf(part.drawingFile?.path ?? null);
const assemblyDrawingPdfBytes = await tryReadPdf(part.assembly.drawingFile?.path ?? null);
const cover: PartCoverData = {
project: projectHead,
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,
kind: op.kind,
machineName: op.machine?.name ?? null,
qcRequired: op.qcRequired,
qrToken: op.qrToken,
unitsCompleted: op.unitsCompleted,
status: op.status,
})),
};
const cards: OperationCardData[] = part.operations.map((op) => ({
project: projectHead,
assembly,
part: partHeader,
operation: {
id: op.id,
sequence: op.sequence,
name: op.name,
kind: op.kind,
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,
unitsCompleted: op.unitsCompleted,
status: op.status,
},
}));
return { cover, cards, drawingPdfBytes, assemblyDrawingPdfBytes };
}),
);
const pdf = await renderProjectTravelers({
project: {
code: project.code,
name: project.name,
customerCode: project.customerCode,
dueDate: project.dueDate,
},
bundles,
});
const suffix = scope === "incomplete" ? "-incomplete" : "";
const safeName = `${project.code}${suffix}-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("[projects/travelers.pdf] could not read drawing file", { path, err });
return null;
}
}