Files
jason 04ae88ca0d
Build and Push Docker Image / build (push) Successful in 45s
QoL changes and additions
2026-04-22 13:16:42 -05:00

179 lines
5.8 KiB
TypeScript

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 };
}