This commit is contained in:
@@ -27,6 +27,13 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||
if (existing.status === "completed") {
|
||||
throw new ApiError(409, "op_completed", "This step is already completed");
|
||||
}
|
||||
if (existing.status === "qc_failed") {
|
||||
throw new ApiError(
|
||||
409,
|
||||
"op_qc_failed",
|
||||
"This step failed QC and is locked until an admin resets it",
|
||||
);
|
||||
}
|
||||
if (existing.claimedByUserId && existing.claimedByUserId !== actor.id) {
|
||||
throw new ApiError(409, "op_claimed", "Another operator is already working on this step");
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
kind: true,
|
||||
claimedByUserId: true,
|
||||
qcRequired: true,
|
||||
unitsCompleted: true,
|
||||
@@ -46,8 +47,18 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||
if (existing.status !== "in_progress") {
|
||||
throw new ApiError(409, "op_not_active", "Step is not active");
|
||||
}
|
||||
if (existing.qcRequired && !body.qc) {
|
||||
throw new ApiError(400, "qc_required", "This step requires an inline QC check before completing");
|
||||
// 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 --------------------------------
|
||||
@@ -63,11 +74,23 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||
//
|
||||
// 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 wouldFinish = units === 0 || units >= remaining;
|
||||
const nextStatus: "completed" | "partial" = wouldFinish ? "completed" : "partial";
|
||||
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) => {
|
||||
@@ -99,8 +122,9 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||
}
|
||||
// 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 close releases the claim so the next
|
||||
// operator can resume; completed close sets completedAt for reporting.
|
||||
// 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: {
|
||||
@@ -114,9 +138,15 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||
});
|
||||
|
||||
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: nextStatus === "completed" ? "close_op" : "partial_close_op",
|
||||
action,
|
||||
entity: "Operation",
|
||||
entityId: id,
|
||||
after: {
|
||||
@@ -128,7 +158,11 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
|
||||
return ok({ operation: op, partial: nextStatus === "partial" });
|
||||
return ok({
|
||||
operation: op,
|
||||
partial: nextStatus === "partial",
|
||||
qcFailed: nextStatus === "qc_failed",
|
||||
});
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, ApiError } from "@/lib/api";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
/**
|
||||
* Admin-only: clear a QC failure so the step can be reworked. We roll the
|
||||
* op back to either `pending` (nothing produced yet — the failed unit was
|
||||
* the first attempt) or `partial` (some units had already been logged as
|
||||
* good before the fail) based on the cumulative `unitsCompleted` counter.
|
||||
*
|
||||
* The failed QCRecord is intentionally LEFT IN PLACE so reporting can still
|
||||
* count rework events — we just unblock the op.
|
||||
*/
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const actor = await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const existing = await prisma.operation.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, status: true, unitsCompleted: true },
|
||||
});
|
||||
if (!existing) throw new ApiError(404, "not_found", "Operation not found");
|
||||
if (existing.status !== "qc_failed") {
|
||||
throw new ApiError(
|
||||
409,
|
||||
"op_not_qc_failed",
|
||||
"Only steps in qc_failed state can be reset",
|
||||
);
|
||||
}
|
||||
|
||||
const nextStatus = existing.unitsCompleted > 0 ? "partial" : "pending";
|
||||
const updated = await prisma.operation.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: nextStatus,
|
||||
// Claim was already cleared on the failing close; belt-and-braces
|
||||
// defensively zero it here in case something set it back somehow.
|
||||
claimedByUserId: null,
|
||||
claimedAt: null,
|
||||
completedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "qc_reset_op",
|
||||
entity: "Operation",
|
||||
entityId: id,
|
||||
before: { status: "qc_failed" },
|
||||
after: { status: nextStatus, unitsCompleted: existing.unitsCompleted },
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
|
||||
return ok({ operation: updated });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,13 @@ export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: strin
|
||||
data: {
|
||||
...(body.templateId !== undefined ? { templateId: body.templateId } : {}),
|
||||
...(body.name !== undefined ? { name: body.name } : {}),
|
||||
// Switching to kind="qc" implicitly flips qcRequired on (an inspection
|
||||
// step without mandatory QC is a contradiction). Switching back to
|
||||
// kind="work" leaves qcRequired alone — the admin can toggle it
|
||||
// explicitly if they want to drop the check.
|
||||
...(body.kind !== undefined
|
||||
? { kind: body.kind, ...(body.kind === "qc" ? { qcRequired: true } : {}) }
|
||||
: {}),
|
||||
...(body.machineId !== undefined ? { machineId: body.machineId } : {}),
|
||||
...(body.settings !== undefined ? { settings: body.settings } : {}),
|
||||
...(body.materialNotes !== undefined ? { materialNotes: body.materialNotes } : {}),
|
||||
|
||||
@@ -106,11 +106,14 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||
sequence,
|
||||
templateId: template?.id ?? null,
|
||||
name: body.name,
|
||||
kind: body.kind ?? "work",
|
||||
machineId: effectiveMachineId,
|
||||
settings: effectiveSettings,
|
||||
materialNotes: body.materialNotes ?? null,
|
||||
instructions: effectiveInstructions,
|
||||
qcRequired: effectiveQcRequired,
|
||||
// Dedicated inspection ops are always QC-on-close — force the flag on
|
||||
// at create time so downstream code doesn't have to special-case kind.
|
||||
qcRequired: (body.kind ?? "work") === "qc" ? true : effectiveQcRequired,
|
||||
plannedMinutes: body.plannedMinutes ?? null,
|
||||
plannedUnits: body.plannedUnits ?? null,
|
||||
qrToken,
|
||||
|
||||
Reference in New Issue
Block a user