planning payload

This commit is contained in:
2026-03-17 23:52:58 -05:00
parent 66d8814d89
commit 14708d7013
7 changed files with 875 additions and 246 deletions

View File

@@ -1,8 +1,94 @@
import type { GanttLinkDto, GanttTaskDto, PlanningTimelineDto } from "@mrp/shared";
import type {
GanttLinkDto,
GanttTaskDto,
PlanningReadinessState,
PlanningStationLoadDto,
PlanningTaskActionDto,
PlanningTimelineDto,
} from "@mrp/shared";
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;
name: string;
status: string;
dueDate: Date | null;
createdAt: Date;
customer: { name: string };
owner: { firstName: string; lastName: string } | null;
};
type PlanningWorkOrderRecord = {
id: string;
workOrderNumber: string;
status: string;
quantity: number;
completedQuantity: number;
dueDate: Date | null;
createdAt: Date;
projectId: string | null;
salesOrderId: string | null;
salesOrderLineId: string | null;
warehouseId: string;
locationId: string;
item: {
id: string;
sku: string;
name: string;
type: string;
isPurchasable: boolean;
bomLines: Array<{
quantity: number;
componentItem: {
id: string;
sku: string;
name: string;
type: string;
isPurchasable: boolean;
};
}>;
};
operations: Array<{
id: string;
sequence: number;
plannedStart: Date;
plannedEnd: Date;
plannedMinutes: number;
station: { id: string; code: string; name: string };
}>;
materialIssues: Array<{ componentItemId: string; quantity: number }>;
};
type WorkOrderInsight = {
readinessState: PlanningReadinessState;
readinessScore: number;
shortageItemCount: number;
totalShortageQuantity: number;
linkedSupplyQuantity: number;
openSupplyQuantity: number;
releaseReady: boolean;
blockedReason: string | null;
overdue: boolean;
actions: PlanningTaskActionDto[];
};
type StationAccumulator = {
stationId: string;
stationCode: string;
stationName: string;
operationCount: number;
workOrderIds: Set<string>;
totalPlannedMinutes: number;
blockedCount: number;
readyCount: number;
lateCount: number;
dayKeys: Set<string>;
};
function clampProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
@@ -20,6 +106,10 @@ function endOfDay(value: Date) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate(), 23, 59, 59, 999);
}
function dateKey(value: Date) {
return value.toISOString().slice(0, 10);
}
function projectProgressFromStatus(status: string) {
switch (status) {
case "COMPLETE":
@@ -39,147 +129,402 @@ function workOrderProgress(quantity: number, completedQuantity: number, status:
if (status === "COMPLETE") {
return 100;
}
if (quantity <= 0) {
return 0;
}
return clampProgress((completedQuantity / quantity) * 100);
}
function buildOwnerLabel(ownerName: string | null, customerName: string | null) {
if (ownerName && customerName) {
return `${ownerName} ${customerName}`;
return `${ownerName} | ${customerName}`;
}
return ownerName ?? customerName ?? null;
}
function encodeQuery(params: Record<string, string | null | undefined>) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value && value.trim().length > 0) {
search.set(key, value);
}
}
const query = search.toString();
return query.length > 0 ? `?${query}` : "";
}
function isBuildItem(type: string) {
return type === "ASSEMBLY" || type === "MANUFACTURED";
}
function shouldBuyItem(type: string, isPurchasable: boolean) {
return type === "PURCHASED" || isPurchasable;
}
function getAvailabilityKey(itemId: string, warehouseId: string, locationId: string) {
return `${itemId}:${warehouseId}:${locationId}`;
}
function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
const capacityMinutes = Math.max(record.dayKeys.size, 1) * SHIFT_MINUTES_PER_DAY;
const utilizationPercent = capacityMinutes > 0 ? Math.round((record.totalPlannedMinutes / capacityMinutes) * 100) : 0;
return {
stationId: record.stationId,
stationCode: record.stationCode,
stationName: record.stationName,
operationCount: record.operationCount,
workOrderCount: record.workOrderIds.size,
totalPlannedMinutes: record.totalPlannedMinutes,
capacityMinutes,
utilizationPercent,
overloaded: utilizationPercent > 100,
blockedCount: record.blockedCount,
readyCount: record.readyCount,
lateCount: record.lateCount,
};
}
function buildProjectTask(
project: PlanningProjectRecord,
projectWorkOrders: PlanningWorkOrderRecord[],
workOrderInsights: Map<string, WorkOrderInsight>,
now: Date
): GanttTaskDto {
const ownerName = project.owner ? `${project.owner.firstName} ${project.owner.lastName}`.trim() : null;
const ownerLabel = buildOwnerLabel(ownerName, project.customer.name);
const dueDates = projectWorkOrders.map((workOrder) => workOrder.dueDate).filter((value): value is Date => Boolean(value));
const earliestWorkStart = projectWorkOrders[0]?.createdAt ?? project.createdAt;
const lastDueDate = dueDates.sort((left, right) => left.getTime() - right.getTime()).at(-1) ?? project.dueDate ?? addDays(project.createdAt, 14);
const insights = projectWorkOrders.map((workOrder) => workOrderInsights.get(workOrder.id)).filter(Boolean) as WorkOrderInsight[];
const readinessState: PlanningReadinessState =
insights.some((entry) => entry.readinessState === "BLOCKED") ? "BLOCKED"
: insights.some((entry) => entry.readinessState === "SHORTAGE") ? "SHORTAGE"
: insights.some((entry) => entry.readinessState === "PENDING_SUPPLY") ? "PENDING_SUPPLY"
: insights.length > 0 && insights.every((entry) => entry.readinessState === "UNSCHEDULED") ? "UNSCHEDULED"
: "READY";
return {
id: `project-${project.id}`,
text: `${project.projectNumber} - ${project.name}`,
start: startOfDay(earliestWorkStart).toISOString(),
end: endOfDay(lastDueDate).toISOString(),
progress: clampProgress(
projectWorkOrders.length > 0
? projectWorkOrders.reduce((sum, workOrder) => sum + workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), 0) /
projectWorkOrders.length
: projectProgressFromStatus(project.status)
),
type: "project",
entityId: project.id,
projectId: project.id,
status: project.status,
ownerLabel,
detailHref: `/projects/${project.id}`,
readinessState,
readinessScore: insights.length > 0 ? Math.round(insights.reduce((sum, entry) => sum + entry.readinessScore, 0) / insights.length) : 80,
shortageItemCount: insights.reduce((sum, entry) => sum + entry.shortageItemCount, 0),
totalShortageQuantity: insights.reduce((sum, entry) => sum + entry.totalShortageQuantity, 0),
openSupplyQuantity: insights.reduce((sum, entry) => sum + entry.openSupplyQuantity, 0),
releaseReady: insights.some((entry) => entry.releaseReady),
overdue: project.dueDate ? project.dueDate.getTime() < now.getTime() : false,
blockedReason:
readinessState === "BLOCKED" ? "A linked work order is blocked."
: readinessState === "SHORTAGE" ? "Linked work orders have shortages."
: readinessState === "PENDING_SUPPLY" ? "Linked work orders are waiting on supply."
: readinessState === "UNSCHEDULED" ? "Linked work orders are unscheduled."
: null,
actions: [{ kind: "OPEN_RECORD", label: "Open record", href: `/projects/${project.id}` }],
};
}
export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
const now = new Date();
const planningProjects = await prisma.project.findMany({
where: {
status: {
not: "COMPLETE",
const [projects, workOrders] = await Promise.all([
prisma.project.findMany({
where: { status: { not: "COMPLETE" } },
select: {
id: true,
projectNumber: true,
name: true,
status: true,
dueDate: true,
createdAt: true,
customer: { select: { name: true } },
owner: { select: { firstName: true, lastName: true } },
},
},
include: {
customer: {
select: {
name: true,
},
},
owner: {
select: {
firstName: true,
lastName: true,
},
},
workOrders: {
where: {
status: {
notIn: ["COMPLETE", "CANCELLED"],
},
},
select: {
id: true,
workOrderNumber: true,
status: true,
quantity: true,
completedQuantity: true,
dueDate: true,
createdAt: true,
operations: {
select: {
id: true,
sequence: true,
plannedStart: true,
plannedEnd: true,
plannedMinutes: true,
station: {
select: {
code: true,
name: true,
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
}),
prisma.workOrder.findMany({
where: { status: { notIn: ["COMPLETE", "CANCELLED"] } },
select: {
id: true,
workOrderNumber: true,
status: true,
quantity: true,
completedQuantity: true,
dueDate: true,
createdAt: true,
projectId: true,
salesOrderId: true,
salesOrderLineId: true,
warehouseId: true,
locationId: true,
item: {
select: {
id: true,
sku: true,
name: true,
type: true,
isPurchasable: true,
bomLines: {
select: {
quantity: true,
componentItem: {
select: {
id: true,
sku: true,
name: true,
type: true,
isPurchasable: true,
},
},
},
},
orderBy: [{ sequence: "asc" }],
},
item: {
select: {
sku: true,
name: true,
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
},
},
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
operations: {
select: {
id: true,
sequence: true,
plannedStart: true,
plannedEnd: true,
plannedMinutes: true,
station: { select: { id: true, code: true, name: true } },
},
orderBy: [{ sequence: "asc" }],
},
materialIssues: { select: { componentItemId: true, quantity: true } },
},
},
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
});
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
}),
]);
const standaloneWorkOrders = await prisma.workOrder.findMany({
where: {
projectId: null,
status: {
notIn: ["COMPLETE", "CANCELLED"],
},
},
include: {
item: {
select: {
sku: true,
name: true,
},
},
operations: {
select: {
id: true,
sequence: true,
plannedStart: true,
plannedEnd: true,
plannedMinutes: true,
station: {
select: {
code: true,
name: true,
},
},
},
orderBy: [{ sequence: "asc" }],
},
},
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
const planningProjects = projects as PlanningProjectRecord[];
const openWorkOrders = workOrders as PlanningWorkOrderRecord[];
const workOrdersByProjectId = new Map<string, PlanningWorkOrderRecord[]>();
const standaloneWorkOrders: PlanningWorkOrderRecord[] = [];
for (const workOrder of openWorkOrders) {
if (workOrder.projectId) {
const existing = workOrdersByProjectId.get(workOrder.projectId) ?? [];
existing.push(workOrder);
workOrdersByProjectId.set(workOrder.projectId, existing);
} else {
standaloneWorkOrders.push(workOrder);
}
}
const componentItemIds = [...new Set(openWorkOrders.flatMap((workOrder) => workOrder.item.bomLines.map((line) => line.componentItem.id)))];
const warehouseIds = [...new Set(openWorkOrders.map((workOrder) => workOrder.warehouseId))];
const locationIds = [...new Set(openWorkOrders.map((workOrder) => workOrder.locationId))];
const [transactions, reservations, supplyWorkOrders, purchaseOrderLines] = componentItemIds.length > 0
? await Promise.all([
prisma.inventoryTransaction.findMany({
where: { itemId: { in: componentItemIds }, warehouseId: { in: warehouseIds }, locationId: { in: locationIds } },
select: { itemId: true, warehouseId: true, locationId: true, transactionType: true, quantity: true },
}),
prisma.inventoryReservation.findMany({
where: { itemId: { in: componentItemIds }, warehouseId: { in: warehouseIds }, locationId: { in: locationIds }, status: "ACTIVE" },
select: { itemId: true, warehouseId: true, locationId: true, quantity: true },
}),
prisma.workOrder.findMany({
where: { itemId: { in: componentItemIds }, status: { notIn: ["COMPLETE", "CANCELLED"] } },
select: { itemId: true, quantity: true, completedQuantity: true },
}),
prisma.purchaseOrderLine.findMany({
where: { itemId: { in: componentItemIds }, purchaseOrder: { status: { not: "CLOSED" } } },
select: { itemId: true, quantity: true, receiptLines: { select: { quantity: true } } },
}),
])
: [[], [], [], []];
const availabilityByKey = new Map<string, { onHandQuantity: number; reservedQuantity: number }>();
for (const transaction of transactions) {
const key = getAvailabilityKey(transaction.itemId, transaction.warehouseId, transaction.locationId);
const current = availabilityByKey.get(key) ?? { onHandQuantity: 0, reservedQuantity: 0 };
current.onHandQuantity += transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity;
availabilityByKey.set(key, current);
}
for (const reservation of reservations) {
if (!reservation.itemId || !reservation.warehouseId || !reservation.locationId) {
continue;
}
const key = getAvailabilityKey(reservation.itemId, reservation.warehouseId, reservation.locationId);
const current = availabilityByKey.get(key) ?? { onHandQuantity: 0, reservedQuantity: 0 };
current.reservedQuantity += reservation.quantity;
availabilityByKey.set(key, current);
}
const openWorkSupplyByItemId = new Map<string, number>();
for (const workOrder of supplyWorkOrders) {
openWorkSupplyByItemId.set(workOrder.itemId, (openWorkSupplyByItemId.get(workOrder.itemId) ?? 0) + Math.max(workOrder.quantity - workOrder.completedQuantity, 0));
}
const openPurchaseSupplyByItemId = new Map<string, number>();
for (const line of purchaseOrderLines) {
const remainingQuantity = Math.max(line.quantity - line.receiptLines.reduce((sum, receiptLine) => sum + receiptLine.quantity, 0), 0);
openPurchaseSupplyByItemId.set(line.itemId, (openPurchaseSupplyByItemId.get(line.itemId) ?? 0) + remainingQuantity);
}
const workOrderInsights = new Map<string, WorkOrderInsight>();
for (const workOrder of openWorkOrders) {
const issuedByComponent = new Map<string, number>();
for (const issue of workOrder.materialIssues) {
issuedByComponent.set(issue.componentItemId, (issuedByComponent.get(issue.componentItemId) ?? 0) + issue.quantity);
}
let shortageItemCount = 0;
let totalShortageQuantity = 0;
let openSupplyQuantity = 0;
let firstShortageAction: PlanningTaskActionDto | null = null;
for (const line of workOrder.item.bomLines) {
const key = getAvailabilityKey(line.componentItem.id, workOrder.warehouseId, workOrder.locationId);
const availability = availabilityByKey.get(key) ?? { onHandQuantity: 0, reservedQuantity: 0 };
const availableQuantity = availability.onHandQuantity - availability.reservedQuantity;
const requiredQuantity = line.quantity * workOrder.quantity;
const issuedQuantity = issuedByComponent.get(line.componentItem.id) ?? 0;
const shortageQuantity = Math.max(requiredQuantity - issuedQuantity - Math.max(availableQuantity, 0), 0);
if (shortageQuantity <= 0) {
continue;
}
shortageItemCount += 1;
totalShortageQuantity += shortageQuantity;
const openSupplyForItem = (openWorkSupplyByItemId.get(line.componentItem.id) ?? 0) + (openPurchaseSupplyByItemId.get(line.componentItem.id) ?? 0);
openSupplyQuantity += openSupplyForItem;
if (!firstShortageAction) {
if (shouldBuyItem(line.componentItem.type, line.componentItem.isPurchasable) && workOrder.salesOrderId) {
firstShortageAction = {
kind: "CREATE_PURCHASE_ORDER",
label: `Buy ${line.componentItem.sku}`,
href: `/purchasing/orders/new${encodeQuery({ planningOrderId: workOrder.salesOrderId, itemId: line.componentItem.id })}`,
itemId: line.componentItem.id,
};
} else if (isBuildItem(line.componentItem.type)) {
firstShortageAction = {
kind: "CREATE_WORK_ORDER",
label: `Build ${line.componentItem.sku}`,
href: `/manufacturing/work-orders/new${encodeQuery({
projectId: workOrder.projectId,
itemId: line.componentItem.id,
salesOrderId: workOrder.salesOrderId,
salesOrderLineId: workOrder.salesOrderLineId,
quantity: Math.ceil(shortageQuantity).toString(),
status: "DRAFT",
notes: `Workbench follow-through from ${workOrder.workOrderNumber}`,
})}`,
itemId: line.componentItem.id,
};
}
}
}
let readinessState: PlanningReadinessState = "READY";
let readinessScore = 90;
let blockedReason: string | null = null;
if (workOrder.status === "ON_HOLD") {
readinessState = "BLOCKED";
readinessScore = 15;
blockedReason = "Work order is on hold.";
} else if (!workOrder.dueDate) {
readinessState = "UNSCHEDULED";
readinessScore = 25;
blockedReason = "Work order has no due date.";
} else if (totalShortageQuantity > 0 && openSupplyQuantity > 0) {
readinessState = "PENDING_SUPPLY";
readinessScore = 55;
blockedReason = "Material is short but open supply exists.";
} else if (totalShortageQuantity > 0) {
readinessState = "SHORTAGE";
readinessScore = 30;
blockedReason = "Material shortage blocks release or execution.";
} else if (workOrder.status === "DRAFT") {
readinessScore = 80;
}
const releaseReady = workOrder.status === "DRAFT" && totalShortageQuantity === 0 && workOrder.dueDate !== null;
const actions: PlanningTaskActionDto[] = [{ kind: "OPEN_RECORD", label: "Open record", href: `/manufacturing/work-orders/${workOrder.id}`, workOrderId: workOrder.id }];
if (releaseReady) {
actions.push({ kind: "RELEASE_WORK_ORDER", label: "Release work order", workOrderId: workOrder.id });
}
if (firstShortageAction) {
actions.push(firstShortageAction);
}
workOrderInsights.set(workOrder.id, {
readinessState,
readinessScore,
shortageItemCount,
totalShortageQuantity,
linkedSupplyQuantity: 0,
openSupplyQuantity,
releaseReady,
blockedReason,
overdue: workOrder.dueDate ? workOrder.dueDate.getTime() < now.getTime() : false,
actions,
});
}
const stationAccumulators = new Map<string, StationAccumulator>();
for (const workOrder of openWorkOrders) {
const insight = workOrderInsights.get(workOrder.id);
for (const operation of workOrder.operations) {
const current = stationAccumulators.get(operation.station.id) ?? {
stationId: operation.station.id,
stationCode: operation.station.code,
stationName: operation.station.name,
operationCount: 0,
workOrderIds: new Set<string>(),
totalPlannedMinutes: 0,
blockedCount: 0,
readyCount: 0,
lateCount: 0,
dayKeys: new Set<string>(),
};
current.operationCount += 1;
current.workOrderIds.add(workOrder.id);
current.totalPlannedMinutes += operation.plannedMinutes;
if (insight?.readinessState === "BLOCKED" || insight?.readinessState === "SHORTAGE" || insight?.readinessState === "PENDING_SUPPLY") {
current.blockedCount += 1;
}
if (insight?.releaseReady || insight?.readinessState === "READY") {
current.readyCount += 1;
}
if (insight?.overdue) {
current.lateCount += 1;
}
for (let cursor = startOfDay(operation.plannedStart).getTime(); cursor <= startOfDay(operation.plannedEnd).getTime(); cursor += DAY_MS) {
current.dayKeys.add(dateKey(new Date(cursor)));
}
stationAccumulators.set(operation.station.id, current);
}
}
const stationLoads = [...stationAccumulators.values()].map(createStationLoad).sort((left, right) => {
if (right.utilizationPercent !== left.utilizationPercent) {
return right.utilizationPercent - left.utilizationPercent;
}
return left.stationCode.localeCompare(right.stationCode);
});
const stationLoadById = new Map(stationLoads.map((load) => [load.stationId, load]));
const tasks: GanttTaskDto[] = [];
const links: GanttLinkDto[] = [];
const exceptions: PlanningTimelineDto["exceptions"] = [];
for (const project of planningProjects) {
const ownerName = project.owner ? `${project.owner.firstName} ${project.owner.lastName}`.trim() : null;
const ownerLabel = buildOwnerLabel(ownerName, project.customer.name);
const dueDates = project.workOrders.map((workOrder) => workOrder.dueDate).filter((value): value is Date => Boolean(value));
const earliestWorkStart = project.workOrders[0]?.createdAt ?? project.createdAt;
const lastDueDate = dueDates.sort((left, right) => left.getTime() - right.getTime()).at(-1) ?? project.dueDate ?? addDays(project.createdAt, 14);
const start = startOfDay(earliestWorkStart);
const end = endOfDay(lastDueDate);
tasks.push({
id: `project-${project.id}`,
text: `${project.projectNumber} - ${project.name}`,
start: start.toISOString(),
end: end.toISOString(),
progress: clampProgress(
project.workOrders.length > 0
? project.workOrders.reduce((sum, workOrder) => sum + workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), 0) / project.workOrders.length
: projectProgressFromStatus(project.status)
),
type: "project",
status: project.status,
ownerLabel,
detailHref: `/projects/${project.id}`,
});
const projectWorkOrders = workOrdersByProjectId.get(project.id) ?? [];
const projectTask = buildProjectTask(project, projectWorkOrders, workOrderInsights, now);
tasks.push(projectTask);
if (project.dueDate) {
tasks.push({
@@ -190,56 +535,67 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
progress: project.status === "COMPLETE" ? 100 : 0,
type: "milestone",
parentId: `project-${project.id}`,
entityId: project.id,
projectId: project.id,
status: project.status,
ownerLabel,
ownerLabel: projectTask.ownerLabel ?? null,
detailHref: `/projects/${project.id}`,
readinessState: projectTask.readinessState,
readinessScore: projectTask.readinessScore,
shortageItemCount: projectTask.shortageItemCount,
totalShortageQuantity: projectTask.totalShortageQuantity,
releaseReady: projectTask.releaseReady,
overdue: project.dueDate.getTime() < now.getTime(),
actions: [{ kind: "OPEN_RECORD", label: "Open record", href: `/projects/${project.id}` }],
});
links.push({
id: `project-link-${project.id}`,
source: `project-${project.id}`,
target: `project-milestone-${project.id}`,
type: "e2e",
});
links.push({ id: `project-link-${project.id}`, source: `project-${project.id}`, target: `project-milestone-${project.id}`, type: "e2e" });
}
let previousTaskId: string | null = null;
for (const workOrder of project.workOrders) {
const workOrderStart = startOfDay(workOrder.createdAt);
const workOrderEnd = endOfDay(workOrder.dueDate ?? addDays(workOrder.createdAt, 7));
for (const workOrder of projectWorkOrders) {
const insight = workOrderInsights.get(workOrder.id)!;
const workOrderTaskId = `work-order-${workOrder.id}`;
tasks.push({
id: workOrderTaskId,
text: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
start: workOrderStart.toISOString(),
end: workOrderEnd.toISOString(),
start: startOfDay(workOrder.createdAt).toISOString(),
end: endOfDay(workOrder.dueDate ?? addDays(workOrder.createdAt, 7)).toISOString(),
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
type: "task",
parentId: `project-${project.id}`,
entityId: workOrder.id,
projectId: project.id,
workOrderId: workOrder.id,
salesOrderId: workOrder.salesOrderId,
salesOrderLineId: workOrder.salesOrderLineId,
itemId: workOrder.item.id,
itemSku: workOrder.item.sku,
status: workOrder.status,
ownerLabel: workOrder.item.name,
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
readinessState: insight.readinessState,
readinessScore: insight.readinessScore,
shortageItemCount: insight.shortageItemCount,
totalShortageQuantity: insight.totalShortageQuantity,
linkedSupplyQuantity: insight.linkedSupplyQuantity,
openSupplyQuantity: insight.openSupplyQuantity,
releaseReady: insight.releaseReady,
overdue: insight.overdue,
blockedReason: insight.blockedReason,
loadMinutes: workOrder.operations.reduce((sum, operation) => sum + operation.plannedMinutes, 0),
actions: insight.actions,
});
if (previousTaskId) {
links.push({
id: `sequence-${previousTaskId}-${workOrderTaskId}`,
source: previousTaskId,
target: workOrderTaskId,
type: "e2e",
});
links.push({ id: `sequence-${previousTaskId}-${workOrderTaskId}`, source: previousTaskId, target: workOrderTaskId, type: "e2e" });
} else {
links.push({
id: `project-start-${project.id}-${workOrder.id}`,
source: `project-${project.id}`,
target: workOrderTaskId,
type: "e2e",
});
links.push({ id: `project-start-${project.id}-${workOrder.id}`, source: `project-${project.id}`, target: workOrderTaskId, type: "e2e" });
}
previousTaskId = workOrderTaskId;
let previousOperationTaskId: string | null = null;
for (const operation of workOrder.operations) {
const stationLoad = stationLoadById.get(operation.station.id) ?? null;
const operationTaskId = `work-order-operation-${operation.id}`;
tasks.push({
id: operationTaskId,
@@ -249,18 +605,34 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
type: "task",
parentId: workOrderTaskId,
entityId: operation.id,
projectId: project.id,
workOrderId: workOrder.id,
salesOrderId: workOrder.salesOrderId,
salesOrderLineId: workOrder.salesOrderLineId,
itemId: workOrder.item.id,
itemSku: workOrder.item.sku,
stationId: operation.station.id,
stationCode: operation.station.code,
stationName: operation.station.name,
status: workOrder.status,
ownerLabel: workOrder.workOrderNumber,
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
readinessState: insight.readinessState,
readinessScore: insight.readinessScore,
shortageItemCount: insight.shortageItemCount,
totalShortageQuantity: insight.totalShortageQuantity,
linkedSupplyQuantity: insight.linkedSupplyQuantity,
openSupplyQuantity: insight.openSupplyQuantity,
releaseReady: insight.releaseReady,
overdue: insight.overdue || operation.plannedEnd.getTime() < now.getTime(),
blockedReason: insight.blockedReason,
loadMinutes: operation.plannedMinutes,
capacityMinutes: stationLoad?.capacityMinutes ?? null,
utilizationPercent: stationLoad?.utilizationPercent ?? null,
actions: insight.actions,
});
links.push({
id: `work-order-operation-parent-${workOrder.id}-${operation.id}`,
source: workOrderTaskId,
target: operationTaskId,
type: "e2e",
});
links.push({ id: `work-order-operation-parent-${workOrder.id}-${operation.id}`, source: workOrderTaskId, target: operationTaskId, type: "e2e" });
if (previousOperationTaskId) {
links.push({
id: `work-order-operation-sequence-${previousOperationTaskId}-${operationTaskId}`,
@@ -269,41 +641,30 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
type: "e2e",
});
}
previousOperationTaskId = operationTaskId;
}
if (workOrder.dueDate && workOrder.dueDate.getTime() < now.getTime()) {
if (insight.overdue || insight.readinessState === "BLOCKED" || insight.readinessState === "SHORTAGE" || insight.readinessState === "PENDING_SUPPLY") {
exceptions.push({
id: `work-order-${workOrder.id}`,
kind: "WORK_ORDER",
title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
status: workOrder.status,
dueDate: workOrder.dueDate.toISOString(),
status: insight.readinessState === "READY" ? workOrder.status : insight.readinessState,
dueDate: workOrder.dueDate ? workOrder.dueDate.toISOString() : null,
ownerLabel: project.projectNumber,
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
}
}
if (project.dueDate && project.dueDate.getTime() < now.getTime()) {
exceptions.push({
id: `project-${project.id}`,
kind: "PROJECT",
title: `${project.projectNumber} - ${project.name}`,
status: project.status,
dueDate: project.dueDate.toISOString(),
ownerLabel,
detailHref: `/projects/${project.id}`,
});
} else if (project.status === "AT_RISK") {
if ((project.dueDate && project.dueDate.getTime() < now.getTime()) || project.status === "AT_RISK") {
exceptions.push({
id: `project-${project.id}`,
kind: "PROJECT",
title: `${project.projectNumber} - ${project.name}`,
status: project.status,
dueDate: project.dueDate ? project.dueDate.toISOString() : null,
ownerLabel,
ownerLabel: projectTask.ownerLabel ?? null,
detailHref: `/projects/${project.id}`,
});
}
@@ -315,14 +676,13 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
standaloneWorkOrders.reduce((earliest, workOrder) => (workOrder.createdAt < earliest ? workOrder.createdAt : earliest), firstStandaloneWorkOrder.createdAt)
);
const bucketEnd = endOfDay(
standaloneWorkOrders.reduce(
(latest, workOrder) => {
const candidate = workOrder.dueDate ?? addDays(workOrder.createdAt, 7);
return candidate > latest ? candidate : latest;
},
firstStandaloneWorkOrder.dueDate ?? addDays(firstStandaloneWorkOrder.createdAt, 7)
)
standaloneWorkOrders.reduce((latest, workOrder) => {
const candidate = workOrder.dueDate ?? addDays(workOrder.createdAt, 7);
return candidate > latest ? candidate : latest;
}, firstStandaloneWorkOrder.dueDate ?? addDays(firstStandaloneWorkOrder.createdAt, 7))
);
const standaloneInsights = standaloneWorkOrders.map((workOrder) => workOrderInsights.get(workOrder.id)).filter(Boolean) as WorkOrderInsight[];
tasks.push({
id: "standalone-manufacturing",
text: "Standalone Manufacturing Queue",
@@ -336,10 +696,19 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
status: "ACTIVE",
ownerLabel: "Manufacturing",
detailHref: "/manufacturing/work-orders",
readinessState:
standaloneInsights.some((entry) => entry.readinessState === "BLOCKED") ? "BLOCKED"
: standaloneInsights.some((entry) => entry.readinessState === "SHORTAGE") ? "SHORTAGE"
: standaloneInsights.some((entry) => entry.readinessState === "PENDING_SUPPLY") ? "PENDING_SUPPLY"
: standaloneInsights.some((entry) => entry.readinessState === "UNSCHEDULED") ? "UNSCHEDULED"
: "READY",
readinessScore: clampProgress(standaloneInsights.reduce((sum, entry) => sum + entry.readinessScore, 0) / standaloneInsights.length),
actions: [{ kind: "OPEN_RECORD", label: "Open record", href: "/manufacturing/work-orders" }],
});
let previousStandaloneTaskId: string | null = null;
for (const workOrder of standaloneWorkOrders) {
const insight = workOrderInsights.get(workOrder.id)!;
const workOrderTaskId = `work-order-${workOrder.id}`;
tasks.push({
id: workOrderTaskId,
@@ -349,24 +718,35 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
type: "task",
parentId: "standalone-manufacturing",
entityId: workOrder.id,
workOrderId: workOrder.id,
salesOrderId: workOrder.salesOrderId,
salesOrderLineId: workOrder.salesOrderLineId,
itemId: workOrder.item.id,
itemSku: workOrder.item.sku,
status: workOrder.status,
ownerLabel: workOrder.item.name,
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
readinessState: insight.readinessState,
readinessScore: insight.readinessScore,
shortageItemCount: insight.shortageItemCount,
totalShortageQuantity: insight.totalShortageQuantity,
linkedSupplyQuantity: insight.linkedSupplyQuantity,
openSupplyQuantity: insight.openSupplyQuantity,
releaseReady: insight.releaseReady,
overdue: insight.overdue,
blockedReason: insight.blockedReason,
loadMinutes: workOrder.operations.reduce((sum, operation) => sum + operation.plannedMinutes, 0),
actions: insight.actions,
});
if (previousStandaloneTaskId) {
links.push({
id: `sequence-${previousStandaloneTaskId}-${workOrderTaskId}`,
source: previousStandaloneTaskId,
target: workOrderTaskId,
type: "e2e",
});
links.push({ id: `sequence-${previousStandaloneTaskId}-${workOrderTaskId}`, source: previousStandaloneTaskId, target: workOrderTaskId, type: "e2e" });
}
previousStandaloneTaskId = workOrderTaskId;
let previousOperationTaskId: string | null = null;
for (const operation of workOrder.operations) {
const stationLoad = stationLoadById.get(operation.station.id) ?? null;
const operationTaskId = `work-order-operation-${operation.id}`;
tasks.push({
id: operationTaskId,
@@ -376,18 +756,33 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
type: "task",
parentId: workOrderTaskId,
entityId: operation.id,
workOrderId: workOrder.id,
salesOrderId: workOrder.salesOrderId,
salesOrderLineId: workOrder.salesOrderLineId,
itemId: workOrder.item.id,
itemSku: workOrder.item.sku,
stationId: operation.station.id,
stationCode: operation.station.code,
stationName: operation.station.name,
status: workOrder.status,
ownerLabel: workOrder.workOrderNumber,
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
readinessState: insight.readinessState,
readinessScore: insight.readinessScore,
shortageItemCount: insight.shortageItemCount,
totalShortageQuantity: insight.totalShortageQuantity,
linkedSupplyQuantity: insight.linkedSupplyQuantity,
openSupplyQuantity: insight.openSupplyQuantity,
releaseReady: insight.releaseReady,
overdue: insight.overdue || operation.plannedEnd.getTime() < now.getTime(),
blockedReason: insight.blockedReason,
loadMinutes: operation.plannedMinutes,
capacityMinutes: stationLoad?.capacityMinutes ?? null,
utilizationPercent: stationLoad?.utilizationPercent ?? null,
actions: insight.actions,
});
links.push({
id: `work-order-operation-parent-${workOrder.id}-${operation.id}`,
source: workOrderTaskId,
target: operationTaskId,
type: "e2e",
});
links.push({ id: `work-order-operation-parent-${workOrder.id}-${operation.id}`, source: workOrderTaskId, target: operationTaskId, type: "e2e" });
if (previousOperationTaskId) {
links.push({
id: `work-order-operation-sequence-${previousOperationTaskId}-${operationTaskId}`,
@@ -396,27 +791,16 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
type: "e2e",
});
}
previousOperationTaskId = operationTaskId;
}
if (workOrder.dueDate === null) {
if (workOrder.dueDate === null || insight.overdue || insight.readinessState === "BLOCKED" || insight.readinessState === "SHORTAGE" || insight.readinessState === "PENDING_SUPPLY") {
exceptions.push({
id: `work-order-unscheduled-${workOrder.id}`,
id: workOrder.dueDate === null ? `work-order-unscheduled-${workOrder.id}` : `work-order-${workOrder.id}`,
kind: "WORK_ORDER",
title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
status: workOrder.status,
dueDate: null,
ownerLabel: "No project",
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
} else if (workOrder.dueDate.getTime() < now.getTime()) {
exceptions.push({
id: `work-order-${workOrder.id}`,
kind: "WORK_ORDER",
title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
status: workOrder.status,
dueDate: workOrder.dueDate.toISOString(),
status: workOrder.dueDate === null ? "UNSCHEDULED" : insight.readinessState === "READY" ? workOrder.status : insight.readinessState,
dueDate: workOrder.dueDate ? workOrder.dueDate.toISOString() : null,
ownerLabel: "No project",
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
@@ -435,26 +819,27 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
activeProjects: planningProjects.filter((project) => project.status === "ACTIVE").length,
atRiskProjects: planningProjects.filter((project) => project.status === "AT_RISK").length,
overdueProjects: planningProjects.filter((project) => project.dueDate && project.dueDate.getTime() < now.getTime()).length,
activeWorkOrders: [...planningProjects.flatMap((project) => project.workOrders), ...standaloneWorkOrders].filter((workOrder) =>
["RELEASED", "IN_PROGRESS", "ON_HOLD"].includes(workOrder.status)
).length,
overdueWorkOrders: [...planningProjects.flatMap((project) => project.workOrders), ...standaloneWorkOrders].filter(
(workOrder) => workOrder.dueDate && workOrder.dueDate.getTime() < now.getTime()
).length,
unscheduledWorkOrders: standaloneWorkOrders.filter((workOrder) => workOrder.dueDate === null).length,
activeWorkOrders: openWorkOrders.filter((workOrder) => ["RELEASED", "IN_PROGRESS", "ON_HOLD"].includes(workOrder.status)).length,
overdueWorkOrders: openWorkOrders.filter((workOrder) => workOrder.dueDate && workOrder.dueDate.getTime() < now.getTime()).length,
unscheduledWorkOrders: openWorkOrders.filter((workOrder) => workOrder.dueDate === null).length,
releaseReadyWorkOrders: [...workOrderInsights.values()].filter((insight) => insight.releaseReady).length,
blockedWorkOrders: [...workOrderInsights.values()].filter((insight) => insight.readinessState !== "READY").length,
stationCount: stationLoads.length,
overloadedStations: stationLoads.filter((station) => station.overloaded).length,
horizonStart: horizonStart.toISOString(),
horizonEnd: horizonEnd.toISOString(),
},
exceptions: exceptions
.sort((left, right) => {
if (!left.dueDate) {
return 1;
return -1;
}
if (!right.dueDate) {
return -1;
return 1;
}
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
})
.slice(0, 12),
stationLoads,
};
}