This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user