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
|
- 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 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
|
- 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 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
|
||||||
|
|||||||
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
|
- 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, material issue posting, completion posting, and work-order attachments
|
- 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, and inline dispatch actions
|
- 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
|
- 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
|
||||||
- pegged WO/PO supply tracking back to sales demand with preferred-vendor sourcing on inventory items
|
- 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 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:
|
Current interactions:
|
||||||
|
|
||||||
@@ -122,11 +122,11 @@ Next expansion areas:
|
|||||||
|
|
||||||
- Purchasing: shortages and buyout demand should surface from manufacturing execution
|
- Purchasing: shortages and buyout demand should surface from manufacturing execution
|
||||||
- Shipping: completed manufacturing should feed shipment readiness
|
- 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 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:
|
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
|
- Task dependencies, milestones, and progress updates
|
||||||
- Manufacturing calendar views and deeper bottleneck visibility beyond the shipped station load and overload workbench summaries
|
- 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
|
- Theme-compliant workbench scheduling surfaces for light/dark mode
|
||||||
- Collapsible schedule groupings and saved planner views
|
- 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
|
- 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
|
- Better mobile and tablet behavior for shop-floor lookups
|
||||||
- Faster filtering by project, customer, work center, and status
|
- 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 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, 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 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
|
||||||
- Revision comparison UX for changed sales and purchasing documents, including purchase-order revision persistence
|
- 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
|
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
|
||||||
- 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
|
||||||
|
- 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
|
- Sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
|
||||||
- Multi-stage Docker packaging and migration-aware entrypoint
|
- Multi-stage Docker packaging and migration-aware entrypoint
|
||||||
- Docker image validated locally with successful app startup and login flow
|
- Docker image validated locally with successful app startup and login flow
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import type {
|
|||||||
WorkOrderCompletionInput,
|
WorkOrderCompletionInput,
|
||||||
WorkOrderDetailDto,
|
WorkOrderDetailDto,
|
||||||
WorkOrderInput,
|
WorkOrderInput,
|
||||||
|
WorkOrderOperationScheduleInput,
|
||||||
WorkOrderMaterialIssueInput,
|
WorkOrderMaterialIssueInput,
|
||||||
WorkOrderStatus,
|
WorkOrderStatus,
|
||||||
WorkOrderSummaryDto,
|
WorkOrderSummaryDto,
|
||||||
@@ -639,6 +640,13 @@ export const api = {
|
|||||||
token
|
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) {
|
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`,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const emptyStationInput: ManufacturingStationInput = {
|
|||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
queueDays: 0,
|
queueDays: 0,
|
||||||
|
dailyCapacityMinutes: 480,
|
||||||
|
parallelCapacity: 1,
|
||||||
|
workingDays: [1, 2, 3, 4, 5],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,6 +75,8 @@ export function ManufacturingPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right text-xs text-muted">
|
<div className="text-right text-xs text-muted">
|
||||||
<div>{station.queueDays} expected wait day(s)</div>
|
<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 className="mt-1">{station.isActive ? "Active" : "Inactive"}</div>
|
||||||
</div>
|
</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>
|
<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" />
|
<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>
|
</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">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
|
<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" />
|
<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 { 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 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";
|
||||||
@@ -22,6 +22,8 @@ export function WorkOrderDetailPage() {
|
|||||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||||
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 [reschedulingOperationId, setReschedulingOperationId] = useState<string | null>(null);
|
||||||
const [pendingConfirmation, setPendingConfirmation] = useState<
|
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||||
| {
|
| {
|
||||||
kind: "status" | "issue" | "completion";
|
kind: "status" | "issue" | "completion";
|
||||||
@@ -56,6 +58,11 @@ export function WorkOrderDetailPage() {
|
|||||||
...emptyCompletionInput,
|
...emptyCompletionInput,
|
||||||
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
|
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
|
||||||
});
|
});
|
||||||
|
setOperationScheduleForm(
|
||||||
|
Object.fromEntries(
|
||||||
|
nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }])
|
||||||
|
)
|
||||||
|
);
|
||||||
setStatus("Work order loaded.");
|
setStatus("Work order loaded.");
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.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) {
|
function handleStatusChange(nextStatus: WorkOrderStatus) {
|
||||||
if (!workOrder) {
|
if (!workOrder) {
|
||||||
return;
|
return;
|
||||||
@@ -271,9 +307,11 @@ 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">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">Minutes</th>
|
||||||
|
{canManage ? <th className="px-3 py-3">Reschedule</th> : null}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-line/70">
|
<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="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>{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.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-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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
DemandPlanningRollupDto,
|
DemandPlanningRollupDto,
|
||||||
GanttTaskDto,
|
GanttTaskDto,
|
||||||
|
ManufacturingStationDto,
|
||||||
PlanningExceptionDto,
|
PlanningExceptionDto,
|
||||||
PlanningStationLoadDto,
|
PlanningStationLoadDto,
|
||||||
PlanningTaskActionDto,
|
PlanningTaskActionDto,
|
||||||
@@ -14,6 +15,7 @@ import { ApiError, api } from "../../lib/api";
|
|||||||
type WorkbenchMode = "overview" | "heatmap" | "agenda";
|
type WorkbenchMode = "overview" | "heatmap" | "agenda";
|
||||||
type FocusRecord = {
|
type FocusRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
|
entityId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
kind: "PROJECT" | "WORK_ORDER" | "OPERATION" | "MILESTONE";
|
kind: "PROJECT" | "WORK_ORDER" | "OPERATION" | "MILESTONE";
|
||||||
status: string;
|
status: string;
|
||||||
@@ -109,6 +111,7 @@ function densityTone(cell: HeatmapCell) {
|
|||||||
function buildFocusRecords(tasks: GanttTaskDto[]) {
|
function buildFocusRecords(tasks: GanttTaskDto[]) {
|
||||||
return tasks.map((task) => ({
|
return tasks.map((task) => ({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
|
entityId: task.entityId ?? null,
|
||||||
title: task.text,
|
title: task.text,
|
||||||
kind: parseFocusKind(task),
|
kind: parseFocusKind(task),
|
||||||
status: task.status ?? "PLANNED",
|
status: task.status ?? "PLANNED",
|
||||||
@@ -164,16 +167,21 @@ export function WorkbenchPage() {
|
|||||||
const [workbenchFilter, setWorkbenchFilter] = useState<WorkbenchFilter>("all");
|
const [workbenchFilter, setWorkbenchFilter] = useState<WorkbenchFilter>("all");
|
||||||
const [selectedFocusId, setSelectedFocusId] = useState<string | null>(null);
|
const [selectedFocusId, setSelectedFocusId] = useState<string | null>(null);
|
||||||
const [selectedHeatmapDate, setSelectedHeatmapDate] = 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(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token)])
|
Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token), api.getManufacturingStations(token)])
|
||||||
.then(([data, rollup]) => {
|
.then(([data, rollup, stationOptions]) => {
|
||||||
setTimeline(data);
|
setTimeline(data);
|
||||||
setPlanningRollup(rollup);
|
setPlanningRollup(rollup);
|
||||||
|
setStations(stationOptions);
|
||||||
setStatus("Planning workbench loaded.");
|
setStatus("Planning workbench loaded.");
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
@@ -191,6 +199,16 @@ export function WorkbenchPage() {
|
|||||||
const focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]);
|
const focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]);
|
||||||
const selectedFocus = selectedFocusId ? focusById.get(selectedFocusId) ?? null : filteredFocusRecords[0] ?? focusRecords[0] ?? null;
|
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 heatmap = useMemo(() => {
|
||||||
const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date());
|
const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date());
|
||||||
const cells = new Map<string, HeatmapCell>();
|
const cells = new Map<string, HeatmapCell>();
|
||||||
@@ -226,6 +244,9 @@ export function WorkbenchPage() {
|
|||||||
}, [filteredFocusRecords, summary]);
|
}, [filteredFocusRecords, summary]);
|
||||||
|
|
||||||
const selectedHeatmapCell = selectedHeatmapDate ? heatmap.find((cell) => cell.dateKey === selectedHeatmapDate) ?? null : null;
|
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(
|
const agendaItems = useMemo(
|
||||||
() => [...focusRecords]
|
() => [...focusRecords]
|
||||||
.filter((record) => record.kind !== "OPERATION")
|
.filter((record) => record.kind !== "OPERATION")
|
||||||
@@ -253,15 +274,23 @@ export function WorkbenchPage() {
|
|||||||
{ value: "overdue", label: "Overdue" },
|
{ 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) {
|
async function handleTaskAction(action: PlanningTaskActionDto) {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action.kind === "RELEASE_WORK_ORDER" && action.workOrderId) {
|
if (action.kind === "RELEASE_WORK_ORDER" && action.workOrderId) {
|
||||||
await api.updateWorkOrderStatus(token, action.workOrderId, "RELEASED");
|
await api.updateWorkOrderStatus(token, action.workOrderId, "RELEASED");
|
||||||
const refreshed = await api.getPlanningTimeline(token);
|
await refreshWorkbench("Workbench refreshed after release.");
|
||||||
setTimeline(refreshed);
|
|
||||||
setStatus("Workbench refreshed after release.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action.href) {
|
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 (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<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>
|
<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}
|
{selectedFocus.blockedReason ? <div className="mt-3 text-xs text-muted">{selectedFocus.blockedReason}</div> : null}
|
||||||
</div>
|
</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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedFocus.actions.map((action, index) => (
|
{selectedFocus.actions.map((action, index) => (
|
||||||
<button
|
<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
|
name String
|
||||||
description String
|
description String
|
||||||
queueDays Int @default(0)
|
queueDays Int @default(0)
|
||||||
|
dailyCapacityMinutes Int @default(480)
|
||||||
|
parallelCapacity Int @default(1)
|
||||||
|
workingDays String @default("1,2,3,4,5")
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import type {
|
|||||||
import { prisma } from "../../lib/prisma.js";
|
import { prisma } from "../../lib/prisma.js";
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
const SHIFT_MINUTES_PER_DAY = 8 * 60;
|
|
||||||
|
|
||||||
type PlanningProjectRecord = {
|
type PlanningProjectRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
projectNumber: string;
|
projectNumber: string;
|
||||||
@@ -59,7 +57,7 @@ type PlanningWorkOrderRecord = {
|
|||||||
plannedStart: Date;
|
plannedStart: Date;
|
||||||
plannedEnd: Date;
|
plannedEnd: Date;
|
||||||
plannedMinutes: number;
|
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 }>;
|
materialIssues: Array<{ componentItemId: string; quantity: number }>;
|
||||||
};
|
};
|
||||||
@@ -88,6 +86,9 @@ type StationAccumulator = {
|
|||||||
readyCount: number;
|
readyCount: number;
|
||||||
lateCount: number;
|
lateCount: number;
|
||||||
dayKeys: Set<string>;
|
dayKeys: Set<string>;
|
||||||
|
dailyCapacityMinutes: number;
|
||||||
|
parallelCapacity: number;
|
||||||
|
workingDays: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function clampProgress(value: number) {
|
function clampProgress(value: number) {
|
||||||
@@ -153,6 +154,14 @@ function encodeQuery(params: Record<string, string | null | undefined>) {
|
|||||||
return query.length > 0 ? `?${query}` : "";
|
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) {
|
function isBuildItem(type: string) {
|
||||||
return type === "ASSEMBLY" || type === "MANUFACTURED";
|
return type === "ASSEMBLY" || type === "MANUFACTURED";
|
||||||
}
|
}
|
||||||
@@ -166,7 +175,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) * 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;
|
const utilizationPercent = capacityMinutes > 0 ? Math.round((record.totalPlannedMinutes / capacityMinutes) * 100) : 0;
|
||||||
return {
|
return {
|
||||||
stationId: record.stationId,
|
stationId: record.stationId,
|
||||||
@@ -299,7 +308,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
plannedStart: true,
|
plannedStart: true,
|
||||||
plannedEnd: true,
|
plannedEnd: true,
|
||||||
plannedMinutes: 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" }],
|
orderBy: [{ sequence: "asc" }],
|
||||||
},
|
},
|
||||||
@@ -489,6 +498,9 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
readyCount: 0,
|
readyCount: 0,
|
||||||
lateCount: 0,
|
lateCount: 0,
|
||||||
dayKeys: new Set<string>(),
|
dayKeys: new Set<string>(),
|
||||||
|
dailyCapacityMinutes: operation.station.dailyCapacityMinutes,
|
||||||
|
parallelCapacity: operation.station.parallelCapacity,
|
||||||
|
workingDays: parseWorkingDays(operation.station.workingDays),
|
||||||
};
|
};
|
||||||
current.operationCount += 1;
|
current.operationCount += 1;
|
||||||
current.workOrderIds.add(workOrder.id);
|
current.workOrderIds.add(workOrder.id);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
listWorkOrders,
|
listWorkOrders,
|
||||||
recordWorkOrderCompletion,
|
recordWorkOrderCompletion,
|
||||||
updateWorkOrder,
|
updateWorkOrder,
|
||||||
|
updateWorkOrderOperationSchedule,
|
||||||
updateWorkOrderStatus,
|
updateWorkOrderStatus,
|
||||||
} from "./service.js";
|
} from "./service.js";
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ const stationSchema = z.object({
|
|||||||
name: z.string().trim().min(1).max(160),
|
name: z.string().trim().min(1).max(160),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
queueDays: z.number().int().min(0).max(365),
|
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(),
|
isActive: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,6 +68,11 @@ const completionSchema = z.object({
|
|||||||
notes: z.string(),
|
notes: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const operationScheduleSchema = z.object({
|
||||||
|
plannedStart: z.string().datetime(),
|
||||||
|
stationId: z.string().trim().min(1).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
function getRouteParam(value: unknown) {
|
function getRouteParam(value: unknown) {
|
||||||
return typeof value === "string" ? value : null;
|
return typeof value === "string" ? value : null;
|
||||||
}
|
}
|
||||||
@@ -166,6 +175,26 @@ manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions
|
|||||||
return ok(response, result.workOrder);
|
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) => {
|
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) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
WorkOrderDetailDto,
|
WorkOrderDetailDto,
|
||||||
WorkOrderInput,
|
WorkOrderInput,
|
||||||
WorkOrderOperationDto,
|
WorkOrderOperationDto,
|
||||||
|
WorkOrderOperationScheduleInput,
|
||||||
WorkOrderMaterialIssueInput,
|
WorkOrderMaterialIssueInput,
|
||||||
WorkOrderStatus,
|
WorkOrderStatus,
|
||||||
WorkOrderSummaryDto,
|
WorkOrderSummaryDto,
|
||||||
@@ -23,6 +24,9 @@ type StationRecord = {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
queueDays: number;
|
queueDays: number;
|
||||||
|
dailyCapacityMinutes: number;
|
||||||
|
parallelCapacity: number;
|
||||||
|
workingDays: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -55,6 +59,9 @@ type WorkOrderRecord = {
|
|||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
queueDays: number;
|
queueDays: number;
|
||||||
|
dailyCapacityMinutes: number;
|
||||||
|
parallelCapacity: number;
|
||||||
|
workingDays: string;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
bomLines: Array<{
|
bomLines: Array<{
|
||||||
@@ -106,6 +113,9 @@ type WorkOrderRecord = {
|
|||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
dailyCapacityMinutes: number;
|
||||||
|
parallelCapacity: number;
|
||||||
|
workingDays: string;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
materialIssues: Array<{
|
materialIssues: Array<{
|
||||||
@@ -146,12 +156,16 @@ type WorkOrderRecord = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function mapStation(record: StationRecord): ManufacturingStationDto {
|
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 {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
code: record.code,
|
code: record.code,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
description: record.description,
|
description: record.description,
|
||||||
queueDays: record.queueDays,
|
queueDays: record.queueDays,
|
||||||
|
dailyCapacityMinutes: record.dailyCapacityMinutes,
|
||||||
|
parallelCapacity: record.parallelCapacity,
|
||||||
|
workingDays: workingDays.length > 0 ? workingDays : [1, 2, 3, 4, 5],
|
||||||
isActive: record.isActive,
|
isActive: record.isActive,
|
||||||
createdAt: record.createdAt.toISOString(),
|
createdAt: record.createdAt.toISOString(),
|
||||||
updatedAt: record.updatedAt.toISOString(),
|
updatedAt: record.updatedAt.toISOString(),
|
||||||
@@ -170,6 +184,9 @@ function buildInclude() {
|
|||||||
code: true,
|
code: true,
|
||||||
name: true,
|
name: true,
|
||||||
queueDays: true,
|
queueDays: true,
|
||||||
|
dailyCapacityMinutes: true,
|
||||||
|
parallelCapacity: true,
|
||||||
|
workingDays: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -230,6 +247,9 @@ function buildInclude() {
|
|||||||
id: true,
|
id: true,
|
||||||
code: true,
|
code: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
dailyCapacityMinutes: true,
|
||||||
|
parallelCapacity: true,
|
||||||
|
workingDays: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -337,6 +357,9 @@ function mapDetail(
|
|||||||
stationId: operation.station.id,
|
stationId: operation.station.id,
|
||||||
stationCode: operation.station.code,
|
stationCode: operation.station.code,
|
||||||
stationName: operation.station.name,
|
stationName: operation.station.name,
|
||||||
|
stationDailyCapacityMinutes: operation.station.dailyCapacityMinutes,
|
||||||
|
stationParallelCapacity: operation.station.parallelCapacity,
|
||||||
|
stationWorkingDays: parseWorkingDays(operation.station.workingDays),
|
||||||
sequence: operation.sequence,
|
sequence: operation.sequence,
|
||||||
setupMinutes: operation.setupMinutes,
|
setupMinutes: operation.setupMinutes,
|
||||||
runMinutesPerUnit: operation.runMinutesPerUnit,
|
runMinutesPerUnit: operation.runMinutesPerUnit,
|
||||||
@@ -400,6 +423,68 @@ function addMinutes(value: Date, minutes: number) {
|
|||||||
return new Date(value.getTime() + minutes * 60 * 1000);
|
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) {
|
function shouldReserveForStatus(status: string) {
|
||||||
return status === "RELEASED" || status === "IN_PROGRESS" || status === "ON_HOLD";
|
return status === "RELEASED" || status === "IN_PROGRESS" || status === "ON_HOLD";
|
||||||
}
|
}
|
||||||
@@ -415,7 +500,7 @@ function buildWorkOrderOperationPlan(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const operationDurations = itemOperations.map((operation) => {
|
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 {
|
return {
|
||||||
stationId: operation.station.id,
|
stationId: operation.station.id,
|
||||||
sequence: operation.position,
|
sequence: operation.position,
|
||||||
@@ -424,33 +509,17 @@ function buildWorkOrderOperationPlan(
|
|||||||
moveMinutes: operation.moveMinutes,
|
moveMinutes: operation.moveMinutes,
|
||||||
plannedMinutes,
|
plannedMinutes,
|
||||||
notes: operation.notes,
|
notes: operation.notes,
|
||||||
|
station: operation.station,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dueDate) {
|
let nextStart = new Date(dueDate ?? fallbackStart);
|
||||||
let nextEnd = new Date(dueDate);
|
|
||||||
return operationDurations
|
|
||||||
.slice()
|
|
||||||
.sort((left, right) => right.sequence - left.sequence)
|
|
||||||
.map((operation) => {
|
|
||||||
const plannedStart = addMinutes(nextEnd, -operation.plannedMinutes);
|
|
||||||
const planned = {
|
|
||||||
...operation,
|
|
||||||
plannedStart,
|
|
||||||
plannedEnd: nextEnd,
|
|
||||||
};
|
|
||||||
nextEnd = plannedStart;
|
|
||||||
return planned;
|
|
||||||
})
|
|
||||||
.reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextStart = new Date(fallbackStart);
|
|
||||||
return operationDurations.map((operation) => {
|
return operationDurations.map((operation) => {
|
||||||
const plannedEnd = addMinutes(nextStart, operation.plannedMinutes);
|
const plannedStart = alignToWorkingWindow(nextStart, parseWorkingDays(operation.station.workingDays));
|
||||||
|
const plannedEnd = addWorkingMinutes(plannedStart, operation.plannedMinutes, operation.station);
|
||||||
const planned = {
|
const planned = {
|
||||||
...operation,
|
...operation,
|
||||||
plannedStart: nextStart,
|
plannedStart,
|
||||||
plannedEnd,
|
plannedEnd,
|
||||||
};
|
};
|
||||||
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) {
|
async function syncWorkOrderReservations(workOrderId: string) {
|
||||||
const workOrder = await getWorkOrderById(workOrderId);
|
const workOrder = await getWorkOrderById(workOrderId);
|
||||||
if (!workOrder) {
|
if (!workOrder) {
|
||||||
@@ -760,12 +893,16 @@ export async function listManufacturingStations(): Promise<ManufacturingStationD
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createManufacturingStation(payload: ManufacturingStationInput, actorId?: string | null) {
|
export async function createManufacturingStation(payload: ManufacturingStationInput, actorId?: string | null) {
|
||||||
|
const workingDays = normalizeStationWorkingDays(payload.workingDays);
|
||||||
const station = await prisma.manufacturingStation.create({
|
const station = await prisma.manufacturingStation.create({
|
||||||
data: {
|
data: {
|
||||||
code: payload.code.trim(),
|
code: payload.code.trim(),
|
||||||
name: payload.name.trim(),
|
name: payload.name.trim(),
|
||||||
description: payload.description,
|
description: payload.description,
|
||||||
queueDays: payload.queueDays,
|
queueDays: payload.queueDays,
|
||||||
|
dailyCapacityMinutes: payload.dailyCapacityMinutes,
|
||||||
|
parallelCapacity: payload.parallelCapacity,
|
||||||
|
workingDays: workingDays.join(","),
|
||||||
isActive: payload.isActive,
|
isActive: payload.isActive,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -777,12 +914,15 @@ export async function createManufacturingStation(payload: ManufacturingStationIn
|
|||||||
action: "created",
|
action: "created",
|
||||||
summary: `Created manufacturing station ${station.code}.`,
|
summary: `Created manufacturing station ${station.code}.`,
|
||||||
metadata: {
|
metadata: {
|
||||||
code: station.code,
|
code: station.code,
|
||||||
name: station.name,
|
name: station.name,
|
||||||
queueDays: station.queueDays,
|
queueDays: station.queueDays,
|
||||||
isActive: station.isActive,
|
dailyCapacityMinutes: station.dailyCapacityMinutes,
|
||||||
},
|
parallelCapacity: station.parallelCapacity,
|
||||||
});
|
workingDays,
|
||||||
|
isActive: station.isActive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return mapStation(station);
|
return mapStation(station);
|
||||||
}
|
}
|
||||||
@@ -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." };
|
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) {
|
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 },
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export interface ManufacturingStationDto {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
queueDays: number;
|
queueDays: number;
|
||||||
|
dailyCapacityMinutes: number;
|
||||||
|
parallelCapacity: number;
|
||||||
|
workingDays: number[];
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -18,6 +21,9 @@ export interface ManufacturingStationInput {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
queueDays: number;
|
queueDays: number;
|
||||||
|
dailyCapacityMinutes: number;
|
||||||
|
parallelCapacity: number;
|
||||||
|
workingDays: number[];
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +77,9 @@ export interface WorkOrderOperationDto {
|
|||||||
stationId: string;
|
stationId: string;
|
||||||
stationCode: string;
|
stationCode: string;
|
||||||
stationName: string;
|
stationName: string;
|
||||||
|
stationDailyCapacityMinutes: number;
|
||||||
|
stationParallelCapacity: number;
|
||||||
|
stationWorkingDays: number[];
|
||||||
sequence: number;
|
sequence: number;
|
||||||
setupMinutes: number;
|
setupMinutes: number;
|
||||||
runMinutesPerUnit: number;
|
runMinutesPerUnit: number;
|
||||||
@@ -159,3 +168,8 @@ export interface WorkOrderCompletionInput {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkOrderOperationScheduleInput {
|
||||||
|
plannedStart: string;
|
||||||
|
stationId?: string | null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user