reliability semi-fix
This commit is contained in:
@@ -111,26 +111,55 @@
|
||||
"overview.recap.cta": "Open daily recap",
|
||||
"recap.title": "Recap",
|
||||
"recap.subtitle": "Last 24h",
|
||||
"recap.grid.title": "Machine recap",
|
||||
"recap.grid.subtitle": "Last 24h · click to open details",
|
||||
"recap.grid.updatedAgo": "Updated {sec}s ago",
|
||||
"recap.grid.empty": "No machines match the current filters.",
|
||||
"recap.detail.back": "All machines",
|
||||
"recap.allMachines": "All machines",
|
||||
"recap.filter.allLocations": "All locations",
|
||||
"recap.filter.allStatuses": "All statuses",
|
||||
"recap.status.running": "Running",
|
||||
"recap.status.moldChange": "Mold change",
|
||||
"recap.status.stopped": "Stopped",
|
||||
"recap.status.offline": "Offline",
|
||||
"recap.range.24h": "24h",
|
||||
"recap.range.shift": "Shift",
|
||||
"recap.range.custom": "Custom range",
|
||||
"recap.range.shiftCurrent": "Current shift",
|
||||
"recap.range.yesterday": "Yesterday",
|
||||
"recap.range.custom": "Custom",
|
||||
"recap.range.apply": "Apply",
|
||||
"recap.shift.1": "Shift 1",
|
||||
"recap.shift.2": "Shift 2",
|
||||
"recap.shift.3": "Shift 3",
|
||||
"recap.kpi.oee": "Avg OEE",
|
||||
"recap.kpi.oee": "OEE",
|
||||
"recap.kpi.noData": "No KPI data",
|
||||
"recap.kpi.good": "Good parts",
|
||||
"recap.kpi.stops": "Total stops",
|
||||
"recap.kpi.stops": "Total stops (min)",
|
||||
"recap.kpi.scrap": "Scrap",
|
||||
"recap.card.oee": "OEE",
|
||||
"recap.card.good": "Good parts",
|
||||
"recap.card.scrap": "Scrap",
|
||||
"recap.card.stops": "Stops",
|
||||
"recap.card.noProduction": "No production",
|
||||
"recap.card.lastActivity": "Last activity {min} min ago",
|
||||
"recap.card.activeWorkOrder": "Active WO: {id}",
|
||||
"recap.card.moldChangeActive": "Mold change in progress · {min}m",
|
||||
"recap.card.desynced": "CT desynchronized",
|
||||
"recap.production.title": "Production by SKU",
|
||||
"recap.production.bySku": "Production by SKU",
|
||||
"recap.production.sku": "SKU",
|
||||
"recap.production.good": "Good",
|
||||
"recap.production.scrap": "Scrap",
|
||||
"recap.production.target": "Target",
|
||||
"recap.production.progress": "Progress",
|
||||
"recap.production.progress": "Progress%",
|
||||
"recap.downtime.title": "Top downtime",
|
||||
"recap.downtime.top": "Top stops",
|
||||
"recap.workOrders.title": "Work orders",
|
||||
"recap.workOrders.active": "Active",
|
||||
"recap.workOrders.completed": "Completed",
|
||||
"recap.workOrders.none": "No production recorded",
|
||||
"recap.workOrders.sku": "SKU",
|
||||
"recap.workOrders.startedAt": "Started",
|
||||
"recap.workOrders.goodParts": "Good parts",
|
||||
"recap.workOrders.duration": "Duration",
|
||||
@@ -138,10 +167,22 @@
|
||||
"recap.machine.running": "Running",
|
||||
"recap.machine.stopped": "Stopped",
|
||||
"recap.machine.mold": "Mold change",
|
||||
"recap.machine.online": "Connected",
|
||||
"recap.machine.offline": "Disconnected",
|
||||
"recap.machine.lastHeartbeat": "Last heartbeat",
|
||||
"recap.machine.uptime": "Uptime",
|
||||
"recap.banner.mold": "Mold change in progress since",
|
||||
"recap.banner.moldChange": "Mold change in progress since {time}",
|
||||
"recap.banner.offline": "No signal for {min} min",
|
||||
"recap.banner.ongoingStop": "Machine stopped for {min} min",
|
||||
"recap.banner.stopped": "Machine stopped for {minutes} min",
|
||||
"recap.timeline.title": "24h timeline",
|
||||
"recap.timeline.noData": "No timeline data",
|
||||
"recap.timeline.type.production": "Production",
|
||||
"recap.timeline.type.moldChange": "Mold change",
|
||||
"recap.timeline.type.macrostop": "Macrostop",
|
||||
"recap.timeline.type.microstop": "Microstop",
|
||||
"recap.timeline.type.idle": "Idle",
|
||||
"recap.empty.production": "No production recorded",
|
||||
"machines.title": "Machines",
|
||||
"machines.subtitle": "Select a machine to view live KPIs.",
|
||||
|
||||
@@ -111,37 +111,78 @@
|
||||
"overview.recap.cta": "Abrir resumen diario",
|
||||
"recap.title": "Resumen",
|
||||
"recap.subtitle": "Últimas 24h",
|
||||
"recap.grid.title": "Resumen de máquinas",
|
||||
"recap.grid.subtitle": "Últimas 24h · click para ver detalle",
|
||||
"recap.grid.updatedAgo": "Actualizado hace {sec}s",
|
||||
"recap.grid.empty": "No hay máquinas que coincidan con los filtros.",
|
||||
"recap.detail.back": "Todas las máquinas",
|
||||
"recap.allMachines": "Todas las máquinas",
|
||||
"recap.filter.allLocations": "Todas las ubicaciones",
|
||||
"recap.filter.allStatuses": "Todos los estados",
|
||||
"recap.status.running": "En marcha",
|
||||
"recap.status.moldChange": "Cambio de molde",
|
||||
"recap.status.stopped": "Detenida",
|
||||
"recap.status.offline": "Sin señal",
|
||||
"recap.range.24h": "24h",
|
||||
"recap.range.shift": "Turno",
|
||||
"recap.range.custom": "Rango personalizado",
|
||||
"recap.range.shiftCurrent": "Turno actual",
|
||||
"recap.range.yesterday": "Ayer",
|
||||
"recap.range.custom": "Personalizado",
|
||||
"recap.range.apply": "Aplicar",
|
||||
"recap.shift.1": "Turno 1",
|
||||
"recap.shift.2": "Turno 2",
|
||||
"recap.shift.3": "Turno 3",
|
||||
"recap.kpi.oee": "OEE prom",
|
||||
"recap.kpi.good": "Piezas buenas",
|
||||
"recap.kpi.stops": "Paros totales",
|
||||
"recap.kpi.oee": "OEE",
|
||||
"recap.kpi.noData": "Sin datos de KPI",
|
||||
"recap.kpi.good": "Buenas",
|
||||
"recap.kpi.stops": "Paros totales (min)",
|
||||
"recap.kpi.scrap": "Scrap",
|
||||
"recap.card.oee": "OEE",
|
||||
"recap.card.good": "Piezas buenas",
|
||||
"recap.card.scrap": "Scrap",
|
||||
"recap.card.stops": "Paros",
|
||||
"recap.card.noProduction": "Sin producción",
|
||||
"recap.card.lastActivity": "Última actividad hace {min} min",
|
||||
"recap.card.activeWorkOrder": "WO activa: {id}",
|
||||
"recap.card.moldChangeActive": "Cambio de molde en curso · {min}m",
|
||||
"recap.card.desynced": "CT desincronizado",
|
||||
"recap.production.title": "Producción por SKU",
|
||||
"recap.production.bySku": "Producción por SKU",
|
||||
"recap.production.sku": "SKU",
|
||||
"recap.production.good": "Buenas",
|
||||
"recap.production.scrap": "Scrap",
|
||||
"recap.production.target": "Meta",
|
||||
"recap.production.progress": "Avance",
|
||||
"recap.production.progress": "Avance%",
|
||||
"recap.downtime.title": "Top downtime",
|
||||
"recap.downtime.top": "Top paros",
|
||||
"recap.workOrders.title": "Órdenes de trabajo",
|
||||
"recap.workOrders.active": "Activa",
|
||||
"recap.workOrders.completed": "Completadas",
|
||||
"recap.workOrders.none": "Sin producción registrada",
|
||||
"recap.workOrders.sku": "SKU",
|
||||
"recap.workOrders.startedAt": "Inicio",
|
||||
"recap.workOrders.goodParts": "Buenas",
|
||||
"recap.workOrders.duration": "Duración",
|
||||
"recap.machine.title": "Estado de máquina",
|
||||
"recap.machine.title": "Estado máquina",
|
||||
"recap.machine.running": "En marcha",
|
||||
"recap.machine.stopped": "Detenida",
|
||||
"recap.machine.mold": "Cambio de molde",
|
||||
"recap.machine.online": "Conectada",
|
||||
"recap.machine.offline": "Sin conexión",
|
||||
"recap.machine.lastHeartbeat": "Último heartbeat",
|
||||
"recap.machine.uptime": "Uptime",
|
||||
"recap.banner.mold": "Cambio de molde en curso desde",
|
||||
"recap.banner.moldChange": "Cambio de molde en curso desde {time}",
|
||||
"recap.banner.offline": "Sin señal hace {min} min",
|
||||
"recap.banner.ongoingStop": "Máquina detenida hace {min} min",
|
||||
"recap.banner.stopped": "Máquina detenida hace {minutes} min",
|
||||
"recap.timeline.title": "Timeline 24h",
|
||||
"recap.timeline.noData": "Sin datos de línea de tiempo",
|
||||
"recap.timeline.type.production": "Producción",
|
||||
"recap.timeline.type.moldChange": "Cambio de molde",
|
||||
"recap.timeline.type.macrostop": "Macroparo",
|
||||
"recap.timeline.type.microstop": "Microparo",
|
||||
"recap.timeline.type.idle": "Idle",
|
||||
"recap.empty.production": "Sin producción registrada",
|
||||
"machines.title": "Máquinas",
|
||||
"machines.subtitle": "Selecciona una máquina para ver KPIs en vivo.",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
|
||||
import type { RecapMachine, RecapQuery, RecapResponse } from "@/lib/recap/types";
|
||||
@@ -25,8 +26,9 @@ const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
|
||||
|
||||
const STOP_TYPES = new Set(["microstop", "macrostop"]);
|
||||
const STOP_STATUS = new Set(["STOP", "DOWN", "OFFLINE"]);
|
||||
const CACHE_TTL_SEC = 180;
|
||||
const CACHE_TTL_SEC = 60;
|
||||
const MOLD_LOOKBACK_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
let workOrderCountersAvailable: boolean | null = null;
|
||||
|
||||
function safeNum(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
@@ -37,6 +39,17 @@ function safeNum(value: unknown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function safeBool(value: unknown) {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeToken(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
@@ -194,6 +207,14 @@ function eventStatus(data: unknown) {
|
||||
return String(inner.status ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isRealStopEvent(data: unknown) {
|
||||
const inner = extractEventData(data);
|
||||
const status = String(inner.status ?? "").trim().toLowerCase();
|
||||
const isUpdate = safeBool(inner.is_update ?? inner.isUpdate);
|
||||
const isAutoAck = safeBool(inner.is_auto_ack ?? inner.isAutoAck);
|
||||
return status !== "active" && !isUpdate && !isAutoAck;
|
||||
}
|
||||
|
||||
function eventIncidentKey(data: unknown, eventType: string, ts: Date) {
|
||||
const inner = extractEventData(data);
|
||||
const direct = String(inner.incidentKey ?? inner.incident_key ?? "").trim();
|
||||
@@ -208,9 +229,75 @@ function moldStartMs(data: unknown, fallbackTs: Date) {
|
||||
return Math.trunc(safeNum(inner.start_ms) ?? safeNum(inner.startMs) ?? fallbackTs.getTime());
|
||||
}
|
||||
|
||||
function avg(sum: number, count: number) {
|
||||
if (!count) return null;
|
||||
return round2(sum / count);
|
||||
type WorkOrderCounterColumnRow = {
|
||||
column_name: string;
|
||||
};
|
||||
|
||||
type WorkOrderCounterRow = {
|
||||
machineId: string;
|
||||
workOrderId: string;
|
||||
sku: string | null;
|
||||
targetQty: number | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
goodParts: number;
|
||||
scrapParts: number;
|
||||
cycleCount: number;
|
||||
};
|
||||
|
||||
async function loadWorkOrderCounterRows(params: {
|
||||
orgId: string;
|
||||
machineIds: string[];
|
||||
start: Date;
|
||||
end: Date;
|
||||
}) {
|
||||
if (!params.machineIds.length) return [] as WorkOrderCounterRow[];
|
||||
|
||||
try {
|
||||
if (workOrderCountersAvailable == null) {
|
||||
const columns = await prisma.$queryRaw<WorkOrderCounterColumnRow[]>`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'machine_work_orders'
|
||||
AND column_name IN ('good_parts', 'scrap_parts', 'cycle_count')
|
||||
`;
|
||||
const availableColumns = new Set(columns.map((row) => row.column_name));
|
||||
workOrderCountersAvailable =
|
||||
availableColumns.has("good_parts") &&
|
||||
availableColumns.has("scrap_parts") &&
|
||||
availableColumns.has("cycle_count");
|
||||
}
|
||||
|
||||
if (!workOrderCountersAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const machineIdList = Prisma.join(params.machineIds.map((id) => Prisma.sql`${id}`));
|
||||
const rows = await prisma.$queryRaw<WorkOrderCounterRow[]>(Prisma.sql`
|
||||
SELECT
|
||||
"machineId",
|
||||
"workOrderId",
|
||||
sku,
|
||||
"targetQty",
|
||||
status,
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
COALESCE(good_parts, 0)::int AS "goodParts",
|
||||
COALESCE(scrap_parts, 0)::int AS "scrapParts",
|
||||
COALESCE(cycle_count, 0)::int AS "cycleCount"
|
||||
FROM "machine_work_orders"
|
||||
WHERE "orgId" = ${params.orgId}
|
||||
AND "machineId" IN (${machineIdList})
|
||||
AND "updatedAt" >= ${params.start}
|
||||
AND "updatedAt" <= ${params.end}
|
||||
`);
|
||||
|
||||
return rows;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRecapQuery(input: {
|
||||
@@ -250,7 +337,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
|
||||
const machineIds = machines.map((m) => m.id);
|
||||
const moldStartLookback = new Date(params.end.getTime() - MOLD_LOOKBACK_MS);
|
||||
const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] =
|
||||
const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, workOrderCounterRowsRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] =
|
||||
await Promise.all([
|
||||
prisma.orgSettings.findUnique({
|
||||
where: { orgId: params.orgId },
|
||||
@@ -283,6 +370,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
machineId: { in: machineIds },
|
||||
ts: { gte: params.start, lte: params.end },
|
||||
},
|
||||
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||
select: {
|
||||
machineId: true,
|
||||
ts: true,
|
||||
@@ -344,6 +432,12 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
loadWorkOrderCounterRows({
|
||||
orgId: params.orgId,
|
||||
machineIds,
|
||||
start: params.start,
|
||||
end: params.end,
|
||||
}),
|
||||
prisma.machineHeartbeat.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
@@ -412,6 +506,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
const eventsByMachine = new Map<string, typeof events>();
|
||||
const reasonsByMachine = new Map<string, typeof reasons>();
|
||||
const workOrdersByMachine = new Map<string, typeof workOrdersRaw>();
|
||||
const workOrderCountersByMachine = new Map<string, WorkOrderCounterRow[]>();
|
||||
const hbRangeByMachine = new Map<string, typeof hbRange>();
|
||||
const hbLatestByMachine = new Map(hbLatestRaw.map((row) => [row.machineId, row]));
|
||||
const moldEventsByMachine = new Map<string, typeof moldEventsRaw>();
|
||||
@@ -446,6 +541,12 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
workOrdersByMachine.set(row.machineId, list);
|
||||
}
|
||||
|
||||
for (const row of workOrderCounterRowsRaw ?? []) {
|
||||
const list = workOrderCountersByMachine.get(row.machineId) ?? [];
|
||||
list.push(row);
|
||||
workOrderCountersByMachine.set(row.machineId, list);
|
||||
}
|
||||
|
||||
for (const row of hbRange) {
|
||||
const list = hbRangeByMachine.get(row.machineId) ?? [];
|
||||
list.push(row);
|
||||
@@ -464,6 +565,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
const machineEvents = eventsByMachine.get(machine.id) ?? [];
|
||||
const machineReasons = reasonsByMachine.get(machine.id) ?? [];
|
||||
const machineWorkOrders = workOrdersByMachine.get(machine.id) ?? [];
|
||||
const machineWorkOrderCounters = workOrderCountersByMachine.get(machine.id) ?? [];
|
||||
const machineHbRange = hbRangeByMachine.get(machine.id) ?? [];
|
||||
const latestHb = hbLatestByMachine.get(machine.id) ?? null;
|
||||
const machineMoldEvents = moldEventsByMachine.get(machine.id) ?? [];
|
||||
@@ -660,7 +762,63 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
}
|
||||
if (latestTelemetry?.sku) ensureSkuRow(latestTelemetry.sku);
|
||||
|
||||
const bySku = [...skuMap.values()]
|
||||
const hasAuthoritativeWorkOrderCounters = machineWorkOrderCounters.length > 0;
|
||||
const authoritativeWorkOrderProgress = new Map<
|
||||
string,
|
||||
{ goodParts: number; scrapParts: number; cycleCount: number; firstTs: Date | null; lastTs: Date | null }
|
||||
>();
|
||||
const authoritativeSkuMap = new Map<string, SkuAggregate>();
|
||||
let authoritativeGoodParts = 0;
|
||||
let authoritativeScrapParts = 0;
|
||||
let authoritativeCycleCount = 0;
|
||||
|
||||
if (hasAuthoritativeWorkOrderCounters) {
|
||||
for (const row of machineWorkOrderCounters) {
|
||||
const safeGood = Math.max(0, Math.trunc(safeNum(row.goodParts) ?? 0));
|
||||
const safeScrap = Math.max(0, Math.trunc(safeNum(row.scrapParts) ?? 0));
|
||||
const safeCycleCount = Math.max(0, Math.trunc(safeNum(row.cycleCount) ?? 0));
|
||||
const skuToken = normalizeToken(row.sku) || "N/A";
|
||||
const skuTokenKey = skuKey(skuToken);
|
||||
const target = safeNum(row.targetQty);
|
||||
|
||||
const skuAgg = authoritativeSkuMap.get(skuTokenKey) ?? {
|
||||
machineName: machine.name,
|
||||
sku: skuToken,
|
||||
good: 0,
|
||||
scrap: 0,
|
||||
target: target != null && target > 0 ? Math.max(0, Math.trunc(target)) : null,
|
||||
};
|
||||
skuAgg.good += safeGood;
|
||||
skuAgg.scrap += safeScrap;
|
||||
if (target != null && target > 0) {
|
||||
skuAgg.target = (skuAgg.target ?? 0) + Math.max(0, Math.trunc(target));
|
||||
}
|
||||
authoritativeSkuMap.set(skuTokenKey, skuAgg);
|
||||
|
||||
authoritativeGoodParts += safeGood;
|
||||
authoritativeScrapParts += safeScrap;
|
||||
authoritativeCycleCount += safeCycleCount;
|
||||
|
||||
const woKey = workOrderKey(row.workOrderId);
|
||||
if (!woKey) continue;
|
||||
|
||||
const progress = authoritativeWorkOrderProgress.get(woKey) ?? {
|
||||
goodParts: 0,
|
||||
scrapParts: 0,
|
||||
cycleCount: 0,
|
||||
firstTs: null,
|
||||
lastTs: null,
|
||||
};
|
||||
progress.goodParts += safeGood;
|
||||
progress.scrapParts += safeScrap;
|
||||
progress.cycleCount += safeCycleCount;
|
||||
if (!progress.firstTs || row.createdAt < progress.firstTs) progress.firstTs = row.createdAt;
|
||||
if (!progress.lastTs || row.updatedAt > progress.lastTs) progress.lastTs = row.updatedAt;
|
||||
authoritativeWorkOrderProgress.set(woKey, progress);
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackBySku = [...skuMap.values()]
|
||||
.map((row) => {
|
||||
const target = row.target ?? targetBySku.get(skuKey(row.sku))?.target ?? null;
|
||||
const produced = row.good + row.scrap;
|
||||
@@ -676,44 +834,52 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
})
|
||||
.sort((a, b) => b.good - a.good);
|
||||
|
||||
let oeeSum = 0;
|
||||
let oeeCount = 0;
|
||||
let availabilitySum = 0;
|
||||
let availabilityCount = 0;
|
||||
let performanceSum = 0;
|
||||
let performanceCount = 0;
|
||||
let qualitySum = 0;
|
||||
let qualityCount = 0;
|
||||
const authoritativeBySku = [...authoritativeSkuMap.values()]
|
||||
.map((row) => {
|
||||
const target = row.target ?? targetBySku.get(skuKey(row.sku))?.target ?? null;
|
||||
const produced = row.good + row.scrap;
|
||||
const progressPct = target && target > 0 ? round2((produced / target) * 100) : null;
|
||||
return {
|
||||
machineName: row.machineName,
|
||||
sku: row.sku,
|
||||
good: row.good,
|
||||
scrap: row.scrap,
|
||||
target,
|
||||
progressPct,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.good - a.good);
|
||||
|
||||
for (const kpi of dedupedKpis) {
|
||||
const oee = safeNum(kpi.oee);
|
||||
const availability = safeNum(kpi.availability);
|
||||
const performance = safeNum(kpi.performance);
|
||||
const quality = safeNum(kpi.quality);
|
||||
|
||||
if (oee != null) {
|
||||
oeeSum += oee;
|
||||
oeeCount += 1;
|
||||
}
|
||||
if (availability != null) {
|
||||
availabilitySum += availability;
|
||||
availabilityCount += 1;
|
||||
}
|
||||
if (performance != null) {
|
||||
performanceSum += performance;
|
||||
performanceCount += 1;
|
||||
}
|
||||
if (quality != null) {
|
||||
qualitySum += quality;
|
||||
qualityCount += 1;
|
||||
}
|
||||
const bySku = hasAuthoritativeWorkOrderCounters ? authoritativeBySku : fallbackBySku;
|
||||
if (hasAuthoritativeWorkOrderCounters) {
|
||||
goodParts = authoritativeGoodParts;
|
||||
scrapParts = authoritativeScrapParts;
|
||||
}
|
||||
|
||||
const sortedKpis = [...dedupedKpis].sort((a, b) => a.ts.getTime() - b.ts.getTime());
|
||||
const weightedAvg = (field: "oee" | "availability" | "performance" | "quality") => {
|
||||
if (!sortedKpis.length) return null;
|
||||
let totalMs = 0;
|
||||
let weightedSum = 0;
|
||||
|
||||
for (let i = 0; i < sortedKpis.length; i += 1) {
|
||||
const current = sortedKpis[i];
|
||||
const nextTsMs = (sortedKpis[i + 1]?.ts ?? params.end).getTime();
|
||||
const dt = Math.max(0, nextTsMs - current.ts.getTime());
|
||||
if (dt <= 0) continue;
|
||||
weightedSum += (safeNum(current[field]) ?? 0) * dt;
|
||||
totalMs += dt;
|
||||
}
|
||||
|
||||
return totalMs > 0 ? round2(weightedSum / totalMs) : null;
|
||||
};
|
||||
|
||||
let stopDurSecFromEvents = 0;
|
||||
let stopsCount = 0;
|
||||
for (const event of machineEvents) {
|
||||
const type = String(event.eventType || "").toLowerCase();
|
||||
if (!STOP_TYPES.has(type)) continue;
|
||||
if (!isRealStopEvent(event.data)) continue;
|
||||
stopsCount += 1;
|
||||
stopDurSecFromEvents += eventDurationSec(event.data);
|
||||
}
|
||||
@@ -759,12 +925,14 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
.filter((wo) => String(wo.status).toUpperCase() === "COMPLETED")
|
||||
.filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end)
|
||||
.map((wo) => {
|
||||
const progress = rangeByWorkOrder.get(workOrderKey(wo.workOrderId)) ?? {
|
||||
const fallbackProgress = rangeByWorkOrder.get(workOrderKey(wo.workOrderId)) ?? {
|
||||
goodParts: 0,
|
||||
scrapParts: 0,
|
||||
firstTs: null,
|
||||
lastTs: null,
|
||||
};
|
||||
const authoritativeProgress = authoritativeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? null;
|
||||
const progress = authoritativeProgress ?? fallbackProgress;
|
||||
const durationHrs =
|
||||
progress.firstTs && progress.lastTs
|
||||
? round2((progress.lastTs.getTime() - progress.firstTs.getTime()) / 3600000)
|
||||
@@ -788,24 +956,37 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
const activeWorkOrderSku =
|
||||
normalizeToken(latestTelemetry?.sku) || normalizeToken(activeWo?.sku) || null;
|
||||
const activeWorkOrderKey = workOrderKey(activeWorkOrderId);
|
||||
const authoritativeActiveWo =
|
||||
activeWorkOrderKey && hasAuthoritativeWorkOrderCounters
|
||||
? machineWorkOrderCounters.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ?? null
|
||||
: null;
|
||||
const activeTargetSource =
|
||||
activeWorkOrderKey
|
||||
? machineWorkOrdersSorted.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ?? activeWo
|
||||
? machineWorkOrdersSorted.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ??
|
||||
activeWo ??
|
||||
authoritativeActiveWo
|
||||
: activeWo;
|
||||
|
||||
let activeProgressPct: number | null = null;
|
||||
let activeStartedAt: string | null = null;
|
||||
if (activeWorkOrderId) {
|
||||
const rangeProgress = activeWorkOrderKey ? rangeByWorkOrder.get(activeWorkOrderKey) : null;
|
||||
const authoritativeProgress = activeWorkOrderKey
|
||||
? authoritativeWorkOrderProgress.get(activeWorkOrderKey) ?? null
|
||||
: null;
|
||||
const cumulativeProgress = activeWorkOrderKey ? kpiLatestByWorkOrder.get(activeWorkOrderKey) : null;
|
||||
const producedForProgress = cumulativeProgress
|
||||
? cumulativeProgress.good + cumulativeProgress.scrap
|
||||
: (rangeProgress?.goodParts ?? 0) + (rangeProgress?.scrapParts ?? 0);
|
||||
const producedForProgress = authoritativeProgress
|
||||
? authoritativeProgress.goodParts + authoritativeProgress.scrapParts
|
||||
: cumulativeProgress
|
||||
? cumulativeProgress.good + cumulativeProgress.scrap
|
||||
: (rangeProgress?.goodParts ?? 0) + (rangeProgress?.scrapParts ?? 0);
|
||||
const targetQty = safeNum(activeTargetSource?.targetQty);
|
||||
if (targetQty && targetQty > 0) {
|
||||
activeProgressPct = round2((producedForProgress / targetQty) * 100);
|
||||
}
|
||||
activeStartedAt = toIso(rangeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null);
|
||||
activeStartedAt = toIso(
|
||||
authoritativeProgress?.firstTs ?? rangeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null
|
||||
);
|
||||
}
|
||||
|
||||
const moldActiveByIncident = new Map<string, number>();
|
||||
@@ -843,14 +1024,14 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
production: {
|
||||
goodParts,
|
||||
scrapParts,
|
||||
totalCycles: dedupedCycles.length,
|
||||
totalCycles: hasAuthoritativeWorkOrderCounters ? authoritativeCycleCount : dedupedCycles.length,
|
||||
bySku,
|
||||
},
|
||||
oee: {
|
||||
avg: avg(oeeSum, oeeCount),
|
||||
availability: avg(availabilitySum, availabilityCount),
|
||||
performance: avg(performanceSum, performanceCount),
|
||||
quality: avg(qualitySum, qualityCount),
|
||||
avg: weightedAvg("oee"),
|
||||
availability: weightedAvg("availability"),
|
||||
performance: weightedAvg("performance"),
|
||||
quality: weightedAvg("quality"),
|
||||
},
|
||||
downtime: {
|
||||
totalMin,
|
||||
|
||||
679
lib/recap/redesign.ts
Normal file
679
lib/recap/redesign.ts
Normal file
@@ -0,0 +1,679 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
|
||||
import { getRecapDataCached } from "@/lib/recap/getRecapData";
|
||||
import {
|
||||
buildTimelineSegments,
|
||||
compressTimelineSegments,
|
||||
TIMELINE_EVENT_TYPES,
|
||||
type TimelineCycleRow,
|
||||
type TimelineEventRow,
|
||||
} from "@/lib/recap/timeline";
|
||||
import type {
|
||||
RecapDetailResponse,
|
||||
RecapMachine,
|
||||
RecapMachineDetail,
|
||||
RecapMachineStatus,
|
||||
RecapRangeMode,
|
||||
RecapSummaryMachine,
|
||||
RecapSummaryResponse,
|
||||
} from "@/lib/recap/types";
|
||||
|
||||
type DetailRangeInput = {
|
||||
mode?: string | null;
|
||||
start?: string | null;
|
||||
end?: string | null;
|
||||
};
|
||||
|
||||
const OFFLINE_THRESHOLD_MS = 10 * 60 * 1000;
|
||||
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||
const RECAP_CACHE_TTL_SEC = 60;
|
||||
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||
const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
|
||||
Mon: "mon",
|
||||
Tue: "tue",
|
||||
Wed: "wed",
|
||||
Thu: "thu",
|
||||
Fri: "fri",
|
||||
Sat: "sat",
|
||||
Sun: "sun",
|
||||
};
|
||||
|
||||
function round2(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function parseDate(input?: string | null) {
|
||||
if (!input) return null;
|
||||
const n = Number(input);
|
||||
if (Number.isFinite(n)) {
|
||||
const d = new Date(n);
|
||||
return Number.isFinite(d.getTime()) ? d : null;
|
||||
}
|
||||
const d = new Date(input);
|
||||
return Number.isFinite(d.getTime()) ? d : null;
|
||||
}
|
||||
|
||||
function parseHours(input: string | null) {
|
||||
const parsed = Math.trunc(Number(input ?? "24"));
|
||||
if (!Number.isFinite(parsed)) return 24;
|
||||
return Math.max(1, Math.min(72, parsed));
|
||||
}
|
||||
|
||||
function parseTimeMinutes(input?: string | null) {
|
||||
if (!input) return null;
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
||||
return null;
|
||||
}
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
function getLocalParts(ts: Date, timeZone: string) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
weekday: "short",
|
||||
hour12: false,
|
||||
}).formatToParts(ts);
|
||||
|
||||
const value = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
|
||||
const year = Number(value("year"));
|
||||
const month = Number(value("month"));
|
||||
const day = Number(value("day"));
|
||||
const hour = Number(value("hour"));
|
||||
const minute = Number(value("minute"));
|
||||
const weekday = value("weekday");
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()],
|
||||
minutesOfDay: hour * 60 + minute,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
year: ts.getUTCFullYear(),
|
||||
month: ts.getUTCMonth() + 1,
|
||||
day: ts.getUTCDate(),
|
||||
hour: ts.getUTCHours(),
|
||||
minute: ts.getUTCMinutes(),
|
||||
weekday: WEEKDAY_KEYS[ts.getUTCDay()],
|
||||
minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseOffsetMinutes(offsetLabel: string | null) {
|
||||
if (!offsetLabel) return null;
|
||||
const normalized = offsetLabel.replace("UTC", "GMT");
|
||||
const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized);
|
||||
if (!match) return null;
|
||||
const sign = match[1] === "-" ? -1 : 1;
|
||||
const hour = Number(match[2]);
|
||||
const minute = Number(match[3] ?? "0");
|
||||
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
||||
return sign * (hour * 60 + minute);
|
||||
}
|
||||
|
||||
function getTzOffsetMinutes(utcDate: Date, timeZone: string) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
timeZoneName: "shortOffset",
|
||||
hour: "2-digit",
|
||||
}).formatToParts(utcDate);
|
||||
const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null;
|
||||
return parseOffsetMinutes(offsetPart);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function zonedToUtcDate(input: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
timeZone: string;
|
||||
}) {
|
||||
const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0);
|
||||
const guessDate = new Date(baseUtc);
|
||||
const offsetA = getTzOffsetMinutes(guessDate, input.timeZone);
|
||||
if (offsetA == null) return guessDate;
|
||||
|
||||
let corrected = new Date(baseUtc - offsetA * 60000);
|
||||
const offsetB = getTzOffsetMinutes(corrected, input.timeZone);
|
||||
if (offsetB != null && offsetB !== offsetA) {
|
||||
corrected = new Date(baseUtc - offsetB * 60000);
|
||||
}
|
||||
|
||||
return corrected;
|
||||
}
|
||||
|
||||
function addDays(input: { year: number; month: number; day: number }, days: number) {
|
||||
const base = new Date(Date.UTC(input.year, input.month - 1, input.day));
|
||||
base.setUTCDate(base.getUTCDate() + days);
|
||||
return {
|
||||
year: base.getUTCFullYear(),
|
||||
month: base.getUTCMonth() + 1,
|
||||
day: base.getUTCDate(),
|
||||
};
|
||||
}
|
||||
|
||||
function statusFromMachine(machine: RecapMachine, endMs: number) {
|
||||
const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
|
||||
const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
|
||||
const offline = !Number.isFinite(lastSeenMs ?? Number.NaN) || offlineForMs > OFFLINE_THRESHOLD_MS;
|
||||
|
||||
const ongoingStopMin = machine.downtime.ongoingStopMin ?? 0;
|
||||
const moldActive = machine.workOrders.moldChangeInProgress;
|
||||
|
||||
let status: RecapMachineStatus = "running";
|
||||
if (offline) status = "offline";
|
||||
else if (moldActive) status = "mold-change";
|
||||
else if (ongoingStopMin > 0) status = "stopped";
|
||||
|
||||
return {
|
||||
status,
|
||||
lastSeenMs,
|
||||
offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null,
|
||||
ongoingStopMin: machine.downtime.ongoingStopMin,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTimelineRowsForMachines(params: {
|
||||
orgId: string;
|
||||
machineIds: string[];
|
||||
start: Date;
|
||||
end: Date;
|
||||
}) {
|
||||
if (!params.machineIds.length) {
|
||||
return {
|
||||
cyclesByMachine: new Map<string, TimelineCycleRow[]>(),
|
||||
eventsByMachine: new Map<string, TimelineEventRow[]>(),
|
||||
};
|
||||
}
|
||||
|
||||
const [cycles, events] = await Promise.all([
|
||||
prisma.machineCycle.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: { in: params.machineIds },
|
||||
ts: { gte: params.start, lte: params.end },
|
||||
},
|
||||
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||
select: {
|
||||
machineId: true,
|
||||
ts: true,
|
||||
cycleCount: true,
|
||||
actualCycleTime: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
},
|
||||
}),
|
||||
prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: { in: params.machineIds },
|
||||
eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
|
||||
ts: {
|
||||
gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
|
||||
lte: params.end,
|
||||
},
|
||||
},
|
||||
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||
select: {
|
||||
machineId: true,
|
||||
ts: true,
|
||||
eventType: true,
|
||||
data: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const cyclesByMachine = new Map<string, TimelineCycleRow[]>();
|
||||
const eventsByMachine = new Map<string, TimelineEventRow[]>();
|
||||
|
||||
for (const row of cycles) {
|
||||
const list = cyclesByMachine.get(row.machineId) ?? [];
|
||||
list.push({
|
||||
ts: row.ts,
|
||||
cycleCount: row.cycleCount,
|
||||
actualCycleTime: row.actualCycleTime,
|
||||
workOrderId: row.workOrderId,
|
||||
sku: row.sku,
|
||||
});
|
||||
cyclesByMachine.set(row.machineId, list);
|
||||
}
|
||||
|
||||
for (const row of events) {
|
||||
const list = eventsByMachine.get(row.machineId) ?? [];
|
||||
list.push({
|
||||
ts: row.ts,
|
||||
eventType: row.eventType,
|
||||
data: row.data,
|
||||
});
|
||||
eventsByMachine.set(row.machineId, list);
|
||||
}
|
||||
|
||||
return { cyclesByMachine, eventsByMachine };
|
||||
}
|
||||
|
||||
function toSummaryMachine(params: {
|
||||
machine: RecapMachine;
|
||||
miniTimeline: ReturnType<typeof compressTimelineSegments>;
|
||||
rangeEndMs: number;
|
||||
}): RecapSummaryMachine {
|
||||
const { machine, miniTimeline, rangeEndMs } = params;
|
||||
const status = statusFromMachine(machine, rangeEndMs);
|
||||
|
||||
return {
|
||||
machineId: machine.machineId,
|
||||
name: machine.machineName,
|
||||
location: machine.location,
|
||||
status: status.status,
|
||||
oee: machine.oee.avg,
|
||||
goodParts: machine.production.goodParts,
|
||||
scrap: machine.production.scrapParts,
|
||||
stopsCount: machine.downtime.stopsCount,
|
||||
lastSeenMs: status.lastSeenMs,
|
||||
lastActivityMin:
|
||||
status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
|
||||
offlineForMin: status.offlineForMin,
|
||||
ongoingStopMin: status.ongoingStopMin,
|
||||
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
||||
moldChange: {
|
||||
active: machine.workOrders.moldChangeInProgress,
|
||||
startMs: machine.workOrders.moldChangeStartMs,
|
||||
elapsedMin:
|
||||
machine.workOrders.moldChangeStartMs == null
|
||||
? null
|
||||
: Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)),
|
||||
},
|
||||
miniTimeline,
|
||||
};
|
||||
}
|
||||
|
||||
async function computeRecapSummary(params: { orgId: string; hours: number }) {
|
||||
const now = new Date();
|
||||
const end = new Date(Math.floor(now.getTime() / 60000) * 60000);
|
||||
const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000);
|
||||
|
||||
const recap = await getRecapDataCached({
|
||||
orgId: params.orgId,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
const machineIds = recap.machines.map((machine) => machine.machineId);
|
||||
const timelineRows = await loadTimelineRowsForMachines({
|
||||
orgId: params.orgId,
|
||||
machineIds,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
const machines = recap.machines.map((machine) => {
|
||||
const segments = buildTimelineSegments({
|
||||
cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [],
|
||||
events: timelineRows.eventsByMachine.get(machine.machineId) ?? [],
|
||||
rangeStart: start,
|
||||
rangeEnd: end,
|
||||
});
|
||||
const miniTimeline = compressTimelineSegments({
|
||||
segments,
|
||||
rangeStart: start,
|
||||
rangeEnd: end,
|
||||
maxSegments: 30,
|
||||
});
|
||||
|
||||
return toSummaryMachine({
|
||||
machine,
|
||||
miniTimeline,
|
||||
rangeEndMs: end.getTime(),
|
||||
});
|
||||
});
|
||||
|
||||
const response: RecapSummaryResponse = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
range: {
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
hours: params.hours,
|
||||
},
|
||||
machines,
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function normalizedRangeMode(mode?: string | null): RecapRangeMode {
|
||||
const raw = String(mode ?? "").trim().toLowerCase();
|
||||
if (raw === "shift") return "shift";
|
||||
if (raw === "yesterday") return "yesterday";
|
||||
if (raw === "custom") return "custom";
|
||||
return "24h";
|
||||
}
|
||||
|
||||
async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
|
||||
const settings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: params.orgId },
|
||||
select: {
|
||||
timezone: true,
|
||||
shiftScheduleOverridesJson: true,
|
||||
},
|
||||
});
|
||||
const shifts = await prisma.orgShift.findMany({
|
||||
where: { orgId: params.orgId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: {
|
||||
name: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
enabled: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
});
|
||||
|
||||
const enabledShifts = shifts.filter((shift) => shift.enabled !== false);
|
||||
if (!enabledShifts.length) return null;
|
||||
|
||||
const timeZone = settings?.timezone || "UTC";
|
||||
const local = getLocalParts(params.now, timeZone);
|
||||
const overrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
|
||||
const dayOverrides = overrides?.[local.weekday];
|
||||
const activeShifts = (dayOverrides?.length
|
||||
? dayOverrides.map((shift) => ({
|
||||
enabled: shift.enabled !== false,
|
||||
start: shift.start,
|
||||
end: shift.end,
|
||||
}))
|
||||
: enabledShifts.map((shift) => ({
|
||||
enabled: shift.enabled !== false,
|
||||
start: shift.startTime,
|
||||
end: shift.endTime,
|
||||
}))
|
||||
).filter((shift) => shift.enabled);
|
||||
|
||||
for (const shift of activeShifts) {
|
||||
const startMin = parseTimeMinutes(shift.start ?? null);
|
||||
const endMin = parseTimeMinutes(shift.end ?? null);
|
||||
if (startMin == null || endMin == null) continue;
|
||||
|
||||
const minutesNow = local.minutesOfDay;
|
||||
let inRange = false;
|
||||
let startDate = { year: local.year, month: local.month, day: local.day };
|
||||
let endDate = { year: local.year, month: local.month, day: local.day };
|
||||
|
||||
if (startMin <= endMin) {
|
||||
inRange = minutesNow >= startMin && minutesNow < endMin;
|
||||
} else {
|
||||
inRange = minutesNow >= startMin || minutesNow < endMin;
|
||||
if (minutesNow >= startMin) {
|
||||
endDate = addDays(endDate, 1);
|
||||
} else {
|
||||
startDate = addDays(startDate, -1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!inRange) continue;
|
||||
|
||||
const start = zonedToUtcDate({
|
||||
...startDate,
|
||||
hours: Math.floor(startMin / 60),
|
||||
minutes: startMin % 60,
|
||||
timeZone,
|
||||
});
|
||||
const end = zonedToUtcDate({
|
||||
...endDate,
|
||||
hours: Math.floor(endMin / 60),
|
||||
minutes: endMin % 60,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
if (end <= start) continue;
|
||||
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
|
||||
const now = new Date();
|
||||
const mode = normalizedRangeMode(params.input.mode);
|
||||
|
||||
if (mode === "custom") {
|
||||
const start = parseDate(params.input.start);
|
||||
const end = parseDate(params.input.end);
|
||||
if (start && end && end > start) {
|
||||
return { mode, start, end };
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "yesterday") {
|
||||
const end = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000);
|
||||
return { mode, start, end };
|
||||
}
|
||||
|
||||
if (mode === "shift") {
|
||||
const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
|
||||
if (shiftRange) {
|
||||
return {
|
||||
mode,
|
||||
start: shiftRange.start,
|
||||
end: shiftRange.end,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "24h" as const,
|
||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function computeRecapMachineDetail(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
range: {
|
||||
mode: RecapRangeMode;
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
}) {
|
||||
const { range } = params;
|
||||
|
||||
const recap = await getRecapDataCached({
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
});
|
||||
|
||||
const machine = recap.machines.find((row) => row.machineId === params.machineId) ?? null;
|
||||
if (!machine) return null;
|
||||
|
||||
const timelineRows = await loadTimelineRowsForMachines({
|
||||
orgId: params.orgId,
|
||||
machineIds: [params.machineId],
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
});
|
||||
|
||||
const timeline = buildTimelineSegments({
|
||||
cycles: timelineRows.cyclesByMachine.get(params.machineId) ?? [],
|
||||
events: timelineRows.eventsByMachine.get(params.machineId) ?? [],
|
||||
rangeStart: range.start,
|
||||
rangeEnd: range.end,
|
||||
});
|
||||
|
||||
const status = statusFromMachine(machine, range.end.getTime());
|
||||
|
||||
const downtimeTotalMin = Math.max(0, machine.downtime.totalMin);
|
||||
const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({
|
||||
reasonLabel: row.reasonLabel,
|
||||
minutes: row.minutes,
|
||||
count: row.count,
|
||||
percent: downtimeTotalMin > 0 ? round2((row.minutes / downtimeTotalMin) * 100) : 0,
|
||||
}));
|
||||
|
||||
const machineDetail: RecapMachineDetail = {
|
||||
machineId: machine.machineId,
|
||||
name: machine.machineName,
|
||||
location: machine.location,
|
||||
status: status.status,
|
||||
oee: machine.oee.avg,
|
||||
goodParts: machine.production.goodParts,
|
||||
scrap: machine.production.scrapParts,
|
||||
stopsCount: machine.downtime.stopsCount,
|
||||
stopMinutes: downtimeTotalMin,
|
||||
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
||||
lastSeenMs: status.lastSeenMs,
|
||||
offlineForMin: status.offlineForMin,
|
||||
ongoingStopMin: status.ongoingStopMin,
|
||||
moldChange: {
|
||||
active: machine.workOrders.moldChangeInProgress,
|
||||
startMs: machine.workOrders.moldChangeStartMs,
|
||||
},
|
||||
timeline,
|
||||
productionBySku: machine.production.bySku,
|
||||
downtimeTop,
|
||||
workOrders: {
|
||||
completed: machine.workOrders.completed,
|
||||
active: machine.workOrders.active,
|
||||
},
|
||||
heartbeat: {
|
||||
lastSeenAt: machine.heartbeat.lastSeenAt,
|
||||
uptimePct: machine.heartbeat.uptimePct,
|
||||
connectionStatus: status.status === "offline" ? "offline" : "online",
|
||||
},
|
||||
};
|
||||
|
||||
const response: RecapDetailResponse = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
range: {
|
||||
mode: range.mode,
|
||||
start: range.start.toISOString(),
|
||||
end: range.end.toISOString(),
|
||||
},
|
||||
machine: machineDetail,
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function summaryCacheKey(params: { orgId: string; hours: number }) {
|
||||
return ["recap-summary-v1", params.orgId, String(params.hours)];
|
||||
}
|
||||
|
||||
function detailCacheKey(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
mode: RecapRangeMode;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}) {
|
||||
return [
|
||||
"recap-detail-v1",
|
||||
params.orgId,
|
||||
params.machineId,
|
||||
params.mode,
|
||||
String(Math.trunc(params.startMs / 60000)),
|
||||
String(Math.trunc(params.endMs / 60000)),
|
||||
];
|
||||
}
|
||||
|
||||
export function parseRecapSummaryHours(raw: string | null) {
|
||||
return parseHours(raw);
|
||||
}
|
||||
|
||||
export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record<string, string | string[] | undefined>) {
|
||||
if (searchParams instanceof URLSearchParams) {
|
||||
return {
|
||||
mode: searchParams.get("range") ?? undefined,
|
||||
start: searchParams.get("start") ?? undefined,
|
||||
end: searchParams.get("end") ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const pick = (key: string) => {
|
||||
const value = searchParams[key];
|
||||
if (Array.isArray(value)) return value[0] ?? undefined;
|
||||
return value ?? undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
mode: pick("range"),
|
||||
start: pick("start"),
|
||||
end: pick("end"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRecapSummaryCached(params: { orgId: string; hours: number }) {
|
||||
const cache = unstable_cache(
|
||||
() => computeRecapSummary(params),
|
||||
summaryCacheKey(params),
|
||||
{
|
||||
revalidate: RECAP_CACHE_TTL_SEC,
|
||||
tags: [`recap:${params.orgId}`],
|
||||
}
|
||||
);
|
||||
|
||||
return cache();
|
||||
}
|
||||
|
||||
export async function getRecapMachineDetailCached(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
input: DetailRangeInput;
|
||||
}) {
|
||||
const resolved = await resolveDetailRange({
|
||||
orgId: params.orgId,
|
||||
input: params.input,
|
||||
});
|
||||
|
||||
const cache = unstable_cache(
|
||||
() =>
|
||||
computeRecapMachineDetail({
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
range: {
|
||||
mode: resolved.mode,
|
||||
start: resolved.start,
|
||||
end: resolved.end,
|
||||
},
|
||||
}),
|
||||
detailCacheKey({
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
mode: resolved.mode,
|
||||
startMs: resolved.start.getTime(),
|
||||
endMs: resolved.end.getTime(),
|
||||
}),
|
||||
{
|
||||
revalidate: RECAP_CACHE_TTL_SEC,
|
||||
tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
|
||||
}
|
||||
);
|
||||
|
||||
return cache();
|
||||
}
|
||||
763
lib/recap/timeline.ts
Normal file
763
lib/recap/timeline.ts
Normal file
@@ -0,0 +1,763 @@
|
||||
import type { RecapTimelineSegment } from "@/lib/recap/types";
|
||||
|
||||
const ACTIVE_STALE_MS = 2 * 60 * 1000;
|
||||
const MERGE_GAP_MS = 30 * 1000;
|
||||
const MICRO_CLUSTER_GAP_MS = 60 * 1000;
|
||||
const ABSORB_SHORT_SEGMENT_MS = 30 * 1000;
|
||||
|
||||
export const TIMELINE_EVENT_TYPES = ["mold-change", "macrostop", "microstop"] as const;
|
||||
|
||||
type TimelineEventType = (typeof TIMELINE_EVENT_TYPES)[number];
|
||||
|
||||
type RawSegment =
|
||||
| {
|
||||
type: "production";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
priority: number;
|
||||
workOrderId: string | null;
|
||||
sku: string | null;
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
type: "mold-change";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
priority: number;
|
||||
fromMoldId: string | null;
|
||||
toMoldId: string | null;
|
||||
durationSec: number;
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
type: "macrostop" | "microstop" | "slow-cycle";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
priority: number;
|
||||
reason: string | null;
|
||||
durationSec: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type TimelineCycleRow = {
|
||||
ts: Date;
|
||||
cycleCount: number | null;
|
||||
actualCycleTime: number;
|
||||
workOrderId: string | null;
|
||||
sku: string | null;
|
||||
};
|
||||
|
||||
export type TimelineEventRow = {
|
||||
ts: Date;
|
||||
eventType: string;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
const PRIORITY: Record<string, number> = {
|
||||
idle: 0,
|
||||
production: 1,
|
||||
microstop: 2,
|
||||
"slow-cycle": 2,
|
||||
macrostop: 3,
|
||||
"mold-change": 4,
|
||||
};
|
||||
|
||||
function safeNum(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function safeBool(value: unknown) {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeToken(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function dedupeByKey<T>(rows: T[], keyFn: (row: T) => string) {
|
||||
const seen = new Set<string>();
|
||||
const out: T[] = [];
|
||||
for (const row of rows) {
|
||||
const key = keyFn(row);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractData(value: unknown) {
|
||||
let parsed: unknown = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
}
|
||||
const record =
|
||||
typeof parsed === "object" && parsed && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const nested = record.data;
|
||||
if (typeof nested === "object" && nested && !Array.isArray(nested)) {
|
||||
return nested as Record<string, unknown>;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function clampToRange(startMs: number, endMs: number, rangeStartMs: number, rangeEndMs: number) {
|
||||
const clampedStart = Math.max(rangeStartMs, Math.min(rangeEndMs, startMs));
|
||||
const clampedEnd = Math.max(rangeStartMs, Math.min(rangeEndMs, endMs));
|
||||
if (clampedEnd <= clampedStart) return null;
|
||||
return { startMs: clampedStart, endMs: clampedEnd };
|
||||
}
|
||||
|
||||
function eventIncidentKey(eventType: string, data: Record<string, unknown>, fallbackTsMs: number) {
|
||||
const key = String(data.incidentKey ?? data.incident_key ?? "").trim();
|
||||
if (key) return key;
|
||||
const alertId = String(data.alert_id ?? data.alertId ?? "").trim();
|
||||
if (alertId) return `${eventType}:${alertId}`;
|
||||
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
|
||||
if (startMs != null) return `${eventType}:${Math.trunc(startMs)}`;
|
||||
return `${eventType}:${fallbackTsMs}`;
|
||||
}
|
||||
|
||||
function reasonLabelFromData(data: Record<string, unknown>) {
|
||||
const direct =
|
||||
String(data.reasonText ?? data.reason_label ?? data.reasonLabel ?? "").trim() || null;
|
||||
if (direct) return direct;
|
||||
|
||||
const reason = data.reason;
|
||||
if (typeof reason === "string") {
|
||||
const text = reason.trim();
|
||||
return text || null;
|
||||
}
|
||||
if (reason && typeof reason === "object" && !Array.isArray(reason)) {
|
||||
const rec = reason as Record<string, unknown>;
|
||||
const reasonText =
|
||||
String(rec.reasonText ?? rec.reason_label ?? rec.reasonLabel ?? "").trim() || null;
|
||||
if (reasonText) return reasonText;
|
||||
const detail =
|
||||
String(rec.detailLabel ?? rec.detail_label ?? rec.detailId ?? rec.detail_id ?? "").trim() ||
|
||||
null;
|
||||
const category =
|
||||
String(rec.categoryLabel ?? rec.category_label ?? rec.categoryId ?? rec.category_id ?? "").trim() ||
|
||||
null;
|
||||
if (category && detail) return `${category} > ${detail}`;
|
||||
if (detail) return detail;
|
||||
if (category) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function labelForStop(type: "macrostop" | "microstop" | "slow-cycle", reason: string | null) {
|
||||
if (type === "macrostop") return reason ? `Paro: ${reason}` : "Paro";
|
||||
if (type === "microstop") return reason ? `Microparo: ${reason}` : "Microparo";
|
||||
return reason ? `Ciclo lento: ${reason}` : "Ciclo lento";
|
||||
}
|
||||
|
||||
function normalizeStopType(type: "macrostop" | "microstop" | "slow-cycle"): "macrostop" | "microstop" {
|
||||
return type === "macrostop" ? "macrostop" : "microstop";
|
||||
}
|
||||
|
||||
function isEquivalent(a: RecapTimelineSegment, b: RecapTimelineSegment) {
|
||||
if (a.type !== b.type) return false;
|
||||
if (a.type === "idle" && b.type === "idle") return true;
|
||||
if (a.type === "production" && b.type === "production") {
|
||||
return a.workOrderId === b.workOrderId && a.sku === b.sku && a.label === b.label;
|
||||
}
|
||||
if (a.type === "mold-change" && b.type === "mold-change") {
|
||||
return a.fromMoldId === b.fromMoldId && a.toMoldId === b.toMoldId;
|
||||
}
|
||||
if (
|
||||
(a.type === "macrostop" || a.type === "microstop" || a.type === "slow-cycle") &&
|
||||
(b.type === "macrostop" || b.type === "microstop" || b.type === "slow-cycle")
|
||||
) {
|
||||
return a.type === b.type && a.reason === b.reason;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function withDuration(segment: RecapTimelineSegment): RecapTimelineSegment {
|
||||
if (segment.type === "production") {
|
||||
return {
|
||||
...segment,
|
||||
durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
|
||||
};
|
||||
}
|
||||
if (segment.type === "mold-change") {
|
||||
return {
|
||||
...segment,
|
||||
durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
|
||||
};
|
||||
}
|
||||
if (segment.type === "macrostop" || segment.type === "microstop" || segment.type === "slow-cycle") {
|
||||
return {
|
||||
...segment,
|
||||
durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...segment,
|
||||
durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneSegment(segment: RecapTimelineSegment): RecapTimelineSegment {
|
||||
return { ...segment };
|
||||
}
|
||||
|
||||
function mergeNearbyEquivalentSegments(segments: RecapTimelineSegment[], maxGapMs: number) {
|
||||
const ordered = [...segments]
|
||||
.map((segment) => withDuration(segment))
|
||||
.filter((segment) => segment.endMs > segment.startMs)
|
||||
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
|
||||
|
||||
const merged: RecapTimelineSegment[] = [];
|
||||
for (const current of ordered) {
|
||||
const prev = merged[merged.length - 1];
|
||||
if (!prev) {
|
||||
merged.push(cloneSegment(current));
|
||||
continue;
|
||||
}
|
||||
|
||||
const gapMs = current.startMs - prev.endMs;
|
||||
if (gapMs <= maxGapMs && isEquivalent(prev, current)) {
|
||||
prev.endMs = Math.max(prev.endMs, current.endMs);
|
||||
const normalized = withDuration(prev);
|
||||
Object.assign(prev, normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.startMs < prev.endMs) {
|
||||
const clipped = { ...current, startMs: prev.endMs };
|
||||
if (clipped.endMs <= clipped.startMs) continue;
|
||||
merged.push(withDuration(clipped));
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.push(cloneSegment(current));
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function fillGapsWithIdle(segments: RecapTimelineSegment[], rangeStartMs: number, rangeEndMs: number) {
|
||||
const ordered = [...segments]
|
||||
.map((segment) => {
|
||||
const startMs = Math.max(rangeStartMs, segment.startMs);
|
||||
const endMs = Math.min(rangeEndMs, segment.endMs);
|
||||
if (endMs <= startMs) return null;
|
||||
return withDuration({ ...segment, startMs, endMs });
|
||||
})
|
||||
.filter((segment): segment is RecapTimelineSegment => !!segment)
|
||||
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
|
||||
|
||||
const out: RecapTimelineSegment[] = [];
|
||||
let cursor = rangeStartMs;
|
||||
|
||||
for (const segment of ordered) {
|
||||
if (segment.startMs > cursor) {
|
||||
out.push({
|
||||
type: "idle",
|
||||
startMs: cursor,
|
||||
endMs: segment.startMs,
|
||||
durationSec: Math.max(0, Math.trunc((segment.startMs - cursor) / 1000)),
|
||||
label: "Idle",
|
||||
});
|
||||
}
|
||||
|
||||
const startMs = Math.max(cursor, segment.startMs);
|
||||
const endMs = Math.min(rangeEndMs, segment.endMs);
|
||||
if (endMs <= startMs) continue;
|
||||
|
||||
out.push(withDuration({ ...segment, startMs, endMs }));
|
||||
cursor = endMs;
|
||||
if (cursor >= rangeEndMs) break;
|
||||
}
|
||||
|
||||
if (cursor < rangeEndMs) {
|
||||
out.push({
|
||||
type: "idle",
|
||||
startMs: cursor,
|
||||
endMs: rangeEndMs,
|
||||
durationSec: Math.max(0, Math.trunc((rangeEndMs - cursor) / 1000)),
|
||||
label: "Idle",
|
||||
});
|
||||
}
|
||||
|
||||
return mergeNearbyEquivalentSegments(out, 0);
|
||||
}
|
||||
|
||||
function absorbMicroStopClusters(segments: RecapTimelineSegment[], maxGapMs: number) {
|
||||
const out: RecapTimelineSegment[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < segments.length) {
|
||||
const first = segments[i];
|
||||
if (first.type !== "microstop") {
|
||||
out.push(cloneSegment(first));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let clusterEndMs = first.endMs;
|
||||
let count = 1;
|
||||
const reasons = new Set<string>();
|
||||
if (first.reason) reasons.add(first.reason);
|
||||
|
||||
let cursor = i;
|
||||
while (cursor + 2 < segments.length) {
|
||||
const gap = segments[cursor + 1];
|
||||
const next = segments[cursor + 2];
|
||||
if (next.type !== "microstop") break;
|
||||
if (gap.type === "macrostop" || gap.type === "mold-change") break;
|
||||
const gapMs = Math.max(0, gap.endMs - gap.startMs);
|
||||
if (gapMs >= maxGapMs) break;
|
||||
|
||||
clusterEndMs = next.endMs;
|
||||
if (next.reason) reasons.add(next.reason);
|
||||
count += 1;
|
||||
cursor += 2;
|
||||
}
|
||||
|
||||
if (count === 1) {
|
||||
out.push(cloneSegment(first));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const reason = reasons.size === 1 ? (Array.from(reasons)[0] ?? null) : null;
|
||||
out.push({
|
||||
type: "microstop",
|
||||
startMs: first.startMs,
|
||||
endMs: clusterEndMs,
|
||||
reason,
|
||||
reasonLabel: reason,
|
||||
durationSec: Math.max(0, Math.trunc((clusterEndMs - first.startMs) / 1000)),
|
||||
label: reason ? `Microparo (${count}) · ${reason}` : `Microparo (${count})`,
|
||||
});
|
||||
i = cursor + 1;
|
||||
}
|
||||
|
||||
return mergeNearbyEquivalentSegments(out, 0);
|
||||
}
|
||||
|
||||
function absorbShortSegments(segments: RecapTimelineSegment[], minDurationMs: number) {
|
||||
const out = segments.map((segment) => withDuration(cloneSegment(segment)));
|
||||
let index = 0;
|
||||
|
||||
while (index < out.length) {
|
||||
const current = out[index];
|
||||
const durationMs = Math.max(0, current.endMs - current.startMs);
|
||||
if (durationMs >= minDurationMs || out.length === 1) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const prev = out[index - 1] ?? null;
|
||||
const next = out[index + 1] ?? null;
|
||||
if (!prev && !next) break;
|
||||
|
||||
if (!prev && next) {
|
||||
next.startMs = current.startMs;
|
||||
out.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (prev && !next) {
|
||||
prev.endMs = current.endMs;
|
||||
out.splice(index, 1);
|
||||
index = Math.max(0, index - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const prevDurationMs = Math.max(0, (prev?.endMs ?? 0) - (prev?.startMs ?? 0));
|
||||
const nextDurationMs = Math.max(0, (next?.endMs ?? 0) - (next?.startMs ?? 0));
|
||||
const absorbIntoPrev = prevDurationMs >= nextDurationMs;
|
||||
|
||||
if (absorbIntoPrev && prev) {
|
||||
prev.endMs = current.endMs;
|
||||
out.splice(index, 1);
|
||||
index = Math.max(0, index - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next) {
|
||||
next.startMs = current.startMs;
|
||||
out.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return mergeNearbyEquivalentSegments(out.map((segment) => withDuration(segment)), MERGE_GAP_MS);
|
||||
}
|
||||
|
||||
function buildSegmentsFromBoundaries(rawSegments: RawSegment[], rangeStartMs: number, rangeEndMs: number) {
|
||||
const clipped = rawSegments
|
||||
.map((segment) => {
|
||||
const range = clampToRange(segment.startMs, segment.endMs, rangeStartMs, rangeEndMs);
|
||||
return range ? { ...segment, ...range } : null;
|
||||
})
|
||||
.filter((segment): segment is RawSegment => !!segment);
|
||||
|
||||
const boundaries = new Set<number>([rangeStartMs, rangeEndMs]);
|
||||
for (const segment of clipped) {
|
||||
boundaries.add(segment.startMs);
|
||||
boundaries.add(segment.endMs);
|
||||
}
|
||||
const orderedBoundaries = Array.from(boundaries).sort((a, b) => a - b);
|
||||
|
||||
const timeline: RecapTimelineSegment[] = [];
|
||||
for (let i = 0; i < orderedBoundaries.length - 1; i += 1) {
|
||||
const intervalStart = orderedBoundaries[i];
|
||||
const intervalEnd = orderedBoundaries[i + 1];
|
||||
if (intervalEnd <= intervalStart) continue;
|
||||
|
||||
const covering = clipped
|
||||
.filter((segment) => segment.startMs < intervalEnd && segment.endMs > intervalStart)
|
||||
.sort((a, b) => b.priority - a.priority || b.startMs - a.startMs);
|
||||
|
||||
const winner = covering[0];
|
||||
if (!winner) continue;
|
||||
|
||||
if (winner.type === "production") {
|
||||
timeline.push({
|
||||
type: "production",
|
||||
startMs: intervalStart,
|
||||
endMs: intervalEnd,
|
||||
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
|
||||
workOrderId: winner.workOrderId,
|
||||
sku: winner.sku,
|
||||
label: winner.label,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (winner.type === "mold-change") {
|
||||
timeline.push({
|
||||
type: "mold-change",
|
||||
startMs: intervalStart,
|
||||
endMs: intervalEnd,
|
||||
fromMoldId: winner.fromMoldId,
|
||||
toMoldId: winner.toMoldId,
|
||||
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
|
||||
label: winner.label,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const stopType = normalizeStopType(winner.type);
|
||||
timeline.push({
|
||||
type: stopType,
|
||||
startMs: intervalStart,
|
||||
endMs: intervalEnd,
|
||||
reason: winner.reason,
|
||||
reasonLabel: winner.reason,
|
||||
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
|
||||
label: labelForStop(stopType, winner.reason),
|
||||
});
|
||||
}
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
function segmentPriority(type: RecapTimelineSegment["type"]) {
|
||||
if (type === "mold-change") return 4;
|
||||
if (type === "macrostop") return 3;
|
||||
if (type === "microstop" || type === "slow-cycle") return 2;
|
||||
if (type === "production") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cloneForRange(segment: RecapTimelineSegment, startMs: number, endMs: number): RecapTimelineSegment {
|
||||
if (segment.type === "production") {
|
||||
return {
|
||||
type: "production",
|
||||
startMs,
|
||||
endMs,
|
||||
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
|
||||
workOrderId: segment.workOrderId,
|
||||
sku: segment.sku,
|
||||
label: segment.label,
|
||||
};
|
||||
}
|
||||
if (segment.type === "mold-change") {
|
||||
return {
|
||||
type: "mold-change",
|
||||
startMs,
|
||||
endMs,
|
||||
fromMoldId: segment.fromMoldId,
|
||||
toMoldId: segment.toMoldId,
|
||||
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
|
||||
label: segment.label,
|
||||
};
|
||||
}
|
||||
if (segment.type === "macrostop" || segment.type === "microstop" || segment.type === "slow-cycle") {
|
||||
const stopType = normalizeStopType(segment.type);
|
||||
return {
|
||||
type: stopType,
|
||||
startMs,
|
||||
endMs,
|
||||
reason: segment.reason,
|
||||
reasonLabel: segment.reasonLabel ?? segment.reason,
|
||||
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
|
||||
label: segment.label,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "idle",
|
||||
startMs,
|
||||
endMs,
|
||||
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
|
||||
label: segment.label,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTimelineSegments(input: {
|
||||
cycles: TimelineCycleRow[];
|
||||
events: TimelineEventRow[];
|
||||
rangeStart: Date;
|
||||
rangeEnd: Date;
|
||||
}) {
|
||||
const rangeStartMs = input.rangeStart.getTime();
|
||||
const rangeEndMs = input.rangeEnd.getTime();
|
||||
|
||||
if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs <= rangeStartMs) {
|
||||
return [] as RecapTimelineSegment[];
|
||||
}
|
||||
|
||||
const dedupedCycles = dedupeByKey(
|
||||
input.cycles,
|
||||
(cycle) =>
|
||||
`${cycle.ts.getTime()}:${safeNum(cycle.cycleCount) ?? "na"}:${normalizeToken(cycle.workOrderId).toUpperCase()}:${normalizeToken(cycle.sku).toUpperCase()}:${safeNum(cycle.actualCycleTime) ?? "na"}`
|
||||
);
|
||||
|
||||
const rawSegments: RawSegment[] = [];
|
||||
|
||||
let currentProduction: RawSegment | null = null;
|
||||
for (const cycle of dedupedCycles) {
|
||||
if (!cycle.workOrderId) continue;
|
||||
const cycleStartMs = cycle.ts.getTime();
|
||||
const cycleDurationMs = Math.max(
|
||||
1000,
|
||||
Math.min(600000, Math.trunc((safeNum(cycle.actualCycleTime) ?? 1) * 1000))
|
||||
);
|
||||
const cycleEndMs = cycleStartMs + cycleDurationMs;
|
||||
|
||||
if (
|
||||
currentProduction &&
|
||||
currentProduction.type === "production" &&
|
||||
currentProduction.workOrderId === cycle.workOrderId &&
|
||||
currentProduction.sku === cycle.sku &&
|
||||
cycleStartMs <= currentProduction.endMs + 5 * 60 * 1000
|
||||
) {
|
||||
currentProduction.endMs = Math.max(currentProduction.endMs, cycleEndMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentProduction) rawSegments.push(currentProduction);
|
||||
currentProduction = {
|
||||
type: "production",
|
||||
startMs: cycleStartMs,
|
||||
endMs: cycleEndMs,
|
||||
priority: PRIORITY.production,
|
||||
workOrderId: cycle.workOrderId,
|
||||
sku: cycle.sku,
|
||||
label: cycle.workOrderId,
|
||||
};
|
||||
}
|
||||
if (currentProduction) rawSegments.push(currentProduction);
|
||||
|
||||
const eventEpisodes = new Map<
|
||||
string,
|
||||
{
|
||||
type: "mold-change" | "macrostop" | "microstop";
|
||||
firstTsMs: number;
|
||||
lastTsMs: number;
|
||||
startMs: number | null;
|
||||
endMs: number | null;
|
||||
durationSec: number | null;
|
||||
statusActive: boolean;
|
||||
statusResolved: boolean;
|
||||
reason: string | null;
|
||||
fromMoldId: string | null;
|
||||
toMoldId: string | null;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const event of input.events) {
|
||||
const eventType = String(event.eventType || "").toLowerCase() as TimelineEventType;
|
||||
if (!TIMELINE_EVENT_TYPES.includes(eventType)) continue;
|
||||
|
||||
const data = extractData(event.data);
|
||||
const isUpdate = safeBool(data.is_update ?? data.isUpdate);
|
||||
const isAutoAck = safeBool(data.is_auto_ack ?? data.isAutoAck);
|
||||
if (isUpdate || isAutoAck) continue;
|
||||
|
||||
const tsMs = event.ts.getTime();
|
||||
const key = eventIncidentKey(eventType, data, tsMs);
|
||||
const status = String(data.status ?? "").trim().toLowerCase();
|
||||
|
||||
const episode = eventEpisodes.get(key) ?? {
|
||||
type: eventType,
|
||||
firstTsMs: tsMs,
|
||||
lastTsMs: tsMs,
|
||||
startMs: null,
|
||||
endMs: null,
|
||||
durationSec: null,
|
||||
statusActive: false,
|
||||
statusResolved: false,
|
||||
reason: null,
|
||||
fromMoldId: null,
|
||||
toMoldId: null,
|
||||
};
|
||||
episode.firstTsMs = Math.min(episode.firstTsMs, tsMs);
|
||||
episode.lastTsMs = Math.max(episode.lastTsMs, tsMs);
|
||||
|
||||
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
|
||||
const endMs = safeNum(data.end_ms) ?? safeNum(data.endMs);
|
||||
const durationSec =
|
||||
safeNum(data.duration_sec) ??
|
||||
safeNum(data.stoppage_duration_seconds) ??
|
||||
safeNum(data.stop_duration_seconds) ??
|
||||
safeNum(data.duration_seconds);
|
||||
|
||||
if (startMs != null) episode.startMs = episode.startMs == null ? startMs : Math.min(episode.startMs, startMs);
|
||||
if (endMs != null) episode.endMs = episode.endMs == null ? endMs : Math.max(episode.endMs, endMs);
|
||||
if (durationSec != null) episode.durationSec = Math.max(0, Math.trunc(durationSec));
|
||||
|
||||
if (status === "active") episode.statusActive = true;
|
||||
if (status === "resolved") episode.statusResolved = true;
|
||||
|
||||
const reason = reasonLabelFromData(data);
|
||||
if (reason) episode.reason = reason;
|
||||
|
||||
const fromMoldId = String(data.from_mold_id ?? data.fromMoldId ?? "").trim() || null;
|
||||
const toMoldId = String(data.to_mold_id ?? data.toMoldId ?? "").trim() || null;
|
||||
if (fromMoldId) episode.fromMoldId = fromMoldId;
|
||||
if (toMoldId) episode.toMoldId = toMoldId;
|
||||
|
||||
eventEpisodes.set(key, episode);
|
||||
}
|
||||
|
||||
for (const episode of eventEpisodes.values()) {
|
||||
const startMs = Math.trunc(episode.startMs ?? episode.firstTsMs);
|
||||
let endMs = Math.trunc(episode.endMs ?? episode.lastTsMs);
|
||||
|
||||
if (episode.statusActive && !episode.statusResolved) {
|
||||
const isFreshActive = rangeEndMs - episode.lastTsMs <= ACTIVE_STALE_MS;
|
||||
endMs = isFreshActive ? rangeEndMs : episode.lastTsMs;
|
||||
} else if (endMs <= startMs && episode.durationSec != null && episode.durationSec > 0) {
|
||||
endMs = startMs + episode.durationSec * 1000;
|
||||
}
|
||||
|
||||
if (endMs <= startMs) continue;
|
||||
|
||||
if (episode.type === "mold-change") {
|
||||
rawSegments.push({
|
||||
type: "mold-change",
|
||||
startMs,
|
||||
endMs,
|
||||
priority: PRIORITY["mold-change"],
|
||||
fromMoldId: episode.fromMoldId,
|
||||
toMoldId: episode.toMoldId,
|
||||
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
|
||||
label: episode.toMoldId ? `Cambio molde ${episode.toMoldId}` : "Cambio molde",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
rawSegments.push({
|
||||
type: episode.type,
|
||||
startMs,
|
||||
endMs,
|
||||
priority: PRIORITY[episode.type],
|
||||
reason: episode.reason,
|
||||
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
|
||||
label: labelForStop(episode.type, episode.reason),
|
||||
});
|
||||
}
|
||||
|
||||
const initial = buildSegmentsFromBoundaries(rawSegments, rangeStartMs, rangeEndMs);
|
||||
const merged = mergeNearbyEquivalentSegments(initial, MERGE_GAP_MS);
|
||||
const withIdle = fillGapsWithIdle(merged, rangeStartMs, rangeEndMs);
|
||||
const clustered = absorbMicroStopClusters(withIdle, MICRO_CLUSTER_GAP_MS);
|
||||
const normalized = fillGapsWithIdle(clustered, rangeStartMs, rangeEndMs);
|
||||
const absorbed = absorbShortSegments(normalized, ABSORB_SHORT_SEGMENT_MS);
|
||||
const finalSegments = fillGapsWithIdle(absorbed, rangeStartMs, rangeEndMs);
|
||||
|
||||
return finalSegments;
|
||||
}
|
||||
|
||||
export function compressTimelineSegments(input: {
|
||||
segments: RecapTimelineSegment[];
|
||||
rangeStart: Date;
|
||||
rangeEnd: Date;
|
||||
maxSegments: number;
|
||||
}) {
|
||||
const rangeStartMs = input.rangeStart.getTime();
|
||||
const rangeEndMs = input.rangeEnd.getTime();
|
||||
const maxSegments = Math.max(1, Math.trunc(input.maxSegments || 1));
|
||||
|
||||
const normalized = fillGapsWithIdle(input.segments, rangeStartMs, rangeEndMs);
|
||||
if (normalized.length <= maxSegments) return normalized;
|
||||
|
||||
const totalMs = Math.max(1, rangeEndMs - rangeStartMs);
|
||||
const bucketMs = totalMs / maxSegments;
|
||||
const buckets: RecapTimelineSegment[] = [];
|
||||
|
||||
for (let i = 0; i < maxSegments; i += 1) {
|
||||
const bucketStart = Math.trunc(rangeStartMs + i * bucketMs);
|
||||
const bucketEnd = i === maxSegments - 1 ? rangeEndMs : Math.trunc(rangeStartMs + (i + 1) * bucketMs);
|
||||
if (bucketEnd <= bucketStart) continue;
|
||||
|
||||
let winner: RecapTimelineSegment | null = null;
|
||||
let winnerOverlap = -1;
|
||||
|
||||
for (const segment of normalized) {
|
||||
const overlapStart = Math.max(bucketStart, segment.startMs);
|
||||
const overlapEnd = Math.min(bucketEnd, segment.endMs);
|
||||
if (overlapEnd <= overlapStart) continue;
|
||||
|
||||
const overlap = overlapEnd - overlapStart;
|
||||
const priorityBonus = segmentPriority(segment.type) / 1000;
|
||||
const score = overlap + priorityBonus;
|
||||
if (score > winnerOverlap) {
|
||||
winner = segment;
|
||||
winnerOverlap = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (!winner) {
|
||||
buckets.push({
|
||||
type: "idle",
|
||||
startMs: bucketStart,
|
||||
endMs: bucketEnd,
|
||||
durationSec: Math.max(0, Math.trunc((bucketEnd - bucketStart) / 1000)),
|
||||
label: "Idle",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
buckets.push(cloneForRange(winner, bucketStart, bucketEnd));
|
||||
}
|
||||
|
||||
const merged = mergeNearbyEquivalentSegments(buckets, 0);
|
||||
return fillGapsWithIdle(merged, rangeStartMs, rangeEndMs);
|
||||
}
|
||||
185
lib/recap/timelineApi.ts
Normal file
185
lib/recap/timelineApi.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
buildTimelineSegments,
|
||||
compressTimelineSegments,
|
||||
TIMELINE_EVENT_TYPES,
|
||||
type TimelineCycleRow,
|
||||
type TimelineEventRow,
|
||||
} from "@/lib/recap/timeline";
|
||||
import type { RecapTimelineResponse } from "@/lib/recap/types";
|
||||
|
||||
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_RANGE_MS = 24 * 60 * 60 * 1000;
|
||||
const MIN_RANGE_MS = 60 * 1000;
|
||||
const MAX_RANGE_MS = 72 * 60 * 60 * 1000;
|
||||
|
||||
function parseDateInput(raw: string | null) {
|
||||
if (!raw) return null;
|
||||
const asNum = Number(raw);
|
||||
if (Number.isFinite(asNum)) {
|
||||
const d = new Date(asNum);
|
||||
return Number.isFinite(d.getTime()) ? d : null;
|
||||
}
|
||||
const d = new Date(raw);
|
||||
return Number.isFinite(d.getTime()) ? d : null;
|
||||
}
|
||||
|
||||
function parseRangeDurationMs(raw: string | null) {
|
||||
if (!raw) return null;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
const match = /^(\d+)\s*([hm])$/.exec(normalized);
|
||||
if (!match) return null;
|
||||
|
||||
const amount = Number(match[1]);
|
||||
if (!Number.isFinite(amount) || amount <= 0) return null;
|
||||
const unit = match[2];
|
||||
const durationMs = unit === "m" ? amount * 60_000 : amount * 60 * 60_000;
|
||||
return Math.max(MIN_RANGE_MS, Math.min(MAX_RANGE_MS, durationMs));
|
||||
}
|
||||
|
||||
function parseHours(raw: string | null) {
|
||||
if (!raw) return null;
|
||||
const parsed = Math.trunc(Number(raw));
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||
return Math.max(1, Math.min(72, parsed));
|
||||
}
|
||||
|
||||
function parseMaxSegments(searchParams: URLSearchParams) {
|
||||
const compact = searchParams.get("compact");
|
||||
const maxSegmentsRaw = searchParams.get("maxSegments");
|
||||
if (compact !== "1" && compact !== "true" && !maxSegmentsRaw) return null;
|
||||
|
||||
const parsed = Math.trunc(Number(maxSegmentsRaw ?? "30"));
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return 30;
|
||||
return Math.max(5, Math.min(120, parsed));
|
||||
}
|
||||
|
||||
export function parseRecapTimelineRange(searchParams: URLSearchParams) {
|
||||
const end = parseDateInput(searchParams.get("end")) ?? new Date();
|
||||
const startParam = parseDateInput(searchParams.get("start"));
|
||||
if (startParam && startParam < end) {
|
||||
return {
|
||||
start: startParam,
|
||||
end,
|
||||
maxSegments: parseMaxSegments(searchParams),
|
||||
};
|
||||
}
|
||||
|
||||
const rangeDurationMs =
|
||||
parseRangeDurationMs(searchParams.get("range")) ??
|
||||
(() => {
|
||||
const hours = parseHours(searchParams.get("hours"));
|
||||
return hours ? hours * 60 * 60 * 1000 : null;
|
||||
})() ??
|
||||
DEFAULT_RANGE_MS;
|
||||
|
||||
const start = new Date(end.getTime() - Math.max(MIN_RANGE_MS, Math.min(MAX_RANGE_MS, rangeDurationMs)));
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
maxSegments: parseMaxSegments(searchParams),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRecapTimelineForMachine(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
maxSegments?: number | null;
|
||||
}) {
|
||||
const [cyclesRaw, eventsRaw, cycleCount, eventCount] = await Promise.all([
|
||||
prisma.machineCycle.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
ts: { gte: params.start, lte: params.end },
|
||||
},
|
||||
orderBy: { ts: "asc" },
|
||||
select: {
|
||||
ts: true,
|
||||
cycleCount: true,
|
||||
actualCycleTime: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
},
|
||||
}),
|
||||
prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
|
||||
ts: {
|
||||
gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
|
||||
lte: params.end,
|
||||
},
|
||||
},
|
||||
orderBy: { ts: "asc" },
|
||||
select: {
|
||||
ts: true,
|
||||
eventType: true,
|
||||
data: true,
|
||||
},
|
||||
}),
|
||||
prisma.machineCycle.count({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
ts: { gte: params.start, lte: params.end },
|
||||
},
|
||||
}),
|
||||
prisma.machineEvent.count({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
ts: { gte: params.start, lte: params.end },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const hasData = cycleCount > 0 || eventCount > 0;
|
||||
|
||||
const cycles: TimelineCycleRow[] = cyclesRaw.map((row) => ({
|
||||
ts: row.ts,
|
||||
cycleCount: row.cycleCount,
|
||||
actualCycleTime: row.actualCycleTime,
|
||||
workOrderId: row.workOrderId,
|
||||
sku: row.sku,
|
||||
}));
|
||||
|
||||
const events: TimelineEventRow[] = eventsRaw.map((row) => ({
|
||||
ts: row.ts,
|
||||
eventType: row.eventType,
|
||||
data: row.data,
|
||||
}));
|
||||
|
||||
let segments = hasData
|
||||
? buildTimelineSegments({
|
||||
cycles,
|
||||
events,
|
||||
rangeStart: params.start,
|
||||
rangeEnd: params.end,
|
||||
})
|
||||
: [];
|
||||
|
||||
if (hasData && params.maxSegments && params.maxSegments > 0) {
|
||||
segments = compressTimelineSegments({
|
||||
segments,
|
||||
rangeStart: params.start,
|
||||
rangeEnd: params.end,
|
||||
maxSegments: params.maxSegments,
|
||||
});
|
||||
}
|
||||
|
||||
const response: RecapTimelineResponse = {
|
||||
range: {
|
||||
start: params.start.toISOString(),
|
||||
end: params.end.toISOString(),
|
||||
},
|
||||
segments,
|
||||
hasData,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -60,6 +60,7 @@ export type RecapTimelineSegment =
|
||||
type: "production";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
durationSec: number;
|
||||
workOrderId: string | null;
|
||||
sku: string | null;
|
||||
label: string;
|
||||
@@ -78,6 +79,7 @@ export type RecapTimelineSegment =
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
reason: string | null;
|
||||
reasonLabel?: string | null;
|
||||
durationSec: number;
|
||||
label: string;
|
||||
}
|
||||
@@ -85,6 +87,7 @@ export type RecapTimelineSegment =
|
||||
type: "idle";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
durationSec: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
@@ -94,6 +97,8 @@ export type RecapTimelineResponse = {
|
||||
end: string;
|
||||
};
|
||||
segments: RecapTimelineSegment[];
|
||||
hasData: boolean;
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
export type RecapResponse = {
|
||||
@@ -115,3 +120,100 @@ export type RecapQuery = {
|
||||
end?: Date;
|
||||
shift?: string;
|
||||
};
|
||||
|
||||
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "offline";
|
||||
|
||||
export type RecapSummaryMachine = {
|
||||
machineId: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
status: RecapMachineStatus;
|
||||
oee: number | null;
|
||||
goodParts: number;
|
||||
scrap: number;
|
||||
stopsCount: number;
|
||||
lastSeenMs: number | null;
|
||||
lastActivityMin: number | null;
|
||||
offlineForMin: number | null;
|
||||
ongoingStopMin: number | null;
|
||||
activeWorkOrderId: string | null;
|
||||
moldChange: {
|
||||
active: boolean;
|
||||
startMs: number | null;
|
||||
elapsedMin: number | null;
|
||||
} | null;
|
||||
miniTimeline: RecapTimelineSegment[];
|
||||
};
|
||||
|
||||
export type RecapSummaryResponse = {
|
||||
generatedAt: string;
|
||||
range: {
|
||||
start: string;
|
||||
end: string;
|
||||
hours: number;
|
||||
};
|
||||
machines: RecapSummaryMachine[];
|
||||
};
|
||||
|
||||
export type RecapRangeMode = "24h" | "shift" | "yesterday" | "custom";
|
||||
|
||||
export type RecapDowntimeTopRow = {
|
||||
reasonLabel: string;
|
||||
minutes: number;
|
||||
count: number;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
export type RecapWorkOrders = {
|
||||
completed: Array<{
|
||||
id: string;
|
||||
sku: string | null;
|
||||
goodParts: number;
|
||||
durationHrs: number;
|
||||
}>;
|
||||
active: {
|
||||
id: string;
|
||||
sku: string | null;
|
||||
progressPct: number | null;
|
||||
startedAt: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type RecapMachineDetail = {
|
||||
machineId: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
status: RecapMachineStatus;
|
||||
oee: number | null;
|
||||
goodParts: number;
|
||||
scrap: number;
|
||||
stopsCount: number;
|
||||
stopMinutes: number;
|
||||
activeWorkOrderId: string | null;
|
||||
lastSeenMs: number | null;
|
||||
offlineForMin: number | null;
|
||||
ongoingStopMin: number | null;
|
||||
moldChange: {
|
||||
active: boolean;
|
||||
startMs: number | null;
|
||||
} | null;
|
||||
timeline: RecapTimelineSegment[];
|
||||
productionBySku: RecapSkuRow[];
|
||||
downtimeTop: RecapDowntimeTopRow[];
|
||||
workOrders: RecapWorkOrders;
|
||||
heartbeat: {
|
||||
lastSeenAt: string | null;
|
||||
uptimePct: number | null;
|
||||
connectionStatus: "online" | "offline";
|
||||
};
|
||||
};
|
||||
|
||||
export type RecapDetailResponse = {
|
||||
generatedAt: string;
|
||||
range: {
|
||||
mode: RecapRangeMode;
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
machine: RecapMachineDetail;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user