import { unstable_cache } from "next/cache"; import { prisma } from "@/lib/prisma"; import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings"; import type { RecapMachine, RecapQuery, RecapResponse } from "@/lib/recap/types"; type ShiftLike = { name: string; startTime?: string | null; endTime?: string | null; start?: string | null; end?: string | null; enabled?: boolean; }; const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; const WEEKDAY_KEY_MAP: Record = { Mon: "mon", Tue: "tue", Wed: "wed", Thu: "thu", Fri: "fri", Sat: "sat", Sun: "sun", }; const STOP_TYPES = new Set(["microstop", "macrostop"]); const STOP_STATUS = new Set(["STOP", "DOWN", "OFFLINE"]); const CACHE_TTL_SEC = 60; const MOLD_LOOKBACK_MS = 14 * 24 * 60 * 60 * 1000; function safeNum(value: unknown) { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string") { const n = Number(value); if (Number.isFinite(n)) return n; } 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 workOrderKey(value: unknown) { const token = normalizeToken(value); return token ? token.toUpperCase() : ""; } function skuKey(value: unknown) { const token = normalizeToken(value); return token ? token.toUpperCase() : ""; } function dedupeByKey(rows: T[], keyFn: (row: T) => string) { const seen = new Set(); 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 toIso(value?: Date | null) { return value ? value.toISOString() : null; } 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.isNaN(n)) return new Date(n); const d = new Date(input); return Number.isNaN(d.getTime()) ? null : d; } function normalizeRange(start?: Date, end?: Date) { const now = new Date(); const safeEnd = end && Number.isFinite(end.getTime()) ? end : now; const defaultStart = new Date(safeEnd.getTime() - 24 * 60 * 60 * 1000); const safeStart = start && Number.isFinite(start.getTime()) ? start : defaultStart; if (safeStart.getTime() > safeEnd.getTime()) { return { start: new Date(safeEnd.getTime() - 24 * 60 * 60 * 1000), end: safeEnd }; } return { start: safeStart, end: safeEnd }; } function parseTimeMinutes(input?: string | null) { if (!input) return null; const match = /^(\d{2}):(\d{2})$/.exec(input.trim()); if (!match) return null; const h = Number(match[1]); const m = Number(match[2]); if (!Number.isInteger(h) || !Number.isInteger(m) || h < 0 || h > 23 || m < 0 || m > 59) return null; return h * 60 + m; } function getLocalMinutes(ts: Date, timeZone: string) { try { const parts = new Intl.DateTimeFormat("en-US", { timeZone, hour12: false, hour: "2-digit", minute: "2-digit", }).formatToParts(ts); const h = Number(parts.find((p) => p.type === "hour")?.value ?? "0"); const m = Number(parts.find((p) => p.type === "minute")?.value ?? "0"); return h * 60 + m; } catch { return ts.getUTCHours() * 60 + ts.getUTCMinutes(); } } function getLocalDayKey(ts: Date, timeZone: string): ShiftOverrideDay { try { const weekday = new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short" }).format(ts); return WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()]; } catch { return WEEKDAY_KEYS[ts.getUTCDay()]; } } function resolveShiftName( shifts: ShiftLike[], overrides: Record | undefined, ts: Date, timeZone: string ) { const dayKey = getLocalDayKey(ts, timeZone); const dayOverrides = overrides?.[dayKey]; const activeShifts = dayOverrides ?? shifts; if (!activeShifts.length) return null; const nowMin = getLocalMinutes(ts, timeZone); for (const shift of activeShifts) { if (shift.enabled === false) continue; const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null); const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null); if (start == null || end == null) continue; if (start <= end) { if (nowMin >= start && nowMin < end) return shift.name; } else if (nowMin >= start || nowMin < end) { return shift.name; } } return null; } function normalizeShiftAlias(shift?: string | null) { const normalized = String(shift ?? "").trim().toLowerCase(); if (!normalized) return null; if (normalized === "shift1" || normalized === "shift2" || normalized === "shift3") return normalized; return null; } function eventDurationSec(data: unknown) { const inner = extractEventData(data); return ( safeNum(inner.stoppage_duration_seconds) ?? safeNum(inner.stop_duration_seconds) ?? safeNum(inner.duration_seconds) ?? safeNum(inner.duration_sec) ?? safeNum(inner.durationSeconds) ?? 0 ); } function extractEventData(data: unknown) { let blob = data; if (typeof blob === "string") { try { blob = JSON.parse(blob); } catch { blob = null; } } const record = typeof blob === "object" && blob ? (blob as Record) : null; const innerCandidate = record?.data ?? record ?? {}; const inner = typeof innerCandidate === "object" && innerCandidate !== null ? (innerCandidate as Record) : {}; return inner; } function eventStatus(data: unknown) { const inner = extractEventData(data); 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(); if (direct) return direct; const startMs = safeNum(inner.start_ms) ?? safeNum(inner.startMs); if (startMs != null) return `${eventType}:${Math.trunc(startMs)}`; return `${eventType}:${ts.getTime()}`; } function moldStartMs(data: unknown, fallbackTs: Date) { const inner = extractEventData(data); return Math.trunc(safeNum(inner.start_ms) ?? safeNum(inner.startMs) ?? fallbackTs.getTime()); } 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[]; }) { if (!params.machineIds.length) return [] as WorkOrderCounterRow[]; return prisma.machineWorkOrder.findMany({ where: { orgId: params.orgId, machineId: { in: params.machineIds }, }, select: { machineId: true, workOrderId: true, sku: true, targetQty: true, status: true, createdAt: true, updatedAt: true, goodParts: true, scrapParts: true, cycleCount: true, }, }); } export function parseRecapQuery(input: { machineId?: string | null; start?: string | null; end?: string | null; shift?: string | null; }) { return { machineId: input.machineId ? String(input.machineId).trim() : undefined, start: parseDate(input.start), end: parseDate(input.end), shift: normalizeShiftAlias(input.shift), }; } async function computeRecap(params: Required> & { machineId?: string; start: Date; end: Date; shift?: string; }): Promise { const machineFilter = params.machineId ? { id: params.machineId } : {}; const machines = await prisma.machine.findMany({ where: { orgId: params.orgId, ...machineFilter }, orderBy: { name: "asc" }, select: { id: true, name: true, location: true }, }); if (!machines.length) { return { range: { start: params.start.toISOString(), end: params.end.toISOString() }, availableShifts: [], machines: [], }; } 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, workOrderCounterRowsRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] = await Promise.all([ prisma.orgSettings.findUnique({ where: { orgId: params.orgId }, select: { timezone: true, shiftScheduleOverridesJson: true }, }), prisma.orgShift.findMany({ where: { orgId: params.orgId }, orderBy: { sortOrder: "asc" }, select: { name: true, startTime: true, endTime: true, enabled: true, sortOrder: true }, }), prisma.machineCycle.findMany({ where: { orgId: params.orgId, machineId: { in: machineIds }, ts: { gte: params.start, lte: params.end }, }, select: { machineId: true, ts: true, cycleCount: true, workOrderId: true, sku: true, goodDelta: true, scrapDelta: true, }, }), prisma.machineKpiSnapshot.findMany({ where: { orgId: params.orgId, machineId: { in: machineIds }, ts: { gte: params.start, lte: params.end }, }, orderBy: [{ machineId: "asc" }, { ts: "asc" }], select: { machineId: true, ts: true, workOrderId: true, sku: true, good: true, scrap: true, goodParts: true, scrapParts: true, cycleCount: true, oee: true, availability: true, performance: true, quality: true, }, }), prisma.machineEvent.findMany({ where: { orgId: params.orgId, machineId: { in: machineIds }, ts: { gte: params.start, lte: params.end }, }, select: { machineId: true, ts: true, eventType: true, data: true, }, }), prisma.reasonEntry.findMany({ where: { orgId: params.orgId, machineId: { in: machineIds }, kind: "downtime", reasonCode: { not: "MOLD_CHANGE" }, capturedAt: { gte: params.start, lte: params.end }, }, select: { machineId: true, capturedAt: true, reasonCode: true, reasonLabel: true, durationSeconds: true, }, }), prisma.machineWorkOrder.findMany({ where: { orgId: params.orgId, machineId: { in: machineIds }, }, orderBy: { updatedAt: "desc" }, select: { machineId: true, workOrderId: true, sku: true, targetQty: true, status: true, createdAt: true, updatedAt: true, }, }), loadWorkOrderCounterRows({ orgId: params.orgId, machineIds, }), prisma.machineHeartbeat.findMany({ where: { orgId: params.orgId, machineId: { in: machineIds }, ts: { gte: params.start, lte: params.end }, }, orderBy: [{ machineId: "asc" }, { ts: "asc" }], select: { machineId: true, ts: true, tsServer: true, status: true, }, }), prisma.machineHeartbeat.findMany({ where: { orgId: params.orgId, machineId: { in: machineIds }, ts: { lte: params.end }, }, orderBy: [{ machineId: "asc" }, { ts: "desc" }], distinct: ["machineId"], select: { machineId: true, ts: true, tsServer: true, status: true, }, }), prisma.machineEvent.findMany({ where: { orgId: params.orgId, machineId: { in: machineIds }, eventType: "mold-change", ts: { gte: moldStartLookback, lte: params.end }, }, orderBy: [{ machineId: "asc" }, { ts: "asc" }], select: { machineId: true, ts: true, data: true, }, }), ]); const timeZone = settings?.timezone || "UTC"; const shiftOverrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson); const orderedEnabledShifts = shifts.filter((s) => s.enabled !== false).sort((a, b) => a.sortOrder - b.sortOrder); const shiftIndex = params.shift ? Number(params.shift.replace("shift", "")) - 1 : -1; const targetShiftName = shiftIndex >= 0 ? orderedEnabledShifts[shiftIndex]?.name ?? "__missing_shift__" : null; const inTargetShift = (ts: Date) => { if (!targetShiftName) return true; const resolved = resolveShiftName(shifts, shiftOverrides, ts, timeZone); return resolved === targetShiftName; }; const cycles = targetShiftName ? cyclesRaw.filter((row) => inTargetShift(row.ts)) : cyclesRaw; const kpis = targetShiftName ? kpisRaw.filter((row) => inTargetShift(row.ts)) : kpisRaw; const events = targetShiftName ? eventsRaw.filter((row) => inTargetShift(row.ts)) : eventsRaw; const reasons = targetShiftName ? reasonsRaw.filter((row) => inTargetShift(row.capturedAt)) : reasonsRaw; const hbRange = targetShiftName ? hbRangeRaw.filter((row) => inTargetShift(row.ts)) : hbRangeRaw; const cyclesByMachine = new Map(); const kpisByMachine = new Map(); const eventsByMachine = new Map(); const reasonsByMachine = new Map(); const workOrdersByMachine = new Map(); const workOrderCountersByMachine = new Map(); const hbRangeByMachine = new Map(); const hbLatestByMachine = new Map(hbLatestRaw.map((row) => [row.machineId, row])); const moldEventsByMachine = new Map(); for (const row of cycles) { const list = cyclesByMachine.get(row.machineId) ?? []; list.push(row); cyclesByMachine.set(row.machineId, list); } for (const row of kpis) { const list = kpisByMachine.get(row.machineId) ?? []; list.push(row); kpisByMachine.set(row.machineId, list); } for (const row of events) { const list = eventsByMachine.get(row.machineId) ?? []; list.push(row); eventsByMachine.set(row.machineId, list); } for (const row of reasons) { const list = reasonsByMachine.get(row.machineId) ?? []; list.push(row); reasonsByMachine.set(row.machineId, list); } for (const row of workOrdersRaw) { const list = workOrdersByMachine.get(row.machineId) ?? []; list.push(row); 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); hbRangeByMachine.set(row.machineId, list); } for (const row of moldEventsRaw) { const list = moldEventsByMachine.get(row.machineId) ?? []; list.push(row); moldEventsByMachine.set(row.machineId, list); } const machineRows: RecapMachine[] = machines.map((machine) => { const machineCycles = cyclesByMachine.get(machine.id) ?? []; const machineKpis = kpisByMachine.get(machine.id) ?? []; 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) ?? []; const dedupedCycles = dedupeByKey( machineCycles, (cycle) => `${cycle.ts.getTime()}:${safeNum(cycle.cycleCount) ?? "na"}:${workOrderKey(cycle.workOrderId)}:${skuKey(cycle.sku)}:${safeNum(cycle.goodDelta) ?? "na"}:${safeNum(cycle.scrapDelta) ?? "na"}` ); const dedupedKpis = dedupeByKey( machineKpis, (kpi) => `${kpi.ts.getTime()}:${workOrderKey(kpi.workOrderId)}:${skuKey(kpi.sku)}:${safeNum(kpi.goodParts) ?? safeNum(kpi.good) ?? "na"}:${safeNum(kpi.scrapParts) ?? safeNum(kpi.scrap) ?? "na"}:${safeNum(kpi.cycleCount) ?? "na"}` ); const machineWorkOrdersSorted = [...machineWorkOrders].sort( (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() ); const targetBySku = new Map(); for (const wo of machineWorkOrdersSorted) { const sku = normalizeToken(wo.sku); const target = safeNum(wo.targetQty); if (!sku || target == null || target <= 0) continue; const key = skuKey(sku); const current = targetBySku.get(key); if (current) { current.target += Math.max(0, Math.trunc(target)); } else { targetBySku.set(key, { sku, target: Math.max(0, Math.trunc(target)) }); } } type SkuAggregate = { machineName: string; sku: string; good: number; scrap: number; target: number | null; }; let latestTelemetry: { ts: Date; workOrderId: string | null; sku: string | null } | null = null; for (const kpi of dedupedKpis) { if (!latestTelemetry || kpi.ts > latestTelemetry.ts) { latestTelemetry = { ts: kpi.ts, workOrderId: normalizeToken(kpi.workOrderId) || null, sku: normalizeToken(kpi.sku) || null, }; } } if (!latestTelemetry) { for (const cycle of dedupedCycles) { if (!latestTelemetry || cycle.ts > latestTelemetry.ts) { latestTelemetry = { ts: cycle.ts, workOrderId: normalizeToken(cycle.workOrderId) || null, sku: normalizeToken(cycle.sku) || null, }; } } } const openWorkOrders = machineWorkOrdersSorted.filter( (wo) => String(wo.status).toUpperCase() !== "COMPLETED" ); const authoritativeWorkOrderProgress = new Map< string, { goodParts: number; scrapParts: number; cycleCount: number; firstTs: Date | null; lastTs: Date | null } >(); const authoritativeSkuMap = new Map(); let goodParts = 0; let scrapParts = 0; let authoritativeCycleCount = 0; const ensureAuthoritativeSku = ( skuInput: string | null, targetInput?: number | null, useFallbackTarget = true ) => { const skuToken = normalizeToken(skuInput) || "N/A"; const skuTokenKey = skuKey(skuToken); const targetFallback = useFallbackTarget ? targetBySku.get(skuTokenKey)?.target ?? null : null; const explicitTarget = targetInput != null && targetInput > 0 ? Math.max(0, Math.trunc(targetInput)) : null; const normalizedTarget = explicitTarget ?? targetFallback; const existing = authoritativeSkuMap.get(skuTokenKey); if (existing) { if (explicitTarget != null) { existing.target = (existing.target ?? 0) + explicitTarget; } else if (normalizedTarget != null && existing.target == null) { existing.target = normalizedTarget; } return existing; } const created: SkuAggregate = { machineName: machine.name, sku: skuToken, good: 0, scrap: 0, target: normalizedTarget, }; authoritativeSkuMap.set(skuTokenKey, created); return created; }; 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 target = safeNum(row.targetQty); const skuAgg = ensureAuthoritativeSku(row.sku, target, false); skuAgg.good += safeGood; skuAgg.scrap += safeScrap; goodParts += safeGood; scrapParts += 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); } for (const wo of openWorkOrders) { ensureAuthoritativeSku(normalizeToken(wo.sku) || null); } if (latestTelemetry?.sku) { ensureAuthoritativeSku(latestTelemetry.sku); } const bySku = [...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); 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); } const reasonAgg = new Map(); let stopDurSecFromReasons = 0; for (const reason of machineReasons) { const label = reason.reasonLabel?.trim() || reason.reasonCode || "Sin razón"; const seconds = Math.max(0, safeNum(reason.durationSeconds) ?? 0); stopDurSecFromReasons += seconds; const agg = reasonAgg.get(label) ?? { reasonLabel: label, seconds: 0, count: 0 }; agg.seconds += seconds; agg.count += 1; reasonAgg.set(label, agg); } const topReasons = [...reasonAgg.values()] .sort((a, b) => b.seconds - a.seconds) .slice(0, 3) .map((row) => ({ reasonLabel: row.reasonLabel, minutes: round2(row.seconds / 60), count: row.count, })); const totalMin = round2(Math.max(stopDurSecFromEvents, stopDurSecFromReasons) / 60); let ongoingStopMin: number | null = null; const latestStatus = String(latestHb?.status ?? "").toUpperCase(); const latestTs = latestHb?.tsServer ?? latestHb?.ts ?? null; if (latestTs && STOP_STATUS.has(latestStatus)) { let downStart = latestTs; for (let i = machineHbRange.length - 1; i >= 0; i -= 1) { const hb = machineHbRange[i]; const hbStatus = String(hb.status ?? "").toUpperCase(); if (!STOP_STATUS.has(hbStatus)) break; downStart = hb.tsServer ?? hb.ts; } ongoingStopMin = round2(Math.max(0, (params.end.getTime() - downStart.getTime()) / 60000)); } const completed = machineWorkOrdersSorted .filter((wo) => String(wo.status).toUpperCase() === "COMPLETED") .filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end) .map((wo) => { const progress = authoritativeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? { goodParts: 0, scrapParts: 0, cycleCount: 0, firstTs: null, lastTs: null, }; const durationHrs = progress.firstTs && progress.lastTs ? round2((progress.lastTs.getTime() - progress.firstTs.getTime()) / 3600000) : 0; return { id: wo.workOrderId, sku: wo.sku, goodParts: progress.goodParts, durationHrs, }; }) .sort((a, b) => b.goodParts - a.goodParts); const telemetryWorkOrderKey = workOrderKey(latestTelemetry?.workOrderId); const matchedTelemetryWo = telemetryWorkOrderKey ? openWorkOrders.find((wo) => workOrderKey(wo.workOrderId) === telemetryWorkOrderKey) ?? null : null; const activeWo = matchedTelemetryWo ?? openWorkOrders[0] ?? null; const activeWorkOrderId = normalizeToken(latestTelemetry?.workOrderId) || normalizeToken(activeWo?.workOrderId) || null; const activeWorkOrderSku = normalizeToken(latestTelemetry?.sku) || normalizeToken(activeWo?.sku) || null; const activeWorkOrderKey = workOrderKey(activeWorkOrderId); const activeTargetSource = activeWorkOrderKey ? machineWorkOrdersSorted.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ?? activeWo : activeWo; let activeProgressPct: number | null = null; let activeStartedAt: string | null = null; if (activeWorkOrderId) { const authoritativeProgress = activeWorkOrderKey ? authoritativeWorkOrderProgress.get(activeWorkOrderKey) ?? null : null; const producedForProgress = authoritativeProgress ? authoritativeProgress.goodParts + authoritativeProgress.scrapParts : 0; const targetQty = safeNum(activeTargetSource?.targetQty); if (targetQty && targetQty > 0) { activeProgressPct = round2((producedForProgress / targetQty) * 100); } activeStartedAt = toIso( authoritativeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null ); } const moldActiveByIncident = new Map(); for (const event of machineMoldEvents) { const key = eventIncidentKey(event.data, "mold-change", event.ts); const status = eventStatus(event.data); if (status === "resolved") { moldActiveByIncident.delete(key); continue; } if (status === "active" || !status) { moldActiveByIncident.set(key, moldStartMs(event.data, event.ts)); } } let moldChangeStartMs: number | null = null; for (const startMs of moldActiveByIncident.values()) { if (moldChangeStartMs == null || startMs > moldChangeStartMs) moldChangeStartMs = startMs; } const moldChangeInProgress = moldChangeStartMs != null; let uptimePct: number | null = null; if (machineHbRange.length) { let onlineCount = 0; for (const hb of machineHbRange) { const status = String(hb.status ?? "").toUpperCase(); if (!STOP_STATUS.has(status)) onlineCount += 1; } uptimePct = round2((onlineCount / machineHbRange.length) * 100); } return { machineId: machine.id, machineName: machine.name, location: machine.location, production: { goodParts, scrapParts, totalCycles: authoritativeCycleCount, bySku, }, oee: { avg: weightedAvg("oee"), availability: weightedAvg("availability"), performance: weightedAvg("performance"), quality: weightedAvg("quality"), }, downtime: { totalMin, stopsCount, topReasons, ongoingStopMin, }, workOrders: { completed, active: activeWorkOrderId ? { id: activeWorkOrderId, sku: activeWorkOrderSku, progressPct: activeProgressPct, startedAt: activeStartedAt, } : null, moldChangeInProgress, moldChangeStartMs, }, heartbeat: { lastSeenAt: toIso(latestTs), uptimePct, }, }; }); return { range: { start: params.start.toISOString(), end: params.end.toISOString(), }, availableShifts: orderedEnabledShifts.map((shift, idx) => ({ id: `shift${idx + 1}`, name: shift.name, })), machines: machineRows, }; } export async function getRecapDataCached(params: RecapQuery): Promise { const { start, end } = normalizeRange(params.start, params.end); const machineId = params.machineId?.trim() || undefined; const shift = normalizeShiftAlias(params.shift) ?? undefined; const cacheKey = [ "recap", params.orgId, machineId ?? "all", String(start.getTime()), String(end.getTime()), shift ?? "all", ]; const cached = unstable_cache( () => computeRecap({ orgId: params.orgId, machineId, start, end, shift, }), cacheKey, { revalidate: CACHE_TTL_SEC, tags: [`recap:${params.orgId}`], } ); return cached(); }