Files
MIS-Contro-Tower/app/api/machines/[machineId]/route.ts
2026-01-15 18:43:21 +00:00

382 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
function normalizeEvent(
row: any,
thresholds: { microMultiplier: number; macroMultiplier: number }
) {
// -----------------------------
// 1) Parse row.data safely
// data may be:
// - object
// - array of objects
// - JSON string of either
// -----------------------------
const raw = row.data;
let parsed: any = raw;
if (typeof raw === "string") {
try {
parsed = JSON.parse(raw);
} catch {
parsed = raw; // keep as string if not JSON
}
}
// data can be object OR [object]
const blob = Array.isArray(parsed) ? parsed[0] : parsed;
// some payloads nest details under blob.data
const inner = blob?.data ?? blob ?? {};
const normalizeType = (t: any) =>
String(t ?? "")
.trim()
.toLowerCase()
.replace(/_/g, "-");
// -----------------------------
// 2) Alias mapping (canonical types)
// -----------------------------
const ALIAS: Record<string, string> = {
// Spanish / synonyms
macroparo: "macrostop",
"macro-stop": "macrostop",
macro_stop: "macrostop",
microparo: "microstop",
"micro-paro": "microstop",
micro_stop: "microstop",
// Node-RED types
"production-stopped": "stop", // we'll classify to micro/macro below
// legacy / generic
down: "stop",
};
// -----------------------------
// 3) Determine event type from DB or blob
// -----------------------------
const fromDbType =
row.eventType && row.eventType !== "unknown" ? row.eventType : null;
const fromBlobType =
blob?.anomaly_type ??
blob?.eventType ??
blob?.topic ??
inner?.anomaly_type ??
inner?.eventType ??
null;
// infer slow-cycle if signature exists
const inferredType =
fromDbType ??
fromBlobType ??
((inner?.actual_cycle_time && inner?.theoretical_cycle_time) ||
(blob?.actual_cycle_time && blob?.theoretical_cycle_time)
? "slow-cycle"
: "unknown");
const eventTypeRaw = normalizeType(inferredType);
let eventType = ALIAS[eventTypeRaw] ?? eventTypeRaw;
// -----------------------------
// 4) Optional: classify "stop" into micro/macro based on duration if present
// (keeps old rows usable even if they stored production-stopped)
// -----------------------------
if (eventType === "stop") {
const stopSec =
(typeof inner?.stoppage_duration_seconds === "number" && inner.stoppage_duration_seconds) ||
(typeof blob?.stoppage_duration_seconds === "number" && blob.stoppage_duration_seconds) ||
(typeof inner?.stop_duration_seconds === "number" && inner.stop_duration_seconds) ||
null;
const microMultiplier = Number(thresholds?.microMultiplier ?? 1.5);
const macroMultiplier = Math.max(
microMultiplier,
Number(thresholds?.macroMultiplier ?? 5)
);
const theoreticalCycle =
Number(inner?.theoretical_cycle_time ?? blob?.theoretical_cycle_time) || 0;
if (stopSec != null) {
if (theoreticalCycle > 0) {
const macroThresholdSec = theoreticalCycle * macroMultiplier;
eventType = stopSec >= macroThresholdSec ? "macrostop" : "microstop";
} else {
const fallbackMacroSec = 300;
eventType = stopSec >= fallbackMacroSec ? "macrostop" : "microstop";
}
}
}
// -----------------------------
// 5) Severity, title, description, timestamp
// -----------------------------
const severity =
String(
(row.severity && row.severity !== "info" ? row.severity : null) ??
blob?.severity ??
inner?.severity ??
"info"
)
.trim()
.toLowerCase();
const title =
String(
(row.title && row.title !== "Event" ? row.title : null) ??
blob?.title ??
inner?.title ??
(eventType === "slow-cycle" ? "Slow Cycle Detected" : "Event")
).trim();
const description =
row.description ??
blob?.description ??
inner?.description ??
(eventType === "slow-cycle" &&
(inner?.actual_cycle_time ?? blob?.actual_cycle_time) &&
(inner?.theoretical_cycle_time ?? blob?.theoretical_cycle_time) &&
(inner?.delta_percent ?? blob?.delta_percent) != null
? `Cycle took ${Number(inner?.actual_cycle_time ?? blob?.actual_cycle_time).toFixed(1)}s (+${Number(inner?.delta_percent ?? blob?.delta_percent)}% vs ${Number(inner?.theoretical_cycle_time ?? blob?.theoretical_cycle_time).toFixed(1)}s objetivo)`
: null);
const ts =
row.ts ??
(typeof blob?.timestamp === "number" ? new Date(blob.timestamp) : null) ??
(typeof inner?.timestamp === "number" ? new Date(inner.timestamp) : null) ??
null;
const workOrderId =
row.workOrderId ??
blob?.work_order_id ??
inner?.work_order_id ??
null;
return {
id: row.id,
ts,
topic: String(row.topic ?? blob?.topic ?? eventType),
eventType,
severity,
title,
description,
requiresAck: !!row.requiresAck,
workOrderId,
};
}
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 { machineId } = await params;
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: {
id: true,
name: true,
code: true,
location: true,
heartbeats: {
orderBy: { ts: "desc" },
take: 1,
select: { ts: 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 orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: session.orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
});
const microMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
const macroMultiplier = Math.max(
microMultiplier,
Number(orgSettings?.macroStoppageMultiplier ?? 5)
);
const rawEvents = await prisma.machineEvent.findMany({
where: {
orgId: session.orgId,
machineId,
},
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",
"oee-drop",
"quality-spike",
"performance-degradation",
"predictive-oee-decline",
]);
const events = normalized
.filter((e) => ALLOWED_TYPES.has(e.eventType))
// drop severity gating so recent info events appear
.slice(0, 30);
// ---- cycles window ----
const url = new URL(_req.url);
const windowSec = Number(url.searchParams.get("windowSec") ?? "3600"); // default 1h
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 doesnt 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,
cycles,
});
}