manufacturing and gantt
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
CREATE TABLE "ManufacturingStation" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"queueDays" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "InventoryItemOperation" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"stationId" TEXT NOT NULL,
|
||||
"setupMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"runMinutesPerUnit" INTEGER NOT NULL DEFAULT 0,
|
||||
"moveMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"notes" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "InventoryItemOperation_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "InventoryItemOperation_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "ManufacturingStation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE "WorkOrderOperation" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"workOrderId" TEXT NOT NULL,
|
||||
"stationId" TEXT NOT NULL,
|
||||
"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,
|
||||
"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
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "ManufacturingStation_code_key" ON "ManufacturingStation"("code");
|
||||
CREATE INDEX "InventoryItemOperation_itemId_position_idx" ON "InventoryItemOperation"("itemId", "position");
|
||||
CREATE INDEX "InventoryItemOperation_stationId_idx" ON "InventoryItemOperation"("stationId");
|
||||
CREATE INDEX "WorkOrderOperation_workOrderId_sequence_idx" ON "WorkOrderOperation"("workOrderId", "sequence");
|
||||
CREATE INDEX "WorkOrderOperation_stationId_plannedStart_idx" ON "WorkOrderOperation"("stationId", "plannedStart");
|
||||
@@ -133,6 +133,7 @@ model InventoryItem {
|
||||
purchaseOrderLines PurchaseOrderLine[]
|
||||
workOrders WorkOrder[]
|
||||
workOrderMaterialIssues WorkOrderMaterialIssue[]
|
||||
operations InventoryItemOperation[]
|
||||
}
|
||||
|
||||
model Warehouse {
|
||||
@@ -476,6 +477,7 @@ model WorkOrder {
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
|
||||
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
|
||||
operations WorkOrderOperation[]
|
||||
materialIssues WorkOrderMaterialIssue[]
|
||||
completions WorkOrderCompletion[]
|
||||
|
||||
@@ -485,6 +487,58 @@ model WorkOrder {
|
||||
@@index([warehouseId, createdAt])
|
||||
}
|
||||
|
||||
model ManufacturingStation {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
description String
|
||||
queueDays Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
itemOperations InventoryItemOperation[]
|
||||
workOrderOperations WorkOrderOperation[]
|
||||
}
|
||||
|
||||
model InventoryItemOperation {
|
||||
id String @id @default(cuid())
|
||||
itemId String
|
||||
stationId String
|
||||
setupMinutes Int @default(0)
|
||||
runMinutesPerUnit Int @default(0)
|
||||
moveMinutes Int @default(0)
|
||||
notes String
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([itemId, position])
|
||||
@@index([stationId])
|
||||
}
|
||||
|
||||
model WorkOrderOperation {
|
||||
id String @id @default(cuid())
|
||||
workOrderId String
|
||||
stationId String
|
||||
sequence Int
|
||||
setupMinutes Int @default(0)
|
||||
runMinutesPerUnit Int @default(0)
|
||||
moveMinutes Int @default(0)
|
||||
plannedMinutes Int @default(0)
|
||||
plannedStart DateTime
|
||||
plannedEnd DateTime
|
||||
notes String
|
||||
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)
|
||||
|
||||
@@index([workOrderId, sequence])
|
||||
@@index([stationId, plannedStart])
|
||||
}
|
||||
|
||||
model WorkOrderMaterialIssue {
|
||||
id String @id @default(cuid())
|
||||
workOrderId String
|
||||
|
||||
@@ -3,21 +3,10 @@ import { Router } from "express";
|
||||
|
||||
import { ok } from "../../lib/http.js";
|
||||
import { requirePermissions } from "../../lib/rbac.js";
|
||||
import { getPlanningTimeline } from "./service.js";
|
||||
|
||||
export const ganttRouter = Router();
|
||||
|
||||
ganttRouter.get("/demo", requirePermissions([permissions.ganttRead]), (_request, response) => {
|
||||
return ok(response, {
|
||||
tasks: [
|
||||
{ id: "project-1", text: "Machine Assembly Program", start: "2026-03-16", end: "2026-03-28", progress: 35, type: "project" },
|
||||
{ id: "task-1", text: "Frame fabrication", start: "2026-03-16", end: "2026-03-19", progress: 80, type: "task" },
|
||||
{ id: "task-2", text: "Electrical install", start: "2026-03-20", end: "2026-03-25", progress: 20, type: "task" },
|
||||
{ id: "milestone-1", text: "Factory acceptance", start: "2026-03-28", end: "2026-03-28", progress: 0, type: "milestone" }
|
||||
],
|
||||
links: [
|
||||
{ id: "link-1", source: "task-1", target: "task-2", type: "e2e" },
|
||||
{ id: "link-2", source: "task-2", target: "milestone-1", type: "e2e" }
|
||||
],
|
||||
});
|
||||
ganttRouter.get("/timeline", requirePermissions([permissions.ganttRead]), async (_request, response) => {
|
||||
return ok(response, await getPlanningTimeline());
|
||||
});
|
||||
|
||||
|
||||
460
server/src/modules/gantt/service.ts
Normal file
460
server/src/modules/gantt/service.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
import type { GanttLinkDto, GanttTaskDto, PlanningTimelineDto } from "@mrp/shared";
|
||||
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function clampProgress(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function addDays(value: Date, days: number) {
|
||||
return new Date(value.getTime() + days * DAY_MS);
|
||||
}
|
||||
|
||||
function startOfDay(value: Date) {
|
||||
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
|
||||
}
|
||||
|
||||
function endOfDay(value: Date) {
|
||||
return new Date(value.getFullYear(), value.getMonth(), value.getDate(), 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
function projectProgressFromStatus(status: string) {
|
||||
switch (status) {
|
||||
case "COMPLETE":
|
||||
return 100;
|
||||
case "AT_RISK":
|
||||
return 45;
|
||||
case "ACTIVE":
|
||||
return 60;
|
||||
case "ON_HOLD":
|
||||
return 20;
|
||||
default:
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
function workOrderProgress(quantity: number, completedQuantity: number, status: string) {
|
||||
if (status === "COMPLETE") {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (quantity <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return clampProgress((completedQuantity / quantity) * 100);
|
||||
}
|
||||
|
||||
function buildOwnerLabel(ownerName: string | null, customerName: string | null) {
|
||||
if (ownerName && customerName) {
|
||||
return `${ownerName} • ${customerName}`;
|
||||
}
|
||||
|
||||
return ownerName ?? customerName ?? null;
|
||||
}
|
||||
|
||||
export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
||||
const now = new Date();
|
||||
const planningProjects = await prisma.project.findMany({
|
||||
where: {
|
||||
status: {
|
||||
not: "COMPLETE",
|
||||
},
|
||||
},
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
workOrders: {
|
||||
where: {
|
||||
status: {
|
||||
notIn: ["COMPLETE", "CANCELLED"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
workOrderNumber: true,
|
||||
status: true,
|
||||
quantity: true,
|
||||
completedQuantity: true,
|
||||
dueDate: true,
|
||||
createdAt: true,
|
||||
operations: {
|
||||
select: {
|
||||
id: true,
|
||||
sequence: true,
|
||||
plannedStart: true,
|
||||
plannedEnd: true,
|
||||
plannedMinutes: true,
|
||||
station: {
|
||||
select: {
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ sequence: "asc" }],
|
||||
},
|
||||
item: {
|
||||
select: {
|
||||
sku: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
},
|
||||
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
const standaloneWorkOrders = await prisma.workOrder.findMany({
|
||||
where: {
|
||||
projectId: null,
|
||||
status: {
|
||||
notIn: ["COMPLETE", "CANCELLED"],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
item: {
|
||||
select: {
|
||||
sku: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
operations: {
|
||||
select: {
|
||||
id: true,
|
||||
sequence: true,
|
||||
plannedStart: true,
|
||||
plannedEnd: true,
|
||||
plannedMinutes: true,
|
||||
station: {
|
||||
select: {
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ sequence: "asc" }],
|
||||
},
|
||||
},
|
||||
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
const tasks: GanttTaskDto[] = [];
|
||||
const links: GanttLinkDto[] = [];
|
||||
const exceptions: PlanningTimelineDto["exceptions"] = [];
|
||||
|
||||
for (const project of planningProjects) {
|
||||
const ownerName = project.owner ? `${project.owner.firstName} ${project.owner.lastName}`.trim() : null;
|
||||
const ownerLabel = buildOwnerLabel(ownerName, project.customer.name);
|
||||
const dueDates = project.workOrders.map((workOrder) => workOrder.dueDate).filter((value): value is Date => Boolean(value));
|
||||
const earliestWorkStart = project.workOrders[0]?.createdAt ?? project.createdAt;
|
||||
const lastDueDate = dueDates.sort((left, right) => left.getTime() - right.getTime()).at(-1) ?? project.dueDate ?? addDays(project.createdAt, 14);
|
||||
const start = startOfDay(earliestWorkStart);
|
||||
const end = endOfDay(lastDueDate);
|
||||
|
||||
tasks.push({
|
||||
id: `project-${project.id}`,
|
||||
text: `${project.projectNumber} - ${project.name}`,
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
progress: clampProgress(
|
||||
project.workOrders.length > 0
|
||||
? project.workOrders.reduce((sum, workOrder) => sum + workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), 0) / project.workOrders.length
|
||||
: projectProgressFromStatus(project.status)
|
||||
),
|
||||
type: "project",
|
||||
status: project.status,
|
||||
ownerLabel,
|
||||
detailHref: `/projects/${project.id}`,
|
||||
});
|
||||
|
||||
if (project.dueDate) {
|
||||
tasks.push({
|
||||
id: `project-milestone-${project.id}`,
|
||||
text: `${project.projectNumber} due`,
|
||||
start: startOfDay(project.dueDate).toISOString(),
|
||||
end: startOfDay(project.dueDate).toISOString(),
|
||||
progress: project.status === "COMPLETE" ? 100 : 0,
|
||||
type: "milestone",
|
||||
parentId: `project-${project.id}`,
|
||||
status: project.status,
|
||||
ownerLabel,
|
||||
detailHref: `/projects/${project.id}`,
|
||||
});
|
||||
links.push({
|
||||
id: `project-link-${project.id}`,
|
||||
source: `project-${project.id}`,
|
||||
target: `project-milestone-${project.id}`,
|
||||
type: "e2e",
|
||||
});
|
||||
}
|
||||
|
||||
let previousTaskId: string | null = null;
|
||||
for (const workOrder of project.workOrders) {
|
||||
const workOrderStart = startOfDay(workOrder.createdAt);
|
||||
const workOrderEnd = endOfDay(workOrder.dueDate ?? addDays(workOrder.createdAt, 7));
|
||||
const workOrderTaskId = `work-order-${workOrder.id}`;
|
||||
tasks.push({
|
||||
id: workOrderTaskId,
|
||||
text: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
|
||||
start: workOrderStart.toISOString(),
|
||||
end: workOrderEnd.toISOString(),
|
||||
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
|
||||
type: "task",
|
||||
parentId: `project-${project.id}`,
|
||||
status: workOrder.status,
|
||||
ownerLabel: workOrder.item.name,
|
||||
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
||||
});
|
||||
|
||||
if (previousTaskId) {
|
||||
links.push({
|
||||
id: `sequence-${previousTaskId}-${workOrderTaskId}`,
|
||||
source: previousTaskId,
|
||||
target: workOrderTaskId,
|
||||
type: "e2e",
|
||||
});
|
||||
} else {
|
||||
links.push({
|
||||
id: `project-start-${project.id}-${workOrder.id}`,
|
||||
source: `project-${project.id}`,
|
||||
target: workOrderTaskId,
|
||||
type: "e2e",
|
||||
});
|
||||
}
|
||||
|
||||
previousTaskId = workOrderTaskId;
|
||||
|
||||
let previousOperationTaskId: string | null = null;
|
||||
for (const operation of workOrder.operations) {
|
||||
const operationTaskId = `work-order-operation-${operation.id}`;
|
||||
tasks.push({
|
||||
id: operationTaskId,
|
||||
text: `${operation.station.code} - ${operation.station.name}`,
|
||||
start: operation.plannedStart.toISOString(),
|
||||
end: operation.plannedEnd.toISOString(),
|
||||
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
|
||||
type: "task",
|
||||
parentId: workOrderTaskId,
|
||||
status: workOrder.status,
|
||||
ownerLabel: workOrder.workOrderNumber,
|
||||
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
||||
});
|
||||
|
||||
links.push({
|
||||
id: `work-order-operation-parent-${workOrder.id}-${operation.id}`,
|
||||
source: workOrderTaskId,
|
||||
target: operationTaskId,
|
||||
type: "e2e",
|
||||
});
|
||||
|
||||
if (previousOperationTaskId) {
|
||||
links.push({
|
||||
id: `work-order-operation-sequence-${previousOperationTaskId}-${operationTaskId}`,
|
||||
source: previousOperationTaskId,
|
||||
target: operationTaskId,
|
||||
type: "e2e",
|
||||
});
|
||||
}
|
||||
|
||||
previousOperationTaskId = operationTaskId;
|
||||
}
|
||||
|
||||
if (workOrder.dueDate && workOrder.dueDate.getTime() < now.getTime()) {
|
||||
exceptions.push({
|
||||
id: `work-order-${workOrder.id}`,
|
||||
kind: "WORK_ORDER",
|
||||
title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
|
||||
status: workOrder.status,
|
||||
dueDate: workOrder.dueDate.toISOString(),
|
||||
ownerLabel: project.projectNumber,
|
||||
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (project.dueDate && project.dueDate.getTime() < now.getTime()) {
|
||||
exceptions.push({
|
||||
id: `project-${project.id}`,
|
||||
kind: "PROJECT",
|
||||
title: `${project.projectNumber} - ${project.name}`,
|
||||
status: project.status,
|
||||
dueDate: project.dueDate.toISOString(),
|
||||
ownerLabel,
|
||||
detailHref: `/projects/${project.id}`,
|
||||
});
|
||||
} else if (project.status === "AT_RISK") {
|
||||
exceptions.push({
|
||||
id: `project-${project.id}`,
|
||||
kind: "PROJECT",
|
||||
title: `${project.projectNumber} - ${project.name}`,
|
||||
status: project.status,
|
||||
dueDate: project.dueDate ? project.dueDate.toISOString() : null,
|
||||
ownerLabel,
|
||||
detailHref: `/projects/${project.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (standaloneWorkOrders.length > 0) {
|
||||
const firstStandaloneWorkOrder = standaloneWorkOrders[0]!;
|
||||
const bucketStart = startOfDay(
|
||||
standaloneWorkOrders.reduce((earliest, workOrder) => (workOrder.createdAt < earliest ? workOrder.createdAt : earliest), firstStandaloneWorkOrder.createdAt)
|
||||
);
|
||||
const bucketEnd = endOfDay(
|
||||
standaloneWorkOrders.reduce(
|
||||
(latest, workOrder) => {
|
||||
const candidate = workOrder.dueDate ?? addDays(workOrder.createdAt, 7);
|
||||
return candidate > latest ? candidate : latest;
|
||||
},
|
||||
firstStandaloneWorkOrder.dueDate ?? addDays(firstStandaloneWorkOrder.createdAt, 7)
|
||||
)
|
||||
);
|
||||
tasks.push({
|
||||
id: "standalone-manufacturing",
|
||||
text: "Standalone Manufacturing Queue",
|
||||
start: bucketStart.toISOString(),
|
||||
end: bucketEnd.toISOString(),
|
||||
progress: clampProgress(
|
||||
standaloneWorkOrders.reduce((sum, workOrder) => sum + workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), 0) /
|
||||
standaloneWorkOrders.length
|
||||
),
|
||||
type: "project",
|
||||
status: "ACTIVE",
|
||||
ownerLabel: "Manufacturing",
|
||||
detailHref: "/manufacturing/work-orders",
|
||||
});
|
||||
|
||||
let previousStandaloneTaskId: string | null = null;
|
||||
for (const workOrder of standaloneWorkOrders) {
|
||||
const workOrderTaskId = `work-order-${workOrder.id}`;
|
||||
tasks.push({
|
||||
id: workOrderTaskId,
|
||||
text: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
|
||||
start: startOfDay(workOrder.createdAt).toISOString(),
|
||||
end: endOfDay(workOrder.dueDate ?? addDays(workOrder.createdAt, 7)).toISOString(),
|
||||
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
|
||||
type: "task",
|
||||
parentId: "standalone-manufacturing",
|
||||
status: workOrder.status,
|
||||
ownerLabel: workOrder.item.name,
|
||||
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
||||
});
|
||||
|
||||
if (previousStandaloneTaskId) {
|
||||
links.push({
|
||||
id: `sequence-${previousStandaloneTaskId}-${workOrderTaskId}`,
|
||||
source: previousStandaloneTaskId,
|
||||
target: workOrderTaskId,
|
||||
type: "e2e",
|
||||
});
|
||||
}
|
||||
|
||||
previousStandaloneTaskId = workOrderTaskId;
|
||||
|
||||
let previousOperationTaskId: string | null = null;
|
||||
for (const operation of workOrder.operations) {
|
||||
const operationTaskId = `work-order-operation-${operation.id}`;
|
||||
tasks.push({
|
||||
id: operationTaskId,
|
||||
text: `${operation.station.code} - ${operation.station.name}`,
|
||||
start: operation.plannedStart.toISOString(),
|
||||
end: operation.plannedEnd.toISOString(),
|
||||
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
|
||||
type: "task",
|
||||
parentId: workOrderTaskId,
|
||||
status: workOrder.status,
|
||||
ownerLabel: workOrder.workOrderNumber,
|
||||
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
||||
});
|
||||
|
||||
links.push({
|
||||
id: `work-order-operation-parent-${workOrder.id}-${operation.id}`,
|
||||
source: workOrderTaskId,
|
||||
target: operationTaskId,
|
||||
type: "e2e",
|
||||
});
|
||||
|
||||
if (previousOperationTaskId) {
|
||||
links.push({
|
||||
id: `work-order-operation-sequence-${previousOperationTaskId}-${operationTaskId}`,
|
||||
source: previousOperationTaskId,
|
||||
target: operationTaskId,
|
||||
type: "e2e",
|
||||
});
|
||||
}
|
||||
|
||||
previousOperationTaskId = operationTaskId;
|
||||
}
|
||||
|
||||
if (workOrder.dueDate === null) {
|
||||
exceptions.push({
|
||||
id: `work-order-unscheduled-${workOrder.id}`,
|
||||
kind: "WORK_ORDER",
|
||||
title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
|
||||
status: workOrder.status,
|
||||
dueDate: null,
|
||||
ownerLabel: "No project",
|
||||
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
||||
});
|
||||
} else if (workOrder.dueDate.getTime() < now.getTime()) {
|
||||
exceptions.push({
|
||||
id: `work-order-${workOrder.id}`,
|
||||
kind: "WORK_ORDER",
|
||||
title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
|
||||
status: workOrder.status,
|
||||
dueDate: workOrder.dueDate.toISOString(),
|
||||
ownerLabel: "No project",
|
||||
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const taskDates = tasks.flatMap((task) => [new Date(task.start), new Date(task.end)]);
|
||||
const horizonStart = taskDates.length > 0 ? new Date(Math.min(...taskDates.map((date) => date.getTime()))) : startOfDay(now);
|
||||
const horizonEnd = taskDates.length > 0 ? new Date(Math.max(...taskDates.map((date) => date.getTime()))) : addDays(startOfDay(now), 30);
|
||||
|
||||
return {
|
||||
tasks,
|
||||
links,
|
||||
summary: {
|
||||
activeProjects: planningProjects.filter((project) => project.status === "ACTIVE").length,
|
||||
atRiskProjects: planningProjects.filter((project) => project.status === "AT_RISK").length,
|
||||
overdueProjects: planningProjects.filter((project) => project.dueDate && project.dueDate.getTime() < now.getTime()).length,
|
||||
activeWorkOrders: [...planningProjects.flatMap((project) => project.workOrders), ...standaloneWorkOrders].filter((workOrder) =>
|
||||
["RELEASED", "IN_PROGRESS", "ON_HOLD"].includes(workOrder.status)
|
||||
).length,
|
||||
overdueWorkOrders: [...planningProjects.flatMap((project) => project.workOrders), ...standaloneWorkOrders].filter(
|
||||
(workOrder) => workOrder.dueDate && workOrder.dueDate.getTime() < now.getTime()
|
||||
).length,
|
||||
unscheduledWorkOrders: standaloneWorkOrders.filter((workOrder) => workOrder.dueDate === null).length,
|
||||
horizonStart: horizonStart.toISOString(),
|
||||
horizonEnd: horizonEnd.toISOString(),
|
||||
},
|
||||
exceptions: exceptions
|
||||
.sort((left, right) => {
|
||||
if (!left.dueDate) {
|
||||
return 1;
|
||||
}
|
||||
if (!right.dueDate) {
|
||||
return -1;
|
||||
}
|
||||
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
|
||||
})
|
||||
.slice(0, 12),
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,15 @@ const bomLineSchema = z.object({
|
||||
position: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
const operationSchema = z.object({
|
||||
stationId: z.string().trim().min(1),
|
||||
setupMinutes: z.number().int().nonnegative(),
|
||||
runMinutesPerUnit: z.number().int().nonnegative(),
|
||||
moveMinutes: z.number().int().nonnegative(),
|
||||
position: z.number().int().nonnegative(),
|
||||
notes: z.string(),
|
||||
});
|
||||
|
||||
const inventoryItemSchema = z.object({
|
||||
sku: z.string().trim().min(1).max(64),
|
||||
name: z.string().trim().min(1).max(160),
|
||||
@@ -40,6 +49,7 @@ const inventoryItemSchema = z.object({
|
||||
defaultPrice: z.number().nonnegative().nullable(),
|
||||
notes: z.string(),
|
||||
bomLines: z.array(bomLineSchema),
|
||||
operations: z.array(operationSchema),
|
||||
});
|
||||
|
||||
const inventoryListQuerySchema = z.object({
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
InventoryBomLineInput,
|
||||
InventoryItemDetailDto,
|
||||
InventoryItemInput,
|
||||
InventoryItemOperationDto,
|
||||
InventoryStockBalanceDto,
|
||||
WarehouseDetailDto,
|
||||
WarehouseInput,
|
||||
@@ -34,6 +35,20 @@ type BomLineRecord = {
|
||||
};
|
||||
};
|
||||
|
||||
type OperationRecord = {
|
||||
id: string;
|
||||
setupMinutes: number;
|
||||
runMinutesPerUnit: number;
|
||||
moveMinutes: number;
|
||||
notes: string;
|
||||
position: number;
|
||||
station: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
type InventoryDetailRecord = {
|
||||
id: string;
|
||||
sku: string;
|
||||
@@ -50,6 +65,7 @@ type InventoryDetailRecord = {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
bomLines: BomLineRecord[];
|
||||
operations: OperationRecord[];
|
||||
inventoryTransactions: InventoryTransactionRecord[];
|
||||
};
|
||||
|
||||
@@ -106,6 +122,21 @@ function mapBomLine(record: BomLineRecord): InventoryBomLineDto {
|
||||
};
|
||||
}
|
||||
|
||||
function mapOperation(record: OperationRecord): InventoryItemOperationDto {
|
||||
return {
|
||||
id: record.id,
|
||||
stationId: record.station.id,
|
||||
stationCode: record.station.code,
|
||||
stationName: record.station.name,
|
||||
setupMinutes: record.setupMinutes,
|
||||
runMinutesPerUnit: record.runMinutesPerUnit,
|
||||
moveMinutes: record.moveMinutes,
|
||||
estimatedMinutesPerUnit: record.setupMinutes + record.runMinutesPerUnit + record.moveMinutes,
|
||||
position: record.position,
|
||||
notes: record.notes,
|
||||
};
|
||||
}
|
||||
|
||||
function mapWarehouseLocation(record: WarehouseLocationRecord): WarehouseLocationDto {
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -225,6 +256,7 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
|
||||
notes: record.notes,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
|
||||
operations: record.operations.slice().sort((a, b) => a.position - b.position).map(mapOperation),
|
||||
onHandQuantity: stockBalances.reduce((sum, balance) => sum + balance.quantityOnHand, 0),
|
||||
stockBalances,
|
||||
recentTransactions,
|
||||
@@ -298,6 +330,19 @@ function normalizeBomLines(bomLines: InventoryBomLineInput[]) {
|
||||
.filter((line) => line.componentItemId.trim().length > 0);
|
||||
}
|
||||
|
||||
function normalizeOperations(operations: InventoryItemInput["operations"]) {
|
||||
return operations
|
||||
.map((operation, index) => ({
|
||||
stationId: operation.stationId,
|
||||
setupMinutes: Number(operation.setupMinutes),
|
||||
runMinutesPerUnit: Number(operation.runMinutesPerUnit),
|
||||
moveMinutes: Number(operation.moveMinutes),
|
||||
notes: operation.notes,
|
||||
position: operation.position ?? (index + 1) * 10,
|
||||
}))
|
||||
.filter((operation) => operation.stationId.trim().length > 0);
|
||||
}
|
||||
|
||||
function normalizeWarehouseLocations(locations: WarehouseLocationInput[]) {
|
||||
return locations
|
||||
.map((location) => ({
|
||||
@@ -346,6 +391,49 @@ async function validateBomLines(parentItemId: string | null, bomLines: Inventory
|
||||
return { ok: true as const, bomLines: normalized };
|
||||
}
|
||||
|
||||
async function validateOperations(type: InventoryItemType, operations: InventoryItemInput["operations"]) {
|
||||
const normalized = normalizeOperations(operations);
|
||||
|
||||
if (type === "ASSEMBLY" || type === "MANUFACTURED") {
|
||||
if (normalized.length === 0) {
|
||||
return { ok: false as const, reason: "Assembly and manufactured items require at least one station operation." };
|
||||
}
|
||||
} else if (normalized.length > 0) {
|
||||
return { ok: false as const, reason: "Only assembly and manufactured items may define station operations." };
|
||||
}
|
||||
|
||||
if (normalized.some((operation) => operation.setupMinutes < 0 || operation.runMinutesPerUnit < 0 || operation.moveMinutes < 0)) {
|
||||
return { ok: false as const, reason: "Operation times must be zero or greater." };
|
||||
}
|
||||
|
||||
if (normalized.some((operation) => operation.setupMinutes + operation.runMinutesPerUnit + operation.moveMinutes <= 0)) {
|
||||
return { ok: false as const, reason: "Each operation must have at least some planned time." };
|
||||
}
|
||||
|
||||
const stationIds = [...new Set(normalized.map((operation) => operation.stationId))];
|
||||
if (stationIds.length === 0) {
|
||||
return { ok: true as const, operations: normalized };
|
||||
}
|
||||
|
||||
const existingStations = await prisma.manufacturingStation.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: stationIds,
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingStations.length !== stationIds.length) {
|
||||
return { ok: false as const, reason: "One or more selected stations do not exist or are inactive." };
|
||||
}
|
||||
|
||||
return { ok: true as const, operations: normalized };
|
||||
}
|
||||
|
||||
export async function listInventoryItems(filters: InventoryListFilters = {}) {
|
||||
const items = await prisma.inventoryItem.findMany({
|
||||
where: buildWhereClause(filters),
|
||||
@@ -404,6 +492,18 @@ export async function getInventoryItemById(itemId: string) {
|
||||
},
|
||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
operations: {
|
||||
include: {
|
||||
station: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
inventoryTransactions: {
|
||||
include: {
|
||||
warehouse: {
|
||||
@@ -511,6 +611,10 @@ export async function createInventoryItem(payload: InventoryItemInput) {
|
||||
if (!validatedBom.ok) {
|
||||
return null;
|
||||
}
|
||||
const validatedOperations = await validateOperations(payload.type, payload.operations);
|
||||
if (!validatedOperations.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = await prisma.inventoryItem.create({
|
||||
data: {
|
||||
@@ -530,6 +634,11 @@ export async function createInventoryItem(payload: InventoryItemInput) {
|
||||
create: validatedBom.bomLines,
|
||||
}
|
||||
: undefined,
|
||||
operations: validatedOperations.operations.length
|
||||
? {
|
||||
create: validatedOperations.operations,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -552,6 +661,10 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
|
||||
if (!validatedBom.ok) {
|
||||
return null;
|
||||
}
|
||||
const validatedOperations = await validateOperations(payload.type, payload.operations);
|
||||
if (!validatedOperations.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = await prisma.inventoryItem.update({
|
||||
where: { id: itemId },
|
||||
@@ -571,6 +684,10 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
|
||||
deleteMany: {},
|
||||
create: validatedBom.bomLines,
|
||||
},
|
||||
operations: {
|
||||
deleteMany: {},
|
||||
create: validatedOperations.operations,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -6,17 +6,27 @@ import { z } from "zod";
|
||||
import { fail, ok } from "../../lib/http.js";
|
||||
import { requirePermissions } from "../../lib/rbac.js";
|
||||
import {
|
||||
createManufacturingStation,
|
||||
createWorkOrder,
|
||||
getWorkOrderById,
|
||||
issueWorkOrderMaterial,
|
||||
listManufacturingItemOptions,
|
||||
listManufacturingProjectOptions,
|
||||
listManufacturingStations,
|
||||
listWorkOrders,
|
||||
recordWorkOrderCompletion,
|
||||
updateWorkOrder,
|
||||
updateWorkOrderStatus,
|
||||
} from "./service.js";
|
||||
|
||||
const stationSchema = z.object({
|
||||
code: z.string().trim().min(1).max(64),
|
||||
name: z.string().trim().min(1).max(160),
|
||||
description: z.string(),
|
||||
queueDays: z.number().int().min(0).max(365),
|
||||
isActive: z.boolean(),
|
||||
});
|
||||
|
||||
const workOrderSchema = z.object({
|
||||
itemId: z.string().trim().min(1),
|
||||
projectId: z.string().trim().min(1).nullable(),
|
||||
@@ -66,6 +76,19 @@ manufacturingRouter.get("/projects/options", requirePermissions([permissions.man
|
||||
return ok(response, await listManufacturingProjectOptions());
|
||||
});
|
||||
|
||||
manufacturingRouter.get("/stations", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
|
||||
return ok(response, await listManufacturingStations());
|
||||
});
|
||||
|
||||
manufacturingRouter.post("/stations", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
|
||||
const parsed = stationSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Manufacturing station payload is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await createManufacturingStation(parsed.data), 201);
|
||||
});
|
||||
|
||||
manufacturingRouter.get("/work-orders", requirePermissions([permissions.manufacturingRead]), async (request, response) => {
|
||||
const parsed = workOrderFiltersSchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type {
|
||||
ManufacturingStationDto,
|
||||
ManufacturingStationInput,
|
||||
ManufacturingItemOptionDto,
|
||||
ManufacturingProjectOptionDto,
|
||||
WorkOrderCompletionInput,
|
||||
WorkOrderDetailDto,
|
||||
WorkOrderInput,
|
||||
WorkOrderOperationDto,
|
||||
WorkOrderMaterialIssueInput,
|
||||
WorkOrderStatus,
|
||||
WorkOrderSummaryDto,
|
||||
@@ -13,6 +16,17 @@ import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const workOrderModel = (prisma as any).workOrder;
|
||||
|
||||
type StationRecord = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
queueDays: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type WorkOrderRecord = {
|
||||
id: string;
|
||||
workOrderNumber: string;
|
||||
@@ -29,6 +43,19 @@ type WorkOrderRecord = {
|
||||
name: string;
|
||||
type: string;
|
||||
unitOfMeasure: string;
|
||||
operations: Array<{
|
||||
setupMinutes: number;
|
||||
runMinutesPerUnit: number;
|
||||
moveMinutes: number;
|
||||
position: number;
|
||||
notes: string;
|
||||
station: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
queueDays: number;
|
||||
};
|
||||
}>;
|
||||
bomLines: Array<{
|
||||
quantity: number;
|
||||
unitOfMeasure: string;
|
||||
@@ -57,6 +84,22 @@ type WorkOrderRecord = {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
operations: Array<{
|
||||
id: string;
|
||||
sequence: number;
|
||||
setupMinutes: number;
|
||||
runMinutesPerUnit: number;
|
||||
moveMinutes: number;
|
||||
plannedMinutes: number;
|
||||
plannedStart: Date;
|
||||
plannedEnd: Date;
|
||||
notes: string;
|
||||
station: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
}>;
|
||||
materialIssues: Array<{
|
||||
id: string;
|
||||
quantity: number;
|
||||
@@ -94,10 +137,36 @@ type WorkOrderRecord = {
|
||||
}>;
|
||||
};
|
||||
|
||||
function mapStation(record: StationRecord): ManufacturingStationDto {
|
||||
return {
|
||||
id: record.id,
|
||||
code: record.code,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
queueDays: record.queueDays,
|
||||
isActive: record.isActive,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildInclude() {
|
||||
return {
|
||||
item: {
|
||||
include: {
|
||||
operations: {
|
||||
include: {
|
||||
station: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
queueDays: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
bomLines: {
|
||||
include: {
|
||||
componentItem: {
|
||||
@@ -135,6 +204,18 @@ function buildInclude() {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
operations: {
|
||||
include: {
|
||||
station: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ sequence: "asc" }],
|
||||
},
|
||||
materialIssues: {
|
||||
include: {
|
||||
componentItem: {
|
||||
@@ -205,6 +286,8 @@ function mapSummary(record: WorkOrderRecord): WorkOrderSummaryDto {
|
||||
locationId: record.location.id,
|
||||
locationCode: record.location.code,
|
||||
locationName: record.location.name,
|
||||
operationCount: record.operations.length,
|
||||
totalPlannedMinutes: record.operations.reduce((sum, operation) => sum + operation.plannedMinutes, 0),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -224,6 +307,20 @@ function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
|
||||
itemUnitOfMeasure: record.item.unitOfMeasure,
|
||||
projectCustomerName: record.project?.customer.name ?? null,
|
||||
dueQuantity: record.quantity - record.completedQuantity,
|
||||
operations: record.operations.map((operation): WorkOrderOperationDto => ({
|
||||
id: operation.id,
|
||||
stationId: operation.station.id,
|
||||
stationCode: operation.station.code,
|
||||
stationName: operation.station.name,
|
||||
sequence: operation.sequence,
|
||||
setupMinutes: operation.setupMinutes,
|
||||
runMinutesPerUnit: operation.runMinutesPerUnit,
|
||||
moveMinutes: operation.moveMinutes,
|
||||
plannedMinutes: operation.plannedMinutes,
|
||||
plannedStart: operation.plannedStart.toISOString(),
|
||||
plannedEnd: operation.plannedEnd.toISOString(),
|
||||
notes: operation.notes,
|
||||
})),
|
||||
materialRequirements: record.item.bomLines.map((line) => {
|
||||
const requiredQuantity = line.quantity * record.quantity;
|
||||
const issuedQuantity = issuedByComponent.get(line.componentItem.id) ?? 0;
|
||||
@@ -265,6 +362,107 @@ function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
|
||||
};
|
||||
}
|
||||
|
||||
function addMinutes(value: Date, minutes: number) {
|
||||
return new Date(value.getTime() + minutes * 60 * 1000);
|
||||
}
|
||||
|
||||
function buildWorkOrderOperationPlan(
|
||||
itemOperations: WorkOrderRecord["item"]["operations"],
|
||||
quantity: number,
|
||||
dueDate: Date | null,
|
||||
fallbackStart: Date
|
||||
) {
|
||||
if (itemOperations.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const operationDurations = itemOperations.map((operation) => {
|
||||
const plannedMinutes = Math.max(operation.setupMinutes + operation.runMinutesPerUnit * quantity + operation.moveMinutes + operation.station.queueDays * 8 * 60, 1);
|
||||
return {
|
||||
stationId: operation.station.id,
|
||||
sequence: operation.position,
|
||||
setupMinutes: operation.setupMinutes,
|
||||
runMinutesPerUnit: operation.runMinutesPerUnit,
|
||||
moveMinutes: operation.moveMinutes,
|
||||
plannedMinutes,
|
||||
notes: operation.notes,
|
||||
};
|
||||
});
|
||||
|
||||
if (dueDate) {
|
||||
let nextEnd = new Date(dueDate);
|
||||
return operationDurations
|
||||
.slice()
|
||||
.sort((left, right) => right.sequence - left.sequence)
|
||||
.map((operation) => {
|
||||
const plannedStart = addMinutes(nextEnd, -operation.plannedMinutes);
|
||||
const planned = {
|
||||
...operation,
|
||||
plannedStart,
|
||||
plannedEnd: nextEnd,
|
||||
};
|
||||
nextEnd = plannedStart;
|
||||
return planned;
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
let nextStart = new Date(fallbackStart);
|
||||
return operationDurations.map((operation) => {
|
||||
const plannedEnd = addMinutes(nextStart, operation.plannedMinutes);
|
||||
const planned = {
|
||||
...operation,
|
||||
plannedStart: nextStart,
|
||||
plannedEnd,
|
||||
};
|
||||
nextStart = plannedEnd;
|
||||
return planned;
|
||||
});
|
||||
}
|
||||
|
||||
async function regenerateWorkOrderOperations(workOrderId: string) {
|
||||
const workOrder = await workOrderModel.findUnique({
|
||||
where: { id: workOrderId },
|
||||
include: buildInclude(),
|
||||
});
|
||||
|
||||
if (!workOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plan = buildWorkOrderOperationPlan(
|
||||
(workOrder as WorkOrderRecord).item.operations,
|
||||
workOrder.quantity,
|
||||
workOrder.dueDate,
|
||||
workOrder.createdAt
|
||||
);
|
||||
|
||||
await prisma.workOrderOperation.deleteMany({
|
||||
where: {
|
||||
workOrderId,
|
||||
},
|
||||
});
|
||||
|
||||
if (plan.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.workOrderOperation.createMany({
|
||||
data: plan.map((operation) => ({
|
||||
workOrderId,
|
||||
stationId: operation.stationId,
|
||||
sequence: operation.sequence,
|
||||
setupMinutes: operation.setupMinutes,
|
||||
runMinutesPerUnit: operation.runMinutesPerUnit,
|
||||
moveMinutes: operation.moveMinutes,
|
||||
plannedMinutes: operation.plannedMinutes,
|
||||
plannedStart: operation.plannedStart,
|
||||
plannedEnd: operation.plannedEnd,
|
||||
notes: operation.notes,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async function nextWorkOrderNumber() {
|
||||
const next = (await workOrderModel.count()) + 1;
|
||||
return `WO-${String(next).padStart(5, "0")}`;
|
||||
@@ -295,6 +493,11 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
_count: {
|
||||
select: {
|
||||
operations: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -310,6 +513,10 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
|
||||
return { ok: false as const, reason: "Work orders can only be created for assembly or manufactured items." };
|
||||
}
|
||||
|
||||
if (item._count.operations === 0) {
|
||||
return { ok: false as const, reason: "Build item must have at least one station operation before a work order can be created." };
|
||||
}
|
||||
|
||||
if (payload.projectId) {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: payload.projectId },
|
||||
@@ -350,11 +557,51 @@ export async function listManufacturingItemOptions(): Promise<ManufacturingItemO
|
||||
name: true,
|
||||
type: true,
|
||||
unitOfMeasure: true,
|
||||
operations: {
|
||||
select: {
|
||||
setupMinutes: true,
|
||||
runMinutesPerUnit: true,
|
||||
moveMinutes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ sku: "asc" }],
|
||||
});
|
||||
|
||||
return items;
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
sku: item.sku,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
unitOfMeasure: item.unitOfMeasure,
|
||||
operationCount: item.operations.length,
|
||||
totalEstimatedMinutesPerUnit: item.operations.reduce(
|
||||
(sum, operation) => sum + operation.setupMinutes + operation.runMinutesPerUnit + operation.moveMinutes,
|
||||
0
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listManufacturingStations(): Promise<ManufacturingStationDto[]> {
|
||||
const stations = await prisma.manufacturingStation.findMany({
|
||||
orderBy: [{ code: "asc" }],
|
||||
});
|
||||
|
||||
return stations.map(mapStation);
|
||||
}
|
||||
|
||||
export async function createManufacturingStation(payload: ManufacturingStationInput) {
|
||||
const station = await prisma.manufacturingStation.create({
|
||||
data: {
|
||||
code: payload.code.trim(),
|
||||
name: payload.name.trim(),
|
||||
description: payload.description,
|
||||
queueDays: payload.queueDays,
|
||||
isActive: payload.isActive,
|
||||
},
|
||||
});
|
||||
|
||||
return mapStation(station);
|
||||
}
|
||||
|
||||
export async function listManufacturingProjectOptions(): Promise<ManufacturingProjectOptionDto[]> {
|
||||
@@ -448,6 +695,8 @@ export async function createWorkOrder(payload: WorkOrderInput) {
|
||||
},
|
||||
});
|
||||
|
||||
await regenerateWorkOrderOperations(created.id);
|
||||
|
||||
const workOrder = await getWorkOrderById(created.id);
|
||||
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
||||
}
|
||||
@@ -488,6 +737,8 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
|
||||
},
|
||||
});
|
||||
|
||||
await regenerateWorkOrderOperations(workOrderId);
|
||||
|
||||
const workOrder = await getWorkOrderById(workOrderId);
|
||||
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user