manufacturing layer
This commit is contained in:
@@ -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
|
- 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 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
|
- 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 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 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 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 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
|
- 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
|
- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Current foundation scope includes:
|
|||||||
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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 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:
|
Current interactions:
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ Next expansion areas:
|
|||||||
|
|
||||||
## Planning Direction
|
## 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:
|
Current interactions:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- 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
|
- Routing/work-center structure for manufacturing steps and handoffs beyond the current station templates
|
||||||
- Material consumption depth, WIP tracking, and execution traceability
|
- 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
|
- Manufacturing rollups for open work, blockers, shortages, and throughput
|
||||||
- Traveler/job packet output
|
- Traveler/job packet output
|
||||||
- Partial completions and split-order execution visibility
|
- Partial completions and split-order execution visibility
|
||||||
|
|||||||
@@ -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 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 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
|
- 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
|
- 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 invoice/supporting-document attachments directly on purchase orders
|
||||||
- Vendor-detail purchasing visibility with recent purchase-order activity
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- Multi-stage Docker packaging and migration-aware entrypoint
|
||||||
- Docker image validated locally with successful app startup and login flow
|
- Docker image validated locally with successful app startup and login flow
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ import type {
|
|||||||
WorkOrderCompletionInput,
|
WorkOrderCompletionInput,
|
||||||
WorkOrderDetailDto,
|
WorkOrderDetailDto,
|
||||||
WorkOrderInput,
|
WorkOrderInput,
|
||||||
|
WorkOrderOperationExecutionInput,
|
||||||
|
WorkOrderOperationLaborEntryInput,
|
||||||
WorkOrderOperationScheduleInput,
|
WorkOrderOperationScheduleInput,
|
||||||
WorkOrderMaterialIssueInput,
|
WorkOrderMaterialIssueInput,
|
||||||
WorkOrderStatus,
|
WorkOrderStatus,
|
||||||
@@ -650,6 +652,20 @@ export const api = {
|
|||||||
token
|
token
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
updateWorkOrderOperationExecution(token: string, workOrderId: string, operationId: string, payload: WorkOrderOperationExecutionInput) {
|
||||||
|
return request<WorkOrderDetailDto>(
|
||||||
|
`/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<WorkOrderDetailDto>(
|
||||||
|
`/api/v1/manufacturing/work-orders/${workOrderId}/operations/${operationId}/labor`,
|
||||||
|
{ method: "POST", body: JSON.stringify(payload) },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
issueWorkOrderMaterial(token: string, workOrderId: string, payload: WorkOrderMaterialIssueInput) {
|
issueWorkOrderMaterial(token: string, workOrderId: string, payload: WorkOrderMaterialIssueInput) {
|
||||||
return request<WorkOrderDetailDto>(
|
return request<WorkOrderDetailDto>(
|
||||||
`/api/v1/manufacturing/work-orders/${workOrderId}/issues`,
|
`/api/v1/manufacturing/work-orders/${workOrderId}/issues`,
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { permissions } from "@mrp/shared";
|
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 type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
@@ -23,7 +31,10 @@ export function WorkOrderDetailPage() {
|
|||||||
const [isPostingIssue, setIsPostingIssue] = useState(false);
|
const [isPostingIssue, setIsPostingIssue] = useState(false);
|
||||||
const [isPostingCompletion, setIsPostingCompletion] = useState(false);
|
const [isPostingCompletion, setIsPostingCompletion] = useState(false);
|
||||||
const [operationScheduleForm, setOperationScheduleForm] = useState<Record<string, WorkOrderOperationScheduleInput>>({});
|
const [operationScheduleForm, setOperationScheduleForm] = useState<Record<string, WorkOrderOperationScheduleInput>>({});
|
||||||
|
const [operationLaborForm, setOperationLaborForm] = useState<Record<string, WorkOrderOperationLaborEntryInput>>({});
|
||||||
const [reschedulingOperationId, setReschedulingOperationId] = useState<string | null>(null);
|
const [reschedulingOperationId, setReschedulingOperationId] = useState<string | null>(null);
|
||||||
|
const [executingOperationId, setExecutingOperationId] = useState<string | null>(null);
|
||||||
|
const [postingLaborOperationId, setPostingLaborOperationId] = useState<string | null>(null);
|
||||||
const [pendingConfirmation, setPendingConfirmation] = useState<
|
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||||
| {
|
| {
|
||||||
kind: "status" | "issue" | "completion";
|
kind: "status" | "issue" | "completion";
|
||||||
@@ -63,6 +74,11 @@ export function WorkOrderDetailPage() {
|
|||||||
nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }])
|
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.");
|
setStatus("Work order loaded.");
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.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) {
|
function handleStatusChange(nextStatus: WorkOrderStatus) {
|
||||||
if (!workOrder) {
|
if (!workOrder) {
|
||||||
return;
|
return;
|
||||||
@@ -279,6 +353,7 @@ export function WorkOrderDetailPage() {
|
|||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</div></article>
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article>
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Shortage</p><div className="mt-2 text-base font-bold text-text">{workOrder.materialRequirements.reduce((sum, requirement) => sum + requirement.shortageQuantity, 0)}</div></article>
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Shortage</p><div className="mt-2 text-base font-bold text-text">{workOrder.materialRequirements.reduce((sum, requirement) => sum + requirement.shortageQuantity, 0)}</div></article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Actual Hours</p><div className="mt-2 text-base font-bold text-text">{(workOrder.totalActualMinutes / 60).toFixed(1)}</div></article>
|
||||||
</section>
|
</section>
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
@@ -307,11 +382,12 @@ export function WorkOrderDetailPage() {
|
|||||||
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
<th className="px-3 py-3">Seq</th>
|
<th className="px-3 py-3">Seq</th>
|
||||||
<th className="px-3 py-3">Station</th>
|
<th className="px-3 py-3">Station</th>
|
||||||
|
<th className="px-3 py-3">Execution</th>
|
||||||
<th className="px-3 py-3">Capacity</th>
|
<th className="px-3 py-3">Capacity</th>
|
||||||
<th className="px-3 py-3">Start</th>
|
<th className="px-3 py-3">Start</th>
|
||||||
<th className="px-3 py-3">End</th>
|
<th className="px-3 py-3">End</th>
|
||||||
<th className="px-3 py-3">Minutes</th>
|
<th className="px-3 py-3">Planned / Actual</th>
|
||||||
{canManage ? <th className="px-3 py-3">Reschedule</th> : null}
|
{canManage ? <th className="px-3 py-3">Execution Controls</th> : null}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-line/70">
|
<tbody className="divide-y divide-line/70">
|
||||||
@@ -322,16 +398,39 @@ export function WorkOrderDetailPage() {
|
|||||||
<div className="font-semibold text-text">{operation.stationCode}</div>
|
<div className="font-semibold text-text">{operation.stationCode}</div>
|
||||||
<div className="mt-1 text-xs text-muted">{operation.stationName}</div>
|
<div className="mt-1 text-xs text-muted">{operation.stationName}</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-3 text-xs text-muted">
|
||||||
|
<div className="font-semibold text-text">{operation.status.replaceAll("_", " ")}</div>
|
||||||
|
<div className="mt-1">Start {operation.actualStart ? new Date(operation.actualStart).toLocaleString() : "Not started"}</div>
|
||||||
|
<div>End {operation.actualEnd ? new Date(operation.actualEnd).toLocaleString() : "Open"}</div>
|
||||||
|
<div>{operation.laborEntryCount} labor entr{operation.laborEntryCount === 1 ? "y" : "ies"}</div>
|
||||||
|
</td>
|
||||||
<td className="px-3 py-3 text-xs text-muted">
|
<td className="px-3 py-3 text-xs text-muted">
|
||||||
<div>{operation.stationDailyCapacityMinutes} min/day x {operation.stationParallelCapacity}</div>
|
<div>{operation.stationDailyCapacityMinutes} min/day x {operation.stationParallelCapacity}</div>
|
||||||
<div>{operation.stationWorkingDays.join(",")}</div>
|
<div>{operation.stationWorkingDays.join(",")}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-text">{new Date(operation.plannedStart).toLocaleString()}</td>
|
<td className="px-3 py-3 text-text">{new Date(operation.plannedStart).toLocaleString()}</td>
|
||||||
<td className="px-3 py-3 text-text">{new Date(operation.plannedEnd).toLocaleString()}</td>
|
<td className="px-3 py-3 text-text">{new Date(operation.plannedEnd).toLocaleString()}</td>
|
||||||
<td className="px-3 py-3 text-text">{operation.plannedMinutes}</td>
|
<td className="px-3 py-3 text-xs text-text">
|
||||||
|
<div>{operation.plannedMinutes} planned</div>
|
||||||
|
<div className="mt-1">{operation.actualMinutes} actual</div>
|
||||||
|
</td>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<td className="px-3 py-3">
|
<td className="px-3 py-3">
|
||||||
<div className="flex min-w-[220px] items-center gap-2">
|
<div className="min-w-[320px] space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{operation.status === "PENDING" ? (
|
||||||
|
<button type="button" onClick={() => void submitOperationExecution(operation.id, "START")} disabled={executingOperationId === operation.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">Start</button>
|
||||||
|
) : null}
|
||||||
|
{(operation.status === "PENDING" || operation.status === "PAUSED") ? (
|
||||||
|
<button type="button" onClick={() => void submitOperationExecution(operation.id, "RESUME")} disabled={executingOperationId === operation.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">Resume</button>
|
||||||
|
) : null}
|
||||||
|
{operation.status === "IN_PROGRESS" ? (
|
||||||
|
<button type="button" onClick={() => void submitOperationExecution(operation.id, "PAUSE")} disabled={executingOperationId === operation.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">Pause</button>
|
||||||
|
) : null}
|
||||||
|
{operation.status !== "COMPLETE" ? (
|
||||||
|
<button type="button" onClick={() => void submitOperationExecution(operation.id, "COMPLETE")} disabled={executingOperationId === operation.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">Complete</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={(operationScheduleForm[operation.id]?.plannedStart ?? operation.plannedStart).slice(0, 16)}
|
value={(operationScheduleForm[operation.id]?.plannedStart ?? operation.plannedStart).slice(0, 16)}
|
||||||
@@ -343,14 +442,57 @@ export function WorkOrderDetailPage() {
|
|||||||
}
|
}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={operationLaborForm[operation.id]?.minutes ?? 15}
|
||||||
|
onChange={(event) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Labor note"
|
||||||
|
value={operationLaborForm[operation.id]?.notes ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void submitOperationReschedule(operation.id)}
|
onClick={() => void submitOperationReschedule(operation.id)}
|
||||||
disabled={reschedulingOperationId === operation.id}
|
disabled={reschedulingOperationId === operation.id}
|
||||||
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{reschedulingOperationId === operation.id ? "Saving..." : "Apply"}
|
{reschedulingOperationId === operation.id ? "Saving..." : "Apply plan"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void submitOperationLabor(operation.id)}
|
||||||
|
disabled={postingLaborOperationId === operation.id || operation.status === "COMPLETE"}
|
||||||
|
className="rounded-2xl bg-brand px-2 py-2 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{postingLaborOperationId === operation.id ? "Posting..." : "Post labor"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -565,7 +565,7 @@ export function WorkbenchPage() {
|
|||||||
<div className="mt-2 flex items-center justify-between gap-3">
|
<div className="mt-2 flex items-center justify-between gap-3">
|
||||||
<span>Current load</span>
|
<span>Current load</span>
|
||||||
<span className="font-semibold text-text">
|
<span className="font-semibold text-text">
|
||||||
{selectedRescheduleLoad.utilizationPercent}% util / {selectedRescheduleLoad.overloaded ? "Overloaded" : "Within load"}
|
{selectedRescheduleLoad.utilizationPercent}% planned / {selectedRescheduleLoad.actualUtilizationPercent}% actual
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -793,6 +793,10 @@ function OverviewBoard({
|
|||||||
<div>Blocked {station.blockedCount}</div>
|
<div>Blocked {station.blockedCount}</div>
|
||||||
<div>Late {station.lateCount}</div>
|
<div>Late {station.lateCount}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 text-xs text-muted">
|
||||||
|
<div>Planned {station.totalPlannedMinutes} min</div>
|
||||||
|
<div>Actual {station.totalActualMinutes} min</div>
|
||||||
|
</div>
|
||||||
{draggingOperation ? (
|
{draggingOperation ? (
|
||||||
<div className="mt-3 rounded-[14px] border border-line/70 bg-page/60 p-2 text-xs text-muted">
|
<div className="mt-3 rounded-[14px] border border-line/70 bg-page/60 p-2 text-xs text-muted">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -26,6 +26,7 @@ model User {
|
|||||||
ownedProjects Project[] @relation("ProjectOwner")
|
ownedProjects Project[] @relation("ProjectOwner")
|
||||||
workOrderMaterialIssues WorkOrderMaterialIssue[]
|
workOrderMaterialIssues WorkOrderMaterialIssue[]
|
||||||
workOrderCompletions WorkOrderCompletion[]
|
workOrderCompletions WorkOrderCompletion[]
|
||||||
|
workOrderOperationLaborEntries WorkOrderOperationLaborEntry[]
|
||||||
approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy")
|
approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy")
|
||||||
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
|
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
|
||||||
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
|
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
|
||||||
@@ -688,15 +689,35 @@ model WorkOrderOperation {
|
|||||||
plannedStart DateTime
|
plannedStart DateTime
|
||||||
plannedEnd DateTime
|
plannedEnd DateTime
|
||||||
notes String
|
notes String
|
||||||
|
status String @default("PENDING")
|
||||||
|
actualStart DateTime?
|
||||||
|
actualEnd DateTime?
|
||||||
|
actualMinutes Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||||
station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict)
|
station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict)
|
||||||
|
laborEntries WorkOrderOperationLaborEntry[]
|
||||||
|
|
||||||
@@index([workOrderId, sequence])
|
@@index([workOrderId, sequence])
|
||||||
@@index([stationId, plannedStart])
|
@@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 {
|
model WorkOrderMaterialIssue {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
workOrderId String
|
workOrderId String
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ type PlanningWorkOrderRecord = {
|
|||||||
plannedStart: Date;
|
plannedStart: Date;
|
||||||
plannedEnd: Date;
|
plannedEnd: Date;
|
||||||
plannedMinutes: number;
|
plannedMinutes: number;
|
||||||
|
actualMinutes: number;
|
||||||
|
status: string;
|
||||||
station: { id: string; code: string; name: string; dailyCapacityMinutes: number; parallelCapacity: number; workingDays: string };
|
station: { id: string; code: string; name: string; dailyCapacityMinutes: number; parallelCapacity: number; workingDays: string };
|
||||||
}>;
|
}>;
|
||||||
materialIssues: Array<{ componentItemId: string; quantity: number }>;
|
materialIssues: Array<{ componentItemId: string; quantity: number }>;
|
||||||
@@ -82,6 +84,7 @@ type StationAccumulator = {
|
|||||||
operationCount: number;
|
operationCount: number;
|
||||||
workOrderIds: Set<string>;
|
workOrderIds: Set<string>;
|
||||||
totalPlannedMinutes: number;
|
totalPlannedMinutes: number;
|
||||||
|
totalActualMinutes: number;
|
||||||
blockedCount: number;
|
blockedCount: number;
|
||||||
readyCount: number;
|
readyCount: number;
|
||||||
lateCount: number;
|
lateCount: number;
|
||||||
@@ -177,6 +180,7 @@ function getAvailabilityKey(itemId: string, warehouseId: string, locationId: str
|
|||||||
function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
|
function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
|
||||||
const capacityMinutes = Math.max(record.dayKeys.size, 1) * Math.max(record.dailyCapacityMinutes, 60) * Math.max(record.parallelCapacity, 1);
|
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 utilizationPercent = capacityMinutes > 0 ? Math.round((record.totalPlannedMinutes / capacityMinutes) * 100) : 0;
|
||||||
|
const actualUtilizationPercent = capacityMinutes > 0 ? Math.round((record.totalActualMinutes / capacityMinutes) * 100) : 0;
|
||||||
return {
|
return {
|
||||||
stationId: record.stationId,
|
stationId: record.stationId,
|
||||||
stationCode: record.stationCode,
|
stationCode: record.stationCode,
|
||||||
@@ -184,8 +188,10 @@ function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
|
|||||||
operationCount: record.operationCount,
|
operationCount: record.operationCount,
|
||||||
workOrderCount: record.workOrderIds.size,
|
workOrderCount: record.workOrderIds.size,
|
||||||
totalPlannedMinutes: record.totalPlannedMinutes,
|
totalPlannedMinutes: record.totalPlannedMinutes,
|
||||||
|
totalActualMinutes: record.totalActualMinutes,
|
||||||
capacityMinutes,
|
capacityMinutes,
|
||||||
utilizationPercent,
|
utilizationPercent,
|
||||||
|
actualUtilizationPercent,
|
||||||
overloaded: utilizationPercent > 100,
|
overloaded: utilizationPercent > 100,
|
||||||
blockedCount: record.blockedCount,
|
blockedCount: record.blockedCount,
|
||||||
readyCount: record.readyCount,
|
readyCount: record.readyCount,
|
||||||
@@ -308,6 +314,8 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
plannedStart: true,
|
plannedStart: true,
|
||||||
plannedEnd: true,
|
plannedEnd: true,
|
||||||
plannedMinutes: true,
|
plannedMinutes: true,
|
||||||
|
actualMinutes: true,
|
||||||
|
status: true,
|
||||||
station: { select: { id: true, code: true, name: true, dailyCapacityMinutes: true, parallelCapacity: true, workingDays: true } },
|
station: { select: { id: true, code: true, name: true, dailyCapacityMinutes: true, parallelCapacity: true, workingDays: true } },
|
||||||
},
|
},
|
||||||
orderBy: [{ sequence: "asc" }],
|
orderBy: [{ sequence: "asc" }],
|
||||||
@@ -494,6 +502,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
operationCount: 0,
|
operationCount: 0,
|
||||||
workOrderIds: new Set<string>(),
|
workOrderIds: new Set<string>(),
|
||||||
totalPlannedMinutes: 0,
|
totalPlannedMinutes: 0,
|
||||||
|
totalActualMinutes: 0,
|
||||||
blockedCount: 0,
|
blockedCount: 0,
|
||||||
readyCount: 0,
|
readyCount: 0,
|
||||||
lateCount: 0,
|
lateCount: 0,
|
||||||
@@ -505,6 +514,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
current.operationCount += 1;
|
current.operationCount += 1;
|
||||||
current.workOrderIds.add(workOrder.id);
|
current.workOrderIds.add(workOrder.id);
|
||||||
current.totalPlannedMinutes += operation.plannedMinutes;
|
current.totalPlannedMinutes += operation.plannedMinutes;
|
||||||
|
current.totalActualMinutes += operation.actualMinutes;
|
||||||
if (insight?.readinessState === "BLOCKED" || insight?.readinessState === "SHORTAGE" || insight?.readinessState === "PENDING_SUPPLY") {
|
if (insight?.readinessState === "BLOCKED" || insight?.readinessState === "SHORTAGE" || insight?.readinessState === "PENDING_SUPPLY") {
|
||||||
current.blockedCount += 1;
|
current.blockedCount += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
listManufacturingStations,
|
listManufacturingStations,
|
||||||
listWorkOrders,
|
listWorkOrders,
|
||||||
recordWorkOrderCompletion,
|
recordWorkOrderCompletion,
|
||||||
|
recordWorkOrderOperationLabor,
|
||||||
updateManufacturingStation,
|
updateManufacturingStation,
|
||||||
|
updateWorkOrderOperationExecution,
|
||||||
updateWorkOrder,
|
updateWorkOrder,
|
||||||
updateWorkOrderOperationSchedule,
|
updateWorkOrderOperationSchedule,
|
||||||
updateWorkOrderStatus,
|
updateWorkOrderStatus,
|
||||||
@@ -74,6 +76,16 @@ const operationScheduleSchema = z.object({
|
|||||||
stationId: z.string().trim().min(1).nullable().optional(),
|
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) {
|
function getRouteParam(value: unknown) {
|
||||||
return typeof value === "string" ? value : null;
|
return typeof value === "string" ? value : null;
|
||||||
}
|
}
|
||||||
@@ -215,6 +227,46 @@ manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/sch
|
|||||||
return ok(response, result.workOrder);
|
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) => {
|
manufacturingRouter.post("/work-orders/:workOrderId/issues", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
|
||||||
const workOrderId = getRouteParam(request.params.workOrderId);
|
const workOrderId = getRouteParam(request.params.workOrderId);
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import type {
|
|||||||
WorkOrderCompletionInput,
|
WorkOrderCompletionInput,
|
||||||
WorkOrderDetailDto,
|
WorkOrderDetailDto,
|
||||||
WorkOrderInput,
|
WorkOrderInput,
|
||||||
|
WorkOrderOperationExecutionInput,
|
||||||
WorkOrderOperationDto,
|
WorkOrderOperationDto,
|
||||||
|
WorkOrderOperationLaborEntryInput,
|
||||||
WorkOrderOperationScheduleInput,
|
WorkOrderOperationScheduleInput,
|
||||||
WorkOrderMaterialIssueInput,
|
WorkOrderMaterialIssueInput,
|
||||||
WorkOrderStatus,
|
WorkOrderStatus,
|
||||||
@@ -109,6 +111,10 @@ type WorkOrderRecord = {
|
|||||||
plannedStart: Date;
|
plannedStart: Date;
|
||||||
plannedEnd: Date;
|
plannedEnd: Date;
|
||||||
notes: string;
|
notes: string;
|
||||||
|
status: string;
|
||||||
|
actualStart: Date | null;
|
||||||
|
actualEnd: Date | null;
|
||||||
|
actualMinutes: number;
|
||||||
station: {
|
station: {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -117,6 +123,16 @@ type WorkOrderRecord = {
|
|||||||
parallelCapacity: number;
|
parallelCapacity: number;
|
||||||
workingDays: string;
|
workingDays: string;
|
||||||
};
|
};
|
||||||
|
laborEntries: Array<{
|
||||||
|
id: string;
|
||||||
|
minutes: number;
|
||||||
|
notes: string;
|
||||||
|
createdAt: Date;
|
||||||
|
createdBy: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
} | null;
|
||||||
|
}>;
|
||||||
}>;
|
}>;
|
||||||
materialIssues: Array<{
|
materialIssues: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -252,6 +268,17 @@ function buildInclude() {
|
|||||||
workingDays: true,
|
workingDays: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
laborEntries: {
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: "desc" }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: [{ sequence: "asc" }],
|
orderBy: [{ sequence: "asc" }],
|
||||||
},
|
},
|
||||||
@@ -352,6 +379,7 @@ function mapDetail(
|
|||||||
itemUnitOfMeasure: record.item.unitOfMeasure,
|
itemUnitOfMeasure: record.item.unitOfMeasure,
|
||||||
projectCustomerName: record.project?.customer.name ?? null,
|
projectCustomerName: record.project?.customer.name ?? null,
|
||||||
dueQuantity: record.quantity - record.completedQuantity,
|
dueQuantity: record.quantity - record.completedQuantity,
|
||||||
|
totalActualMinutes: record.operations.reduce((sum, operation) => sum + operation.actualMinutes, 0),
|
||||||
operations: record.operations.map((operation): WorkOrderOperationDto => ({
|
operations: record.operations.map((operation): WorkOrderOperationDto => ({
|
||||||
id: operation.id,
|
id: operation.id,
|
||||||
stationId: operation.station.id,
|
stationId: operation.station.id,
|
||||||
@@ -368,6 +396,18 @@ function mapDetail(
|
|||||||
plannedStart: operation.plannedStart.toISOString(),
|
plannedStart: operation.plannedStart.toISOString(),
|
||||||
plannedEnd: operation.plannedEnd.toISOString(),
|
plannedEnd: operation.plannedEnd.toISOString(),
|
||||||
notes: operation.notes,
|
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) => {
|
materialRequirements: record.item.bomLines.map((line) => {
|
||||||
const requiredQuantity = line.quantity * record.quantity;
|
const requiredQuantity = line.quantity * record.quantity;
|
||||||
@@ -566,6 +606,7 @@ async function regenerateWorkOrderOperations(workOrderId: string) {
|
|||||||
plannedStart: operation.plannedStart,
|
plannedStart: operation.plannedStart,
|
||||||
plannedEnd: operation.plannedEnd,
|
plannedEnd: operation.plannedEnd,
|
||||||
notes: operation.notes,
|
notes: operation.notes,
|
||||||
|
status: "PENDING",
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -634,6 +675,29 @@ async function rescheduleWorkOrderOperationsToStation(
|
|||||||
return getWorkOrderById(workOrderId);
|
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) {
|
async function syncWorkOrderReservations(workOrderId: string) {
|
||||||
const workOrder = await getWorkOrderById(workOrderId);
|
const workOrder = await getWorkOrderById(workOrderId);
|
||||||
if (!workOrder) {
|
if (!workOrder) {
|
||||||
@@ -1312,6 +1376,183 @@ export async function updateWorkOrderOperationSchedule(
|
|||||||
return { ok: true as const, workOrder: rescheduled };
|
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) {
|
export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkOrderMaterialIssueInput, createdById?: string | null) {
|
||||||
const workOrder = await workOrderModel.findUnique({
|
const workOrder = await workOrderModel.findUnique({
|
||||||
where: { id: workOrderId },
|
where: { id: workOrderId },
|
||||||
|
|||||||
@@ -85,8 +85,10 @@ export interface PlanningStationLoadDto {
|
|||||||
operationCount: number;
|
operationCount: number;
|
||||||
workOrderCount: number;
|
workOrderCount: number;
|
||||||
totalPlannedMinutes: number;
|
totalPlannedMinutes: number;
|
||||||
|
totalActualMinutes: number;
|
||||||
capacityMinutes: number;
|
capacityMinutes: number;
|
||||||
utilizationPercent: number;
|
utilizationPercent: number;
|
||||||
|
actualUtilizationPercent: number;
|
||||||
overloaded: boolean;
|
overloaded: boolean;
|
||||||
blockedCount: number;
|
blockedCount: number;
|
||||||
readyCount: number;
|
readyCount: number;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export const workOrderStatuses = ["DRAFT", "RELEASED", "IN_PROGRESS", "ON_HOLD", "COMPLETE", "CANCELLED"] as const;
|
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 WorkOrderStatus = (typeof workOrderStatuses)[number];
|
||||||
|
export type WorkOrderOperationStatus = (typeof workOrderOperationStatuses)[number];
|
||||||
|
|
||||||
export interface ManufacturingStationDto {
|
export interface ManufacturingStationDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -88,6 +90,20 @@ export interface WorkOrderOperationDto {
|
|||||||
plannedStart: string;
|
plannedStart: string;
|
||||||
plannedEnd: string;
|
plannedEnd: string;
|
||||||
notes: 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 {
|
export interface WorkOrderMaterialRequirementDto {
|
||||||
@@ -137,6 +153,7 @@ export interface WorkOrderDetailDto extends WorkOrderSummaryDto {
|
|||||||
itemUnitOfMeasure: string;
|
itemUnitOfMeasure: string;
|
||||||
projectCustomerName: string | null;
|
projectCustomerName: string | null;
|
||||||
dueQuantity: number;
|
dueQuantity: number;
|
||||||
|
totalActualMinutes: number;
|
||||||
operations: WorkOrderOperationDto[];
|
operations: WorkOrderOperationDto[];
|
||||||
materialRequirements: WorkOrderMaterialRequirementDto[];
|
materialRequirements: WorkOrderMaterialRequirementDto[];
|
||||||
materialIssues: WorkOrderMaterialIssueDto[];
|
materialIssues: WorkOrderMaterialIssueDto[];
|
||||||
@@ -173,3 +190,13 @@ export interface WorkOrderOperationScheduleInput {
|
|||||||
plannedStart: string;
|
plannedStart: string;
|
||||||
stationId?: string | null;
|
stationId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkOrderOperationExecutionInput {
|
||||||
|
action: "START" | "PAUSE" | "RESUME" | "COMPLETE";
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkOrderOperationLaborEntryInput {
|
||||||
|
minutes: number;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user