stage 5-6
Build and Push Docker Image / build (push) Successful in 1m11s

This commit is contained in:
jason
2026-04-21 13:14:27 -05:00
parent fc5bce4868
commit 5847a175af
26 changed files with 3031 additions and 29 deletions
+803
View File
@@ -0,0 +1,803 @@
import { PDFDocument, StandardFonts, rgb, type PDFFont, type PDFPage } from "pdf-lib";
import { renderQrPngBuffer, scanUrlForToken } from "@/lib/qr";
/**
* Traveler PDF generation. Shipped as pdf-lib (pure JS, no system deps) so
* the Docker image stays slim. Two public entry points:
*
* - renderOperationCard(op) -> single Letter page, big QR
* - renderPartTravelers({cover, ops}) -> cover sheet + one card per op
*
* Everything else in this file is layout glue. Nothing here hits the DB — the
* route handlers load the data and hand fully-denormalised structs down so
* this module stays easy to unit-test later.
*/
// Letter @ 72 dpi. Points (pt) are the native pdf-lib unit.
const PAGE_WIDTH = 612;
const PAGE_HEIGHT = 792;
const MARGIN = 48; // 2/3"
export interface OperationCardData {
project: { code: string; name: string };
assembly: { code: string; name: string };
part: { code: string; name: string; material: string | null; qty: number };
operation: {
id: string;
sequence: number;
name: string;
qrToken: string;
machineName: string | null;
machineKind: string | null;
settings: string | null;
materialNotes: string | null;
instructions: string | null;
qcRequired: boolean;
plannedMinutes: number | null;
plannedUnits: number | null;
};
}
export interface PurchaseOrderPdfData {
po: {
id: string;
vendor: string;
status: string;
createdAt: Date;
sentAt: Date | null;
notes: string | null;
};
project: { code: string; name: string };
lines: {
partNumber: string;
description: string;
supplier: string | null;
qty: number;
unitCost: number | null;
}[];
}
export interface PartCoverData {
project: { code: string; name: string };
assembly: { code: string; name: string };
part: {
code: string;
name: string;
material: string | null;
qty: number;
notes: string | null;
};
files: {
label: string;
file: { originalName: string; sizeBytes: number; sha256: string } | null;
}[];
operations: {
sequence: number;
name: string;
machineName: string | null;
qcRequired: boolean;
qrToken: string;
}[];
}
/** Entry point: render a single operation card as a PDF byte array. */
export async function renderOperationCard(data: OperationCardData): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const fonts = await embedFonts(doc);
const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
await drawOperationCard(doc, page, fonts, data);
return doc.save();
}
/** Entry point: render a purchase order PDF for sending to a vendor. */
export async function renderPurchaseOrder(data: PurchaseOrderPdfData): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const fonts = await embedFonts(doc);
const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
drawPurchaseOrder(page, fonts, data);
return doc.save();
}
/** Entry point: cover sheet + every operation card, all in one PDF. */
export async function renderPartTravelers(payload: {
cover: PartCoverData;
cards: OperationCardData[];
}): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const fonts = await embedFonts(doc);
const coverPage = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
await drawCoverSheet(doc, coverPage, fonts, payload.cover);
for (const card of payload.cards) {
const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
await drawOperationCard(doc, page, fonts, card);
}
return doc.save();
}
// ---------------------------------------------------------------------------
// Layout helpers
// ---------------------------------------------------------------------------
interface Fonts {
regular: PDFFont;
bold: PDFFont;
mono: PDFFont;
}
async function embedFonts(doc: PDFDocument): Promise<Fonts> {
return {
regular: await doc.embedFont(StandardFonts.Helvetica),
bold: await doc.embedFont(StandardFonts.HelveticaBold),
mono: await doc.embedFont(StandardFonts.Courier),
};
}
// pdf-lib uses a Y-up coordinate system anchored at the page's bottom-left.
// We track the cursor from the top to keep drawing code readable; `top` is
// the y-coordinate of the next line's baseline in pt.
interface Cursor {
top: number;
}
function drawText(
page: PDFPage,
text: string,
opts: { x: number; y: number; font: PDFFont; size: number; color?: ReturnType<typeof rgb> },
): void {
page.drawText(text, {
x: opts.x,
y: opts.y,
font: opts.font,
size: opts.size,
color: opts.color ?? rgb(0.07, 0.09, 0.15),
});
}
// Break a long string into lines that fit a given width, honouring \n. We
// measure in pt with the font's width table so it actually matches the output.
function wrapLines(text: string, font: PDFFont, size: number, maxWidth: number): string[] {
const out: string[] = [];
for (const rawLine of text.split(/\r?\n/)) {
if (rawLine.length === 0) {
out.push("");
continue;
}
const words = rawLine.split(/\s+/);
let line = "";
for (const word of words) {
const candidate = line.length === 0 ? word : `${line} ${word}`;
if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
line = candidate;
} else {
if (line.length > 0) out.push(line);
// Word itself overflows — hard-chop so we don't stall.
if (font.widthOfTextAtSize(word, size) > maxWidth) {
let chunk = "";
for (const ch of word) {
if (font.widthOfTextAtSize(chunk + ch, size) > maxWidth) {
out.push(chunk);
chunk = ch;
} else {
chunk += ch;
}
}
line = chunk;
} else {
line = word;
}
}
}
if (line.length > 0) out.push(line);
}
return out;
}
// ---------------------------------------------------------------------------
// Operation card
// ---------------------------------------------------------------------------
async function drawOperationCard(
doc: PDFDocument,
page: PDFPage,
fonts: Fonts,
data: OperationCardData,
): Promise<void> {
const cursor: Cursor = { top: PAGE_HEIGHT - MARGIN };
const contentWidth = PAGE_WIDTH - MARGIN * 2;
// --- Header strip: project / assembly ------------------------------------
drawText(page, "TRAVELER CARD", {
x: MARGIN,
y: cursor.top - 10,
font: fonts.bold,
size: 10,
color: rgb(0.4, 0.4, 0.45),
});
drawText(page, `${data.project.code} · ${data.assembly.code}`, {
x: PAGE_WIDTH - MARGIN,
y: cursor.top - 10,
font: fonts.regular,
size: 10,
color: rgb(0.4, 0.4, 0.45),
});
// Right-align fix: pdf-lib doesn't do alignment for us, so re-measure.
const rightLabel = `${data.project.code} · ${data.assembly.code}`;
const rightLabelW = fonts.regular.widthOfTextAtSize(rightLabel, 10);
page.drawRectangle({
x: MARGIN,
y: cursor.top - 16,
width: contentWidth,
height: 0,
color: rgb(1, 1, 1),
});
// Redraw the right label at the correct x now that we know its width.
page.drawRectangle({
x: PAGE_WIDTH - MARGIN - rightLabelW - 1,
y: cursor.top - 14,
width: rightLabelW + 2,
height: 14,
color: rgb(1, 1, 1),
});
drawText(page, rightLabel, {
x: PAGE_WIDTH - MARGIN - rightLabelW,
y: cursor.top - 10,
font: fonts.regular,
size: 10,
color: rgb(0.4, 0.4, 0.45),
});
cursor.top -= 20;
// --- Part name (big) ----------------------------------------------------
const partTitle = data.part.name;
const partLines = wrapLines(partTitle, fonts.bold, 22, contentWidth);
for (const line of partLines) {
cursor.top -= 24;
drawText(page, line, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 22 });
}
cursor.top -= 14;
const partMeta = [
`Part ${data.part.code}`,
data.part.material ? `${data.part.material}` : null,
`qty ${data.part.qty}`,
]
.filter(Boolean)
.join(" · ");
drawText(page, partMeta, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 11,
color: rgb(0.35, 0.4, 0.5),
});
cursor.top -= 18;
// Divider
page.drawLine({
start: { x: MARGIN, y: cursor.top },
end: { x: PAGE_WIDTH - MARGIN, y: cursor.top },
thickness: 0.75,
color: rgb(0.8, 0.82, 0.88),
});
cursor.top -= 24;
// --- Step header + QR side-by-side --------------------------------------
// Left column: step label, name, machine, plan. Right column: QR image.
const qrSize = 156; // pt -> ~2.2" on paper, well above the 20 mm phone minimum
const qrX = PAGE_WIDTH - MARGIN - qrSize;
const qrY = cursor.top - qrSize;
const qrBytes = await renderQrPngBuffer(data.operation.qrToken);
const qrImage = await doc.embedPng(qrBytes);
page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize });
// Text under QR: scan URL + last 8 of token for manual lookup if it smudges.
const scanUrl = scanUrlForToken(data.operation.qrToken);
const scanLines = wrapLines(scanUrl, fonts.mono, 7, qrSize);
let scanY = qrY - 10;
for (const l of scanLines) {
drawText(page, l, {
x: qrX,
y: scanY,
font: fonts.mono,
size: 7,
color: rgb(0.4, 0.4, 0.45),
});
scanY -= 9;
}
// Left column content, constrained so it doesn't collide with the QR.
const leftColWidth = qrX - MARGIN - 18;
drawText(page, `STEP ${data.operation.sequence}`, {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 11,
color: rgb(0.38, 0.45, 0.85),
});
cursor.top -= 6;
const stepNameLines = wrapLines(data.operation.name, fonts.bold, 18, leftColWidth);
for (const line of stepNameLines) {
cursor.top -= 22;
drawText(page, line, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 18 });
}
cursor.top -= 10;
const metaRows: [string, string][] = [];
if (data.operation.machineName) {
metaRows.push([
"Machine",
data.operation.machineKind
? `${data.operation.machineName} (${data.operation.machineKind})`
: data.operation.machineName,
]);
}
if (data.operation.plannedMinutes) metaRows.push(["Planned time", `${data.operation.plannedMinutes} min`]);
if (data.operation.plannedUnits) metaRows.push(["Planned units", `${data.operation.plannedUnits}`]);
if (data.operation.qcRequired) metaRows.push(["QC", "Required on close-out"]);
for (const [k, v] of metaRows) {
cursor.top -= 13;
drawText(page, k.toUpperCase(), {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 7,
color: rgb(0.45, 0.5, 0.6),
});
const valueLines = wrapLines(v, fonts.regular, 11, leftColWidth);
for (let i = 0; i < valueLines.length; i++) {
if (i > 0) cursor.top -= 13;
drawText(page, valueLines[i], {
x: MARGIN + 80,
y: cursor.top,
font: fonts.regular,
size: 11,
});
}
}
// Align the cursor below whichever column is taller (QR column vs. left).
cursor.top = Math.min(cursor.top, qrY - 10 - scanLines.length * 9);
cursor.top -= 26;
// --- Full-width sections -------------------------------------------------
drawSection(page, fonts, cursor, "Instructions", data.operation.instructions, contentWidth);
drawSection(page, fonts, cursor, "Material notes", data.operation.materialNotes, contentWidth);
drawSection(page, fonts, cursor, "Settings", data.operation.settings, contentWidth, fonts.mono);
// --- Footer: sign-off strip ---------------------------------------------
const footerY = MARGIN + 20;
page.drawLine({
start: { x: MARGIN, y: footerY + 14 },
end: { x: PAGE_WIDTH - MARGIN, y: footerY + 14 },
thickness: 0.5,
color: rgb(0.82, 0.84, 0.88),
});
drawText(page, "Operator", {
x: MARGIN,
y: footerY,
font: fonts.bold,
size: 7,
color: rgb(0.45, 0.5, 0.6),
});
drawText(page, "Start / End", {
x: MARGIN + 180,
y: footerY,
font: fonts.bold,
size: 7,
color: rgb(0.45, 0.5, 0.6),
});
drawText(page, "Units", {
x: MARGIN + 340,
y: footerY,
font: fonts.bold,
size: 7,
color: rgb(0.45, 0.5, 0.6),
});
drawText(page, "QC", {
x: MARGIN + 420,
y: footerY,
font: fonts.bold,
size: 7,
color: rgb(0.45, 0.5, 0.6),
});
}
// Inline helper that renders a labelled prose block and advances the cursor.
// Silently does nothing if `body` is empty, so we don't leave orphan labels.
function drawSection(
page: PDFPage,
fonts: Fonts,
cursor: Cursor,
label: string,
body: string | null,
width: number,
font: PDFFont = fonts.regular,
): void {
if (!body || !body.trim()) return;
drawText(page, label.toUpperCase(), {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 8,
color: rgb(0.45, 0.5, 0.6),
});
cursor.top -= 12;
const lines = wrapLines(body.trim(), font, 10, width);
for (const line of lines) {
drawText(page, line, { x: MARGIN, y: cursor.top, font, size: 10 });
cursor.top -= 13;
}
cursor.top -= 8;
}
// ---------------------------------------------------------------------------
// Cover sheet
// ---------------------------------------------------------------------------
async function drawCoverSheet(
doc: PDFDocument,
page: PDFPage,
fonts: Fonts,
data: PartCoverData,
): Promise<void> {
const cursor: Cursor = { top: PAGE_HEIGHT - MARGIN };
const contentWidth = PAGE_WIDTH - MARGIN * 2;
drawText(page, "WORK ORDER", {
x: MARGIN,
y: cursor.top - 10,
font: fonts.bold,
size: 10,
color: rgb(0.4, 0.4, 0.45),
});
cursor.top -= 28;
const title = `${data.project.code} · ${data.assembly.code} · ${data.part.code}`;
drawText(page, title, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 20 });
cursor.top -= 26;
drawText(page, data.part.name, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 14,
color: rgb(0.2, 0.25, 0.35),
});
cursor.top -= 18;
const meta = [
data.part.material ? `Material: ${data.part.material}` : null,
`Quantity: ${data.part.qty}`,
`Project: ${data.project.name}`,
`Assembly: ${data.assembly.name}`,
].filter(Boolean) as string[];
for (const line of meta) {
drawText(page, line, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 10,
color: rgb(0.35, 0.4, 0.5),
});
cursor.top -= 13;
}
cursor.top -= 6;
if (data.part.notes && data.part.notes.trim()) {
drawSection(page, fonts, cursor, "Part notes", data.part.notes, contentWidth);
}
// --- Files manifest ------------------------------------------------------
const filesWithContent = data.files.filter((f) => f.file !== null);
if (filesWithContent.length > 0) {
drawText(page, "FILES", {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 8,
color: rgb(0.45, 0.5, 0.6),
});
cursor.top -= 14;
for (const f of filesWithContent) {
if (!f.file) continue;
drawText(page, `${f.label}`, {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 10,
});
drawText(page, f.file.originalName, {
x: MARGIN + 110,
y: cursor.top,
font: fonts.regular,
size: 10,
});
drawText(page, `${formatBytes(f.file.sizeBytes)} · sha256 ${f.file.sha256.slice(0, 12)}`, {
x: MARGIN + 110,
y: cursor.top - 11,
font: fonts.mono,
size: 8,
color: rgb(0.5, 0.5, 0.55),
});
cursor.top -= 26;
}
cursor.top -= 6;
}
// --- Operations table ----------------------------------------------------
drawText(page, "OPERATIONS", {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 8,
color: rgb(0.45, 0.5, 0.6),
});
cursor.top -= 14;
const rowThumb = 44; // QR thumbnail side (pt)
const rowGap = 10;
const rowHeight = rowThumb + rowGap;
for (const op of data.operations) {
// Stop drawing if we run out of room; the cards that follow are the
// authoritative per-step references anyway.
if (cursor.top - rowHeight < MARGIN + 30) {
drawText(page, `${data.operations.length} operations total (see following pages)`, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 9,
color: rgb(0.5, 0.5, 0.55),
});
break;
}
const thumbBytes = await renderQrPngBuffer(op.qrToken, 256);
const thumbImg = await doc.embedPng(thumbBytes);
const thumbY = cursor.top - rowThumb;
page.drawImage(thumbImg, {
x: MARGIN,
y: thumbY,
width: rowThumb,
height: rowThumb,
});
const textX = MARGIN + rowThumb + 12;
drawText(page, `${op.sequence}. ${op.name}`, {
x: textX,
y: cursor.top - 12,
font: fonts.bold,
size: 11,
});
const subline = [
op.machineName ?? "no machine",
op.qcRequired ? "QC" : null,
]
.filter(Boolean)
.join(" · ");
drawText(page, subline, {
x: textX,
y: cursor.top - 26,
font: fonts.regular,
size: 9,
color: rgb(0.4, 0.45, 0.55),
});
drawText(page, scanUrlForToken(op.qrToken), {
x: textX,
y: cursor.top - 40,
font: fonts.mono,
size: 7,
color: rgb(0.5, 0.5, 0.55),
});
cursor.top -= rowHeight;
}
// --- Footer timestamp ----------------------------------------------------
drawText(page, `Printed ${new Date().toISOString().slice(0, 16).replace("T", " ")}Z`, {
x: MARGIN,
y: MARGIN - 10,
font: fonts.regular,
size: 8,
color: rgb(0.55, 0.58, 0.65),
});
}
// ---------------------------------------------------------------------------
// Purchase order
// ---------------------------------------------------------------------------
function drawPurchaseOrder(page: PDFPage, fonts: Fonts, data: PurchaseOrderPdfData): void {
const cursor: Cursor = { top: PAGE_HEIGHT - MARGIN };
const contentWidth = PAGE_WIDTH - MARGIN * 2;
// --- Header --------------------------------------------------------------
drawText(page, "PURCHASE ORDER", {
x: MARGIN,
y: cursor.top - 10,
font: fonts.bold,
size: 10,
color: rgb(0.4, 0.4, 0.45),
});
const poRef = `PO-${data.po.id.slice(0, 8).toUpperCase()}`;
const poRefW = fonts.bold.widthOfTextAtSize(poRef, 10);
drawText(page, poRef, {
x: PAGE_WIDTH - MARGIN - poRefW,
y: cursor.top - 10,
font: fonts.bold,
size: 10,
});
cursor.top -= 28;
drawText(page, data.po.vendor, {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 22,
});
cursor.top -= 26;
const meta: string[] = [
`Project: ${data.project.code}${data.project.name}`,
`Status: ${data.po.status}`,
`Issued: ${(data.po.sentAt ?? data.po.createdAt).toISOString().slice(0, 10)}`,
];
for (const m of meta) {
drawText(page, m, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 10,
color: rgb(0.35, 0.4, 0.5),
});
cursor.top -= 13;
}
cursor.top -= 10;
// --- Line table ---------------------------------------------------------
const cols = {
partNo: MARGIN,
desc: MARGIN + 110,
qty: MARGIN + 360,
unit: MARGIN + 410,
total: MARGIN + 480,
};
const headerRow = cursor.top;
drawText(page, "PART NO.", { x: cols.partNo, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) });
drawText(page, "DESCRIPTION", { x: cols.desc, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) });
drawText(page, "QTY", { x: cols.qty, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) });
drawText(page, "UNIT", { x: cols.unit, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) });
drawText(page, "TOTAL", { x: cols.total, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) });
cursor.top -= 4;
page.drawLine({
start: { x: MARGIN, y: cursor.top },
end: { x: PAGE_WIDTH - MARGIN, y: cursor.top },
thickness: 0.75,
color: rgb(0.82, 0.84, 0.88),
});
cursor.top -= 10;
const lineSize = 10;
const descWidth = cols.qty - cols.desc - 10;
let grandTotal = 0;
let hasAnyCost = false;
for (const l of data.lines) {
const descLines = wrapLines(l.description, fonts.regular, lineSize, descWidth);
const rowHeight = Math.max(14, descLines.length * 13) + 4;
// Page break guard — if we run out of space, stop. (Multi-page POs can
// be added later; for now a single page is fine for typical line counts.)
if (cursor.top - rowHeight < MARGIN + 60) {
drawText(page, `… additional ${data.lines.length} lines truncated`, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 8,
color: rgb(0.5, 0.5, 0.55),
});
break;
}
drawText(page, l.partNumber, {
x: cols.partNo,
y: cursor.top - 2,
font: fonts.mono,
size: lineSize,
});
for (let i = 0; i < descLines.length; i++) {
drawText(page, descLines[i], {
x: cols.desc,
y: cursor.top - 2 - i * 13,
font: fonts.regular,
size: lineSize,
});
}
drawText(page, String(l.qty), {
x: cols.qty,
y: cursor.top - 2,
font: fonts.regular,
size: lineSize,
});
if (l.unitCost !== null && l.unitCost !== undefined) {
hasAnyCost = true;
const total = l.unitCost * l.qty;
grandTotal += total;
drawText(page, l.unitCost.toFixed(2), {
x: cols.unit,
y: cursor.top - 2,
font: fonts.regular,
size: lineSize,
});
drawText(page, total.toFixed(2), {
x: cols.total,
y: cursor.top - 2,
font: fonts.regular,
size: lineSize,
});
} else {
drawText(page, "—", {
x: cols.unit,
y: cursor.top - 2,
font: fonts.regular,
size: lineSize,
color: rgb(0.55, 0.58, 0.65),
});
}
cursor.top -= rowHeight;
}
// --- Totals --------------------------------------------------------------
if (hasAnyCost) {
cursor.top -= 8;
page.drawLine({
start: { x: cols.unit, y: cursor.top + 10 },
end: { x: PAGE_WIDTH - MARGIN, y: cursor.top + 10 },
thickness: 0.5,
color: rgb(0.82, 0.84, 0.88),
});
drawText(page, "TOTAL", {
x: cols.unit,
y: cursor.top,
font: fonts.bold,
size: 10,
});
drawText(page, grandTotal.toFixed(2), {
x: cols.total,
y: cursor.top,
font: fonts.bold,
size: 10,
});
cursor.top -= 16;
}
// --- Notes --------------------------------------------------------------
drawSection(page, fonts, cursor, "Notes", data.po.notes, contentWidth);
// --- Footer -------------------------------------------------------------
drawText(page, `Generated ${new Date().toISOString().slice(0, 16).replace("T", " ")}Z`, {
x: MARGIN,
y: MARGIN - 10,
font: fonts.regular,
size: 8,
color: rgb(0.55, 0.58, 0.65),
});
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}