From c49ed4bf4a82dc49cc0afe5ade6170be4406eba6 Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 18 Mar 2026 06:22:37 -0500 Subject: [PATCH] manufacturing layer --- CHANGELOG.md | 2 + README.md | 6 +- ROADMAP.md | 2 +- SHIPPED.md | 4 +- client/src/lib/api.ts | 16 ++ .../manufacturing/WorkOrderDetailPage.tsx | 168 +++++++++++- .../src/modules/workbench/WorkbenchPage.tsx | 6 +- .../migration.sql | 24 ++ server/prisma/schema.prisma | 21 ++ server/src/modules/gantt/service.ts | 10 + server/src/modules/manufacturing/router.ts | 52 ++++ server/src/modules/manufacturing/service.ts | 241 ++++++++++++++++++ shared/src/gantt/types.ts | 2 + shared/src/manufacturing/types.ts | 27 ++ 14 files changed, 561 insertions(+), 20 deletions(-) create mode 100644 server/prisma/migrations/20260318004000_operation_execution_and_labor/migration.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d8473e..f605c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,11 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh - Planning workbench dispatch upgrade with station load summaries, readiness scoring, release-ready and blocker filters, richer planner rows, and inline release/build/buy actions - Manufacturing finite-capacity slice with station daily capacity, parallel capacity, working-day calendars, calendar-aware operation scheduling, and operation-level rescheduling from the work-order detail page - Manufacturing station edit support for working days, active state, queue, and capacity settings directly from the manufacturing screen +- Operation execution controls on work orders, including start/pause/resume/complete actions, labor posting, and actual-minute rollups by operation and work order - Workbench rebalance controls for operation rows, including planner-side datetime rescheduling, quick shift moves, and heatmap-day targeting without leaving the dispatch surface - Workbench station-to-station rebalance so planners can move an operation onto another active work center and rebuild the downstream chain from the same dispatch surface - Workbench drag scheduling in station grouping mode, with draggable operation cards, station drop targets, heatmap-day-aware drop timing, and projected post-drop load cues before the move is committed +- Workbench station cards now show planned-vs-actual load so planners can compare schedule intent against recorded execution time - Project milestones with status, due dates, notes, and edit-time sequencing inside the project workflow - Project-side milestone and work-order rollups surfaced on project list and detail pages - Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form diff --git a/README.md b/README.md index 7b30666..535f8f3 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Current foundation scope includes: - purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files - shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments - projects with customer/commercial/shipment linkage, owners, due dates, milestones, rollups, notes, and attachments -- manufacturing work orders with project linkage, station-based operation templates, editable station calendars/capacity settings, calendar-aware operation scheduling, material issue posting, completion posting, operation rescheduling, and work-order attachments +- manufacturing work orders with project linkage, station-based operation templates, editable station calendars/capacity settings, calendar-aware operation scheduling, operation execution controls, labor posting, material issue posting, completion posting, operation rescheduling, and work-order attachments - planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, focus drawer, station load grouping, readiness filters, overload visibility, inline dispatch actions, planner-side operation rebalance controls including station-to-station moves, and station-lane drag scheduling - sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations - planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts @@ -110,7 +110,7 @@ Next expansion areas: ## Manufacturing Direction -Manufacturing is now a separate execution subsystem rather than being collapsed into Projects. The current slice ships work-order records with build-item linkage, optional project linkage, warehouse/location output posting, BOM-based material requirement visibility, station master data, item-level operation templates, editable station calendars and capacity settings, automatic work-order operation plans, operation-level rescheduling, material issue posting, completion posting, work-order attachments, and dashboard visibility. +Manufacturing is now a separate execution subsystem rather than being collapsed into Projects. The current slice ships work-order records with build-item linkage, optional project linkage, warehouse/location output posting, BOM-based material requirement visibility, station master data, item-level operation templates, editable station calendars and capacity settings, automatic work-order operation plans, operation-level execution controls, labor posting, operation-level rescheduling, material issue posting, completion posting, work-order attachments, and dashboard visibility. Current interactions: @@ -126,7 +126,7 @@ Next expansion areas: ## Planning Direction -Planning is now the live scheduling and visibility layer over projects and manufacturing instead of a placeholder wrapper. The current slice ships a planning workbench backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, exception rails, dense load heatmaps, station load summaries, readiness scoring, overload visibility, focus-drawer inspection, planner-side operation rebalance controls including station reassignment, station-lane drag scheduling with projected load cues, inline release/build/buy follow-through, and agenda sequencing. +Planning is now the live scheduling and visibility layer over projects and manufacturing instead of a placeholder wrapper. The current slice ships a planning workbench backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, exception rails, dense load heatmaps, station load summaries, readiness scoring, overload visibility, focus-drawer inspection, planner-side operation rebalance controls including station reassignment, station-lane drag scheduling with projected load cues, planned-vs-actual station load visibility, inline release/build/buy follow-through, and agenda sequencing. Current interactions: diff --git a/ROADMAP.md b/ROADMAP.md index 387812d..50e2fe1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -90,7 +90,7 @@ This file tracks work that still needs to be completed. Shipped phase history an - Work orders tied more explicitly to sales demand or internal build demand where appropriate - Routing/work-center structure for manufacturing steps and handoffs beyond the current station templates - Material consumption depth, WIP tracking, and execution traceability -- Labor and machine-time capture for production execution +- Deeper labor depth beyond the shipped manual operation labor posting, including crew assignment, live timer capture, and machine/runtime integration - Manufacturing rollups for open work, blockers, shortages, and throughput - Traveler/job packet output - Partial completions and split-order execution visibility diff --git a/SHIPPED.md b/SHIPPED.md index 69545d4..5110e7e 100644 --- a/SHIPPED.md +++ b/SHIPPED.md @@ -36,7 +36,7 @@ This file tracks roadmap phases, slices, and major foundations that have already - Project milestones and project-side milestone/work-order rollups - Project cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and cost-snapshot visibility, with direct launch paths into prefilled project work orders and demand-linked purchase orders plus a project activity timeline - Project list/detail/create/edit workflows and dashboard program widgets -- Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments +- Manufacturing foundation with work orders, project linkage, operation execution controls, labor posting, material issue posting, completion posting, and work-order attachments - Manufacturing stations, item routing templates, editable station calendars/capacity settings, automatic work-order operation planning, and operation-level rescheduling for the workbench schedule - Vendor invoice/supporting-document attachments directly on purchase orders - Vendor-detail purchasing visibility with recent purchase-order activity @@ -57,7 +57,7 @@ This file tracks roadmap phases, slices, and major foundations that have already - Live planning workbench timelines driven by project and manufacturing data - Planning workbench with heatmap, overview, and agenda modes plus exception rail, focus drawer, station load grouping, readiness scoring, and inline dispatch actions - Finite-capacity foundation with station working-day calendars, daily/parallel capacity settings, and calendar-aware operation scheduling -- Planner-side workbench rebalance controls for operation scheduling, with quick shift moves, heatmap-day targeting, station-to-station reassignment, and station-lane drag scheduling +- Planner-side workbench rebalance controls for operation scheduling, with quick shift moves, heatmap-day targeting, station-to-station reassignment, station-lane drag scheduling, and planned-vs-actual station load visibility - Sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations - Multi-stage Docker packaging and migration-aware entrypoint - Docker image validated locally with successful app startup and login flow diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 2e37350..be30030 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -61,6 +61,8 @@ import type { WorkOrderCompletionInput, WorkOrderDetailDto, WorkOrderInput, + WorkOrderOperationExecutionInput, + WorkOrderOperationLaborEntryInput, WorkOrderOperationScheduleInput, WorkOrderMaterialIssueInput, WorkOrderStatus, @@ -650,6 +652,20 @@ export const api = { token ); }, + updateWorkOrderOperationExecution(token: string, workOrderId: string, operationId: string, payload: WorkOrderOperationExecutionInput) { + return request( + `/api/v1/manufacturing/work-orders/${workOrderId}/operations/${operationId}/execution`, + { method: "PATCH", body: JSON.stringify(payload) }, + token + ); + }, + recordWorkOrderOperationLabor(token: string, workOrderId: string, operationId: string, payload: WorkOrderOperationLaborEntryInput) { + return request( + `/api/v1/manufacturing/work-orders/${workOrderId}/operations/${operationId}/labor`, + { method: "POST", body: JSON.stringify(payload) }, + token + ); + }, issueWorkOrderMaterial(token: string, workOrderId: string, payload: WorkOrderMaterialIssueInput) { return request( `/api/v1/manufacturing/work-orders/${workOrderId}/issues`, diff --git a/client/src/modules/manufacturing/WorkOrderDetailPage.tsx b/client/src/modules/manufacturing/WorkOrderDetailPage.tsx index 0e60ced..955a411 100644 --- a/client/src/modules/manufacturing/WorkOrderDetailPage.tsx +++ b/client/src/modules/manufacturing/WorkOrderDetailPage.tsx @@ -1,5 +1,13 @@ import { permissions } from "@mrp/shared"; -import type { WorkOrderCompletionInput, WorkOrderDetailDto, WorkOrderMaterialIssueInput, WorkOrderOperationScheduleInput, WorkOrderStatus } from "@mrp/shared"; +import type { + WorkOrderCompletionInput, + WorkOrderDetailDto, + WorkOrderMaterialIssueInput, + WorkOrderOperationExecutionInput, + WorkOrderOperationLaborEntryInput, + WorkOrderOperationScheduleInput, + WorkOrderStatus, +} from "@mrp/shared"; import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js"; import { useEffect, useMemo, useState } from "react"; import { Link, useParams } from "react-router-dom"; @@ -23,7 +31,10 @@ export function WorkOrderDetailPage() { const [isPostingIssue, setIsPostingIssue] = useState(false); const [isPostingCompletion, setIsPostingCompletion] = useState(false); const [operationScheduleForm, setOperationScheduleForm] = useState>({}); + const [operationLaborForm, setOperationLaborForm] = useState>({}); const [reschedulingOperationId, setReschedulingOperationId] = useState(null); + const [executingOperationId, setExecutingOperationId] = useState(null); + const [postingLaborOperationId, setPostingLaborOperationId] = useState(null); const [pendingConfirmation, setPendingConfirmation] = useState< | { kind: "status" | "issue" | "completion"; @@ -63,6 +74,11 @@ export function WorkOrderDetailPage() { nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }]) ) ); + setOperationLaborForm( + Object.fromEntries( + nextWorkOrder.operations.map((operation) => [operation.id, { minutes: Math.max(Math.round(operation.plannedMinutes / 4), 15), notes: "" }]) + ) + ); setStatus("Work order loaded."); }) .catch((error: unknown) => { @@ -173,6 +189,64 @@ export function WorkOrderDetailPage() { } } + async function submitOperationExecution(operationId: string, action: WorkOrderOperationExecutionInput["action"]) { + if (!token || !workOrder) { + return; + } + + setExecutingOperationId(operationId); + setStatus("Updating operation execution..."); + try { + const nextWorkOrder = await api.updateWorkOrderOperationExecution(token, workOrder.id, operationId, { + action, + notes: `${action} from work-order detail`, + }); + setWorkOrder(nextWorkOrder); + setOperationScheduleForm( + Object.fromEntries( + nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }]) + ) + ); + setStatus("Operation execution updated."); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to update operation execution."; + setStatus(message); + } finally { + setExecutingOperationId(null); + } + } + + async function submitOperationLabor(operationId: string) { + if (!token || !workOrder) { + return; + } + + const payload = operationLaborForm[operationId]; + if (!payload?.minutes) { + return; + } + + setPostingLaborOperationId(operationId); + setStatus("Posting labor entry..."); + try { + const nextWorkOrder = await api.recordWorkOrderOperationLabor(token, workOrder.id, operationId, payload); + setWorkOrder(nextWorkOrder); + setOperationLaborForm((current) => ({ + ...current, + [operationId]: { + minutes: Math.max(Math.round((nextWorkOrder.operations.find((operation) => operation.id === operationId)?.plannedMinutes ?? 60) / 4), 15), + notes: "", + }, + })); + setStatus("Labor entry posted."); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to post operation labor."; + setStatus(message); + } finally { + setPostingLaborOperationId(null); + } + } + function handleStatusChange(nextStatus: WorkOrderStatus) { if (!workOrder) { return; @@ -279,6 +353,7 @@ export function WorkOrderDetailPage() {

