179 lines
5.8 KiB
TypeScript
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 };
|
|
}
|