QoL changes and additions
Build and Push Docker Image / build (push) Successful in 45s

This commit is contained in:
jason
2026-04-22 13:16:42 -05:00
parent a165428f14
commit 04ae88ca0d
14 changed files with 1424 additions and 29 deletions
+178
View File
@@ -0,0 +1,178 @@
import { prisma } from "@/lib/prisma";
import { ApiError } from "@/lib/api";
import { generateQrToken } from "@/lib/qr";
/**
* Clone a part's operations under a new partId. Each op gets a fresh qrToken
* and is reset to `pending` with a zero unit count — the copy has never been
* worked on even if the source had partial/completed steps. Claims, time logs,
* and QC records are intentionally NOT carried over: they belong to the
* original's history.
*/
async function cloneOperationsForPart(
sourcePartId: string,
destPartId: string,
): Promise<number> {
const sourceOps = await prisma.operation.findMany({
where: { partId: sourcePartId },
orderBy: { sequence: "asc" },
});
for (const op of sourceOps) {
// Collision-retry for qrToken; 192 bits of entropy, same pattern as
// the create-op route.
let qrToken = generateQrToken();
for (let attempt = 0; attempt < 5; attempt++) {
const existing = await prisma.operation.findUnique({
where: { qrToken },
select: { id: true },
});
if (!existing) break;
qrToken = generateQrToken();
if (attempt === 4)
throw new ApiError(500, "qr_collision", "Unable to allocate QR token");
}
await prisma.operation.create({
data: {
partId: destPartId,
sequence: op.sequence,
templateId: op.templateId,
name: op.name,
kind: op.kind,
machineId: op.machineId,
settings: op.settings,
materialNotes: op.materialNotes,
instructions: op.instructions,
qcRequired: op.qcRequired,
plannedMinutes: op.plannedMinutes,
plannedUnits: op.plannedUnits,
qrToken,
// fresh copy — no prior work, no claim, no status
status: "pending",
unitsCompleted: 0,
},
});
}
return sourceOps.length;
}
/**
* Clone a Part into its existing assembly. Returns the new part's id.
* Operations are copied unless `includeOperations` is false. File
* attachments are re-referenced by id — FileAsset rows are content-addressed
* and shared.
*
* The caller must check that `code` is unique within the destination assembly
* before calling; we re-check inside for safety and turn Prisma's unique-
* constraint violation into a 409.
*/
export async function duplicatePart(opts: {
sourcePartId: string;
code: string;
name?: string;
includeOperations: boolean;
}): Promise<{ id: string; operationsCopied: number }> {
const source = await prisma.part.findUnique({
where: { id: opts.sourcePartId },
});
if (!source) throw new ApiError(404, "not_found", "Part not found");
const conflict = await prisma.part.findUnique({
where: { assemblyId_code: { assemblyId: source.assemblyId, code: opts.code } },
select: { id: true },
});
if (conflict)
throw new ApiError(409, "code_taken", `Part code ${opts.code} already in use in this assembly`);
const created = await prisma.part.create({
data: {
assemblyId: source.assemblyId,
code: opts.code,
name: opts.name ?? source.name,
material: source.material,
qty: source.qty,
notes: source.notes,
// Re-attach the same FileAssets — storage is content-addressed, no copy needed.
stepFileId: source.stepFileId,
drawingFileId: source.drawingFileId,
cutFileId: source.cutFileId,
thumbnailFileId: source.thumbnailFileId,
},
});
const operationsCopied = opts.includeOperations
? await cloneOperationsForPart(source.id, created.id)
: 0;
return { id: created.id, operationsCopied };
}
/**
* Clone an Assembly (plus every child Part, plus — optionally — every
* Operation) into the same project. Returns the new assembly's id and a
* summary count. Part codes and operation sequences are preserved since
* they were unique within their parent in the source and will remain unique
* within the new parent. Only the assembly's own `code` might clash with a
* sibling in the project, so we check that up front.
*/
export async function duplicateAssembly(opts: {
sourceAssemblyId: string;
code: string;
name?: string;
includeOperations: boolean;
}): Promise<{ id: string; partsCopied: number; operationsCopied: number }> {
const source = await prisma.assembly.findUnique({
where: { id: opts.sourceAssemblyId },
include: { parts: { select: { id: true } } },
});
if (!source) throw new ApiError(404, "not_found", "Assembly not found");
const conflict = await prisma.assembly.findUnique({
where: { projectId_code: { projectId: source.projectId, code: opts.code } },
select: { id: true },
});
if (conflict)
throw new ApiError(409, "code_taken", `Assembly code ${opts.code} already in use in this project`);
const newAssembly = await prisma.assembly.create({
data: {
projectId: source.projectId,
code: opts.code,
name: opts.name ?? source.name,
qty: source.qty,
notes: source.notes,
stepFileId: source.stepFileId,
drawingFileId: source.drawingFileId,
cutFileId: source.cutFileId,
},
});
let operationsCopied = 0;
let partsCopied = 0;
for (const sourcePart of source.parts) {
const full = await prisma.part.findUnique({ where: { id: sourcePart.id } });
if (!full) continue;
const clonedPart = await prisma.part.create({
data: {
assemblyId: newAssembly.id,
code: full.code,
name: full.name,
material: full.material,
qty: full.qty,
notes: full.notes,
stepFileId: full.stepFileId,
drawingFileId: full.drawingFileId,
cutFileId: full.cutFileId,
thumbnailFileId: full.thumbnailFileId,
},
});
partsCopied++;
if (opts.includeOperations) {
operationsCopied += await cloneOperationsForPart(full.id, clonedPart.id);
}
}
return { id: newAssembly.id, partsCopied, operationsCopied };
}
+151 -7
View File
@@ -131,26 +131,170 @@ export async function renderPartTravelers(payload: {
}): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const fonts = await embedFonts(doc);
await appendPartBundle(doc, fonts, payload);
return doc.save();
}
/** Entry point: one combined PDF for an entire project — every part bundle
* concatenated (cover → drawings → op cards, repeated). Prefixed with a short
* project-level summary page so the binder has a front sheet. Useful for
* bulk reprints when a run changes.
*/
export async function renderProjectTravelers(payload: {
project: {
code: string;
name: string;
customerCode: string | null;
dueDate: Date | null;
};
bundles: {
cover: PartCoverData;
cards: OperationCardData[];
drawingPdfBytes?: Uint8Array | null;
assemblyDrawingPdfBytes?: Uint8Array | null;
}[];
}): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const fonts = await embedFonts(doc);
const summaryPage = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
drawProjectSummary(summaryPage, fonts, payload.project, payload.bundles);
for (const bundle of payload.bundles) {
await appendPartBundle(doc, fonts, bundle);
}
return doc.save();
}
/** Append one part's cover + drawings + op cards onto an existing doc. */
async function appendPartBundle(
doc: PDFDocument,
fonts: Fonts,
bundle: {
cover: PartCoverData;
cards: OperationCardData[];
drawingPdfBytes?: Uint8Array | null;
assemblyDrawingPdfBytes?: Uint8Array | null;
},
): Promise<void> {
const coverPage = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
await drawCoverSheet(doc, coverPage, fonts, payload.cover);
await drawCoverSheet(doc, coverPage, fonts, bundle.cover);
// Inline the assembly-level drawing first, then the part drawing. Both are
// optional. We swallow per-PDF errors so a corrupt drawing doesn't block
// the op cards from printing.
if (payload.assemblyDrawingPdfBytes) {
await appendPdfPages(doc, payload.assemblyDrawingPdfBytes, "assembly drawing");
if (bundle.assemblyDrawingPdfBytes) {
await appendPdfPages(doc, bundle.assemblyDrawingPdfBytes, "assembly drawing");
}
if (payload.drawingPdfBytes) {
await appendPdfPages(doc, payload.drawingPdfBytes, "part drawing");
if (bundle.drawingPdfBytes) {
await appendPdfPages(doc, bundle.drawingPdfBytes, "part drawing");
}
for (const card of payload.cards) {
for (const card of bundle.cards) {
const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
await drawOperationCard(doc, page, fonts, card);
}
}
return doc.save();
// Project-level front sheet. Deliberately minimal — the real detail lives on
// each part's own cover sheet further in. Shows project header, a totals
// line, and a table of contents listing every part being included.
function drawProjectSummary(
page: PDFPage,
fonts: Fonts,
project: { code: string; name: string; customerCode: string | null; dueDate: Date | null },
bundles: { cover: PartCoverData; cards: OperationCardData[] }[],
): void {
const cursor: Cursor = { top: PAGE_HEIGHT - MARGIN };
const contentWidth = PAGE_WIDTH - MARGIN * 2;
drawText(page, "PROJECT TRAVELERS", {
x: MARGIN,
y: cursor.top - 10,
font: fonts.bold,
size: 10,
color: rgb(0.4, 0.4, 0.45),
});
cursor.top -= 28;
drawText(page, `${project.code}${project.name}`, {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 20,
});
cursor.top -= 20;
const metaBits = [
project.customerCode ? `Customer ${project.customerCode}` : null,
project.dueDate ? `Due ${project.dueDate.toISOString().slice(0, 10)}` : null,
`${bundles.length} part${bundles.length === 1 ? "" : "s"}`,
`${bundles.reduce((acc, b) => acc + b.cards.length, 0)} operations total`,
]
.filter(Boolean)
.join(" · ");
drawText(page, metaBits, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 11,
color: rgb(0.35, 0.4, 0.5),
});
cursor.top -= 20;
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 -= 18;
drawText(page, "CONTENTS", {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 9,
color: rgb(0.45, 0.5, 0.6),
});
cursor.top -= 14;
// Simple TOC. Long projects (50+ parts) will overflow the page; we cap the
// visible rows and footnote the rest rather than spilling into a multi-page
// TOC — the goal here is a summary, not a catalogue.
const maxRows = 38;
const rows = bundles.slice(0, maxRows);
for (const b of rows) {
const label = `${b.cover.assembly.code} · ${b.cover.part.code}${b.cover.part.name}`;
const line = wrapLines(label, fonts.regular, 10, contentWidth - 60)[0] ?? label;
drawText(page, line, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 10,
});
const opsLabel = `${b.cards.length} op${b.cards.length === 1 ? "" : "s"}`;
const opsLabelW = fonts.regular.widthOfTextAtSize(opsLabel, 10);
drawText(page, opsLabel, {
x: PAGE_WIDTH - MARGIN - opsLabelW,
y: cursor.top,
font: fonts.regular,
size: 10,
color: rgb(0.45, 0.5, 0.6),
});
cursor.top -= 13;
}
if (bundles.length > maxRows) {
cursor.top -= 4;
drawText(page, `… and ${bundles.length - maxRows} more part${bundles.length - maxRows === 1 ? "" : "s"}.`, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 10,
color: rgb(0.45, 0.5, 0.6),
});
}
}
// Load an external PDF's pages and copy them into `doc`. Best-effort: if the
+30
View File
@@ -203,6 +203,23 @@ export const UpdatePartSchema = z
})
.strict();
// Clone a part (or whole assembly) into the same parent. Operations are
// re-seeded with fresh QR tokens and `status: "pending"`, `unitsCompleted: 0`
// — travelers printed for the copy need new QRs or they'd collide with the
// original. includeOperations=false gives you just the skeleton if the recipe
// is being reworked.
export const DuplicatePartSchema = z.object({
code: Code,
name: NonEmpty.optional(),
includeOperations: z.boolean().default(true).optional(),
});
export const DuplicateAssemblySchema = z.object({
code: Code,
name: NonEmpty.optional(),
includeOperations: z.boolean().default(true).optional(),
});
// ---- operations ---------------------------------------------------------
// "partial" = an operation that was started, had units logged, and then paused.
@@ -347,6 +364,19 @@ export const ReceivePOSchema = z.object({
.min(1),
});
// Admin correction of a time log. Most common use: the operator forgot to
// pause overnight so endedAt is null (or absurdly late) and plan-vs-actual
// reporting is poisoned. We allow startedAt nudges too for paper-log
// backfills. The route enforces endedAt >= startedAt and audits the diff.
export const UpdateTimeLogSchema = z
.object({
startedAt: z.coerce.date().optional(),
endedAt: z.coerce.date().nullable().optional(),
unitsProcessed: z.coerce.number().int().min(0).max(1_000_000).nullable().optional(),
note: OptionalText,
})
.strict();
export const ReleaseOperationSchema = z.object({
unitsProcessed: z.coerce.number().int().min(0).max(1_000_000).nullable().optional(),
note: OptionalText,