planning payload
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user