manufacturing layer
This commit is contained in:
@@ -6,7 +6,9 @@ import type {
|
||||
WorkOrderCompletionInput,
|
||||
WorkOrderDetailDto,
|
||||
WorkOrderInput,
|
||||
WorkOrderOperationExecutionInput,
|
||||
WorkOrderOperationDto,
|
||||
WorkOrderOperationLaborEntryInput,
|
||||
WorkOrderOperationScheduleInput,
|
||||
WorkOrderMaterialIssueInput,
|
||||
WorkOrderStatus,
|
||||
@@ -109,6 +111,10 @@ type WorkOrderRecord = {
|
||||
plannedStart: Date;
|
||||
plannedEnd: Date;
|
||||
notes: string;
|
||||
status: string;
|
||||
actualStart: Date | null;
|
||||
actualEnd: Date | null;
|
||||
actualMinutes: number;
|
||||
station: {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -117,6 +123,16 @@ type WorkOrderRecord = {
|
||||
parallelCapacity: number;
|
||||
workingDays: string;
|
||||
};
|
||||
laborEntries: Array<{
|
||||
id: string;
|
||||
minutes: number;
|
||||
notes: string;
|
||||
createdAt: Date;
|
||||
createdBy: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
} | null;
|
||||
}>;
|
||||
}>;
|
||||
materialIssues: Array<{
|
||||
id: string;
|
||||
@@ -252,6 +268,17 @@ function buildInclude() {
|
||||
workingDays: true,
|
||||
},
|
||||
},
|
||||
laborEntries: {
|
||||
include: {
|
||||
createdBy: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
},
|
||||
orderBy: [{ sequence: "asc" }],
|
||||
},
|
||||
@@ -352,6 +379,7 @@ function mapDetail(
|
||||
itemUnitOfMeasure: record.item.unitOfMeasure,
|
||||
projectCustomerName: record.project?.customer.name ?? null,
|
||||
dueQuantity: record.quantity - record.completedQuantity,
|
||||
totalActualMinutes: record.operations.reduce((sum, operation) => sum + operation.actualMinutes, 0),
|
||||
operations: record.operations.map((operation): WorkOrderOperationDto => ({
|
||||
id: operation.id,
|
||||
stationId: operation.station.id,
|
||||
@@ -368,6 +396,18 @@ function mapDetail(
|
||||
plannedStart: operation.plannedStart.toISOString(),
|
||||
plannedEnd: operation.plannedEnd.toISOString(),
|
||||
notes: operation.notes,
|
||||
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),
|
||||
})),
|
||||
})),
|
||||
materialRequirements: record.item.bomLines.map((line) => {
|
||||
const requiredQuantity = line.quantity * record.quantity;
|
||||
@@ -566,6 +606,7 @@ async function regenerateWorkOrderOperations(workOrderId: string) {
|
||||
plannedStart: operation.plannedStart,
|
||||
plannedEnd: operation.plannedEnd,
|
||||
notes: operation.notes,
|
||||
status: "PENDING",
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -634,6 +675,29 @@ async function rescheduleWorkOrderOperationsToStation(
|
||||
return getWorkOrderById(workOrderId);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function syncWorkOrderReservations(workOrderId: string) {
|
||||
const workOrder = await getWorkOrderById(workOrderId);
|
||||
if (!workOrder) {
|
||||
@@ -1312,6 +1376,183 @@ export async function updateWorkOrderOperationSchedule(
|
||||
return { ok: true as const, workOrder: rescheduled };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkOrderMaterialIssueInput, createdById?: string | null) {
|
||||
const workOrder = await workOrderModel.findUnique({
|
||||
where: { id: workOrderId },
|
||||
|
||||
Reference in New Issue
Block a user