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

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