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 };
|
||||
}
|
||||
Reference in New Issue
Block a user