import { type NextRequest } from "next/server"; import { prisma } from "@/lib/prisma"; import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api"; import { CloseOperationSchema } from "@/lib/schemas"; import { audit } from "@/lib/audit"; import { clientIp } from "@/lib/request"; /** * Complete an operation. Only the current claim holder may close, and if * the operation is flagged qcRequired the payload must include an inline * QC block (pass/fail + optional measurements). Close does four things * atomically: * * 1. marks the operation completed + records completedAt, * 2. closes the open TimeLog with unitsProcessed / note if provided, * 3. writes a QCRecord if this op requires QC (or if the operator passed * one in voluntarily), * 4. audits the close for later reporting. */ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { try { const actor = await requireRole("operator"); const { id } = await ctx.params; const body = await parseJson(req, CloseOperationSchema); const existing = await prisma.operation.findUnique({ where: { id }, select: { id: true, status: true, kind: true, claimedByUserId: true, qcRequired: true, unitsCompleted: true, part: { select: { qty: true, assembly: { select: { qty: true } }, }, }, }, }); if (!existing) throw new ApiError(404, "not_found", "Operation not found"); if (existing.claimedByUserId !== actor.id) { throw new ApiError(409, "not_claim_holder", "Only the current operator can complete this step"); } if (existing.status !== "in_progress") { throw new ApiError(409, "op_not_active", "Step is not active"); } // Dedicated QC ops (kind="qc") are all-about-QC — always demand the inline // payload. Regular work ops only demand it when the template/op was flagged // qcRequired. Either way, without a QC block we short-circuit. const qcMandatory = existing.kind === "qc" || existing.qcRequired; if (qcMandatory && !body.qc) { throw new ApiError( 400, "qc_required", existing.kind === "qc" ? "QC result is required to complete an inspection step" : "This step requires an inline QC check before completing", ); } // --- Partial vs fully-done detection -------------------------------- // The operator hits "Done" but may have only produced some of the // assembly.qty × part.qty total units (e.g. end-of-shift handoff). In // that case the step should end up `partial` so the NEXT operator can // resume it — NOT `completed`, which locks it and requires admin to // reset. We infer the operator's intent from the units they typed in: // // units == 0 (blank) → "trust me, it's done" → completed // units >= remaining → math agrees → completed // units < remaining → partial (clear claim, no completedAt) // // QC-required ops still demand a QC record even on partial close — // that's about material checks, not about finishing the batch. // QC ops don't track units at all; treat them as "done" by default. const units = body.unitsProcessed ?? 0; const totalUnits = existing.part.assembly.qty * existing.part.qty; const remaining = Math.max(0, totalUnits - existing.unitsCompleted); const isQcOp = existing.kind === "qc"; const wouldFinish = isQcOp || units === 0 || units >= remaining; // QC failure short-circuits the usual partial-vs-complete decision: the // step moves to `qc_failed` which blocks further work until an admin // clears it via /api/v1/operations/:id/qc-reset. We still log the QC // record + close the timelog so the failure is on the paper trail. const qcFailed = body.qc !== undefined && body.qc.passed === false; const nextStatus: "completed" | "partial" | "qc_failed" = qcFailed ? "qc_failed" : wouldFinish ? "completed" : "partial"; const now = new Date(); await prisma.$transaction(async (tx) => { const openLog = await tx.timeLog.findFirst({ where: { operationId: id, operatorId: actor.id, endedAt: null }, orderBy: { startedAt: "desc" }, }); if (openLog) { await tx.timeLog.update({ where: { id: openLog.id }, data: { endedAt: now, unitsProcessed: body.unitsProcessed ?? null, note: body.note ?? null, }, }); } if (body.qc) { await tx.qCRecord.create({ data: { operationId: id, operatorId: actor.id, kind: "inline", passed: body.qc.passed, measurements: body.qc.measurements ?? null, notes: body.qc.notes ?? null, }, }); } // unitsCompleted is cumulative across pause/resume cycles; on close we // add this session's batch so the total reflects everything the step // actually produced. Partial + qc_failed close release the claim so the // next operator (or admin, in the fail case) can act; completed close // sets completedAt for reporting. await tx.operation.update({ where: { id }, data: { status: nextStatus, completedAt: nextStatus === "completed" ? now : null, claimedByUserId: null, claimedAt: null, ...(units > 0 ? { unitsCompleted: { increment: units } } : {}), }, }); }); const op = await prisma.operation.findUnique({ where: { id } }); const action = nextStatus === "completed" ? "close_op" : nextStatus === "qc_failed" ? "qc_fail_op" : "partial_close_op"; await audit({ actorId: actor.id, action, entity: "Operation", entityId: id, after: { status: nextStatus, unitsProcessed: body.unitsProcessed ?? null, unitsRemaining: Math.max(0, remaining - units), qcPassed: body.qc?.passed ?? null, }, ipAddress: clientIp(req), }); return ok({ operation: op, partial: nextStatus === "partial", qcFailed: nextStatus === "qc_failed", }); } catch (err) { return errorResponse(err); } }