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

@@ -0,0 +1,44 @@
-- AlterTable
ALTER TABLE "WorkOrderOperation" ADD COLUMN "assignedOperatorId" TEXT;
ALTER TABLE "WorkOrderOperation" ADD COLUMN "activeTimerStartedAt" DATETIME;
-- CreateIndex
CREATE INDEX "WorkOrderOperation_assignedOperatorId_plannedStart_idx" ON "WorkOrderOperation"("assignedOperatorId", "plannedStart");
-- AddForeignKey
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_WorkOrderOperation" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderId" TEXT NOT NULL,
"stationId" TEXT NOT NULL,
"assignedOperatorId" TEXT,
"sequence" INTEGER NOT NULL,
"setupMinutes" INTEGER NOT NULL DEFAULT 0,
"runMinutesPerUnit" INTEGER NOT NULL DEFAULT 0,
"moveMinutes" INTEGER NOT NULL DEFAULT 0,
"plannedMinutes" INTEGER NOT NULL DEFAULT 0,
"plannedStart" DATETIME NOT NULL,
"plannedEnd" DATETIME NOT NULL,
"notes" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"actualStart" DATETIME,
"actualEnd" DATETIME,
"actualMinutes" INTEGER NOT NULL DEFAULT 0,
"activeTimerStartedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrderOperation_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkOrderOperation_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "ManufacturingStation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrderOperation_assignedOperatorId_fkey" FOREIGN KEY ("assignedOperatorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_WorkOrderOperation" ("actualEnd", "actualMinutes", "actualStart", "activeTimerStartedAt", "assignedOperatorId", "createdAt", "id", "moveMinutes", "notes", "plannedEnd", "plannedMinutes", "plannedStart", "runMinutesPerUnit", "sequence", "setupMinutes", "stationId", "status", "updatedAt", "workOrderId")
SELECT "actualEnd", "actualMinutes", "actualStart", "activeTimerStartedAt", "assignedOperatorId", "createdAt", "id", "moveMinutes", "notes", "plannedEnd", "plannedMinutes", "plannedStart", "runMinutesPerUnit", "sequence", "setupMinutes", "stationId", "status", "updatedAt", "workOrderId" FROM "WorkOrderOperation";
DROP TABLE "WorkOrderOperation";
ALTER TABLE "new_WorkOrderOperation" RENAME TO "WorkOrderOperation";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;
-- RecreateIndex
CREATE INDEX "WorkOrderOperation_workOrderId_sequence_idx" ON "WorkOrderOperation"("workOrderId", "sequence");
CREATE INDEX "WorkOrderOperation_stationId_plannedStart_idx" ON "WorkOrderOperation"("stationId", "plannedStart");
CREATE INDEX "WorkOrderOperation_assignedOperatorId_plannedStart_idx" ON "WorkOrderOperation"("assignedOperatorId", "plannedStart");

View File

@@ -27,6 +27,7 @@ model User {
workOrderMaterialIssues WorkOrderMaterialIssue[]
workOrderCompletions WorkOrderCompletion[]
workOrderOperationLaborEntries WorkOrderOperationLaborEntry[]
assignedWorkOrderOperations WorkOrderOperation[]
approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy")
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
@@ -681,6 +682,7 @@ model WorkOrderOperation {
id String @id @default(cuid())
workOrderId String
stationId String
assignedOperatorId String?
sequence Int
setupMinutes Int @default(0)
runMinutesPerUnit Int @default(0)
@@ -693,14 +695,17 @@ model WorkOrderOperation {
actualStart DateTime?
actualEnd DateTime?
actualMinutes Int @default(0)
activeTimerStartedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict)
assignedOperator User? @relation(fields: [assignedOperatorId], references: [id], onDelete: SetNull)
laborEntries WorkOrderOperationLaborEntry[]
@@index([workOrderId, sequence])
@@index([stationId, plannedStart])
@@index([assignedOperatorId, plannedStart])
}
model WorkOrderOperationLaborEntry {

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 },