manufacturing and gantt

This commit is contained in:
2026-03-15 12:11:46 -05:00
parent a9d31730f8
commit 16582d3cea
26 changed files with 1614 additions and 75 deletions

View File

@@ -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");

View File

@@ -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

View File

@@ -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());
});

View 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),
};
}

View File

@@ -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({

View File

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

View File

@@ -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) {

View File

@@ -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." };
}