Operations

{workOrder.operations.length}

Due Date

{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}

Material Shortage

{workOrder.materialRequirements.reduce((sum, requirement) => sum + requirement.shortageQuantity, 0)}
+

Actual Hours

{(workOrder.totalActualMinutes / 60).toFixed(1)}
@@ -307,11 +382,12 @@ export function WorkOrderDetailPage() { Seq Station + Execution Capacity Start End - Minutes - {canManage ? Reschedule : null} + Planned / Actual + {canManage ? Execution Controls : null} @@ -322,16 +398,39 @@ export function WorkOrderDetailPage() {
{operation.stationCode}
{operation.stationName}
+ +
{operation.status.replaceAll("_", " ")}
+
Start {operation.actualStart ? new Date(operation.actualStart).toLocaleString() : "Not started"}
+
End {operation.actualEnd ? new Date(operation.actualEnd).toLocaleString() : "Open"}
+
{operation.laborEntryCount} labor entr{operation.laborEntryCount === 1 ? "y" : "ies"}
+
{operation.stationDailyCapacityMinutes} min/day x {operation.stationParallelCapacity}
{operation.stationWorkingDays.join(",")}
{new Date(operation.plannedStart).toLocaleString()} {new Date(operation.plannedEnd).toLocaleString()} - {operation.plannedMinutes} + +
{operation.plannedMinutes} planned
+
{operation.actualMinutes} actual
+ {canManage ? ( -
+
+
+ {operation.status === "PENDING" ? ( + + ) : null} + {(operation.status === "PENDING" || operation.status === "PAUSED") ? ( + + ) : null} + {operation.status === "IN_PROGRESS" ? ( + + ) : null} + {operation.status !== "COMPLETE" ? ( + + ) : null} +
- +
+ + setOperationLaborForm((current) => ({ + ...current, + [operation.id]: { + ...(current[operation.id] ?? { notes: "" }), + minutes: Number.parseInt(event.target.value, 10) || 1, + }, + })) + } + className="w-24 rounded-2xl border border-line/70 bg-page px-2 py-2 text-xs text-text outline-none transition focus:border-brand" + /> + + setOperationLaborForm((current) => ({ + ...current, + [operation.id]: { + ...(current[operation.id] ?? { minutes: 15 }), + notes: event.target.value, + }, + })) + } + className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-xs text-text outline-none transition focus:border-brand" + /> +
+
+ + +
) : null} diff --git a/client/src/modules/workbench/WorkbenchPage.tsx b/client/src/modules/workbench/WorkbenchPage.tsx index 43978d3..6054e3e 100644 --- a/client/src/modules/workbench/WorkbenchPage.tsx +++ b/client/src/modules/workbench/WorkbenchPage.tsx @@ -565,7 +565,7 @@ export function WorkbenchPage() {
Current load - {selectedRescheduleLoad.utilizationPercent}% util / {selectedRescheduleLoad.overloaded ? "Overloaded" : "Within load"} + {selectedRescheduleLoad.utilizationPercent}% planned / {selectedRescheduleLoad.actualUtilizationPercent}% actual
) : null} @@ -793,6 +793,10 @@ function OverviewBoard({
Blocked {station.blockedCount}
Late {station.lateCount}
+
+
Planned {station.totalPlannedMinutes} min
+
Actual {station.totalActualMinutes} min
+
{draggingOperation ? (
diff --git a/server/prisma/migrations/20260318004000_operation_execution_and_labor/migration.sql b/server/prisma/migrations/20260318004000_operation_execution_and_labor/migration.sql new file mode 100644 index 0000000..3df3f61 --- /dev/null +++ b/server/prisma/migrations/20260318004000_operation_execution_and_labor/migration.sql @@ -0,0 +1,24 @@ +-- AlterTable +ALTER TABLE "WorkOrderOperation" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'PENDING'; +ALTER TABLE "WorkOrderOperation" ADD COLUMN "actualStart" DATETIME; +ALTER TABLE "WorkOrderOperation" ADD COLUMN "actualEnd" DATETIME; +ALTER TABLE "WorkOrderOperation" ADD COLUMN "actualMinutes" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable +CREATE TABLE "WorkOrderOperationLaborEntry" ( + "id" TEXT NOT NULL PRIMARY KEY, + "operationId" TEXT NOT NULL, + "minutes" INTEGER NOT NULL, + "notes" TEXT NOT NULL, + "createdById" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "WorkOrderOperationLaborEntry_operationId_fkey" FOREIGN KEY ("operationId") REFERENCES "WorkOrderOperation" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "WorkOrderOperationLaborEntry_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "WorkOrderOperationLaborEntry_operationId_createdAt_idx" ON "WorkOrderOperationLaborEntry"("operationId", "createdAt"); + +-- CreateIndex +CREATE INDEX "WorkOrderOperationLaborEntry_createdById_createdAt_idx" ON "WorkOrderOperationLaborEntry"("createdById", "createdAt"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 8bebcb0..0734462 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -26,6 +26,7 @@ model User { ownedProjects Project[] @relation("ProjectOwner") workOrderMaterialIssues WorkOrderMaterialIssue[] workOrderCompletions WorkOrderCompletion[] + workOrderOperationLaborEntries WorkOrderOperationLaborEntry[] approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy") approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy") salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy") @@ -688,15 +689,35 @@ model WorkOrderOperation { plannedStart DateTime plannedEnd DateTime notes String + status String @default("PENDING") + actualStart DateTime? + actualEnd DateTime? + actualMinutes Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict) + laborEntries WorkOrderOperationLaborEntry[] @@index([workOrderId, sequence]) @@index([stationId, plannedStart]) } +model WorkOrderOperationLaborEntry { + id String @id @default(cuid()) + operationId String + minutes Int + notes String + createdById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + operation WorkOrderOperation @relation(fields: [operationId], references: [id], onDelete: Cascade) + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) + + @@index([operationId, createdAt]) + @@index([createdById, createdAt]) +} + model WorkOrderMaterialIssue { id String @id @default(cuid()) workOrderId String diff --git a/server/src/modules/gantt/service.ts b/server/src/modules/gantt/service.ts index 058d9dd..4a1e142 100644 --- a/server/src/modules/gantt/service.ts +++ b/server/src/modules/gantt/service.ts @@ -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; 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 { 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 { operationCount: 0, workOrderIds: new Set(), totalPlannedMinutes: 0, + totalActualMinutes: 0, blockedCount: 0, readyCount: 0, lateCount: 0, @@ -505,6 +514,7 @@ export async function getPlanningTimeline(): Promise { 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; } diff --git a/server/src/modules/manufacturing/router.ts b/server/src/modules/manufacturing/router.ts index 4639b0a..1e20a6f 100644 --- a/server/src/modules/manufacturing/router.ts +++ b/server/src/modules/manufacturing/router.ts @@ -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) { diff --git a/server/src/modules/manufacturing/service.ts b/server/src/modules/manufacturing/service.ts index fb3b582..c2c0726 100644 --- a/server/src/modules/manufacturing/service.ts +++ b/server/src/modules/manufacturing/service.ts @@ -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 = {}; + + 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 }, diff --git a/shared/src/gantt/types.ts b/shared/src/gantt/types.ts index 43053b1..51f63cb 100644 --- a/shared/src/gantt/types.ts +++ b/shared/src/gantt/types.ts @@ -85,8 +85,10 @@ export interface PlanningStationLoadDto { operationCount: number; workOrderCount: number; totalPlannedMinutes: number; + totalActualMinutes: number; capacityMinutes: number; utilizationPercent: number; + actualUtilizationPercent: number; overloaded: boolean; blockedCount: number; readyCount: number; diff --git a/shared/src/manufacturing/types.ts b/shared/src/manufacturing/types.ts index 5791974..75362c4 100644 --- a/shared/src/manufacturing/types.ts +++ b/shared/src/manufacturing/types.ts @@ -1,6 +1,8 @@ export const workOrderStatuses = ["DRAFT", "RELEASED", "IN_PROGRESS", "ON_HOLD", "COMPLETE", "CANCELLED"] as const; +export const workOrderOperationStatuses = ["PENDING", "IN_PROGRESS", "PAUSED", "COMPLETE"] as const; export type WorkOrderStatus = (typeof workOrderStatuses)[number]; +export type WorkOrderOperationStatus = (typeof workOrderOperationStatuses)[number]; export interface ManufacturingStationDto { id: string; @@ -88,6 +90,20 @@ export interface WorkOrderOperationDto { plannedStart: string; plannedEnd: string; notes: string; + status: WorkOrderOperationStatus; + actualStart: string | null; + actualEnd: string | null; + actualMinutes: number; + laborEntryCount: number; + laborEntries: WorkOrderOperationLaborEntryDto[]; +} + +export interface WorkOrderOperationLaborEntryDto { + id: string; + minutes: number; + notes: string; + createdAt: string; + createdByName: string; } export interface WorkOrderMaterialRequirementDto { @@ -137,6 +153,7 @@ export interface WorkOrderDetailDto extends WorkOrderSummaryDto { itemUnitOfMeasure: string; projectCustomerName: string | null; dueQuantity: number; + totalActualMinutes: number; operations: WorkOrderOperationDto[]; materialRequirements: WorkOrderMaterialRequirementDto[]; materialIssues: WorkOrderMaterialIssueDto[]; @@ -173,3 +190,13 @@ export interface WorkOrderOperationScheduleInput { plannedStart: string; stationId?: string | null; } + +export interface WorkOrderOperationExecutionInput { + action: "START" | "PAUSE" | "RESUME" | "COMPLETE"; + notes: string; +} + +export interface WorkOrderOperationLaborEntryInput { + minutes: number; + notes: string; +}