MVP
This commit is contained in:
45
app/api/ingest/cycle/route.ts
Normal file
45
app/api/ingest/cycle/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const apiKey = req.headers.get("x-api-key");
|
||||
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body?.machineId || !body?.cycle) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const machine = await prisma.machine.findFirst({
|
||||
where: { id: String(body.machineId), apiKey },
|
||||
select: { id: true, orgId: true },
|
||||
});
|
||||
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const c = body.cycle;
|
||||
|
||||
const tsMs =
|
||||
(typeof c.timestamp === "number" && c.timestamp) ||
|
||||
(typeof c.ts === "number" && c.ts) ||
|
||||
(typeof c.event_timestamp === "number" && c.event_timestamp) ||
|
||||
undefined;
|
||||
|
||||
const ts = tsMs ? new Date(tsMs) : new Date();
|
||||
|
||||
const row = await prisma.machineCycle.create({
|
||||
data: {
|
||||
orgId: machine.orgId,
|
||||
machineId: machine.id,
|
||||
ts,
|
||||
cycleCount: typeof c.cycle_count === "number" ? c.cycle_count : null,
|
||||
actualCycleTime: Number(c.actual_cycle_time),
|
||||
theoreticalCycleTime: c.theoretical_cycle_time != null ? Number(c.theoretical_cycle_time) : null,
|
||||
workOrderId: c.work_order_id ? String(c.work_order_id) : null,
|
||||
sku: c.sku ? String(c.sku) : null,
|
||||
cavities: typeof c.cavities === "number" ? c.cavities : null,
|
||||
goodDelta: typeof c.good_delta === "number" ? c.good_delta : null,
|
||||
scrapDelta: typeof c.scrap_delta === "number" ? c.scrap_delta : null,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
|
||||
}
|
||||
@@ -1,77 +1,153 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const normalizeType = (t: any) =>
|
||||
String(t ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/_/g, "-");
|
||||
|
||||
const CANON_TYPE: Record<string, string> = {
|
||||
// Node-RED
|
||||
"production-stopped": "stop",
|
||||
"oee-drop": "oee-drop",
|
||||
"quality-spike": "quality-spike",
|
||||
"predictive-oee-decline": "predictive-oee-decline",
|
||||
"performance-degradation": "performance-degradation",
|
||||
|
||||
// legacy / synonyms
|
||||
"macroparo": "macrostop",
|
||||
"macro-stop": "macrostop",
|
||||
"microparo": "microstop",
|
||||
"micro-paro": "microstop",
|
||||
"down": "stop",
|
||||
};
|
||||
|
||||
const ALLOWED_TYPES = new Set([
|
||||
"slow-cycle",
|
||||
"microstop",
|
||||
"macrostop",
|
||||
"oee-drop",
|
||||
"quality-spike",
|
||||
"performance-degradation",
|
||||
"predictive-oee-decline",
|
||||
]);
|
||||
|
||||
// thresholds for stop classification (tune later / move to machine config)
|
||||
const MICROSTOP_SEC = 60;
|
||||
const MACROSTOP_SEC = 300;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const apiKey = req.headers.get("x-api-key");
|
||||
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body?.machineId || !body?.event) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
const machine = await prisma.machine.findFirst({
|
||||
where: { id: String(body.machineId), apiKey },
|
||||
select: { id: true, orgId: true },
|
||||
});
|
||||
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
if (!machine) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
|
||||
// Normalize to array (Node-RED sends array of anomalies)
|
||||
const rawEvent = body.event;
|
||||
const events = Array.isArray(rawEvent) ? rawEvent : [rawEvent];
|
||||
|
||||
// Convert ms epoch -> Date if provided
|
||||
const created: { id: string; ts: Date; eventType: string }[] = [];
|
||||
const skipped: any[] = [];
|
||||
|
||||
|
||||
|
||||
const e = body.event;
|
||||
for (const ev of events) {
|
||||
if (!ev || typeof ev !== "object") {
|
||||
skipped.push({ reason: "invalid_event_object" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const ts =
|
||||
typeof e?.data?.timestamp === "number"
|
||||
? new Date(e.data.timestamp)
|
||||
: undefined;
|
||||
const rawType = (ev as any).eventType ?? (ev as any).anomaly_type ?? (ev as any).topic ?? body.topic ?? "";
|
||||
const typ0 = normalizeType(rawType);
|
||||
const typ = CANON_TYPE[typ0] ?? typ0;
|
||||
|
||||
// normalize inputs from event
|
||||
const sev = String(e.severity ?? "").toLowerCase();
|
||||
const typ = String(e.eventType ?? e.anomaly_type ?? "").toLowerCase();
|
||||
const title = String(e.title ?? "").trim();
|
||||
// Determine timestamp
|
||||
const tsMs =
|
||||
(typeof (ev as any)?.timestamp === "number" && (ev as any).timestamp) ||
|
||||
(typeof (ev as any)?.data?.timestamp === "number" && (ev as any).data.timestamp) ||
|
||||
(typeof (ev as any)?.data?.event_timestamp === "number" && (ev as any).data.event_timestamp) ||
|
||||
null;
|
||||
|
||||
const ALLOWED_TYPES = new Set([
|
||||
"slow-cycle",
|
||||
"anomaly-detected",
|
||||
"performance-degradation",
|
||||
"scrap-spike",
|
||||
"down",
|
||||
"microstop",
|
||||
]);
|
||||
const ts = tsMs ? new Date(tsMs) : new Date();
|
||||
|
||||
const ALLOWED_SEVERITIES = new Set(["warning", "critical"]);
|
||||
// Severity defaulting (do not skip on severity — store for audit)
|
||||
let sev = String((ev as any).severity ?? "").trim().toLowerCase();
|
||||
if (!sev) sev = "warning";
|
||||
|
||||
// Drop generic/noise
|
||||
if (!ALLOWED_SEVERITIES.has(sev) || !ALLOWED_TYPES.has(typ)) {
|
||||
return NextResponse.json({ ok: true, skipped: true }, { status: 200 });
|
||||
}
|
||||
|
||||
if (!title) return NextResponse.json({ ok: true, skipped: true }, { status: 200 });
|
||||
|
||||
|
||||
|
||||
const row = await prisma.machineEvent.create({
|
||||
data: {
|
||||
orgId: machine.orgId,
|
||||
machineId: machine.id,
|
||||
ts: ts ?? undefined,
|
||||
|
||||
topic: e.topic ? String(e.topic) : "event",
|
||||
eventType: e.anomaly_type ? String(e.anomaly_type) : "unknown",
|
||||
severity: e.severity ? String(e.severity) : "info",
|
||||
requiresAck: !!e.requires_ack,
|
||||
title: e.title ? String(e.title) : "Event",
|
||||
description: e.description ? String(e.description) : null,
|
||||
|
||||
data: e.data ?? e, // store full blob
|
||||
|
||||
workOrderId: e?.data?.work_order_id ? String(e.data.work_order_id) : null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
|
||||
// Stop classification -> microstop/macrostop
|
||||
let finalType = typ;
|
||||
if (typ === "stop") {
|
||||
const stopSec =
|
||||
(typeof (ev as any)?.data?.stoppage_duration_seconds === "number" && (ev as any).data.stoppage_duration_seconds) ||
|
||||
(typeof (ev as any)?.data?.stop_duration_seconds === "number" && (ev as any).data.stop_duration_seconds) ||
|
||||
null;
|
||||
|
||||
if (stopSec != null) {
|
||||
finalType = stopSec >= MACROSTOP_SEC ? "macrostop" : "microstop";
|
||||
} else {
|
||||
// missing duration -> conservative
|
||||
finalType = "microstop";
|
||||
}
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.has(finalType)) {
|
||||
skipped.push({ reason: "type_not_allowed", typ: finalType, sev });
|
||||
continue;
|
||||
}
|
||||
|
||||
const title =
|
||||
String((ev as any).title ?? "").trim() ||
|
||||
(finalType === "slow-cycle" ? "Slow Cycle Detected" :
|
||||
finalType === "macrostop" ? "Macrostop Detected" :
|
||||
finalType === "microstop" ? "Microstop Detected" :
|
||||
"Event");
|
||||
|
||||
const description = (ev as any).description ? String((ev as any).description) : null;
|
||||
|
||||
// store full blob, ensure object
|
||||
const rawData = (ev as any).data ?? ev;
|
||||
const dataObj = typeof rawData === "string" ? (() => {
|
||||
try { return JSON.parse(rawData); } catch { return { raw: rawData }; }
|
||||
})() : rawData;
|
||||
|
||||
const row = await prisma.machineEvent.create({
|
||||
data: {
|
||||
orgId: machine.orgId,
|
||||
machineId: machine.id,
|
||||
ts,
|
||||
topic: String((ev as any).topic ?? finalType),
|
||||
eventType: finalType,
|
||||
severity: sev,
|
||||
requiresAck: !!(ev as any).requires_ack,
|
||||
title,
|
||||
description,
|
||||
data: dataObj,
|
||||
workOrderId:
|
||||
(ev as any)?.work_order_id ? String((ev as any).work_order_id)
|
||||
: (ev as any)?.data?.work_order_id ? String((ev as any).data.work_order_id)
|
||||
: null,
|
||||
sku:
|
||||
(ev as any)?.sku ? String((ev as any).sku)
|
||||
: (ev as any)?.data?.sku ? String((ev as any).data.sku)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, createdCount: created.length, created, skippedCount: skipped.length, skipped });
|
||||
}
|
||||
|
||||
@@ -3,17 +3,169 @@ import type { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
|
||||
function normalizeEvent(row: any) {
|
||||
// -----------------------------
|
||||
// 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;
|
||||
|
||||
// tune these thresholds to match your MES spec
|
||||
const MACROSTOP_SEC = 300; // 5 min
|
||||
eventType = stopSec != null && stopSec >= MACROSTOP_SEC ? "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: { machineId: string } }
|
||||
{ params }: { params: Promise<{ machineId: string }> }
|
||||
) {
|
||||
const session = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { machineId } = params;
|
||||
|
||||
const { machineId } = await params;
|
||||
|
||||
const machine = await prisma.machine.findFirst({
|
||||
where: { id: machineId, orgId: session.orgId },
|
||||
@@ -51,18 +203,89 @@ export async function GET(
|
||||
return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const events = await prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId: session.orgId,
|
||||
machineId,
|
||||
severity: { in: ["warning", "critical"] },
|
||||
eventType: { in: ["slow-cycle", "anomaly-detected", "performance-degradation", "scrap-spike", "down", "microstop"] },
|
||||
},
|
||||
orderBy: { ts: "desc" },
|
||||
take: 30,
|
||||
select: { /* same as now */ },
|
||||
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(normalizeEvent);
|
||||
|
||||
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))
|
||||
// keep slow-cycle even if severity is info, otherwise require warning/critical/error
|
||||
.filter((e) =>
|
||||
["slow-cycle", "microstop", "macrostop"].includes(e.eventType) ||
|
||||
["warning", "critical", "error"].includes(e.severity)
|
||||
)
|
||||
.slice(0, 30);
|
||||
|
||||
|
||||
const rawCycles = await prisma.machineCycle.findMany({
|
||||
where: { orgId: session.orgId, machineId },
|
||||
orderBy: { ts: "desc" },
|
||||
take: 200,
|
||||
select: {
|
||||
ts: true,
|
||||
cycleCount: true,
|
||||
actualCycleTime: true,
|
||||
theoreticalCycleTime: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
},
|
||||
});
|
||||
|
||||
// chart-friendly: oldest -> newest + numeric timestamps
|
||||
const cycles = rawCycles
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((c) => ({
|
||||
ts: c.ts, // keep Date for “time ago” UI
|
||||
t: c.ts.getTime(), // numeric x-axis for charts
|
||||
cycleCount: c.cycleCount ?? null,
|
||||
actual: c.actualCycleTime, // rename to what chart expects
|
||||
ideal: c.theoreticalCycleTime ?? null,
|
||||
workOrderId: c.workOrderId ?? null,
|
||||
sku: c.sku ?? null,
|
||||
}
|
||||
));
|
||||
|
||||
const latestKpi = machine.kpiSnapshots[0] ?? null;
|
||||
|
||||
// rawCycles is ordered DESC, so [0] is the most recent cycle row
|
||||
const latestCycleIdeal = rawCycles[0]?.theoreticalCycleTime ?? null;
|
||||
|
||||
// REAL effective value (not mock): prefer KPI if present, else fallback to cycles table
|
||||
const effectiveCycleTime = latestKpi?.cycleTime ?? latestCycleIdeal ?? null;
|
||||
|
||||
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
machine: {
|
||||
@@ -72,7 +295,14 @@ export async function GET(
|
||||
location: machine.location,
|
||||
latestHeartbeat: machine.heartbeats[0] ?? null,
|
||||
latestKpi: machine.kpiSnapshots[0] ?? null,
|
||||
effectiveCycleTime
|
||||
|
||||
},
|
||||
events,
|
||||
cycles
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user