2026-03-15 11:12:58 -05:00
|
|
|
import type {
|
2026-03-15 12:11:46 -05:00
|
|
|
ManufacturingStationDto,
|
|
|
|
|
ManufacturingStationInput,
|
2026-03-15 11:12:58 -05:00
|
|
|
ManufacturingItemOptionDto,
|
|
|
|
|
ManufacturingProjectOptionDto,
|
|
|
|
|
WorkOrderCompletionInput,
|
|
|
|
|
WorkOrderDetailDto,
|
|
|
|
|
WorkOrderInput,
|
2026-03-18 06:22:37 -05:00
|
|
|
WorkOrderOperationExecutionInput,
|
2026-03-15 12:11:46 -05:00
|
|
|
WorkOrderOperationDto,
|
2026-03-18 06:22:37 -05:00
|
|
|
WorkOrderOperationLaborEntryInput,
|
2026-03-18 00:10:15 -05:00
|
|
|
WorkOrderOperationScheduleInput,
|
2026-03-15 11:12:58 -05:00
|
|
|
WorkOrderMaterialIssueInput,
|
|
|
|
|
WorkOrderStatus,
|
|
|
|
|
WorkOrderSummaryDto,
|
|
|
|
|
} from "@mrp/shared";
|
|
|
|
|
|
2026-03-15 14:11:21 -05:00
|
|
|
import { logAuditEvent } from "../../lib/audit.js";
|
2026-03-15 11:12:58 -05:00
|
|
|
import { prisma } from "../../lib/prisma.js";
|
|
|
|
|
|
|
|
|
|
const workOrderModel = (prisma as any).workOrder;
|
|
|
|
|
|
2026-03-15 12:11:46 -05:00
|
|
|
type StationRecord = {
|
|
|
|
|
id: string;
|
|
|
|
|
code: string;
|
|
|
|
|
name: string;
|
|
|
|
|
description: string;
|
|
|
|
|
queueDays: number;
|
2026-03-18 00:10:15 -05:00
|
|
|
dailyCapacityMinutes: number;
|
|
|
|
|
parallelCapacity: number;
|
|
|
|
|
workingDays: string;
|
2026-03-15 12:11:46 -05:00
|
|
|
isActive: boolean;
|
|
|
|
|
createdAt: Date;
|
|
|
|
|
updatedAt: Date;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-15 11:12:58 -05:00
|
|
|
type WorkOrderRecord = {
|
|
|
|
|
id: string;
|
|
|
|
|
workOrderNumber: string;
|
|
|
|
|
status: string;
|
|
|
|
|
quantity: number;
|
|
|
|
|
completedQuantity: number;
|
|
|
|
|
dueDate: Date | null;
|
|
|
|
|
notes: string;
|
|
|
|
|
createdAt: Date;
|
|
|
|
|
updatedAt: Date;
|
|
|
|
|
item: {
|
|
|
|
|
id: string;
|
|
|
|
|
sku: string;
|
|
|
|
|
name: string;
|
|
|
|
|
type: string;
|
|
|
|
|
unitOfMeasure: string;
|
2026-03-15 12:11:46 -05:00
|
|
|
operations: Array<{
|
|
|
|
|
setupMinutes: number;
|
|
|
|
|
runMinutesPerUnit: number;
|
|
|
|
|
moveMinutes: number;
|
|
|
|
|
position: number;
|
|
|
|
|
notes: string;
|
|
|
|
|
station: {
|
|
|
|
|
id: string;
|
|
|
|
|
code: string;
|
|
|
|
|
name: string;
|
|
|
|
|
queueDays: number;
|
2026-03-18 00:10:15 -05:00
|
|
|
dailyCapacityMinutes: number;
|
|
|
|
|
parallelCapacity: number;
|
|
|
|
|
workingDays: string;
|
2026-03-15 12:11:46 -05:00
|
|
|
};
|
|
|
|
|
}>;
|
2026-03-15 11:12:58 -05:00
|
|
|
bomLines: Array<{
|
|
|
|
|
quantity: number;
|
|
|
|
|
unitOfMeasure: string;
|
|
|
|
|
componentItem: {
|
|
|
|
|
id: string;
|
|
|
|
|
sku: string;
|
|
|
|
|
name: string;
|
|
|
|
|
};
|
|
|
|
|
}>;
|
|
|
|
|
};
|
|
|
|
|
project: {
|
|
|
|
|
id: string;
|
|
|
|
|
projectNumber: string;
|
|
|
|
|
name: string;
|
|
|
|
|
customer: {
|
|
|
|
|
name: string;
|
|
|
|
|
};
|
|
|
|
|
} | null;
|
2026-03-15 16:40:25 -05:00
|
|
|
salesOrder: {
|
|
|
|
|
id: string;
|
|
|
|
|
documentNumber: string;
|
|
|
|
|
} | null;
|
|
|
|
|
salesOrderLine: {
|
|
|
|
|
id: string;
|
|
|
|
|
} | null;
|
2026-03-15 11:12:58 -05:00
|
|
|
warehouse: {
|
|
|
|
|
id: string;
|
|
|
|
|
code: string;
|
|
|
|
|
name: string;
|
|
|
|
|
};
|
|
|
|
|
location: {
|
|
|
|
|
id: string;
|
|
|
|
|
code: string;
|
|
|
|
|
name: string;
|
|
|
|
|
};
|
2026-03-15 12:11:46 -05:00
|
|
|
operations: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
sequence: number;
|
|
|
|
|
setupMinutes: number;
|
|
|
|
|
runMinutesPerUnit: number;
|
|
|
|
|
moveMinutes: number;
|
|
|
|
|
plannedMinutes: number;
|
|
|
|
|
plannedStart: Date;
|
|
|
|
|
plannedEnd: Date;
|
|
|
|
|
notes: string;
|
2026-03-18 06:22:37 -05:00
|
|
|
status: string;
|
|
|
|
|
actualStart: Date | null;
|
|
|
|
|
actualEnd: Date | null;
|
|
|
|
|
actualMinutes: number;
|
2026-03-15 12:11:46 -05:00
|
|
|
station: {
|
|
|
|
|
id: string;
|
|
|
|
|
code: string;
|
|
|
|
|
name: string;
|
2026-03-18 00:10:15 -05:00
|
|
|
dailyCapacityMinutes: number;
|
|
|
|
|
parallelCapacity: number;
|
|
|
|
|
workingDays: string;
|
2026-03-15 12:11:46 -05:00
|
|
|
};
|
2026-03-18 06:22:37 -05:00
|
|
|
laborEntries: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
minutes: number;
|
|
|
|
|
notes: string;
|
|
|
|
|
createdAt: Date;
|
|
|
|
|
createdBy: {
|
|
|
|
|
firstName: string;
|
|
|
|
|
lastName: string;
|
|
|
|
|
} | null;
|
|
|
|
|
}>;
|
2026-03-15 12:11:46 -05:00
|
|
|
}>;
|
2026-03-15 11:12:58 -05:00
|
|
|
materialIssues: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
quantity: number;
|
|
|
|
|
notes: string;
|
|
|
|
|
createdAt: Date;
|
|
|
|
|
componentItem: {
|
|
|
|
|
id: string;
|
|
|
|
|
sku: string;
|
|
|
|
|
name: string;
|
|
|
|
|
};
|
|
|
|
|
warehouse: {
|
|
|
|
|
id: string;
|
|
|
|
|
code: string;
|
|
|
|
|
name: string;
|
|
|
|
|
};
|
|
|
|
|
location: {
|
|
|
|
|
id: string;
|
|
|
|
|
code: string;
|
|
|
|
|
name: string;
|
|
|
|
|
};
|
|
|
|
|
createdBy: {
|
|
|
|
|
firstName: string;
|
|
|
|
|
lastName: string;
|
|
|
|
|
} | null;
|
|
|
|
|
}>;
|
|
|
|
|
completions: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
quantity: number;
|
|
|
|
|
notes: string;
|
|
|
|
|
createdAt: Date;
|
|
|
|
|
createdBy: {
|
|
|
|
|
firstName: string;
|
|
|
|
|
lastName: string;
|
|
|
|
|
} | null;
|
|
|
|
|
}>;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-15 12:11:46 -05:00
|
|
|
function mapStation(record: StationRecord): ManufacturingStationDto {
|
2026-03-18 00:10:15 -05:00
|
|
|
const workingDays = record.workingDays.split(",").map((value) => Number.parseInt(value, 10)).filter((value) => Number.isInteger(value) && value >= 0 && value <= 6);
|
2026-03-15 12:11:46 -05:00
|
|
|
return {
|
|
|
|
|
id: record.id,
|
|
|
|
|
code: record.code,
|
|
|
|
|
name: record.name,
|
|
|
|
|
description: record.description,
|
|
|
|
|
queueDays: record.queueDays,
|
2026-03-18 00:10:15 -05:00
|
|
|
dailyCapacityMinutes: record.dailyCapacityMinutes,
|
|
|
|
|
parallelCapacity: record.parallelCapacity,
|
|
|
|
|
workingDays: workingDays.length > 0 ? workingDays : [1, 2, 3, 4, 5],
|
2026-03-15 12:11:46 -05:00
|
|
|
isActive: record.isActive,
|
|
|
|
|
createdAt: record.createdAt.toISOString(),
|
|
|
|
|
updatedAt: record.updatedAt.toISOString(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 11:12:58 -05:00
|
|
|
function buildInclude() {
|
|
|
|
|
return {
|
|
|
|
|
item: {
|
|
|
|
|
include: {
|
2026-03-15 12:11:46 -05:00
|
|
|
operations: {
|
|
|
|
|
include: {
|
|
|
|
|
station: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
code: true,
|
|
|
|
|
name: true,
|
|
|
|
|
queueDays: true,
|
2026-03-18 00:10:15 -05:00
|
|
|
dailyCapacityMinutes: true,
|
|
|
|
|
parallelCapacity: true,
|
|
|
|
|
workingDays: true,
|
2026-03-15 12:11:46 -05:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
|
|
|
|
},
|
2026-03-15 11:12:58 -05:00
|
|
|
bomLines: {
|
|
|
|
|
include: {
|
|
|
|
|
componentItem: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
sku: true,
|
|
|
|
|
name: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
project: {
|
|
|
|
|
include: {
|
|
|
|
|
customer: {
|
|
|
|
|
select: {
|
|
|
|
|
name: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-03-15 16:40:25 -05:00
|
|
|
salesOrder: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
documentNumber: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
salesOrderLine: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-03-15 11:12:58 -05:00
|
|
|
warehouse: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
code: true,
|
|
|
|
|
name: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
location: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
code: true,
|
|
|
|
|
name: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-03-15 12:11:46 -05:00
|
|
|
operations: {
|
|
|
|
|
include: {
|
|
|
|
|
station: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
code: true,
|
|
|
|
|
name: true,
|
2026-03-18 00:10:15 -05:00
|
|
|
dailyCapacityMinutes: true,
|
|
|
|
|
parallelCapacity: true,
|
|
|
|
|
workingDays: true,
|
2026-03-15 12:11:46 -05:00
|
|
|
},
|
|
|
|
|
},
|
2026-03-18 06:22:37 -05:00
|
|
|
laborEntries: {
|
|
|
|
|
include: {
|
|
|
|
|
createdBy: {
|
|
|
|
|
select: {
|
|
|
|
|
firstName: true,
|
|
|
|
|
lastName: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: [{ createdAt: "desc" }],
|
|
|
|
|
},
|
2026-03-15 12:11:46 -05:00
|
|
|
},
|
|
|
|
|
orderBy: [{ sequence: "asc" }],
|
|
|
|
|
},
|
2026-03-15 11:12:58 -05:00
|
|
|
materialIssues: {
|
|
|
|
|
include: {
|
|
|
|
|
componentItem: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
sku: true,
|
|
|
|
|
name: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
warehouse: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
code: true,
|
|
|
|
|
name: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
location: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
code: true,
|
|
|
|
|
name: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
createdBy: {
|
|
|
|
|
select: {
|
|
|
|
|
firstName: true,
|
|
|
|
|
lastName: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: [{ createdAt: "desc" }],
|
|
|
|
|
},
|
|
|
|
|
completions: {
|
|
|
|
|
include: {
|
|
|
|
|
createdBy: {
|
|
|
|
|
select: {
|
|
|
|
|
firstName: true,
|
|
|
|
|
lastName: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: [{ createdAt: "desc" }],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getUserName(user: { firstName: string; lastName: string } | null) {
|
|
|
|
|
return user ? `${user.firstName} ${user.lastName}`.trim() : "System";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapSummary(record: WorkOrderRecord): WorkOrderSummaryDto {
|
|
|
|
|
return {
|
|
|
|
|
id: record.id,
|
|
|
|
|
workOrderNumber: record.workOrderNumber,
|
|
|
|
|
status: record.status as WorkOrderStatus,
|
|
|
|
|
itemId: record.item.id,
|
|
|
|
|
itemSku: record.item.sku,
|
|
|
|
|
itemName: record.item.name,
|
|
|
|
|
projectId: record.project?.id ?? null,
|
|
|
|
|
projectNumber: record.project?.projectNumber ?? null,
|
|
|
|
|
projectName: record.project?.name ?? null,
|
2026-03-15 16:40:25 -05:00
|
|
|
salesOrderId: record.salesOrder?.id ?? null,
|
|
|
|
|
salesOrderLineId: record.salesOrderLine?.id ?? null,
|
|
|
|
|
salesOrderNumber: record.salesOrder?.documentNumber ?? null,
|
2026-03-15 11:12:58 -05:00
|
|
|
quantity: record.quantity,
|
|
|
|
|
completedQuantity: record.completedQuantity,
|
|
|
|
|
dueDate: record.dueDate ? record.dueDate.toISOString() : null,
|
|
|
|
|
warehouseId: record.warehouse.id,
|
|
|
|
|
warehouseCode: record.warehouse.code,
|
|
|
|
|
warehouseName: record.warehouse.name,
|
|
|
|
|
locationId: record.location.id,
|
|
|
|
|
locationCode: record.location.code,
|
|
|
|
|
locationName: record.location.name,
|
2026-03-15 12:11:46 -05:00
|
|
|
operationCount: record.operations.length,
|
|
|
|
|
totalPlannedMinutes: record.operations.reduce((sum, operation) => sum + operation.plannedMinutes, 0),
|
2026-03-15 11:12:58 -05:00
|
|
|
updatedAt: record.updatedAt.toISOString(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 16:40:25 -05:00
|
|
|
function mapDetail(
|
|
|
|
|
record: WorkOrderRecord,
|
|
|
|
|
componentAvailability: Map<string, { onHandQuantity: number; reservedQuantity: number }>
|
|
|
|
|
): WorkOrderDetailDto {
|
2026-03-15 11:12:58 -05:00
|
|
|
const issuedByComponent = new Map<string, number>();
|
|
|
|
|
|
|
|
|
|
for (const issue of record.materialIssues) {
|
|
|
|
|
issuedByComponent.set(issue.componentItem.id, (issuedByComponent.get(issue.componentItem.id) ?? 0) + issue.quantity);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...mapSummary(record),
|
|
|
|
|
notes: record.notes,
|
|
|
|
|
createdAt: record.createdAt.toISOString(),
|
|
|
|
|
itemType: record.item.type,
|
|
|
|
|
itemUnitOfMeasure: record.item.unitOfMeasure,
|
|
|
|
|
projectCustomerName: record.project?.customer.name ?? null,
|
|
|
|
|
dueQuantity: record.quantity - record.completedQuantity,
|
2026-03-18 06:22:37 -05:00
|
|
|
totalActualMinutes: record.operations.reduce((sum, operation) => sum + operation.actualMinutes, 0),
|
2026-03-15 12:11:46 -05:00
|
|
|
operations: record.operations.map((operation): WorkOrderOperationDto => ({
|
|
|
|
|
id: operation.id,
|
|
|
|
|
stationId: operation.station.id,
|
|
|
|
|
stationCode: operation.station.code,
|
|
|
|
|
stationName: operation.station.name,
|
2026-03-18 00:10:15 -05:00
|
|
|
stationDailyCapacityMinutes: operation.station.dailyCapacityMinutes,
|
|
|
|
|
stationParallelCapacity: operation.station.parallelCapacity,
|
|
|
|
|
stationWorkingDays: parseWorkingDays(operation.station.workingDays),
|
2026-03-15 12:11:46 -05:00
|
|
|
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,
|
2026-03-18 06:22:37 -05:00
|
|
|
status: operation.status as WorkOrderOperationDto["status"],
|
|
|
|
|
actualStart: operation.actualStart ? operation.actualStart.toISOString() : null,
|
|
|
|
|
actualEnd: operation.actualEnd ? operation.actualEnd.toISOString() : null,
|
|
|
|
|
actualMinutes: operation.actualMinutes,
|
|
|
|
|
laborEntryCount: operation.laborEntries.length,
|
|
|
|
|
laborEntries: operation.laborEntries.map((entry) => ({
|
|
|
|
|
id: entry.id,
|
|
|
|
|
minutes: entry.minutes,
|
|
|
|
|
notes: entry.notes,
|
|
|
|
|
createdAt: entry.createdAt.toISOString(),
|
|
|
|
|
createdByName: getUserName(entry.createdBy),
|
|
|
|
|
})),
|
2026-03-15 12:11:46 -05:00
|
|
|
})),
|
2026-03-15 11:12:58 -05:00
|
|
|
materialRequirements: record.item.bomLines.map((line) => {
|
|
|
|
|
const requiredQuantity = line.quantity * record.quantity;
|
|
|
|
|
const issuedQuantity = issuedByComponent.get(line.componentItem.id) ?? 0;
|
2026-03-15 16:40:25 -05:00
|
|
|
const availability = componentAvailability.get(line.componentItem.id) ?? { onHandQuantity: 0, reservedQuantity: 0 };
|
|
|
|
|
const onHandQuantity = availability.onHandQuantity;
|
|
|
|
|
const reservedQuantity = availability.reservedQuantity;
|
|
|
|
|
const availableQuantity = onHandQuantity - reservedQuantity;
|
|
|
|
|
const shortageQuantity = Math.max(requiredQuantity - issuedQuantity - Math.max(availableQuantity, 0), 0);
|
2026-03-15 11:12:58 -05:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
componentItemId: line.componentItem.id,
|
|
|
|
|
componentSku: line.componentItem.sku,
|
|
|
|
|
componentName: line.componentItem.name,
|
|
|
|
|
unitOfMeasure: line.unitOfMeasure,
|
|
|
|
|
quantityPer: line.quantity,
|
|
|
|
|
requiredQuantity,
|
|
|
|
|
issuedQuantity,
|
|
|
|
|
remainingQuantity: Math.max(requiredQuantity - issuedQuantity, 0),
|
2026-03-15 16:40:25 -05:00
|
|
|
onHandQuantity,
|
|
|
|
|
reservedQuantity,
|
|
|
|
|
availableQuantity,
|
|
|
|
|
shortageQuantity,
|
2026-03-15 11:12:58 -05:00
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
materialIssues: record.materialIssues.map((issue) => ({
|
|
|
|
|
id: issue.id,
|
|
|
|
|
componentItemId: issue.componentItem.id,
|
|
|
|
|
componentSku: issue.componentItem.sku,
|
|
|
|
|
componentName: issue.componentItem.name,
|
|
|
|
|
quantity: issue.quantity,
|
|
|
|
|
warehouseId: issue.warehouse.id,
|
|
|
|
|
warehouseCode: issue.warehouse.code,
|
|
|
|
|
warehouseName: issue.warehouse.name,
|
|
|
|
|
locationId: issue.location.id,
|
|
|
|
|
locationCode: issue.location.code,
|
|
|
|
|
locationName: issue.location.name,
|
|
|
|
|
notes: issue.notes,
|
|
|
|
|
createdAt: issue.createdAt.toISOString(),
|
|
|
|
|
createdByName: getUserName(issue.createdBy),
|
|
|
|
|
})),
|
|
|
|
|
completions: record.completions.map((completion) => ({
|
|
|
|
|
id: completion.id,
|
|
|
|
|
quantity: completion.quantity,
|
|
|
|
|
notes: completion.notes,
|
|
|
|
|
createdAt: completion.createdAt.toISOString(),
|
|
|
|
|
createdByName: getUserName(completion.createdBy),
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 12:11:46 -05:00
|
|
|
function addMinutes(value: Date, minutes: number) {
|
|
|
|
|
return new Date(value.getTime() + minutes * 60 * 1000);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 00:10:15 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:00:12 -05:00
|
|
|
function shouldReserveForStatus(status: string) {
|
|
|
|
|
return status === "RELEASED" || status === "IN_PROGRESS" || status === "ON_HOLD";
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 12:11:46 -05:00
|
|
|
function buildWorkOrderOperationPlan(
|
|
|
|
|
itemOperations: WorkOrderRecord["item"]["operations"],
|
|
|
|
|
quantity: number,
|
|
|
|
|
dueDate: Date | null,
|
|
|
|
|
fallbackStart: Date
|
|
|
|
|
) {
|
|
|
|
|
if (itemOperations.length === 0) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const operationDurations = itemOperations.map((operation) => {
|
2026-03-18 00:10:15 -05:00
|
|
|
const plannedMinutes = Math.max(operation.setupMinutes + operation.runMinutesPerUnit * quantity + operation.moveMinutes, 1);
|
2026-03-15 12:11:46 -05:00
|
|
|
return {
|
|
|
|
|
stationId: operation.station.id,
|
|
|
|
|
sequence: operation.position,
|
|
|
|
|
setupMinutes: operation.setupMinutes,
|
|
|
|
|
runMinutesPerUnit: operation.runMinutesPerUnit,
|
|
|
|
|
moveMinutes: operation.moveMinutes,
|
|
|
|
|
plannedMinutes,
|
|
|
|
|
notes: operation.notes,
|
2026-03-18 00:10:15 -05:00
|
|
|
station: operation.station,
|
2026-03-15 12:11:46 -05:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-18 00:10:15 -05:00
|
|
|
let nextStart = new Date(dueDate ?? fallbackStart);
|
2026-03-15 12:11:46 -05:00
|
|
|
return operationDurations.map((operation) => {
|
2026-03-18 00:10:15 -05:00
|
|
|
const plannedStart = alignToWorkingWindow(nextStart, parseWorkingDays(operation.station.workingDays));
|
|
|
|
|
const plannedEnd = addWorkingMinutes(plannedStart, operation.plannedMinutes, operation.station);
|
2026-03-15 12:11:46 -05:00
|
|
|
const planned = {
|
|
|
|
|
...operation,
|
2026-03-18 00:10:15 -05:00
|
|
|
plannedStart,
|
2026-03-15 12:11:46 -05:00
|
|
|
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,
|
2026-03-18 06:22:37 -05:00
|
|
|
status: "PENDING",
|
2026-03-15 12:11:46 -05:00
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 00:10:15 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 06:22:37 -05:00
|
|
|
async function syncWorkOrderStatusFromOperationActivity(workOrderId: string) {
|
|
|
|
|
const workOrder = await prisma.workOrder.findUnique({
|
|
|
|
|
where: { id: workOrderId },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
status: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!workOrder) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (workOrder.status === "RELEASED" || workOrder.status === "ON_HOLD") {
|
|
|
|
|
await prisma.workOrder.update({
|
|
|
|
|
where: { id: workOrderId },
|
|
|
|
|
data: {
|
|
|
|
|
status: "IN_PROGRESS",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:00:12 -05:00
|
|
|
async function syncWorkOrderReservations(workOrderId: string) {
|
|
|
|
|
const workOrder = await getWorkOrderById(workOrderId);
|
|
|
|
|
if (!workOrder) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await prisma.inventoryReservation.deleteMany({
|
|
|
|
|
where: {
|
|
|
|
|
workOrderId,
|
|
|
|
|
sourceType: "WORK_ORDER",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!shouldReserveForStatus(workOrder.status)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const reservations = workOrder.materialRequirements
|
|
|
|
|
.filter((requirement) => requirement.remainingQuantity > 0)
|
|
|
|
|
.map((requirement) => ({
|
|
|
|
|
itemId: requirement.componentItemId,
|
|
|
|
|
warehouseId: workOrder.warehouseId,
|
|
|
|
|
locationId: workOrder.locationId,
|
|
|
|
|
workOrderId,
|
|
|
|
|
sourceType: "WORK_ORDER",
|
|
|
|
|
sourceId: workOrderId,
|
|
|
|
|
quantity: requirement.remainingQuantity,
|
|
|
|
|
status: "ACTIVE",
|
|
|
|
|
notes: `${workOrder.workOrderNumber} component demand`,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
if (reservations.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await prisma.inventoryReservation.createMany({
|
|
|
|
|
data: reservations,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 11:12:58 -05:00
|
|
|
async function nextWorkOrderNumber() {
|
|
|
|
|
const next = (await workOrderModel.count()) + 1;
|
|
|
|
|
return `WO-${String(next).padStart(5, "0")}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getItemLocationOnHand(itemId: string, warehouseId: string, locationId: string) {
|
|
|
|
|
const transactions = await prisma.inventoryTransaction.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
itemId,
|
|
|
|
|
warehouseId,
|
|
|
|
|
locationId,
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
transactionType: true,
|
|
|
|
|
quantity: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return transactions.reduce((total, transaction) => {
|
|
|
|
|
return total + (transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity);
|
|
|
|
|
}, 0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 16:40:25 -05:00
|
|
|
async function getComponentAvailability(workOrder: WorkOrderRecord) {
|
|
|
|
|
const componentItemIds = [...new Set(workOrder.item.bomLines.map((line) => line.componentItem.id))];
|
|
|
|
|
if (componentItemIds.length === 0) {
|
|
|
|
|
return new Map<string, { onHandQuantity: number; reservedQuantity: number }>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [transactions, reservations] = await Promise.all([
|
|
|
|
|
prisma.inventoryTransaction.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
itemId: { in: componentItemIds },
|
|
|
|
|
warehouseId: workOrder.warehouse.id,
|
|
|
|
|
locationId: workOrder.location.id,
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
itemId: true,
|
|
|
|
|
transactionType: true,
|
|
|
|
|
quantity: true,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
prisma.inventoryReservation.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
itemId: { in: componentItemIds },
|
|
|
|
|
warehouseId: workOrder.warehouse.id,
|
|
|
|
|
locationId: workOrder.location.id,
|
|
|
|
|
status: "ACTIVE",
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
itemId: true,
|
|
|
|
|
quantity: true,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const availability = new Map<string, { onHandQuantity: number; reservedQuantity: number }>();
|
|
|
|
|
for (const itemId of componentItemIds) {
|
|
|
|
|
availability.set(itemId, { onHandQuantity: 0, reservedQuantity: 0 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const transaction of transactions) {
|
|
|
|
|
const current = availability.get(transaction.itemId);
|
|
|
|
|
if (!current) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
current.onHandQuantity += transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const reservation of reservations) {
|
|
|
|
|
const current = availability.get(reservation.itemId);
|
|
|
|
|
if (!current) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
current.reservedQuantity += reservation.quantity;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return availability;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 11:12:58 -05:00
|
|
|
async function validateWorkOrderInput(payload: WorkOrderInput) {
|
|
|
|
|
const item = await prisma.inventoryItem.findUnique({
|
|
|
|
|
where: { id: payload.itemId },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
type: true,
|
|
|
|
|
status: true,
|
2026-03-15 12:11:46 -05:00
|
|
|
_count: {
|
|
|
|
|
select: {
|
|
|
|
|
operations: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-03-15 11:12:58 -05:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!item) {
|
|
|
|
|
return { ok: false as const, reason: "Build item was not found." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (item.status !== "ACTIVE") {
|
|
|
|
|
return { ok: false as const, reason: "Build item must be active." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (item.type !== "ASSEMBLY" && item.type !== "MANUFACTURED") {
|
|
|
|
|
return { ok: false as const, reason: "Work orders can only be created for assembly or manufactured items." };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 12:11:46 -05:00
|
|
|
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." };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 11:12:58 -05:00
|
|
|
if (payload.projectId) {
|
|
|
|
|
const project = await prisma.project.findUnique({
|
|
|
|
|
where: { id: payload.projectId },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
return { ok: false as const, reason: "Linked project was not found." };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 16:40:25 -05:00
|
|
|
if (payload.salesOrderId) {
|
|
|
|
|
const salesOrder = await prisma.salesOrder.findUnique({
|
|
|
|
|
where: { id: payload.salesOrderId },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!salesOrder) {
|
|
|
|
|
return { ok: false as const, reason: "Linked sales order was not found." };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (payload.salesOrderLineId) {
|
|
|
|
|
if (!payload.salesOrderId) {
|
|
|
|
|
return { ok: false as const, reason: "Linked sales-order line requires a linked sales order." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const salesOrderLine = await prisma.salesOrderLine.findUnique({
|
|
|
|
|
where: { id: payload.salesOrderLineId },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
orderId: true,
|
|
|
|
|
itemId: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!salesOrderLine || salesOrderLine.orderId !== payload.salesOrderId) {
|
|
|
|
|
return { ok: false as const, reason: "Linked sales-order line was not found on the selected sales order." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (salesOrderLine.itemId !== payload.itemId) {
|
|
|
|
|
return { ok: false as const, reason: "Linked sales-order line item does not match the selected build item." };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 11:12:58 -05:00
|
|
|
const location = await prisma.warehouseLocation.findUnique({
|
|
|
|
|
where: { id: payload.locationId },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
warehouseId: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!location || location.warehouseId !== payload.warehouseId) {
|
|
|
|
|
return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { ok: true as const };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function listManufacturingItemOptions(): Promise<ManufacturingItemOptionDto[]> {
|
|
|
|
|
const items = await prisma.inventoryItem.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
status: "ACTIVE",
|
|
|
|
|
type: {
|
|
|
|
|
in: ["ASSEMBLY", "MANUFACTURED"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
sku: true,
|
|
|
|
|
name: true,
|
|
|
|
|
type: true,
|
|
|
|
|
unitOfMeasure: true,
|
2026-03-15 12:11:46 -05:00
|
|
|
operations: {
|
|
|
|
|
select: {
|
|
|
|
|
setupMinutes: true,
|
|
|
|
|
runMinutesPerUnit: true,
|
|
|
|
|
moveMinutes: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-03-15 11:12:58 -05:00
|
|
|
},
|
|
|
|
|
orderBy: [{ sku: "asc" }],
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-15 12:11:46 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:11:21 -05:00
|
|
|
export async function createManufacturingStation(payload: ManufacturingStationInput, actorId?: string | null) {
|
2026-03-18 00:10:15 -05:00
|
|
|
const workingDays = normalizeStationWorkingDays(payload.workingDays);
|
2026-03-15 12:11:46 -05:00
|
|
|
const station = await prisma.manufacturingStation.create({
|
|
|
|
|
data: {
|
|
|
|
|
code: payload.code.trim(),
|
|
|
|
|
name: payload.name.trim(),
|
|
|
|
|
description: payload.description,
|
|
|
|
|
queueDays: payload.queueDays,
|
2026-03-18 00:10:15 -05:00
|
|
|
dailyCapacityMinutes: payload.dailyCapacityMinutes,
|
|
|
|
|
parallelCapacity: payload.parallelCapacity,
|
|
|
|
|
workingDays: workingDays.join(","),
|
2026-03-15 12:11:46 -05:00
|
|
|
isActive: payload.isActive,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-15 14:11:21 -05:00
|
|
|
await logAuditEvent({
|
|
|
|
|
actorId,
|
|
|
|
|
entityType: "manufacturing-station",
|
|
|
|
|
entityId: station.id,
|
|
|
|
|
action: "created",
|
|
|
|
|
summary: `Created manufacturing station ${station.code}.`,
|
|
|
|
|
metadata: {
|
2026-03-18 00:10:15 -05:00
|
|
|
code: station.code,
|
|
|
|
|
name: station.name,
|
|
|
|
|
queueDays: station.queueDays,
|
|
|
|
|
dailyCapacityMinutes: station.dailyCapacityMinutes,
|
|
|
|
|
parallelCapacity: station.parallelCapacity,
|
|
|
|
|
workingDays,
|
|
|
|
|
isActive: station.isActive,
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-03-15 14:11:21 -05:00
|
|
|
|
2026-03-15 12:11:46 -05:00
|
|
|
return mapStation(station);
|
2026-03-15 11:12:58 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 00:18:30 -05:00
|
|
|
export async function updateManufacturingStation(stationId: string, payload: ManufacturingStationInput, actorId?: string | null) {
|
|
|
|
|
const existing = await prisma.manufacturingStation.findUnique({
|
|
|
|
|
where: { id: stationId },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return { ok: false as const, reason: "Manufacturing station was not found." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const workingDays = normalizeStationWorkingDays(payload.workingDays);
|
|
|
|
|
const station = await prisma.manufacturingStation.update({
|
|
|
|
|
where: { id: stationId },
|
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await logAuditEvent({
|
|
|
|
|
actorId,
|
|
|
|
|
entityType: "manufacturing-station",
|
|
|
|
|
entityId: station.id,
|
|
|
|
|
action: "updated",
|
|
|
|
|
summary: `Updated manufacturing station ${station.code}.`,
|
|
|
|
|
metadata: {
|
|
|
|
|
previousCode: existing.code,
|
|
|
|
|
previousName: existing.name,
|
|
|
|
|
previousQueueDays: existing.queueDays,
|
|
|
|
|
previousDailyCapacityMinutes: existing.dailyCapacityMinutes,
|
|
|
|
|
previousParallelCapacity: existing.parallelCapacity,
|
|
|
|
|
previousWorkingDays: parseWorkingDays(existing.workingDays),
|
|
|
|
|
previousIsActive: existing.isActive,
|
|
|
|
|
code: station.code,
|
|
|
|
|
name: station.name,
|
|
|
|
|
queueDays: station.queueDays,
|
|
|
|
|
dailyCapacityMinutes: station.dailyCapacityMinutes,
|
|
|
|
|
parallelCapacity: station.parallelCapacity,
|
|
|
|
|
workingDays,
|
|
|
|
|
isActive: station.isActive,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { ok: true as const, station: mapStation(station) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 11:12:58 -05:00
|
|
|
export async function listManufacturingProjectOptions(): Promise<ManufacturingProjectOptionDto[]> {
|
|
|
|
|
const projects = await prisma.project.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
status: {
|
|
|
|
|
notIn: ["COMPLETE"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
customer: {
|
|
|
|
|
select: {
|
|
|
|
|
name: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: [{ dueDate: "asc" }, { updatedAt: "desc" }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return projects.map((project) => ({
|
|
|
|
|
id: project.id,
|
|
|
|
|
projectNumber: project.projectNumber,
|
|
|
|
|
name: project.name,
|
|
|
|
|
customerName: project.customer.name,
|
|
|
|
|
status: project.status,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function listWorkOrders(filters: {
|
|
|
|
|
q?: string;
|
|
|
|
|
status?: WorkOrderStatus;
|
|
|
|
|
projectId?: string;
|
|
|
|
|
itemId?: string;
|
|
|
|
|
} = {}) {
|
|
|
|
|
const query = filters.q?.trim();
|
|
|
|
|
|
|
|
|
|
const workOrders = await workOrderModel.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
...(filters.status ? { status: filters.status } : {}),
|
|
|
|
|
...(filters.projectId ? { projectId: filters.projectId } : {}),
|
|
|
|
|
...(filters.itemId ? { itemId: filters.itemId } : {}),
|
|
|
|
|
...(query
|
|
|
|
|
? {
|
|
|
|
|
OR: [
|
|
|
|
|
{ workOrderNumber: { contains: query } },
|
|
|
|
|
{ item: { sku: { contains: query } } },
|
|
|
|
|
{ item: { name: { contains: query } } },
|
|
|
|
|
{ project: { projectNumber: { contains: query } } },
|
|
|
|
|
{ project: { name: { contains: query } } },
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
|
|
|
|
},
|
|
|
|
|
include: buildInclude(),
|
|
|
|
|
orderBy: [{ dueDate: "asc" }, { updatedAt: "desc" }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return workOrders.map((workOrder: unknown) => mapSummary(workOrder as WorkOrderRecord));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getWorkOrderById(workOrderId: string) {
|
|
|
|
|
const workOrder = await workOrderModel.findUnique({
|
|
|
|
|
where: { id: workOrderId },
|
|
|
|
|
include: buildInclude(),
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-15 16:40:25 -05:00
|
|
|
if (!workOrder) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return mapDetail(workOrder as WorkOrderRecord, await getComponentAvailability(workOrder as WorkOrderRecord));
|
2026-03-15 11:12:58 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:11:21 -05:00
|
|
|
export async function createWorkOrder(payload: WorkOrderInput, actorId?: string | null) {
|
2026-03-15 11:12:58 -05:00
|
|
|
const validated = await validateWorkOrderInput(payload);
|
|
|
|
|
if (!validated.ok) {
|
|
|
|
|
return { ok: false as const, reason: validated.reason };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const workOrderNumber = await nextWorkOrderNumber();
|
|
|
|
|
const created = await workOrderModel.create({
|
|
|
|
|
data: {
|
|
|
|
|
workOrderNumber,
|
|
|
|
|
itemId: payload.itemId,
|
|
|
|
|
projectId: payload.projectId,
|
2026-03-15 16:40:25 -05:00
|
|
|
salesOrderId: payload.salesOrderId,
|
|
|
|
|
salesOrderLineId: payload.salesOrderLineId,
|
2026-03-15 11:12:58 -05:00
|
|
|
warehouseId: payload.warehouseId,
|
|
|
|
|
locationId: payload.locationId,
|
|
|
|
|
status: payload.status,
|
|
|
|
|
quantity: payload.quantity,
|
|
|
|
|
dueDate: payload.dueDate ? new Date(payload.dueDate) : null,
|
|
|
|
|
notes: payload.notes,
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-15 12:11:46 -05:00
|
|
|
await regenerateWorkOrderOperations(created.id);
|
2026-03-15 14:00:12 -05:00
|
|
|
await syncWorkOrderReservations(created.id);
|
2026-03-15 12:11:46 -05:00
|
|
|
|
2026-03-15 11:12:58 -05:00
|
|
|
const workOrder = await getWorkOrderById(created.id);
|
2026-03-15 14:11:21 -05:00
|
|
|
if (workOrder) {
|
|
|
|
|
await logAuditEvent({
|
|
|
|
|
actorId,
|
|
|
|
|
entityType: "work-order",
|
|
|
|
|
entityId: created.id,
|
|
|
|
|
action: "created",
|
|
|
|
|
summary: `Created work order ${workOrder.workOrderNumber}.`,
|
|
|
|
|
metadata: {
|
|
|
|
|
workOrderNumber: workOrder.workOrderNumber,
|
|
|
|
|
itemId: workOrder.itemId,
|
|
|
|
|
projectId: workOrder.projectId,
|
|
|
|
|
status: workOrder.status,
|
|
|
|
|
quantity: workOrder.quantity,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-15 11:12:58 -05:00
|
|
|
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:11:21 -05:00
|
|
|
export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInput, actorId?: string | null) {
|
2026-03-15 11:12:58 -05:00
|
|
|
const existing = await workOrderModel.findUnique({
|
|
|
|
|
where: { id: workOrderId },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
completedQuantity: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return { ok: false as const, reason: "Work order was not found." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (payload.quantity < existing.completedQuantity) {
|
|
|
|
|
return { ok: false as const, reason: "Planned quantity cannot be less than the already completed quantity." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const validated = await validateWorkOrderInput(payload);
|
|
|
|
|
if (!validated.ok) {
|
|
|
|
|
return { ok: false as const, reason: validated.reason };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await workOrderModel.update({
|
|
|
|
|
where: { id: workOrderId },
|
|
|
|
|
data: {
|
|
|
|
|
itemId: payload.itemId,
|
|
|
|
|
projectId: payload.projectId,
|
2026-03-15 16:40:25 -05:00
|
|
|
salesOrderId: payload.salesOrderId,
|
|
|
|
|
salesOrderLineId: payload.salesOrderLineId,
|
2026-03-15 11:12:58 -05:00
|
|
|
warehouseId: payload.warehouseId,
|
|
|
|
|
locationId: payload.locationId,
|
|
|
|
|
status: payload.status,
|
|
|
|
|
quantity: payload.quantity,
|
|
|
|
|
dueDate: payload.dueDate ? new Date(payload.dueDate) : null,
|
|
|
|
|
notes: payload.notes,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-15 12:11:46 -05:00
|
|
|
await regenerateWorkOrderOperations(workOrderId);
|
2026-03-15 14:00:12 -05:00
|
|
|
await syncWorkOrderReservations(workOrderId);
|
2026-03-15 12:11:46 -05:00
|
|
|
|
2026-03-15 11:12:58 -05:00
|
|
|
const workOrder = await getWorkOrderById(workOrderId);
|
2026-03-15 14:11:21 -05:00
|
|
|
if (workOrder) {
|
|
|
|
|
await logAuditEvent({
|
|
|
|
|
actorId,
|
|
|
|
|
entityType: "work-order",
|
|
|
|
|
entityId: workOrderId,
|
|
|
|
|
action: "updated",
|
|
|
|
|
summary: `Updated work order ${workOrder.workOrderNumber}.`,
|
|
|
|
|
metadata: {
|
|
|
|
|
workOrderNumber: workOrder.workOrderNumber,
|
|
|
|
|
itemId: workOrder.itemId,
|
|
|
|
|
projectId: workOrder.projectId,
|
|
|
|
|
status: workOrder.status,
|
|
|
|
|
quantity: workOrder.quantity,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-15 11:12:58 -05:00
|
|
|
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:11:21 -05:00
|
|
|
export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrderStatus, actorId?: string | null) {
|
2026-03-15 11:12:58 -05:00
|
|
|
const existing = await workOrderModel.findUnique({
|
|
|
|
|
where: { id: workOrderId },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
status: true,
|
|
|
|
|
quantity: true,
|
|
|
|
|
completedQuantity: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return { ok: false as const, reason: "Work order was not found." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (existing.status === "COMPLETE" && status !== "COMPLETE") {
|
|
|
|
|
return { ok: false as const, reason: "Completed work orders cannot be reopened from quick actions." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (status === "COMPLETE" && existing.completedQuantity < existing.quantity) {
|
|
|
|
|
return { ok: false as const, reason: "Use the completion action to finish a work order." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await workOrderModel.update({
|
|
|
|
|
where: { id: workOrderId },
|
|
|
|
|
data: {
|
|
|
|
|
status,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-15 14:00:12 -05:00
|
|
|
await syncWorkOrderReservations(workOrderId);
|
|
|
|
|
|
2026-03-15 11:12:58 -05:00
|
|
|
const workOrder = await getWorkOrderById(workOrderId);
|
2026-03-15 14:11:21 -05:00
|
|
|
if (workOrder) {
|
|
|
|
|
await logAuditEvent({
|
|
|
|
|
actorId,
|
|
|
|
|
entityType: "work-order",
|
|
|
|
|
entityId: workOrderId,
|
|
|
|
|
action: "status.updated",
|
|
|
|
|
summary: `Updated work order ${workOrder.workOrderNumber} to ${status}.`,
|
|
|
|
|
metadata: {
|
|
|
|
|
workOrderNumber: workOrder.workOrderNumber,
|
|
|
|
|
status,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-15 11:12:58 -05:00
|
|
|
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 00:10:15 -05:00
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 06:22:37 -05:00
|
|
|
export async function updateWorkOrderOperationExecution(
|
|
|
|
|
workOrderId: string,
|
|
|
|
|
operationId: string,
|
|
|
|
|
payload: WorkOrderOperationExecutionInput,
|
|
|
|
|
actorId?: string | null
|
|
|
|
|
) {
|
|
|
|
|
const existing = await prisma.workOrderOperation.findUnique({
|
|
|
|
|
where: { id: operationId },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
workOrderId: true,
|
|
|
|
|
sequence: true,
|
|
|
|
|
status: true,
|
|
|
|
|
actualStart: true,
|
|
|
|
|
actualEnd: true,
|
|
|
|
|
actualMinutes: true,
|
|
|
|
|
plannedMinutes: true,
|
|
|
|
|
workOrder: {
|
|
|
|
|
select: {
|
|
|
|
|
status: true,
|
|
|
|
|
workOrderNumber: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!existing || existing.workOrderId !== workOrderId) {
|
|
|
|
|
return { ok: false as const, reason: "Work-order operation was not found." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (existing.workOrder.status === "COMPLETE" || existing.workOrder.status === "CANCELLED" || existing.workOrder.status === "DRAFT") {
|
|
|
|
|
return { ok: false as const, reason: "Operation execution can only be updated on released or active work orders." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const nextData: Record<string, unknown> = {};
|
|
|
|
|
|
|
|
|
|
if (payload.action === "START") {
|
|
|
|
|
if (existing.status === "COMPLETE") {
|
|
|
|
|
return { ok: false as const, reason: "Completed operations cannot be restarted." };
|
|
|
|
|
}
|
|
|
|
|
nextData.status = "IN_PROGRESS";
|
|
|
|
|
nextData.actualStart = existing.actualStart ?? now;
|
|
|
|
|
} else if (payload.action === "PAUSE") {
|
|
|
|
|
if (existing.status !== "IN_PROGRESS") {
|
|
|
|
|
return { ok: false as const, reason: "Only in-progress operations can be paused." };
|
|
|
|
|
}
|
|
|
|
|
nextData.status = "PAUSED";
|
|
|
|
|
nextData.actualStart = existing.actualStart ?? now;
|
|
|
|
|
} else if (payload.action === "RESUME") {
|
|
|
|
|
if (existing.status !== "PAUSED" && existing.status !== "PENDING") {
|
|
|
|
|
return { ok: false as const, reason: "Only paused or pending operations can be resumed." };
|
|
|
|
|
}
|
|
|
|
|
nextData.status = "IN_PROGRESS";
|
|
|
|
|
nextData.actualStart = existing.actualStart ?? now;
|
|
|
|
|
} else if (payload.action === "COMPLETE") {
|
|
|
|
|
if (existing.status === "COMPLETE") {
|
|
|
|
|
return { ok: false as const, reason: "Operation is already complete." };
|
|
|
|
|
}
|
|
|
|
|
nextData.status = "COMPLETE";
|
|
|
|
|
nextData.actualStart = existing.actualStart ?? now;
|
|
|
|
|
nextData.actualEnd = now;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await prisma.workOrderOperation.update({
|
|
|
|
|
where: { id: operationId },
|
|
|
|
|
data: nextData,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await syncWorkOrderStatusFromOperationActivity(workOrderId);
|
|
|
|
|
|
|
|
|
|
const workOrder = await getWorkOrderById(workOrderId);
|
|
|
|
|
if (!workOrder) {
|
|
|
|
|
return { ok: false as const, reason: "Unable to load updated work order." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await logAuditEvent({
|
|
|
|
|
actorId,
|
|
|
|
|
entityType: "work-order",
|
|
|
|
|
entityId: workOrderId,
|
|
|
|
|
action: "operation.execution.updated",
|
|
|
|
|
summary: `${payload.action} operation ${existing.sequence} on ${existing.workOrder.workOrderNumber}.`,
|
|
|
|
|
metadata: {
|
|
|
|
|
workOrderNumber: existing.workOrder.workOrderNumber,
|
|
|
|
|
operationId,
|
|
|
|
|
sequence: existing.sequence,
|
|
|
|
|
action: payload.action,
|
|
|
|
|
notes: payload.notes,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { ok: true as const, workOrder };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function recordWorkOrderOperationLabor(
|
|
|
|
|
workOrderId: string,
|
|
|
|
|
operationId: string,
|
|
|
|
|
payload: WorkOrderOperationLaborEntryInput,
|
|
|
|
|
createdById?: string | null
|
|
|
|
|
) {
|
|
|
|
|
const existing = await prisma.workOrderOperation.findUnique({
|
|
|
|
|
where: { id: operationId },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
workOrderId: true,
|
|
|
|
|
sequence: true,
|
|
|
|
|
status: true,
|
|
|
|
|
actualStart: true,
|
|
|
|
|
actualMinutes: true,
|
|
|
|
|
workOrder: {
|
|
|
|
|
select: {
|
|
|
|
|
status: true,
|
|
|
|
|
workOrderNumber: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!existing || existing.workOrderId !== workOrderId) {
|
|
|
|
|
return { ok: false as const, reason: "Work-order operation was not found." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (existing.workOrder.status === "COMPLETE" || existing.workOrder.status === "CANCELLED" || existing.workOrder.status === "DRAFT") {
|
|
|
|
|
return { ok: false as const, reason: "Labor can only be posted to released or active work orders." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (existing.status === "COMPLETE") {
|
|
|
|
|
return { ok: false as const, reason: "Completed operations cannot receive additional labor entries." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
|
|
|
await tx.workOrderOperationLaborEntry.create({
|
|
|
|
|
data: {
|
|
|
|
|
operationId,
|
|
|
|
|
minutes: payload.minutes,
|
|
|
|
|
notes: payload.notes,
|
|
|
|
|
createdById: createdById ?? null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await tx.workOrderOperation.update({
|
|
|
|
|
where: { id: operationId },
|
|
|
|
|
data: {
|
|
|
|
|
status: existing.status === "PENDING" ? "IN_PROGRESS" : existing.status,
|
|
|
|
|
actualStart: existing.actualStart ?? new Date(),
|
|
|
|
|
actualMinutes: {
|
|
|
|
|
increment: payload.minutes,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await syncWorkOrderStatusFromOperationActivity(workOrderId);
|
|
|
|
|
|
|
|
|
|
const workOrder = await getWorkOrderById(workOrderId);
|
|
|
|
|
if (!workOrder) {
|
|
|
|
|
return { ok: false as const, reason: "Unable to load updated work order." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await logAuditEvent({
|
|
|
|
|
actorId: createdById,
|
|
|
|
|
entityType: "work-order",
|
|
|
|
|
entityId: workOrderId,
|
|
|
|
|
action: "operation.labor.recorded",
|
|
|
|
|
summary: `Recorded labor on operation ${existing.sequence} for ${existing.workOrder.workOrderNumber}.`,
|
|
|
|
|
metadata: {
|
|
|
|
|
workOrderNumber: existing.workOrder.workOrderNumber,
|
|
|
|
|
operationId,
|
|
|
|
|
sequence: existing.sequence,
|
|
|
|
|
minutes: payload.minutes,
|
|
|
|
|
notes: payload.notes,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { ok: true as const, workOrder };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 11:12:58 -05:00
|
|
|
export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkOrderMaterialIssueInput, createdById?: string | null) {
|
|
|
|
|
const workOrder = await workOrderModel.findUnique({
|
|
|
|
|
where: { id: workOrderId },
|
|
|
|
|
include: buildInclude(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!workOrder) {
|
|
|
|
|
return { ok: false as const, reason: "Work order was not found." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (workOrder.status === "DRAFT" || workOrder.status === "CANCELLED" || workOrder.status === "COMPLETE") {
|
|
|
|
|
return { ok: false as const, reason: "Material can only be issued to released or active work orders." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const componentRequirement = (workOrder as WorkOrderRecord).item.bomLines.find((line) => line.componentItem.id === payload.componentItemId);
|
|
|
|
|
if (!componentRequirement) {
|
|
|
|
|
return { ok: false as const, reason: "Issued material must be part of the work order BOM." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const location = await prisma.warehouseLocation.findUnique({
|
|
|
|
|
where: { id: payload.locationId },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
warehouseId: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!location || location.warehouseId !== payload.warehouseId) {
|
|
|
|
|
return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 16:40:25 -05:00
|
|
|
const currentDetail = mapDetail(workOrder as WorkOrderRecord, await getComponentAvailability(workOrder as WorkOrderRecord));
|
2026-03-15 11:12:58 -05:00
|
|
|
const currentRequirement = currentDetail.materialRequirements.find(
|
|
|
|
|
(requirement: WorkOrderDetailDto["materialRequirements"][number]) => requirement.componentItemId === payload.componentItemId
|
|
|
|
|
);
|
|
|
|
|
if (!currentRequirement) {
|
|
|
|
|
return { ok: false as const, reason: "Issued material must be part of the work order BOM." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (payload.quantity > currentRequirement.remainingQuantity) {
|
|
|
|
|
return { ok: false as const, reason: "Material issue exceeds the remaining required quantity." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onHand = await getItemLocationOnHand(payload.componentItemId, payload.warehouseId, payload.locationId);
|
|
|
|
|
if (onHand < payload.quantity) {
|
|
|
|
|
return { ok: false as const, reason: "Material issue would drive the selected location below zero on-hand." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
|
|
|
const transactionClient = tx as any;
|
|
|
|
|
|
|
|
|
|
await transactionClient.workOrderMaterialIssue.create({
|
|
|
|
|
data: {
|
|
|
|
|
workOrderId,
|
|
|
|
|
componentItemId: payload.componentItemId,
|
|
|
|
|
warehouseId: payload.warehouseId,
|
|
|
|
|
locationId: payload.locationId,
|
|
|
|
|
quantity: payload.quantity,
|
|
|
|
|
notes: payload.notes,
|
|
|
|
|
createdById: createdById ?? null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await transactionClient.inventoryTransaction.create({
|
|
|
|
|
data: {
|
|
|
|
|
itemId: payload.componentItemId,
|
|
|
|
|
warehouseId: payload.warehouseId,
|
|
|
|
|
locationId: payload.locationId,
|
|
|
|
|
transactionType: "ISSUE",
|
|
|
|
|
quantity: payload.quantity,
|
|
|
|
|
reference: `${(workOrder as WorkOrderRecord).workOrderNumber} material issue`,
|
|
|
|
|
notes: payload.notes,
|
|
|
|
|
createdById: createdById ?? null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await transactionClient.workOrder.update({
|
|
|
|
|
where: { id: workOrderId },
|
|
|
|
|
data: {
|
|
|
|
|
status: workOrder.status === "RELEASED" || workOrder.status === "ON_HOLD" ? "IN_PROGRESS" : workOrder.status,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-15 14:00:12 -05:00
|
|
|
await syncWorkOrderReservations(workOrderId);
|
|
|
|
|
|
2026-03-15 11:12:58 -05:00
|
|
|
const nextWorkOrder = await getWorkOrderById(workOrderId);
|
2026-03-15 14:11:21 -05:00
|
|
|
if (nextWorkOrder) {
|
|
|
|
|
await logAuditEvent({
|
|
|
|
|
actorId: createdById,
|
|
|
|
|
entityType: "work-order",
|
|
|
|
|
entityId: workOrderId,
|
|
|
|
|
action: "material.issued",
|
|
|
|
|
summary: `Issued material to work order ${nextWorkOrder.workOrderNumber}.`,
|
|
|
|
|
metadata: {
|
|
|
|
|
workOrderNumber: nextWorkOrder.workOrderNumber,
|
|
|
|
|
componentItemId: payload.componentItemId,
|
|
|
|
|
warehouseId: payload.warehouseId,
|
|
|
|
|
locationId: payload.locationId,
|
|
|
|
|
quantity: payload.quantity,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-15 11:12:58 -05:00
|
|
|
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function recordWorkOrderCompletion(workOrderId: string, payload: WorkOrderCompletionInput, createdById?: string | null) {
|
|
|
|
|
const workOrder = await workOrderModel.findUnique({
|
|
|
|
|
where: { id: workOrderId },
|
|
|
|
|
include: buildInclude(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!workOrder) {
|
|
|
|
|
return { ok: false as const, reason: "Work order was not found." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (workOrder.status === "DRAFT" || workOrder.status === "CANCELLED" || workOrder.status === "COMPLETE") {
|
|
|
|
|
return { ok: false as const, reason: "Completion can only be posted to released or active work orders." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const remainingQuantity = workOrder.quantity - workOrder.completedQuantity;
|
|
|
|
|
if (payload.quantity > remainingQuantity) {
|
|
|
|
|
return { ok: false as const, reason: "Completion quantity exceeds the remaining build quantity." };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextCompletedQuantity = workOrder.completedQuantity + payload.quantity;
|
|
|
|
|
const nextStatus = nextCompletedQuantity >= workOrder.quantity ? "COMPLETE" : "IN_PROGRESS";
|
|
|
|
|
|
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
|
|
|
const transactionClient = tx as any;
|
|
|
|
|
|
|
|
|
|
await transactionClient.workOrderCompletion.create({
|
|
|
|
|
data: {
|
|
|
|
|
workOrderId,
|
|
|
|
|
quantity: payload.quantity,
|
|
|
|
|
notes: payload.notes,
|
|
|
|
|
createdById: createdById ?? null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await transactionClient.inventoryTransaction.create({
|
|
|
|
|
data: {
|
|
|
|
|
itemId: workOrder.item.id,
|
|
|
|
|
warehouseId: workOrder.warehouse.id,
|
|
|
|
|
locationId: workOrder.location.id,
|
|
|
|
|
transactionType: "RECEIPT",
|
|
|
|
|
quantity: payload.quantity,
|
|
|
|
|
reference: `${workOrder.workOrderNumber} production completion`,
|
|
|
|
|
notes: payload.notes,
|
|
|
|
|
createdById: createdById ?? null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await transactionClient.workOrder.update({
|
|
|
|
|
where: { id: workOrderId },
|
|
|
|
|
data: {
|
|
|
|
|
completedQuantity: nextCompletedQuantity,
|
|
|
|
|
status: nextStatus,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-15 14:00:12 -05:00
|
|
|
await syncWorkOrderReservations(workOrderId);
|
|
|
|
|
|
2026-03-15 11:12:58 -05:00
|
|
|
const nextWorkOrder = await getWorkOrderById(workOrderId);
|
2026-03-15 14:11:21 -05:00
|
|
|
if (nextWorkOrder) {
|
|
|
|
|
await logAuditEvent({
|
|
|
|
|
actorId: createdById,
|
|
|
|
|
entityType: "work-order",
|
|
|
|
|
entityId: workOrderId,
|
|
|
|
|
action: "completion.recorded",
|
|
|
|
|
summary: `Recorded completion against work order ${nextWorkOrder.workOrderNumber}.`,
|
|
|
|
|
metadata: {
|
|
|
|
|
workOrderNumber: nextWorkOrder.workOrderNumber,
|
|
|
|
|
quantity: payload.quantity,
|
|
|
|
|
status: nextWorkOrder.status,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-15 11:12:58 -05:00
|
|
|
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
|
|
|
|
|
}
|