workbench rebalance

This commit is contained in:
2026-03-18 00:10:15 -05:00
parent 14708d7013
commit abc795b4a7
14 changed files with 640 additions and 48 deletions

View File

@@ -0,0 +1,3 @@
ALTER TABLE "ManufacturingStation" ADD COLUMN "dailyCapacityMinutes" INTEGER NOT NULL DEFAULT 480;
ALTER TABLE "ManufacturingStation" ADD COLUMN "parallelCapacity" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "ManufacturingStation" ADD COLUMN "workingDays" TEXT NOT NULL DEFAULT '1,2,3,4,5';

View File

@@ -648,6 +648,9 @@ model ManufacturingStation {
name String
description String
queueDays Int @default(0)
dailyCapacityMinutes Int @default(480)
parallelCapacity Int @default(1)
workingDays String @default("1,2,3,4,5")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -10,8 +10,6 @@ import type {
import { prisma } from "../../lib/prisma.js";
const DAY_MS = 24 * 60 * 60 * 1000;
const SHIFT_MINUTES_PER_DAY = 8 * 60;
type PlanningProjectRecord = {
id: string;
projectNumber: string;
@@ -59,7 +57,7 @@ type PlanningWorkOrderRecord = {
plannedStart: Date;
plannedEnd: Date;
plannedMinutes: number;
station: { id: string; code: string; name: string };
station: { id: string; code: string; name: string; dailyCapacityMinutes: number; parallelCapacity: number; workingDays: string };
}>;
materialIssues: Array<{ componentItemId: string; quantity: number }>;
};
@@ -88,6 +86,9 @@ type StationAccumulator = {
readyCount: number;
lateCount: number;
dayKeys: Set<string>;
dailyCapacityMinutes: number;
parallelCapacity: number;
workingDays: number[];
};
function clampProgress(value: number) {
@@ -153,6 +154,14 @@ function encodeQuery(params: Record<string, string | null | undefined>) {
return query.length > 0 ? `?${query}` : "";
}
function parseWorkingDays(value: string) {
const parsed = value
.split(",")
.map((entry) => Number.parseInt(entry.trim(), 10))
.filter((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 6);
return parsed.length > 0 ? parsed : [1, 2, 3, 4, 5];
}
function isBuildItem(type: string) {
return type === "ASSEMBLY" || type === "MANUFACTURED";
}
@@ -166,7 +175,7 @@ function getAvailabilityKey(itemId: string, warehouseId: string, locationId: str
}
function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
const capacityMinutes = Math.max(record.dayKeys.size, 1) * SHIFT_MINUTES_PER_DAY;
const capacityMinutes = Math.max(record.dayKeys.size, 1) * Math.max(record.dailyCapacityMinutes, 60) * Math.max(record.parallelCapacity, 1);
const utilizationPercent = capacityMinutes > 0 ? Math.round((record.totalPlannedMinutes / capacityMinutes) * 100) : 0;
return {
stationId: record.stationId,
@@ -299,7 +308,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
plannedStart: true,
plannedEnd: true,
plannedMinutes: true,
station: { select: { id: true, code: true, name: true } },
station: { select: { id: true, code: true, name: true, dailyCapacityMinutes: true, parallelCapacity: true, workingDays: true } },
},
orderBy: [{ sequence: "asc" }],
},
@@ -489,6 +498,9 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
readyCount: 0,
lateCount: 0,
dayKeys: new Set<string>(),
dailyCapacityMinutes: operation.station.dailyCapacityMinutes,
parallelCapacity: operation.station.parallelCapacity,
workingDays: parseWorkingDays(operation.station.workingDays),
};
current.operationCount += 1;
current.workOrderIds.add(workOrder.id);

View File

@@ -16,6 +16,7 @@ import {
listWorkOrders,
recordWorkOrderCompletion,
updateWorkOrder,
updateWorkOrderOperationSchedule,
updateWorkOrderStatus,
} from "./service.js";
@@ -24,6 +25,9 @@ const stationSchema = z.object({
name: z.string().trim().min(1).max(160),
description: z.string(),
queueDays: z.number().int().min(0).max(365),
dailyCapacityMinutes: z.number().int().min(60).max(1440),
parallelCapacity: z.number().int().min(1).max(24),
workingDays: z.array(z.number().int().min(0).max(6)).min(1).max(7),
isActive: z.boolean(),
});
@@ -64,6 +68,11 @@ const completionSchema = z.object({
notes: z.string(),
});
const operationScheduleSchema = z.object({
plannedStart: z.string().datetime(),
stationId: z.string().trim().min(1).nullable().optional(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
@@ -166,6 +175,26 @@ manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions
return ok(response, result.workOrder);
});
manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/schedule", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
const operationId = getRouteParam(request.params.operationId);
if (!workOrderId || !operationId) {
return fail(response, 400, "INVALID_INPUT", "Work-order or operation id is invalid.");
}
const parsed = operationScheduleSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation schedule payload is invalid.");
}
const result = await updateWorkOrderOperationSchedule(workOrderId, operationId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.post("/work-orders/:workOrderId/issues", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {

View File

@@ -7,6 +7,7 @@ import type {
WorkOrderDetailDto,
WorkOrderInput,
WorkOrderOperationDto,
WorkOrderOperationScheduleInput,
WorkOrderMaterialIssueInput,
WorkOrderStatus,
WorkOrderSummaryDto,
@@ -23,6 +24,9 @@ type StationRecord = {
name: string;
description: string;
queueDays: number;
dailyCapacityMinutes: number;
parallelCapacity: number;
workingDays: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
@@ -55,6 +59,9 @@ type WorkOrderRecord = {
code: string;
name: string;
queueDays: number;
dailyCapacityMinutes: number;
parallelCapacity: number;
workingDays: string;
};
}>;
bomLines: Array<{
@@ -106,6 +113,9 @@ type WorkOrderRecord = {
id: string;
code: string;
name: string;
dailyCapacityMinutes: number;
parallelCapacity: number;
workingDays: string;
};
}>;
materialIssues: Array<{
@@ -146,12 +156,16 @@ type WorkOrderRecord = {
};
function mapStation(record: StationRecord): ManufacturingStationDto {
const workingDays = record.workingDays.split(",").map((value) => Number.parseInt(value, 10)).filter((value) => Number.isInteger(value) && value >= 0 && value <= 6);
return {
id: record.id,
code: record.code,
name: record.name,
description: record.description,
queueDays: record.queueDays,
dailyCapacityMinutes: record.dailyCapacityMinutes,
parallelCapacity: record.parallelCapacity,
workingDays: workingDays.length > 0 ? workingDays : [1, 2, 3, 4, 5],
isActive: record.isActive,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
@@ -170,6 +184,9 @@ function buildInclude() {
code: true,
name: true,
queueDays: true,
dailyCapacityMinutes: true,
parallelCapacity: true,
workingDays: true,
},
},
},
@@ -230,6 +247,9 @@ function buildInclude() {
id: true,
code: true,
name: true,
dailyCapacityMinutes: true,
parallelCapacity: true,
workingDays: true,
},
},
},
@@ -337,6 +357,9 @@ function mapDetail(
stationId: operation.station.id,
stationCode: operation.station.code,
stationName: operation.station.name,
stationDailyCapacityMinutes: operation.station.dailyCapacityMinutes,
stationParallelCapacity: operation.station.parallelCapacity,
stationWorkingDays: parseWorkingDays(operation.station.workingDays),
sequence: operation.sequence,
setupMinutes: operation.setupMinutes,
runMinutesPerUnit: operation.runMinutesPerUnit,
@@ -400,6 +423,68 @@ function addMinutes(value: Date, minutes: number) {
return new Date(value.getTime() + minutes * 60 * 1000);
}
function parseWorkingDays(value: string) {
const parsed = value
.split(",")
.map((entry) => Number.parseInt(entry.trim(), 10))
.filter((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 6);
return parsed.length > 0 ? parsed : [1, 2, 3, 4, 5];
}
function normalizeStationWorkingDays(value: number[]) {
const normalized = [...new Set(value.filter((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 6))].sort((left, right) => left - right);
return normalized.length > 0 ? normalized : [1, 2, 3, 4, 5];
}
function alignToWorkingWindow(value: Date, workingDays: number[]) {
let next = new Date(value);
while (!workingDays.includes(next.getDay())) {
next = new Date(next.getFullYear(), next.getMonth(), next.getDate() + 1, 8, 0, 0, 0);
}
return next;
}
function addWorkingMinutes(
start: Date,
minutes: number,
station: { dailyCapacityMinutes: number; parallelCapacity: number; workingDays: string; queueDays: number }
) {
const workingDays = parseWorkingDays(station.workingDays);
const dailyCapacityMinutes = Math.max(station.dailyCapacityMinutes * Math.max(station.parallelCapacity, 1), 60);
let cursor = alignToWorkingWindow(start, workingDays);
let remaining = Math.max(minutes, 0);
while (remaining > 0) {
const dayStart = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate(), 8, 0, 0, 0);
const dayEnd = addMinutes(dayStart, dailyCapacityMinutes);
const effectiveStart = cursor < dayStart ? dayStart : cursor;
const availableToday = Math.max(Math.round((dayEnd.getTime() - effectiveStart.getTime()) / 60000), 0);
if (availableToday <= 0) {
cursor = alignToWorkingWindow(new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1, 8, 0, 0, 0), workingDays);
continue;
}
const consumed = Math.min(availableToday, remaining);
cursor = addMinutes(effectiveStart, consumed);
remaining -= consumed;
if (remaining > 0) {
cursor = alignToWorkingWindow(new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1, 8, 0, 0, 0), workingDays);
}
}
if (station.queueDays > 0) {
let queued = new Date(cursor);
for (let day = 0; day < station.queueDays; day += 1) {
queued = alignToWorkingWindow(new Date(queued.getFullYear(), queued.getMonth(), queued.getDate() + 1, queued.getHours(), queued.getMinutes(), 0, 0), workingDays);
}
return queued;
}
return cursor;
}
function shouldReserveForStatus(status: string) {
return status === "RELEASED" || status === "IN_PROGRESS" || status === "ON_HOLD";
}
@@ -415,7 +500,7 @@ function buildWorkOrderOperationPlan(
}
const operationDurations = itemOperations.map((operation) => {
const plannedMinutes = Math.max(operation.setupMinutes + operation.runMinutesPerUnit * quantity + operation.moveMinutes + operation.station.queueDays * 8 * 60, 1);
const plannedMinutes = Math.max(operation.setupMinutes + operation.runMinutesPerUnit * quantity + operation.moveMinutes, 1);
return {
stationId: operation.station.id,
sequence: operation.position,
@@ -424,33 +509,17 @@ function buildWorkOrderOperationPlan(
moveMinutes: operation.moveMinutes,
plannedMinutes,
notes: operation.notes,
station: operation.station,
};
});
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);
let nextStart = new Date(dueDate ?? fallbackStart);
return operationDurations.map((operation) => {
const plannedEnd = addMinutes(nextStart, operation.plannedMinutes);
const plannedStart = alignToWorkingWindow(nextStart, parseWorkingDays(operation.station.workingDays));
const plannedEnd = addWorkingMinutes(plannedStart, operation.plannedMinutes, operation.station);
const planned = {
...operation,
plannedStart: nextStart,
plannedStart,
plannedEnd,
};
nextStart = plannedEnd;
@@ -501,6 +570,70 @@ async function regenerateWorkOrderOperations(workOrderId: string) {
});
}
async function rescheduleWorkOrderOperations(workOrderId: string, operationId: string, plannedStart: Date) {
const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId },
include: buildInclude(),
});
if (!workOrder) {
return null;
}
const operations = (workOrder as WorkOrderRecord).operations;
const anchorIndex = operations.findIndex((operation) => operation.id === operationId);
if (anchorIndex < 0) {
return null;
}
return rescheduleWorkOrderOperationsToStation(workOrderId, operations, anchorIndex, plannedStart, null);
}
async function rescheduleWorkOrderOperationsToStation(
workOrderId: string,
operations: WorkOrderRecord["operations"],
anchorIndex: number,
plannedStart: Date,
targetStation: Pick<StationRecord, "id" | "queueDays" | "dailyCapacityMinutes" | "parallelCapacity" | "workingDays"> | null
) {
let nextStart = plannedStart;
const updates = operations.slice(anchorIndex).map((operation) => {
const station = operation.id === operations[anchorIndex]?.id && targetStation
? targetStation
: {
id: operation.station.id,
dailyCapacityMinutes: operation.station.dailyCapacityMinutes,
parallelCapacity: operation.station.parallelCapacity,
workingDays: operation.station.workingDays,
queueDays: 0,
};
const alignedStart = alignToWorkingWindow(nextStart, parseWorkingDays(station.workingDays));
const plannedEnd = addWorkingMinutes(alignedStart, operation.plannedMinutes, station);
nextStart = plannedEnd;
return {
id: operation.id,
stationId: station.id,
plannedStart: alignedStart,
plannedEnd,
};
});
await prisma.$transaction(
updates.map((update) =>
prisma.workOrderOperation.update({
where: { id: update.id },
data: {
stationId: update.stationId,
plannedStart: update.plannedStart,
plannedEnd: update.plannedEnd,
},
})
)
);
return getWorkOrderById(workOrderId);
}
async function syncWorkOrderReservations(workOrderId: string) {
const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) {
@@ -760,12 +893,16 @@ export async function listManufacturingStations(): Promise<ManufacturingStationD
}
export async function createManufacturingStation(payload: ManufacturingStationInput, actorId?: string | null) {
const workingDays = normalizeStationWorkingDays(payload.workingDays);
const station = await prisma.manufacturingStation.create({
data: {
code: payload.code.trim(),
name: payload.name.trim(),
description: payload.description,
queueDays: payload.queueDays,
dailyCapacityMinutes: payload.dailyCapacityMinutes,
parallelCapacity: payload.parallelCapacity,
workingDays: workingDays.join(","),
isActive: payload.isActive,
},
});
@@ -777,12 +914,15 @@ export async function createManufacturingStation(payload: ManufacturingStationIn
action: "created",
summary: `Created manufacturing station ${station.code}.`,
metadata: {
code: station.code,
name: station.name,
queueDays: station.queueDays,
isActive: station.isActive,
},
});
code: station.code,
name: station.name,
queueDays: station.queueDays,
dailyCapacityMinutes: station.dailyCapacityMinutes,
parallelCapacity: station.parallelCapacity,
workingDays,
isActive: station.isActive,
},
});
return mapStation(station);
}
@@ -1017,6 +1157,110 @@ export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrd
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
}
export async function updateWorkOrderOperationSchedule(
workOrderId: string,
operationId: string,
payload: WorkOrderOperationScheduleInput,
actorId?: string | null
) {
const existing = await prisma.workOrderOperation.findUnique({
where: { id: operationId },
select: {
id: true,
workOrderId: true,
sequence: true,
stationId: true,
station: {
select: {
code: true,
name: true,
},
},
},
});
if (!existing || existing.workOrderId !== workOrderId) {
return { ok: false as const, reason: "Work-order operation was not found." };
}
const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) {
return { ok: false as const, reason: "Work order was not found." };
}
if (workOrder.status === "COMPLETE" || workOrder.status === "CANCELLED") {
return { ok: false as const, reason: "Completed or cancelled work orders cannot be rescheduled." };
}
let targetStation:
| Pick<StationRecord, "id" | "queueDays" | "dailyCapacityMinutes" | "parallelCapacity" | "workingDays">
| null = null;
if (payload.stationId && payload.stationId !== existing.stationId) {
const station = await prisma.manufacturingStation.findUnique({
where: { id: payload.stationId },
select: {
id: true,
queueDays: true,
dailyCapacityMinutes: true,
parallelCapacity: true,
workingDays: true,
isActive: true,
},
});
if (!station || !station.isActive) {
return { ok: false as const, reason: "Selected manufacturing station was not found or is inactive." };
}
targetStation = station;
}
const workOrderRecord = await workOrderModel.findUnique({
where: { id: workOrderId },
include: buildInclude(),
});
if (!workOrderRecord) {
return { ok: false as const, reason: "Work order was not found." };
}
const operations = (workOrderRecord as WorkOrderRecord).operations;
const anchorIndex = operations.findIndex((operation) => operation.id === operationId);
if (anchorIndex < 0) {
return { ok: false as const, reason: "Work-order operation was not found." };
}
const rescheduled = await rescheduleWorkOrderOperationsToStation(
workOrderId,
operations,
anchorIndex,
new Date(payload.plannedStart),
targetStation
);
if (!rescheduled) {
return { ok: false as const, reason: "Unable to reschedule the requested operation." };
}
await logAuditEvent({
actorId,
entityType: "work-order",
entityId: workOrderId,
action: "operations.rescheduled",
summary: `Rescheduled work order ${rescheduled.workOrderNumber} from operation ${existing.sequence}.`,
metadata: {
workOrderNumber: rescheduled.workOrderNumber,
operationId,
sequence: existing.sequence,
plannedStart: payload.plannedStart,
previousStationId: existing.stationId,
previousStationCode: existing.station.code,
previousStationName: existing.station.name,
stationId: payload.stationId ?? existing.stationId,
},
});
return { ok: true as const, workOrder: rescheduled };
}
export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkOrderMaterialIssueInput, createdById?: string | null) {
const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId },