Files
mrp/server/src/modules/manufacturing/service.ts

1065 lines
29 KiB
TypeScript
Raw Normal View History

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-15 12:11:46 -05:00
WorkOrderOperationDto,
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;
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-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;
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;
station: {
id: string;
code: string;
name: string;
};
}>;
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 {
return {
id: record.id,
code: record.code,
name: record.name,
description: record.description,
queueDays: record.queueDays,
isActive: record.isActive,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
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,
},
},
},
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,
},
},
},
},
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,
},
},
},
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,
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(),
};
}
function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
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-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,
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-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;
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),
};
}),
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-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) => {
const plannedMinutes = Math.max(operation.setupMinutes + operation.runMinutesPerUnit * quantity + operation.moveMinutes + operation.station.queueDays * 8 * 60, 1);
return {
stationId: operation.station.id,
sequence: operation.position,
setupMinutes: operation.setupMinutes,
runMinutesPerUnit: operation.runMinutesPerUnit,
moveMinutes: operation.moveMinutes,
plannedMinutes,
notes: operation.notes,
};
});
if (dueDate) {
let nextEnd = new Date(dueDate);
return operationDurations
.slice()
.sort((left, right) => right.sequence - left.sequence)
.map((operation) => {
const plannedStart = addMinutes(nextEnd, -operation.plannedMinutes);
const planned = {
...operation,
plannedStart,
plannedEnd: nextEnd,
};
nextEnd = plannedStart;
return planned;
})
.reverse();
}
let nextStart = new Date(fallbackStart);
return operationDurations.map((operation) => {
const plannedEnd = addMinutes(nextStart, operation.plannedMinutes);
const planned = {
...operation,
plannedStart: nextStart,
plannedEnd,
};
nextStart = plannedEnd;
return planned;
});
}
async function regenerateWorkOrderOperations(workOrderId: string) {
const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId },
include: buildInclude(),
});
if (!workOrder) {
return;
}
const plan = buildWorkOrderOperationPlan(
(workOrder as WorkOrderRecord).item.operations,
workOrder.quantity,
workOrder.dueDate,
workOrder.createdAt
);
await prisma.workOrderOperation.deleteMany({
where: {
workOrderId,
},
});
if (plan.length === 0) {
return;
}
await prisma.workOrderOperation.createMany({
data: plan.map((operation) => ({
workOrderId,
stationId: operation.stationId,
sequence: operation.sequence,
setupMinutes: operation.setupMinutes,
runMinutesPerUnit: operation.runMinutesPerUnit,
moveMinutes: operation.moveMinutes,
plannedMinutes: operation.plannedMinutes,
plannedStart: operation.plannedStart,
plannedEnd: operation.plannedEnd,
notes: operation.notes,
})),
});
}
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);
}
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." };
}
}
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-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,
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: {
code: station.code,
name: station.name,
queueDays: station.queueDays,
isActive: station.isActive,
},
});
2026-03-15 12:11:46 -05:00
return 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(),
});
return workOrder ? mapDetail(workOrder as WorkOrderRecord) : null;
}
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,
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,
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." };
}
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." };
}
const currentDetail = mapDetail(workOrder as WorkOrderRecord);
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." };
}