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,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import {
@@ -76,6 +76,11 @@ type MachineDetail = {
latestKpi: Kpi | null;
};
type Thresholds = {
stoppageMultiplier: number;
macroStoppageMultiplier: number;
};
type TimelineState = "normal" | "slow" | "microstop" | "macrostop";
type TimelineSeg = {
@@ -85,32 +90,148 @@ type TimelineSeg = {
state: TimelineState;
};
type UploadState = {
status: "idle" | "parsing" | "uploading" | "success" | "error";
message?: string;
count?: number;
};
type WorkOrderUpload = {
workOrderId: string;
sku?: string;
targetQty?: number;
cycleTime?: number;
};
const TOL = 0.10;
const DEFAULT_MICRO_MULT = 1.5;
const DEFAULT_MACRO_MULT = 5;
const NORMAL_TOL_SEC = 0.1;
function classifyGap(dtSec: number, idealSec: number): TimelineState {
const SLOW_X = 1.5;
const STOP_X = 3.0;
const MACRO_X = 10.0;
if (dtSec <= idealSec * SLOW_X) return "normal";
if (dtSec <= idealSec * STOP_X) return "slow";
if (dtSec <= idealSec * MACRO_X) return "microstop";
function resolveMultipliers(thresholds?: Thresholds | null) {
const micro = Number(thresholds?.stoppageMultiplier ?? DEFAULT_MICRO_MULT);
const macro = Math.max(
micro,
Number(thresholds?.macroStoppageMultiplier ?? DEFAULT_MACRO_MULT)
);
return { micro, macro };
}
function classifyCycleDuration(
actualSec: number,
idealSec: number,
thresholds?: Thresholds | null
): TimelineState {
const { micro, macro } = resolveMultipliers(thresholds);
if (actualSec < idealSec + NORMAL_TOL_SEC) return "normal";
if (actualSec < idealSec * micro) return "slow";
if (actualSec < idealSec * macro) return "microstop";
return "macrostop";
}
function mergeAdjacent(segs: TimelineSeg[]): TimelineSeg[] {
if (!segs.length) return [];
const out: TimelineSeg[] = [segs[0]];
for (let i = 1; i < segs.length; i++) {
const prev = out[out.length - 1];
const cur = segs[i];
if (cur.state === prev.state && cur.start <= prev.end + 1) {
prev.end = Math.max(prev.end, cur.end);
prev.durationSec = (prev.end - prev.start) / 1000;
const WORK_ORDER_KEYS = {
id: new Set(["workorderid", "workorder", "orderid", "woid", "work_order_id", "otid"]),
sku: new Set(["sku"]),
cycle: new Set([
"theoreticalcycletimeseconds",
"theoreticalcycletime",
"cycletime",
"cycle_time",
"theoretical_cycle_time",
]),
target: new Set(["targetquantity", "targetqty", "target", "target_qty"]),
};
function normalizeKey(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
}
function parseCsvText(text: string) {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let inQuotes = false;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (ch === "\"") {
if (inQuotes && text[i + 1] === "\"") {
field += "\"";
i += 1;
} else {
out.push(cur);
inQuotes = !inQuotes;
}
continue;
}
if (ch === "," && !inQuotes) {
row.push(field);
field = "";
continue;
}
if ((ch === "\n" || ch === "\r") && !inQuotes) {
if (ch === "\r" && text[i + 1] === "\n") i += 1;
row.push(field);
field = "";
if (row.some((cell) => cell.trim().length > 0)) {
rows.push(row);
}
row = [];
continue;
}
field += ch;
}
row.push(field);
if (row.some((cell) => cell.trim().length > 0)) {
rows.push(row);
}
if (!rows.length) return [];
const headers = rows.shift()!.map((h) => h.trim());
return rows.map((cols) => {
const obj: Record<string, string> = {};
headers.forEach((header, idx) => {
obj[header] = (cols[idx] ?? "").trim();
});
return obj;
});
}
function pickRowValue(row: Record<string, any>, keys: Set<string>) {
for (const [key, value] of Object.entries(row)) {
if (keys.has(normalizeKey(key))) return value;
}
return undefined;
}
function rowsToWorkOrders(rows: Array<Record<string, any>>): WorkOrderUpload[] {
const seen = new Set<string>();
const out: WorkOrderUpload[] = [];
rows.forEach((row) => {
const rawId = pickRowValue(row, WORK_ORDER_KEYS.id);
const workOrderId = String(rawId ?? "").trim();
if (!workOrderId || seen.has(workOrderId)) return;
seen.add(workOrderId);
const sku = String(pickRowValue(row, WORK_ORDER_KEYS.sku) ?? "").trim();
const targetRaw = pickRowValue(row, WORK_ORDER_KEYS.target);
const cycleRaw = pickRowValue(row, WORK_ORDER_KEYS.cycle);
const targetQty = Number.isFinite(Number(targetRaw)) ? Math.trunc(Number(targetRaw)) : undefined;
const cycleTime = Number.isFinite(Number(cycleRaw)) ? Number(cycleRaw) : undefined;
out.push({ workOrderId, sku: sku || undefined, targetQty, cycleTime });
});
return out;
}
@@ -124,7 +245,10 @@ export default function MachineDetailClient() {
const [events, setEvents] = useState<EventRow[]>([]);
const [error, setError] = useState<string | null>(null);
const [cycles, setCycles] = useState<CycleRow[]>([]);
const [thresholds, setThresholds] = useState<Thresholds | null>(null);
const [open, setOpen] = useState<null | "events" | "deviation" | "impact">(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [uploadState, setUploadState] = useState<UploadState>({ status: "idle" });
const BUCKET = {
normal: {
@@ -183,6 +307,7 @@ export default function MachineDetailClient() {
setMachine(json.machine ?? null);
setEvents(json.events ?? []);
setCycles(json.cycles ?? []);
setThresholds(json.thresholds ?? null);
setError(null);
setLoading(false);
} catch {
@@ -200,6 +325,101 @@ export default function MachineDetailClient() {
};
}, [machineId, t]);
async function parseWorkOrdersFile(file: File) {
const name = file.name.toLowerCase();
if (name.endsWith(".csv")) {
const text = await file.text();
return rowsToWorkOrders(parseCsvText(text));
}
if (name.endsWith(".xls") || name.endsWith(".xlsx")) {
const buffer = await file.arrayBuffer();
const xlsx = await import("xlsx");
const workbook = xlsx.read(buffer, { type: "array" });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
if (!sheet) return [];
const rows = xlsx.utils.sheet_to_json(sheet, { defval: "" });
return rowsToWorkOrders(rows as Array<Record<string, any>>);
}
return null;
}
async function handleWorkOrderUpload(event: any) {
const file = event?.target?.files?.[0] as File | undefined;
if (!file) return;
if (!machineId) {
setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadError") });
event.target.value = "";
return;
}
setUploadState({ status: "parsing", message: t("machine.detail.workOrders.uploadParsing") });
try {
const workOrders = await parseWorkOrdersFile(file);
if (!workOrders) {
setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadInvalid") });
event.target.value = "";
return;
}
if (!workOrders.length) {
setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadInvalid") });
event.target.value = "";
return;
}
setUploadState({ status: "uploading", message: t("machine.detail.workOrders.uploading") });
const res = await fetch("/api/work-orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ machineId, workOrders }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json?.ok === false) {
if (res.status === 401 || res.status === 403) {
setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadUnauthorized") });
} else {
setUploadState({
status: "error",
message: json?.error ?? t("machine.detail.workOrders.uploadError"),
});
}
event.target.value = "";
return;
}
setUploadState({
status: "success",
message: t("machine.detail.workOrders.uploadSuccess", { count: workOrders.length }),
count: workOrders.length,
});
event.target.value = "";
} catch {
setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadError") });
event.target.value = "";
}
}
const uploadButtonLabel =
uploadState.status === "parsing"
? t("machine.detail.workOrders.uploadParsing")
: uploadState.status === "uploading"
? t("machine.detail.workOrders.uploading")
: t("machine.detail.workOrders.upload");
const uploadStatusClass =
uploadState.status === "success"
? "bg-emerald-500/15 text-emerald-300 border-emerald-500/20"
: uploadState.status === "error"
? "bg-red-500/15 text-red-300 border-red-500/20"
: "bg-white/10 text-zinc-200 border-white/10";
const isUploading = uploadState.status === "parsing" || uploadState.status === "uploading";
function fmtPct(v?: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return t("common.na");
return `${v.toFixed(1)}%`;
@@ -474,6 +694,7 @@ export default function MachineDetailClient() {
const cycleDerived = useMemo(() => {
const rows = cycles ?? [];
const { micro, macro } = resolveMultipliers(thresholds);
const mapped: CycleDerivedRow[] = rows.map((cycle) => {
const ideal = cycle.ideal ?? null;
@@ -482,10 +703,7 @@ export default function MachineDetailClient() {
let bucket: CycleDerivedRow["bucket"] = "unknown";
if (ideal != null && actual != null) {
if (actual <= ideal * (1 + TOL)) bucket = "normal";
else if (extra != null && extra <= 1) bucket = "slow";
else if (extra != null && extra <= 10) bucket = "microstop";
else bucket = "macrostop";
bucket = classifyCycleDuration(actual, ideal, thresholds);
}
return { ...cycle, ideal, actual, extra, bucket };
@@ -505,7 +723,7 @@ export default function MachineDetailClient() {
const avgDeltaPct = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : null;
return { mapped, counts, avgDeltaPct };
}, [cycles]);
}, [cycles, thresholds]);
const deviationSeries = useMemo(() => {
const last = cycleDerived.mapped.slice(-100);
@@ -557,7 +775,7 @@ export default function MachineDetailClient() {
const timeline = useMemo(() => {
const rows = cycles ?? [];
if (rows.length < 2) {
if (rows.length < 1) {
return {
windowSec: 10800,
segments: [] as TimelineSeg[],
@@ -570,27 +788,24 @@ export default function MachineDetailClient() {
const end = rows[rows.length - 1].t;
const start = end - windowSec * 1000;
const idxFirst = Math.max(
0,
rows.findIndex((row) => row.t >= start) - 1
);
const sliced = rows.slice(idxFirst);
const segs: TimelineSeg[] = [];
for (let i = 1; i < sliced.length; i++) {
const prev = sliced[i - 1];
const cur = sliced[i];
for (const cycle of rows) {
const ideal = (cycle.ideal ?? cycleTarget ?? 0) as number;
const actual = cycle.actual ?? 0;
if (!ideal || ideal <= 0 || !actual || actual <= 0) continue;
const segStart = Math.max(prev.t, start);
const segEnd = Math.min(cur.t, end);
const cycleEnd = cycle.t;
const cycleStart = cycleEnd - actual * 1000;
if (cycleEnd <= start || cycleStart >= end) continue;
const segStart = Math.max(cycleStart, start);
const segEnd = Math.min(cycleEnd, end);
if (segEnd <= segStart) continue;
const dtSec = (cur.t - prev.t) / 1000;
const ideal = (cur.ideal ?? prev.ideal ?? cycleTarget ?? 0) as number;
if (!ideal || ideal <= 0) continue;
const state = classifyCycleDuration(actual, ideal, thresholds);
const state = classifyGap(dtSec, ideal);
segs.push({
start: segStart,
@@ -600,9 +815,8 @@ export default function MachineDetailClient() {
});
}
const segments = mergeAdjacent(segs);
return { windowSec, segments, start, end };
}, [cycles, cycleTarget]);
return { windowSec, segments: segs, start, end };
}, [cycles, cycleTarget, thresholds]);
const cycleTargetLabel = cycleTarget ? `${cycleTarget}s` : t("common.na");
const workOrderLabel = kpi?.workOrderId ?? t("common.na");
@@ -625,7 +839,23 @@ export default function MachineDetailClient() {
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex shrink-0 flex-col items-end gap-2">
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".csv,.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
className="hidden"
onChange={handleWorkOrderUpload}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-100 transition hover:bg-emerald-500/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{uploadButtonLabel}
</button>
<Link
href="/machines"
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
@@ -633,6 +863,15 @@ export default function MachineDetailClient() {
{t("machine.detail.back")}
</Link>
</div>
<div className="text-right text-[11px] text-zinc-500">
{t("machine.detail.workOrders.uploadHint")}
</div>
{uploadState.status !== "idle" && uploadState.message && (
<div className={`rounded-full border px-3 py-1 text-xs ${uploadStatusClass}`}>
{uploadState.message}
</div>
)}
</div>
</div>
{loading && <div className="text-sm text-zinc-400">{t("machine.detail.loading")}</div>}

View File

@@ -297,8 +297,21 @@ export default function MachinesPage() {
</div>
<div className="mt-4 text-sm text-zinc-400">{t("machines.status")}</div>
<div className="text-xl font-semibold text-white">
{offline ? t("machines.status.noHeartbeat") : (hb?.message ?? t("machines.status.ok"))}
<div className="mt-1 flex items-center gap-2 text-sm font-semibold text-white">
{offline ? (
<>
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-zinc-500" aria-hidden="true" />
<span>{t("machines.status.noHeartbeat")}</span>
</>
) : (
<>
<span className="relative flex h-2.5 w-2.5" aria-hidden="true">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" />
</span>
<span>{t("machines.status.ok")}</span>
</>
)}
</div>
</Link>
);
@@ -310,4 +323,3 @@ export default function MachinesPage() {

View File

@@ -35,6 +35,11 @@ type MachineRow = {
latestKpi?: Kpi | null;
};
type Thresholds = {
stoppageMultiplier: number;
macroStoppageMultiplier: number;
};
type EventRow = {
id: string;
ts: string;
@@ -60,7 +65,17 @@ type CycleRow = {
const OFFLINE_MS = 30000;
const EVENT_WINDOW_SEC = 1800;
const MAX_EVENT_MACHINES = 6;
const TOL = 0.10;
const DEFAULT_MICRO_MULT = 1.5;
const DEFAULT_MACRO_MULT = 5;
function resolveMultipliers(thresholds?: Thresholds | null) {
const micro = Number(thresholds?.stoppageMultiplier ?? DEFAULT_MICRO_MULT);
const macro = Math.max(
micro,
Number(thresholds?.macroStoppageMultiplier ?? DEFAULT_MACRO_MULT)
);
return { micro, macro };
}
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
if (!ts) return fallback;
@@ -106,16 +121,17 @@ function sourceClass(src: EventRow["source"]) {
: "bg-emerald-500/15 text-emerald-300";
}
function classifyDerivedEvent(c: CycleRow) {
function classifyDerivedEvent(c: CycleRow, thresholds?: Thresholds | null) {
if (c.ideal == null || c.ideal <= 0 || c.actual <= 0) return null;
if (c.actual <= c.ideal * (1 + TOL)) return null;
if (c.actual <= c.ideal) return null;
const { micro, macro } = resolveMultipliers(thresholds);
const extra = c.actual - c.ideal;
let eventType = "slow-cycle";
let severity = "warning";
if (extra <= 1) {
if (c.actual < c.ideal * micro) {
eventType = "slow-cycle";
severity = "info";
} else if (extra <= 10) {
severity = "warning";
} else if (c.actual < c.ideal * macro) {
eventType = "microstop";
severity = "warning";
} else {
@@ -216,7 +232,7 @@ export default function OverviewPage() {
const cycles: CycleRow[] = Array.isArray(payload?.cycles) ? payload.cycles : [];
for (const c of cycles.slice(-120)) {
const derived = classifyDerivedEvent(c);
const derived = classifyDerivedEvent(c, payload?.thresholds);
if (!derived) continue;
combined.push({
id: `derived-${machine.id}-${c.t}`,

View File

@@ -21,6 +21,7 @@ type SettingsPayload = {
};
thresholds: {
stoppageMultiplier: number;
macroStoppageMultiplier: number;
oeeAlertThresholdPct: number;
performanceThresholdPct: number;
qualitySpikeDeltaPct: number;
@@ -82,6 +83,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
thresholds: {
stoppageMultiplier: 1.5,
oeeAlertThresholdPct: 90,
macroStoppageMultiplier: 5,
performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5,
},
@@ -151,6 +153,9 @@ function normalizeSettings(raw: any, fallbackName: (index: number) => string): S
stoppageMultiplier: Number(
raw.thresholds?.stoppageMultiplier ?? DEFAULT_SETTINGS.thresholds.stoppageMultiplier
),
macroStoppageMultiplier: Number(
raw.thresholds?.macroStoppageMultiplier ?? DEFAULT_SETTINGS.thresholds.macroStoppageMultiplier
),
oeeAlertThresholdPct: Number(
raw.thresholds?.oeeAlertThresholdPct ?? DEFAULT_SETTINGS.thresholds.oeeAlertThresholdPct
),
@@ -351,6 +356,7 @@ export default function SettingsPage() {
(
key:
| "stoppageMultiplier"
| "macroStoppageMultiplier"
| "oeeAlertThresholdPct"
| "performanceThresholdPct"
| "qualitySpikeDeltaPct",
@@ -650,6 +656,20 @@ export default function SettingsPage() {
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
/>
</label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
{t("settings.thresholds.macroStoppage")}
<input
type="number"
min={1.1}
max={20}
step={0.1}
value={draft.thresholds.macroStoppageMultiplier}
onChange={(event) =>
updateThreshold("macroStoppageMultiplier", Number(event.target.value))
}
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
/>
</label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
{t("settings.thresholds.performance")} (%)
<input

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,
},
});

View File

@@ -3,6 +3,13 @@ import type { NextRequest } from "next/server";
import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma";
import { buildSessionCookieOptions, COOKIE_NAME, SESSION_DAYS } from "@/lib/auth/sessionCookie";
import { z } from "zod";
const tokenSchema = z.string().regex(/^[a-f0-9]{48}$/i);
const acceptSchema = z.object({
name: z.string().trim().min(1).max(80).optional(),
password: z.string().min(8).max(256),
});
async function loadInvite(token: string) {
return prisma.orgInvite.findFirst({
@@ -23,6 +30,9 @@ export async function GET(
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
if (!tokenSchema.safeParse(token).success) {
return NextResponse.json({ ok: false, error: "Invalid invite token" }, { status: 400 });
}
const invite = await loadInvite(token);
if (!invite) {
return NextResponse.json({ ok: false, error: "Invite not found" }, { status: 404 });
@@ -44,23 +54,26 @@ export async function POST(
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
if (!tokenSchema.safeParse(token).success) {
return NextResponse.json({ ok: false, error: "Invalid invite token" }, { status: 400 });
}
const invite = await loadInvite(token);
if (!invite) {
return NextResponse.json({ ok: false, error: "Invite not found" }, { status: 404 });
}
const body = await req.json().catch(() => ({}));
const name = String(body.name || "").trim();
const password = String(body.password || "");
const parsed = acceptSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid invite payload" }, { status: 400 });
}
const name = String(parsed.data.name || "").trim();
const password = parsed.data.password;
const existingUser = await prisma.user.findUnique({
where: { email: invite.email },
});
if (!password || password.length < 8) {
return NextResponse.json({ ok: false, error: "Password must be at least 8 characters" }, { status: 400 });
}
if (!existingUser && !name) {
return NextResponse.json({ ok: false, error: "Name is required" }, { status: 400 });
}

View File

@@ -1,19 +1,33 @@
import { NextResponse } from "next/server";
import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const COOKIE_NAME = "mis_session";
const SESSION_DAYS = 7;
const loginSchema = z.object({
email: z.string().trim().min(1).max(254).email(),
password: z.string().min(1).max(256),
next: z.string().optional(),
});
function safeNextPath(value: unknown) {
const raw = String(value ?? "").trim();
if (!raw) return "/machines";
if (!raw.startsWith("/") || raw.startsWith("//")) return "/machines";
return raw;
}
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const email = String(body.email || "").trim().toLowerCase();
const password = String(body.password || "");
const next = String(body.next || "/machines");
if (!email || !password) {
return NextResponse.json({ ok: false, error: "Missing email/password" }, { status: 400 });
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid login payload" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase();
const password = parsed.data.password;
const next = safeNextPath(parsed.data.next);
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.isActive) {

View File

@@ -2,16 +2,30 @@ import { NextResponse } from "next/server";
import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma";
import { buildSessionCookieOptions, COOKIE_NAME, SESSION_DAYS } from "@/lib/auth/sessionCookie";
import { z } from "zod";
const loginSchema = z.object({
email: z.string().trim().min(1).max(254).email(),
password: z.string().min(1).max(256),
next: z.string().optional(),
});
function safeNextPath(value: unknown) {
const raw = String(value ?? "").trim();
if (!raw) return "/machines";
if (!raw.startsWith("/") || raw.startsWith("//")) return "/machines";
return raw;
}
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const email = String(body.email || "").trim().toLowerCase();
const password = String(body.password || "");
const next = String(body.next || "/machines");
if (!email || !password) {
return NextResponse.json({ ok: false, error: "Missing email/password" }, { status: 400 });
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid login payload" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase();
const password = parsed.data.password;
const next = safeNextPath(parsed.data.next);
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.isActive) {

View File

@@ -3,7 +3,10 @@ import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
function normalizeEvent(row: any) {
function normalizeEvent(
row: any,
thresholds: { microMultiplier: number; macroMultiplier: number }
) {
// -----------------------------
// 1) Parse row.data safely
// data may be:
@@ -91,9 +94,24 @@ function normalizeEvent(row: any) {
(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";
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";
}
}
}
// -----------------------------
@@ -203,6 +221,17 @@ export async function GET(
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,
@@ -224,7 +253,9 @@ export async function GET(
},
});
const normalized = rawEvents.map(normalizeEvent);
const normalized = rawEvents.map((row) =>
normalizeEvent(row, { microMultiplier, macroMultiplier })
);
const ALLOWED_TYPES = new Set([
"slow-cycle",
@@ -308,14 +339,15 @@ const cycles = rawCycles
location: machine.location,
latestHeartbeat: machine.heartbeats[0] ?? null,
latestKpi: machine.kpiSnapshots[0] ?? null,
effectiveCycleTime
effectiveCycleTime,
},
thresholds: {
stoppageMultiplier: microMultiplier,
macroStoppageMultiplier: macroMultiplier,
},
events,
cycles
});
}

View File

@@ -3,10 +3,20 @@ import { randomBytes } from "crypto";
import { prisma } from "@/lib/prisma";
import { getBaseUrl } from "@/lib/appUrl";
import { normalizePairingCode } from "@/lib/pairingCode";
import { z } from "zod";
const pairSchema = z.object({
code: z.string().trim().max(16).optional(),
pairingCode: z.string().trim().max(16).optional(),
});
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const rawCode = String(body.code || body.pairingCode || "").trim();
const parsed = pairSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid pairing payload" }, { status: 400 });
}
const rawCode = String(parsed.data.code || parsed.data.pairingCode || "").trim();
const code = normalizePairingCode(rawCode);
if (!code || code.length !== 5) {

View File

@@ -3,9 +3,16 @@ import { randomBytes } from "crypto";
import { prisma } from "@/lib/prisma";
import { cookies } from "next/headers";
import { generatePairingCode } from "@/lib/pairingCode";
import { z } from "zod";
const COOKIE_NAME = "mis_session";
const createMachineSchema = z.object({
name: z.string().trim().min(1).max(80),
code: z.string().trim().max(40).optional(),
location: z.string().trim().max(80).optional(),
});
async function requireSession() {
const sessionId = (await cookies()).get(COOKIE_NAME)?.value;
if (!sessionId) return null;
@@ -79,14 +86,15 @@ export async function POST(req: Request) {
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const body = await req.json().catch(() => ({}));
const name = String(body.name || "").trim();
const codeRaw = String(body.code || "").trim();
const locationRaw = String(body.location || "").trim();
if (!name) {
return NextResponse.json({ ok: false, error: "Machine name is required" }, { status: 400 });
const parsed = createMachineSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid machine payload" }, { status: 400 });
}
const name = parsed.data.name;
const codeRaw = parsed.data.code ?? "";
const locationRaw = parsed.data.location ?? "";
const existing = await prisma.machine.findFirst({
where: { orgId: session.orgId, name },
select: { id: true },

View File

@@ -2,11 +2,14 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { z } from "zod";
function canManageMembers(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
const inviteIdSchema = z.string().uuid();
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ inviteId: string }> }
@@ -17,6 +20,9 @@ export async function DELETE(
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const { inviteId } = await params;
if (!inviteIdSchema.safeParse(inviteId).success) {
return NextResponse.json({ ok: false, error: "Invalid invite id" }, { status: 400 });
}
const membership = await prisma.orgUser.findUnique({
where: {

View File

@@ -4,18 +4,19 @@ import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { buildInviteEmail, sendEmail } from "@/lib/email";
import { getBaseUrl } from "@/lib/appUrl";
import { z } from "zod";
const INVITE_DAYS = 7;
const ROLES = new Set(["OWNER", "ADMIN", "MEMBER"]);
const inviteSchema = z.object({
email: z.string().trim().min(1).max(254).email(),
role: z.string().trim().toUpperCase().optional(),
});
function canManageMembers(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
function isValidEmail(email: string) {
return email.includes("@") && email.includes(".");
}
export async function GET() {
try {
@@ -97,12 +98,12 @@ export async function POST(req: Request) {
}
const body = await req.json().catch(() => ({}));
const email = String(body.email || "").trim().toLowerCase();
const role = String(body.role || "MEMBER").toUpperCase();
if (!email || !isValidEmail(email)) {
return NextResponse.json({ ok: false, error: "Invalid email" }, { status: 400 });
const parsed = inviteSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid invite payload" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase();
const role = String(parsed.data.role || "MEMBER").toUpperCase();
if (!ROLES.has(role)) {
return NextResponse.json({ ok: false, error: "Invalid role" }, { status: 400 });

View File

@@ -15,11 +15,25 @@ import {
validateShiftSchedule,
validateThresholds,
} from "@/lib/settings";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
function isPlainObject(value: any): value is Record<string, any> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function canManageSettings(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
const machineIdSchema = z.string().uuid();
const machineSettingsSchema = z
.object({
source: z.string().trim().max(40).optional(),
overrides: z.any().optional(),
})
.passthrough();
function pickAllowedOverrides(raw: any) {
if (!isPlainObject(raw)) return {};
const out: Record<string, any> = {};
@@ -29,7 +43,11 @@ function pickAllowedOverrides(raw: any) {
return out;
}
async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) {
async function ensureOrgSettings(
tx: Prisma.TransactionClient,
orgId: string,
userId?: string | null
) {
let settings = await tx.orgSettings.findUnique({
where: { orgId },
});
@@ -65,12 +83,13 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
shiftChangeCompMin: 10,
lunchBreakMin: 30,
stoppageMultiplier: 1.5,
macroStoppageMultiplier: 5,
oeeAlertThresholdPct: 90,
performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5,
alertsJson: DEFAULT_ALERTS,
defaultsJson: DEFAULT_DEFAULTS,
updatedBy: userId,
updatedBy: userId ?? null,
},
});
@@ -93,23 +112,40 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
}
export async function GET(
_req: NextRequest,
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;
if (!machineIdSchema.safeParse(machineId).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
const session = await requireSession();
let orgId: string | null = null;
let userId: string | null = null;
let machine: { id: string; orgId: string } | null = null;
if (session) {
machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: { id: true },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
orgId = machine.orgId;
userId = session.userId;
} else {
const apiKey = req.headers.get("x-api-key");
if (!apiKey) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
machine = await prisma.machine.findFirst({
where: { id: machineId, apiKey },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
orgId = machine.orgId;
}
const { settings, overrides } = await prisma.$transaction(async (tx) => {
const orgSettings = await ensureOrgSettings(tx, session.orgId, session.userId);
const orgSettings = await ensureOrgSettings(tx, orgId as string, userId);
if (!orgSettings?.settings) throw new Error("SETTINGS_NOT_FOUND");
const machineSettings = await tx.machineSettings.findUnique({
@@ -140,7 +176,18 @@ export async function PUT(
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const membership = await prisma.orgUser.findUnique({
where: { orgId_userId: { orgId: session.orgId, userId: session.userId } },
select: { role: true },
});
if (!canManageSettings(membership?.role)) {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
const { machineId } = await params;
if (!machineIdSchema.safeParse(machineId).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
@@ -150,9 +197,13 @@ export async function PUT(
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
const body = await req.json().catch(() => ({}));
const source = String(body.source ?? "control_tower");
const parsed = machineSettingsSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid settings payload" }, { status: 400 });
}
const source = String(parsed.data.source ?? "control_tower");
let patch = body.overrides ?? body;
let patch = parsed.data.overrides ?? parsed.data;
if (patch === null) {
patch = null;
}
@@ -244,6 +295,10 @@ export async function PUT(
patch.thresholds.stoppageMultiplier !== undefined
? Number(patch.thresholds.stoppageMultiplier)
: patch.thresholds.stoppageMultiplier,
macroStoppageMultiplier:
patch.thresholds.macroStoppageMultiplier !== undefined
? Number(patch.thresholds.macroStoppageMultiplier)
: patch.thresholds.macroStoppageMultiplier,
oeeAlertThresholdPct:
patch.thresholds.oeeAlertThresholdPct !== undefined
? Number(patch.thresholds.oeeAlertThresholdPct)
@@ -318,9 +373,30 @@ export async function PUT(
const overrides = pickAllowedOverrides(saved.overridesJson ?? {});
const effective = deepMerge(orgPayload, overrides);
return { orgPayload, overrides, effective };
return {
orgPayload,
overrides,
effective,
overridesUpdatedAt: saved.updatedAt,
};
});
const overridesUpdatedAt =
result.overridesUpdatedAt && result.overridesUpdatedAt instanceof Date
? result.overridesUpdatedAt.toISOString()
: undefined;
try {
await publishSettingsUpdate({
orgId: session.orgId,
machineId,
version: Number(result.orgPayload.version ?? 0),
source,
overridesUpdatedAt,
});
} catch (err) {
console.warn("[settings machine PUT] MQTT publish failed", err);
}
return NextResponse.json({
ok: true,
machineId,

View File

@@ -16,11 +16,29 @@ import {
validateShiftSchedule,
validateThresholds,
} from "@/lib/settings";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
function isPlainObject(value: any): value is Record<string, any> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function canManageSettings(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
const settingsPayloadSchema = z
.object({
source: z.string().trim().max(40).optional(),
timezone: z.string().trim().max(64).optional(),
shiftSchedule: z.any().optional(),
thresholds: z.any().optional(),
alerts: z.any().optional(),
defaults: z.any().optional(),
version: z.union([z.number(), z.string()]).optional(),
})
.passthrough();
async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) {
let settings = await tx.orgSettings.findUnique({
where: { orgId },
@@ -57,6 +75,7 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
shiftChangeCompMin: 10,
lunchBreakMin: 30,
stoppageMultiplier: 1.5,
macroStoppageMultiplier: 5,
oeeAlertThresholdPct: 90,
performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5,
@@ -108,15 +127,28 @@ export async function PUT(req: Request) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const membership = await prisma.orgUser.findUnique({
where: { orgId_userId: { orgId: session.orgId, userId: session.userId } },
select: { role: true },
});
if (!canManageSettings(membership?.role)) {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
try {
const body = await req.json().catch(() => ({}));
const source = String(body.source ?? "control_tower");
const timezone = body.timezone;
const shiftSchedule = body.shiftSchedule;
const thresholds = body.thresholds;
const alerts = body.alerts;
const defaults = body.defaults;
const expectedVersion = body.version;
const parsed = settingsPayloadSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid settings payload" }, { status: 400 });
}
const source = String(parsed.data.source ?? "control_tower");
const timezone = parsed.data.timezone;
const shiftSchedule = parsed.data.shiftSchedule;
const thresholds = parsed.data.thresholds;
const alerts = parsed.data.alerts;
const defaults = parsed.data.defaults;
const expectedVersion = parsed.data.version;
if (
timezone === undefined &&
@@ -192,6 +224,10 @@ export async function PUT(req: Request) {
shiftSchedule?.lunchBreakMin !== undefined ? Number(shiftSchedule.lunchBreakMin) : undefined,
stoppageMultiplier:
thresholds?.stoppageMultiplier !== undefined ? Number(thresholds.stoppageMultiplier) : undefined,
macroStoppageMultiplier:
thresholds?.macroStoppageMultiplier !== undefined
? Number(thresholds.macroStoppageMultiplier)
: undefined,
oeeAlertThresholdPct:
thresholds?.oeeAlertThresholdPct !== undefined ? Number(thresholds.oeeAlertThresholdPct) : undefined,
performanceThresholdPct:
@@ -267,6 +303,22 @@ export async function PUT(req: Request) {
}
const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []);
const updatedAt =
typeof payload.updatedAt === "string"
? payload.updatedAt
: payload.updatedAt
? payload.updatedAt.toISOString()
: undefined;
try {
await publishSettingsUpdate({
orgId: session.orgId,
version: Number(payload.version ?? 0),
source,
updatedAt,
});
} catch (err) {
console.warn("[settings PUT] MQTT publish failed", err);
}
return NextResponse.json({ ok: true, settings: payload });
} catch (err) {
console.error("[settings PUT] failed", err);

View File

@@ -6,7 +6,14 @@ import { DEFAULT_ALERTS, DEFAULT_DEFAULTS, DEFAULT_SHIFT } from "@/lib/settings"
import { buildVerifyEmail, sendEmail } from "@/lib/email";
import { getBaseUrl } from "@/lib/appUrl";
import { logLine } from "@/lib/logger";
import { z } from "zod";
const signupSchema = z.object({
orgName: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(80),
email: z.string().trim().min(1).max(254).email(),
password: z.string().min(8).max(256),
});
function slugify(input: string) {
const trimmed = input.trim().toLowerCase();
@@ -16,28 +23,16 @@ function slugify(input: string) {
return slug || "org";
}
function isValidEmail(email: string) {
return email.includes("@") && email.includes(".");
}
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const orgName = String(body.orgName || "").trim();
const name = String(body.name || "").trim();
const email = String(body.email || "").trim().toLowerCase();
const password = String(body.password || "");
if (!orgName || !name || !email || !password) {
return NextResponse.json({ ok: false, error: "Missing required fields" }, { status: 400 });
}
if (!isValidEmail(email)) {
return NextResponse.json({ ok: false, error: "Invalid email" }, { status: 400 });
}
if (password.length < 8) {
return NextResponse.json({ ok: false, error: "Password must be at least 8 characters" }, { status: 400 });
const parsed = signupSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid signup payload" }, { status: 400 });
}
const orgName = parsed.data.orgName;
const name = parsed.data.name;
const email = parsed.data.email.toLowerCase();
const password = parsed.data.password;
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
@@ -86,6 +81,7 @@ export async function POST(req: Request) {
shiftChangeCompMin: 10,
lunchBreakMin: 30,
stoppageMultiplier: 1.5,
macroStoppageMultiplier: 5,
oeeAlertThresholdPct: 90,
performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5,

View File

@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> }
) {
const { machineId } = await params;
const session = await requireSession();
let orgId: string | null = null;
if (session) {
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
orgId = machine.orgId;
} else {
const apiKey = req.headers.get("x-api-key");
if (!apiKey) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const machine = await prisma.machine.findFirst({
where: { id: machineId, apiKey },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
orgId = machine.orgId;
}
const rows = await prisma.machineWorkOrder.findMany({
where: { machineId, orgId: orgId as string, status: { not: "DONE" } },
orderBy: { createdAt: "desc" },
});
return NextResponse.json({
ok: true,
machineId,
workOrders: rows.map((row) => ({
workOrderId: row.workOrderId,
sku: row.sku,
targetQty: row.targetQty,
cycleTime: row.cycleTime,
status: row.status,
})),
});
}

View File

@@ -0,0 +1,164 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { publishWorkOrdersUpdate } from "@/lib/mqtt";
import { z } from "zod";
function canManage(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
const MAX_WORK_ORDERS = 2000;
const MAX_WORK_ORDER_ID_LENGTH = 64;
const MAX_SKU_LENGTH = 64;
const MAX_TARGET_QTY = 2_000_000_000;
const MAX_CYCLE_TIME = 86_400;
const WORK_ORDER_ID_RE = /^[A-Za-z0-9._-]+$/;
const uploadBodySchema = z.object({
machineId: z.string().trim().min(1),
workOrders: z.array(z.any()).optional(),
orders: z.array(z.any()).optional(),
workOrder: z.any().optional(),
});
function cleanText(value: unknown, maxLen: number) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
if (!text) return null;
const sanitized = text.replace(/[\u0000-\u001f\u007f]/g, "");
if (!sanitized) return null;
return sanitized.length > maxLen ? sanitized.slice(0, maxLen) : sanitized;
}
function toIntOrNull(value: unknown) {
if (value === null || value === undefined || value === "") return null;
const n = Number(value);
if (!Number.isFinite(n)) return null;
return Math.trunc(n);
}
function toFloatOrNull(value: unknown) {
if (value === null || value === undefined || value === "") return null;
const n = Number(value);
if (!Number.isFinite(n)) return null;
return n;
}
type WorkOrderInput = {
workOrderId: string;
sku?: string | null;
targetQty?: number | null;
cycleTime?: number | null;
};
function normalizeWorkOrders(raw: any[]) {
const seen = new Set<string>();
const cleaned: WorkOrderInput[] = [];
for (const item of raw) {
const idRaw = cleanText(item?.workOrderId ?? item?.id ?? item?.work_order_id, MAX_WORK_ORDER_ID_LENGTH);
if (!idRaw || !WORK_ORDER_ID_RE.test(idRaw) || seen.has(idRaw)) continue;
seen.add(idRaw);
const sku = cleanText(item?.sku ?? item?.SKU ?? null, MAX_SKU_LENGTH);
const targetQtyRaw = toIntOrNull(item?.targetQty ?? item?.target_qty ?? item?.target ?? item?.targetQuantity);
const cycleTimeRaw = toFloatOrNull(
item?.cycleTime ?? item?.theoreticalCycleTime ?? item?.theoretical_cycle_time ?? item?.cycle_time
);
const targetQty =
targetQtyRaw == null ? null : Math.min(Math.max(targetQtyRaw, 0), MAX_TARGET_QTY);
const cycleTime =
cycleTimeRaw == null ? null : Math.min(Math.max(cycleTimeRaw, 0), MAX_CYCLE_TIME);
cleaned.push({
workOrderId: idRaw,
sku: sku ?? null,
targetQty: targetQty ?? null,
cycleTime: cycleTime ?? null,
});
}
return cleaned;
}
export async function POST(req: NextRequest) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const membership = await prisma.orgUser.findUnique({
where: { orgId_userId: { orgId: session.orgId, userId: session.userId } },
select: { role: true },
});
if (!canManage(membership?.role)) {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const parsedBody = uploadBodySchema.safeParse(body);
if (!parsedBody.success) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const machineId = String(parsedBody.data.machineId ?? "").trim();
if (!machineId) {
return NextResponse.json({ ok: false, error: "machineId is required" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: { id: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
const listRaw = Array.isArray(parsedBody.data.workOrders)
? parsedBody.data.workOrders
: Array.isArray(parsedBody.data.orders)
? parsedBody.data.orders
: parsedBody.data.workOrder
? [parsedBody.data.workOrder]
: [];
if (listRaw.length > MAX_WORK_ORDERS) {
return NextResponse.json(
{ ok: false, error: `Too many work orders (max ${MAX_WORK_ORDERS})` },
{ status: 400 }
);
}
const cleaned = normalizeWorkOrders(listRaw);
if (!cleaned.length) {
return NextResponse.json({ ok: false, error: "No valid work orders provided" }, { status: 400 });
}
const created = await prisma.machineWorkOrder.createMany({
data: cleaned.map((row) => ({
orgId: session.orgId,
machineId,
workOrderId: row.workOrderId,
sku: row.sku ?? null,
targetQty: row.targetQty ?? null,
cycleTime: row.cycleTime ?? null,
status: "PENDING",
})),
skipDuplicates: true,
});
try {
await publishWorkOrdersUpdate({
orgId: session.orgId,
machineId,
count: created.count,
});
} catch (err) {
console.warn("[work orders POST] MQTT publish failed", err);
}
return NextResponse.json({
ok: true,
machineId,
inserted: created.count,
total: cleaned.length,
});
}

View File

@@ -178,6 +178,14 @@ Main KPIs remain English in ES-MX (OEE, KPI, SKU, AVAILABILITY, PERFORMANCE, QUA
| machine.detail.error.failed | Failed to load machine | No se pudo cargar la máquina |
| machine.detail.error.network | Network error | Error de red |
| machine.detail.back | Back | Volver |
| machine.detail.workOrders.upload | Upload Work Orders | Subir ordenes de trabajo |
| machine.detail.workOrders.uploading | Uploading... | Subiendo... |
| machine.detail.workOrders.uploadParsing | Parsing file... | Leyendo archivo... |
| machine.detail.workOrders.uploadHint | CSV or XLSX with Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity. | CSV o XLSX con Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity. |
| machine.detail.workOrders.uploadSuccess | Uploaded {count} work orders | Se cargaron {count} ordenes de trabajo |
| machine.detail.workOrders.uploadError | Upload failed | No se pudo cargar |
| machine.detail.workOrders.uploadInvalid | No valid work orders found | No se encontraron ordenes de trabajo validas |
| machine.detail.workOrders.uploadUnauthorized | Not authorized to upload work orders | No autorizado para cargar ordenes de trabajo |
| machine.detail.status.offline | OFFLINE | FUERA DE LÍNEA |
| machine.detail.status.unknown | UNKNOWN | DESCONOCIDO |
| machine.detail.status.run | RUN | EN MARCHA |
@@ -334,6 +342,7 @@ Main KPIs remain English in ES-MX (OEE, KPI, SKU, AVAILABILITY, PERFORMANCE, QUA
| settings.thresholds.stoppage | Stoppage multiplier | Multiplicador de paro |
| settings.alerts | Alerts | Alertas |
| settings.alertsSubtitle | Choose which alerts to notify. | Elige qué alertas notificar. |
| settings.thresholds.macroStoppage | Macro stoppage multiplier | Multiplicador de macroparo |
| settings.alerts.oeeDrop | OEE drop alerts | Alertas por caída de OEE |
| settings.alerts.oeeDropHelper | Notify when OEE falls below threshold | Notificar cuando OEE esté por debajo del umbral |
| settings.alerts.performanceDegradation | Performance degradation alerts | Alertas por baja de Performance |

View File

@@ -130,7 +130,7 @@
"machines.empty": "No machines found for this org.",
"machines.status": "Status",
"machines.status.noHeartbeat": "No heartbeat",
"machines.status.ok": "OK",
"machines.status.ok": "Heartbeat",
"machines.status.offline": "OFFLINE",
"machines.status.unknown": "UNKNOWN",
"machines.lastSeen": "Last seen {time}",
@@ -140,6 +140,14 @@
"machine.detail.error.failed": "Failed to load machine",
"machine.detail.error.network": "Network error",
"machine.detail.back": "Back",
"machine.detail.workOrders.upload": "Upload Work Orders",
"machine.detail.workOrders.uploading": "Uploading...",
"machine.detail.workOrders.uploadParsing": "Parsing file...",
"machine.detail.workOrders.uploadHint": "CSV or XLSX with Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity.",
"machine.detail.workOrders.uploadSuccess": "Uploaded {count} work orders",
"machine.detail.workOrders.uploadError": "Upload failed",
"machine.detail.workOrders.uploadInvalid": "No valid work orders found",
"machine.detail.workOrders.uploadUnauthorized": "Not authorized to upload work orders",
"machine.detail.status.offline": "OFFLINE",
"machine.detail.status.unknown": "UNKNOWN",
"machine.detail.status.run": "RUN",
@@ -286,6 +294,7 @@
"settings.thresholds.performance": "Performance threshold",
"settings.thresholds.qualitySpike": "Quality spike delta",
"settings.thresholds.stoppage": "Stoppage multiplier",
"settings.thresholds.macroStoppage": "Macro stoppage multiplier",
"settings.alerts": "Alerts",
"settings.alertsSubtitle": "Choose which alerts to notify.",
"settings.alerts.oeeDrop": "OEE drop alerts",

View File

@@ -130,7 +130,7 @@
"machines.empty": "No se encontraron máquinas para esta organización.",
"machines.status": "Estado",
"machines.status.noHeartbeat": "Sin heartbeat",
"machines.status.ok": "OK",
"machines.status.ok": "Latido",
"machines.status.offline": "FUERA DE LÍNEA",
"machines.status.unknown": "DESCONOCIDO",
"machines.lastSeen": "Visto hace {time}",
@@ -140,6 +140,14 @@
"machine.detail.error.failed": "No se pudo cargar la máquina",
"machine.detail.error.network": "Error de red",
"machine.detail.back": "Volver",
"machine.detail.workOrders.upload": "Subir ordenes de trabajo",
"machine.detail.workOrders.uploading": "Subiendo...",
"machine.detail.workOrders.uploadParsing": "Leyendo archivo...",
"machine.detail.workOrders.uploadHint": "CSV o XLSX con Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity.",
"machine.detail.workOrders.uploadSuccess": "Se cargaron {count} ordenes de trabajo",
"machine.detail.workOrders.uploadError": "No se pudo cargar",
"machine.detail.workOrders.uploadInvalid": "No se encontraron ordenes de trabajo validas",
"machine.detail.workOrders.uploadUnauthorized": "No autorizado para cargar ordenes de trabajo",
"machine.detail.status.offline": "FUERA DE LÍNEA",
"machine.detail.status.unknown": "DESCONOCIDO",
"machine.detail.status.run": "EN MARCHA",
@@ -286,6 +294,7 @@
"settings.thresholds.performance": "Umbral de Performance",
"settings.thresholds.qualitySpike": "Delta de pico de calidad",
"settings.thresholds.stoppage": "Multiplicador de paro",
"settings.thresholds.macroStoppage": "Multiplicador de macroparo",
"settings.alerts": "Alertas",
"settings.alertsSubtitle": "Elige qué alertas notificar.",
"settings.alerts.oeeDrop": "Alertas por caída de OEE",

124
lib/mqtt.ts Normal file
View File

@@ -0,0 +1,124 @@
import "server-only";
import mqtt, { MqttClient } from "mqtt";
type SettingsUpdate = {
orgId: string;
version: number;
source?: string;
updatedAt?: string;
machineId?: string;
overridesUpdatedAt?: string;
};
type WorkOrdersUpdate = {
orgId: string;
machineId: string;
count?: number;
source?: string;
updatedAt?: string;
};
const MQTT_URL = process.env.MQTT_BROKER_URL || "";
const MQTT_USERNAME = process.env.MQTT_USERNAME;
const MQTT_PASSWORD = process.env.MQTT_PASSWORD;
const MQTT_CLIENT_ID = process.env.MQTT_CLIENT_ID;
const MQTT_TOPIC_PREFIX = (process.env.MQTT_TOPIC_PREFIX || "mis").replace(/\/+$/, "");
const MQTT_QOS_RAW = Number(process.env.MQTT_QOS ?? "2");
const MQTT_QOS = MQTT_QOS_RAW === 0 || MQTT_QOS_RAW === 1 || MQTT_QOS_RAW === 2 ? MQTT_QOS_RAW : 2;
let client: MqttClient | null = null;
let connecting: Promise<MqttClient> | null = null;
function buildSettingsTopic(orgId: string, machineId?: string) {
const base = `${MQTT_TOPIC_PREFIX}/org/${orgId}`;
if (machineId) return `${base}/machines/${machineId}/settings/updated`;
return `${base}/settings/updated`;
}
function buildWorkOrdersTopic(orgId: string, machineId: string) {
const base = `${MQTT_TOPIC_PREFIX}/org/${orgId}`;
return `${base}/machines/${machineId}/work_orders/updated`;
}
async function getClient() {
if (!MQTT_URL) return null;
if (client?.connected) return client;
if (connecting) return connecting;
connecting = new Promise((resolve, reject) => {
const next = mqtt.connect(MQTT_URL, {
clientId: MQTT_CLIENT_ID,
username: MQTT_USERNAME,
password: MQTT_PASSWORD,
clean: true,
reconnectPeriod: 5000,
});
next.once("connect", () => {
client = next;
connecting = null;
resolve(next);
});
next.once("error", (err) => {
next.end(true);
client = null;
connecting = null;
reject(err);
});
next.once("close", () => {
client = null;
});
});
return connecting;
}
export async function publishSettingsUpdate(update: SettingsUpdate) {
if (!MQTT_URL) return { ok: false, reason: "MQTT_NOT_CONFIGURED" as const };
const mqttClient = await getClient();
if (!mqttClient) return { ok: false, reason: "MQTT_NOT_CONFIGURED" as const };
const topic = buildSettingsTopic(update.orgId, update.machineId);
const payload = JSON.stringify({
type: update.machineId ? "machine_settings_updated" : "org_settings_updated",
orgId: update.orgId,
machineId: update.machineId,
version: update.version,
source: update.source || "control_tower",
updatedAt: update.updatedAt,
overridesUpdatedAt: update.overridesUpdatedAt,
});
return new Promise<{ ok: true }>((resolve, reject) => {
mqttClient.publish(topic, payload, { qos: MQTT_QOS }, (err) => {
if (err) return reject(err);
resolve({ ok: true });
});
});
}
export async function publishWorkOrdersUpdate(update: WorkOrdersUpdate) {
if (!MQTT_URL) return { ok: false, reason: "MQTT_NOT_CONFIGURED" as const };
const mqttClient = await getClient();
if (!mqttClient) return { ok: false, reason: "MQTT_NOT_CONFIGURED" as const };
const topic = buildWorkOrdersTopic(update.orgId, update.machineId);
const payload = JSON.stringify({
type: "work_orders_updated",
orgId: update.orgId,
machineId: update.machineId,
count: update.count ?? null,
source: update.source || "control_tower",
updatedAt: update.updatedAt,
});
return new Promise<{ ok: true }>((resolve, reject) => {
mqttClient.publish(topic, payload, { qos: MQTT_QOS }, (err) => {
if (err) return reject(err);
resolve({ ok: true });
});
});
}

View File

@@ -54,6 +54,7 @@ export function buildSettingsPayload(settings: any, shifts: any[]) {
},
thresholds: {
stoppageMultiplier: settings.stoppageMultiplier,
macroStoppageMultiplier: settings.macroStoppageMultiplier,
oeeAlertThresholdPct: settings.oeeAlertThresholdPct,
performanceThresholdPct: settings.performanceThresholdPct,
qualitySpikeDeltaPct: settings.qualitySpikeDeltaPct,
@@ -159,6 +160,14 @@ export function validateThresholds(thresholds: any) {
}
}
const macroStoppage = thresholds.macroStoppageMultiplier;
if (macroStoppage != null) {
const v = Number(macroStoppage);
if (!Number.isFinite(v) || v < 1.1 || v > 20.0) {
return { ok: false, error: "macroStoppageMultiplier must be 1.1-20.0" };
}
}
const oee = thresholds.oeeAlertThresholdPct;
if (oee != null) {
const v = Number(oee);

576
package-lock.json generated
View File

@@ -12,11 +12,13 @@
"bcrypt": "^6.0.0",
"i18n": "^0.15.3",
"lucide-react": "^0.561.0",
"mqtt": "^5.10.0",
"next": "16.0.10",
"nodemailer": "^7.0.12",
"react": "19.2.1",
"react-dom": "19.2.1",
"recharts": "^3.6.0",
"xlsx": "^0.18.5",
"zod": "^4.2.1"
},
"devDependencies": {
@@ -984,6 +986,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -3175,7 +3186,6 @@
"version": "20.19.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -3212,12 +3222,30 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/readable-stream": {
"version": "4.0.23",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
"integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz",
@@ -3756,6 +3784,18 @@
"win32"
]
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -3779,6 +3819,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -4049,6 +4098,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.9",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.9.tgz",
@@ -4073,6 +4142,18 @@
"node": ">= 18"
}
},
"node_modules/bl": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.0",
"buffer": "^6.0.3",
"inherits": "^2.0.4",
"readable-stream": "^4.2.0"
}
},
"node_modules/bowser": {
"version": "2.13.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz",
@@ -4104,6 +4185,18 @@
"node": ">=8"
}
},
"node_modules/broker-factory": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.11.tgz",
"integrity": "sha512-ex4RuEI0AJOdaIcXe1lu9EqRAVkoYvdcvwLvNcE5UZQzYNqzPY+z0frnlxT4+cUwNVpE//9MwGx4lKiLH+pEcw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"fast-unique-numbers": "^9.0.24",
"tslib": "^2.8.1",
"worker-factory": "^7.0.46"
}
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -4138,6 +4231,36 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@@ -4247,6 +4370,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -4305,6 +4441,15 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4325,6 +4470,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/commist": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4332,6 +4483,35 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/confbox": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
@@ -4356,6 +4536,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5429,12 +5621,30 @@
"node": ">=0.10.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
@@ -5525,6 +5735,19 @@
"node": ">=10.0"
}
},
"node_modules/fast-unique-numbers": {
"version": "9.0.24",
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.24.tgz",
"integrity": "sha512-Dv0BYn4waOWse94j16rsZ5w/0zoaCa74O3q6IZjMqaXbtT92Q+Sb6pPk+phGzD8Xh+nueQmSRI3tSCaHKidzKw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=18.2.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
@@ -5634,6 +5857,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -5940,6 +6172,12 @@
"node": ">= 0.4"
}
},
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -5977,6 +6215,26 @@
"url": "https://github.com/sponsors/mashpie"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -6024,6 +6282,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -6048,6 +6312,15 @@
"node": ">=12"
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -6505,6 +6778,16 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-sdsl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
"integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7024,7 +7307,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -7036,6 +7318,55 @@
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
"license": "BSD-3-Clause"
},
"node_modules/mqtt": {
"version": "5.14.1",
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.14.1.tgz",
"integrity": "sha512-NxkPxE70Uq3Ph7goefQa7ggSsVzHrayCD0OyxlJgITN/EbzlZN+JEPmaAZdxP1LsIT5FamDyILoQTF72W7Nnbw==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.21",
"@types/ws": "^8.18.1",
"commist": "^3.2.0",
"concat-stream": "^2.0.0",
"debug": "^4.4.1",
"help-me": "^5.0.0",
"lru-cache": "^10.4.3",
"minimist": "^1.2.8",
"mqtt-packet": "^9.0.2",
"number-allocator": "^1.0.14",
"readable-stream": "^4.7.0",
"rfdc": "^1.4.1",
"socks": "^2.8.6",
"split2": "^4.2.0",
"worker-timers": "^8.0.23",
"ws": "^8.18.3"
},
"bin": {
"mqtt": "build/bin/mqtt.js",
"mqtt_pub": "build/bin/pub.js",
"mqtt_sub": "build/bin/sub.js"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/mqtt-packet": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
"license": "MIT",
"dependencies": {
"bl": "^6.0.8",
"debug": "^4.3.4",
"process-nextick-args": "^2.0.1"
}
},
"node_modules/mqtt/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -7215,6 +7546,16 @@
"node": ">=6.0.0"
}
},
"node_modules/number-allocator": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
"integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.1",
"js-sdsl": "4.3.0"
}
},
"node_modules/nypm": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
@@ -7593,6 +7934,21 @@
}
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -7714,6 +8070,22 @@
}
}
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -7875,6 +8247,12 @@
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -7919,6 +8297,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-identifier": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz",
@@ -8182,6 +8580,30 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -8191,6 +8613,27 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -8212,6 +8655,15 @@
"node": ">= 0.4"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -8644,6 +9096,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -8705,7 +9163,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -8793,6 +9250,12 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
@@ -8920,6 +9383,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -8930,6 +9411,95 @@
"node": ">=0.10.0"
}
},
"node_modules/worker-factory": {
"version": "7.0.46",
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.46.tgz",
"integrity": "sha512-Sr1hq2FMgNa04UVhYQacsw+i58BtMimzDb4+CqYphZ97OfefRpURu0UZ+JxMr/H36VVJBfuVkxTK7MytsanC3w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"fast-unique-numbers": "^9.0.24",
"tslib": "^2.8.1"
}
},
"node_modules/worker-timers": {
"version": "8.0.27",
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.27.tgz",
"integrity": "sha512-+7ptDduAWj6Wd09Ga0weRFRx/MUwLhExazn+zu3IrwF0N2U2FPqFRR5W3Qz4scnI3cOILzdIEEytIJ2vbeD9Gw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"tslib": "^2.8.1",
"worker-timers-broker": "^8.0.13",
"worker-timers-worker": "^9.0.11"
}
},
"node_modules/worker-timers-broker": {
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.13.tgz",
"integrity": "sha512-PZnHHmqOY5oMKQPyfJhqPI9cb3QFmwD3lCIc/Zip6sShpfG2rvvCVDl0xeabGIspiEpP5exNNIlTUHjgP5VAcg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"broker-factory": "^3.1.11",
"fast-unique-numbers": "^9.0.24",
"tslib": "^2.8.1",
"worker-timers-worker": "^9.0.11"
}
},
"node_modules/worker-timers-worker": {
"version": "9.0.11",
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.11.tgz",
"integrity": "sha512-pArb5xtgHWImYpXhjg1OFv7JFG0ubmccb73TFoXHXjG830fFj+16N57q9YeBnZX52dn+itRrMoJZ9HaZBVzDaA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"tslib": "^2.8.1",
"worker-factory": "^7.0.46"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -13,11 +13,13 @@
"bcrypt": "^6.0.0",
"i18n": "^0.15.3",
"lucide-react": "^0.561.0",
"mqtt": "^5.10.0",
"next": "16.0.10",
"nodemailer": "^7.0.12",
"react": "19.2.1",
"react-dom": "19.2.1",
"recharts": "^3.6.0",
"xlsx": "^0.20.2",
"zod": "^4.2.1"
},
"devDependencies": {

View File

@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "machine_work_orders" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"machineId" TEXT NOT NULL,
"workOrderId" TEXT NOT NULL,
"sku" TEXT,
"targetQty" INTEGER,
"cycleTime" DOUBLE PRECISION,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "machine_work_orders_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "machine_work_orders_machineId_workOrderId_key" ON "machine_work_orders"("machineId", "workOrderId");
-- CreateIndex
CREATE INDEX "machine_work_orders_orgId_machineId_idx" ON "machine_work_orders"("orgId", "machineId");
-- CreateIndex
CREATE INDEX "machine_work_orders_orgId_workOrderId_idx" ON "machine_work_orders"("orgId", "workOrderId");
-- AddForeignKey
ALTER TABLE "machine_work_orders" ADD CONSTRAINT "machine_work_orders_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "machine_work_orders" ADD CONSTRAINT "machine_work_orders_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,90 @@
-- CreateTable
CREATE TABLE "org_settings" (
"org_id" TEXT NOT NULL,
"timezone" TEXT NOT NULL DEFAULT 'UTC',
"shift_change_comp_min" INTEGER NOT NULL DEFAULT 10,
"lunch_break_min" INTEGER NOT NULL DEFAULT 30,
"stoppage_multiplier" DOUBLE PRECISION NOT NULL DEFAULT 1.5,
"oee_alert_threshold_pct" DOUBLE PRECISION NOT NULL DEFAULT 90,
"macro_stoppage_multiplier" DOUBLE PRECISION NOT NULL DEFAULT 5,
"performance_threshold_pct" DOUBLE PRECISION NOT NULL DEFAULT 85,
"quality_spike_delta_pct" DOUBLE PRECISION NOT NULL DEFAULT 5,
"alerts_json" JSONB,
"defaults_json" JSONB,
"version" INTEGER NOT NULL DEFAULT 1,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" TEXT,
CONSTRAINT "org_settings_pkey" PRIMARY KEY ("org_id")
);
-- CreateTable
CREATE TABLE "org_shifts" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"start_time" TEXT NOT NULL,
"end_time" TEXT NOT NULL,
"sort_order" INTEGER NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "org_shifts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "machine_settings" (
"machine_id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"overrides_json" JSONB,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" TEXT,
CONSTRAINT "machine_settings_pkey" PRIMARY KEY ("machine_id")
);
-- CreateTable
CREATE TABLE "settings_audit" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"machine_id" TEXT,
"actor_id" TEXT,
"source" TEXT NOT NULL,
"payload_json" JSONB NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "settings_audit_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "org_shifts_org_id_idx" ON "org_shifts"("org_id");
-- CreateIndex
CREATE INDEX "org_shifts_org_id_sort_order_idx" ON "org_shifts"("org_id", "sort_order");
-- CreateIndex
CREATE INDEX "machine_settings_org_id_idx" ON "machine_settings"("org_id");
-- CreateIndex
CREATE INDEX "settings_audit_org_id_created_at_idx" ON "settings_audit"("org_id", "created_at");
-- CreateIndex
CREATE INDEX "settings_audit_machine_id_created_at_idx" ON "settings_audit"("machine_id", "created_at");
-- AddForeignKey
ALTER TABLE "org_settings" ADD CONSTRAINT "org_settings_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "org_shifts" ADD CONSTRAINT "org_shifts_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "machine_settings" ADD CONSTRAINT "machine_settings_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "machine_settings" ADD CONSTRAINT "machine_settings_machine_id_fkey" FOREIGN KEY ("machine_id") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "settings_audit" ADD CONSTRAINT "settings_audit_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "settings_audit" ADD CONSTRAINT "settings_audit_machine_id_fkey" FOREIGN KEY ("machine_id") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -19,6 +19,7 @@ model Org {
heartbeats MachineHeartbeat[]
kpiSnapshots MachineKpiSnapshot[]
events MachineEvent[]
workOrders MachineWorkOrder[]
settings OrgSettings?
shifts OrgShift[]
machineSettings MachineSettings[]
@@ -119,6 +120,7 @@ model Machine {
kpiSnapshots MachineKpiSnapshot[]
events MachineEvent[]
cycles MachineCycle[]
workOrders MachineWorkOrder[]
settings MachineSettings?
settingsAudits SettingsAudit[]
@@ -239,6 +241,27 @@ model MachineCycle {
@@index([orgId, machineId, cycleCount])
}
model MachineWorkOrder {
id String @id @default(uuid())
orgId String
machineId String
workOrderId String
sku String?
targetQty Int?
cycleTime Float?
status String @default("PENDING")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@unique([machineId, workOrderId])
@@index([orgId, machineId])
@@index([orgId, workOrderId])
@@map("machine_work_orders")
}
model IngestLog {
id String @id @default(uuid())
orgId String?
@@ -269,6 +292,7 @@ model OrgSettings {
lunchBreakMin Int @default(30) @map("lunch_break_min")
stoppageMultiplier Float @default(1.5) @map("stoppage_multiplier")
oeeAlertThresholdPct Float @default(90) @map("oee_alert_threshold_pct")
macroStoppageMultiplier Float @default(5) @map("macro_stoppage_multiplier")
performanceThresholdPct Float @default(85) @map("performance_threshold_pct")
qualitySpikeDeltaPct Float @default(5) @map("quality_spike_delta_pct")
alertsJson Json? @map("alerts_json")

View File

@@ -53,6 +53,7 @@ async function main() {
lunchBreakMin: 30,
stoppageMultiplier: 1.5,
oeeAlertThresholdPct: 90,
macroStoppageMultiplier: 5,
performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5,
alertsJson: {