317 lines
9.1 KiB
TypeScript
317 lines
9.1 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import type { NextRequest } from "next/server";
|
||
import { createHash } from "crypto";
|
||
import { prisma } from "@/lib/prisma";
|
||
import { requireSession } from "@/lib/auth/requireSession";
|
||
import { normalizeEvent } from "@/lib/events/normalizeEvent";
|
||
|
||
|
||
export async function GET(
|
||
_req: NextRequest,
|
||
{ params }: { params: Promise<{ machineId: string }> }
|
||
) {
|
||
const session = await requireSession();
|
||
if (!session) {
|
||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||
}
|
||
|
||
const url = new URL(_req.url);
|
||
const eventsMode = url.searchParams.get("events") ?? "all";
|
||
const eventsOnly = url.searchParams.get("eventsOnly") === "1";
|
||
const eventsWindowSec = Number(url.searchParams.get("eventsWindowSec") ?? "21600"); // default 6h
|
||
const eventsWindowStart = new Date(Date.now() - Math.max(0, eventsWindowSec) * 1000);
|
||
const windowSec = Number(url.searchParams.get("windowSec") ?? "3600"); // default 1h
|
||
|
||
const { machineId } = await params;
|
||
|
||
const machineBase = await prisma.machine.findFirst({
|
||
where: { id: machineId, orgId: session.orgId },
|
||
select: { id: true, updatedAt: true },
|
||
});
|
||
|
||
if (!machineBase) {
|
||
return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
|
||
}
|
||
|
||
const [heartbeatAgg, kpiAgg, eventAgg, cycleAgg, orgSettingsAgg] = await Promise.all([
|
||
prisma.machineHeartbeat.aggregate({
|
||
where: { orgId: session.orgId, machineId },
|
||
_max: { tsServer: true },
|
||
}),
|
||
prisma.machineKpiSnapshot.aggregate({
|
||
where: { orgId: session.orgId, machineId },
|
||
_max: { tsServer: true },
|
||
}),
|
||
prisma.machineEvent.aggregate({
|
||
where: { orgId: session.orgId, machineId, ts: { gte: eventsWindowStart } },
|
||
_max: { tsServer: true },
|
||
}),
|
||
prisma.machineCycle.aggregate({
|
||
where: { orgId: session.orgId, machineId },
|
||
_max: { ts: true },
|
||
}),
|
||
prisma.orgSettings.findUnique({
|
||
where: { orgId: session.orgId },
|
||
select: { updatedAt: true, stoppageMultiplier: true, macroStoppageMultiplier: true },
|
||
}),
|
||
]);
|
||
|
||
const toMs = (value?: Date | null) => (value ? value.getTime() : 0);
|
||
const lastModifiedMs = Math.max(
|
||
toMs(machineBase.updatedAt),
|
||
toMs(heartbeatAgg._max.tsServer),
|
||
toMs(kpiAgg._max.tsServer),
|
||
toMs(eventAgg._max.tsServer),
|
||
toMs(cycleAgg._max.ts),
|
||
toMs(orgSettingsAgg?.updatedAt)
|
||
);
|
||
|
||
const versionParts = [
|
||
session.orgId,
|
||
machineId,
|
||
eventsMode,
|
||
eventsOnly ? "1" : "0",
|
||
eventsWindowSec,
|
||
windowSec,
|
||
toMs(machineBase.updatedAt),
|
||
toMs(heartbeatAgg._max.tsServer),
|
||
toMs(kpiAgg._max.tsServer),
|
||
toMs(eventAgg._max.tsServer),
|
||
toMs(cycleAgg._max.ts),
|
||
toMs(orgSettingsAgg?.updatedAt),
|
||
];
|
||
|
||
const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`;
|
||
const lastModified = new Date(lastModifiedMs || 0).toUTCString();
|
||
const responseHeaders = new Headers({
|
||
"Cache-Control": "private, no-cache, max-age=0, must-revalidate",
|
||
ETag: etag,
|
||
"Last-Modified": lastModified,
|
||
Vary: "Cookie",
|
||
});
|
||
|
||
const ifNoneMatch = _req.headers.get("if-none-match");
|
||
if (ifNoneMatch && ifNoneMatch === etag) {
|
||
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
||
}
|
||
|
||
const ifModifiedSince = _req.headers.get("if-modified-since");
|
||
if (!ifNoneMatch && ifModifiedSince) {
|
||
const since = Date.parse(ifModifiedSince);
|
||
if (!Number.isNaN(since) && lastModifiedMs <= since) {
|
||
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
||
}
|
||
}
|
||
|
||
const machine = await prisma.machine.findFirst({
|
||
where: { id: machineId, orgId: session.orgId },
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
code: true,
|
||
location: true,
|
||
heartbeats: {
|
||
orderBy: { tsServer: "desc" },
|
||
take: 1,
|
||
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
|
||
},
|
||
kpiSnapshots: {
|
||
orderBy: { ts: "desc" },
|
||
take: 1,
|
||
select: {
|
||
ts: true,
|
||
oee: true,
|
||
availability: true,
|
||
performance: true,
|
||
quality: true,
|
||
workOrderId: true,
|
||
sku: true,
|
||
good: true,
|
||
scrap: true,
|
||
target: true,
|
||
cycleTime: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
if (!machine) {
|
||
return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
|
||
}
|
||
|
||
const microMultiplier = Number(orgSettingsAgg?.stoppageMultiplier ?? 1.5);
|
||
const macroMultiplier = Math.max(
|
||
microMultiplier,
|
||
Number(orgSettingsAgg?.macroStoppageMultiplier ?? 5)
|
||
);
|
||
|
||
const rawEvents = await prisma.machineEvent.findMany({
|
||
where: {
|
||
orgId: session.orgId,
|
||
machineId,
|
||
ts: { gte: eventsWindowStart },
|
||
},
|
||
orderBy: { ts: "desc" },
|
||
take: 100, // pull more, we'll filter after normalization
|
||
select: {
|
||
id: true,
|
||
ts: true,
|
||
topic: true,
|
||
eventType: true,
|
||
severity: true,
|
||
title: true,
|
||
description: true,
|
||
requiresAck: true,
|
||
data: true,
|
||
workOrderId: true,
|
||
},
|
||
});
|
||
|
||
const normalized = rawEvents.map((row) =>
|
||
normalizeEvent(row, { microMultiplier, macroMultiplier })
|
||
);
|
||
|
||
const ALLOWED_TYPES = new Set([
|
||
"slow-cycle",
|
||
"microstop",
|
||
"macrostop",
|
||
"offline",
|
||
"error",
|
||
"oee-drop",
|
||
"quality-spike",
|
||
"performance-degradation",
|
||
"predictive-oee-decline",
|
||
"alert-delivery-failed",
|
||
]);
|
||
|
||
const allEvents = normalized.filter((e) => ALLOWED_TYPES.has(e.eventType));
|
||
|
||
const isCritical = (event: (typeof allEvents)[number]) => {
|
||
const severity = String(event.severity ?? "").toLowerCase();
|
||
return (
|
||
event.eventType === "macrostop" ||
|
||
event.requiresAck === true ||
|
||
severity === "critical" ||
|
||
severity === "error" ||
|
||
severity === "high"
|
||
);
|
||
};
|
||
|
||
const eventsFiltered = eventsMode === "critical" ? allEvents.filter(isCritical) : allEvents;
|
||
const events = eventsFiltered.slice(0, 30);
|
||
const eventsCountAll = allEvents.length;
|
||
const eventsCountCritical = allEvents.filter(isCritical).length;
|
||
|
||
if (eventsOnly) {
|
||
return NextResponse.json(
|
||
{ ok: true, events, eventsCountAll, eventsCountCritical },
|
||
{ headers: responseHeaders }
|
||
);
|
||
}
|
||
|
||
|
||
// ---- cycles window ----
|
||
|
||
const latestKpi = machine.kpiSnapshots[0] ?? null;
|
||
|
||
// If KPI cycleTime missing, fallback to DB cycles (we fetch 1 first)
|
||
const latestCycleForIdeal = await prisma.machineCycle.findFirst({
|
||
where: { orgId: session.orgId, machineId },
|
||
orderBy: { ts: "desc" },
|
||
select: { theoreticalCycleTime: true },
|
||
});
|
||
|
||
const effectiveCycleTime =
|
||
latestKpi?.cycleTime ??
|
||
latestCycleForIdeal?.theoreticalCycleTime ??
|
||
null;
|
||
|
||
// Estimate how many cycles we need to cover the window.
|
||
// Add buffer so the chart doesn’t look “tight”.
|
||
const estCycleSec = Math.max(1, Number(effectiveCycleTime ?? 14));
|
||
const needed = Math.ceil(windowSec / estCycleSec) + 50;
|
||
|
||
// Safety cap to avoid crazy payloads
|
||
const takeCycles = Math.min(1000, Math.max(200, needed));
|
||
|
||
const rawCycles = await prisma.machineCycle.findMany({
|
||
where: { orgId: session.orgId, machineId },
|
||
orderBy: { ts: "desc" },
|
||
take: takeCycles,
|
||
select: {
|
||
ts: true,
|
||
cycleCount: true,
|
||
actualCycleTime: true,
|
||
theoreticalCycleTime: true,
|
||
workOrderId: true,
|
||
sku: true,
|
||
},
|
||
});
|
||
const latestCycle = rawCycles[0] ?? null;
|
||
|
||
let activeStoppage: {
|
||
state: "microstop" | "macrostop";
|
||
startedAt: string;
|
||
durationSec: number;
|
||
theoreticalCycleTime: number;
|
||
} | null = null;
|
||
|
||
if (latestCycle?.ts && effectiveCycleTime && effectiveCycleTime > 0) {
|
||
const elapsedSec = (Date.now() - latestCycle.ts.getTime()) / 1000;
|
||
const microThresholdSec = effectiveCycleTime * microMultiplier;
|
||
const macroThresholdSec = effectiveCycleTime * macroMultiplier;
|
||
|
||
if (elapsedSec >= microThresholdSec) {
|
||
const isMacro = elapsedSec >= macroThresholdSec;
|
||
const state = isMacro ? "macrostop" : "microstop";
|
||
const thresholdSec = isMacro ? macroThresholdSec : microThresholdSec;
|
||
const startedAtMs = latestCycle.ts.getTime() + thresholdSec * 1000;
|
||
|
||
activeStoppage = {
|
||
state,
|
||
startedAt: new Date(startedAtMs).toISOString(),
|
||
durationSec: Math.max(0, Math.floor(elapsedSec - thresholdSec)),
|
||
theoreticalCycleTime: effectiveCycleTime,
|
||
};
|
||
}
|
||
}
|
||
|
||
// chart-friendly: oldest -> newest + numeric timestamps
|
||
const cycles = rawCycles
|
||
.slice()
|
||
.reverse()
|
||
.map((c) => ({
|
||
ts: c.ts,
|
||
t: c.ts.getTime(),
|
||
cycleCount: c.cycleCount ?? null,
|
||
actual: c.actualCycleTime,
|
||
ideal: c.theoreticalCycleTime ?? null,
|
||
workOrderId: c.workOrderId ?? null,
|
||
sku: c.sku ?? null,
|
||
}));
|
||
return NextResponse.json(
|
||
{
|
||
ok: true,
|
||
machine: {
|
||
id: machine.id,
|
||
name: machine.name,
|
||
code: machine.code,
|
||
location: machine.location,
|
||
latestHeartbeat: machine.heartbeats[0] ?? null,
|
||
latestKpi: machine.kpiSnapshots[0] ?? null,
|
||
effectiveCycleTime,
|
||
},
|
||
thresholds: {
|
||
stoppageMultiplier: microMultiplier,
|
||
macroStoppageMultiplier: macroMultiplier,
|
||
},
|
||
activeStoppage,
|
||
events,
|
||
eventsCountAll,
|
||
eventsCountCritical,
|
||
cycles,
|
||
},
|
||
{ headers: responseHeaders }
|
||
);
|
||
}
|