Macrostop and timeline segmentation

This commit is contained in:
mdares
2026-01-09 00:01:04 +00:00
parent 7790361a0a
commit d0ab254dd7
33 changed files with 1865 additions and 179 deletions

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
function unwrapEnvelope(raw: any) {
if (!raw || typeof raw !== "object") return raw;
@@ -25,6 +26,39 @@ function unwrapEnvelope(raw: any) {
};
}
const numberFromAny = z.preprocess((value) => {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim() !== "") return Number(value);
return value;
}, z.number().finite());
const intFromAny = z.preprocess((value) => {
if (typeof value === "number") return Math.trunc(value);
if (typeof value === "string" && value.trim() !== "") return Math.trunc(Number(value));
return value;
}, z.number().int().finite());
const cyclePayloadSchema = z
.object({
machineId: z.string().uuid(),
cycle: z
.object({
actual_cycle_time: numberFromAny,
theoretical_cycle_time: numberFromAny.optional(),
cycle_count: intFromAny.optional(),
work_order_id: z.string().trim().max(64).optional(),
sku: z.string().trim().max(64).optional(),
cavities: intFromAny.optional(),
good_delta: intFromAny.optional(),
scrap_delta: intFromAny.optional(),
timestamp: numberFromAny.optional(),
ts: numberFromAny.optional(),
event_timestamp: numberFromAny.optional(),
})
.passthrough(),
})
.passthrough();
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 });
@@ -32,24 +66,26 @@ export async function POST(req: Request) {
let body = await req.json().catch(() => null);
body = unwrapEnvelope(body);
if (!body?.machineId || !body?.cycle) {
const parsed = cyclePayloadSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
where: { id: String(body.machineId), apiKey },
where: { id: parsed.data.machineId, apiKey },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const c = body.cycle;
const c = parsed.data.cycle;
const raw = body as any;
const tsMs =
(typeof c.timestamp === "number" && c.timestamp) ||
(typeof c.ts === "number" && c.ts) ||
(typeof c.event_timestamp === "number" && c.event_timestamp) ||
(typeof body.tsMs === "number" && body.tsMs) ||
(typeof body.tsDevice === "number" && body.tsDevice) ||
(typeof raw?.tsMs === "number" && raw.tsMs) ||
(typeof raw?.tsDevice === "number" && raw.tsDevice) ||
undefined;
const ts = tsMs ? new Date(tsMs) : new Date();
@@ -60,8 +96,8 @@ export async function POST(req: Request) {
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,
actualCycleTime: c.actual_cycle_time,
theoreticalCycleTime: typeof c.theoretical_cycle_time === "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,

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const normalizeType = (t: any) =>
String(t ?? "")
@@ -33,9 +34,19 @@ const ALLOWED_TYPES = new Set([
"predictive-oee-decline",
]);
// thresholds for stop classification (tune later / move to machine config)
const MICROSTOP_SEC = 60;
const MACROSTOP_SEC = 300;
const machineIdSchema = z.string().uuid();
const MAX_EVENTS = 100;
//when no cycle time is configed
const DEFAULT_MACROSTOP_SEC = 300;
function clampText(value: unknown, maxLen: number) {
if (value === null || value === undefined) return null;
const text = String(value).trim().replace(/[\u0000-\u001f\u007f]/g, "");
if (!text) return null;
return text.length > maxLen ? text.slice(0, maxLen) : text;
}
export async function POST(req: Request) {
const apiKey = req.headers.get("x-api-key");
@@ -68,14 +79,32 @@ export async function POST(req: Request) {
);
}
if (!machineIdSchema.safeParse(String(machineId)).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
where: { id: String(machineId), apiKey },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: machine.orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
});
const defaultMicroMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
const defaultMacroMultiplier = Math.max(
defaultMicroMultiplier,
Number(orgSettings?.macroStoppageMultiplier ?? 5)
);
// ✅ normalize to array no matter what
const events = Array.isArray(rawEvent) ? rawEvent : [rawEvent];
if (events.length > MAX_EVENTS) {
return NextResponse.json({ ok: false, error: "Too many events" }, { status: 400 });
}
const created: { id: string; ts: Date; eventType: string }[] = [];
const skipped: any[] = [];
@@ -112,7 +141,29 @@ export async function POST(req: Request) {
null;
if (stopSec != null) {
finalType = stopSec >= MACROSTOP_SEC ? "macrostop" : "microstop";
const theoretical =
Number(
(ev as any)?.data?.theoretical_cycle_time ??
(ev as any)?.data?.theoreticalCycleTime ??
0
) || 0;
const microMultiplier = Number(
(ev as any)?.data?.micro_threshold_multiplier ??
(ev as any)?.data?.threshold_multiplier ??
defaultMicroMultiplier
);
const macroMultiplier = Math.max(
microMultiplier,
Number((ev as any)?.data?.macro_threshold_multiplier ?? defaultMacroMultiplier)
);
if (theoretical > 0) {
const macroThresholdSec = theoretical * macroMultiplier;
finalType = stopSec >= macroThresholdSec ? "macrostop" : "microstop";
} else {
finalType = stopSec >= DEFAULT_MACROSTOP_SEC ? "macrostop" : "microstop";
}
} else {
// missing duration -> conservative
finalType = "microstop";
@@ -125,13 +176,13 @@ export async function POST(req: Request) {
}
const title =
String((ev as any).title ?? "").trim() ||
clampText((ev as any).title, 160) ||
(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;
const description = clampText((ev as any).description, 1000);
// store full blob, ensure object
const rawData = (ev as any).data ?? ev;
@@ -144,7 +195,7 @@ export async function POST(req: Request) {
orgId: machine.orgId,
machineId: machine.id,
ts,
topic: String((ev as any).topic ?? finalType),
topic: clampText((ev as any).topic ?? finalType, 64) ?? finalType,
eventType: finalType,
severity: sev,
requiresAck: !!(ev as any).requires_ack,
@@ -152,13 +203,13 @@ export async function POST(req: Request) {
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,
clampText((ev as any)?.work_order_id, 64) ??
clampText((ev as any)?.data?.work_order_id, 64) ??
null,
sku:
(ev as any)?.sku ? String((ev as any).sku)
: (ev as any)?.data?.sku ? String((ev as any).data.sku)
: null,
clampText((ev as any)?.sku, 64) ??
clampText((ev as any)?.data?.sku, 64) ??
null,
},
});