This commit is contained in:
2026-03-18 06:39:38 -05:00
parent c49ed4bf4a
commit e00639bb8b
11 changed files with 488 additions and 4 deletions

View File

@@ -13,11 +13,14 @@ import {
listManufacturingItemOptions,
listManufacturingProjectOptions,
listManufacturingStations,
listManufacturingUserOptions,
listWorkOrders,
recordWorkOrderCompletion,
recordWorkOrderOperationLabor,
updateManufacturingStation,
updateWorkOrderOperationAssignment,
updateWorkOrderOperationExecution,
updateWorkOrderOperationTimer,
updateWorkOrder,
updateWorkOrderOperationSchedule,
updateWorkOrderStatus,
@@ -86,6 +89,15 @@ const operationLaborSchema = z.object({
notes: z.string(),
});
const operationAssignmentSchema = z.object({
assignedOperatorId: z.string().trim().min(1).nullable(),
});
const operationTimerSchema = z.object({
action: z.enum(["START", "STOP"]),
notes: z.string(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
@@ -100,6 +112,10 @@ manufacturingRouter.get("/projects/options", requirePermissions([permissions.man
return ok(response, await listManufacturingProjectOptions());
});
manufacturingRouter.get("/users/options", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingUserOptions());
});
manufacturingRouter.get("/stations", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingStations());
});
@@ -267,6 +283,46 @@ manufacturingRouter.post("/work-orders/:workOrderId/operations/:operationId/labo
return ok(response, result.workOrder, 201);
});
manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/assignment", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
const operationId = getRouteParam(request.params.operationId);
if (!workOrderId || !operationId) {
return fail(response, 400, "INVALID_INPUT", "Work-order or operation id is invalid.");
}
const parsed = operationAssignmentSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation assignment payload is invalid.");
}
const result = await updateWorkOrderOperationAssignment(workOrderId, operationId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/timer", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
const operationId = getRouteParam(request.params.operationId);
if (!workOrderId || !operationId) {
return fail(response, 400, "INVALID_INPUT", "Work-order or operation id is invalid.");
}
const parsed = operationTimerSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation timer payload is invalid.");
}
const result = await updateWorkOrderOperationTimer(workOrderId, operationId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.post("/work-orders/:workOrderId/issues", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {

View File

@@ -3,13 +3,16 @@ import type {
ManufacturingStationInput,
ManufacturingItemOptionDto,
ManufacturingProjectOptionDto,
ManufacturingUserOptionDto,
WorkOrderCompletionInput,
WorkOrderDetailDto,
WorkOrderInput,
WorkOrderOperationAssignmentInput,
WorkOrderOperationExecutionInput,
WorkOrderOperationDto,
WorkOrderOperationLaborEntryInput,
WorkOrderOperationScheduleInput,
WorkOrderOperationTimerInput,
WorkOrderMaterialIssueInput,
WorkOrderStatus,
WorkOrderSummaryDto,
@@ -115,6 +118,7 @@ type WorkOrderRecord = {
actualStart: Date | null;
actualEnd: Date | null;
actualMinutes: number;
activeTimerStartedAt: Date | null;
station: {
id: string;
code: string;
@@ -123,6 +127,11 @@ type WorkOrderRecord = {
parallelCapacity: number;
workingDays: string;
};
assignedOperator: {
id: string;
firstName: string;
lastName: string;
} | null;
laborEntries: Array<{
id: string;
minutes: number;
@@ -268,6 +277,13 @@ function buildInclude() {
workingDays: true,
},
},
assignedOperator: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
laborEntries: {
include: {
createdBy: {
@@ -401,6 +417,9 @@ function mapDetail(
actualEnd: operation.actualEnd ? operation.actualEnd.toISOString() : null,
actualMinutes: operation.actualMinutes,
laborEntryCount: operation.laborEntries.length,
assignedOperatorId: operation.assignedOperator?.id ?? null,
assignedOperatorName: operation.assignedOperator ? `${operation.assignedOperator.firstName} ${operation.assignedOperator.lastName}`.trim() : null,
activeTimerStartedAt: operation.activeTimerStartedAt ? operation.activeTimerStartedAt.toISOString() : null,
laborEntries: operation.laborEntries.map((entry) => ({
id: entry.id,
minutes: entry.minutes,
@@ -1068,6 +1087,27 @@ export async function listManufacturingProjectOptions(): Promise<ManufacturingPr
}));
}
export async function listManufacturingUserOptions(): Promise<ManufacturingUserOptionDto[]> {
const users = await prisma.user.findMany({
where: {
isActive: true,
},
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
orderBy: [{ firstName: "asc" }, { lastName: "asc" }, { email: "asc" }],
});
return users.map((user) => ({
id: user.id,
name: `${user.firstName} ${user.lastName}`.trim(),
email: user.email,
}));
}
export async function listWorkOrders(filters: {
q?: string;
status?: WorkOrderStatus;
@@ -1553,6 +1593,177 @@ export async function recordWorkOrderOperationLabor(
return { ok: true as const, workOrder };
}
export async function updateWorkOrderOperationAssignment(
workOrderId: string,
operationId: string,
payload: WorkOrderOperationAssignmentInput,
actorId?: string | null
) {
const existing = await prisma.workOrderOperation.findUnique({
where: { id: operationId },
select: {
id: true,
workOrderId: true,
sequence: true,
assignedOperatorId: true,
workOrder: {
select: {
workOrderNumber: true,
},
},
},
});
if (!existing || existing.workOrderId !== workOrderId) {
return { ok: false as const, reason: "Work-order operation was not found." };
}
if (payload.assignedOperatorId) {
const user = await prisma.user.findUnique({
where: { id: payload.assignedOperatorId },
select: {
id: true,
isActive: true,
},
});
if (!user || !user.isActive) {
return { ok: false as const, reason: "Assigned operator was not found or is inactive." };
}
}
await prisma.workOrderOperation.update({
where: { id: operationId },
data: {
assignedOperatorId: payload.assignedOperatorId,
},
});
const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) {
return { ok: false as const, reason: "Unable to load updated work order." };
}
await logAuditEvent({
actorId,
entityType: "work-order",
entityId: workOrderId,
action: "operation.assignment.updated",
summary: `Updated operator assignment for operation ${existing.sequence} on ${existing.workOrder.workOrderNumber}.`,
metadata: {
workOrderNumber: existing.workOrder.workOrderNumber,
operationId,
sequence: existing.sequence,
previousAssignedOperatorId: existing.assignedOperatorId,
assignedOperatorId: payload.assignedOperatorId,
},
});
return { ok: true as const, workOrder };
}
export async function updateWorkOrderOperationTimer(
workOrderId: string,
operationId: string,
payload: WorkOrderOperationTimerInput,
actorId?: string | null
) {
const existing = await prisma.workOrderOperation.findUnique({
where: { id: operationId },
select: {
id: true,
workOrderId: true,
sequence: true,
status: true,
actualStart: true,
activeTimerStartedAt: true,
assignedOperatorId: true,
workOrder: {
select: {
status: true,
workOrderNumber: true,
},
},
},
});
if (!existing || existing.workOrderId !== workOrderId) {
return { ok: false as const, reason: "Work-order operation was not found." };
}
if (existing.workOrder.status === "COMPLETE" || existing.workOrder.status === "CANCELLED" || existing.workOrder.status === "DRAFT") {
return { ok: false as const, reason: "Timer actions can only be used on released or active work orders." };
}
const now = new Date();
if (payload.action === "START") {
if (existing.activeTimerStartedAt) {
return { ok: false as const, reason: "Operation timer is already running." };
}
await prisma.workOrderOperation.update({
where: { id: operationId },
data: {
activeTimerStartedAt: now,
actualStart: existing.actualStart ?? now,
status: existing.status === "COMPLETE" ? existing.status : "IN_PROGRESS",
},
});
await syncWorkOrderStatusFromOperationActivity(workOrderId);
} else {
if (!existing.activeTimerStartedAt) {
return { ok: false as const, reason: "Operation timer is not currently running." };
}
const minutes = Math.max(Math.ceil((now.getTime() - existing.activeTimerStartedAt.getTime()) / 60000), 1);
await prisma.$transaction(async (tx) => {
await tx.workOrderOperationLaborEntry.create({
data: {
operationId,
minutes,
notes: payload.notes || `Timer stop on operation ${existing.sequence}`,
createdById: actorId ?? existing.assignedOperatorId ?? null,
},
});
await tx.workOrderOperation.update({
where: { id: operationId },
data: {
activeTimerStartedAt: null,
status: existing.status === "COMPLETE" ? existing.status : "PAUSED",
actualMinutes: {
increment: minutes,
},
},
});
});
await syncWorkOrderStatusFromOperationActivity(workOrderId);
}
const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) {
return { ok: false as const, reason: "Unable to load updated work order." };
}
await logAuditEvent({
actorId,
entityType: "work-order",
entityId: workOrderId,
action: "operation.timer.updated",
summary: `${payload.action} timer on operation ${existing.sequence} for ${existing.workOrder.workOrderNumber}.`,
metadata: {
workOrderNumber: existing.workOrder.workOrderNumber,
operationId,
sequence: existing.sequence,
action: payload.action,
notes: payload.notes,
},
});
return { ok: true as const, workOrder };
}
export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkOrderMaterialIssueInput, createdById?: string | null) {
const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId },