timers
This commit is contained in:
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user