diff --git a/app/(app)/recap/RecapClient.tsx b/app/(app)/recap/RecapClient.tsx index 940c01f..1b7f932 100644 --- a/app/(app)/recap/RecapClient.tsx +++ b/app/(app)/recap/RecapClient.tsx @@ -2,12 +2,13 @@ import { useEffect, useMemo, useState } from "react"; import { useI18n } from "@/lib/i18n/useI18n"; -import type { RecapMachine, RecapResponse } from "@/lib/recap/types"; +import type { RecapMachine, RecapResponse, RecapTimelineResponse } from "@/lib/recap/types"; import RecapKpiRow from "@/components/recap/RecapKpiRow"; import RecapProductionBySku from "@/components/recap/RecapProductionBySku"; import RecapDowntimeTop from "@/components/recap/RecapDowntimeTop"; import RecapWorkOrderStatus from "@/components/recap/RecapWorkOrderStatus"; import RecapMachineStatus from "@/components/recap/RecapMachineStatus"; +import RecapTimeline from "@/components/recap/RecapTimeline"; type Props = { initialData: RecapResponse; @@ -46,6 +47,25 @@ export default function RecapClient({ initialData, initialFilters }: Props) { return "24h"; }); const [loading, setLoading] = useState(false); + const [timeline, setTimeline] = useState(null); + + const shiftOptions = useMemo( + () => + data.availableShifts?.length + ? data.availableShifts + : [ + { id: "shift1", name: t("recap.shift.1") }, + { id: "shift2", name: t("recap.shift.2") }, + { id: "shift3", name: t("recap.shift.3") }, + ], + [data.availableShifts, t] + ); + + useEffect(() => { + if (mode !== "shift") return; + if (shiftOptions.some((option) => option.id === shift)) return; + setShift(shiftOptions[0]?.id ?? "shift1"); + }, [mode, shift, shiftOptions]); useEffect(() => { let alive = true; @@ -108,6 +128,41 @@ export default function RecapClient({ initialData, initialFilters }: Props) { return data.machines.find((m) => m.machineId === machineId) ?? data.machines[0]; }, [data.machines, machineId]); + useEffect(() => { + let alive = true; + + async function loadTimeline() { + if (mode !== "24h") { + if (alive) setTimeline(null); + return; + } + if (!selectedMachine?.machineId) { + if (alive) setTimeline(null); + return; + } + + const qs = new URLSearchParams({ + machineId: selectedMachine.machineId, + hours: "24", + start: data.range.start, + end: data.range.end, + }); + const res = await fetch(`/api/recap/timeline?${qs.toString()}`, { cache: "no-cache" }); + const json = await res.json().catch(() => null); + if (!alive) return; + if (res.ok && json && json.segments) { + setTimeline(json as RecapTimelineResponse); + } else { + setTimeline(null); + } + } + + void loadTimeline(); + return () => { + alive = false; + }; + }, [mode, selectedMachine?.machineId, data.range.start, data.range.end]); + const fleet = useMemo(() => { let good = 0; let scrap = 0; @@ -132,6 +187,11 @@ export default function RecapClient({ initialData, initialFilters }: Props) { }, [data.machines]); const bannerMold = selectedMachine?.workOrders.moldChangeInProgress; + const moldStartMs = selectedMachine?.workOrders.moldChangeStartMs ?? null; + const moldStartLabel = moldStartMs + ? new Date(moldStartMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }) + : "--:--"; + const moldElapsedMin = moldStartMs ? Math.max(0, Math.floor((Date.now() - moldStartMs) / 60000)) : null; const bannerStop = (selectedMachine?.downtime.ongoingStopMin ?? 0) > 0; return ( @@ -174,9 +234,11 @@ export default function RecapClient({ initialData, initialFilters }: Props) { onChange={(event) => setShift(event.target.value)} className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200" > - - - + {shiftOptions.map((option) => ( + + ))} ) : null} @@ -202,7 +264,8 @@ export default function RecapClient({ initialData, initialFilters }: Props) { {bannerMold ? (
- {t("recap.banner.mold")} {selectedMachine?.workOrders.active?.startedAt ? new Date(selectedMachine.workOrders.active.startedAt).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }) : "--:--"} + {t("recap.banner.mold")} {moldStartLabel} + {moldElapsedMin != null ? ` · ${moldElapsedMin} min` : ""}
) : null} {bannerStop ? ( @@ -213,6 +276,15 @@ export default function RecapClient({ initialData, initialFilters }: Props) { {loading ?
{t("common.loading")}
: null} + {timeline ? ( + + ) : null} +
@@ -227,6 +299,7 @@ export default function RecapClient({ initialData, initialFilters }: Props) { completed: [], active: null, moldChangeInProgress: false, + moldChangeStartMs: null, } } /> diff --git a/app/api/analytics/downtime-events/route.ts b/app/api/analytics/downtime-events/route.ts index c234f4a..9da3c7b 100644 --- a/app/api/analytics/downtime-events/route.ts +++ b/app/api/analytics/downtime-events/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange"; +import type { Prisma } from "@prisma/client"; const bad = (status: number, error: string) => NextResponse.json({ ok: false, error }, { status }); @@ -24,6 +25,7 @@ export async function GET(req: Request) { const machineId = url.searchParams.get("machineId"); // optional const reasonCode = url.searchParams.get("reasonCode"); // optional + const includeMoldChange = url.searchParams.get("includeMoldChange") === "true"; const limitRaw = url.searchParams.get("limit"); const limit = Math.min(Math.max(Number(limitRaw || 200), 1), 500); @@ -44,10 +46,11 @@ export async function GET(req: Request) { // ✅ Query ReasonEntry as the "episode" table for downtime // We only return rows that have an episodeId (true downtime episodes) - const where: any = { + const where: Prisma.ReasonEntryWhereInput = { orgId, kind: "downtime", episodeId: { not: null }, + ...(includeMoldChange ? {} : { reasonCode: { not: "MOLD_CHANGE" } }), capturedAt: { gte: start, ...(beforeDate ? { lt: beforeDate } : {}), @@ -122,6 +125,7 @@ export async function GET(req: Request) { start, machineId: machineId ?? null, reasonCode: reasonCode ?? null, + includeMoldChange, limit, before: before ?? null, nextBefore, // pass this back for pagination diff --git a/app/api/analytics/pareto/route.ts b/app/api/analytics/pareto/route.ts index 1f3b61a..9267a4a 100644 --- a/app/api/analytics/pareto/route.ts +++ b/app/api/analytics/pareto/route.ts @@ -20,9 +20,10 @@ export async function GET(req: Request) { const machineId = url.searchParams.get("machineId"); // optional const kind = (url.searchParams.get("kind") || "downtime").toLowerCase(); + const includeMoldChange = url.searchParams.get("includeMoldChange") === "true"; - if (kind !== "downtime" && kind !== "scrap") { - return bad(400, "Invalid kind (downtime|scrap)"); + if (kind !== "downtime" && kind !== "scrap" && kind !== "planned-downtime") { + return bad(400, "Invalid kind (downtime|scrap|planned-downtime)"); } // ✅ If machineId provided, verify it belongs to this org @@ -40,7 +41,9 @@ export async function GET(req: Request) { where: { orgId, ...(machineId ? { machineId } : {}), - kind, + kind: kind === "planned-downtime" ? "downtime" : kind, + ...(kind === "downtime" && !includeMoldChange ? { reasonCode: { not: "MOLD_CHANGE" } } : {}), + ...(kind === "planned-downtime" ? { reasonCode: "MOLD_CHANGE" } : {}), capturedAt: { gte: start }, }, _sum: { @@ -53,7 +56,7 @@ export async function GET(req: Request) { const itemsRaw = grouped .map((g) => { const value = - kind === "downtime" + kind === "downtime" || kind === "planned-downtime" ? Math.round(((g._sum.durationSeconds ?? 0) / 60) * 10) / 10 // minutes, 1 decimal : g._sum.scrapQty ?? 0; @@ -64,7 +67,9 @@ export async function GET(req: Request) { count: g._count._all, }; }) - .filter((x) => (kind === "downtime" ? x.value > 0 || x.count > 0 : x.value > 0)); + .filter((x) => + kind === "downtime" || kind === "planned-downtime" ? x.value > 0 || x.count > 0 : x.value > 0 + ); itemsRaw.sort((a, b) => b.value - a.value); @@ -83,7 +88,7 @@ export async function GET(req: Request) { return { reasonCode: x.reasonCode, reasonLabel: x.reasonLabel, - minutesLost: kind === "downtime" ? x.value : undefined, + minutesLost: kind === "downtime" || kind === "planned-downtime" ? x.value : undefined, scrapQty: kind === "scrap" ? x.value : undefined, pctOfTotal, cumulativePct, @@ -106,9 +111,10 @@ export async function GET(req: Request) { orgId, machineId: machineId ?? null, kind, + includeMoldChange, range, // ✅ now defined correctly start, // ✅ now defined correctly - totalMinutesLost: kind === "downtime" ? total : undefined, + totalMinutesLost: kind === "downtime" || kind === "planned-downtime" ? total : undefined, totalScrap: kind === "scrap" ? total : undefined, rows, top3, diff --git a/app/api/ingest/cycle/route.ts b/app/api/ingest/cycle/route.ts index 2461fb0..7505f77 100644 --- a/app/api/ingest/cycle/route.ts +++ b/app/api/ingest/cycle/route.ts @@ -68,7 +68,8 @@ function normalizeCycleInput(raw: unknown): Record | null { cycle_count: fromRowOrData(["cycle_count", "cycleCount"]), work_order_id: fromRowOrData(["work_order_id", "workOrderId"]), good_delta: fromRowOrData(["good_delta", "goodDelta"]), - scrap_delta: fromRowOrData(["scrap_delta", "scrapDelta", "scrap_total"]), + // `scrap_total` is cumulative and should not be persisted as per-cycle delta. + scrap_delta: fromRowOrData(["scrap_delta", "scrapDelta"]), timestamp: fromRowOrData(["timestamp", "tsMs"]), ts: fromRowOrData(["ts", "tsMs"]), event_timestamp: fromRowOrData(["event_timestamp", "eventTimestamp"]), diff --git a/app/api/ingest/event/route.ts b/app/api/ingest/event/route.ts index b3a2ac5..1c3418c 100644 --- a/app/api/ingest/event/route.ts +++ b/app/api/ingest/event/route.ts @@ -40,6 +40,7 @@ const CANON_TYPE: Record = { "down": "stop", "downtime-acknowledged": "downtime-acknowledged", "scrap-manual-entry": "scrap-manual-entry", + "mold-change": "mold-change", }; const ALLOWED_TYPES = new Set([ @@ -54,6 +55,7 @@ const ALLOWED_TYPES = new Set([ "predictive-oee-decline", "downtime-acknowledged", "scrap-manual-entry", + "mold-change", ]); const machineIdSchema = z.string().uuid(); @@ -441,9 +443,26 @@ export async function POST(req: Request) { // If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage. if (evRecord.is_update || evRecord.is_auto_ack || dataObj.is_update || dataObj.is_auto_ack){ // skip duplicate reasonEntry for refresh/ack - } else if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged"){ + } else if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged" || finalType === "mold-change"){ + const moldIncidentKey = + clampText(evData.incidentKey ?? dataObj.incidentKey, 128) ?? + (numberFrom(evData.start_ms ?? dataObj.start_ms) != null + ? `mold-change:${Math.trunc(numberFrom(evData.start_ms ?? dataObj.start_ms) as number)}` + : null); const reasonRaw: Record = evReason ?? + (finalType === "mold-change" + ? ({ + type: "downtime", + categoryId: "cambio-molde", + detailId: "cambio-molde", + categoryLabel: "Cambio molde", + detailLabel: "Cambio molde", + reasonCode: "MOLD_CHANGE", + reasonText: "Cambio molde", + incidentKey: moldIncidentKey ?? row.id, + } as Record) + : ({ type: "downtime", categoryId: "unclassified", @@ -453,7 +472,7 @@ export async function POST(req: Request) { reasonCode: "UNCLASSIFIED", reasonText: "Unclassified", incidentKey: row.id, - } as Record); + } as Record)); const inferredKind: ReasonCatalogKind = String(reasonRaw.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry" @@ -506,11 +525,13 @@ export async function POST(req: Request) { const incidentKey = clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id; const durationSeconds = numberFrom(evDowntime?.durationSeconds) ?? + numberFrom(evData.duration_sec) ?? numberFrom(evData.stoppage_duration_seconds) ?? numberFrom(evData.stop_duration_seconds) ?? (stopSecForReason != null ? stopSecForReason : null) ?? null; const episodeEndTsMs = + numberFrom(evData.end_ms) ?? numberFrom(evDowntime?.episodeEndTsMs) ?? numberFrom(evDowntime?.acknowledgedAtMs) ?? null; diff --git a/app/api/recap/timeline/route.ts b/app/api/recap/timeline/route.ts new file mode 100644 index 0000000..296bebc --- /dev/null +++ b/app/api/recap/timeline/route.ts @@ -0,0 +1,461 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { requireSession } from "@/lib/auth/requireSession"; +import { prisma } from "@/lib/prisma"; +import type { RecapTimelineResponse, RecapTimelineSegment } from "@/lib/recap/types"; + +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; + }; + +const EVENT_TYPES = ["mold-change", "macrostop", "microstop", "slow-cycle"] as const; +type TimelineEventType = (typeof EVENT_TYPES)[number]; +const ACTIVE_STALE_MS = 2 * 60 * 1000; +const PRIORITY: Record = { + idle: 0, + production: 1, + microstop: 2, + "slow-cycle": 2, + macrostop: 3, + "mold-change": 4, +}; + +function bad(status: number, error: string) { + return NextResponse.json({ ok: false, error }, { status }); +} + +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 normalizeToken(value: unknown) { + return String(value ?? "").trim(); +} + +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 parseHours(raw: string | null) { + const value = Math.trunc(Number(raw || "24")); + if (!Number.isFinite(value)) return 24; + return Math.max(1, Math.min(72, value)); +} + +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 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) : {}; + const nested = record.data; + if (typeof nested === "object" && nested && !Array.isArray(nested)) return nested as Record; + 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, 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) { + 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; + 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 "Ciclo lento"; +} + +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; +} + +export async function GET(req: NextRequest) { + const session = await requireSession(); + if (!session) return bad(401, "Unauthorized"); + + const url = new URL(req.url); + const machineId = url.searchParams.get("machineId"); + if (!machineId) return bad(400, "machineId is required"); + const hours = parseHours(url.searchParams.get("hours")); + const startParam = parseDateInput(url.searchParams.get("start")); + const endParam = parseDateInput(url.searchParams.get("end")); + + const machine = await prisma.machine.findFirst({ + where: { id: machineId, orgId: session.orgId }, + select: { id: true }, + }); + if (!machine) return bad(404, "Machine not found"); + + const end = endParam ?? new Date(); + const start = startParam && startParam < end ? startParam : new Date(end.getTime() - hours * 60 * 60 * 1000); + const rangeStartMs = start.getTime(); + const rangeEndMs = end.getTime(); + + const [cycles, events] = await Promise.all([ + prisma.machineCycle.findMany({ + where: { + orgId: session.orgId, + machineId, + ts: { gte: start, lte: end }, + }, + orderBy: { ts: "asc" }, + select: { + ts: true, + cycleCount: true, + actualCycleTime: true, + workOrderId: true, + sku: true, + }, + }), + prisma.machineEvent.findMany({ + where: { + orgId: session.orgId, + machineId, + eventType: { in: EVENT_TYPES as unknown as string[] }, + ts: { gte: new Date(start.getTime() - 24 * 60 * 60 * 1000), lte: end }, + }, + orderBy: { ts: "asc" }, + select: { + ts: true, + eventType: true, + data: true, + }, + }), + ]); + + const dedupedCycles = dedupeByKey( + 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" | "slow-cycle"; + 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 events) { + const eventType = String(event.eventType || "").toLowerCase() as TimelineEventType; + if (!EVENT_TYPES.includes(eventType)) continue; + + const data = extractData(event.data); + 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; + } + + const stopType = episode.type; + rawSegments.push({ + type: stopType, + startMs, + endMs, + priority: PRIORITY[stopType], + reason: episode.reason, + durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)), + label: labelForStop(stopType, episode.reason), + }); + } + + 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([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) { + timeline.push({ type: "idle", startMs: intervalStart, endMs: intervalEnd, label: "Idle" }); + continue; + } + + if (winner.type === "production") { + timeline.push({ + type: "production", + startMs: intervalStart, + endMs: intervalEnd, + 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; + } + + timeline.push({ + type: winner.type, + startMs: intervalStart, + endMs: intervalEnd, + reason: winner.reason, + durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)), + label: winner.label, + }); + } + + const merged: RecapTimelineSegment[] = []; + for (const segment of timeline) { + const prev = merged[merged.length - 1]; + if (!prev || !isEquivalent(prev, segment) || prev.endMs !== segment.startMs) { + merged.push(segment); + continue; + } + prev.endMs = segment.endMs; + if (prev.type === "mold-change") prev.durationSec = Math.max(0, Math.trunc((prev.endMs - prev.startMs) / 1000)); + if (prev.type === "macrostop" || prev.type === "microstop" || prev.type === "slow-cycle") { + prev.durationSec = Math.max(0, Math.trunc((prev.endMs - prev.startMs) / 1000)); + } + } + + const response: RecapTimelineResponse = { + range: { + start: start.toISOString(), + end: end.toISOString(), + }, + segments: merged, + }; + + return NextResponse.json(response); +} diff --git a/app/page.tsx b/app/page.tsx index 1dcb7d8..cc021c1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function Home() { - redirect("/machines"); + redirect("/recap"); } diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index 24164be..ef3deb5 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -30,13 +30,13 @@ type NavItem = { }; const items: NavItem[] = [ + { href: "/recap", labelKey: "nav.recap", icon: Sunrise }, { href: "/overview", labelKey: "nav.overview", icon: LayoutGrid }, { href: "/machines", labelKey: "nav.machines", icon: Wrench }, { href: "/reports", labelKey: "nav.reports", icon: BarChart3 }, { href: "/alerts", labelKey: "nav.alerts", icon: Bell }, { href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true }, { href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 }, - { href: "/recap", labelKey: "nav.recap", icon: Sunrise }, ]; const settingsItem: NavItem = { href: "/settings", labelKey: "nav.settings", icon: Settings }; diff --git a/components/recap/RecapProductionBySku.tsx b/components/recap/RecapProductionBySku.tsx index 99308d7..361aae9 100644 --- a/components/recap/RecapProductionBySku.tsx +++ b/components/recap/RecapProductionBySku.tsx @@ -17,7 +17,8 @@ export default function RecapProductionBySku({ rows }: Props) {
{t("recap.empty.production")}
) : (
-
+
+
Maquina
SKU
{t("recap.production.good")}
{t("recap.production.scrap")}
@@ -27,7 +28,8 @@ export default function RecapProductionBySku({ rows }: Props) { {rows.slice(0, 8).map((row) => { const pct = row.progressPct == null ? "--" : `${Math.round(row.progressPct)}%`; return ( -
+
+
{row.machineName}
{row.sku}
{row.good}
0 ? "text-red-400" : "text-zinc-200"}>{row.scrap}
diff --git a/components/recap/RecapTimeline.tsx b/components/recap/RecapTimeline.tsx new file mode 100644 index 0000000..b45a984 --- /dev/null +++ b/components/recap/RecapTimeline.tsx @@ -0,0 +1,84 @@ +"use client"; + +import type { RecapTimelineSegment } from "@/lib/recap/types"; + +type Props = { + rangeStart: string; + rangeEnd: string; + segments: RecapTimelineSegment[]; + locale: string; +}; + +const COLORS: Record = { + production: "bg-emerald-500 text-emerald-50", + "mold-change": "bg-blue-400 text-blue-950", + macrostop: "bg-red-500 text-red-50", + microstop: "bg-orange-500 text-orange-50", + "slow-cycle": "bg-amber-500 text-amber-950", + idle: "bg-zinc-600 text-zinc-100", +}; + +function fmtTime(valueMs: number, locale: string) { + return new Date(valueMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }); +} + +function fmtDuration(startMs: number, endMs: number) { + const totalMin = Math.max(0, Math.round((endMs - startMs) / 60000)); + if (totalMin < 60) return `${totalMin}m`; + const h = Math.floor(totalMin / 60); + const m = totalMin % 60; + return `${h}h ${m}m`; +} + +export default function RecapTimeline({ rangeStart, rangeEnd, segments, locale }: Props) { + const startMs = new Date(rangeStart).getTime(); + const endMs = new Date(rangeEnd).getTime(); + const totalMs = Math.max(1, endMs - startMs); + + const bars: RecapTimelineSegment[] = []; + const dots: Array<{ leftPct: number; segment: RecapTimelineSegment }> = []; + + for (const segment of segments) { + const widthPct = ((segment.endMs - segment.startMs) / totalMs) * 100; + const leftPct = ((segment.startMs - startMs) / totalMs) * 100; + if (widthPct < 1) { + if (segment.type !== "idle" && leftPct > 0.5 && leftPct < 99.5) { + dots.push({ leftPct, segment }); + } + } else { + bars.push(segment); + } + } + + return ( +
+
Timeline 24h
+
+
+ {bars.map((segment) => { + const widthPct = ((segment.endMs - segment.startMs) / totalMs) * 100; + const title = `${segment.type} · ${fmtTime(segment.startMs, locale)}-${fmtTime(segment.endMs, locale)} · ${fmtDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`; + return ( +
+ {widthPct >= 6 ? segment.label : ""} +
+ ); + })} +
+ {dots.map(({ leftPct, segment }) => ( +
+ ))} +
+
+ ); +} diff --git a/lib/financial/impact.ts b/lib/financial/impact.ts index 27a23ec..a2a9347 100644 --- a/lib/financial/impact.ts +++ b/lib/financial/impact.ts @@ -244,6 +244,7 @@ export async function computeFinancialImpact(params: FinancialImpactParams): Pro for (const ev of events) { const eventType = String(ev.eventType ?? "").toLowerCase(); + if (eventType === "mold-change") continue; const { blob, inner } = parseBlob(ev.data); const status = String(blob?.status ?? inner?.status ?? "").toLowerCase(); const severity = String(ev.severity ?? "").toLowerCase(); diff --git a/lib/recap/getRecapData.ts b/lib/recap/getRecapData.ts index 1c7d589..8a32209 100644 --- a/lib/recap/getRecapData.ts +++ b/lib/recap/getRecapData.ts @@ -26,7 +26,7 @@ const WEEKDAY_KEY_MAP: Record = { const STOP_TYPES = new Set(["microstop", "macrostop"]); const STOP_STATUS = new Set(["STOP", "DOWN", "OFFLINE"]); const CACHE_TTL_SEC = 180; -const MOLD_IDLE_MIN = 10; +const MOLD_LOOKBACK_MS = 14 * 24 * 60 * 60 * 1000; function safeNum(value: unknown) { if (typeof value === "number" && Number.isFinite(value)) return value; @@ -37,6 +37,32 @@ function safeNum(value: unknown) { return null; } +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; } @@ -134,6 +160,18 @@ function normalizeShiftAlias(shift?: string | 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 { @@ -148,14 +186,26 @@ function eventDurationSec(data: unknown) { typeof innerCandidate === "object" && innerCandidate !== null ? (innerCandidate as Record) : {}; + return inner; +} - return ( - safeNum(inner.stoppage_duration_seconds) ?? - safeNum(inner.stop_duration_seconds) ?? - safeNum(inner.duration_seconds) ?? - safeNum(record?.durationSeconds) ?? - 0 - ); +function eventStatus(data: unknown) { + const inner = extractEventData(data); + return String(inner.status ?? "").trim().toLowerCase(); +} + +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()); } function avg(sum: number, count: number) { @@ -193,12 +243,14 @@ async function computeRecap(params: Required> & { if (!machines.length) { return { range: { start: params.start.toISOString(), end: params.end.toISOString() }, + availableShifts: [], machines: [], }; } const machineIds = machines.map((m) => m.id); - const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, hbRangeRaw, hbLatestRaw] = + const moldStartLookback = new Date(params.end.getTime() - MOLD_LOOKBACK_MS); + const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] = await Promise.all([ prisma.orgSettings.findUnique({ where: { orgId: params.orgId }, @@ -218,6 +270,7 @@ async function computeRecap(params: Required> & { select: { machineId: true, ts: true, + cycleCount: true, workOrderId: true, sku: true, goodDelta: true, @@ -233,6 +286,13 @@ async function computeRecap(params: Required> & { 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, @@ -257,6 +317,7 @@ async function computeRecap(params: Required> & { orgId: params.orgId, machineId: { in: machineIds }, kind: "downtime", + reasonCode: { not: "MOLD_CHANGE" }, capturedAt: { gte: params.start, lte: params.end }, }, select: { @@ -312,6 +373,20 @@ async function computeRecap(params: Required> & { 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"; @@ -333,13 +408,13 @@ async function computeRecap(params: Required> & { const hbRange = targetShiftName ? hbRangeRaw.filter((row) => inTargetShift(row.ts)) : hbRangeRaw; const cyclesByMachine = new Map(); - const cyclesAllByMachine = new Map(); const kpisByMachine = new Map(); const eventsByMachine = new Map(); const reasonsByMachine = new Map(); const workOrdersByMachine = 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) ?? []; @@ -347,12 +422,6 @@ async function computeRecap(params: Required> & { cyclesByMachine.set(row.machineId, list); } - for (const row of cyclesRaw) { - const list = cyclesAllByMachine.get(row.machineId) ?? []; - list.push(row); - cyclesAllByMachine.set(row.machineId, list); - } - for (const row of kpis) { const list = kpisByMachine.get(row.machineId) ?? []; list.push(row); @@ -383,49 +452,227 @@ async function computeRecap(params: Required> & { 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 machineCyclesAll = cyclesAllByMachine.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 machineHbRange = hbRangeByMachine.get(machine.id) ?? []; const latestHb = hbLatestByMachine.get(machine.id) ?? null; + const machineMoldEvents = moldEventsByMachine.get(machine.id) ?? []; - const targetBySku = new Map(); - for (const wo of machineWorkOrders) { - if (!wo.sku || wo.targetQty == null) continue; - targetBySku.set(wo.sku, (targetBySku.get(wo.sku) ?? 0) + Number(wo.targetQty)); + 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)) }); + } } - const skuMap = new Map(); + type SkuAggregate = { + machineName: string; + sku: string; + good: number; + scrap: number; + target: number | null; + }; + const skuMap = new Map(); + const rangeByWorkOrder = new Map(); + const kpiLatestByWorkOrder = new Map(); + let latestTelemetry: { ts: Date; workOrderId: string | null; sku: string | null } | null = null; let goodParts = 0; let scrapParts = 0; - for (const cycle of machineCycles) { - const sku = cycle.sku || "N/A"; - const good = safeNum(cycle.goodDelta) ?? 0; - const scrap = safeNum(cycle.scrapDelta) ?? 0; - goodParts += good; - scrapParts += scrap; - - const row = skuMap.get(sku) ?? { - sku, + const ensureSkuRow = (skuInput: string | null) => { + const skuToken = normalizeToken(skuInput) || "N/A"; + const key = skuKey(skuToken); + const existing = skuMap.get(key); + if (existing) return existing; + const target = targetBySku.get(key)?.target ?? null; + const created: SkuAggregate = { + machineName: machine.name, + sku: skuToken, good: 0, scrap: 0, - target: targetBySku.has(sku) ? targetBySku.get(sku) ?? null : null, + target, }; - row.good += good; - row.scrap += scrap; - skuMap.set(sku, row); + skuMap.set(key, created); + return created; + }; + + type KpiRangeAggregate = { + workOrderId: string | null; + sku: string | null; + minGood: number | null; + maxGood: number | null; + minScrap: number | null; + maxScrap: number | null; + firstTs: Date | null; + lastTs: Date | null; + }; + const kpiRanges = new Map(); + + 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, + }; + } + + const workOrderId = normalizeToken(kpi.workOrderId) || null; + const sku = normalizeToken(kpi.sku) || null; + const goodCounterRaw = safeNum(kpi.goodParts) ?? safeNum(kpi.good); + const scrapCounterRaw = safeNum(kpi.scrapParts) ?? safeNum(kpi.scrap); + const goodCounter = goodCounterRaw != null ? Math.max(0, Math.trunc(goodCounterRaw)) : null; + const scrapCounter = scrapCounterRaw != null ? Math.max(0, Math.trunc(scrapCounterRaw)) : null; + + const woKey = workOrderKey(workOrderId); + if (woKey) { + const existingLatest = kpiLatestByWorkOrder.get(woKey); + if (!existingLatest || kpi.ts > existingLatest.ts) { + kpiLatestByWorkOrder.set(woKey, { + good: goodCounter ?? 0, + scrap: scrapCounter ?? 0, + ts: kpi.ts, + sku, + }); + } + } + + if ((goodCounter == null && scrapCounter == null) || (!workOrderId && !sku)) continue; + + const key = `${woKey || "__none"}::${skuKey(sku) || "__none"}`; + const current = kpiRanges.get(key) ?? { + workOrderId, + sku, + minGood: null, + maxGood: null, + minScrap: null, + maxScrap: null, + firstTs: null, + lastTs: null, + }; + + if (goodCounter != null) { + current.minGood = current.minGood == null ? goodCounter : Math.min(current.minGood, goodCounter); + current.maxGood = current.maxGood == null ? goodCounter : Math.max(current.maxGood, goodCounter); + } + if (scrapCounter != null) { + current.minScrap = current.minScrap == null ? scrapCounter : Math.min(current.minScrap, scrapCounter); + current.maxScrap = current.maxScrap == null ? scrapCounter : Math.max(current.maxScrap, scrapCounter); + } + if (!current.firstTs || kpi.ts < current.firstTs) current.firstTs = kpi.ts; + if (!current.lastTs || kpi.ts > current.lastTs) current.lastTs = kpi.ts; + kpiRanges.set(key, current); } + if (kpiRanges.size > 0) { + for (const agg of kpiRanges.values()) { + const rangeGood = Math.max(0, (agg.maxGood ?? 0) - (agg.minGood ?? agg.maxGood ?? 0)); + const rangeScrap = Math.max(0, (agg.maxScrap ?? 0) - (agg.minScrap ?? agg.maxScrap ?? 0)); + const skuRow = ensureSkuRow(agg.sku); + skuRow.good += rangeGood; + skuRow.scrap += rangeScrap; + goodParts += rangeGood; + scrapParts += rangeScrap; + + const woKey = workOrderKey(agg.workOrderId); + if (!woKey) continue; + const existing = rangeByWorkOrder.get(woKey) ?? { + goodParts: 0, + scrapParts: 0, + firstTs: null, + lastTs: null, + }; + existing.goodParts += rangeGood; + existing.scrapParts += rangeScrap; + if (agg.firstTs && (!existing.firstTs || agg.firstTs < existing.firstTs)) existing.firstTs = agg.firstTs; + if (agg.lastTs && (!existing.lastTs || agg.lastTs > existing.lastTs)) existing.lastTs = agg.lastTs; + rangeByWorkOrder.set(woKey, existing); + } + } else { + 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 skuRow = ensureSkuRow(normalizeToken(cycle.sku) || null); + const good = Math.max(0, Math.trunc(safeNum(cycle.goodDelta) ?? 0)); + const scrap = Math.max(0, Math.trunc(safeNum(cycle.scrapDelta) ?? 0)); + skuRow.good += good; + skuRow.scrap += scrap; + goodParts += good; + scrapParts += scrap; + + const woKey = workOrderKey(cycle.workOrderId); + if (!woKey) continue; + const existing = rangeByWorkOrder.get(woKey) ?? { + goodParts: 0, + scrapParts: 0, + firstTs: null, + lastTs: null, + }; + existing.goodParts += good; + existing.scrapParts += scrap; + if (!existing.firstTs || cycle.ts < existing.firstTs) existing.firstTs = cycle.ts; + if (!existing.lastTs || cycle.ts > existing.lastTs) existing.lastTs = cycle.ts; + rangeByWorkOrder.set(woKey, existing); + } + } + + const openWorkOrders = machineWorkOrdersSorted.filter( + (wo) => String(wo.status).toUpperCase() !== "COMPLETED" + ); + for (const wo of openWorkOrders) { + ensureSkuRow(normalizeToken(wo.sku) || null); + } + if (latestTelemetry?.sku) ensureSkuRow(latestTelemetry.sku); + const bySku = [...skuMap.values()] .map((row) => { + const target = row.target ?? targetBySku.get(skuKey(row.sku))?.target ?? null; const produced = row.good + row.scrap; - const progressPct = row.target && row.target > 0 ? round2((produced / row.target) * 100) : null; - return { ...row, progressPct }; + 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); @@ -438,7 +685,7 @@ async function computeRecap(params: Required> & { let qualitySum = 0; let qualityCount = 0; - for (const kpi of machineKpis) { + for (const kpi of dedupedKpis) { const oee = safeNum(kpi.oee); const availability = safeNum(kpi.availability); const performance = safeNum(kpi.performance); @@ -508,29 +755,13 @@ async function computeRecap(params: Required> & { ongoingStopMin = round2(Math.max(0, (params.end.getTime() - downStart.getTime()) / 60000)); } - const cyclesByWorkOrder = new Map< - string, - { goodParts: number; firstTs: Date | null; lastTs: Date | null } - >(); - for (const cycle of machineCycles) { - if (!cycle.workOrderId) continue; - const current = cyclesByWorkOrder.get(cycle.workOrderId) ?? { - goodParts: 0, - firstTs: null, - lastTs: null, - }; - current.goodParts += safeNum(cycle.goodDelta) ?? 0; - if (!current.firstTs || cycle.ts < current.firstTs) current.firstTs = cycle.ts; - if (!current.lastTs || cycle.ts > current.lastTs) current.lastTs = cycle.ts; - cyclesByWorkOrder.set(cycle.workOrderId, current); - } - - const completed = machineWorkOrders + const completed = machineWorkOrdersSorted .filter((wo) => String(wo.status).toUpperCase() === "COMPLETED") .filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end) .map((wo) => { - const progress = cyclesByWorkOrder.get(wo.workOrderId) ?? { + const progress = rangeByWorkOrder.get(workOrderKey(wo.workOrderId)) ?? { goodParts: 0, + scrapParts: 0, firstTs: null, lastTs: null, }; @@ -547,25 +778,53 @@ async function computeRecap(params: Required> & { }) .sort((a, b) => b.goodParts - a.goodParts); - const activeWo = machineWorkOrders.find((wo) => String(wo.status).toUpperCase() !== "COMPLETED") ?? null; + 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 (activeWo) { - const progress = cyclesByWorkOrder.get(activeWo.workOrderId); - const produced = (progress?.goodParts ?? 0) + (machineCycles - .filter((row) => row.workOrderId === activeWo.workOrderId) - .reduce((sum, row) => sum + (safeNum(row.scrapDelta) ?? 0), 0)); - if (activeWo.targetQty && activeWo.targetQty > 0) { - activeProgressPct = round2((produced / activeWo.targetQty) * 100); + if (activeWorkOrderId) { + const rangeProgress = activeWorkOrderKey ? rangeByWorkOrder.get(activeWorkOrderKey) : null; + const cumulativeProgress = activeWorkOrderKey ? kpiLatestByWorkOrder.get(activeWorkOrderKey) : null; + const producedForProgress = 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(progress?.firstTs ?? activeWo.createdAt); + activeStartedAt = toIso(rangeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null); } - const cutoffTs = new Date(params.end.getTime() - MOLD_IDLE_MIN * 60000); - const hasRecentCycle = machineCyclesAll.some((cycle) => cycle.ts >= cutoffTs && cycle.ts <= params.end); - const moldChangeInProgress = - !!activeWo && String(activeWo.status).toUpperCase() === "PENDING" && !hasRecentCycle; + 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) { @@ -584,7 +843,7 @@ async function computeRecap(params: Required> & { production: { goodParts, scrapParts, - totalCycles: machineCycles.length, + totalCycles: dedupedCycles.length, bySku, }, oee: { @@ -601,15 +860,16 @@ async function computeRecap(params: Required> & { }, workOrders: { completed, - active: activeWo + active: activeWorkOrderId ? { - id: activeWo.workOrderId, - sku: activeWo.sku, + id: activeWorkOrderId, + sku: activeWorkOrderSku, progressPct: activeProgressPct, startedAt: activeStartedAt, } : null, moldChangeInProgress, + moldChangeStartMs, }, heartbeat: { lastSeenAt: toIso(latestTs), @@ -623,6 +883,10 @@ async function computeRecap(params: Required> & { start: params.start.toISOString(), end: params.end.toISOString(), }, + availableShifts: orderedEnabledShifts.map((shift, idx) => ({ + id: `shift${idx + 1}`, + name: shift.name, + })), machines: machineRows, }; } diff --git a/lib/recap/types.ts b/lib/recap/types.ts index b318197..e925ac2 100644 --- a/lib/recap/types.ts +++ b/lib/recap/types.ts @@ -1,4 +1,5 @@ export type RecapSkuRow = { + machineName: string; sku: string; good: number; scrap: number; @@ -46,6 +47,7 @@ export type RecapMachine = { startedAt: string | null; } | null; moldChangeInProgress: boolean; + moldChangeStartMs: number | null; }; heartbeat: { lastSeenAt: string | null; @@ -53,11 +55,56 @@ export type RecapMachine = { }; }; +export type RecapTimelineSegment = + | { + type: "production"; + startMs: number; + endMs: number; + workOrderId: string | null; + sku: string | null; + label: string; + } + | { + type: "mold-change"; + startMs: number; + endMs: number; + fromMoldId: string | null; + toMoldId: string | null; + durationSec: number; + label: string; + } + | { + type: "macrostop" | "microstop" | "slow-cycle"; + startMs: number; + endMs: number; + reason: string | null; + durationSec: number; + label: string; + } + | { + type: "idle"; + startMs: number; + endMs: number; + label: string; + }; + +export type RecapTimelineResponse = { + range: { + start: string; + end: string; + }; + segments: RecapTimelineSegment[]; +}; + export type RecapResponse = { range: { start: string; end: string; }; + availableShifts: Array<{ + id: string; + name: string; + }>; machines: RecapMachine[]; };