manufacturing layer
This commit is contained in:
@@ -57,6 +57,8 @@ type PlanningWorkOrderRecord = {
|
||||
plannedStart: Date;
|
||||
plannedEnd: Date;
|
||||
plannedMinutes: number;
|
||||
actualMinutes: number;
|
||||
status: string;
|
||||
station: { id: string; code: string; name: string; dailyCapacityMinutes: number; parallelCapacity: number; workingDays: string };
|
||||
}>;
|
||||
materialIssues: Array<{ componentItemId: string; quantity: number }>;
|
||||
@@ -82,6 +84,7 @@ type StationAccumulator = {
|
||||
operationCount: number;
|
||||
workOrderIds: Set<string>;
|
||||
totalPlannedMinutes: number;
|
||||
totalActualMinutes: number;
|
||||
blockedCount: number;
|
||||
readyCount: number;
|
||||
lateCount: number;
|
||||
@@ -177,6 +180,7 @@ function getAvailabilityKey(itemId: string, warehouseId: string, locationId: str
|
||||
function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
|
||||
const capacityMinutes = Math.max(record.dayKeys.size, 1) * Math.max(record.dailyCapacityMinutes, 60) * Math.max(record.parallelCapacity, 1);
|
||||
const utilizationPercent = capacityMinutes > 0 ? Math.round((record.totalPlannedMinutes / capacityMinutes) * 100) : 0;
|
||||
const actualUtilizationPercent = capacityMinutes > 0 ? Math.round((record.totalActualMinutes / capacityMinutes) * 100) : 0;
|
||||
return {
|
||||
stationId: record.stationId,
|
||||
stationCode: record.stationCode,
|
||||
@@ -184,8 +188,10 @@ function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
|
||||
operationCount: record.operationCount,
|
||||
workOrderCount: record.workOrderIds.size,
|
||||
totalPlannedMinutes: record.totalPlannedMinutes,
|
||||
totalActualMinutes: record.totalActualMinutes,
|
||||
capacityMinutes,
|
||||
utilizationPercent,
|
||||
actualUtilizationPercent,
|
||||
overloaded: utilizationPercent > 100,
|
||||
blockedCount: record.blockedCount,
|
||||
readyCount: record.readyCount,
|
||||
@@ -308,6 +314,8 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
||||
plannedStart: true,
|
||||
plannedEnd: true,
|
||||
plannedMinutes: true,
|
||||
actualMinutes: true,
|
||||
status: true,
|
||||
station: { select: { id: true, code: true, name: true, dailyCapacityMinutes: true, parallelCapacity: true, workingDays: true } },
|
||||
},
|
||||
orderBy: [{ sequence: "asc" }],
|
||||
@@ -494,6 +502,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
||||
operationCount: 0,
|
||||
workOrderIds: new Set<string>(),
|
||||
totalPlannedMinutes: 0,
|
||||
totalActualMinutes: 0,
|
||||
blockedCount: 0,
|
||||
readyCount: 0,
|
||||
lateCount: 0,
|
||||
@@ -505,6 +514,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
||||
current.operationCount += 1;
|
||||
current.workOrderIds.add(workOrder.id);
|
||||
current.totalPlannedMinutes += operation.plannedMinutes;
|
||||
current.totalActualMinutes += operation.actualMinutes;
|
||||
if (insight?.readinessState === "BLOCKED" || insight?.readinessState === "SHORTAGE" || insight?.readinessState === "PENDING_SUPPLY") {
|
||||
current.blockedCount += 1;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
listManufacturingStations,
|
||||
listWorkOrders,
|
||||
recordWorkOrderCompletion,
|
||||
recordWorkOrderOperationLabor,
|
||||
updateManufacturingStation,
|
||||
updateWorkOrderOperationExecution,
|
||||
updateWorkOrder,
|
||||
updateWorkOrderOperationSchedule,
|
||||
updateWorkOrderStatus,
|
||||
@@ -74,6 +76,16 @@ const operationScheduleSchema = z.object({
|
||||
stationId: z.string().trim().min(1).nullable().optional(),
|
||||
});
|
||||
|
||||
const operationExecutionSchema = z.object({
|
||||
action: z.enum(["START", "PAUSE", "RESUME", "COMPLETE"]),
|
||||
notes: z.string(),
|
||||
});
|
||||
|
||||
const operationLaborSchema = z.object({
|
||||
minutes: z.number().int().positive(),
|
||||
notes: z.string(),
|
||||
});
|
||||
|
||||
function getRouteParam(value: unknown) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
@@ -215,6 +227,46 @@ manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/sch
|
||||
return ok(response, result.workOrder);
|
||||
});
|
||||
|
||||
manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/execution", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
|
||||
const workOrderId = getRouteParam(request.params.workOrderId);
|
||||
const operationId = getRouteParam(request.params.operationId);
|
||||
if (!workOrderId || !operationId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Work-order or operation id is invalid.");
|
||||
}
|
||||
|
||||
const parsed = operationExecutionSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Operation execution payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateWorkOrderOperationExecution(workOrderId, operationId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.workOrder);
|
||||
});
|
||||
|
||||
manufacturingRouter.post("/work-orders/:workOrderId/operations/:operationId/labor", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
|
||||
const workOrderId = getRouteParam(request.params.workOrderId);
|
||||
const operationId = getRouteParam(request.params.operationId);
|
||||
if (!workOrderId || !operationId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Work-order or operation id is invalid.");
|
||||
}
|
||||
|
||||
const parsed = operationLaborSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Operation labor payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await recordWorkOrderOperationLabor(workOrderId, operationId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.workOrder, 201);
|
||||
});
|
||||
|
||||
manufacturingRouter.post("/work-orders/:workOrderId/issues", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
|
||||
const workOrderId = getRouteParam(request.params.workOrderId);
|
||||
if (!workOrderId) {
|
||||
|
||||
@@ -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