Files
mrp-qrcode/app/api/v1/operations/[id]/close/route.ts
T
jason e0dfac2d48
Build and Push Docker Image / build (push) Successful in 1m4s
step 9 and cleanup
2026-04-22 09:27:01 -05:00

170 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}