workbench rebalance
This commit is contained in:
@@ -9,6 +9,9 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
|
||||
- Project cockpit section on project detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and project cost snapshot rollups, plus direct launch paths into prefilled work-order and purchase-order follow-through and a chronological project activity timeline
|
||||
- Planning workbench replacing the old one-note planning screen with mode switching, dense exception rail, heatmap load view, agenda view, and focus drawer
|
||||
- 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
|
||||
- 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
|
||||
- Project milestones with status, due dates, notes, and edit-time sequencing inside the project workflow
|
||||
- Project-side milestone and work-order rollups surfaced on project list and detail pages
|
||||
- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form
|
||||
|
||||
10
README.md
10
README.md
@@ -27,8 +27,8 @@ Current foundation scope includes:
|
||||
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
|
||||
- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
|
||||
- projects with customer/commercial/shipment linkage, owners, due dates, milestones, rollups, notes, and attachments
|
||||
- manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, 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, and inline dispatch actions
|
||||
- manufacturing work orders with project linkage, station-based operation templates, station calendars/capacity settings, calendar-aware operation scheduling, 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, and planner-side operation rebalance controls including station-to-station moves
|
||||
- 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
|
||||
- pegged WO/PO supply tracking back to sales demand with preferred-vendor sourcing on inventory items
|
||||
@@ -110,7 +110,7 @@ Next expansion areas:
|
||||
|
||||
## Manufacturing Direction
|
||||
|
||||
Manufacturing is now a separate execution subsystem rather than being collapsed into Projects. The current slice ships work-order records with build-item linkage, optional project linkage, warehouse/location output posting, BOM-based material requirement visibility, station master data, item-level operation templates, automatic work-order operation plans, 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, station calendars and capacity settings, automatic work-order operation plans, operation-level rescheduling, material issue posting, completion posting, work-order attachments, and dashboard visibility.
|
||||
|
||||
Current interactions:
|
||||
|
||||
@@ -122,11 +122,11 @@ Next expansion areas:
|
||||
|
||||
- Purchasing: shortages and buyout demand should surface from manufacturing execution
|
||||
- Shipping: completed manufacturing should feed shipment readiness
|
||||
- Planning: manufacturing orders, routings, and work centers should drive capacity and schedule views
|
||||
- Planning: manufacturing orders, routings, and work centers now drive the first capacity/load layer and should continue expanding into fuller finite-capacity scheduling
|
||||
|
||||
## 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, focus-drawer inspection, 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, inline release/build/buy follow-through, and agenda sequencing.
|
||||
|
||||
Current interactions:
|
||||
|
||||
|
||||
@@ -103,12 +103,12 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
||||
|
||||
- Task dependencies, milestones, and progress updates
|
||||
- Manufacturing calendar views and deeper bottleneck visibility beyond the shipped station load and overload workbench summaries
|
||||
- Labor and machine scheduling support
|
||||
- Labor and machine scheduling support beyond the shipped station calendar/capacity foundation
|
||||
- Theme-compliant workbench scheduling surfaces for light/dark mode
|
||||
- Collapsible schedule groupings and saved planner views
|
||||
- Drag-and-drop rescheduling improvements
|
||||
- Conflict-aware drag-and-drop rescheduling improvements beyond the shipped planner-side station reassignment controls
|
||||
- Critical-path and overdue highlighting
|
||||
- Richer finite-capacity warnings and rescheduling controls beyond the shipped workbench overload indicators
|
||||
- Richer finite-capacity warnings, automated rebalance logic, and station drag-rescheduling beyond the shipped overload indicators and workbench rebalance controls
|
||||
- Better mobile and tablet behavior for shop-floor lookups
|
||||
- Faster filtering by project, customer, work center, and status
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
||||
- Project cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and cost-snapshot visibility, with direct launch paths into prefilled project work orders and demand-linked purchase orders plus a project activity timeline
|
||||
- Project list/detail/create/edit workflows and dashboard program widgets
|
||||
- Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments
|
||||
- Manufacturing stations, item routing templates, and automatic work-order operation planning for the workbench schedule
|
||||
- Manufacturing stations, item routing templates, station calendars/capacity settings, automatic work-order operation planning, and operation-level rescheduling for the workbench schedule
|
||||
- Vendor invoice/supporting-document attachments directly on purchase orders
|
||||
- Vendor-detail purchasing visibility with recent purchase-order activity
|
||||
- Revision comparison UX for changed sales and purchasing documents, including purchase-order revision persistence
|
||||
@@ -56,6 +56,8 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
||||
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
|
||||
- Live planning workbench timelines driven by project and manufacturing data
|
||||
- Planning workbench with heatmap, overview, and agenda modes plus exception rail, focus drawer, station load grouping, readiness scoring, and inline dispatch actions
|
||||
- Finite-capacity foundation with station working-day calendars, daily/parallel capacity settings, and calendar-aware operation scheduling
|
||||
- Planner-side workbench rebalance controls for operation scheduling, with quick shift moves, heatmap-day targeting, and station-to-station reassignment
|
||||
- Sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
|
||||
- Multi-stage Docker packaging and migration-aware entrypoint
|
||||
- Docker image validated locally with successful app startup and login flow
|
||||
|
||||
@@ -61,6 +61,7 @@ import type {
|
||||
WorkOrderCompletionInput,
|
||||
WorkOrderDetailDto,
|
||||
WorkOrderInput,
|
||||
WorkOrderOperationScheduleInput,
|
||||
WorkOrderMaterialIssueInput,
|
||||
WorkOrderStatus,
|
||||
WorkOrderSummaryDto,
|
||||
@@ -639,6 +640,13 @@ export const api = {
|
||||
token
|
||||
);
|
||||
},
|
||||
updateWorkOrderOperationSchedule(token: string, workOrderId: string, operationId: string, payload: WorkOrderOperationScheduleInput) {
|
||||
return request<WorkOrderDetailDto>(
|
||||
`/api/v1/manufacturing/work-orders/${workOrderId}/operations/${operationId}/schedule`,
|
||||
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||
token
|
||||
);
|
||||
},
|
||||
issueWorkOrderMaterial(token: string, workOrderId: string, payload: WorkOrderMaterialIssueInput) {
|
||||
return request<WorkOrderDetailDto>(
|
||||
`/api/v1/manufacturing/work-orders/${workOrderId}/issues`,
|
||||
|
||||
@@ -10,6 +10,9 @@ const emptyStationInput: ManufacturingStationInput = {
|
||||
name: "",
|
||||
description: "",
|
||||
queueDays: 0,
|
||||
dailyCapacityMinutes: 480,
|
||||
parallelCapacity: 1,
|
||||
workingDays: [1, 2, 3, 4, 5],
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
@@ -72,6 +75,8 @@ export function ManufacturingPage() {
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted">
|
||||
<div>{station.queueDays} expected wait day(s)</div>
|
||||
<div>{station.dailyCapacityMinutes} min/day x {station.parallelCapacity}</div>
|
||||
<div>Days {station.workingDays.join(",")}</div>
|
||||
<div className="mt-1">{station.isActive ? "Active" : "Inactive"}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,6 +101,46 @@ export function ManufacturingPage() {
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Expected Wait (Days)</span>
|
||||
<input type="number" min={0} step={1} value={form.queueDays} onChange={(event) => setForm((current) => ({ ...current, queueDays: Number.parseInt(event.target.value, 10) || 0 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Capacity Minutes / Day</span>
|
||||
<input type="number" min={60} step={30} value={form.dailyCapacityMinutes} onChange={(event) => setForm((current) => ({ ...current, dailyCapacityMinutes: Number.parseInt(event.target.value, 10) || 480 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Parallel Capacity</span>
|
||||
<input type="number" min={1} step={1} value={form.parallelCapacity} onChange={(event) => setForm((current) => ({ ...current, parallelCapacity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Working Days</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ value: 1, label: "Mon" },
|
||||
{ value: 2, label: "Tue" },
|
||||
{ value: 3, label: "Wed" },
|
||||
{ value: 4, label: "Thu" },
|
||||
{ value: 5, label: "Fri" },
|
||||
{ value: 6, label: "Sat" },
|
||||
{ value: 0, label: "Sun" },
|
||||
].map((day) => (
|
||||
<label key={day.value} className="flex items-center gap-2 rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.workingDays.includes(day.value)}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
workingDays: event.target.checked
|
||||
? [...current.workingDays, day.value].sort((left, right) => left - right)
|
||||
: current.workingDays.filter((value) => value !== day.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span>{day.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
|
||||
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { WorkOrderCompletionInput, WorkOrderDetailDto, WorkOrderMaterialIssueInput, WorkOrderStatus } from "@mrp/shared";
|
||||
import type { WorkOrderCompletionInput, WorkOrderDetailDto, WorkOrderMaterialIssueInput, WorkOrderOperationScheduleInput, WorkOrderStatus } from "@mrp/shared";
|
||||
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
@@ -22,6 +22,8 @@ export function WorkOrderDetailPage() {
|
||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||
const [isPostingIssue, setIsPostingIssue] = useState(false);
|
||||
const [isPostingCompletion, setIsPostingCompletion] = useState(false);
|
||||
const [operationScheduleForm, setOperationScheduleForm] = useState<Record<string, WorkOrderOperationScheduleInput>>({});
|
||||
const [reschedulingOperationId, setReschedulingOperationId] = useState<string | null>(null);
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||
| {
|
||||
kind: "status" | "issue" | "completion";
|
||||
@@ -56,6 +58,11 @@ export function WorkOrderDetailPage() {
|
||||
...emptyCompletionInput,
|
||||
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
|
||||
});
|
||||
setOperationScheduleForm(
|
||||
Object.fromEntries(
|
||||
nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }])
|
||||
)
|
||||
);
|
||||
setStatus("Work order loaded.");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -137,6 +144,35 @@ export function WorkOrderDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOperationReschedule(operationId: string) {
|
||||
if (!token || !workOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = operationScheduleForm[operationId];
|
||||
if (!payload?.plannedStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReschedulingOperationId(operationId);
|
||||
setStatus("Rebuilding operation schedule...");
|
||||
try {
|
||||
const nextWorkOrder = await api.updateWorkOrderOperationSchedule(token, workOrder.id, operationId, payload);
|
||||
setWorkOrder(nextWorkOrder);
|
||||
setOperationScheduleForm(
|
||||
Object.fromEntries(
|
||||
nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }])
|
||||
)
|
||||
);
|
||||
setStatus("Operation schedule updated with station calendar constraints.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to reschedule operation.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setReschedulingOperationId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatusChange(nextStatus: WorkOrderStatus) {
|
||||
if (!workOrder) {
|
||||
return;
|
||||
@@ -271,9 +307,11 @@ export function WorkOrderDetailPage() {
|
||||
<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">Station</th>
|
||||
<th className="px-3 py-3">Capacity</th>
|
||||
<th className="px-3 py-3">Start</th>
|
||||
<th className="px-3 py-3">End</th>
|
||||
<th className="px-3 py-3">Minutes</th>
|
||||
{canManage ? <th className="px-3 py-3">Reschedule</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70">
|
||||
@@ -284,9 +322,38 @@ export function WorkOrderDetailPage() {
|
||||
<div className="font-semibold text-text">{operation.stationCode}</div>
|
||||
<div className="mt-1 text-xs text-muted">{operation.stationName}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-xs text-muted">
|
||||
<div>{operation.stationDailyCapacityMinutes} min/day x {operation.stationParallelCapacity}</div>
|
||||
<div>{operation.stationWorkingDays.join(",")}</div>
|
||||
</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">{operation.plannedMinutes}</td>
|
||||
{canManage ? (
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex min-w-[220px] items-center gap-2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={(operationScheduleForm[operation.id]?.plannedStart ?? operation.plannedStart).slice(0, 16)}
|
||||
onChange={(event) =>
|
||||
setOperationScheduleForm((current) => ({
|
||||
...current,
|
||||
[operation.id]: { plannedStart: new Date(event.target.value).toISOString() },
|
||||
}))
|
||||
}
|
||||
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
|
||||
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"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
DemandPlanningRollupDto,
|
||||
GanttTaskDto,
|
||||
ManufacturingStationDto,
|
||||
PlanningExceptionDto,
|
||||
PlanningStationLoadDto,
|
||||
PlanningTaskActionDto,
|
||||
@@ -14,6 +15,7 @@ import { ApiError, api } from "../../lib/api";
|
||||
type WorkbenchMode = "overview" | "heatmap" | "agenda";
|
||||
type FocusRecord = {
|
||||
id: string;
|
||||
entityId: string | null;
|
||||
title: string;
|
||||
kind: "PROJECT" | "WORK_ORDER" | "OPERATION" | "MILESTONE";
|
||||
status: string;
|
||||
@@ -109,6 +111,7 @@ function densityTone(cell: HeatmapCell) {
|
||||
function buildFocusRecords(tasks: GanttTaskDto[]) {
|
||||
return tasks.map((task) => ({
|
||||
id: task.id,
|
||||
entityId: task.entityId ?? null,
|
||||
title: task.text,
|
||||
kind: parseFocusKind(task),
|
||||
status: task.status ?? "PLANNED",
|
||||
@@ -164,16 +167,21 @@ export function WorkbenchPage() {
|
||||
const [workbenchFilter, setWorkbenchFilter] = useState<WorkbenchFilter>("all");
|
||||
const [selectedFocusId, setSelectedFocusId] = useState<string | null>(null);
|
||||
const [selectedHeatmapDate, setSelectedHeatmapDate] = useState<string | null>(null);
|
||||
const [rescheduleStart, setRescheduleStart] = useState("");
|
||||
const [rescheduleStationId, setRescheduleStationId] = useState("");
|
||||
const [isRescheduling, setIsRescheduling] = useState(false);
|
||||
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token)])
|
||||
.then(([data, rollup]) => {
|
||||
Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token), api.getManufacturingStations(token)])
|
||||
.then(([data, rollup, stationOptions]) => {
|
||||
setTimeline(data);
|
||||
setPlanningRollup(rollup);
|
||||
setStations(stationOptions);
|
||||
setStatus("Planning workbench loaded.");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -191,6 +199,16 @@ export function WorkbenchPage() {
|
||||
const focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]);
|
||||
const selectedFocus = selectedFocusId ? focusById.get(selectedFocusId) ?? null : filteredFocusRecords[0] ?? focusRecords[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFocus?.kind === "OPERATION") {
|
||||
setRescheduleStart(selectedFocus.start.slice(0, 16));
|
||||
setRescheduleStationId(selectedFocus.stationId ?? "");
|
||||
} else {
|
||||
setRescheduleStart("");
|
||||
setRescheduleStationId("");
|
||||
}
|
||||
}, [selectedFocus?.id, selectedFocus?.kind, selectedFocus?.start, selectedFocus?.stationId]);
|
||||
|
||||
const heatmap = useMemo(() => {
|
||||
const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date());
|
||||
const cells = new Map<string, HeatmapCell>();
|
||||
@@ -226,6 +244,9 @@ export function WorkbenchPage() {
|
||||
}, [filteredFocusRecords, summary]);
|
||||
|
||||
const selectedHeatmapCell = selectedHeatmapDate ? heatmap.find((cell) => cell.dateKey === selectedHeatmapDate) ?? null : null;
|
||||
const stationLoadById = useMemo(() => new Map(stationLoads.map((station) => [station.stationId, station])), [stationLoads]);
|
||||
const selectedRescheduleStation = rescheduleStationId ? stations.find((station) => station.id === rescheduleStationId) ?? null : null;
|
||||
const selectedRescheduleLoad = selectedRescheduleStation ? stationLoadById.get(selectedRescheduleStation.id) ?? null : null;
|
||||
const agendaItems = useMemo(
|
||||
() => [...focusRecords]
|
||||
.filter((record) => record.kind !== "OPERATION")
|
||||
@@ -253,15 +274,23 @@ export function WorkbenchPage() {
|
||||
{ value: "overdue", label: "Overdue" },
|
||||
];
|
||||
|
||||
async function refreshWorkbench(message: string) {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
const [refreshed, stationOptions] = await Promise.all([api.getPlanningTimeline(token), api.getManufacturingStations(token)]);
|
||||
setTimeline(refreshed);
|
||||
setStations(stationOptions);
|
||||
setStatus(message);
|
||||
}
|
||||
|
||||
async function handleTaskAction(action: PlanningTaskActionDto) {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
if (action.kind === "RELEASE_WORK_ORDER" && action.workOrderId) {
|
||||
await api.updateWorkOrderStatus(token, action.workOrderId, "RELEASED");
|
||||
const refreshed = await api.getPlanningTimeline(token);
|
||||
setTimeline(refreshed);
|
||||
setStatus("Workbench refreshed after release.");
|
||||
await refreshWorkbench("Workbench refreshed after release.");
|
||||
return;
|
||||
}
|
||||
if (action.href) {
|
||||
@@ -269,6 +298,50 @@ export function WorkbenchPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRescheduleOperation(nextStartIso?: string, nextStationId?: string | null) {
|
||||
if (!token || !selectedFocus || selectedFocus.kind !== "OPERATION" || !selectedFocus.workOrderId || !selectedFocus.entityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plannedStart = nextStartIso ?? (rescheduleStart ? new Date(rescheduleStart).toISOString() : "");
|
||||
if (!plannedStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRescheduling(true);
|
||||
try {
|
||||
await api.updateWorkOrderOperationSchedule(token, selectedFocus.workOrderId, selectedFocus.entityId, {
|
||||
plannedStart,
|
||||
stationId: (nextStationId ?? rescheduleStationId) || null,
|
||||
});
|
||||
await refreshWorkbench("Workbench refreshed after operation rebalance.");
|
||||
setRescheduleStart(plannedStart.slice(0, 16));
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to rebalance operation from Workbench.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setIsRescheduling(false);
|
||||
}
|
||||
}
|
||||
|
||||
function shiftRescheduleDraft(hours: number) {
|
||||
if (!rescheduleStart) {
|
||||
return;
|
||||
}
|
||||
const next = new Date(rescheduleStart);
|
||||
next.setHours(next.getHours() + hours);
|
||||
setRescheduleStart(next.toISOString().slice(0, 16));
|
||||
}
|
||||
|
||||
function moveDraftToSelectedHeatmapDay() {
|
||||
if (!selectedHeatmapDate) {
|
||||
return;
|
||||
}
|
||||
const current = rescheduleStart ? new Date(rescheduleStart) : new Date(`${selectedHeatmapDate}T08:00:00`);
|
||||
const target = new Date(`${selectedHeatmapDate}T${String(current.getHours()).padStart(2, "0")}:${String(current.getMinutes()).padStart(2, "0")}:00`);
|
||||
setRescheduleStart(target.toISOString().slice(0, 16));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
@@ -391,6 +464,95 @@ export function WorkbenchPage() {
|
||||
<div className="mt-2 flex items-center justify-between gap-3"><span className="text-muted">Open supply</span><span className="font-semibold text-text">{selectedFocus.openSupplyQuantity}</span></div>
|
||||
{selectedFocus.blockedReason ? <div className="mt-3 text-xs text-muted">{selectedFocus.blockedReason}</div> : null}
|
||||
</div>
|
||||
{selectedFocus.kind === "OPERATION" ? (
|
||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Workbench Rebalance</div>
|
||||
<div className="mt-3">
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Target Station</label>
|
||||
<select
|
||||
value={rescheduleStationId}
|
||||
onChange={(event) => setRescheduleStationId(event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{stations
|
||||
.filter((station) => station.isActive || station.id === selectedFocus.stationId)
|
||||
.map((station) => (
|
||||
<option key={station.id} value={station.id}>
|
||||
{station.code} - {station.name}{station.isActive ? "" : " (Inactive)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Planned Start</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={rescheduleStart}
|
||||
onChange={(event) => setRescheduleStart(event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</div>
|
||||
{selectedRescheduleStation ? (
|
||||
<div className="mt-3 rounded-[16px] border border-line/70 bg-surface/80 p-3 text-xs text-muted">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>Capacity</span>
|
||||
<span className="font-semibold text-text">
|
||||
{selectedRescheduleStation.dailyCapacityMinutes} min/day x {selectedRescheduleStation.parallelCapacity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<span>Working days</span>
|
||||
<span className="font-semibold text-text">
|
||||
{selectedRescheduleStation.workingDays.map((day) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][day]).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
{selectedRescheduleLoad ? (
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<span>Current load</span>
|
||||
<span className="font-semibold text-text">
|
||||
{selectedRescheduleLoad.utilizationPercent}% util / {selectedRescheduleLoad.overloaded ? "Overloaded" : "Within load"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => shiftRescheduleDraft(1)} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">+1h</button>
|
||||
<button type="button" onClick={() => shiftRescheduleDraft(8)} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">+1 shift</button>
|
||||
<button type="button" onClick={() => shiftRescheduleDraft(24)} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">+1 day</button>
|
||||
{selectedHeatmapDate ? (
|
||||
<button type="button" onClick={moveDraftToSelectedHeatmapDay} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">
|
||||
Move to {formatDate(selectedHeatmapDate)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRescheduleOperation()}
|
||||
disabled={isRescheduling || !rescheduleStart}
|
||||
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isRescheduling ? "Rebalancing..." : "Apply rebalance"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const originalStationId = selectedFocus.stationId ?? "";
|
||||
setRescheduleStationId(originalStationId);
|
||||
void handleRescheduleOperation(new Date(selectedFocus.start).toISOString(), originalStationId);
|
||||
}}
|
||||
disabled={isRescheduling}
|
||||
className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-muted">
|
||||
Rebalance starts from this operation, can move it onto another active station, and rebuilds downstream operations using station calendars and capacity.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedFocus.actions.map((action, index) => (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "ManufacturingStation" ADD COLUMN "dailyCapacityMinutes" INTEGER NOT NULL DEFAULT 480;
|
||||
ALTER TABLE "ManufacturingStation" ADD COLUMN "parallelCapacity" INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE "ManufacturingStation" ADD COLUMN "workingDays" TEXT NOT NULL DEFAULT '1,2,3,4,5';
|
||||
@@ -648,6 +648,9 @@ model ManufacturingStation {
|
||||
name String
|
||||
description String
|
||||
queueDays Int @default(0)
|
||||
dailyCapacityMinutes Int @default(480)
|
||||
parallelCapacity Int @default(1)
|
||||
workingDays String @default("1,2,3,4,5")
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -10,8 +10,6 @@ import type {
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const SHIFT_MINUTES_PER_DAY = 8 * 60;
|
||||
|
||||
type PlanningProjectRecord = {
|
||||
id: string;
|
||||
projectNumber: string;
|
||||
@@ -59,7 +57,7 @@ type PlanningWorkOrderRecord = {
|
||||
plannedStart: Date;
|
||||
plannedEnd: Date;
|
||||
plannedMinutes: number;
|
||||
station: { id: string; code: string; name: string };
|
||||
station: { id: string; code: string; name: string; dailyCapacityMinutes: number; parallelCapacity: number; workingDays: string };
|
||||
}>;
|
||||
materialIssues: Array<{ componentItemId: string; quantity: number }>;
|
||||
};
|
||||
@@ -88,6 +86,9 @@ type StationAccumulator = {
|
||||
readyCount: number;
|
||||
lateCount: number;
|
||||
dayKeys: Set<string>;
|
||||
dailyCapacityMinutes: number;
|
||||
parallelCapacity: number;
|
||||
workingDays: number[];
|
||||
};
|
||||
|
||||
function clampProgress(value: number) {
|
||||
@@ -153,6 +154,14 @@ function encodeQuery(params: Record<string, string | null | undefined>) {
|
||||
return query.length > 0 ? `?${query}` : "";
|
||||
}
|
||||
|
||||
function parseWorkingDays(value: string) {
|
||||
const parsed = value
|
||||
.split(",")
|
||||
.map((entry) => Number.parseInt(entry.trim(), 10))
|
||||
.filter((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 6);
|
||||
return parsed.length > 0 ? parsed : [1, 2, 3, 4, 5];
|
||||
}
|
||||
|
||||
function isBuildItem(type: string) {
|
||||
return type === "ASSEMBLY" || type === "MANUFACTURED";
|
||||
}
|
||||
@@ -166,7 +175,7 @@ function getAvailabilityKey(itemId: string, warehouseId: string, locationId: str
|
||||
}
|
||||
|
||||
function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
|
||||
const capacityMinutes = Math.max(record.dayKeys.size, 1) * SHIFT_MINUTES_PER_DAY;
|
||||
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;
|
||||
return {
|
||||
stationId: record.stationId,
|
||||
@@ -299,7 +308,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
||||
plannedStart: true,
|
||||
plannedEnd: true,
|
||||
plannedMinutes: true,
|
||||
station: { select: { id: true, code: true, name: true } },
|
||||
station: { select: { id: true, code: true, name: true, dailyCapacityMinutes: true, parallelCapacity: true, workingDays: true } },
|
||||
},
|
||||
orderBy: [{ sequence: "asc" }],
|
||||
},
|
||||
@@ -489,6 +498,9 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
||||
readyCount: 0,
|
||||
lateCount: 0,
|
||||
dayKeys: new Set<string>(),
|
||||
dailyCapacityMinutes: operation.station.dailyCapacityMinutes,
|
||||
parallelCapacity: operation.station.parallelCapacity,
|
||||
workingDays: parseWorkingDays(operation.station.workingDays),
|
||||
};
|
||||
current.operationCount += 1;
|
||||
current.workOrderIds.add(workOrder.id);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
listWorkOrders,
|
||||
recordWorkOrderCompletion,
|
||||
updateWorkOrder,
|
||||
updateWorkOrderOperationSchedule,
|
||||
updateWorkOrderStatus,
|
||||
} from "./service.js";
|
||||
|
||||
@@ -24,6 +25,9 @@ const stationSchema = z.object({
|
||||
name: z.string().trim().min(1).max(160),
|
||||
description: z.string(),
|
||||
queueDays: z.number().int().min(0).max(365),
|
||||
dailyCapacityMinutes: z.number().int().min(60).max(1440),
|
||||
parallelCapacity: z.number().int().min(1).max(24),
|
||||
workingDays: z.array(z.number().int().min(0).max(6)).min(1).max(7),
|
||||
isActive: z.boolean(),
|
||||
});
|
||||
|
||||
@@ -64,6 +68,11 @@ const completionSchema = z.object({
|
||||
notes: z.string(),
|
||||
});
|
||||
|
||||
const operationScheduleSchema = z.object({
|
||||
plannedStart: z.string().datetime(),
|
||||
stationId: z.string().trim().min(1).nullable().optional(),
|
||||
});
|
||||
|
||||
function getRouteParam(value: unknown) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
@@ -166,6 +175,26 @@ manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions
|
||||
return ok(response, result.workOrder);
|
||||
});
|
||||
|
||||
manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/schedule", 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 = operationScheduleSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Operation schedule payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateWorkOrderOperationSchedule(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/issues", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
|
||||
const workOrderId = getRouteParam(request.params.workOrderId);
|
||||
if (!workOrderId) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
WorkOrderDetailDto,
|
||||
WorkOrderInput,
|
||||
WorkOrderOperationDto,
|
||||
WorkOrderOperationScheduleInput,
|
||||
WorkOrderMaterialIssueInput,
|
||||
WorkOrderStatus,
|
||||
WorkOrderSummaryDto,
|
||||
@@ -23,6 +24,9 @@ type StationRecord = {
|
||||
name: string;
|
||||
description: string;
|
||||
queueDays: number;
|
||||
dailyCapacityMinutes: number;
|
||||
parallelCapacity: number;
|
||||
workingDays: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -55,6 +59,9 @@ type WorkOrderRecord = {
|
||||
code: string;
|
||||
name: string;
|
||||
queueDays: number;
|
||||
dailyCapacityMinutes: number;
|
||||
parallelCapacity: number;
|
||||
workingDays: string;
|
||||
};
|
||||
}>;
|
||||
bomLines: Array<{
|
||||
@@ -106,6 +113,9 @@ type WorkOrderRecord = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
dailyCapacityMinutes: number;
|
||||
parallelCapacity: number;
|
||||
workingDays: string;
|
||||
};
|
||||
}>;
|
||||
materialIssues: Array<{
|
||||
@@ -146,12 +156,16 @@ type WorkOrderRecord = {
|
||||
};
|
||||
|
||||
function mapStation(record: StationRecord): ManufacturingStationDto {
|
||||
const workingDays = record.workingDays.split(",").map((value) => Number.parseInt(value, 10)).filter((value) => Number.isInteger(value) && value >= 0 && value <= 6);
|
||||
return {
|
||||
id: record.id,
|
||||
code: record.code,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
queueDays: record.queueDays,
|
||||
dailyCapacityMinutes: record.dailyCapacityMinutes,
|
||||
parallelCapacity: record.parallelCapacity,
|
||||
workingDays: workingDays.length > 0 ? workingDays : [1, 2, 3, 4, 5],
|
||||
isActive: record.isActive,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
@@ -170,6 +184,9 @@ function buildInclude() {
|
||||
code: true,
|
||||
name: true,
|
||||
queueDays: true,
|
||||
dailyCapacityMinutes: true,
|
||||
parallelCapacity: true,
|
||||
workingDays: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -230,6 +247,9 @@ function buildInclude() {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
dailyCapacityMinutes: true,
|
||||
parallelCapacity: true,
|
||||
workingDays: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -337,6 +357,9 @@ function mapDetail(
|
||||
stationId: operation.station.id,
|
||||
stationCode: operation.station.code,
|
||||
stationName: operation.station.name,
|
||||
stationDailyCapacityMinutes: operation.station.dailyCapacityMinutes,
|
||||
stationParallelCapacity: operation.station.parallelCapacity,
|
||||
stationWorkingDays: parseWorkingDays(operation.station.workingDays),
|
||||
sequence: operation.sequence,
|
||||
setupMinutes: operation.setupMinutes,
|
||||
runMinutesPerUnit: operation.runMinutesPerUnit,
|
||||
@@ -400,6 +423,68 @@ function addMinutes(value: Date, minutes: number) {
|
||||
return new Date(value.getTime() + minutes * 60 * 1000);
|
||||
}
|
||||
|
||||
function parseWorkingDays(value: string) {
|
||||
const parsed = value
|
||||
.split(",")
|
||||
.map((entry) => Number.parseInt(entry.trim(), 10))
|
||||
.filter((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 6);
|
||||
return parsed.length > 0 ? parsed : [1, 2, 3, 4, 5];
|
||||
}
|
||||
|
||||
function normalizeStationWorkingDays(value: number[]) {
|
||||
const normalized = [...new Set(value.filter((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 6))].sort((left, right) => left - right);
|
||||
return normalized.length > 0 ? normalized : [1, 2, 3, 4, 5];
|
||||
}
|
||||
|
||||
function alignToWorkingWindow(value: Date, workingDays: number[]) {
|
||||
let next = new Date(value);
|
||||
while (!workingDays.includes(next.getDay())) {
|
||||
next = new Date(next.getFullYear(), next.getMonth(), next.getDate() + 1, 8, 0, 0, 0);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function addWorkingMinutes(
|
||||
start: Date,
|
||||
minutes: number,
|
||||
station: { dailyCapacityMinutes: number; parallelCapacity: number; workingDays: string; queueDays: number }
|
||||
) {
|
||||
const workingDays = parseWorkingDays(station.workingDays);
|
||||
const dailyCapacityMinutes = Math.max(station.dailyCapacityMinutes * Math.max(station.parallelCapacity, 1), 60);
|
||||
let cursor = alignToWorkingWindow(start, workingDays);
|
||||
let remaining = Math.max(minutes, 0);
|
||||
|
||||
while (remaining > 0) {
|
||||
const dayStart = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate(), 8, 0, 0, 0);
|
||||
const dayEnd = addMinutes(dayStart, dailyCapacityMinutes);
|
||||
const effectiveStart = cursor < dayStart ? dayStart : cursor;
|
||||
const availableToday = Math.max(Math.round((dayEnd.getTime() - effectiveStart.getTime()) / 60000), 0);
|
||||
|
||||
if (availableToday <= 0) {
|
||||
cursor = alignToWorkingWindow(new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1, 8, 0, 0, 0), workingDays);
|
||||
continue;
|
||||
}
|
||||
|
||||
const consumed = Math.min(availableToday, remaining);
|
||||
cursor = addMinutes(effectiveStart, consumed);
|
||||
remaining -= consumed;
|
||||
|
||||
if (remaining > 0) {
|
||||
cursor = alignToWorkingWindow(new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1, 8, 0, 0, 0), workingDays);
|
||||
}
|
||||
}
|
||||
|
||||
if (station.queueDays > 0) {
|
||||
let queued = new Date(cursor);
|
||||
for (let day = 0; day < station.queueDays; day += 1) {
|
||||
queued = alignToWorkingWindow(new Date(queued.getFullYear(), queued.getMonth(), queued.getDate() + 1, queued.getHours(), queued.getMinutes(), 0, 0), workingDays);
|
||||
}
|
||||
return queued;
|
||||
}
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function shouldReserveForStatus(status: string) {
|
||||
return status === "RELEASED" || status === "IN_PROGRESS" || status === "ON_HOLD";
|
||||
}
|
||||
@@ -415,7 +500,7 @@ function buildWorkOrderOperationPlan(
|
||||
}
|
||||
|
||||
const operationDurations = itemOperations.map((operation) => {
|
||||
const plannedMinutes = Math.max(operation.setupMinutes + operation.runMinutesPerUnit * quantity + operation.moveMinutes + operation.station.queueDays * 8 * 60, 1);
|
||||
const plannedMinutes = Math.max(operation.setupMinutes + operation.runMinutesPerUnit * quantity + operation.moveMinutes, 1);
|
||||
return {
|
||||
stationId: operation.station.id,
|
||||
sequence: operation.position,
|
||||
@@ -424,33 +509,17 @@ function buildWorkOrderOperationPlan(
|
||||
moveMinutes: operation.moveMinutes,
|
||||
plannedMinutes,
|
||||
notes: operation.notes,
|
||||
station: operation.station,
|
||||
};
|
||||
});
|
||||
|
||||
if (dueDate) {
|
||||
let nextEnd = new Date(dueDate);
|
||||
return operationDurations
|
||||
.slice()
|
||||
.sort((left, right) => right.sequence - left.sequence)
|
||||
.map((operation) => {
|
||||
const plannedStart = addMinutes(nextEnd, -operation.plannedMinutes);
|
||||
let nextStart = new Date(dueDate ?? fallbackStart);
|
||||
return operationDurations.map((operation) => {
|
||||
const plannedStart = alignToWorkingWindow(nextStart, parseWorkingDays(operation.station.workingDays));
|
||||
const plannedEnd = addWorkingMinutes(plannedStart, operation.plannedMinutes, operation.station);
|
||||
const planned = {
|
||||
...operation,
|
||||
plannedStart,
|
||||
plannedEnd: nextEnd,
|
||||
};
|
||||
nextEnd = plannedStart;
|
||||
return planned;
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
let nextStart = new Date(fallbackStart);
|
||||
return operationDurations.map((operation) => {
|
||||
const plannedEnd = addMinutes(nextStart, operation.plannedMinutes);
|
||||
const planned = {
|
||||
...operation,
|
||||
plannedStart: nextStart,
|
||||
plannedEnd,
|
||||
};
|
||||
nextStart = plannedEnd;
|
||||
@@ -501,6 +570,70 @@ async function regenerateWorkOrderOperations(workOrderId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
async function rescheduleWorkOrderOperations(workOrderId: string, operationId: string, plannedStart: Date) {
|
||||
const workOrder = await workOrderModel.findUnique({
|
||||
where: { id: workOrderId },
|
||||
include: buildInclude(),
|
||||
});
|
||||
|
||||
if (!workOrder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const operations = (workOrder as WorkOrderRecord).operations;
|
||||
const anchorIndex = operations.findIndex((operation) => operation.id === operationId);
|
||||
if (anchorIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rescheduleWorkOrderOperationsToStation(workOrderId, operations, anchorIndex, plannedStart, null);
|
||||
}
|
||||
|
||||
async function rescheduleWorkOrderOperationsToStation(
|
||||
workOrderId: string,
|
||||
operations: WorkOrderRecord["operations"],
|
||||
anchorIndex: number,
|
||||
plannedStart: Date,
|
||||
targetStation: Pick<StationRecord, "id" | "queueDays" | "dailyCapacityMinutes" | "parallelCapacity" | "workingDays"> | null
|
||||
) {
|
||||
let nextStart = plannedStart;
|
||||
const updates = operations.slice(anchorIndex).map((operation) => {
|
||||
const station = operation.id === operations[anchorIndex]?.id && targetStation
|
||||
? targetStation
|
||||
: {
|
||||
id: operation.station.id,
|
||||
dailyCapacityMinutes: operation.station.dailyCapacityMinutes,
|
||||
parallelCapacity: operation.station.parallelCapacity,
|
||||
workingDays: operation.station.workingDays,
|
||||
queueDays: 0,
|
||||
};
|
||||
const alignedStart = alignToWorkingWindow(nextStart, parseWorkingDays(station.workingDays));
|
||||
const plannedEnd = addWorkingMinutes(alignedStart, operation.plannedMinutes, station);
|
||||
nextStart = plannedEnd;
|
||||
return {
|
||||
id: operation.id,
|
||||
stationId: station.id,
|
||||
plannedStart: alignedStart,
|
||||
plannedEnd,
|
||||
};
|
||||
});
|
||||
|
||||
await prisma.$transaction(
|
||||
updates.map((update) =>
|
||||
prisma.workOrderOperation.update({
|
||||
where: { id: update.id },
|
||||
data: {
|
||||
stationId: update.stationId,
|
||||
plannedStart: update.plannedStart,
|
||||
plannedEnd: update.plannedEnd,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return getWorkOrderById(workOrderId);
|
||||
}
|
||||
|
||||
async function syncWorkOrderReservations(workOrderId: string) {
|
||||
const workOrder = await getWorkOrderById(workOrderId);
|
||||
if (!workOrder) {
|
||||
@@ -760,12 +893,16 @@ export async function listManufacturingStations(): Promise<ManufacturingStationD
|
||||
}
|
||||
|
||||
export async function createManufacturingStation(payload: ManufacturingStationInput, actorId?: string | null) {
|
||||
const workingDays = normalizeStationWorkingDays(payload.workingDays);
|
||||
const station = await prisma.manufacturingStation.create({
|
||||
data: {
|
||||
code: payload.code.trim(),
|
||||
name: payload.name.trim(),
|
||||
description: payload.description,
|
||||
queueDays: payload.queueDays,
|
||||
dailyCapacityMinutes: payload.dailyCapacityMinutes,
|
||||
parallelCapacity: payload.parallelCapacity,
|
||||
workingDays: workingDays.join(","),
|
||||
isActive: payload.isActive,
|
||||
},
|
||||
});
|
||||
@@ -780,6 +917,9 @@ export async function createManufacturingStation(payload: ManufacturingStationIn
|
||||
code: station.code,
|
||||
name: station.name,
|
||||
queueDays: station.queueDays,
|
||||
dailyCapacityMinutes: station.dailyCapacityMinutes,
|
||||
parallelCapacity: station.parallelCapacity,
|
||||
workingDays,
|
||||
isActive: station.isActive,
|
||||
},
|
||||
});
|
||||
@@ -1017,6 +1157,110 @@ export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrd
|
||||
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
||||
}
|
||||
|
||||
export async function updateWorkOrderOperationSchedule(
|
||||
workOrderId: string,
|
||||
operationId: string,
|
||||
payload: WorkOrderOperationScheduleInput,
|
||||
actorId?: string | null
|
||||
) {
|
||||
const existing = await prisma.workOrderOperation.findUnique({
|
||||
where: { id: operationId },
|
||||
select: {
|
||||
id: true,
|
||||
workOrderId: true,
|
||||
sequence: true,
|
||||
stationId: true,
|
||||
station: {
|
||||
select: {
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing || existing.workOrderId !== workOrderId) {
|
||||
return { ok: false as const, reason: "Work-order operation was not found." };
|
||||
}
|
||||
|
||||
const workOrder = await getWorkOrderById(workOrderId);
|
||||
if (!workOrder) {
|
||||
return { ok: false as const, reason: "Work order was not found." };
|
||||
}
|
||||
|
||||
if (workOrder.status === "COMPLETE" || workOrder.status === "CANCELLED") {
|
||||
return { ok: false as const, reason: "Completed or cancelled work orders cannot be rescheduled." };
|
||||
}
|
||||
|
||||
let targetStation:
|
||||
| Pick<StationRecord, "id" | "queueDays" | "dailyCapacityMinutes" | "parallelCapacity" | "workingDays">
|
||||
| null = null;
|
||||
if (payload.stationId && payload.stationId !== existing.stationId) {
|
||||
const station = await prisma.manufacturingStation.findUnique({
|
||||
where: { id: payload.stationId },
|
||||
select: {
|
||||
id: true,
|
||||
queueDays: true,
|
||||
dailyCapacityMinutes: true,
|
||||
parallelCapacity: true,
|
||||
workingDays: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!station || !station.isActive) {
|
||||
return { ok: false as const, reason: "Selected manufacturing station was not found or is inactive." };
|
||||
}
|
||||
|
||||
targetStation = station;
|
||||
}
|
||||
|
||||
const workOrderRecord = await workOrderModel.findUnique({
|
||||
where: { id: workOrderId },
|
||||
include: buildInclude(),
|
||||
});
|
||||
if (!workOrderRecord) {
|
||||
return { ok: false as const, reason: "Work order was not found." };
|
||||
}
|
||||
|
||||
const operations = (workOrderRecord as WorkOrderRecord).operations;
|
||||
const anchorIndex = operations.findIndex((operation) => operation.id === operationId);
|
||||
if (anchorIndex < 0) {
|
||||
return { ok: false as const, reason: "Work-order operation was not found." };
|
||||
}
|
||||
|
||||
const rescheduled = await rescheduleWorkOrderOperationsToStation(
|
||||
workOrderId,
|
||||
operations,
|
||||
anchorIndex,
|
||||
new Date(payload.plannedStart),
|
||||
targetStation
|
||||
);
|
||||
if (!rescheduled) {
|
||||
return { ok: false as const, reason: "Unable to reschedule the requested operation." };
|
||||
}
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "work-order",
|
||||
entityId: workOrderId,
|
||||
action: "operations.rescheduled",
|
||||
summary: `Rescheduled work order ${rescheduled.workOrderNumber} from operation ${existing.sequence}.`,
|
||||
metadata: {
|
||||
workOrderNumber: rescheduled.workOrderNumber,
|
||||
operationId,
|
||||
sequence: existing.sequence,
|
||||
plannedStart: payload.plannedStart,
|
||||
previousStationId: existing.stationId,
|
||||
previousStationCode: existing.station.code,
|
||||
previousStationName: existing.station.name,
|
||||
stationId: payload.stationId ?? existing.stationId,
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true as const, workOrder: rescheduled };
|
||||
}
|
||||
|
||||
export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkOrderMaterialIssueInput, createdById?: string | null) {
|
||||
const workOrder = await workOrderModel.findUnique({
|
||||
where: { id: workOrderId },
|
||||
|
||||
@@ -8,6 +8,9 @@ export interface ManufacturingStationDto {
|
||||
name: string;
|
||||
description: string;
|
||||
queueDays: number;
|
||||
dailyCapacityMinutes: number;
|
||||
parallelCapacity: number;
|
||||
workingDays: number[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -18,6 +21,9 @@ export interface ManufacturingStationInput {
|
||||
name: string;
|
||||
description: string;
|
||||
queueDays: number;
|
||||
dailyCapacityMinutes: number;
|
||||
parallelCapacity: number;
|
||||
workingDays: number[];
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@@ -71,6 +77,9 @@ export interface WorkOrderOperationDto {
|
||||
stationId: string;
|
||||
stationCode: string;
|
||||
stationName: string;
|
||||
stationDailyCapacityMinutes: number;
|
||||
stationParallelCapacity: number;
|
||||
stationWorkingDays: number[];
|
||||
sequence: number;
|
||||
setupMinutes: number;
|
||||
runMinutesPerUnit: number;
|
||||
@@ -159,3 +168,8 @@ export interface WorkOrderCompletionInput {
|
||||
quantity: number;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface WorkOrderOperationScheduleInput {
|
||||
plannedStart: string;
|
||||
stationId?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user