manufacturing layer

This commit is contained in:
2026-03-18 06:22:37 -05:00
parent 6eaf084fcd
commit c49ed4bf4a
14 changed files with 561 additions and 20 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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`,

View File

@@ -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"
/> />
<button <div className="flex items-center gap-2">
type="button" <input
onClick={() => void submitOperationReschedule(operation.id)} type="number"
disabled={reschedulingOperationId === operation.id} min={1}
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60" step={1}
> value={operationLaborForm[operation.id]?.minutes ?? 15}
{reschedulingOperationId === operation.id ? "Saving..." : "Apply"} onChange={(event) =>
</button> 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
type="button"
onClick={() => void submitOperationReschedule(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"
>
{reschedulingOperationId === operation.id ? "Saving..." : "Apply plan"}
</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}

View File

@@ -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">

View File

@@ -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");

View File

@@ -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

View File

@@ -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;
} }

View File

@@ -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) {

View File

@@ -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 },

View File

@@ -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;

View File

@@ -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;
}