workbench rebalance
This commit is contained in:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user