import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; const RANGE_MS: Record = { "24h": 24 * 60 * 60 * 1000, "7d": 7 * 24 * 60 * 60 * 1000, "30d": 30 * 24 * 60 * 60 * 1000, }; 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 pickRange(req: NextRequest) { const url = new URL(req.url); const range = url.searchParams.get("range") ?? "24h"; const now = new Date(); if (range === "custom") { const start = parseDate(url.searchParams.get("start")) ?? new Date(now.getTime() - RANGE_MS["24h"]); const end = parseDate(url.searchParams.get("end")) ?? now; return { start, end }; } const ms = RANGE_MS[range] ?? RANGE_MS["24h"]; return { start: new Date(now.getTime() - ms), end: now }; } function safeNum(v: unknown) { return typeof v === "number" && Number.isFinite(v) ? v : null; } export async function GET(req: NextRequest) { const session = await requireSession(); if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); const url = new URL(req.url); const machineId = url.searchParams.get("machineId") ?? undefined; const { start, end } = pickRange(req); const workOrderId = url.searchParams.get("workOrderId") ?? undefined; const sku = url.searchParams.get("sku") ?? undefined; const baseWhere = { orgId: session.orgId, ...(machineId ? { machineId } : {}), ...(workOrderId ? { workOrderId } : {}), ...(sku ? { sku } : {}), }; const kpiRows = await prisma.machineKpiSnapshot.findMany({ where: { ...baseWhere, ts: { gte: start, lte: end } }, orderBy: { ts: "asc" }, select: { ts: true, oee: true, availability: true, performance: true, quality: true, good: true, scrap: true, target: true, machineId: true, }, }); let oeeSum = 0; let oeeCount = 0; let availSum = 0; let availCount = 0; let perfSum = 0; let perfCount = 0; let qualSum = 0; let qualCount = 0; for (const k of kpiRows) { if (safeNum(k.oee) != null) { oeeSum += Number(k.oee); oeeCount += 1; } if (safeNum(k.availability) != null) { availSum += Number(k.availability); availCount += 1; } if (safeNum(k.performance) != null) { perfSum += Number(k.performance); perfCount += 1; } if (safeNum(k.quality) != null) { qualSum += Number(k.quality); qualCount += 1; } } const cycles = await prisma.machineCycle.findMany({ where: { ...baseWhere, ts: { gte: start, lte: end } }, select: { goodDelta: true, scrapDelta: true }, }); let goodTotal = 0; let scrapTotal = 0; for (const c of cycles) { if (safeNum(c.goodDelta) != null) goodTotal += Number(c.goodDelta); if (safeNum(c.scrapDelta) != null) scrapTotal += Number(c.scrapDelta); } const kpiAgg = await prisma.machineKpiSnapshot.groupBy({ by: ["machineId"], where: { ...baseWhere, ts: { gte: start, lte: end } }, _max: { good: true, scrap: true, target: true }, _min: { good: true, scrap: true }, _count: { _all: true }, }); let targetTotal = 0; if (goodTotal === 0 && scrapTotal === 0) { let goodFallback = 0; let scrapFallback = 0; for (const row of kpiAgg) { const count = row._count._all ?? 0; const maxGood = safeNum(row._max.good); const minGood = safeNum(row._min.good); const maxScrap = safeNum(row._max.scrap); const minScrap = safeNum(row._min.scrap); if (count > 1 && maxGood != null && minGood != null) { goodFallback += Math.max(0, maxGood - minGood); } else if (maxGood != null) { goodFallback += maxGood; } if (count > 1 && maxScrap != null && minScrap != null) { scrapFallback += Math.max(0, maxScrap - minScrap); } else if (maxScrap != null) { scrapFallback += maxScrap; } } goodTotal = goodFallback; scrapTotal = scrapFallback; } for (const row of kpiAgg) { const maxTarget = safeNum(row._max.target); if (maxTarget != null) targetTotal += maxTarget; } const events = await prisma.machineEvent.findMany({ where: { ...baseWhere, ts: { gte: start, lte: end } }, select: { eventType: true, data: true }, }); let macrostopSec = 0; let microstopSec = 0; let slowCycleCount = 0; let qualitySpikeCount = 0; let performanceDegradationCount = 0; let oeeDropCount = 0; for (const e of events) { const type = String(e.eventType ?? "").toLowerCase(); let blob: unknown = e.data; if (typeof blob === "string") { try { blob = JSON.parse(blob); } catch { blob = null; } } const blobRecord = typeof blob === "object" && blob !== null ? (blob as Record) : null; const innerCandidate = blobRecord?.data ?? blobRecord ?? {}; const inner = typeof innerCandidate === "object" && innerCandidate !== null ? (innerCandidate as Record) : {}; const stopSec = (typeof inner?.stoppage_duration_seconds === "number" && inner.stoppage_duration_seconds) || (typeof inner?.stop_duration_seconds === "number" && inner.stop_duration_seconds) || 0; if (type === "macrostop") macrostopSec += Number(stopSec) || 0; else if (type === "microstop") microstopSec += Number(stopSec) || 0; else if (type === "slow-cycle") slowCycleCount += 1; else if (type === "quality-spike") qualitySpikeCount += 1; else if (type === "performance-degradation") performanceDegradationCount += 1; else if (type === "oee-drop") oeeDropCount += 1; } type TrendPoint = { t: string; v: number }; const trend: { oee: TrendPoint[]; availability: TrendPoint[]; performance: TrendPoint[]; quality: TrendPoint[]; scrapRate: TrendPoint[]; } = { oee: [], availability: [], performance: [], quality: [], scrapRate: [], }; for (const k of kpiRows) { const t = k.ts.toISOString(); if (safeNum(k.oee) != null) trend.oee.push({ t, v: Number(k.oee) }); if (safeNum(k.availability) != null) trend.availability.push({ t, v: Number(k.availability) }); if (safeNum(k.performance) != null) trend.performance.push({ t, v: Number(k.performance) }); if (safeNum(k.quality) != null) trend.quality.push({ t, v: Number(k.quality) }); const good = safeNum(k.good); const scrap = safeNum(k.scrap); if (good != null && scrap != null && good + scrap > 0) { trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 }); } } const cycleRows = await prisma.machineCycle.findMany({ where: { ...baseWhere, ts: { gte: start, lte: end } }, select: { actualCycleTime: true }, }); const values = cycleRows .map((c) => Number(c.actualCycleTime)) .filter((v) => Number.isFinite(v) && v > 0) .sort((a, b) => a - b); let cycleTimeBins: { label: string; count: number; rangeStart?: number; rangeEnd?: number; overflow?: "low" | "high"; minValue?: number; maxValue?: number; }[] = []; if (values.length) { const pct = (p: number) => { const idx = Math.max(0, Math.min(values.length - 1, Math.floor(p * (values.length - 1)))); return values[idx]; }; const p5 = pct(0.05); const p95 = pct(0.95); const inRange = values.filter((v) => v >= p5 && v <= p95); const low = values.filter((v) => v < p5); const high = values.filter((v) => v > p95); const binCount = 10; const span = Math.max(0.1, p95 - p5); const step = span / binCount; const counts = new Array(binCount).fill(0); for (const v of inRange) { const idx = Math.min(binCount - 1, Math.floor((v - p5) / step)); counts[idx] += 1; } const decimals = step < 0.1 ? 2 : step < 1 ? 1 : 0; cycleTimeBins = counts.map((count, i) => { const a = p5 + step * i; const b = p5 + step * (i + 1); return { label: `${a.toFixed(decimals)}-${b.toFixed(decimals)}s`, count, rangeStart: a, rangeEnd: b, }; }); if (low.length) { cycleTimeBins.unshift({ label: `< ${p5.toFixed(1)}s`, count: low.length, rangeEnd: p5, overflow: "low", minValue: low[0], maxValue: low[low.length - 1], }); } if (high.length) { cycleTimeBins.push({ label: `> ${p95.toFixed(1)}s`, count: high.length, rangeStart: p95, overflow: "high", minValue: high[0], maxValue: high[high.length - 1], }); } } const scrapRate = goodTotal + scrapTotal > 0 ? (scrapTotal / (goodTotal + scrapTotal)) * 100 : null; // top scrap SKU / work order (from cycles) const scrapBySku = new Map(); const scrapByWo = new Map(); const scrapRows = await prisma.machineCycle.findMany({ where: { ...baseWhere, ts: { gte: start, lte: end } }, select: { sku: true, workOrderId: true, scrapDelta: true }, }); for (const row of scrapRows) { const scrap = safeNum(row.scrapDelta); if (scrap == null || scrap <= 0) continue; if (row.sku) scrapBySku.set(row.sku, (scrapBySku.get(row.sku) ?? 0) + scrap); if (row.workOrderId) scrapByWo.set(row.workOrderId, (scrapByWo.get(row.workOrderId) ?? 0) + scrap); } const topScrapSku = [...scrapBySku.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null; const topScrapWorkOrder = [...scrapByWo.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null; const oeeAvg = oeeCount ? oeeSum / oeeCount : null; const availabilityAvg = availCount ? availSum / availCount : null; const performanceAvg = perfCount ? perfSum / perfCount : null; const qualityAvg = qualCount ? qualSum / qualCount : null; // insights const insights: string[] = []; if (scrapRate != null && scrapRate > 5) insights.push(`Scrap rate is ${scrapRate.toFixed(1)}% (above 5%).`); if (performanceAvg != null && performanceAvg < 85) insights.push("Performance below 85%."); if (availabilityAvg != null && availabilityAvg < 85) insights.push("Availability below 85%."); if (oeeAvg != null && oeeAvg < 85) insights.push("OEE below 85%."); if (macrostopSec > 1800) insights.push("Macrostop time exceeds 30 minutes in this range."); return NextResponse.json({ ok: true, summary: { oeeAvg, availabilityAvg, performanceAvg, qualityAvg, goodTotal, scrapTotal, targetTotal, scrapRate, topScrapSku, topScrapWorkOrder, }, downtime: { macrostopSec, microstopSec, slowCycleCount, qualitySpikeCount, performanceDegradationCount, oeeDropCount, }, trend, insights, distribution: { cycleTime: cycleTimeBins }, }); }