manufacturing layer

This commit is contained in:
2026-03-18 06:22:37 -05:00
parent 6eaf084fcd
commit c49ed4bf4a
14 changed files with 561 additions and 20 deletions

View File

@@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "WorkOrderOperation" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'PENDING';
ALTER TABLE "WorkOrderOperation" ADD COLUMN "actualStart" DATETIME;
ALTER TABLE "WorkOrderOperation" ADD COLUMN "actualEnd" DATETIME;
ALTER TABLE "WorkOrderOperation" ADD COLUMN "actualMinutes" INTEGER NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "WorkOrderOperationLaborEntry" (
"id" TEXT NOT NULL PRIMARY KEY,
"operationId" TEXT NOT NULL,
"minutes" INTEGER NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrderOperationLaborEntry_operationId_fkey" FOREIGN KEY ("operationId") REFERENCES "WorkOrderOperation" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkOrderOperationLaborEntry_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "WorkOrderOperationLaborEntry_operationId_createdAt_idx" ON "WorkOrderOperationLaborEntry"("operationId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrderOperationLaborEntry_createdById_createdAt_idx" ON "WorkOrderOperationLaborEntry"("createdById", "createdAt");

View File

@@ -26,6 +26,7 @@ model User {
ownedProjects Project[] @relation("ProjectOwner")
workOrderMaterialIssues WorkOrderMaterialIssue[]
workOrderCompletions WorkOrderCompletion[]
workOrderOperationLaborEntries WorkOrderOperationLaborEntry[]
approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy")
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
@@ -688,15 +689,35 @@ model WorkOrderOperation {
plannedStart DateTime
plannedEnd DateTime
notes String
status String @default("PENDING")
actualStart DateTime?
actualEnd DateTime?
actualMinutes Int @default(0)
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)
laborEntries WorkOrderOperationLaborEntry[]
@@index([workOrderId, sequence])
@@index([stationId, plannedStart])
}
model WorkOrderOperationLaborEntry {
id String @id @default(cuid())
operationId String
minutes Int
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
operation WorkOrderOperation @relation(fields: [operationId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([operationId, createdAt])
@@index([createdById, createdAt])
}
model WorkOrderMaterialIssue {
id String @id @default(cuid())
workOrderId String

View File

@@ -57,6 +57,8 @@ type PlanningWorkOrderRecord = {
plannedStart: Date;
plannedEnd: Date;
plannedMinutes: number;
actualMinutes: number;
status: string;
station: { id: string; code: string; name: string; dailyCapacityMinutes: number; parallelCapacity: number; workingDays: string };
}>;
materialIssues: Array<{ componentItemId: string; quantity: number }>;
@@ -82,6 +84,7 @@ type StationAccumulator = {
operationCount: number;
workOrderIds: Set<string>;
totalPlannedMinutes: number;
totalActualMinutes: number;
blockedCount: number;
readyCount: number;
lateCount: number;
@@ -177,6 +180,7 @@ function getAvailabilityKey(itemId: string, warehouseId: string, locationId: str
function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
const capacityMinutes = Math.max(record.dayKeys.size, 1) * Math.max(record.dailyCapacityMinutes, 60) * Math.max(record.parallelCapacity, 1);
const utilizationPercent = capacityMinutes > 0 ? Math.round((record.totalPlannedMinutes / capacityMinutes) * 100) : 0;
const actualUtilizationPercent = capacityMinutes > 0 ? Math.round((record.totalActualMinutes / capacityMinutes) * 100) : 0;
return {
stationId: record.stationId,
stationCode: record.stationCode,
@@ -184,8 +188,10 @@ function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
operationCount: record.operationCount,
workOrderCount: record.workOrderIds.size,
totalPlannedMinutes: record.totalPlannedMinutes,
totalActualMinutes: record.totalActualMinutes,
capacityMinutes,
utilizationPercent,
actualUtilizationPercent,
overloaded: utilizationPercent > 100,
blockedCount: record.blockedCount,
readyCount: record.readyCount,
@@ -308,6 +314,8 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
plannedStart: true,
plannedEnd: true,
plannedMinutes: true,
actualMinutes: true,
status: true,
station: { select: { id: true, code: true, name: true, dailyCapacityMinutes: true, parallelCapacity: true, workingDays: true } },
},
orderBy: [{ sequence: "asc" }],
@@ -494,6 +502,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
operationCount: 0,
workOrderIds: new Set<string>(),
totalPlannedMinutes: 0,
totalActualMinutes: 0,
blockedCount: 0,
readyCount: 0,
lateCount: 0,
@@ -505,6 +514,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
current.operationCount += 1;
current.workOrderIds.add(workOrder.id);
current.totalPlannedMinutes += operation.plannedMinutes;
current.totalActualMinutes += operation.actualMinutes;
if (insight?.readinessState === "BLOCKED" || insight?.readinessState === "SHORTAGE" || insight?.readinessState === "PENDING_SUPPLY") {
current.blockedCount += 1;
}

View File

@@ -15,7 +15,9 @@ import {
listManufacturingStations,
listWorkOrders,
recordWorkOrderCompletion,
recordWorkOrderOperationLabor,
updateManufacturingStation,
updateWorkOrderOperationExecution,
updateWorkOrder,
updateWorkOrderOperationSchedule,
updateWorkOrderStatus,
@@ -74,6 +76,16 @@ const operationScheduleSchema = z.object({
stationId: z.string().trim().min(1).nullable().optional(),
});
const operationExecutionSchema = z.object({
action: z.enum(["START", "PAUSE", "RESUME", "COMPLETE"]),
notes: z.string(),
});
const operationLaborSchema = z.object({
minutes: z.number().int().positive(),
notes: z.string(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
@@ -215,6 +227,46 @@ manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/sch
return ok(response, result.workOrder);
});
manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/execution", 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 = operationExecutionSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation execution payload is invalid.");
}
const result = await updateWorkOrderOperationExecution(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/operations/:operationId/labor", 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 = operationLaborSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation labor payload is invalid.");
}
const result = await recordWorkOrderOperationLabor(workOrderId, operationId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder, 201);
});
manufacturingRouter.post("/work-orders/:workOrderId/issues", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {

View File

@@ -6,7 +6,9 @@ import type {
WorkOrderCompletionInput,
WorkOrderDetailDto,
WorkOrderInput,
WorkOrderOperationExecutionInput,
WorkOrderOperationDto,
WorkOrderOperationLaborEntryInput,
WorkOrderOperationScheduleInput,
WorkOrderMaterialIssueInput,
WorkOrderStatus,
@@ -109,6 +111,10 @@ type WorkOrderRecord = {
plannedStart: Date;
plannedEnd: Date;
notes: string;
status: string;
actualStart: Date | null;
actualEnd: Date | null;
actualMinutes: number;
station: {
id: string;
code: string;
@@ -117,6 +123,16 @@ type WorkOrderRecord = {
parallelCapacity: number;
workingDays: string;
};
laborEntries: Array<{
id: string;
minutes: number;
notes: string;
createdAt: Date;
createdBy: {
firstName: string;
lastName: string;
} | null;
}>;
}>;
materialIssues: Array<{
id: string;
@@ -252,6 +268,17 @@ function buildInclude() {
workingDays: true,
},
},
laborEntries: {
include: {
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
},
},
orderBy: [{ sequence: "asc" }],
},
@@ -352,6 +379,7 @@ function mapDetail(
itemUnitOfMeasure: record.item.unitOfMeasure,
projectCustomerName: record.project?.customer.name ?? null,
dueQuantity: record.quantity - record.completedQuantity,
totalActualMinutes: record.operations.reduce((sum, operation) => sum + operation.actualMinutes, 0),
operations: record.operations.map((operation): WorkOrderOperationDto => ({
id: operation.id,
stationId: operation.station.id,
@@ -368,6 +396,18 @@ function mapDetail(
plannedStart: operation.plannedStart.toISOString(),
plannedEnd: operation.plannedEnd.toISOString(),
notes: operation.notes,
status: operation.status as WorkOrderOperationDto["status"],
actualStart: operation.actualStart ? operation.actualStart.toISOString() : null,
actualEnd: operation.actualEnd ? operation.actualEnd.toISOString() : null,
actualMinutes: operation.actualMinutes,
laborEntryCount: operation.laborEntries.length,
laborEntries: operation.laborEntries.map((entry) => ({
id: entry.id,
minutes: entry.minutes,
notes: entry.notes,
createdAt: entry.createdAt.toISOString(),
createdByName: getUserName(entry.createdBy),
})),
})),
materialRequirements: record.item.bomLines.map((line) => {
const requiredQuantity = line.quantity * record.quantity;
@@ -566,6 +606,7 @@ async function regenerateWorkOrderOperations(workOrderId: string) {
plannedStart: operation.plannedStart,
plannedEnd: operation.plannedEnd,
notes: operation.notes,
status: "PENDING",
})),
});
}
@@ -634,6 +675,29 @@ async function rescheduleWorkOrderOperationsToStation(
return getWorkOrderById(workOrderId);
}
async function syncWorkOrderStatusFromOperationActivity(workOrderId: string) {
const workOrder = await prisma.workOrder.findUnique({
where: { id: workOrderId },
select: {
id: true,
status: true,
},
});
if (!workOrder) {
return;
}
if (workOrder.status === "RELEASED" || workOrder.status === "ON_HOLD") {
await prisma.workOrder.update({
where: { id: workOrderId },
data: {
status: "IN_PROGRESS",
},
});
}
}
async function syncWorkOrderReservations(workOrderId: string) {
const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) {
@@ -1312,6 +1376,183 @@ export async function updateWorkOrderOperationSchedule(
return { ok: true as const, workOrder: rescheduled };
}
export async function updateWorkOrderOperationExecution(
workOrderId: string,
operationId: string,
payload: WorkOrderOperationExecutionInput,
actorId?: string | null
) {
const existing = await prisma.workOrderOperation.findUnique({
where: { id: operationId },
select: {
id: true,
workOrderId: true,
sequence: true,
status: true,
actualStart: true,
actualEnd: true,
actualMinutes: true,
plannedMinutes: 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: "Operation execution can only be updated on released or active work orders." };
}
const now = new Date();
const nextData: Record<string, unknown> = {};
if (payload.action === "START") {
if (existing.status === "COMPLETE") {
return { ok: false as const, reason: "Completed operations cannot be restarted." };
}
nextData.status = "IN_PROGRESS";
nextData.actualStart = existing.actualStart ?? now;
} else if (payload.action === "PAUSE") {
if (existing.status !== "IN_PROGRESS") {
return { ok: false as const, reason: "Only in-progress operations can be paused." };
}
nextData.status = "PAUSED";
nextData.actualStart = existing.actualStart ?? now;
} else if (payload.action === "RESUME") {
if (existing.status !== "PAUSED" && existing.status !== "PENDING") {
return { ok: false as const, reason: "Only paused or pending operations can be resumed." };
}
nextData.status = "IN_PROGRESS";
nextData.actualStart = existing.actualStart ?? now;
} else if (payload.action === "COMPLETE") {
if (existing.status === "COMPLETE") {
return { ok: false as const, reason: "Operation is already complete." };
}
nextData.status = "COMPLETE";
nextData.actualStart = existing.actualStart ?? now;
nextData.actualEnd = now;
}
await prisma.workOrderOperation.update({
where: { id: operationId },
data: nextData,
});
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.execution.updated",
summary: `${payload.action} operation ${existing.sequence} on ${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 recordWorkOrderOperationLabor(
workOrderId: string,
operationId: string,
payload: WorkOrderOperationLaborEntryInput,
createdById?: string | null
) {
const existing = await prisma.workOrderOperation.findUnique({
where: { id: operationId },
select: {
id: true,
workOrderId: true,
sequence: true,
status: true,
actualStart: true,
actualMinutes: 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: "Labor can only be posted to released or active work orders." };
}
if (existing.status === "COMPLETE") {
return { ok: false as const, reason: "Completed operations cannot receive additional labor entries." };
}
await prisma.$transaction(async (tx) => {
await tx.workOrderOperationLaborEntry.create({
data: {
operationId,
minutes: payload.minutes,
notes: payload.notes,
createdById: createdById ?? null,
},
});
await tx.workOrderOperation.update({
where: { id: operationId },
data: {
status: existing.status === "PENDING" ? "IN_PROGRESS" : existing.status,
actualStart: existing.actualStart ?? new Date(),
actualMinutes: {
increment: payload.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: createdById,
entityType: "work-order",
entityId: workOrderId,
action: "operation.labor.recorded",
summary: `Recorded labor on operation ${existing.sequence} for ${existing.workOrder.workOrderNumber}.`,
metadata: {
workOrderNumber: existing.workOrder.workOrderNumber,
operationId,
sequence: existing.sequence,
minutes: payload.minutes,
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 },