Macrostop and timeline segmentation
This commit is contained in:
@@ -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;
|
||||
} else {
|
||||
out.push(cur);
|
||||
|
||||
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 {
|
||||
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,13 +839,38 @@ export default function MachineDetailClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<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"
|
||||
>
|
||||
{t("machine.detail.back")}
|
||||
</Link>
|
||||
<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"
|
||||
>
|
||||
{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>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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",
|
||||
@@ -651,6 +657,20 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</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
|
||||
type="number"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
where: { id: machineId, orgId: session.orgId },
|
||||
select: { id: true },
|
||||
});
|
||||
const session = await requireSession();
|
||||
let orgId: string | null = null;
|
||||
let userId: string | null = null;
|
||||
let machine: { id: string; orgId: string } | null = null;
|
||||
|
||||
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||
if (session) {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
@@ -238,16 +289,20 @@ export async function PUT(
|
||||
if (patch?.thresholds) {
|
||||
patch = {
|
||||
...patch,
|
||||
thresholds: {
|
||||
...patch.thresholds,
|
||||
stoppageMultiplier:
|
||||
patch.thresholds.stoppageMultiplier !== undefined
|
||||
? Number(patch.thresholds.stoppageMultiplier)
|
||||
: patch.thresholds.stoppageMultiplier,
|
||||
oeeAlertThresholdPct:
|
||||
patch.thresholds.oeeAlertThresholdPct !== undefined
|
||||
? Number(patch.thresholds.oeeAlertThresholdPct)
|
||||
: patch.thresholds.oeeAlertThresholdPct,
|
||||
thresholds: {
|
||||
...patch.thresholds,
|
||||
stoppageMultiplier:
|
||||
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)
|
||||
: patch.thresholds.oeeAlertThresholdPct,
|
||||
performanceThresholdPct:
|
||||
patch.thresholds.performanceThresholdPct !== undefined
|
||||
? Number(patch.thresholds.performanceThresholdPct)
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
49
app/api/work-orders/machines/[machineId]/route.ts
Normal file
49
app/api/work-orders/machines/[machineId]/route.ts
Normal 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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
164
app/api/work-orders/route.ts
Normal file
164
app/api/work-orders/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user