Compare commits
4 Commits
b2214ec46f
...
sandbox-ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfc1673d89 | ||
|
|
0491237bad | ||
|
|
4299ef3478 | ||
|
|
864be8d932 |
43
MACHINE_STATE_PROGRESS.md
Normal file
43
MACHINE_STATE_PROGRESS.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Machine State Progress
|
||||
|
||||
## Final State Model (5 states + sub-reasons)
|
||||
|
||||
| State | Color | Trigger |
|
||||
|---|---|---|
|
||||
| OFFLINE | dark gray | Heartbeat dead >2 min |
|
||||
| STOPPED | red, pulse >5min | Active WO + no cycles (regardless of tracking) |
|
||||
| - reason `machine_fault` | | Tracking on, macrostop event active |
|
||||
| - reason `not_started` | | Tracking off, has WO |
|
||||
| DATA_LOSS | red + icon, pulse | Tracking off + cycles arriving (>5 cycles or >10 min) |
|
||||
| MOLD_CHANGE | blue | Active mold-change event |
|
||||
| - sub at >3h | yellow accent | (Round 2) |
|
||||
| - sub at >5h | red accent | (Round 2) |
|
||||
| IDLE | calm gray | No tracking, no WO, no cycles |
|
||||
| RUNNING | green | Tracking + WO + recent cycles |
|
||||
|
||||
## Round 1 — Foundation: classifier + IDLE + STOPPED collapse + DATA_LOSS
|
||||
- [x] Step 1: Add `"idle"` and `"data-loss"` to `RecapMachineStatus` union
|
||||
- [x] Step 2: Create `lib/recap/machineState.ts` shared classifier with all reasons
|
||||
- [x] Step 3: Refactor `statusFromMachine` in redesign.ts to call classifier
|
||||
- [x] Step 4: Plumb new fields (status reason, ongoing min) through types/responses
|
||||
- [x] Step 5: UI rendering: IDLE (calm gray) on /recap, /machines, detail
|
||||
- [x] Step 6: UI rendering: DATA_LOSS (red + icon) on all surfaces
|
||||
- [x] Step 7: STOPPED reason text: show `not_started` vs `machine_fault` distinction
|
||||
- [x] Step 8: i18n keys (en + es-MX)
|
||||
- [x] Step 9: End-to-end verify each state transitions correctly
|
||||
|
||||
## Round 2 — Mold change duration escalation (CT-only)
|
||||
- [ ] MOLD_CHANGE >3h yellow accent
|
||||
- [ ] MOLD_CHANGE >5h red accent
|
||||
- [ ] i18n strings
|
||||
|
||||
## Notes / parked items
|
||||
- Prisma drift on (orgId,machineId,seq) unique indexes — pre-existing, not related to this work. Address as separate housekeeping task.
|
||||
- Node-RED incidentKey rotation behavior verified: 10 distinct keys per real stoppage = correct.
|
||||
|
||||
## Path A — dead state cleanup (post Round 1)
|
||||
- [x] Removed `not_started` and `data-loss` branches from classifier
|
||||
- [x] Removed `RecapStoppedReason` and `RecapDataLossReason` types
|
||||
- [x] Simplified `RecapStateContext` to empty struct (kept for future use)
|
||||
- [x] Updated UI rendering: 5 states only (offline/stopped/mold-change/idle/running)
|
||||
- [x] i18n: removed dead keys
|
||||
@@ -19,6 +19,12 @@ type MachineRow = {
|
||||
ip?: string | null;
|
||||
fwVersion?: string | null;
|
||||
};
|
||||
latestMacrostop?: null | {
|
||||
machineId: string;
|
||||
ts: string;
|
||||
status: "active" | "resolved" | "unknown";
|
||||
startedAtMs: number;
|
||||
};
|
||||
};
|
||||
const LIVE_REFRESH_MS = 5000;
|
||||
const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||
@@ -51,6 +57,21 @@ function badgeClass(status?: string, offline?: boolean) {
|
||||
return "bg-white/10 text-white";
|
||||
}
|
||||
|
||||
const MACROSTOP_FRESH_MS = 2 * 60 * 1000;
|
||||
|
||||
function isMacrostopActive(macrostop: MachineRow["latestMacrostop"]) {
|
||||
if (!macrostop) return false;
|
||||
if (macrostop.status !== "active") return false;
|
||||
// Fresh if last refresh was within 2 min — Node-RED refreshes every 10s,
|
||||
// so anything older means the stoppage already ended without resolution event.
|
||||
return Date.now() - new Date(macrostop.ts).getTime() <= MACROSTOP_FRESH_MS;
|
||||
}
|
||||
|
||||
function ongoingMacrostopMin(macrostop: MachineRow["latestMacrostop"]) {
|
||||
if (!macrostop) return 0;
|
||||
return Math.max(0, Math.floor((Date.now() - macrostop.startedAtMs) / 60000));
|
||||
}
|
||||
|
||||
export default function MachinesClient({ initialMachines = [] }: { initialMachines?: MachineRow[] }) {
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
@@ -292,9 +313,28 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
|
||||
const hbTs = hb?.tsServer ?? hb?.ts;
|
||||
const offline = isOffline(hbTs);
|
||||
const normalizedStatus = normalizeStatus(hb?.status);
|
||||
const statusLabel = offline ? t("machines.status.offline") : (normalizedStatus || t("machines.status.unknown"));
|
||||
const lastSeen = secondsAgo(hbTs, locale, t("common.never"));
|
||||
|
||||
const macrostopActive = isMacrostopActive(m.latestMacrostop);
|
||||
const stoppedMin = macrostopActive ? ongoingMacrostopMin(m.latestMacrostop) : 0;
|
||||
|
||||
// Production-state badge: STOPPED if active macrostop, else heartbeat-based.
|
||||
const productionBadgeLabel = offline
|
||||
? t("machines.status.offline")
|
||||
: macrostopActive
|
||||
? t("machines.status.stopped")
|
||||
: (normalizedStatus || t("machines.status.unknown"));
|
||||
|
||||
const productionBadgeClass = offline
|
||||
? "bg-white/10 text-zinc-300"
|
||||
: macrostopActive
|
||||
? "bg-red-500/20 text-red-200 ring-2 ring-red-500/50 animate-pulse"
|
||||
: badgeClass(normalizedStatus, offline);
|
||||
|
||||
const cardClass = macrostopActive
|
||||
? "cursor-pointer rounded-2xl border border-red-500/60 bg-red-500/10 p-5 ring-2 ring-red-500/40 animate-pulse hover:bg-red-500/15"
|
||||
: "cursor-pointer rounded-2xl border border-white/10 bg-white/5 p-5 hover:bg-white/10";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
@@ -302,7 +342,7 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
|
||||
tabIndex={0}
|
||||
onClick={() => router.push(`/machines/${m.id}`)}
|
||||
onKeyDown={(event) => handleCardKeyDown(event, m.id)}
|
||||
className="cursor-pointer rounded-2xl border border-white/10 bg-white/5 p-5 hover:bg-white/10"
|
||||
className={cardClass}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
@@ -310,15 +350,17 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{m.code ? m.code : t("common.na")} - {t("machines.lastSeen", { time: lastSeen })}
|
||||
</div>
|
||||
{macrostopActive ? (
|
||||
<div className="mt-1 text-xs font-semibold text-red-200">
|
||||
{t("machines.stoppedFor", { min: stoppedMin })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs ${badgeClass(
|
||||
normalizedStatus,
|
||||
offline
|
||||
)}`}
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs ${productionBadgeClass}`}
|
||||
>
|
||||
{statusLabel}
|
||||
{productionBadgeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, type KeyboardEvent } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||
|
||||
type MachineRow = {
|
||||
id: string;
|
||||
@@ -20,6 +21,7 @@ type MachineRow = {
|
||||
};
|
||||
};
|
||||
const LIVE_REFRESH_MS = 5000;
|
||||
const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||
|
||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
if (!ts) return fallback;
|
||||
@@ -31,7 +33,7 @@ function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
|
||||
function isOffline(ts?: string) {
|
||||
if (!ts) return true;
|
||||
return Date.now() - new Date(ts).getTime() > 30000; // 30s threshold
|
||||
return Date.now() - new Date(ts).getTime() > OFFLINE_MS;
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string) {
|
||||
|
||||
@@ -13,6 +13,7 @@ function statusLabel(status: RecapMachineStatus, t: (key: string) => string) {
|
||||
if (status === "running") return t("recap.status.running");
|
||||
if (status === "mold-change") return t("recap.status.moldChange");
|
||||
if (status === "stopped") return t("recap.status.stopped");
|
||||
if (status === "idle") return t("recap.status.idle");
|
||||
return t("recap.status.offline");
|
||||
}
|
||||
|
||||
@@ -110,7 +111,7 @@ export default function RecapGridClient({ initialData }: Props) {
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
>
|
||||
<option value="all">{t("recap.filter.allStatuses")}</option>
|
||||
{(["running", "mold-change", "stopped", "offline"] as const).map((status) => (
|
||||
{(["running", "mold-change", "stopped", "idle", "offline"] as const).map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{statusLabel(status, t)}
|
||||
</option>
|
||||
|
||||
153
app/(app)/recap/RecapGridClient.tsx.bak.step5
Normal file
153
app/(app)/recap/RecapGridClient.tsx.bak.step5
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import type { RecapMachineStatus, RecapSummaryResponse } from "@/lib/recap/types";
|
||||
import RecapMachineCard from "@/components/recap/RecapMachineCard";
|
||||
|
||||
type Props = {
|
||||
initialData: RecapSummaryResponse;
|
||||
};
|
||||
|
||||
function statusLabel(status: RecapMachineStatus, t: (key: string) => string) {
|
||||
if (status === "running") return t("recap.status.running");
|
||||
if (status === "mold-change") return t("recap.status.moldChange");
|
||||
if (status === "stopped") return t("recap.status.stopped");
|
||||
return t("recap.status.offline");
|
||||
}
|
||||
|
||||
export default function RecapGridClient({ initialData }: Props) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const [data, setData] = useState<RecapSummaryResponse>(initialData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [locationFilter, setLocationFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | RecapMachineStatus>("all");
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/recap/summary?hours=${data.range.hours}`, { cache: "no-store" });
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!alive || !json || !res.ok) return;
|
||||
setData(json as RecapSummaryResponse);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const interval = window.setInterval(onFocus, 60000);
|
||||
window.addEventListener("focus", onFocus);
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}, [data.range.hours]);
|
||||
|
||||
const locationOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const machine of data.machines) {
|
||||
if (machine.location) set.add(machine.location);
|
||||
}
|
||||
return [...set].sort((a, b) => a.localeCompare(b));
|
||||
}, [data.machines]);
|
||||
|
||||
const filteredMachines = useMemo(() => {
|
||||
return data.machines.filter((machine) => {
|
||||
if (locationFilter !== "all" && machine.location !== locationFilter) return false;
|
||||
if (statusFilter !== "all" && machine.status !== statusFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}, [data.machines, locationFilter, statusFilter]);
|
||||
|
||||
const generatedAtMs = new Date(data.generatedAt).getTime();
|
||||
const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">{t("recap.grid.title")}</h1>
|
||||
<p className="text-sm text-zinc-400">{t("recap.grid.subtitle")}</p>
|
||||
{freshAgeSec != null ? (
|
||||
<p className="mt-1 text-xs text-zinc-500">{t("recap.grid.updatedAgo", { sec: freshAgeSec })}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<select
|
||||
value={locationFilter}
|
||||
onChange={(event) => setLocationFilter(event.target.value)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
>
|
||||
<option value="all">{t("recap.filter.allLocations")}</option>
|
||||
{locationOptions.map((location) => (
|
||||
<option key={location} value={location}>
|
||||
{location}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as "all" | RecapMachineStatus)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
>
|
||||
<option value="all">{t("recap.filter.allStatuses")}</option>
|
||||
{(["running", "mold-change", "stopped", "offline"] as const).map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{statusLabel(status, t)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && data.machines.length === 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, idx) => (
|
||||
<div key={idx} className="h-[220px] animate-pulse rounded-2xl border border-white/10 bg-white/5" />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && data.machines.length > 0 ? (
|
||||
<div className="mb-3 text-xs text-zinc-500">{t("common.loading")}</div>
|
||||
) : null}
|
||||
|
||||
{filteredMachines.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 p-4 text-sm text-zinc-400">
|
||||
{t("recap.grid.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredMachines.map((machine) => (
|
||||
<RecapMachineCard
|
||||
key={machine.machineId}
|
||||
machine={machine}
|
||||
rangeStart={data.range.start}
|
||||
rangeEnd={data.range.end}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
app/(app)/recap/[machineId]/RecapDetailClient.tsx.bak
Normal file
240
app/(app)/recap/[machineId]/RecapDetailClient.tsx.bak
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import type { RecapDetailResponse, RecapRangeMode, RecapTimelineResponse } from "@/lib/recap/types";
|
||||
import RecapBanners from "@/components/recap/RecapBanners";
|
||||
import RecapKpiRow from "@/components/recap/RecapKpiRow";
|
||||
import RecapProductionBySku from "@/components/recap/RecapProductionBySku";
|
||||
import RecapDowntimeTop from "@/components/recap/RecapDowntimeTop";
|
||||
import RecapWorkOrders from "@/components/recap/RecapWorkOrders";
|
||||
import RecapMachineStatus from "@/components/recap/RecapMachineStatus";
|
||||
import RecapFullTimeline from "@/components/recap/RecapFullTimeline";
|
||||
|
||||
type Props = {
|
||||
machineId: string;
|
||||
initialData: RecapDetailResponse;
|
||||
};
|
||||
|
||||
function toInputDate(value: string) {
|
||||
const d = new Date(value);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function normalizeInputDate(value: string) {
|
||||
const d = new Date(value);
|
||||
if (!Number.isFinite(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export default function RecapDetailClient({ machineId, initialData }: Props) {
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||
const [timelineLoading, setTimelineLoading] = useState(true);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start));
|
||||
const [customEnd, setCustomEnd] = useState(toInputDate(initialData.range.end));
|
||||
|
||||
const requestedRange =
|
||||
(searchParams.get("range") as RecapRangeMode | null) ?? initialData.range.requestedMode ?? initialData.range.mode;
|
||||
const selectedRange = requestedRange;
|
||||
const shiftAvailable = initialData.range.shiftAvailable ?? true;
|
||||
const shiftFallbackReason = initialData.range.fallbackReason;
|
||||
const shiftFallbackActive = selectedRange === "shift" && initialData.range.mode !== "shift";
|
||||
|
||||
function pushRange(nextRange: RecapRangeMode, start?: string, end?: string) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("range", nextRange);
|
||||
|
||||
if (nextRange === "custom" && start && end) {
|
||||
params.set("start", start);
|
||||
params.set("end", end);
|
||||
} else {
|
||||
params.delete("start");
|
||||
params.delete("end");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
});
|
||||
}
|
||||
|
||||
function applyCustomRange() {
|
||||
const start = normalizeInputDate(customStart);
|
||||
const end = normalizeInputDate(customEnd);
|
||||
if (!start || !end || end <= start) return;
|
||||
pushRange("custom", start, end);
|
||||
}
|
||||
|
||||
const machine = initialData.machine;
|
||||
const generatedAtMs = new Date(initialData.generatedAt).getTime();
|
||||
const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null;
|
||||
const timelineStart = timeline?.range.start ?? initialData.range.start;
|
||||
const timelineEnd = timeline?.range.end ?? initialData.range.end;
|
||||
const timelineSegments = timeline?.segments ?? [];
|
||||
const timelineHasData = timeline?.hasData ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setTimeline(null);
|
||||
setTimelineLoading(true);
|
||||
|
||||
async function loadTimeline() {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
start: initialData.range.start,
|
||||
end: initialData.range.end,
|
||||
});
|
||||
const res = await fetch(`/api/recap/${machineId}/timeline?${params.toString()}`, { cache: "no-store" });
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!alive || !res.ok || !json) return;
|
||||
setTimeline(json as RecapTimelineResponse);
|
||||
} catch {
|
||||
} finally {
|
||||
if (alive) setTimelineLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadTimeline();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [initialData.range.end, initialData.range.start, machineId]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<Link href="/recap" className="text-sm text-zinc-400 hover:text-zinc-200">
|
||||
{`← ${t("recap.detail.back")}`}
|
||||
</Link>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-white">{machine.name || machineId}</h1>
|
||||
<div className="text-sm text-zinc-400">{machine.location || t("common.na")}</div>
|
||||
{freshAgeSec != null ? (
|
||||
<div className="mt-1 text-xs text-zinc-500">{t("recap.grid.updatedAgo", { sec: freshAgeSec })}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
{(["24h", "shift", "yesterday", "custom"] as const).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
type="button"
|
||||
disabled={range === "shift" && !shiftAvailable}
|
||||
onClick={() => {
|
||||
if (range === "shift" && !shiftAvailable) return;
|
||||
if (range === "custom") {
|
||||
pushRange("custom", normalizeInputDate(customStart) ?? undefined, normalizeInputDate(customEnd) ?? undefined);
|
||||
return;
|
||||
}
|
||||
pushRange(range);
|
||||
}}
|
||||
className={`rounded-xl border px-3 py-2 ${
|
||||
selectedRange === range
|
||||
? "border-emerald-300/60 bg-emerald-500/20 text-emerald-100"
|
||||
: "border-white/10 bg-black/40 text-zinc-200"
|
||||
} ${range === "shift" && !shiftAvailable ? "cursor-not-allowed opacity-60" : ""}`}
|
||||
>
|
||||
{range === "24h" ? t("recap.range.24h") : null}
|
||||
{range === "shift" ? t("recap.range.shiftCurrent") : null}
|
||||
{range === "yesterday" ? t("recap.range.yesterday") : null}
|
||||
{range === "custom" ? t("recap.range.custom") : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!shiftAvailable ? (
|
||||
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
|
||||
{t("recap.range.shiftUnavailable")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{shiftFallbackActive ? (
|
||||
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
|
||||
{shiftFallbackReason === "shift-inactive" ? t("recap.range.shiftFallbackInactive") : t("recap.range.shiftFallbackUnavailable")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedRange === "custom" ? (
|
||||
<div className="mb-4 flex flex-wrap gap-2 text-sm">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customStart}
|
||||
onChange={(event) => setCustomStart(event.target.value)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
/>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customEnd}
|
||||
onChange={(event) => setCustomEnd(event.target.value)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyCustomRange}
|
||||
className="rounded-xl border border-emerald-300/50 bg-emerald-500/20 px-3 py-2 text-emerald-100"
|
||||
>
|
||||
{t("recap.range.apply")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPending ? <div className="mb-3 text-xs text-zinc-500">{t("common.loading")}</div> : null}
|
||||
|
||||
<div className="mb-4">
|
||||
<RecapBanners
|
||||
moldChangeStartMs={machine.moldChange?.active ? machine.moldChange.startMs : null}
|
||||
offlineForMin={machine.offlineForMin}
|
||||
ongoingStopMin={machine.ongoingStopMin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RecapKpiRow
|
||||
oeeAvg={machine.oee}
|
||||
goodParts={machine.goodParts}
|
||||
totalStops={Math.round(machine.stopMinutes)}
|
||||
scrapParts={machine.scrap}
|
||||
rangeMode={initialData.range.mode}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<RecapFullTimeline
|
||||
rangeStart={timelineStart}
|
||||
rangeEnd={timelineEnd}
|
||||
segments={timelineSegments}
|
||||
hasData={timelineHasData}
|
||||
loading={timelineLoading}
|
||||
locale={locale}
|
||||
rangeMode={initialData.range.mode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<RecapProductionBySku rows={machine.productionBySku} />
|
||||
<RecapDowntimeTop rows={machine.downtimeTop} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<RecapWorkOrders workOrders={machine.workOrders} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<RecapMachineStatus heartbeat={machine.heartbeat} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertsConfig } from "@/components/settings/AlertsConfig";
|
||||
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
|
||||
import { ReasonCatalogConfig } from "@/components/settings/ReasonCatalogConfig";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { SHIFT_OVERRIDE_DAYS, type ShiftOverrideDay } from "@/lib/settings";
|
||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||
@@ -122,6 +123,7 @@ const SETTINGS_TABS = [
|
||||
{ id: "thresholds", labelKey: "settings.tabs.thresholds" },
|
||||
{ id: "alerts", labelKey: "settings.tabs.alerts" },
|
||||
{ id: "financial", labelKey: "settings.tabs.financial" },
|
||||
{ id: "reasonCatalog", labelKey: "settings.tabs.reasonCatalog" },
|
||||
{ id: "team", labelKey: "settings.tabs.team" },
|
||||
] as const;
|
||||
|
||||
@@ -239,7 +241,6 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
|
||||
const thresholds = asRecord(record.thresholds) ?? {};
|
||||
const alerts = asRecord(record.alerts) ?? {};
|
||||
const defaults = asRecord(record.defaults) ?? {};
|
||||
|
||||
return {
|
||||
orgId: String(record.orgId ?? ""),
|
||||
version: Number(record.version ?? 0),
|
||||
@@ -1276,6 +1277,18 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "reasonCatalog" && (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-sm font-semibold text-white">{t("settings.reasonCatalog.title")}</div>
|
||||
<p className="mt-1 text-xs text-zinc-400">{t("settings.reasonCatalog.subtitle")}</p>
|
||||
<div className="mt-4">
|
||||
<ReasonCatalogConfig disabled={saving} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "team" && (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
|
||||
@@ -5,13 +5,14 @@ import { z } from "zod";
|
||||
import { evaluateAlertsForEvent } from "@/lib/alerts/engine";
|
||||
import { toJsonValue } from "@/lib/prismaJson";
|
||||
import {
|
||||
detailEffectiveReasonCode,
|
||||
findCatalogReason,
|
||||
loadFallbackReasonCatalog,
|
||||
normalizeReasonCatalog,
|
||||
findCatalogReasonByReasonCode,
|
||||
toReasonCode,
|
||||
type ReasonCatalog,
|
||||
type ReasonCatalogKind,
|
||||
} from "@/lib/reasonCatalog";
|
||||
import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb";
|
||||
|
||||
const normalizeType = (t: unknown) =>
|
||||
String(t ?? "")
|
||||
@@ -169,7 +170,7 @@ function findCatalogReasonFlexible(
|
||||
categoryLabel: category.label,
|
||||
detailId: detail.id,
|
||||
detailLabel: detail.label,
|
||||
reasonCode: toReasonCode(category.id, detail.id),
|
||||
reasonCode: detailEffectiveReasonCode(category, detail),
|
||||
reasonLabel: `${category.label} > ${detail.label}`,
|
||||
};
|
||||
}
|
||||
@@ -177,12 +178,6 @@ function findCatalogReasonFlexible(
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCatalogFromDefaults(defaultsJson: unknown) {
|
||||
const defaults = asRecord(defaultsJson);
|
||||
if (!defaults) return null;
|
||||
return normalizeReasonCatalog(defaults.reasonCatalog ?? defaults.reasonCatalogData);
|
||||
}
|
||||
|
||||
function resolveReason(
|
||||
raw: Record<string, unknown>,
|
||||
kind: ReasonCatalogKind,
|
||||
@@ -193,7 +188,13 @@ function resolveReason(
|
||||
const reasonTextPath = parseReasonTextPath(raw.reasonText);
|
||||
const categoryIdRaw = clampText(raw.categoryId ?? reasonPath.category ?? reasonTextPath.category, 64);
|
||||
const detailIdRaw = clampText(raw.detailId ?? reasonPath.detail ?? reasonTextPath.detail, 64);
|
||||
const fromCatalog = findCatalogReasonFlexible(catalog, kind, categoryIdRaw, detailIdRaw);
|
||||
const fromCatalogFlexible = findCatalogReasonFlexible(catalog, kind, categoryIdRaw, detailIdRaw);
|
||||
const rawReasonCodeEarly = clampText(raw.reasonCode, 64);
|
||||
const fromCatalogByCode =
|
||||
!fromCatalogFlexible && rawReasonCodeEarly
|
||||
? findCatalogReasonByReasonCode(catalog, kind, rawReasonCodeEarly)
|
||||
: null;
|
||||
const fromCatalog = fromCatalogFlexible ?? fromCatalogByCode;
|
||||
|
||||
const categoryLabelRaw = clampText(raw.categoryLabel ?? reasonPath.category ?? reasonTextPath.category, 120);
|
||||
const detailLabelRaw = clampText(raw.detailLabel ?? reasonPath.detail ?? reasonTextPath.detail, 120);
|
||||
@@ -282,11 +283,13 @@ export async function POST(req: Request) {
|
||||
|
||||
const orgSettings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: machine.orgId },
|
||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
|
||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true, version: true },
|
||||
});
|
||||
const fallbackCatalog = await loadFallbackReasonCatalog();
|
||||
const settingsCatalog = getCatalogFromDefaults(orgSettings?.defaultsJson);
|
||||
const reasonCatalog = settingsCatalog ?? fallbackCatalog;
|
||||
const reasonCatalog = await effectiveReasonCatalogForOrg(
|
||||
machine.orgId,
|
||||
orgSettings?.defaultsJson ?? null,
|
||||
orgSettings?.version ?? 1
|
||||
);
|
||||
|
||||
const defaultMicroMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
|
||||
const defaultMacroMultiplier = Math.max(
|
||||
|
||||
@@ -258,14 +258,65 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach
|
||||
})
|
||||
: allowed;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const deduped = filtered.filter((event) => {
|
||||
const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
// Build a lookup of raw event metadata (incidentKey, status, is_auto_ack)
|
||||
// by event id, so we can collapse the normalized events down to one
|
||||
// "active" + one "resolved" per incident.
|
||||
const rawMetaById = new Map<string, { incidentKey: string | null; status: string | null; isAutoAck: boolean }>();
|
||||
for (const row of rawEvents) {
|
||||
let parsed: unknown = row.data;
|
||||
if (typeof parsed === "string") {
|
||||
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
|
||||
}
|
||||
const data: Record<string, unknown> =
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const isAutoAck =
|
||||
data.is_auto_ack === true ||
|
||||
data.isAutoAck === true ||
|
||||
data.is_auto_ack === "true" ||
|
||||
data.isAutoAck === "true";
|
||||
const incidentKey =
|
||||
typeof data.incidentKey === "string" ? data.incidentKey :
|
||||
typeof data.incident_key === "string" ? data.incident_key : null;
|
||||
const status = typeof data.status === "string" ? data.status.toLowerCase() : null;
|
||||
rawMetaById.set(row.id, { incidentKey, status, isAutoAck });
|
||||
}
|
||||
|
||||
// Drop pure auto-ack refresh pings.
|
||||
const filteredNoAutoAck = filtered.filter((event) => {
|
||||
const meta = rawMetaById.get(event.id);
|
||||
return !meta?.isAutoAck;
|
||||
});
|
||||
|
||||
// Group by incidentKey: keep at most one "active" (oldest = original happen)
|
||||
// and one "resolved" (newest = actual end) per incident. Events without
|
||||
// incidentKey pass through unchanged (mold-change, edge-case events).
|
||||
const byGroup = new Map<string, typeof filteredNoAutoAck[number]>();
|
||||
const passthrough: typeof filteredNoAutoAck = [];
|
||||
|
||||
for (const event of filteredNoAutoAck) {
|
||||
const meta = rawMetaById.get(event.id);
|
||||
const groupId = meta?.incidentKey;
|
||||
if (!groupId) {
|
||||
passthrough.push(event);
|
||||
continue;
|
||||
}
|
||||
const statusKey = meta.status === "resolved" ? "resolved" : "active";
|
||||
const key = `${groupId}:${statusKey}`;
|
||||
const existing = byGroup.get(key);
|
||||
if (!existing) {
|
||||
byGroup.set(key, event);
|
||||
continue;
|
||||
}
|
||||
const existingTs = existing.ts ? existing.ts.getTime() : 0;
|
||||
const eventTs = event.ts ? event.ts.getTime() : 0;
|
||||
const pickNewest = statusKey === "resolved";
|
||||
const shouldReplace = pickNewest ? eventTs > existingTs : eventTs < existingTs;
|
||||
if (shouldReplace) byGroup.set(key, event);
|
||||
}
|
||||
|
||||
const deduped = [...passthrough, ...byGroup.values()];
|
||||
deduped.sort((a, b) => {
|
||||
const at = a.ts ? a.ts.getTime() : 0;
|
||||
const bt = b.ts ? b.ts.getTime() : 0;
|
||||
|
||||
492
app/api/machines/[machineId]/route.ts.bak
Normal file
492
app/api/machines/[machineId]/route.ts.bak
Normal file
@@ -0,0 +1,492 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { normalizeEvent } from "@/lib/events/normalizeEvent";
|
||||
import { invalidateMachineAuth } from "@/lib/machineAuthCache";
|
||||
|
||||
const machineIdSchema = z.string().uuid();
|
||||
|
||||
const ALLOWED_EVENT_TYPES = new Set([
|
||||
"slow-cycle",
|
||||
"microstop",
|
||||
"macrostop",
|
||||
"offline",
|
||||
"error",
|
||||
"oee-drop",
|
||||
"quality-spike",
|
||||
"performance-degradation",
|
||||
"predictive-oee-decline",
|
||||
"alert-delivery-failed",
|
||||
]);
|
||||
|
||||
function canManageMachines(role?: string | null) {
|
||||
return role === "OWNER" || role === "ADMIN";
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseNumber(value: string | null, fallback: number) {
|
||||
if (value == null || value === "") return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
type MachineFkReference = {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
deleteRule: string;
|
||||
};
|
||||
|
||||
function quoteIdent(identifier: string) {
|
||||
return `"${identifier.replace(/"/g, "\"\"")}"`;
|
||||
}
|
||||
|
||||
async function cleanupMachineReferences(machineId: string) {
|
||||
const refs = await prisma.$queryRaw<MachineFkReference[]>`
|
||||
SELECT DISTINCT
|
||||
tc.table_name AS "tableName",
|
||||
kcu.column_name AS "columnName",
|
||||
rc.delete_rule AS "deleteRule"
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.referential_constraints rc
|
||||
ON tc.constraint_name = rc.constraint_name
|
||||
AND tc.table_schema = rc.constraint_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_schema = 'public'
|
||||
AND rc.unique_constraint_schema = 'public'
|
||||
AND rc.unique_constraint_name IN (
|
||||
SELECT constraint_name
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'Machine'
|
||||
AND constraint_type IN ('PRIMARY KEY', 'UNIQUE')
|
||||
)
|
||||
`;
|
||||
|
||||
for (const ref of refs) {
|
||||
if (ref.tableName === "Machine") continue;
|
||||
const table = quoteIdent(ref.tableName);
|
||||
const column = quoteIdent(ref.columnName);
|
||||
const rule = String(ref.deleteRule ?? "").toUpperCase();
|
||||
|
||||
if (rule === "CASCADE") continue;
|
||||
|
||||
if (rule === "SET NULL") {
|
||||
await prisma.$executeRawUnsafe(`UPDATE ${table} SET ${column} = NULL WHERE ${column} = $1`, machineId);
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.$executeRawUnsafe(`DELETE FROM ${table} WHERE ${column} = $1`, machineId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ machineId: string }> }) {
|
||||
const session = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { machineId } = await params;
|
||||
if (!machineIdSchema.safeParse(machineId).success) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const windowSec = Math.max(0, parseNumber(url.searchParams.get("windowSec"), 3600));
|
||||
const eventsWindowSec = Math.max(0, parseNumber(url.searchParams.get("eventsWindowSec"), 21600));
|
||||
const eventsMode = url.searchParams.get("events") ?? "critical";
|
||||
const eventsOnly = url.searchParams.get("eventsOnly") === "1";
|
||||
|
||||
const [machineRow, orgSettings, machineSettings] = await Promise.all([
|
||||
prisma.machine.findFirst({
|
||||
where: { id: machineId, orgId: session.orgId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
location: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
heartbeats: {
|
||||
orderBy: { tsServer: "desc" },
|
||||
take: 1,
|
||||
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
|
||||
},
|
||||
kpiSnapshots: {
|
||||
orderBy: { ts: "desc" },
|
||||
take: 1,
|
||||
select: {
|
||||
ts: true,
|
||||
oee: true,
|
||||
availability: true,
|
||||
performance: true,
|
||||
quality: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
good: true,
|
||||
scrap: true,
|
||||
target: true,
|
||||
cycleTime: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.orgSettings.findUnique({
|
||||
where: { orgId: session.orgId },
|
||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
|
||||
}),
|
||||
prisma.machineSettings.findUnique({
|
||||
where: { machineId },
|
||||
select: { overridesJson: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!machineRow) {
|
||||
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const overrides = isPlainObject(machineSettings?.overridesJson) ? machineSettings?.overridesJson : {};
|
||||
const thresholdsOverride = isPlainObject(overrides.thresholds) ? overrides.thresholds : {};
|
||||
const stoppageMultiplier =
|
||||
typeof thresholdsOverride.stoppageMultiplier === "number"
|
||||
? thresholdsOverride.stoppageMultiplier
|
||||
: Number(orgSettings?.stoppageMultiplier ?? 1.5);
|
||||
const macroStoppageMultiplier =
|
||||
typeof thresholdsOverride.macroStoppageMultiplier === "number"
|
||||
? thresholdsOverride.macroStoppageMultiplier
|
||||
: Number(orgSettings?.macroStoppageMultiplier ?? 5);
|
||||
|
||||
const thresholds = {
|
||||
stoppageMultiplier,
|
||||
macroStoppageMultiplier,
|
||||
};
|
||||
|
||||
const machine = {
|
||||
...machineRow,
|
||||
effectiveCycleTime: null,
|
||||
latestHeartbeat: machineRow.heartbeats[0] ?? null,
|
||||
latestKpi: machineRow.kpiSnapshots[0] ?? null,
|
||||
heartbeats: undefined,
|
||||
kpiSnapshots: undefined,
|
||||
};
|
||||
|
||||
const cycles = eventsOnly
|
||||
? []
|
||||
: await prisma.machineCycle.findMany({
|
||||
where: {
|
||||
orgId: session.orgId,
|
||||
machineId,
|
||||
ts: { gte: new Date(Date.now() - windowSec * 1000) },
|
||||
},
|
||||
orderBy: { ts: "asc" },
|
||||
select: {
|
||||
ts: true,
|
||||
tsServer: true,
|
||||
cycleCount: true,
|
||||
actualCycleTime: true,
|
||||
theoreticalCycleTime: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
},
|
||||
});
|
||||
|
||||
const cyclesOut = cycles.map((row) => {
|
||||
const ts = row.tsServer ?? row.ts;
|
||||
return {
|
||||
ts,
|
||||
t: ts.getTime(),
|
||||
cycleCount: row.cycleCount ?? null,
|
||||
actual: row.actualCycleTime,
|
||||
ideal: row.theoreticalCycleTime ?? null,
|
||||
workOrderId: row.workOrderId ?? null,
|
||||
sku: row.sku ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const eventWindowStart = new Date(Date.now() - eventsWindowSec * 1000);
|
||||
const criticalSeverities = ["critical", "error", "high"];
|
||||
const eventWhereBase = {
|
||||
orgId: session.orgId,
|
||||
machineId,
|
||||
ts: { gte: eventWindowStart },
|
||||
};
|
||||
|
||||
const [rawEvents, eventsCountAll] = await Promise.all([
|
||||
prisma.machineEvent.findMany({
|
||||
where: eventWhereBase,
|
||||
orderBy: { ts: "desc" },
|
||||
take: eventsOnly ? 300 : 120,
|
||||
select: {
|
||||
id: true,
|
||||
ts: true,
|
||||
topic: true,
|
||||
eventType: true,
|
||||
severity: true,
|
||||
title: true,
|
||||
description: true,
|
||||
requiresAck: true,
|
||||
data: true,
|
||||
workOrderId: true,
|
||||
},
|
||||
}),
|
||||
prisma.machineEvent.count({ where: eventWhereBase }),
|
||||
]);
|
||||
|
||||
const normalized = rawEvents.map((row) =>
|
||||
normalizeEvent(row, { microMultiplier: stoppageMultiplier, macroMultiplier: macroStoppageMultiplier })
|
||||
);
|
||||
|
||||
const allowed = normalized.filter((event) => ALLOWED_EVENT_TYPES.has(event.eventType));
|
||||
const criticalEventTypes = new Set(["macrostop", "microstop", "slow-cycle", "offline", "error"]);
|
||||
const filtered =
|
||||
eventsMode === "critical"
|
||||
? allowed.filter((event) => {
|
||||
const severity = String(event.severity ?? "").toLowerCase();
|
||||
return (
|
||||
criticalEventTypes.has(event.eventType) ||
|
||||
event.requiresAck === true ||
|
||||
criticalSeverities.includes(severity)
|
||||
);
|
||||
})
|
||||
: allowed;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const deduped = filtered.filter((event) => {
|
||||
const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
deduped.sort((a, b) => {
|
||||
const at = a.ts ? a.ts.getTime() : 0;
|
||||
const bt = b.ts ? b.ts.getTime() : 0;
|
||||
return bt - at;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
machine,
|
||||
events: deduped,
|
||||
eventsCountAll,
|
||||
cycles: cyclesOut,
|
||||
thresholds,
|
||||
activeStoppage: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { 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 membership = await prisma.orgUser.findUnique({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: session.orgId,
|
||||
userId: session.userId,
|
||||
},
|
||||
},
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
if (!canManageMachines(membership?.role)) {
|
||||
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
if (attempt === 0) {
|
||||
// Revoke credentials first in a committed write so ingest auth fails immediately.
|
||||
const revoked = await prisma.machine.updateMany({
|
||||
where: {
|
||||
id: machineId,
|
||||
orgId: session.orgId,
|
||||
},
|
||||
data: {
|
||||
apiKey: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (revoked.count === 0) {
|
||||
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
invalidateMachineAuth(machineId);
|
||||
}
|
||||
|
||||
// Avoid long interactive transactions on very large history tables (P2028 timeout).
|
||||
// This sequence is idempotent and safe to retry because apiKey is revoked first.
|
||||
await prisma.machineCycle.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.machineHeartbeat.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.machineKpiSnapshot.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.machineEvent.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.machineWorkOrder.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.machineSettings.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.settingsAudit.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.alertNotification.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.machineFinancialOverride.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.reasonEntry.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.downtimeAction.updateMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
data: {
|
||||
machineId: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await prisma.machine.deleteMany({
|
||||
where: {
|
||||
id: machineId,
|
||||
orgId: session.orgId,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
invalidateMachineAuth(machineId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err: unknown) {
|
||||
const code = err instanceof Prisma.PrismaClientKnownRequestError ? err.code : undefined;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("DELETE /api/machines/[machineId] failed", {
|
||||
machineId,
|
||||
orgId: session.orgId,
|
||||
attempt,
|
||||
code,
|
||||
message,
|
||||
});
|
||||
|
||||
if (code === "P2003") {
|
||||
if (attempt < 2) {
|
||||
try {
|
||||
await cleanupMachineReferences(machineId);
|
||||
} catch (cleanupErr: unknown) {
|
||||
const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
|
||||
console.error("DELETE /api/machines/[machineId] cleanup failed", {
|
||||
machineId,
|
||||
orgId: session.orgId,
|
||||
attempt,
|
||||
cleanupMessage,
|
||||
});
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 150));
|
||||
continue;
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Machine has dependent records and could not be removed",
|
||||
code,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
if (code === "P2022") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Server schema is out of date for machine delete",
|
||||
code,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (code === "P2028") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Delete timed out while removing machine history",
|
||||
code,
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
if (code) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Delete failed due to database error",
|
||||
code,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: false, error: "Delete failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: false, error: "Delete failed", code: "DELETE_RETRY_EXHAUSTED" }, { status: 500 });
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { requireSession } from "@/lib/auth/requireSession";
|
||||
import {
|
||||
fetchLatestHeartbeats,
|
||||
fetchLatestKpis,
|
||||
fetchLatestMacrostops,
|
||||
fetchMachineBase,
|
||||
mergeMachineOverviewRows,
|
||||
} from "@/lib/machines/withLatest";
|
||||
@@ -58,6 +59,10 @@ export async function GET(req: Request) {
|
||||
if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart);
|
||||
}
|
||||
|
||||
const macrostopStart = nowMs();
|
||||
const macrostops = await fetchLatestMacrostops(session.orgId, machineIds);
|
||||
if (perfEnabled) timings.macrostopsQuery = elapsedMs(macrostopStart);
|
||||
|
||||
const postQueryStart = nowMs();
|
||||
|
||||
// flatten latest heartbeat for UI convenience
|
||||
@@ -65,6 +70,7 @@ export async function GET(req: Request) {
|
||||
machines,
|
||||
heartbeats,
|
||||
kpis,
|
||||
macrostops,
|
||||
includeKpi,
|
||||
});
|
||||
|
||||
|
||||
161
app/api/machines/route.ts.bak
Normal file
161
app/api/machines/route.ts.bak
Normal file
@@ -0,0 +1,161 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { generatePairingCode } from "@/lib/pairingCode";
|
||||
import { z } from "zod";
|
||||
import { logLine } from "@/lib/logger";
|
||||
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import {
|
||||
fetchLatestHeartbeats,
|
||||
fetchLatestKpis,
|
||||
fetchMachineBase,
|
||||
mergeMachineOverviewRows,
|
||||
} from "@/lib/machines/withLatest";
|
||||
|
||||
let machinesColdStart = true;
|
||||
|
||||
function getColdStartInfo() {
|
||||
const coldStart = machinesColdStart;
|
||||
machinesColdStart = false;
|
||||
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const perfEnabled = PERF_LOGS_ENABLED;
|
||||
const totalStart = nowMs();
|
||||
const timings: Record<string, number> = {};
|
||||
const { coldStart, uptimeMs } = getColdStartInfo();
|
||||
const url = new URL(req.url);
|
||||
const includeKpi = url.searchParams.get("includeKpi") === "1";
|
||||
|
||||
const authStart = nowMs();
|
||||
const session = await requireSession();
|
||||
if (perfEnabled) timings.auth = elapsedMs(authStart);
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const preQueryStart = nowMs();
|
||||
const machinesStart = nowMs();
|
||||
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
||||
const machines = await fetchMachineBase(session.orgId);
|
||||
if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
|
||||
|
||||
const heartbeatStart = nowMs();
|
||||
const machineIds = machines.map((machine) => machine.id);
|
||||
const heartbeats = await fetchLatestHeartbeats(session.orgId, machineIds);
|
||||
if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
|
||||
|
||||
let kpis: Awaited<ReturnType<typeof fetchLatestKpis>> = [];
|
||||
if (includeKpi) {
|
||||
const kpiStart = nowMs();
|
||||
kpis = await fetchLatestKpis(session.orgId, machineIds);
|
||||
if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart);
|
||||
}
|
||||
|
||||
const postQueryStart = nowMs();
|
||||
|
||||
// flatten latest heartbeat for UI convenience
|
||||
const out = mergeMachineOverviewRows({
|
||||
machines,
|
||||
heartbeats,
|
||||
kpis,
|
||||
includeKpi,
|
||||
});
|
||||
|
||||
const payload = { ok: true, machines: out };
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
if (perfEnabled) {
|
||||
timings.postQuery = elapsedMs(postQueryStart);
|
||||
timings.total = elapsedMs(totalStart);
|
||||
responseHeaders.set("Server-Timing", formatServerTiming(timings));
|
||||
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
|
||||
logLine("perf.machines.api", {
|
||||
orgId: session.orgId,
|
||||
coldStart,
|
||||
uptimeMs,
|
||||
timings,
|
||||
counts: { machines: out.length },
|
||||
payloadBytes,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { headers: responseHeaders });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await requireSession();
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
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 },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ ok: false, error: "Machine name already exists" }, { status: 409 });
|
||||
}
|
||||
|
||||
const apiKey = randomBytes(24).toString("hex");
|
||||
const pairingExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
let machine = null as null | {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
location?: string | null;
|
||||
pairingCode?: string | null;
|
||||
pairingCodeExpiresAt?: Date | null;
|
||||
};
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const pairingCode = generatePairingCode();
|
||||
try {
|
||||
machine = await prisma.machine.create({
|
||||
data: {
|
||||
orgId: session.orgId,
|
||||
name,
|
||||
code: codeRaw || null,
|
||||
location: locationRaw || null,
|
||||
apiKey,
|
||||
pairingCode,
|
||||
pairingCodeExpiresAt: pairingExpiresAt,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
location: true,
|
||||
pairingCode: true,
|
||||
pairingCodeExpiresAt: true,
|
||||
},
|
||||
});
|
||||
break;
|
||||
} catch (err: unknown) {
|
||||
const code = typeof err === "object" && err !== null ? (err as { code?: string }).code : undefined;
|
||||
if (code !== "P2002") throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!machine?.pairingCode) {
|
||||
return NextResponse.json({ ok: false, error: "Failed to generate pairing code" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, machine });
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import {
|
||||
flattenReasonCatalog,
|
||||
loadFallbackReasonCatalog,
|
||||
normalizeReasonCatalog,
|
||||
type ReasonCatalogKind,
|
||||
} from "@/lib/reasonCatalog";
|
||||
import { flattenReasonCatalog, normalizeReasonCatalog, type ReasonCatalogKind } from "@/lib/reasonCatalog";
|
||||
import { effectiveReasonCatalogForOrg, loadReasonCatalogFromDb } from "@/lib/reasonCatalogDb";
|
||||
|
||||
function asKind(value: string | null): ReasonCatalogKind | null {
|
||||
const kind = String(value ?? "").toLowerCase();
|
||||
@@ -26,20 +22,30 @@ export async function GET(req: Request) {
|
||||
|
||||
const orgSettings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: session.orgId },
|
||||
select: { defaultsJson: true },
|
||||
select: { defaultsJson: true, version: true },
|
||||
});
|
||||
const defaultsJson =
|
||||
orgSettings?.defaultsJson && typeof orgSettings.defaultsJson === "object" && !Array.isArray(orgSettings.defaultsJson)
|
||||
? (orgSettings.defaultsJson as Record<string, unknown>)
|
||||
const version = orgSettings?.version ?? 1;
|
||||
const defaultsJson = orgSettings?.defaultsJson ?? null;
|
||||
|
||||
const fromDb = await loadReasonCatalogFromDb(session.orgId, version);
|
||||
const catalog = await effectiveReasonCatalogForOrg(session.orgId, defaultsJson, version);
|
||||
|
||||
const defs =
|
||||
defaultsJson && typeof defaultsJson === "object" && !Array.isArray(defaultsJson)
|
||||
? (defaultsJson as Record<string, unknown>)
|
||||
: {};
|
||||
const settingsCatalog = normalizeReasonCatalog(defaultsJson.reasonCatalog ?? defaultsJson.reasonCatalogData);
|
||||
const fallbackCatalog = await loadFallbackReasonCatalog();
|
||||
const catalog = settingsCatalog ?? fallbackCatalog;
|
||||
const rows = flattenReasonCatalog(catalog, kind);
|
||||
const legacyJson = normalizeReasonCatalog(defs.reasonCatalog ?? defs.reasonCatalogData);
|
||||
|
||||
let source: "db" | "legacy" | "fallback";
|
||||
if (fromDb) source = "db";
|
||||
else if (legacyJson) source = "legacy";
|
||||
else source = "fallback";
|
||||
|
||||
const rows = flattenReasonCatalog(catalog, kind, { activeOnly: true });
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
source: settingsCatalog ? "settings" : "fallback",
|
||||
source,
|
||||
kind,
|
||||
catalogVersion: catalog.version,
|
||||
categories: catalog[kind],
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
validateShiftOverrides,
|
||||
validateThresholds,
|
||||
} from "@/lib/settings";
|
||||
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
|
||||
import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb";
|
||||
import { publishSettingsUpdate } from "@/lib/mqtt";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -46,21 +46,18 @@ function pickAllowedOverrides(raw: unknown) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function withReasonCatalog<T extends Record<string, unknown>>(payload: T, fallbackCatalog: ReasonCatalog) {
|
||||
const base = (isPlainObject(payload) ? { ...payload } : {}) as T;
|
||||
const defaults = isPlainObject(base.defaults) ? base.defaults : {};
|
||||
const parsed =
|
||||
normalizeReasonCatalog(base.reasonCatalog) ??
|
||||
normalizeReasonCatalog(base.reasonCatalogData) ??
|
||||
normalizeReasonCatalog(defaults.reasonCatalog) ??
|
||||
normalizeReasonCatalog(defaults.reasonCatalogData) ??
|
||||
fallbackCatalog;
|
||||
|
||||
async function attachReasonCatalog(
|
||||
orgId: string,
|
||||
defaultsJson: unknown,
|
||||
settingsVersion: number,
|
||||
base: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
const catalog = await effectiveReasonCatalogForOrg(orgId, defaultsJson, settingsVersion);
|
||||
return {
|
||||
...base,
|
||||
reasonCatalog: parsed,
|
||||
reasonCatalogData: parsed,
|
||||
reasonCatalogVersion: Number(parsed.version || 1),
|
||||
reasonCatalog: catalog,
|
||||
reasonCatalogData: catalog,
|
||||
reasonCatalogVersion: Number(catalog.version || 1),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,9 +161,7 @@ export async function GET(
|
||||
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
orgId = machine.orgId;
|
||||
}
|
||||
const fallbackCatalog = await loadFallbackReasonCatalog();
|
||||
|
||||
const { settings, overrides } = await prisma.$transaction(async (tx) => {
|
||||
const { orgRow, shifts, rawOverrides } = await prisma.$transaction(async (tx) => {
|
||||
const orgSettings = await ensureOrgSettings(tx, orgId as string, userId);
|
||||
if (!orgSettings?.settings) throw new Error("SETTINGS_NOT_FOUND");
|
||||
|
||||
@@ -175,25 +170,24 @@ export async function GET(
|
||||
select: { overridesJson: true },
|
||||
});
|
||||
|
||||
const orgPayload = withReasonCatalog(
|
||||
buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []),
|
||||
fallbackCatalog
|
||||
);
|
||||
const rawOverrides = pickAllowedOverrides(machineSettings?.overridesJson ?? {});
|
||||
const effective = withReasonCatalog(
|
||||
deepMerge(orgPayload, rawOverrides) as Record<string, unknown>,
|
||||
fallbackCatalog
|
||||
);
|
||||
|
||||
return { settings: { org: orgPayload, effective }, overrides: rawOverrides };
|
||||
return {
|
||||
orgRow: orgSettings.settings,
|
||||
shifts: orgSettings.shifts ?? [],
|
||||
rawOverrides,
|
||||
};
|
||||
});
|
||||
|
||||
const baseOrg = buildSettingsPayload(orgRow, shifts) as Record<string, unknown>;
|
||||
const orgPayload = await attachReasonCatalog(orgId as string, orgRow.defaultsJson, orgRow.version, baseOrg);
|
||||
const effective = deepMerge(orgPayload, rawOverrides) as Record<string, unknown>;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
machineId,
|
||||
orgSettings: settings.org,
|
||||
effectiveSettings: settings.effective,
|
||||
overrides,
|
||||
orgSettings: orgPayload,
|
||||
effectiveSettings: effective,
|
||||
overrides: rawOverrides,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -413,25 +407,23 @@ export async function PUT(
|
||||
},
|
||||
});
|
||||
|
||||
const fallbackCatalog = await loadFallbackReasonCatalog();
|
||||
const orgPayload = withReasonCatalog(
|
||||
buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []),
|
||||
fallbackCatalog
|
||||
);
|
||||
const overrides = pickAllowedOverrides(saved.overridesJson ?? {});
|
||||
const effective = withReasonCatalog(
|
||||
deepMerge(orgPayload, overrides) as Record<string, unknown>,
|
||||
fallbackCatalog
|
||||
);
|
||||
|
||||
return {
|
||||
orgPayload,
|
||||
overrides,
|
||||
effective,
|
||||
orgSettingsRow: orgSettings.settings,
|
||||
shifts: orgSettings.shifts ?? [],
|
||||
overrides: pickAllowedOverrides(saved.overridesJson ?? {}),
|
||||
overridesUpdatedAt: saved.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
const baseOrg = buildSettingsPayload(result.orgSettingsRow, result.shifts) as Record<string, unknown>;
|
||||
const orgPayload = await attachReasonCatalog(
|
||||
session.orgId,
|
||||
result.orgSettingsRow.defaultsJson,
|
||||
result.orgSettingsRow.version,
|
||||
baseOrg
|
||||
);
|
||||
const effective = deepMerge(orgPayload, result.overrides) as Record<string, unknown>;
|
||||
|
||||
const overridesUpdatedAt =
|
||||
result.overridesUpdatedAt && result.overridesUpdatedAt instanceof Date
|
||||
? result.overridesUpdatedAt.toISOString()
|
||||
@@ -440,7 +432,7 @@ export async function PUT(
|
||||
await publishSettingsUpdate({
|
||||
orgId: session.orgId,
|
||||
machineId,
|
||||
version: Number(result.orgPayload.version ?? 0),
|
||||
version: Number(result.orgSettingsRow.version ?? 0),
|
||||
source,
|
||||
overridesUpdatedAt,
|
||||
});
|
||||
@@ -451,8 +443,8 @@ export async function PUT(
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
machineId,
|
||||
orgSettings: result.orgPayload,
|
||||
effectiveSettings: result.effective,
|
||||
orgSettings: orgPayload,
|
||||
effectiveSettings: effective,
|
||||
overrides: result.overrides,
|
||||
});
|
||||
}
|
||||
|
||||
106
app/api/settings/reason-catalog/categories/[categoryId]/route.ts
Normal file
106
app/api/settings/reason-catalog/categories/[categoryId]/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
|
||||
import { bumpOrgSettingsVersion, composeReasonCode } from "@/lib/reasonCatalogDb";
|
||||
import { z } from "zod";
|
||||
|
||||
const PREFIX_RE = /^[A-Za-z][A-Za-z0-9-]*$/;
|
||||
|
||||
const patchSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200).optional(),
|
||||
codePrefix: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(32)
|
||||
.transform((s) => s.toUpperCase())
|
||||
.optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ categoryId: string }> }
|
||||
) {
|
||||
const auth = await requireOrgAdminSession();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { categoryId } = await params;
|
||||
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.reasonCatalogCategory.findFirst({
|
||||
where: { id: categoryId, orgId: auth.session.orgId },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!existing) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||
|
||||
const nextPrefix = parsed.data.codePrefix ?? existing.codePrefix;
|
||||
if (parsed.data.codePrefix !== undefined && !PREFIX_RE.test(nextPrefix)) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "codePrefix must start with a letter; letters, digits, hyphen allowed." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.codePrefix !== undefined && parsed.data.codePrefix !== existing.codePrefix) {
|
||||
const proposed = new Set<string>();
|
||||
for (const it of existing.items) {
|
||||
proposed.add(composeReasonCode(nextPrefix, it.codeSuffix));
|
||||
}
|
||||
const codes = [...proposed];
|
||||
const conflicts = await prisma.reasonCatalogItem.findMany({
|
||||
where: {
|
||||
orgId: auth.session.orgId,
|
||||
reasonCode: { in: codes },
|
||||
NOT: { categoryId: existing.id },
|
||||
},
|
||||
select: { reasonCode: true },
|
||||
});
|
||||
if (conflicts.length) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Prefix change would duplicate codes", conflicts: conflicts.map((c) => c.reasonCode) },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.reasonCatalogCategory.update({
|
||||
where: { id: categoryId },
|
||||
data: {
|
||||
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
|
||||
...(parsed.data.codePrefix !== undefined ? { codePrefix: parsed.data.codePrefix } : {}),
|
||||
...(parsed.data.sortOrder !== undefined ? { sortOrder: parsed.data.sortOrder } : {}),
|
||||
...(parsed.data.active !== undefined ? { active: parsed.data.active } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (parsed.data.codePrefix !== undefined && parsed.data.codePrefix !== existing.codePrefix) {
|
||||
for (const it of existing.items) {
|
||||
const reasonCode = composeReasonCode(nextPrefix, it.codeSuffix);
|
||||
await tx.reasonCatalogItem.update({
|
||||
where: { id: it.id },
|
||||
data: { reasonCode },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
|
||||
});
|
||||
|
||||
const updated = await prisma.reasonCatalogCategory.findUnique({
|
||||
where: { id: categoryId },
|
||||
include: { items: { orderBy: [{ sortOrder: "asc" }, { reasonCode: "asc" }] } },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, category: updated });
|
||||
} catch (e) {
|
||||
console.error("[reason-catalog category PATCH]", e);
|
||||
return NextResponse.json({ ok: false, error: "Update failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
64
app/api/settings/reason-catalog/categories/route.ts
Normal file
64
app/api/settings/reason-catalog/categories/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
|
||||
import { bumpOrgSettingsVersion } from "@/lib/reasonCatalogDb";
|
||||
import { z } from "zod";
|
||||
|
||||
const PREFIX_RE = /^[A-Za-z][A-Za-z0-9-]*$/;
|
||||
|
||||
const bodySchema = z.object({
|
||||
kind: z.enum(["downtime", "scrap"]),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
codePrefix: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(32)
|
||||
.transform((s) => s.toUpperCase()),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const auth = await requireOrgAdminSession();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
const { kind, name, codePrefix } = parsed.data;
|
||||
if (!PREFIX_RE.test(codePrefix)) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "codePrefix must start with a letter; letters, digits, hyphen allowed." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await prisma.$transaction(async (tx) => {
|
||||
const last = await tx.reasonCatalogCategory.findFirst({
|
||||
where: { orgId: auth.session.orgId, kind },
|
||||
orderBy: { sortOrder: "desc" },
|
||||
select: { sortOrder: true },
|
||||
});
|
||||
const sortOrder = (last?.sortOrder ?? -1) + 1;
|
||||
|
||||
const created = await tx.reasonCatalogCategory.create({
|
||||
data: {
|
||||
orgId: auth.session.orgId,
|
||||
kind,
|
||||
name,
|
||||
codePrefix,
|
||||
sortOrder,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
|
||||
return created;
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, category: row });
|
||||
} catch (e) {
|
||||
console.error("[reason-catalog categories POST]", e);
|
||||
return NextResponse.json({ ok: false, error: "Create failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
69
app/api/settings/reason-catalog/items/[itemId]/route.ts
Normal file
69
app/api/settings/reason-catalog/items/[itemId]/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
|
||||
import { bumpOrgSettingsVersion, composeReasonCode, isNumericSuffix } from "@/lib/reasonCatalogDb";
|
||||
import { z } from "zod";
|
||||
|
||||
const patchSchema = z.object({
|
||||
name: z.string().trim().min(1).max(500).optional(),
|
||||
codeSuffix: z.string().trim().min(1).max(32).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ itemId: string }> }
|
||||
) {
|
||||
const auth = await requireOrgAdminSession();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { itemId } = await params;
|
||||
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.reasonCatalogItem.findFirst({
|
||||
where: { id: itemId, orgId: auth.session.orgId },
|
||||
include: { category: true },
|
||||
});
|
||||
if (!existing) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||
|
||||
const nextSuffix = parsed.data.codeSuffix ?? existing.codeSuffix;
|
||||
if (parsed.data.codeSuffix !== undefined && !isNumericSuffix(nextSuffix)) {
|
||||
return NextResponse.json({ ok: false, error: "codeSuffix must be digits only" }, { status: 400 });
|
||||
}
|
||||
|
||||
const reasonCode = composeReasonCode(existing.category.codePrefix, nextSuffix);
|
||||
if (reasonCode !== existing.reasonCode) {
|
||||
const conflict = await prisma.reasonCatalogItem.findFirst({
|
||||
where: { orgId: auth.session.orgId, reasonCode, NOT: { id: itemId } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (conflict) {
|
||||
return NextResponse.json({ ok: false, error: "Duplicate reasonCode for this organization" }, { status: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.reasonCatalogItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
|
||||
...(parsed.data.codeSuffix !== undefined ? { codeSuffix: nextSuffix, reasonCode } : {}),
|
||||
...(parsed.data.sortOrder !== undefined ? { sortOrder: parsed.data.sortOrder } : {}),
|
||||
...(parsed.data.active !== undefined ? { active: parsed.data.active } : {}),
|
||||
},
|
||||
});
|
||||
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
|
||||
});
|
||||
|
||||
const updated = await prisma.reasonCatalogItem.findUnique({ where: { id: itemId } });
|
||||
return NextResponse.json({ ok: true, item: updated });
|
||||
} catch (e) {
|
||||
console.error("[reason-catalog item PATCH]", e);
|
||||
return NextResponse.json({ ok: false, error: "Update failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
71
app/api/settings/reason-catalog/items/route.ts
Normal file
71
app/api/settings/reason-catalog/items/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
|
||||
import { bumpOrgSettingsVersion, composeReasonCode, isNumericSuffix } from "@/lib/reasonCatalogDb";
|
||||
import { z } from "zod";
|
||||
|
||||
const bodySchema = z.object({
|
||||
categoryId: z.string().uuid(),
|
||||
codeSuffix: z.string().trim().min(1).max(32),
|
||||
name: z.string().trim().min(1).max(500),
|
||||
sortOrder: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const auth = await requireOrgAdminSession();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
const { categoryId, codeSuffix, name, sortOrder } = parsed.data;
|
||||
if (!isNumericSuffix(codeSuffix)) {
|
||||
return NextResponse.json({ ok: false, error: "codeSuffix must be digits only" }, { status: 400 });
|
||||
}
|
||||
|
||||
const category = await prisma.reasonCatalogCategory.findFirst({
|
||||
where: { id: categoryId, orgId: auth.session.orgId },
|
||||
});
|
||||
if (!category) return NextResponse.json({ ok: false, error: "Category not found" }, { status: 404 });
|
||||
|
||||
const reasonCode = composeReasonCode(category.codePrefix, codeSuffix);
|
||||
|
||||
try {
|
||||
const row = await prisma.$transaction(async (tx) => {
|
||||
let nextOrder = sortOrder;
|
||||
if (nextOrder === undefined) {
|
||||
const last = await tx.reasonCatalogItem.findFirst({
|
||||
where: { categoryId },
|
||||
orderBy: { sortOrder: "desc" },
|
||||
select: { sortOrder: true },
|
||||
});
|
||||
nextOrder = (last?.sortOrder ?? -1) + 1;
|
||||
}
|
||||
|
||||
const created = await tx.reasonCatalogItem.create({
|
||||
data: {
|
||||
orgId: auth.session.orgId,
|
||||
categoryId,
|
||||
name,
|
||||
codeSuffix,
|
||||
reasonCode,
|
||||
sortOrder: nextOrder,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
|
||||
return created;
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, item: row });
|
||||
} catch (e: unknown) {
|
||||
const code = typeof e === "object" && e && "code" in e ? (e as { code: string }).code : "";
|
||||
if (code === "P2002") {
|
||||
return NextResponse.json({ ok: false, error: "Duplicate reasonCode for this organization" }, { status: 409 });
|
||||
}
|
||||
console.error("[reason-catalog items POST]", e);
|
||||
return NextResponse.json({ ok: false, error: "Create failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
43
app/api/settings/reason-catalog/route.ts
Normal file
43
app/api/settings/reason-catalog/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
|
||||
|
||||
/** Full tree for Control Tower (includes inactive rows). */
|
||||
export async function GET() {
|
||||
const auth = await requireOrgAdminSession();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const orgSettings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: auth.session.orgId },
|
||||
select: { version: true },
|
||||
});
|
||||
|
||||
const categories = await prisma.reasonCatalogCategory.findMany({
|
||||
where: { orgId: auth.session.orgId },
|
||||
include: {
|
||||
items: { orderBy: [{ sortOrder: "asc" }, { reasonCode: "asc" }] },
|
||||
},
|
||||
orderBy: [{ kind: "asc" }, { sortOrder: "asc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
catalogVersion: orgSettings?.version ?? 1,
|
||||
categories: categories.map((c) => ({
|
||||
id: c.id,
|
||||
kind: c.kind,
|
||||
name: c.name,
|
||||
codePrefix: c.codePrefix,
|
||||
sortOrder: c.sortOrder,
|
||||
active: c.active,
|
||||
items: c.items.map((it) => ({
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
codeSuffix: it.codeSuffix,
|
||||
reasonCode: it.reasonCode,
|
||||
sortOrder: it.sortOrder,
|
||||
active: it.active,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
validateShiftOverrides,
|
||||
validateThresholds,
|
||||
} from "@/lib/settings";
|
||||
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
|
||||
import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb";
|
||||
import { publishSettingsUpdate } from "@/lib/mqtt";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -39,21 +39,18 @@ function canManageSettings(role?: string | null) {
|
||||
return role === "OWNER" || role === "ADMIN";
|
||||
}
|
||||
|
||||
function withReasonCatalog<T extends Record<string, unknown>>(payload: T, fallbackCatalog: ReasonCatalog) {
|
||||
const base = (isPlainObject(payload) ? { ...payload } : {}) as T;
|
||||
const defaults = isPlainObject(base.defaults) ? base.defaults : {};
|
||||
const parsed =
|
||||
normalizeReasonCatalog(base.reasonCatalog) ??
|
||||
normalizeReasonCatalog(base.reasonCatalogData) ??
|
||||
normalizeReasonCatalog(defaults.reasonCatalog) ??
|
||||
normalizeReasonCatalog(defaults.reasonCatalogData) ??
|
||||
fallbackCatalog;
|
||||
|
||||
async function attachReasonCatalog(
|
||||
orgId: string,
|
||||
defaultsJson: unknown,
|
||||
settingsVersion: number,
|
||||
base: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
const catalog = await effectiveReasonCatalogForOrg(orgId, defaultsJson, settingsVersion);
|
||||
return {
|
||||
...base,
|
||||
reasonCatalog: parsed,
|
||||
reasonCatalogData: parsed,
|
||||
reasonCatalogVersion: Number(parsed.version || 1),
|
||||
reasonCatalog: catalog,
|
||||
reasonCatalogData: catalog,
|
||||
reasonCatalogVersion: Number(catalog.version || 1),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,7 +63,6 @@ const settingsPayloadSchema = z
|
||||
thresholds: z.any().optional(),
|
||||
alerts: z.any().optional(),
|
||||
defaults: z.any().optional(),
|
||||
reasonCatalog: z.any().optional(),
|
||||
version: z.union([z.number(), z.string()]).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
@@ -145,8 +141,13 @@ async function loadSettingsPayload(orgId: string, userId: string) {
|
||||
return found;
|
||||
});
|
||||
|
||||
const fallbackCatalog = await loadFallbackReasonCatalog();
|
||||
const payload = withReasonCatalog(buildSettingsPayload(loaded.settings, loaded.shifts ?? []), fallbackCatalog);
|
||||
const base = buildSettingsPayload(loaded.settings, loaded.shifts ?? []) as Record<string, unknown>;
|
||||
const payload = await attachReasonCatalog(
|
||||
orgId,
|
||||
loaded.settings.defaultsJson,
|
||||
loaded.settings.version,
|
||||
base
|
||||
);
|
||||
const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {};
|
||||
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
|
||||
const modules = { screenlessMode: modulesRaw.screenlessMode === true };
|
||||
@@ -221,7 +222,6 @@ export async function PUT(req: Request) {
|
||||
const thresholds = parsed.data.thresholds;
|
||||
const alerts = parsed.data.alerts;
|
||||
const defaults = parsed.data.defaults;
|
||||
const reasonCatalogRaw = parsed.data.reasonCatalog;
|
||||
const expectedVersion = parsed.data.version;
|
||||
const modules = parsed.data.modules;
|
||||
|
||||
@@ -233,7 +233,6 @@ export async function PUT(req: Request) {
|
||||
thresholds === undefined &&
|
||||
alerts === undefined &&
|
||||
defaults === undefined &&
|
||||
reasonCatalogRaw === undefined &&
|
||||
modules === undefined
|
||||
|
||||
) {
|
||||
@@ -252,13 +251,6 @@ export async function PUT(req: Request) {
|
||||
if (defaults !== undefined && !isPlainObject(defaults)) {
|
||||
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
|
||||
}
|
||||
const nextReasonCatalog =
|
||||
reasonCatalogRaw === undefined || reasonCatalogRaw === null
|
||||
? reasonCatalogRaw
|
||||
: normalizeReasonCatalog(reasonCatalogRaw);
|
||||
if (reasonCatalogRaw !== undefined && reasonCatalogRaw !== null && !nextReasonCatalog) {
|
||||
return NextResponse.json({ ok: false, error: "reasonCatalog must be a valid catalog payload" }, { status: 400 });
|
||||
}
|
||||
if (modules !== undefined && !isPlainObject(modules)) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 });
|
||||
}
|
||||
@@ -333,20 +325,16 @@ export async function PUT(req: Request) {
|
||||
: { ...currentModulesRaw, screenlessMode };
|
||||
|
||||
// Write defaultsJson if either defaults changed OR modules changed
|
||||
const shouldWriteDefaultsJson =
|
||||
!!nextDefaultsCore || screenlessMode !== undefined || reasonCatalogRaw !== undefined;
|
||||
const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined;
|
||||
|
||||
const nextDefaultsJson = shouldWriteDefaultsJson
|
||||
? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules }
|
||||
: undefined;
|
||||
|
||||
if (nextDefaultsJson && reasonCatalogRaw !== undefined) {
|
||||
if (nextDefaultsJson) {
|
||||
const defaultsTarget = nextDefaultsJson as Record<string, unknown>;
|
||||
if (nextReasonCatalog === null) {
|
||||
delete defaultsTarget.reasonCatalog;
|
||||
} else if (nextReasonCatalog) {
|
||||
defaultsTarget.reasonCatalog = nextReasonCatalog;
|
||||
}
|
||||
delete defaultsTarget.reasonCatalogData;
|
||||
}
|
||||
|
||||
|
||||
@@ -444,12 +432,18 @@ export async function PUT(req: Request) {
|
||||
return NextResponse.json({ ok: false, error: updated.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []);
|
||||
const baseOut = buildSettingsPayload(updated.settings, updated.shifts ?? []) as Record<string, unknown>;
|
||||
const payload = await attachReasonCatalog(
|
||||
session.orgId,
|
||||
updated.settings.defaultsJson,
|
||||
updated.settings.version,
|
||||
baseOut
|
||||
);
|
||||
const updatedAt =
|
||||
typeof payload.updatedAt === "string"
|
||||
? payload.updatedAt
|
||||
: payload.updatedAt
|
||||
? payload.updatedAt.toISOString()
|
||||
? (payload.updatedAt as Date).toISOString()
|
||||
: undefined;
|
||||
try {
|
||||
await publishSettingsUpdate({
|
||||
|
||||
@@ -90,18 +90,19 @@ export default function RecapFullTimeline({
|
||||
locale
|
||||
)} · ${formatDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
|
||||
|
||||
const showLabel = widthPct > LABEL_MIN_WIDTH_PCT;
|
||||
return (
|
||||
<div
|
||||
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
|
||||
className={`flex h-full shrink-0 items-center justify-center truncate px-2 text-xs font-semibold ${
|
||||
TIMELINE_COLORS[segment.type]
|
||||
} ${index === 0 ? "rounded-l-xl" : ""} ${
|
||||
index === normalized.length - 1 ? "rounded-r-xl" : ""
|
||||
}`}
|
||||
className={`flex h-full items-center justify-center overflow-hidden text-xs font-semibold ${
|
||||
showLabel ? "truncate px-2" : ""
|
||||
} ${TIMELINE_COLORS[segment.type]} ${
|
||||
index === 0 ? "rounded-l-xl" : ""
|
||||
} ${index === normalized.length - 1 ? "rounded-r-xl" : ""}`}
|
||||
style={{ width: `${Math.max(0, widthPct)}%` }}
|
||||
title={title}
|
||||
>
|
||||
{widthPct > LABEL_MIN_WIDTH_PCT ? segment.label : ""}
|
||||
{showLabel ? segment.label : ""}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -17,12 +17,14 @@ const STATUS_DOT: Record<RecapSummaryMachine["status"], string> = {
|
||||
"mold-change": "bg-amber-400",
|
||||
stopped: "bg-red-500",
|
||||
offline: "bg-zinc-500",
|
||||
idle: "bg-zinc-400",
|
||||
};
|
||||
|
||||
function statusLabel(status: RecapSummaryMachine["status"], t: (key: string) => string) {
|
||||
if (status === "running") return t("recap.status.running");
|
||||
if (status === "mold-change") return t("recap.status.moldChange");
|
||||
if (status === "stopped") return t("recap.status.stopped");
|
||||
if (status === "idle") return t("recap.status.idle");
|
||||
return t("recap.status.offline");
|
||||
}
|
||||
|
||||
@@ -37,6 +39,9 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
|
||||
|
||||
const zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
|
||||
const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`;
|
||||
const ongoingStopMin = machine.ongoingStopMin ?? 0;
|
||||
const isUrgent = machine.status === "stopped" && ongoingStopMin >= 5;
|
||||
const isCalm = machine.status === "idle";
|
||||
const timelineSegments = timeline?.segments ?? machine.miniTimeline;
|
||||
const timelineStart = timeline?.range.start ?? rangeStart;
|
||||
const timelineEnd = timeline?.range.end ?? rangeEnd;
|
||||
@@ -59,7 +64,7 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
|
||||
async function loadTimeline() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=60`,
|
||||
`/api/recap/${machine.machineId}/timeline?range=24h`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const json = await res.json().catch(() => null);
|
||||
@@ -83,7 +88,13 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
|
||||
return (
|
||||
<Link
|
||||
href={`/recap/${machine.machineId}`}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 p-4 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80"
|
||||
className={`rounded-2xl border p-4 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80 ${
|
||||
isUrgent
|
||||
? "border-red-500/60 bg-red-500/10 hover:bg-red-500/15 ring-2 ring-red-500/40 animate-pulse"
|
||||
: isCalm
|
||||
? "border-white/5 bg-white/[0.02] hover:bg-white/[0.04] opacity-70"
|
||||
: "border-white/10 bg-white/5 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
@@ -136,7 +147,14 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 text-xs text-zinc-400">{footerText}</div>
|
||||
<div className={`mt-3 text-xs ${isUrgent ? "text-red-200 font-semibold" : isCalm ? "text-zinc-500" : "text-zinc-400"}`}>
|
||||
{isUrgent
|
||||
? t("recap.card.stoppedFor", { min: ongoingStopMin })
|
||||
+ (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "")
|
||||
: machine.status === "idle"
|
||||
? t("recap.card.idle")
|
||||
: footerText}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
142
components/recap/RecapMachineCard.tsx.bak
Normal file
142
components/recap/RecapMachineCard.tsx.bak
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types";
|
||||
import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline";
|
||||
|
||||
type Props = {
|
||||
machine: RecapSummaryMachine;
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
};
|
||||
|
||||
const STATUS_DOT: Record<RecapSummaryMachine["status"], string> = {
|
||||
running: "bg-emerald-400",
|
||||
"mold-change": "bg-amber-400",
|
||||
stopped: "bg-red-500",
|
||||
offline: "bg-zinc-500",
|
||||
};
|
||||
|
||||
function statusLabel(status: RecapSummaryMachine["status"], t: (key: string) => string) {
|
||||
if (status === "running") return t("recap.status.running");
|
||||
if (status === "mold-change") return t("recap.status.moldChange");
|
||||
if (status === "stopped") return t("recap.status.stopped");
|
||||
return t("recap.status.offline");
|
||||
}
|
||||
|
||||
function toInt(value: number | null | undefined) {
|
||||
if (value == null || Number.isNaN(value)) return 0;
|
||||
return Math.max(0, Math.round(value));
|
||||
}
|
||||
|
||||
export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Props) {
|
||||
const { t, locale } = useI18n();
|
||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||
|
||||
const zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
|
||||
const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`;
|
||||
const timelineSegments = timeline?.segments ?? machine.miniTimeline;
|
||||
const timelineStart = timeline?.range.start ?? rangeStart;
|
||||
const timelineEnd = timeline?.range.end ?? rangeEnd;
|
||||
const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0;
|
||||
|
||||
const lastSeenLabel =
|
||||
machine.lastActivityMin == null
|
||||
? t("common.never")
|
||||
: t("recap.card.lastActivity", { min: toInt(machine.lastActivityMin) });
|
||||
|
||||
const footerText = machine.activeWorkOrderId
|
||||
? t("recap.card.activeWorkOrder", { id: machine.activeWorkOrderId })
|
||||
: lastSeenLabel;
|
||||
|
||||
const moldMinutes = machine.moldChange?.active ? machine.moldChange.elapsedMin : null;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
async function loadTimeline() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=60`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!alive || !res.ok || !json) return;
|
||||
setTimeline(json as RecapTimelineResponse);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
void loadTimeline();
|
||||
const timer = window.setInterval(() => {
|
||||
void loadTimeline();
|
||||
}, 60000);
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [machine.machineId]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/recap/${machine.machineId}`}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 p-4 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-lg font-semibold text-white">{machine.name}</div>
|
||||
<div className="mt-1 truncate text-xs text-zinc-400">{machine.location || t("common.na")}</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 px-2 py-1 text-xs text-zinc-200">
|
||||
<span
|
||||
className={`inline-block h-2.5 w-2.5 rounded-full ${STATUS_DOT[machine.status]}`}
|
||||
aria-label={statusLabel(machine.status, t)}
|
||||
/>
|
||||
{statusLabel(machine.status, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
<div className={`text-3xl font-semibold ${machine.oee == null ? "text-zinc-400" : "text-white"}`}>{primaryMetric}</div>
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.card.oee")}</div>
|
||||
</div>
|
||||
{machine.oee == null ? <div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div> : null}
|
||||
|
||||
{zeroActivity ? <div className="mt-1 text-xs text-zinc-500">{t("recap.card.noProduction")}</div> : null}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-300">
|
||||
<span>{t("recap.card.good")}: {machine.goodParts}</span>
|
||||
<span>{t("recap.card.scrap")}: {machine.scrap}</span>
|
||||
<span>{t("recap.card.stops")}: {machine.stopsCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<RecapMiniTimeline
|
||||
rangeStart={timelineStart}
|
||||
rangeEnd={timelineEnd}
|
||||
segments={timelineSegments}
|
||||
locale={locale}
|
||||
hasData={hasTimelineData}
|
||||
muted={zeroActivity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{machine.moldChange?.active ? (
|
||||
<div className="mt-3 rounded-lg border border-amber-400/40 bg-amber-400/10 px-2 py-1.5 text-xs text-amber-200">
|
||||
{t("recap.card.moldChangeActive", { min: toInt(moldMinutes) })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{machine.offlineForMin != null && machine.offlineForMin > 10 ? (
|
||||
<div className="mt-2 rounded-lg border border-red-500/40 bg-red-500/10 px-2 py-1.5 text-xs text-red-200">
|
||||
{t("recap.banner.offline", { min: toInt(machine.offlineForMin) })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 text-xs text-zinc-400">{footerText}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
153
components/recap/RecapMachineCard.tsx.bak.step5
Normal file
153
components/recap/RecapMachineCard.tsx.bak.step5
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types";
|
||||
import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline";
|
||||
|
||||
type Props = {
|
||||
machine: RecapSummaryMachine;
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
};
|
||||
|
||||
const STATUS_DOT: Record<RecapSummaryMachine["status"], string> = {
|
||||
running: "bg-emerald-400",
|
||||
"mold-change": "bg-amber-400",
|
||||
stopped: "bg-red-500",
|
||||
offline: "bg-zinc-500",
|
||||
};
|
||||
|
||||
function statusLabel(status: RecapSummaryMachine["status"], t: (key: string) => string) {
|
||||
if (status === "running") return t("recap.status.running");
|
||||
if (status === "mold-change") return t("recap.status.moldChange");
|
||||
if (status === "stopped") return t("recap.status.stopped");
|
||||
return t("recap.status.offline");
|
||||
}
|
||||
|
||||
function toInt(value: number | null | undefined) {
|
||||
if (value == null || Number.isNaN(value)) return 0;
|
||||
return Math.max(0, Math.round(value));
|
||||
}
|
||||
|
||||
export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Props) {
|
||||
const { t, locale } = useI18n();
|
||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||
|
||||
const zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
|
||||
const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`;
|
||||
const ongoingStopMin = machine.ongoingStopMin ?? 0;
|
||||
const isUrgent = machine.status === "stopped" && ongoingStopMin >= 5;
|
||||
const timelineSegments = timeline?.segments ?? machine.miniTimeline;
|
||||
const timelineStart = timeline?.range.start ?? rangeStart;
|
||||
const timelineEnd = timeline?.range.end ?? rangeEnd;
|
||||
const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0;
|
||||
|
||||
const lastSeenLabel =
|
||||
machine.lastActivityMin == null
|
||||
? t("common.never")
|
||||
: t("recap.card.lastActivity", { min: toInt(machine.lastActivityMin) });
|
||||
|
||||
const footerText = machine.activeWorkOrderId
|
||||
? t("recap.card.activeWorkOrder", { id: machine.activeWorkOrderId })
|
||||
: lastSeenLabel;
|
||||
|
||||
const moldMinutes = machine.moldChange?.active ? machine.moldChange.elapsedMin : null;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
async function loadTimeline() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=60`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!alive || !res.ok || !json) return;
|
||||
setTimeline(json as RecapTimelineResponse);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
void loadTimeline();
|
||||
const timer = window.setInterval(() => {
|
||||
void loadTimeline();
|
||||
}, 60000);
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [machine.machineId]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/recap/${machine.machineId}`}
|
||||
className={`rounded-2xl border p-4 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80 ${
|
||||
isUrgent
|
||||
? "border-red-500/60 bg-red-500/10 hover:bg-red-500/15 ring-2 ring-red-500/40 animate-pulse"
|
||||
: "border-white/10 bg-white/5 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-lg font-semibold text-white">{machine.name}</div>
|
||||
<div className="mt-1 truncate text-xs text-zinc-400">{machine.location || t("common.na")}</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 px-2 py-1 text-xs text-zinc-200">
|
||||
<span
|
||||
className={`inline-block h-2.5 w-2.5 rounded-full ${STATUS_DOT[machine.status]}`}
|
||||
aria-label={statusLabel(machine.status, t)}
|
||||
/>
|
||||
{statusLabel(machine.status, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
<div className={`text-3xl font-semibold ${machine.oee == null ? "text-zinc-400" : "text-white"}`}>{primaryMetric}</div>
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.card.oee")}</div>
|
||||
</div>
|
||||
{machine.oee == null ? <div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div> : null}
|
||||
|
||||
{zeroActivity ? <div className="mt-1 text-xs text-zinc-500">{t("recap.card.noProduction")}</div> : null}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-300">
|
||||
<span>{t("recap.card.good")}: {machine.goodParts}</span>
|
||||
<span>{t("recap.card.scrap")}: {machine.scrap}</span>
|
||||
<span>{t("recap.card.stops")}: {machine.stopsCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<RecapMiniTimeline
|
||||
rangeStart={timelineStart}
|
||||
rangeEnd={timelineEnd}
|
||||
segments={timelineSegments}
|
||||
locale={locale}
|
||||
hasData={hasTimelineData}
|
||||
muted={zeroActivity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{machine.moldChange?.active ? (
|
||||
<div className="mt-3 rounded-lg border border-amber-400/40 bg-amber-400/10 px-2 py-1.5 text-xs text-amber-200">
|
||||
{t("recap.card.moldChangeActive", { min: toInt(moldMinutes) })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{machine.offlineForMin != null && machine.offlineForMin > 10 ? (
|
||||
<div className="mt-2 rounded-lg border border-red-500/40 bg-red-500/10 px-2 py-1.5 text-xs text-red-200">
|
||||
{t("recap.banner.offline", { min: toInt(machine.offlineForMin) })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`mt-3 text-xs ${isUrgent ? "text-red-200 font-semibold" : "text-zinc-400"}`}>
|
||||
{isUrgent
|
||||
? t("recap.card.stoppedFor", { min: ongoingStopMin })
|
||||
+ (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "")
|
||||
: footerText}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
445
components/settings/ReasonCatalogConfig.tsx
Normal file
445
components/settings/ReasonCatalogConfig.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type CatalogKind = "downtime" | "scrap";
|
||||
|
||||
type ApiItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
codeSuffix: string;
|
||||
reasonCode: string;
|
||||
sortOrder: number;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type ApiCategory = {
|
||||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
codePrefix: string;
|
||||
sortOrder: number;
|
||||
active: boolean;
|
||||
items: ApiItem[];
|
||||
};
|
||||
|
||||
const PREFIX_RE = /^[A-Za-z][A-Za-z0-9-]*$/;
|
||||
|
||||
/** Matches composeReasonCode in reasonCatalogDb (client-safe). */
|
||||
function formatPrintedPreview(prefix: string, digits: string): string {
|
||||
const p = String(prefix).trim().toUpperCase();
|
||||
const d = String(digits).trim();
|
||||
if (!d) return p.length >= 3 ? `${p}-…` : `${p}…`;
|
||||
if (/^\d+$/.test(d) && p.length >= 3) return `${p}-${d}`;
|
||||
return `${p}${d}`;
|
||||
}
|
||||
|
||||
async function readJson(res: Response) {
|
||||
const data = await res.json().catch(() => null);
|
||||
return data as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export function ReasonCatalogConfig({ disabled }: { disabled?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [catalogVersion, setCatalogVersion] = useState(1);
|
||||
const [categories, setCategories] = useState<ApiCategory[]>([]);
|
||||
const [kind, setKind] = useState<CatalogKind>("downtime");
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||
const [newCatName, setNewCatName] = useState("");
|
||||
const [newCatPrefix, setNewCatPrefix] = useState("");
|
||||
const [newDigits, setNewDigits] = useState("");
|
||||
const [newItemName, setNewItemName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [editCatName, setEditCatName] = useState("");
|
||||
const [editCatPrefix, setEditCatPrefix] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/reason-catalog");
|
||||
const data = await readJson(res);
|
||||
if (!res.ok || !data || data.ok !== true) {
|
||||
const msg = typeof data?.error === "string" ? data.error : "Load failed";
|
||||
throw new Error(msg);
|
||||
}
|
||||
setCatalogVersion(Number(data.catalogVersion ?? 1));
|
||||
setCategories(Array.isArray(data.categories) ? (data.categories as ApiCategory[]) : []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Load failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const forKind = useMemo(
|
||||
() => categories.filter((c) => String(c.kind).toLowerCase() === kind),
|
||||
[categories, kind]
|
||||
);
|
||||
|
||||
const selected = useMemo(
|
||||
() => forKind.find((c) => c.id === selectedCategoryId) ?? null,
|
||||
[forKind, selectedCategoryId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
setEditCatName("");
|
||||
setEditCatPrefix("");
|
||||
return;
|
||||
}
|
||||
setEditCatName(selected.name);
|
||||
setEditCatPrefix(selected.codePrefix);
|
||||
}, [selected?.id, selected?.name, selected?.codePrefix]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!forKind.length) {
|
||||
setSelectedCategoryId(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedCategoryId || !forKind.some((c) => c.id === selectedCategoryId)) {
|
||||
setSelectedCategoryId(forKind[0]?.id ?? null);
|
||||
}
|
||||
}, [forKind, selectedCategoryId]);
|
||||
|
||||
const onDigitsChange = (raw: string) => {
|
||||
setNewDigits(raw.replace(/\D/g, ""));
|
||||
};
|
||||
|
||||
const createCategory = async () => {
|
||||
const name = newCatName.trim();
|
||||
const codePrefix = newCatPrefix.trim().toUpperCase();
|
||||
if (!name || !codePrefix) return;
|
||||
if (!PREFIX_RE.test(codePrefix)) {
|
||||
setError(t("settings.reasonCatalog.prefixInvalid"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/reason-catalog/categories", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind, name, codePrefix }),
|
||||
});
|
||||
const data = await readJson(res);
|
||||
if (!res.ok || !data || data.ok !== true) {
|
||||
const msg = typeof data?.error === "string" ? data.error : "Create failed";
|
||||
throw new Error(msg);
|
||||
}
|
||||
setNewCatName("");
|
||||
setNewCatPrefix("");
|
||||
await load();
|
||||
const cat = data.category as { id?: string } | undefined;
|
||||
if (cat?.id) setSelectedCategoryId(cat.id);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Create failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addItem = async () => {
|
||||
if (!selected) return;
|
||||
const digits = newDigits.trim();
|
||||
const name = newItemName.trim();
|
||||
if (!digits || !name) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/reason-catalog/items", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ categoryId: selected.id, codeSuffix: digits, name }),
|
||||
});
|
||||
const data = await readJson(res);
|
||||
if (!res.ok || !data || data.ok !== true) {
|
||||
const msg = typeof data?.error === "string" ? data.error : "Create failed";
|
||||
throw new Error(msg);
|
||||
}
|
||||
setNewDigits("");
|
||||
setNewItemName("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Create failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const patchItem = async (itemId: string, patch: Record<string, unknown>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/settings/reason-catalog/items/${itemId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
const data = await readJson(res);
|
||||
if (!res.ok || !data || data.ok !== true) {
|
||||
const msg = typeof data?.error === "string" ? data.error : "Update failed";
|
||||
throw new Error(msg);
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Update failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const patchCategory = async (categoryId: string, patch: Record<string, unknown>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/settings/reason-catalog/categories/${categoryId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
const data = await readJson(res);
|
||||
if (!res.ok || !data || data.ok !== true) {
|
||||
const msg = typeof data?.error === "string" ? data.error : "Update failed";
|
||||
throw new Error(msg);
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Update failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
"mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-xs text-white placeholder:text-zinc-600";
|
||||
|
||||
const kindBtn = (k: CatalogKind, label: string) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || busy}
|
||||
onClick={() => setKind(k)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
|
||||
kind === k ? "bg-emerald-500/25 text-emerald-100 ring-1 ring-emerald-400/40" : "bg-black/30 text-zinc-400 hover:bg-white/5"
|
||||
} disabled:opacity-40`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
{t("settings.reasonCatalog.dbVersionHint", { version: catalogVersion })}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || busy || loading}
|
||||
onClick={() => void load()}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-[11px] text-white hover:bg-white/10 disabled:opacity-40"
|
||||
>
|
||||
{t("settings.reasonCatalog.reload")}
|
||||
</button>
|
||||
</div>
|
||||
{loading ? <p className="mt-2 text-xs text-zinc-500">{t("settings.loading")}</p> : null}
|
||||
{error ? (
|
||||
<p className="mt-2 rounded-lg border border-red-500/30 bg-red-500/10 px-2 py-1.5 text-xs text-red-200">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-xs font-semibold text-zinc-300">{t("settings.reasonCatalog.stepKind")}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{kindBtn("downtime", t("settings.reasonCatalog.downtime"))}
|
||||
{kindBtn("scrap", t("settings.reasonCatalog.scrap"))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-xs font-semibold text-zinc-300">{t("settings.reasonCatalog.stepCategory")}</div>
|
||||
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<label className="min-w-[200px] flex-1 text-[11px] text-zinc-400">
|
||||
{t("settings.reasonCatalog.pickCategory")}
|
||||
<select
|
||||
disabled={disabled || busy || !forKind.length}
|
||||
value={selectedCategoryId ?? ""}
|
||||
onChange={(e) => setSelectedCategoryId(e.target.value || null)}
|
||||
className={`${inputCls} cursor-pointer`}
|
||||
>
|
||||
{!forKind.length ? <option value="">{t("settings.reasonCatalog.emptyKind")}</option> : null}
|
||||
{forKind.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.codePrefix}){c.active ? "" : ` — ${t("settings.reasonCatalog.inactive")}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<div className="mt-4 grid gap-3 rounded-lg border border-white/5 bg-black/30 p-3 sm:grid-cols-2">
|
||||
<label className="text-[11px] text-zinc-400">
|
||||
{t("settings.reasonCatalog.categoryNameEdit")}
|
||||
<input
|
||||
disabled={disabled || busy}
|
||||
value={editCatName}
|
||||
onChange={(e) => setEditCatName(e.target.value)}
|
||||
onBlur={() => {
|
||||
const n = editCatName.trim();
|
||||
if (n && n !== selected.name) void patchCategory(selected.id, { name: n });
|
||||
}}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-[11px] text-zinc-400">
|
||||
{t("settings.reasonCatalog.codePrefixEdit")}
|
||||
<input
|
||||
disabled={disabled || busy}
|
||||
value={editCatPrefix}
|
||||
onChange={(e) => setEditCatPrefix(e.target.value.toUpperCase())}
|
||||
onBlur={() => {
|
||||
const v = editCatPrefix.trim().toUpperCase();
|
||||
if (!v || !PREFIX_RE.test(v)) {
|
||||
setEditCatPrefix(selected.codePrefix);
|
||||
return;
|
||||
}
|
||||
if (v !== selected.codePrefix) void patchCategory(selected.id, { codePrefix: v });
|
||||
}}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-[11px] text-zinc-400 sm:col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={disabled || busy}
|
||||
checked={selected.active}
|
||||
onChange={(e) => void patchCategory(selected.id, { active: e.target.checked })}
|
||||
className="h-3.5 w-3.5 rounded border border-white/20 bg-black/20"
|
||||
/>
|
||||
{t("settings.reasonCatalog.categoryActive")}
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 border-t border-white/5 pt-4">
|
||||
<div className="text-[11px] font-semibold text-zinc-400">{t("settings.reasonCatalog.newCategorySection")}</div>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<label className="text-[11px] text-zinc-400">
|
||||
{t("settings.reasonCatalog.categoryLabel")}
|
||||
<input
|
||||
disabled={disabled || busy}
|
||||
value={newCatName}
|
||||
onChange={(e) => setNewCatName(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-[11px] text-zinc-400">
|
||||
{t("settings.reasonCatalog.codePrefixField")}
|
||||
<input
|
||||
disabled={disabled || busy}
|
||||
value={newCatPrefix}
|
||||
onChange={(e) => setNewCatPrefix(e.target.value.toUpperCase())}
|
||||
placeholder="DTPRC"
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || busy || !newCatName.trim() || !newCatPrefix.trim()}
|
||||
onClick={() => void createCategory()}
|
||||
className="mt-2 rounded-lg border border-emerald-400/30 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-100 hover:bg-emerald-500/25 disabled:opacity-40"
|
||||
>
|
||||
{t("settings.reasonCatalog.addCategory")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-xs font-semibold text-zinc-300">{t("settings.reasonCatalog.stepReason")}</div>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">{t("settings.reasonCatalog.digitsOnlyHint")}</p>
|
||||
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||
<div className="text-[11px] text-zinc-400">
|
||||
<span className="block text-zinc-500">{t("settings.reasonCatalog.fullCodePreview")}</span>
|
||||
<span className="mt-1 inline-flex min-h-[2rem] items-center rounded-lg border border-white/10 bg-black/40 px-3 font-mono text-sm text-emerald-200">
|
||||
{formatPrintedPreview(selected.codePrefix, newDigits)}
|
||||
</span>
|
||||
</div>
|
||||
<label className="w-32 text-[11px] text-zinc-400">
|
||||
{t("settings.reasonCatalog.numericSuffix")}
|
||||
<input
|
||||
disabled={disabled || busy}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={newDigits}
|
||||
onChange={(e) => onDigitsChange(e.target.value)}
|
||||
placeholder="01"
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-[180px] flex-1 text-[11px] text-zinc-400">
|
||||
{t("settings.reasonCatalog.detailLabel")}
|
||||
<input
|
||||
disabled={disabled || busy}
|
||||
value={newItemName}
|
||||
onChange={(e) => setNewItemName(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || busy || !newDigits.trim() || !newItemName.trim()}
|
||||
onClick={() => void addItem()}
|
||||
className="rounded-lg border border-emerald-400/30 bg-emerald-500/15 px-3 py-2 text-xs text-emerald-100 hover:bg-emerald-500/25 disabled:opacity-40"
|
||||
>
|
||||
{t("settings.reasonCatalog.addReason")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="text-[11px] font-semibold text-zinc-500">{t("settings.reasonCatalog.reasonsInCategory")}</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{selected.items.length === 0 ? (
|
||||
<div className="text-xs text-zinc-500">{t("settings.reasonCatalog.noItemsYet")}</div>
|
||||
) : (
|
||||
selected.items.map((it) => (
|
||||
<div
|
||||
key={it.id}
|
||||
className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border border-white/5 px-3 py-2 ${
|
||||
it.active ? "bg-black/30" : "bg-black/10 opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="font-mono text-xs text-emerald-200">{it.reasonCode}</div>
|
||||
<div className="min-w-0 flex-1 truncate text-xs text-white">{it.name}</div>
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-zinc-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={disabled || busy}
|
||||
checked={it.active}
|
||||
onChange={(e) => void patchItem(it.id, { active: e.target.checked })}
|
||||
className="h-3.5 w-3.5 rounded border border-white/20 bg-black/20"
|
||||
/>
|
||||
{t("settings.reasonCatalog.active")}
|
||||
</label>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-zinc-500">{t("settings.reasonCatalog.hint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4288
flows_may_4_26.json
Normal file
4288
flows_may_4_26.json
Normal file
File diff suppressed because one or more lines are too long
@@ -38,6 +38,7 @@ type AlertsInboxEvent = {
|
||||
status?: string | null;
|
||||
shift?: string | null;
|
||||
alertId?: string | null;
|
||||
incidentKey?: string | null;
|
||||
isUpdate?: boolean;
|
||||
isAutoAck?: boolean;
|
||||
};
|
||||
@@ -224,29 +225,34 @@ function resolveShift(
|
||||
}
|
||||
|
||||
function collapseAlertEvents(events: AlertsInboxEvent[]) {
|
||||
const byAlert = new Map<string, AlertsInboxEvent>();
|
||||
// Group by incidentKey (preferred — stable across the entire incident lifecycle)
|
||||
// OR alertId (fallback — for older or non-stoppage events).
|
||||
// Per group, keep AT MOST one "active" (oldest = when it first happened) and
|
||||
// one "resolved" (newest = when it actually ended). Result: max 2 entries per incident.
|
||||
const byGroup = new Map<string, AlertsInboxEvent>();
|
||||
const passthrough: AlertsInboxEvent[] = [];
|
||||
|
||||
for (const ev of events) {
|
||||
if (!ev.alertId) {
|
||||
const groupId = ev.incidentKey ?? ev.alertId;
|
||||
if (!groupId) {
|
||||
passthrough.push(ev);
|
||||
continue;
|
||||
}
|
||||
const statusKey = ev.status === "resolved" ? "resolved" : "active";
|
||||
const key = `${ev.alertId}:${statusKey}`;
|
||||
const existing = byAlert.get(key);
|
||||
const key = `${groupId}:${statusKey}`;
|
||||
const existing = byGroup.get(key);
|
||||
if (!existing) {
|
||||
byAlert.set(key, ev);
|
||||
byGroup.set(key, ev);
|
||||
continue;
|
||||
}
|
||||
const pickNewest = statusKey === "resolved";
|
||||
const shouldReplace = pickNewest
|
||||
? ev.ts.getTime() > existing.ts.getTime()
|
||||
: ev.ts.getTime() < existing.ts.getTime();
|
||||
if (shouldReplace) byAlert.set(key, ev);
|
||||
if (shouldReplace) byGroup.set(key, ev);
|
||||
}
|
||||
|
||||
const combined = [...passthrough, ...byAlert.values()];
|
||||
const combined = [...passthrough, ...byGroup.values()];
|
||||
combined.sort((a, b) => b.ts.getTime() - a.ts.getTime());
|
||||
return combined;
|
||||
}
|
||||
@@ -325,7 +331,12 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
const rawStatus = safeString(payload?.status ?? inner?.status);
|
||||
const isUpdate = safeBool(payload?.is_update ?? inner?.is_update);
|
||||
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
||||
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
|
||||
// Drop only auto-ack pings (every-10s refresh noise).
|
||||
// Keep is_update events: due to a Node-RED spread inheritance pattern,
|
||||
// virtually all events carry is_update=true even legitimate first-emission
|
||||
// and cycle-arrival resolved events. Dedup happens via collapseAlertEvents
|
||||
// grouping by incidentKey below.
|
||||
if (!includeUpdates && isAutoAck) continue;
|
||||
|
||||
const shiftName = resolveShift(shifts, shiftOverrides, ev.ts, timeZone);
|
||||
if (normalizedShift && shiftName !== normalizedShift) continue;
|
||||
@@ -349,6 +360,7 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
status: statusLabel,
|
||||
shift: shiftName,
|
||||
alertId: safeString(payload?.alert_id ?? inner?.alert_id),
|
||||
incidentKey: safeString(payload?.incidentKey ?? payload?.incident_key ?? inner?.incidentKey ?? inner?.incident_key),
|
||||
isUpdate,
|
||||
isAutoAck,
|
||||
});
|
||||
|
||||
363
lib/alerts/getAlertsInboxData.ts.bak
Normal file
363
lib/alerts/getAlertsInboxData.ts.bak
Normal file
@@ -0,0 +1,363 @@
|
||||
import { normalizeShiftOverrides } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const RANGE_MS: Record<string, number> = {
|
||||
"24h": 24 * 60 * 60 * 1000,
|
||||
"7d": 7 * 24 * 60 * 60 * 1000,
|
||||
"30d": 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
type AlertsInboxParams = {
|
||||
orgId: string;
|
||||
range?: string;
|
||||
start?: Date | null;
|
||||
end?: Date | null;
|
||||
machineId?: string;
|
||||
location?: string;
|
||||
eventType?: string;
|
||||
severity?: string;
|
||||
status?: string;
|
||||
shift?: string;
|
||||
includeUpdates?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
type AlertsInboxEvent = {
|
||||
id: string;
|
||||
ts: Date;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
machineId: string;
|
||||
machineName?: string | null;
|
||||
location?: string | null;
|
||||
workOrderId?: string | null;
|
||||
sku?: string | null;
|
||||
durationSec?: number | null;
|
||||
status?: string | null;
|
||||
shift?: string | null;
|
||||
alertId?: string | null;
|
||||
isUpdate?: boolean;
|
||||
isAutoAck?: boolean;
|
||||
};
|
||||
|
||||
function pickRange(range: string, start?: Date | null, end?: Date | null) {
|
||||
const now = new Date();
|
||||
if (range === "custom") {
|
||||
const startFallback = new Date(now.getTime() - RANGE_MS["24h"]);
|
||||
return {
|
||||
range,
|
||||
start: start ?? startFallback,
|
||||
end: end ?? now,
|
||||
};
|
||||
}
|
||||
const ms = RANGE_MS[range] ?? RANGE_MS["24h"];
|
||||
return { range, start: new Date(now.getTime() - ms), end: now };
|
||||
}
|
||||
|
||||
function safeString(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function safeNumber(value: unknown) {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function safeBool(value: unknown) {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function normalizeStatus(value?: string | null) {
|
||||
if (!value) return null;
|
||||
const raw = value.trim().toLowerCase();
|
||||
if (!raw) return null;
|
||||
if (raw === "in_progress" || raw === "in-progress" || raw === "open" || raw === "activa" || raw === "activo") {
|
||||
return "active";
|
||||
}
|
||||
if (raw === "resuelta" || raw === "resuelto" || raw === "closed" || raw === "ended" || raw === "done") {
|
||||
return "resolved";
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parsePayload(raw: unknown) {
|
||||
let parsed: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = raw;
|
||||
}
|
||||
}
|
||||
const payload =
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const innerCandidate = payload.data;
|
||||
const inner =
|
||||
innerCandidate && typeof innerCandidate === "object" && !Array.isArray(innerCandidate)
|
||||
? (innerCandidate as Record<string, unknown>)
|
||||
: payload;
|
||||
return { payload, inner };
|
||||
}
|
||||
|
||||
function extractDurationSec(raw: unknown) {
|
||||
const { payload, inner } = parsePayload(raw);
|
||||
const candidates = [
|
||||
inner?.duration_seconds,
|
||||
inner?.duration_sec,
|
||||
inner?.stoppage_duration_seconds,
|
||||
inner?.stop_duration_seconds,
|
||||
payload?.duration_seconds,
|
||||
payload?.duration_sec,
|
||||
payload?.stoppage_duration_seconds,
|
||||
payload?.stop_duration_seconds,
|
||||
];
|
||||
for (const val of candidates) {
|
||||
if (typeof val === "number" && Number.isFinite(val) && val >= 0) return val;
|
||||
}
|
||||
const msCandidates = [inner?.duration_ms, inner?.durationMs, payload?.duration_ms, payload?.durationMs];
|
||||
for (const val of msCandidates) {
|
||||
if (typeof val === "number" && Number.isFinite(val) && val >= 0) {
|
||||
return Math.round(val / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
const startMs = inner.start_ts ?? inner.startTs ?? payload.start_ts ?? payload.startTs ?? null;
|
||||
const endMs = inner.end_ts ?? inner.endTs ?? payload.end_ts ?? payload.endTs ?? null;
|
||||
if (typeof startMs === "number" && typeof endMs === "number" && endMs >= startMs) {
|
||||
return Math.round((endMs - startMs) / 1000);
|
||||
}
|
||||
|
||||
const actual = safeNumber(inner.actual_cycle_time ?? payload.actual_cycle_time);
|
||||
const theoretical = safeNumber(inner.theoretical_cycle_time ?? payload.theoretical_cycle_time);
|
||||
if (actual != null && theoretical != null) {
|
||||
return Math.max(0, actual - theoretical);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTimeMinutes(value?: string | null) {
|
||||
if (!value || !/^\d{2}:\d{2}$/.test(value)) return null;
|
||||
const [hh, mm] = value.split(":").map((n) => Number(n));
|
||||
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return null;
|
||||
return hh * 60 + mm;
|
||||
}
|
||||
|
||||
function getLocalMinutes(ts: Date, timeZone: string) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(ts);
|
||||
const hours = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
|
||||
const minutes = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
|
||||
return hours * 60 + minutes;
|
||||
} catch {
|
||||
return ts.getUTCHours() * 60 + ts.getUTCMinutes();
|
||||
}
|
||||
}
|
||||
|
||||
const WEEKDAY_KEY_MAP: Record<string, string> = {
|
||||
Sun: "sun",
|
||||
Mon: "mon",
|
||||
Tue: "tue",
|
||||
Wed: "wed",
|
||||
Thu: "thu",
|
||||
Fri: "fri",
|
||||
Sat: "sat",
|
||||
};
|
||||
|
||||
const WEEKDAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
|
||||
|
||||
function getLocalDayKey(ts: Date, timeZone: string) {
|
||||
try {
|
||||
const weekday = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
weekday: "short",
|
||||
}).format(ts);
|
||||
return WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()];
|
||||
} catch {
|
||||
return WEEKDAY_KEYS[ts.getUTCDay()];
|
||||
}
|
||||
}
|
||||
|
||||
type ShiftLike = {
|
||||
name: string;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
start?: string | null;
|
||||
end?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function resolveShift(
|
||||
shifts: ShiftLike[],
|
||||
overrides: Record<string, ShiftLike[]> | undefined,
|
||||
ts: Date,
|
||||
timeZone: string
|
||||
) {
|
||||
const dayKey = getLocalDayKey(ts, timeZone);
|
||||
const dayOverrides = overrides?.[dayKey];
|
||||
const activeShifts = dayOverrides ?? shifts;
|
||||
if (!activeShifts.length) return null;
|
||||
const nowMin = getLocalMinutes(ts, timeZone);
|
||||
for (const shift of activeShifts) {
|
||||
if (shift.enabled === false) continue;
|
||||
const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null);
|
||||
const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null);
|
||||
if (start == null || end == null) continue;
|
||||
if (start <= end) {
|
||||
if (nowMin >= start && nowMin < end) return shift.name;
|
||||
} else {
|
||||
if (nowMin >= start || nowMin < end) return shift.name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collapseAlertEvents(events: AlertsInboxEvent[]) {
|
||||
const byAlert = new Map<string, AlertsInboxEvent>();
|
||||
const passthrough: AlertsInboxEvent[] = [];
|
||||
|
||||
for (const ev of events) {
|
||||
if (!ev.alertId) {
|
||||
passthrough.push(ev);
|
||||
continue;
|
||||
}
|
||||
const statusKey = ev.status === "resolved" ? "resolved" : "active";
|
||||
const key = `${ev.alertId}:${statusKey}`;
|
||||
const existing = byAlert.get(key);
|
||||
if (!existing) {
|
||||
byAlert.set(key, ev);
|
||||
continue;
|
||||
}
|
||||
const pickNewest = statusKey === "resolved";
|
||||
const shouldReplace = pickNewest
|
||||
? ev.ts.getTime() > existing.ts.getTime()
|
||||
: ev.ts.getTime() < existing.ts.getTime();
|
||||
if (shouldReplace) byAlert.set(key, ev);
|
||||
}
|
||||
|
||||
const combined = [...passthrough, ...byAlert.values()];
|
||||
combined.sort((a, b) => b.ts.getTime() - a.ts.getTime());
|
||||
return combined;
|
||||
}
|
||||
|
||||
export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
const {
|
||||
orgId,
|
||||
range = "24h",
|
||||
start,
|
||||
end,
|
||||
machineId,
|
||||
location,
|
||||
eventType,
|
||||
severity,
|
||||
status,
|
||||
shift,
|
||||
includeUpdates = false,
|
||||
limit = 200,
|
||||
} = params;
|
||||
|
||||
const picked = pickRange(range, start, end);
|
||||
const normalizedStatus = safeString(status)?.toLowerCase();
|
||||
const normalizedShift = safeString(shift);
|
||||
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 500) : 200;
|
||||
|
||||
const where = {
|
||||
orgId,
|
||||
ts: { gte: picked.start, lte: picked.end },
|
||||
...(machineId ? { machineId } : {}),
|
||||
...(eventType ? { eventType } : {}),
|
||||
...(severity ? { severity } : {}),
|
||||
...(location ? { machine: { location } } : {}),
|
||||
};
|
||||
|
||||
const [events, shifts, settings] = await Promise.all([
|
||||
prisma.machineEvent.findMany({
|
||||
where,
|
||||
orderBy: { ts: "desc" },
|
||||
take: safeLimit,
|
||||
select: {
|
||||
id: true,
|
||||
ts: true,
|
||||
eventType: true,
|
||||
severity: true,
|
||||
title: true,
|
||||
description: true,
|
||||
data: true,
|
||||
machineId: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
machine: {
|
||||
select: {
|
||||
name: true,
|
||||
location: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.orgShift.findMany({
|
||||
where: { orgId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { name: true, startTime: true, endTime: true, enabled: true },
|
||||
}),
|
||||
prisma.orgSettings.findUnique({
|
||||
where: { orgId },
|
||||
select: { timezone: true, shiftScheduleOverridesJson: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const timeZone = settings?.timezone || "UTC";
|
||||
const shiftOverrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
|
||||
const mapped: AlertsInboxEvent[] = [];
|
||||
|
||||
for (const ev of events) {
|
||||
const { payload, inner } = parsePayload(ev.data);
|
||||
const rawStatus = safeString(payload?.status ?? inner?.status);
|
||||
const isUpdate = safeBool(payload?.is_update ?? inner?.is_update);
|
||||
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
||||
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
|
||||
|
||||
const shiftName = resolveShift(shifts, shiftOverrides, ev.ts, timeZone);
|
||||
if (normalizedShift && shiftName !== normalizedShift) continue;
|
||||
|
||||
const statusLabel = normalizeStatus(rawStatus) ?? "unknown";
|
||||
if (normalizedStatus && statusLabel !== normalizedStatus) continue;
|
||||
|
||||
mapped.push({
|
||||
id: ev.id,
|
||||
ts: ev.ts,
|
||||
eventType: ev.eventType,
|
||||
severity: ev.severity,
|
||||
title: ev.title,
|
||||
description: ev.description,
|
||||
machineId: ev.machineId,
|
||||
machineName: ev.machine?.name ?? null,
|
||||
location: ev.machine?.location ?? null,
|
||||
workOrderId: ev.workOrderId ?? null,
|
||||
sku: ev.sku ?? null,
|
||||
durationSec: extractDurationSec(ev.data),
|
||||
status: statusLabel,
|
||||
shift: shiftName,
|
||||
alertId: safeString(payload?.alert_id ?? inner?.alert_id),
|
||||
isUpdate,
|
||||
isAutoAck,
|
||||
});
|
||||
}
|
||||
|
||||
const finalEvents = includeUpdates ? mapped : collapseAlertEvents(mapped);
|
||||
|
||||
return {
|
||||
range: { range: picked.range, start: picked.start, end: picked.end },
|
||||
events: finalEvents,
|
||||
};
|
||||
}
|
||||
25
lib/auth/requireOrgAdminSession.ts
Normal file
25
lib/auth/requireOrgAdminSession.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
|
||||
export type OrgAdminSession = { orgId: string; userId: string };
|
||||
|
||||
export async function requireOrgAdminSession(): Promise<
|
||||
{ ok: true; session: OrgAdminSession } | { ok: false; response: NextResponse }
|
||||
> {
|
||||
const session = await requireSession();
|
||||
if (!session) {
|
||||
return {
|
||||
ok: false,
|
||||
response: 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 (membership?.role !== "OWNER" && membership?.role !== "ADMIN") {
|
||||
return { ok: false, response: NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }) };
|
||||
}
|
||||
return { ok: true, session: { orgId: session.orgId, userId: session.userId } };
|
||||
}
|
||||
@@ -111,7 +111,12 @@
|
||||
"overview.recap.cta": "Open daily recap",
|
||||
"recap.title": "Recap",
|
||||
"recap.subtitle": "Last 24h",
|
||||
"recap.card.stoppedFor": "Stopped for {min} min",
|
||||
"machines.status.stopped": "STOPPED",
|
||||
"machines.stoppedFor": "Stopped for {min} min",
|
||||
"recap.grid.title": "Machine recap",
|
||||
"recap.status.idle": "Idle",
|
||||
"recap.card.idle": "No active work order",
|
||||
"recap.grid.subtitle": "Last 24h · click to open details",
|
||||
"recap.grid.updatedAgo": "Updated {sec}s ago",
|
||||
"recap.grid.empty": "No machines match the current filters.",
|
||||
@@ -459,6 +464,7 @@
|
||||
"settings.tabs.alerts": "Alerts",
|
||||
"settings.tabs.financial": "Financial",
|
||||
"settings.tabs.team": "Team",
|
||||
"settings.tabs.reasonCatalog": "Downtime & scrap",
|
||||
"settings.loading": "Loading settings...",
|
||||
"settings.loadingTeam": "Loading team...",
|
||||
"settings.refresh": "Refresh",
|
||||
@@ -514,6 +520,46 @@
|
||||
"settings.thresholds.macroStoppage": "Macro stoppage multiplier",
|
||||
"settings.alerts": "Alerts",
|
||||
"settings.alertsSubtitle": "Choose which alerts to notify.",
|
||||
"settings.reasonCatalog.title": "Downtime and scrap catalogs",
|
||||
"settings.reasonCatalog.subtitle": "Catalogs are stored in MIS (categories + codes). Changes bump settings version so machines pick them up. Deactivate retired codes instead of deleting them.",
|
||||
"settings.reasonCatalog.version": "Catalog version",
|
||||
"settings.reasonCatalog.hint": "Increase version when you change codes so edge devices can detect updates. Use \"Active\" to hide a code from new selections while keeping history labels.",
|
||||
"settings.reasonCatalog.downtime": "Downtime (stops)",
|
||||
"settings.reasonCatalog.scrap": "Scrap",
|
||||
"settings.reasonCatalog.addCategory": "Add category",
|
||||
"settings.reasonCatalog.emptyKind": "No categories yet.",
|
||||
"settings.reasonCatalog.categoryId": "Category id",
|
||||
"settings.reasonCatalog.categoryLabel": "Category name",
|
||||
"settings.reasonCatalog.reasons": "Reasons",
|
||||
"settings.reasonCatalog.addReason": "Add reason",
|
||||
"settings.reasonCatalog.removeCategory": "Remove category",
|
||||
"settings.reasonCatalog.detailId": "Detail id",
|
||||
"settings.reasonCatalog.reasonCode": "Printed code",
|
||||
"settings.reasonCatalog.detailLabel": "Description",
|
||||
"settings.reasonCatalog.active": "Active",
|
||||
"settings.reasonCatalog.removeRow": "Remove",
|
||||
"settings.reasonCatalog.removeDetailHint": "Prefer deactivating codes that were already used in production.",
|
||||
"settings.reasonCatalog.newCategory": "New category",
|
||||
"settings.reasonCatalog.newReason": "New reason",
|
||||
"settings.reasonCatalog.dbVersionHint": "Settings version (includes catalog): {version}",
|
||||
"settings.reasonCatalog.reload": "Reload",
|
||||
"settings.reasonCatalog.stepKind": "1. Catalog type",
|
||||
"settings.reasonCatalog.stepCategory": "2. Category and prefix",
|
||||
"settings.reasonCatalog.pickCategory": "Category",
|
||||
"settings.reasonCatalog.inactive": "inactive",
|
||||
"settings.reasonCatalog.categoryNameEdit": "Category name",
|
||||
"settings.reasonCatalog.codePrefixEdit": "Code prefix (letters; optional digits/hyphen after first letter)",
|
||||
"settings.reasonCatalog.categoryActive": "Category active",
|
||||
"settings.reasonCatalog.newCategorySection": "New category in this catalog type",
|
||||
"settings.reasonCatalog.codePrefixField": "Prefix (shown before the number)",
|
||||
"settings.reasonCatalog.stepReason": "3. Add reason (numbers only)",
|
||||
"settings.reasonCatalog.digitsOnlyHint": "Enter only the numeric part; the full printed code is prefix + number.",
|
||||
"settings.reasonCatalog.fullCodePreview": "Printed code",
|
||||
"settings.reasonCatalog.numericSuffix": "Number",
|
||||
"settings.reasonCatalog.reasonsInCategory": "Reasons in this category",
|
||||
"settings.reasonCatalog.noItemsYet": "No reasons yet.",
|
||||
"settings.reasonCatalog.prefixInvalid": "Prefix must start with a letter and use letters, digits, or hyphen.",
|
||||
|
||||
"settings.alerts.oeeDrop": "OEE drop alerts",
|
||||
"settings.alerts.oeeDropHelper": "Notify when OEE falls below threshold",
|
||||
"settings.alerts.performanceDegradation": "Performance degradation alerts",
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
"overview.recap.cta": "Abrir resumen diario",
|
||||
"recap.title": "Resumen",
|
||||
"recap.subtitle": "Últimas 24h",
|
||||
"recap.card.stoppedFor": "Detenida hace {min} min",
|
||||
"machines.status.stopped": "DETENIDA",
|
||||
"machines.stoppedFor": "Detenida hace {min} min",
|
||||
"recap.status.idle": "Inactiva",
|
||||
"recap.card.idle": "Sin orden de trabajo activa",
|
||||
"recap.grid.title": "Resumen de máquinas",
|
||||
"recap.grid.subtitle": "Últimas 24h · click para ver detalle",
|
||||
"recap.grid.updatedAgo": "Actualizado hace {sec}s",
|
||||
@@ -466,6 +471,7 @@
|
||||
"settings.tabs.alerts": "Alertas",
|
||||
"settings.tabs.financial": "Finanzas",
|
||||
"settings.tabs.team": "Equipo",
|
||||
"settings.tabs.reasonCatalog": "Paros y scrap",
|
||||
"settings.loading": "Cargando configuración...",
|
||||
"settings.loadingTeam": "Cargando equipo...",
|
||||
"settings.refresh": "Actualizar",
|
||||
@@ -521,6 +527,46 @@
|
||||
"settings.thresholds.macroStoppage": "Multiplicador de macroparo",
|
||||
"settings.alerts": "Alertas",
|
||||
"settings.alertsSubtitle": "Elige qué alertas notificar.",
|
||||
"settings.reasonCatalog.title": "Catálogos de paros y scrap",
|
||||
"settings.reasonCatalog.subtitle": "Los catálogos viven en MIS (categorías y códigos). Los cambios suben la versión de ajustes para que las máquinas los reciban. Desactiva códigos retirados en lugar de borrarlos.",
|
||||
"settings.reasonCatalog.version": "Versión del catálogo",
|
||||
"settings.reasonCatalog.hint": "Sube la versión cuando cambies códigos para que el borde detecte actualizaciones. Usa \"Activo\" para ocultar un código en nuevas capturas sin perder etiquetas en histórico.",
|
||||
"settings.reasonCatalog.downtime": "Tiempo muerto (paros)",
|
||||
"settings.reasonCatalog.scrap": "Scrap",
|
||||
"settings.reasonCatalog.addCategory": "Agregar categoría",
|
||||
"settings.reasonCatalog.emptyKind": "Aún no hay categorías.",
|
||||
"settings.reasonCatalog.categoryId": "Id de categoría",
|
||||
"settings.reasonCatalog.categoryLabel": "Nombre de categoría",
|
||||
"settings.reasonCatalog.reasons": "Razones",
|
||||
"settings.reasonCatalog.addReason": "Agregar razón",
|
||||
"settings.reasonCatalog.removeCategory": "Quitar categoría",
|
||||
"settings.reasonCatalog.detailId": "Id del detalle",
|
||||
"settings.reasonCatalog.reasonCode": "Código impreso",
|
||||
"settings.reasonCatalog.detailLabel": "Descripción",
|
||||
"settings.reasonCatalog.active": "Activo",
|
||||
"settings.reasonCatalog.removeRow": "Quitar",
|
||||
"settings.reasonCatalog.removeDetailHint": "Para códigos ya usados en producción, preferir desactivar en lugar de quitar la fila.",
|
||||
"settings.reasonCatalog.newCategory": "Nueva categoría",
|
||||
"settings.reasonCatalog.newReason": "Nueva razón",
|
||||
"settings.reasonCatalog.dbVersionHint": "Versión de ajustes (incluye catálogo): {version}",
|
||||
"settings.reasonCatalog.reload": "Recargar",
|
||||
"settings.reasonCatalog.stepKind": "1. Tipo de catálogo",
|
||||
"settings.reasonCatalog.stepCategory": "2. Categoría y prefijo",
|
||||
"settings.reasonCatalog.pickCategory": "Categoría",
|
||||
"settings.reasonCatalog.inactive": "inactiva",
|
||||
"settings.reasonCatalog.categoryNameEdit": "Nombre de categoría",
|
||||
"settings.reasonCatalog.codePrefixEdit": "Prefijo de código (letras; opcional dígitos o guión después de la primera letra)",
|
||||
"settings.reasonCatalog.categoryActive": "Categoría activa",
|
||||
"settings.reasonCatalog.newCategorySection": "Nueva categoría en este tipo de catálogo",
|
||||
"settings.reasonCatalog.codePrefixField": "Prefijo (se muestra antes del número)",
|
||||
"settings.reasonCatalog.stepReason": "3. Agregar razón (solo números)",
|
||||
"settings.reasonCatalog.digitsOnlyHint": "Captura solo la parte numérica; el código impreso completo es prefijo + número.",
|
||||
"settings.reasonCatalog.fullCodePreview": "Código impreso",
|
||||
"settings.reasonCatalog.numericSuffix": "Número",
|
||||
"settings.reasonCatalog.reasonsInCategory": "Razones en esta categoría",
|
||||
"settings.reasonCatalog.noItemsYet": "Aún no hay razones.",
|
||||
"settings.reasonCatalog.prefixInvalid": "El prefijo debe empezar con letra y usar letras, dígitos o guión.",
|
||||
|
||||
"settings.alerts.oeeDrop": "Alertas por caída de OEE",
|
||||
"settings.alerts.oeeDropHelper": "Notificar cuando OEE esté por debajo del umbral",
|
||||
"settings.alerts.performanceDegradation": "Alertas por baja de Performance",
|
||||
|
||||
@@ -31,6 +31,15 @@ type LatestKpiRow = {
|
||||
cycleTime?: number | null;
|
||||
};
|
||||
|
||||
export type LatestMacrostopRow = {
|
||||
machineId: string;
|
||||
ts: Date;
|
||||
status: "active" | "resolved" | "unknown";
|
||||
startedAtMs: number;
|
||||
};
|
||||
|
||||
const MACROSTOP_LOOKBACK_MS = 5 * 60 * 1000;
|
||||
|
||||
export async function fetchMachineBase(orgId: string): Promise<MachineBaseRow[]> {
|
||||
return prisma.machine.findMany({
|
||||
where: { orgId },
|
||||
@@ -93,20 +102,75 @@ export async function fetchLatestKpis(
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchLatestMacrostops(
|
||||
orgId: string,
|
||||
machineIds: string[]
|
||||
): Promise<LatestMacrostopRow[]> {
|
||||
if (!machineIds.length) return [];
|
||||
|
||||
const rows = await prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
machineId: { in: machineIds },
|
||||
eventType: "macrostop",
|
||||
ts: { gte: new Date(Date.now() - MACROSTOP_LOOKBACK_MS) },
|
||||
},
|
||||
orderBy: [{ machineId: "asc" }, { ts: "desc" }],
|
||||
select: { machineId: true, ts: true, data: true },
|
||||
});
|
||||
|
||||
const byMachine = new Map<string, LatestMacrostopRow>();
|
||||
for (const row of rows) {
|
||||
if (byMachine.has(row.machineId)) continue;
|
||||
|
||||
let parsed: unknown = row.data;
|
||||
if (typeof parsed === "string") {
|
||||
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
|
||||
}
|
||||
const data: Record<string, unknown> =
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const isAutoAck =
|
||||
data.is_auto_ack === true || data.isAutoAck === true ||
|
||||
data.is_auto_ack === "true" || data.isAutoAck === "true";
|
||||
if (isAutoAck) continue;
|
||||
|
||||
const rawStatus = String(data.status ?? "").trim().toLowerCase();
|
||||
const status: LatestMacrostopRow["status"] =
|
||||
rawStatus === "active" ? "active" : rawStatus === "resolved" ? "resolved" : "unknown";
|
||||
|
||||
const lastCycleTs = Number(data.last_cycle_timestamp);
|
||||
const startedAtMs = Number.isFinite(lastCycleTs) && lastCycleTs > 0
|
||||
? lastCycleTs
|
||||
: row.ts.getTime();
|
||||
|
||||
byMachine.set(row.machineId, { machineId: row.machineId, ts: row.ts, status, startedAtMs });
|
||||
}
|
||||
|
||||
return Array.from(byMachine.values());
|
||||
}
|
||||
|
||||
|
||||
export function mergeMachineOverviewRows(params: {
|
||||
machines: MachineBaseRow[];
|
||||
heartbeats: LatestHeartbeatRow[];
|
||||
kpis?: LatestKpiRow[];
|
||||
macrostops?: LatestMacrostopRow[];
|
||||
includeKpi?: boolean;
|
||||
}): OverviewMachineRow[] {
|
||||
const { machines, heartbeats, kpis = [], includeKpi = false } = params;
|
||||
const { machines, heartbeats, kpis = [], macrostops = [], includeKpi = false } = params;
|
||||
const heartbeatMap = new Map(heartbeats.map((row) => [row.machineId, row]));
|
||||
const kpiMap = new Map(kpis.map((row) => [row.machineId, row]));
|
||||
const macrostopMap = new Map(macrostops.map((row) => [row.machineId, row]));
|
||||
|
||||
|
||||
return machines.map((machine) => ({
|
||||
...machine,
|
||||
latestHeartbeat: (heartbeatMap.get(machine.id) ?? null) as OverviewMachineRow["latestHeartbeat"],
|
||||
latestKpi: includeKpi ? (kpiMap.get(machine.id) ?? null) : null,
|
||||
latestMacrostop: macrostopMap.get(machine.id) ?? null,
|
||||
heartbeats: undefined,
|
||||
kpiSnapshots: undefined,
|
||||
}));
|
||||
|
||||
113
lib/machines/withLatest.ts.bak
Normal file
113
lib/machines/withLatest.ts.bak
Normal file
@@ -0,0 +1,113 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { OverviewMachineRow } from "@/lib/overview/types";
|
||||
|
||||
type MachineBaseRow = Pick<
|
||||
OverviewMachineRow,
|
||||
"id" | "name" | "code" | "location" | "createdAt" | "updatedAt"
|
||||
>;
|
||||
|
||||
type LatestHeartbeatRow = {
|
||||
machineId: string;
|
||||
ts: Date;
|
||||
tsServer: Date | null;
|
||||
status: string;
|
||||
message?: string | null;
|
||||
ip?: string | null;
|
||||
fwVersion?: string | null;
|
||||
};
|
||||
|
||||
type LatestKpiRow = {
|
||||
machineId: string;
|
||||
ts: Date;
|
||||
oee?: number | null;
|
||||
availability?: number | null;
|
||||
performance?: number | null;
|
||||
quality?: number | null;
|
||||
workOrderId?: string | null;
|
||||
sku?: string | null;
|
||||
good?: number | null;
|
||||
scrap?: number | null;
|
||||
target?: number | null;
|
||||
cycleTime?: number | null;
|
||||
};
|
||||
|
||||
export async function fetchMachineBase(orgId: string): Promise<MachineBaseRow[]> {
|
||||
return prisma.machine.findMany({
|
||||
where: { orgId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
location: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchLatestHeartbeats(
|
||||
orgId: string,
|
||||
machineIds: string[]
|
||||
): Promise<LatestHeartbeatRow[]> {
|
||||
if (!machineIds.length) return [];
|
||||
return prisma.machineHeartbeat.findMany({
|
||||
where: { orgId, machineId: { in: machineIds } },
|
||||
orderBy: [{ machineId: "asc" }, { tsServer: "desc" }],
|
||||
distinct: ["machineId"],
|
||||
select: {
|
||||
machineId: true,
|
||||
ts: true,
|
||||
tsServer: true,
|
||||
status: true,
|
||||
message: true,
|
||||
ip: true,
|
||||
fwVersion: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchLatestKpis(
|
||||
orgId: string,
|
||||
machineIds: string[]
|
||||
): Promise<LatestKpiRow[]> {
|
||||
if (!machineIds.length) return [];
|
||||
return prisma.machineKpiSnapshot.findMany({
|
||||
where: { orgId, machineId: { in: machineIds } },
|
||||
orderBy: [{ machineId: "asc" }, { ts: "desc" }],
|
||||
distinct: ["machineId"],
|
||||
select: {
|
||||
machineId: true,
|
||||
ts: true,
|
||||
oee: true,
|
||||
availability: true,
|
||||
performance: true,
|
||||
quality: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
good: true,
|
||||
scrap: true,
|
||||
target: true,
|
||||
cycleTime: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeMachineOverviewRows(params: {
|
||||
machines: MachineBaseRow[];
|
||||
heartbeats: LatestHeartbeatRow[];
|
||||
kpis?: LatestKpiRow[];
|
||||
includeKpi?: boolean;
|
||||
}): OverviewMachineRow[] {
|
||||
const { machines, heartbeats, kpis = [], includeKpi = false } = params;
|
||||
const heartbeatMap = new Map(heartbeats.map((row) => [row.machineId, row]));
|
||||
const kpiMap = new Map(kpis.map((row) => [row.machineId, row]));
|
||||
|
||||
return machines.map((machine) => ({
|
||||
...machine,
|
||||
latestHeartbeat: (heartbeatMap.get(machine.id) ?? null) as OverviewMachineRow["latestHeartbeat"],
|
||||
latestKpi: includeKpi ? (kpiMap.get(machine.id) ?? null) : null,
|
||||
heartbeats: undefined,
|
||||
kpiSnapshots: undefined,
|
||||
}));
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
export type ReasonCatalogKind = "downtime" | "scrap";
|
||||
@@ -8,6 +5,10 @@ export type ReasonCatalogKind = "downtime" | "scrap";
|
||||
export type ReasonCatalogDetail = {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Official code (e.g. DTPRC-01, MX001). When set, used as reasonCode instead of slug. */
|
||||
reasonCode?: string;
|
||||
/** When false, hidden from operator pickers but kept for historical label resolution. Default true. */
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export type ReasonCatalogCategory = {
|
||||
@@ -22,6 +23,11 @@ export type ReasonCatalog = {
|
||||
scrap: ReasonCatalogCategory[];
|
||||
};
|
||||
|
||||
export type FlattenReasonCatalogOptions = {
|
||||
/** If true, omit details with active === false (operator / tactile UI). */
|
||||
activeOnly?: boolean;
|
||||
};
|
||||
|
||||
function isPlainObject(value: unknown): value is AnyRecord {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -40,6 +46,17 @@ function buildReasonCode(categoryId: string, detailId: string) {
|
||||
return `${canonicalId(categoryId)}__${canonicalId(detailId)}`.toUpperCase();
|
||||
}
|
||||
|
||||
/** Uppercase official or derived code for this detail row. */
|
||||
export function detailEffectiveReasonCode(category: ReasonCatalogCategory, detail: ReasonCatalogDetail): string {
|
||||
const explicit = String(detail.reasonCode ?? "").trim();
|
||||
if (explicit) return explicit.toUpperCase();
|
||||
return buildReasonCode(category.id, detail.id);
|
||||
}
|
||||
|
||||
export function isDetailActive(detail: ReasonCatalogDetail): boolean {
|
||||
return detail.active !== false;
|
||||
}
|
||||
|
||||
function toCategory(raw: unknown): ReasonCatalogCategory | null {
|
||||
if (!isPlainObject(raw)) return null;
|
||||
const labelRaw = String(raw.label ?? "").trim();
|
||||
@@ -57,7 +74,16 @@ function toCategory(raw: unknown): ReasonCatalogCategory | null {
|
||||
const detailLabel = String(detailRaw.label ?? "").trim();
|
||||
if (!detailLabel) continue;
|
||||
const detailId = String(detailRaw.id ?? "").trim() || canonicalId(detailLabel, "detail");
|
||||
details.push({ id: detailId, label: detailLabel });
|
||||
const reasonCodeRaw = detailRaw.reasonCode ?? detailRaw.code;
|
||||
const reasonCode =
|
||||
reasonCodeRaw != null && String(reasonCodeRaw).trim() ? String(reasonCodeRaw).trim() : undefined;
|
||||
const active = detailRaw.active === false ? false : true;
|
||||
details.push({
|
||||
id: detailId,
|
||||
label: detailLabel,
|
||||
...(reasonCode ? { reasonCode } : {}),
|
||||
...(active ? {} : { active: false }),
|
||||
});
|
||||
}
|
||||
|
||||
if (!details.length) return null;
|
||||
@@ -131,7 +157,7 @@ export function parseReasonCatalogMarkdown(markdown: string): ReasonCatalog {
|
||||
details: [] as ReasonCatalogDetail[],
|
||||
};
|
||||
if (!existing.details.some((d) => d.id === detailId)) {
|
||||
existing.details.push({ id: detailId, label: detailLabel });
|
||||
existing.details.push({ id: detailId, label: detailLabel, active: true });
|
||||
}
|
||||
buckets[activeKind].set(categoryId, existing);
|
||||
}
|
||||
@@ -143,31 +169,37 @@ export function parseReasonCatalogMarkdown(markdown: string): ReasonCatalog {
|
||||
};
|
||||
}
|
||||
|
||||
let catalogPromise: Promise<ReasonCatalog> | null = null;
|
||||
|
||||
export async function loadFallbackReasonCatalog() {
|
||||
if (!catalogPromise) {
|
||||
catalogPromise = readFile(path.join(process.cwd(), "downtime_menu.md"), "utf8")
|
||||
.then((raw) => parseReasonCatalogMarkdown(raw))
|
||||
.catch(() => ({ version: 1, downtime: [], scrap: [] }));
|
||||
}
|
||||
return catalogPromise;
|
||||
}
|
||||
|
||||
export function flattenReasonCatalog(catalog: ReasonCatalog, kind: ReasonCatalogKind) {
|
||||
export function flattenReasonCatalog(
|
||||
catalog: ReasonCatalog,
|
||||
kind: ReasonCatalogKind,
|
||||
options?: FlattenReasonCatalogOptions
|
||||
) {
|
||||
const activeOnly = options?.activeOnly === true;
|
||||
return (catalog[kind] ?? []).flatMap((category) =>
|
||||
category.details.map((detail) => ({
|
||||
category.details
|
||||
.filter((d) => !activeOnly || isDetailActive(d))
|
||||
.map((detail) => ({
|
||||
kind,
|
||||
categoryId: category.id,
|
||||
categoryLabel: category.label,
|
||||
detailId: detail.id,
|
||||
detailLabel: detail.label,
|
||||
reasonCode: buildReasonCode(category.id, detail.id),
|
||||
reasonCode: detailEffectiveReasonCode(category, detail),
|
||||
reasonLabel: `${category.label} > ${detail.label}`,
|
||||
active: isDetailActive(detail),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalText(value: unknown) {
|
||||
return String(value ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function findCatalogReason(
|
||||
catalog: ReasonCatalog | null | undefined,
|
||||
kind: ReasonCatalogKind,
|
||||
@@ -187,11 +219,38 @@ export function findCatalogReason(
|
||||
categoryLabel: category.label,
|
||||
detailId: detail.id,
|
||||
detailLabel: detail.label,
|
||||
reasonCode: buildReasonCode(category.id, detail.id),
|
||||
reasonCode: detailEffectiveReasonCode(category, detail),
|
||||
reasonLabel: `${category.label} > ${detail.label}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve category/detail + labels by official or derived reasonCode (includes inactive details). */
|
||||
export function findCatalogReasonByReasonCode(
|
||||
catalog: ReasonCatalog | null | undefined,
|
||||
kind: ReasonCatalogKind,
|
||||
reasonCode: string | null | undefined
|
||||
) {
|
||||
if (!catalog) return null;
|
||||
const needle = String(reasonCode ?? "").trim().toUpperCase();
|
||||
if (!needle) return null;
|
||||
for (const category of catalog[kind] ?? []) {
|
||||
for (const detail of category.details) {
|
||||
const rc = detailEffectiveReasonCode(category, detail);
|
||||
if (rc === needle) {
|
||||
return {
|
||||
categoryId: category.id,
|
||||
categoryLabel: category.label,
|
||||
detailId: detail.id,
|
||||
detailLabel: detail.label,
|
||||
reasonCode: rc,
|
||||
reasonLabel: `${category.label} > ${detail.label}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toReasonCode(categoryId: unknown, detailId: unknown) {
|
||||
const cat = canonicalId(categoryId, "");
|
||||
const det = canonicalId(detailId, "");
|
||||
|
||||
98
lib/reasonCatalogDb.ts
Normal file
98
lib/reasonCatalogDb.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { ReasonCatalog, ReasonCatalogCategory, ReasonCatalogDetail } from "@/lib/reasonCatalog";
|
||||
import { normalizeReasonCatalog } from "@/lib/reasonCatalog";
|
||||
import { loadFallbackReasonCatalog } from "@/lib/reasonCatalogFallback";
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full printed code from category prefix + operator numeric suffix (or suffix digits from seed).
|
||||
* Downtime-style keys use a hyphen before the numeric part (e.g. DTPRC-01); short scrap-style
|
||||
* prefixes (e.g. MX) concatenate without hyphen (MX001).
|
||||
*/
|
||||
export function composeReasonCode(prefix: string, suffix: string): string {
|
||||
const p = String(prefix ?? "").trim().toUpperCase();
|
||||
const s = String(suffix ?? "").trim();
|
||||
if (/^\d+$/.test(s) && p.length >= 3) {
|
||||
return `${p}-${s}`.toUpperCase();
|
||||
}
|
||||
return `${p}${s}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function isNumericSuffix(value: string): boolean {
|
||||
return /^\d+$/.test(String(value ?? "").trim());
|
||||
}
|
||||
|
||||
function mapKind(kind: string): "downtime" | "scrap" | null {
|
||||
const k = String(kind).toLowerCase();
|
||||
if (k === "downtime" || k === "scrap") return k;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load catalog from Postgres tables. Returns null if org has no catalog rows yet.
|
||||
* Includes inactive rows for historical label resolution (same as prior JSON behavior).
|
||||
*/
|
||||
export async function loadReasonCatalogFromDb(
|
||||
orgId: string,
|
||||
catalogVersion: number
|
||||
): Promise<ReasonCatalog | null> {
|
||||
const rows = await prisma.reasonCatalogCategory.findMany({
|
||||
where: { orgId },
|
||||
include: {
|
||||
items: { orderBy: { sortOrder: "asc" } },
|
||||
},
|
||||
orderBy: [{ kind: "asc" }, { sortOrder: "asc" }],
|
||||
});
|
||||
if (!rows.length) return null;
|
||||
|
||||
const downtime: ReasonCatalogCategory[] = [];
|
||||
const scrap: ReasonCatalogCategory[] = [];
|
||||
|
||||
for (const cat of rows) {
|
||||
const k = mapKind(cat.kind);
|
||||
if (!k) continue;
|
||||
const details: ReasonCatalogDetail[] = cat.items.map((it) => ({
|
||||
id: it.id,
|
||||
label: it.name,
|
||||
reasonCode: it.reasonCode,
|
||||
active: it.active,
|
||||
}));
|
||||
const bucket: ReasonCatalogCategory = {
|
||||
id: cat.id,
|
||||
label: cat.name,
|
||||
details,
|
||||
};
|
||||
if (k === "downtime") downtime.push(bucket);
|
||||
else scrap.push(bucket);
|
||||
}
|
||||
|
||||
if (!downtime.length && !scrap.length) return null;
|
||||
return { version: Math.max(1, catalogVersion), downtime, scrap };
|
||||
}
|
||||
|
||||
/** DB first, then legacy JSON in defaults, then file fallback. */
|
||||
export async function effectiveReasonCatalogForOrg(
|
||||
orgId: string,
|
||||
defaultsJson: unknown,
|
||||
settingsVersion: number
|
||||
): Promise<ReasonCatalog> {
|
||||
const fromDb = await loadReasonCatalogFromDb(orgId, settingsVersion);
|
||||
if (fromDb) return fromDb;
|
||||
|
||||
const defs = isPlainObject(defaultsJson) ? defaultsJson : {};
|
||||
const fromJson = normalizeReasonCatalog(defs.reasonCatalog ?? defs.reasonCatalogData);
|
||||
if (fromJson) return fromJson;
|
||||
|
||||
return loadFallbackReasonCatalog();
|
||||
}
|
||||
|
||||
export async function bumpOrgSettingsVersion(tx: Prisma.TransactionClient, orgId: string, userId: string) {
|
||||
await tx.orgSettings.update({
|
||||
where: { orgId },
|
||||
data: { version: { increment: 1 }, updatedBy: userId },
|
||||
});
|
||||
}
|
||||
15
lib/reasonCatalogFallback.ts
Normal file
15
lib/reasonCatalogFallback.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { parseReasonCatalogMarkdown, type ReasonCatalog } from "@/lib/reasonCatalog";
|
||||
|
||||
let catalogPromise: Promise<ReasonCatalog> | null = null;
|
||||
|
||||
/** Server-only: reads downtime_menu.md from the repo root. */
|
||||
export async function loadFallbackReasonCatalog() {
|
||||
if (!catalogPromise) {
|
||||
catalogPromise = readFile(path.join(process.cwd(), "downtime_menu.md"), "utf8")
|
||||
.then((raw) => parseReasonCatalogMarkdown(raw))
|
||||
.catch(() => ({ version: 1, downtime: [], scrap: [] }));
|
||||
}
|
||||
return catalogPromise;
|
||||
}
|
||||
@@ -289,6 +289,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
ts: true,
|
||||
cycleCount: true,
|
||||
workOrderId: true,
|
||||
theoreticalCycleTime: true,
|
||||
sku: true,
|
||||
goodDelta: true,
|
||||
scrapDelta: true,
|
||||
|
||||
131
lib/recap/machineState.ts
Normal file
131
lib/recap/machineState.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { TimelineEventRow } from "@/lib/recap/timeline";
|
||||
|
||||
/**
|
||||
* Shared classifier for machine state across /recap, /machines, /overview.
|
||||
*
|
||||
* State precedence (top wins):
|
||||
* 1. OFFLINE — heartbeat dead
|
||||
* 2. MOLD_CHANGE — operator initiated mold swap
|
||||
* 3. STOPPED — should be producing, isn't
|
||||
* 4. DATA_LOSS — producing but tracking off (operator forgot START)
|
||||
* 5. IDLE — nothing loaded, nothing running, nothing expected
|
||||
* 6. RUNNING — healthy
|
||||
*
|
||||
* Inputs are intentionally raw and computed by the caller, not fetched here,
|
||||
* so this module stays pure (testable, no DB/Prisma dependency).
|
||||
*/
|
||||
|
||||
export type MachineStateName =
|
||||
| "offline"
|
||||
| "mold-change"
|
||||
| "stopped"
|
||||
| "data-loss"
|
||||
| "idle"
|
||||
| "running";
|
||||
|
||||
export type MachineStateResult =
|
||||
| { state: "offline"; lastSeenMs: number | null; offlineForMin: number }
|
||||
| {
|
||||
state: "mold-change";
|
||||
moldChangeStartMs: number | null;
|
||||
moldChangeMin: number;
|
||||
}
|
||||
| {
|
||||
state: "stopped";
|
||||
ongoingStopMin: number;
|
||||
stopStartedAtMs: number | null;
|
||||
}
|
||||
| { state: "idle" }
|
||||
| { state: "running" };
|
||||
|
||||
export type MachineStateInputs = {
|
||||
/** Heartbeat freshness — true if the Pi has been seen within the offline threshold */
|
||||
heartbeatAlive: boolean;
|
||||
/** Last heartbeat timestamp in ms (or null if never seen) */
|
||||
lastSeenMs: number | null;
|
||||
/** Computed offline duration in ms — used when heartbeatAlive is false */
|
||||
offlineForMs: number;
|
||||
|
||||
/** Operator pressed START — true if latest KPI snapshot has trackingEnabled=true */
|
||||
trackingEnabled: boolean;
|
||||
|
||||
/** A work order with status RUNNING or PENDING is currently assigned */
|
||||
hasActiveWorkOrder: boolean;
|
||||
|
||||
/** Active mold-change event (from timeline events) */
|
||||
activeMoldChange: { startedAtMs: number } | null;
|
||||
|
||||
/** Active macrostop event (from timeline events) — fires when tracking on + no cycles */
|
||||
activeMacrostop: { startedAtMs: number } | null;
|
||||
|
||||
/**
|
||||
* Untracked cycles arriving while tracking is OFF.
|
||||
* Caller computes by counting MachineCycle rows in the last UNTRACKED_WINDOW_MS
|
||||
* where ts > latestKpi.ts (so they're "after" the tracking-off snapshot).
|
||||
*/
|
||||
/**
|
||||
* Most recent cycle timestamp regardless of tracking — used as a sanity check
|
||||
* for IDLE classification.
|
||||
*/
|
||||
lastCycleTsMs: number | null;
|
||||
};
|
||||
|
||||
// Trigger thresholds — tunable
|
||||
|
||||
const RECENT_CYCLE_MS = 15 * 60 * 1000; // for IDLE check — "no cycles in 15 min"
|
||||
|
||||
export function classifyMachineState(
|
||||
inputs: MachineStateInputs,
|
||||
nowMs: number
|
||||
): MachineStateResult {
|
||||
// 1. OFFLINE — wins over everything. If we can't see the Pi, nothing else is reliable.
|
||||
if (!inputs.heartbeatAlive) {
|
||||
return {
|
||||
state: "offline",
|
||||
lastSeenMs: inputs.lastSeenMs,
|
||||
offlineForMin: Math.max(0, Math.floor(inputs.offlineForMs / 60000)),
|
||||
};
|
||||
}
|
||||
|
||||
// 2. MOLD_CHANGE — operator-initiated, suppresses STOPPED/ATTENTION even if cycles missing
|
||||
if (inputs.activeMoldChange) {
|
||||
return {
|
||||
state: "mold-change",
|
||||
moldChangeStartMs: inputs.activeMoldChange.startedAtMs,
|
||||
moldChangeMin: Math.max(
|
||||
0,
|
||||
Math.floor((nowMs - inputs.activeMoldChange.startedAtMs) / 60000)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 3. DATA_LOSS — tracking off but cycles arriving. Operator forgot START.
|
||||
// Check this BEFORE STOPPED because cycles ARE arriving (so the "no cycles" branch
|
||||
// would never fire), but we still want to flag it.
|
||||
|
||||
// 4. STOPPED — should be producing, isn't. Two reasons:
|
||||
// a) machine_fault: operator pressed START, macrostop event active → mechanical issue
|
||||
// b) not_started: operator never pressed START but a WO is loaded
|
||||
// 4. STOPPED — machine should be producing, isn't.
|
||||
// The Pi only emits macrostop events when tracking is on AND a WO is active,
|
||||
// so the presence of an active macrostop event is sufficient.
|
||||
if (inputs.activeMacrostop) {
|
||||
const startedAt = inputs.activeMacrostop.startedAtMs;
|
||||
return {
|
||||
state: "stopped",
|
||||
ongoingStopMin: Math.max(0, Math.floor((nowMs - startedAt) / 60000)),
|
||||
stopStartedAtMs: startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. IDLE — no one expects this machine to be doing anything right now.
|
||||
// No tracking, no WO, no recent cycles. Calm gray.
|
||||
const cycledRecently =
|
||||
inputs.lastCycleTsMs != null && nowMs - inputs.lastCycleTsMs <= RECENT_CYCLE_MS;
|
||||
if (!inputs.trackingEnabled && !inputs.hasActiveWorkOrder && !cycledRecently) {
|
||||
return { state: "idle" };
|
||||
}
|
||||
|
||||
// 6. RUNNING — default. Tracking on, WO loaded, cycles flowing.
|
||||
return { state: "running" };
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type TimelineCycleRow,
|
||||
type TimelineEventRow,
|
||||
} from "@/lib/recap/timeline";
|
||||
import { classifyMachineState, type MachineStateResult } from "@/lib/recap/machineState";
|
||||
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||
import type {
|
||||
RecapDetailResponse,
|
||||
@@ -16,6 +17,7 @@ import type {
|
||||
RecapMachineDetail,
|
||||
RecapMachineStatus,
|
||||
RecapRangeMode,
|
||||
RecapStateContext,
|
||||
RecapSummaryMachine,
|
||||
RecapSummaryResponse,
|
||||
} from "@/lib/recap/types";
|
||||
@@ -175,24 +177,150 @@ function addDays(input: { year: number; month: number; day: number }, days: numb
|
||||
};
|
||||
}
|
||||
|
||||
function statusFromMachine(machine: RecapMachine, endMs: number) {
|
||||
// Detect active episodes (macrostop, mold-change) from event rows.
|
||||
// Returns the latest non-auto-ack episode whose final status is "active"
|
||||
// and that's been refreshed within ACTIVE_STALE_MS.
|
||||
const ACTIVE_STALE_MS = 2 * 60 * 1000;
|
||||
|
||||
type ActiveEpisode = { startedAtMs: number; lastTsMs: number };
|
||||
|
||||
function detectActiveEpisode(
|
||||
events: TimelineEventRow[] | undefined,
|
||||
eventType: "macrostop" | "mold-change",
|
||||
endMs: number
|
||||
): ActiveEpisode | null {
|
||||
if (!events || events.length === 0) return null;
|
||||
|
||||
type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string; lastCycleTs: number | null };
|
||||
const episodes = new Map<string, Episode>();
|
||||
|
||||
for (const event of events) {
|
||||
if (String(event.eventType || "").toLowerCase() !== eventType) continue;
|
||||
|
||||
let parsed: unknown = event.data;
|
||||
if (typeof parsed === "string") {
|
||||
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
|
||||
}
|
||||
const data: Record<string, unknown> =
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const isAutoAck =
|
||||
data.is_auto_ack === true ||
|
||||
data.isAutoAck === true ||
|
||||
data.is_auto_ack === "true" ||
|
||||
data.isAutoAck === "true";
|
||||
if (isAutoAck) continue;
|
||||
|
||||
const status = String(data.status ?? "").trim().toLowerCase();
|
||||
const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
|
||||
|| `${eventType}:${event.ts.getTime()}`;
|
||||
const tsMs = event.ts.getTime();
|
||||
const lastCycleTs = Number(data.last_cycle_timestamp);
|
||||
|
||||
const existing = episodes.get(incidentKey);
|
||||
if (!existing) {
|
||||
episodes.set(incidentKey, {
|
||||
firstTsMs: tsMs,
|
||||
lastTsMs: tsMs,
|
||||
lastStatus: status,
|
||||
lastCycleTs: Number.isFinite(lastCycleTs) && lastCycleTs > 0 ? lastCycleTs : null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
|
||||
if (tsMs >= existing.lastTsMs) {
|
||||
existing.lastTsMs = tsMs;
|
||||
existing.lastStatus = status;
|
||||
}
|
||||
}
|
||||
|
||||
let best: ActiveEpisode | null = null;
|
||||
for (const ep of episodes.values()) {
|
||||
if (ep.lastStatus !== "active") continue;
|
||||
if (endMs - ep.lastTsMs > ACTIVE_STALE_MS) continue;
|
||||
// Prefer the freshest active episode (highest lastTsMs)
|
||||
if (!best || ep.lastTsMs > best.lastTsMs) {
|
||||
best = {
|
||||
startedAtMs: ep.lastCycleTs ?? ep.firstTsMs,
|
||||
lastTsMs: ep.lastTsMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function statusFromMachine(
|
||||
machine: RecapMachine,
|
||||
endMs: number,
|
||||
events?: TimelineEventRow[]
|
||||
): {
|
||||
status: RecapMachineStatus;
|
||||
result: MachineStateResult;
|
||||
stateContext: RecapStateContext;
|
||||
lastSeenMs: number | null;
|
||||
offlineForMin: number | null;
|
||||
ongoingStopMin: number | null;
|
||||
} {
|
||||
const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
|
||||
const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
|
||||
const offline = !Number.isFinite(lastSeenMs ?? Number.NaN) || offlineForMs > OFFLINE_THRESHOLD_MS;
|
||||
const heartbeatAlive = Number.isFinite(lastSeenMs ?? Number.NaN) && offlineForMs <= OFFLINE_THRESHOLD_MS;
|
||||
|
||||
const ongoingStopMin = machine.downtime.ongoingStopMin ?? 0;
|
||||
const moldActive = machine.workOrders.moldChangeInProgress;
|
||||
const activeMacrostop = detectActiveEpisode(events, "macrostop", endMs);
|
||||
const activeMoldChange = detectActiveEpisode(events, "mold-change", endMs);
|
||||
|
||||
// Round 1 limitation: trackingEnabled and untrackedCycles inputs require KPI/cycle queries
|
||||
// we don't yet plumb here. We approximate from the legacy fields:
|
||||
// - trackingEnabled: true when there's an active macrostop (Pi only fires those when tracking on)
|
||||
// OR when an active WO exists and machine.workOrders.moldChangeInProgress is false.
|
||||
// This is a SIMPLIFICATION; Round 3 will replace with real KPI snapshot read.
|
||||
// - untrackedCycles: 0 (Round 3 will compute from MachineCycle vs latest KPI)
|
||||
//
|
||||
// Effect for Round 1: STOPPED `not_started` reason cannot trigger yet (we always assume tracking
|
||||
// is on when a WO exists). Only `machine_fault` STOPPED fires. DATA_LOSS cannot fire yet.
|
||||
// IDLE fires correctly when there's no WO and no recent activity.
|
||||
const hasActiveWorkOrder = machine.workOrders.active != null;
|
||||
const trackingEnabledApprox = hasActiveWorkOrder; // see comment above
|
||||
|
||||
const lastCycleTsMs = (() => {
|
||||
// Best-effort: use the machine's heartbeat as a "recent activity" proxy.
|
||||
// The Pi only heartbeats every minute regardless of cycles, so this is a weak signal.
|
||||
// Round 3 will pass the actual latest cycle ts.
|
||||
return lastSeenMs;
|
||||
})();
|
||||
|
||||
const result = classifyMachineState(
|
||||
{
|
||||
heartbeatAlive,
|
||||
lastSeenMs,
|
||||
offlineForMs,
|
||||
trackingEnabled: trackingEnabledApprox,
|
||||
hasActiveWorkOrder,
|
||||
activeMoldChange,
|
||||
activeMacrostop,
|
||||
lastCycleTsMs,
|
||||
},
|
||||
endMs
|
||||
);
|
||||
|
||||
// Map the rich classifier result back to the existing RecapMachineStatus union
|
||||
const status: RecapMachineStatus = result.state;
|
||||
|
||||
// Pull common fields out for the caller's convenience
|
||||
let ongoingStopMin: number | null = null;
|
||||
if (result.state === "stopped") ongoingStopMin = result.ongoingStopMin;
|
||||
|
||||
const stateContext: RecapStateContext = {};
|
||||
|
||||
let status: RecapMachineStatus = "running";
|
||||
if (offline) status = "offline";
|
||||
else if (moldActive) status = "mold-change";
|
||||
else if (ongoingStopMin > 0) status = "stopped";
|
||||
|
||||
return {
|
||||
status,
|
||||
result,
|
||||
stateContext,
|
||||
lastSeenMs,
|
||||
offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null,
|
||||
ongoingStopMin: machine.downtime.ongoingStopMin,
|
||||
offlineForMin: result.state === "offline" ? result.offlineForMin : null,
|
||||
ongoingStopMin,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -225,6 +353,7 @@ async function loadTimelineRowsForMachines(params: {
|
||||
ts: true,
|
||||
cycleCount: true,
|
||||
actualCycleTime: true,
|
||||
theoreticalCycleTime: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
},
|
||||
@@ -258,6 +387,7 @@ async function loadTimelineRowsForMachines(params: {
|
||||
ts: row.ts,
|
||||
cycleCount: row.cycleCount,
|
||||
actualCycleTime: row.actualCycleTime,
|
||||
theoreticalCycleTime: row.theoreticalCycleTime ?? null,
|
||||
workOrderId: row.workOrderId,
|
||||
sku: row.sku,
|
||||
});
|
||||
@@ -281,9 +411,10 @@ function toSummaryMachine(params: {
|
||||
machine: RecapMachine;
|
||||
miniTimeline: ReturnType<typeof compressTimelineSegments>;
|
||||
rangeEndMs: number;
|
||||
events?: TimelineEventRow[];
|
||||
}): RecapSummaryMachine {
|
||||
const { machine, miniTimeline, rangeEndMs } = params;
|
||||
const status = statusFromMachine(machine, rangeEndMs);
|
||||
const { machine, miniTimeline, rangeEndMs, events } = params;
|
||||
const status = statusFromMachine(machine, rangeEndMs, events);
|
||||
|
||||
return {
|
||||
machineId: machine.machineId,
|
||||
@@ -299,6 +430,7 @@ function toSummaryMachine(params: {
|
||||
status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
|
||||
offlineForMin: status.offlineForMin,
|
||||
ongoingStopMin: status.ongoingStopMin,
|
||||
stateContext: status.stateContext,
|
||||
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
||||
moldChange: {
|
||||
active: machine.workOrders.moldChangeInProgress,
|
||||
@@ -349,6 +481,7 @@ async function computeRecapSummary(params: { orgId: string; hours: number }) {
|
||||
machine,
|
||||
miniTimeline,
|
||||
rangeEndMs: end.getTime(),
|
||||
events: timelineRows.eventsByMachine.get(machine.machineId),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -608,7 +741,11 @@ async function computeRecapMachineDetail(params: {
|
||||
rangeEnd: range.end,
|
||||
});
|
||||
|
||||
const status = statusFromMachine(machine, range.end.getTime());
|
||||
const status = statusFromMachine(
|
||||
machine,
|
||||
range.end.getTime(),
|
||||
timelineRows.eventsByMachine.get(params.machineId)
|
||||
);
|
||||
|
||||
const downtimeTotalMin = Math.max(0, machine.downtime.totalMin);
|
||||
const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({
|
||||
@@ -632,6 +769,8 @@ async function computeRecapMachineDetail(params: {
|
||||
lastSeenMs: status.lastSeenMs,
|
||||
offlineForMin: status.offlineForMin,
|
||||
ongoingStopMin: status.ongoingStopMin,
|
||||
stateContext: status.stateContext,
|
||||
|
||||
moldChange: {
|
||||
active: machine.workOrders.moldChangeInProgress,
|
||||
startMs: machine.workOrders.moldChangeStartMs,
|
||||
|
||||
776
lib/recap/redesign.ts.bak
Normal file
776
lib/recap/redesign.ts.bak
Normal file
@@ -0,0 +1,776 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
|
||||
import { getRecapDataCached } from "@/lib/recap/getRecapData";
|
||||
import {
|
||||
buildTimelineSegments,
|
||||
compressTimelineSegments,
|
||||
TIMELINE_EVENT_TYPES,
|
||||
type TimelineCycleRow,
|
||||
type TimelineEventRow,
|
||||
} from "@/lib/recap/timeline";
|
||||
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||
import type {
|
||||
RecapDetailResponse,
|
||||
RecapMachine,
|
||||
RecapMachineDetail,
|
||||
RecapMachineStatus,
|
||||
RecapRangeMode,
|
||||
RecapSummaryMachine,
|
||||
RecapSummaryResponse,
|
||||
} from "@/lib/recap/types";
|
||||
|
||||
type DetailRangeInput = {
|
||||
mode?: string | null;
|
||||
start?: string | null;
|
||||
end?: string | null;
|
||||
};
|
||||
|
||||
const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
|
||||
const RECAP_CACHE_TTL_SEC = 60;
|
||||
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||
const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
|
||||
Mon: "mon",
|
||||
Tue: "tue",
|
||||
Wed: "wed",
|
||||
Thu: "thu",
|
||||
Fri: "fri",
|
||||
Sat: "sat",
|
||||
Sun: "sun",
|
||||
};
|
||||
|
||||
function round2(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function parseDate(input?: string | null) {
|
||||
if (!input) return null;
|
||||
const n = Number(input);
|
||||
if (Number.isFinite(n)) {
|
||||
const d = new Date(n);
|
||||
return Number.isFinite(d.getTime()) ? d : null;
|
||||
}
|
||||
const d = new Date(input);
|
||||
return Number.isFinite(d.getTime()) ? d : null;
|
||||
}
|
||||
|
||||
function parseHours(input: string | null) {
|
||||
const parsed = Math.trunc(Number(input ?? "24"));
|
||||
if (!Number.isFinite(parsed)) return 24;
|
||||
return Math.max(1, Math.min(72, parsed));
|
||||
}
|
||||
|
||||
function parseTimeMinutes(input?: string | null) {
|
||||
if (!input) return null;
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
||||
return null;
|
||||
}
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
function getLocalParts(ts: Date, timeZone: string) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
weekday: "short",
|
||||
hour12: false,
|
||||
}).formatToParts(ts);
|
||||
|
||||
const value = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
|
||||
const year = Number(value("year"));
|
||||
const month = Number(value("month"));
|
||||
const day = Number(value("day"));
|
||||
const hour = Number(value("hour"));
|
||||
const minute = Number(value("minute"));
|
||||
const weekday = value("weekday");
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()],
|
||||
minutesOfDay: hour * 60 + minute,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
year: ts.getUTCFullYear(),
|
||||
month: ts.getUTCMonth() + 1,
|
||||
day: ts.getUTCDate(),
|
||||
hour: ts.getUTCHours(),
|
||||
minute: ts.getUTCMinutes(),
|
||||
weekday: WEEKDAY_KEYS[ts.getUTCDay()],
|
||||
minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseOffsetMinutes(offsetLabel: string | null) {
|
||||
if (!offsetLabel) return null;
|
||||
const normalized = offsetLabel.replace("UTC", "GMT");
|
||||
const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized);
|
||||
if (!match) return null;
|
||||
const sign = match[1] === "-" ? -1 : 1;
|
||||
const hour = Number(match[2]);
|
||||
const minute = Number(match[3] ?? "0");
|
||||
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
||||
return sign * (hour * 60 + minute);
|
||||
}
|
||||
|
||||
function getTzOffsetMinutes(utcDate: Date, timeZone: string) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
timeZoneName: "shortOffset",
|
||||
hour: "2-digit",
|
||||
}).formatToParts(utcDate);
|
||||
const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null;
|
||||
return parseOffsetMinutes(offsetPart);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function zonedToUtcDate(input: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
timeZone: string;
|
||||
}) {
|
||||
const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0);
|
||||
const guessDate = new Date(baseUtc);
|
||||
const offsetA = getTzOffsetMinutes(guessDate, input.timeZone);
|
||||
if (offsetA == null) return guessDate;
|
||||
|
||||
let corrected = new Date(baseUtc - offsetA * 60000);
|
||||
const offsetB = getTzOffsetMinutes(corrected, input.timeZone);
|
||||
if (offsetB != null && offsetB !== offsetA) {
|
||||
corrected = new Date(baseUtc - offsetB * 60000);
|
||||
}
|
||||
|
||||
return corrected;
|
||||
}
|
||||
|
||||
function addDays(input: { year: number; month: number; day: number }, days: number) {
|
||||
const base = new Date(Date.UTC(input.year, input.month - 1, input.day));
|
||||
base.setUTCDate(base.getUTCDate() + days);
|
||||
return {
|
||||
year: base.getUTCFullYear(),
|
||||
month: base.getUTCMonth() + 1,
|
||||
day: base.getUTCDate(),
|
||||
};
|
||||
}
|
||||
|
||||
function statusFromMachine(machine: RecapMachine, endMs: number) {
|
||||
const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
|
||||
const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
|
||||
const offline = !Number.isFinite(lastSeenMs ?? Number.NaN) || offlineForMs > OFFLINE_THRESHOLD_MS;
|
||||
|
||||
const ongoingStopMin = machine.downtime.ongoingStopMin ?? 0;
|
||||
const moldActive = machine.workOrders.moldChangeInProgress;
|
||||
|
||||
let status: RecapMachineStatus = "running";
|
||||
if (offline) status = "offline";
|
||||
else if (moldActive) status = "mold-change";
|
||||
else if (ongoingStopMin > 0) status = "stopped";
|
||||
|
||||
return {
|
||||
status,
|
||||
lastSeenMs,
|
||||
offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null,
|
||||
ongoingStopMin: machine.downtime.ongoingStopMin,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTimelineRowsForMachines(params: {
|
||||
orgId: string;
|
||||
machineIds: string[];
|
||||
start: Date;
|
||||
end: Date;
|
||||
}) {
|
||||
if (!params.machineIds.length) {
|
||||
return {
|
||||
cyclesByMachine: new Map<string, TimelineCycleRow[]>(),
|
||||
eventsByMachine: new Map<string, TimelineEventRow[]>(),
|
||||
};
|
||||
}
|
||||
|
||||
const [cycles, events] = await Promise.all([
|
||||
prisma.machineCycle.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: { in: params.machineIds },
|
||||
ts: {
|
||||
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||
lte: params.end,
|
||||
},
|
||||
},
|
||||
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||
select: {
|
||||
machineId: true,
|
||||
ts: true,
|
||||
cycleCount: true,
|
||||
actualCycleTime: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
},
|
||||
}),
|
||||
prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: { in: params.machineIds },
|
||||
eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
|
||||
ts: {
|
||||
gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
|
||||
lte: params.end,
|
||||
},
|
||||
},
|
||||
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||
select: {
|
||||
machineId: true,
|
||||
ts: true,
|
||||
eventType: true,
|
||||
data: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const cyclesByMachine = new Map<string, TimelineCycleRow[]>();
|
||||
const eventsByMachine = new Map<string, TimelineEventRow[]>();
|
||||
|
||||
for (const row of cycles) {
|
||||
const list = cyclesByMachine.get(row.machineId) ?? [];
|
||||
list.push({
|
||||
ts: row.ts,
|
||||
cycleCount: row.cycleCount,
|
||||
actualCycleTime: row.actualCycleTime,
|
||||
workOrderId: row.workOrderId,
|
||||
sku: row.sku,
|
||||
});
|
||||
cyclesByMachine.set(row.machineId, list);
|
||||
}
|
||||
|
||||
for (const row of events) {
|
||||
const list = eventsByMachine.get(row.machineId) ?? [];
|
||||
list.push({
|
||||
ts: row.ts,
|
||||
eventType: row.eventType,
|
||||
data: row.data,
|
||||
});
|
||||
eventsByMachine.set(row.machineId, list);
|
||||
}
|
||||
|
||||
return { cyclesByMachine, eventsByMachine };
|
||||
}
|
||||
|
||||
function toSummaryMachine(params: {
|
||||
machine: RecapMachine;
|
||||
miniTimeline: ReturnType<typeof compressTimelineSegments>;
|
||||
rangeEndMs: number;
|
||||
}): RecapSummaryMachine {
|
||||
const { machine, miniTimeline, rangeEndMs } = params;
|
||||
const status = statusFromMachine(machine, rangeEndMs);
|
||||
|
||||
return {
|
||||
machineId: machine.machineId,
|
||||
name: machine.machineName,
|
||||
location: machine.location,
|
||||
status: status.status,
|
||||
oee: machine.oee.avg,
|
||||
goodParts: machine.production.goodParts,
|
||||
scrap: machine.production.scrapParts,
|
||||
stopsCount: machine.downtime.stopsCount,
|
||||
lastSeenMs: status.lastSeenMs,
|
||||
lastActivityMin:
|
||||
status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
|
||||
offlineForMin: status.offlineForMin,
|
||||
ongoingStopMin: status.ongoingStopMin,
|
||||
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
||||
moldChange: {
|
||||
active: machine.workOrders.moldChangeInProgress,
|
||||
startMs: machine.workOrders.moldChangeStartMs,
|
||||
elapsedMin:
|
||||
machine.workOrders.moldChangeStartMs == null
|
||||
? null
|
||||
: Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)),
|
||||
},
|
||||
miniTimeline,
|
||||
};
|
||||
}
|
||||
|
||||
async function computeRecapSummary(params: { orgId: string; hours: number }) {
|
||||
const now = new Date();
|
||||
const end = new Date(Math.floor(now.getTime() / 60000) * 60000);
|
||||
const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000);
|
||||
|
||||
const recap = await getRecapDataCached({
|
||||
orgId: params.orgId,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
const machineIds = recap.machines.map((machine) => machine.machineId);
|
||||
const timelineRows = await loadTimelineRowsForMachines({
|
||||
orgId: params.orgId,
|
||||
machineIds,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
const machines = recap.machines.map((machine) => {
|
||||
const segments = buildTimelineSegments({
|
||||
cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [],
|
||||
events: timelineRows.eventsByMachine.get(machine.machineId) ?? [],
|
||||
rangeStart: start,
|
||||
rangeEnd: end,
|
||||
});
|
||||
const miniTimeline = compressTimelineSegments({
|
||||
segments,
|
||||
rangeStart: start,
|
||||
rangeEnd: end,
|
||||
maxSegments: 60,
|
||||
});
|
||||
|
||||
return toSummaryMachine({
|
||||
machine,
|
||||
miniTimeline,
|
||||
rangeEndMs: end.getTime(),
|
||||
});
|
||||
});
|
||||
|
||||
const response: RecapSummaryResponse = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
range: {
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
hours: params.hours,
|
||||
},
|
||||
machines,
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function normalizedRangeMode(mode?: string | null): RecapRangeMode {
|
||||
const raw = String(mode ?? "").trim().toLowerCase();
|
||||
if (raw === "shift") return "shift";
|
||||
if (raw === "yesterday") return "yesterday";
|
||||
if (raw === "custom") return "custom";
|
||||
return "24h";
|
||||
}
|
||||
|
||||
async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
|
||||
const settings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: params.orgId },
|
||||
select: {
|
||||
timezone: true,
|
||||
shiftScheduleOverridesJson: true,
|
||||
},
|
||||
});
|
||||
const shifts = await prisma.orgShift.findMany({
|
||||
where: { orgId: params.orgId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: {
|
||||
name: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
enabled: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
});
|
||||
|
||||
const enabledShifts = shifts.filter((shift) => shift.enabled !== false);
|
||||
if (!enabledShifts.length) {
|
||||
return {
|
||||
hasEnabledShifts: false,
|
||||
range: null,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const timeZone = settings?.timezone || "UTC";
|
||||
const local = getLocalParts(params.now, timeZone);
|
||||
const overrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
|
||||
const dayOverrides = overrides?.[local.weekday];
|
||||
const activeShifts = (dayOverrides?.length
|
||||
? dayOverrides.map((shift) => ({
|
||||
enabled: shift.enabled !== false,
|
||||
start: shift.start,
|
||||
end: shift.end,
|
||||
}))
|
||||
: enabledShifts.map((shift) => ({
|
||||
enabled: shift.enabled !== false,
|
||||
start: shift.startTime,
|
||||
end: shift.endTime,
|
||||
}))
|
||||
).filter((shift) => shift.enabled);
|
||||
|
||||
for (const shift of activeShifts) {
|
||||
const startMin = parseTimeMinutes(shift.start ?? null);
|
||||
const endMin = parseTimeMinutes(shift.end ?? null);
|
||||
if (startMin == null || endMin == null) continue;
|
||||
|
||||
const minutesNow = local.minutesOfDay;
|
||||
let inRange = false;
|
||||
let startDate = { year: local.year, month: local.month, day: local.day };
|
||||
let endDate = { year: local.year, month: local.month, day: local.day };
|
||||
|
||||
if (startMin <= endMin) {
|
||||
inRange = minutesNow >= startMin && minutesNow < endMin;
|
||||
} else {
|
||||
inRange = minutesNow >= startMin || minutesNow < endMin;
|
||||
if (minutesNow >= startMin) {
|
||||
endDate = addDays(endDate, 1);
|
||||
} else {
|
||||
startDate = addDays(startDate, -1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!inRange) continue;
|
||||
|
||||
const start = zonedToUtcDate({
|
||||
...startDate,
|
||||
hours: Math.floor(startMin / 60),
|
||||
minutes: startMin % 60,
|
||||
timeZone,
|
||||
});
|
||||
const shiftEndUtc = zonedToUtcDate({
|
||||
...endDate,
|
||||
hours: Math.floor(endMin / 60),
|
||||
minutes: endMin % 60,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
if (shiftEndUtc <= start) continue;
|
||||
|
||||
// Cap end at "now" so we render shift-so-far, not shift-as-planned.
|
||||
// Without cap:
|
||||
// - timeline fills future minutes with idle (visual lie)
|
||||
// - offline calc = (shift_end_future - last_seen) = looks 5h offline
|
||||
// even on a machine producing right now
|
||||
const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;
|
||||
|
||||
return {
|
||||
hasEnabledShifts: true,
|
||||
range: { start, end },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasEnabledShifts: true,
|
||||
range: null,
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
|
||||
const now = new Date(Math.floor(Date.now() / 60000) * 60000);
|
||||
const requestedMode = normalizedRangeMode(params.input.mode);
|
||||
const shiftEnabledCount = await prisma.orgShift.count({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
enabled: { not: false },
|
||||
},
|
||||
});
|
||||
const shiftAvailable = shiftEnabledCount > 0;
|
||||
|
||||
if (requestedMode === "custom") {
|
||||
const start = parseDate(params.input.start);
|
||||
const end = parseDate(params.input.end);
|
||||
if (start && end && end > start) {
|
||||
return {
|
||||
requestedMode,
|
||||
mode: requestedMode,
|
||||
start,
|
||||
end,
|
||||
shiftAvailable,
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedMode === "yesterday") {
|
||||
const settings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: params.orgId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const timeZone = settings?.timezone || "America/Mexico_City";
|
||||
const localNow = getLocalParts(now, timeZone);
|
||||
const today = { year: localNow.year, month: localNow.month, day: localNow.day };
|
||||
const yesterday = addDays(today, -1);
|
||||
const start = zonedToUtcDate({
|
||||
...yesterday,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
timeZone,
|
||||
});
|
||||
const end = zonedToUtcDate({
|
||||
...today,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
timeZone,
|
||||
});
|
||||
return {
|
||||
requestedMode,
|
||||
mode: requestedMode,
|
||||
start,
|
||||
end,
|
||||
shiftAvailable,
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (requestedMode === "shift") {
|
||||
const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
|
||||
if (shiftRange.range) {
|
||||
return {
|
||||
requestedMode,
|
||||
mode: requestedMode,
|
||||
start: shiftRange.range.start,
|
||||
end: shiftRange.range.end,
|
||||
shiftAvailable,
|
||||
} as const;
|
||||
}
|
||||
if (!shiftRange.hasEnabledShifts) {
|
||||
return {
|
||||
requestedMode,
|
||||
mode: "24h" as const,
|
||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
shiftAvailable,
|
||||
fallbackReason: "shift-unavailable" as const,
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
requestedMode,
|
||||
mode: "24h" as const,
|
||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
shiftAvailable,
|
||||
fallbackReason: "shift-inactive" as const,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
requestedMode,
|
||||
mode: "24h" as const,
|
||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
shiftAvailable,
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function computeRecapMachineDetail(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
range: {
|
||||
requestedMode: RecapRangeMode;
|
||||
mode: RecapRangeMode;
|
||||
start: Date;
|
||||
end: Date;
|
||||
shiftAvailable: boolean;
|
||||
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||
};
|
||||
}) {
|
||||
const { range } = params;
|
||||
|
||||
const recap = await getRecapDataCached({
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
});
|
||||
|
||||
const machine = recap.machines.find((row) => row.machineId === params.machineId) ?? null;
|
||||
if (!machine) return null;
|
||||
|
||||
const timelineRows = await loadTimelineRowsForMachines({
|
||||
orgId: params.orgId,
|
||||
machineIds: [params.machineId],
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
});
|
||||
|
||||
const timeline = buildTimelineSegments({
|
||||
cycles: timelineRows.cyclesByMachine.get(params.machineId) ?? [],
|
||||
events: timelineRows.eventsByMachine.get(params.machineId) ?? [],
|
||||
rangeStart: range.start,
|
||||
rangeEnd: range.end,
|
||||
});
|
||||
|
||||
const status = statusFromMachine(machine, range.end.getTime());
|
||||
|
||||
const downtimeTotalMin = Math.max(0, machine.downtime.totalMin);
|
||||
const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({
|
||||
reasonLabel: row.reasonLabel,
|
||||
minutes: row.minutes,
|
||||
count: row.count,
|
||||
percent: downtimeTotalMin > 0 ? round2((row.minutes / downtimeTotalMin) * 100) : 0,
|
||||
}));
|
||||
|
||||
const machineDetail: RecapMachineDetail = {
|
||||
machineId: machine.machineId,
|
||||
name: machine.machineName,
|
||||
location: machine.location,
|
||||
status: status.status,
|
||||
oee: machine.oee.avg,
|
||||
goodParts: machine.production.goodParts,
|
||||
scrap: machine.production.scrapParts,
|
||||
stopsCount: machine.downtime.stopsCount,
|
||||
stopMinutes: downtimeTotalMin,
|
||||
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
||||
lastSeenMs: status.lastSeenMs,
|
||||
offlineForMin: status.offlineForMin,
|
||||
ongoingStopMin: status.ongoingStopMin,
|
||||
moldChange: {
|
||||
active: machine.workOrders.moldChangeInProgress,
|
||||
startMs: machine.workOrders.moldChangeStartMs,
|
||||
},
|
||||
timeline,
|
||||
productionBySku: machine.production.bySku,
|
||||
downtimeTop,
|
||||
workOrders: {
|
||||
completed: machine.workOrders.completed,
|
||||
active: machine.workOrders.active,
|
||||
},
|
||||
heartbeat: {
|
||||
lastSeenAt: machine.heartbeat.lastSeenAt,
|
||||
uptimePct: machine.heartbeat.uptimePct,
|
||||
connectionStatus: status.status === "offline" ? "offline" : "online",
|
||||
},
|
||||
};
|
||||
|
||||
const response: RecapDetailResponse = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
range: {
|
||||
requestedMode: range.requestedMode,
|
||||
mode: range.mode,
|
||||
start: range.start.toISOString(),
|
||||
end: range.end.toISOString(),
|
||||
shiftAvailable: range.shiftAvailable,
|
||||
fallbackReason: range.fallbackReason,
|
||||
},
|
||||
machine: machineDetail,
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function summaryCacheKey(params: { orgId: string; hours: number }) {
|
||||
return ["recap-summary-v1", params.orgId, String(params.hours)];
|
||||
}
|
||||
|
||||
function detailCacheKey(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
requestedMode: RecapRangeMode;
|
||||
mode: RecapRangeMode;
|
||||
shiftAvailable: boolean;
|
||||
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}) {
|
||||
return [
|
||||
"recap-detail-v1",
|
||||
params.orgId,
|
||||
params.machineId,
|
||||
params.requestedMode,
|
||||
params.mode,
|
||||
params.shiftAvailable ? "shift-on" : "shift-off",
|
||||
params.fallbackReason ?? "",
|
||||
String(Math.trunc(params.startMs / 60000)),
|
||||
String(Math.trunc(params.endMs / 60000)),
|
||||
];
|
||||
}
|
||||
|
||||
export function parseRecapSummaryHours(raw: string | null) {
|
||||
return parseHours(raw);
|
||||
}
|
||||
|
||||
export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record<string, string | string[] | undefined>) {
|
||||
if (searchParams instanceof URLSearchParams) {
|
||||
return {
|
||||
mode: searchParams.get("range") ?? undefined,
|
||||
start: searchParams.get("start") ?? undefined,
|
||||
end: searchParams.get("end") ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const pick = (key: string) => {
|
||||
const value = searchParams[key];
|
||||
if (Array.isArray(value)) return value[0] ?? undefined;
|
||||
return value ?? undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
mode: pick("range"),
|
||||
start: pick("start"),
|
||||
end: pick("end"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRecapSummaryCached(params: { orgId: string; hours: number }) {
|
||||
const cache = unstable_cache(
|
||||
() => computeRecapSummary(params),
|
||||
summaryCacheKey(params),
|
||||
{
|
||||
revalidate: RECAP_CACHE_TTL_SEC,
|
||||
tags: [`recap:${params.orgId}`],
|
||||
}
|
||||
);
|
||||
|
||||
return cache();
|
||||
}
|
||||
|
||||
export async function getRecapMachineDetailCached(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
input: DetailRangeInput;
|
||||
}) {
|
||||
const resolved = await resolveDetailRange({
|
||||
orgId: params.orgId,
|
||||
input: params.input,
|
||||
});
|
||||
|
||||
const cache = unstable_cache(
|
||||
() =>
|
||||
computeRecapMachineDetail({
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
range: {
|
||||
requestedMode: resolved.requestedMode,
|
||||
mode: resolved.mode,
|
||||
start: resolved.start,
|
||||
end: resolved.end,
|
||||
shiftAvailable: resolved.shiftAvailable,
|
||||
fallbackReason: resolved.fallbackReason,
|
||||
},
|
||||
}),
|
||||
detailCacheKey({
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
requestedMode: resolved.requestedMode,
|
||||
mode: resolved.mode,
|
||||
shiftAvailable: resolved.shiftAvailable,
|
||||
fallbackReason: resolved.fallbackReason,
|
||||
startMs: resolved.start.getTime(),
|
||||
endMs: resolved.end.getTime(),
|
||||
}),
|
||||
{
|
||||
revalidate: RECAP_CACHE_TTL_SEC,
|
||||
tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
|
||||
}
|
||||
);
|
||||
|
||||
return cache();
|
||||
}
|
||||
848
lib/recap/redesign.ts.bak.step3
Normal file
848
lib/recap/redesign.ts.bak.step3
Normal file
@@ -0,0 +1,848 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
|
||||
import { getRecapDataCached } from "@/lib/recap/getRecapData";
|
||||
import {
|
||||
buildTimelineSegments,
|
||||
compressTimelineSegments,
|
||||
TIMELINE_EVENT_TYPES,
|
||||
type TimelineCycleRow,
|
||||
type TimelineEventRow,
|
||||
} from "@/lib/recap/timeline";
|
||||
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||
import type {
|
||||
RecapDetailResponse,
|
||||
RecapMachine,
|
||||
RecapMachineDetail,
|
||||
RecapMachineStatus,
|
||||
RecapRangeMode,
|
||||
RecapSummaryMachine,
|
||||
RecapSummaryResponse,
|
||||
} from "@/lib/recap/types";
|
||||
|
||||
type DetailRangeInput = {
|
||||
mode?: string | null;
|
||||
start?: string | null;
|
||||
end?: string | null;
|
||||
};
|
||||
|
||||
const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
|
||||
const RECAP_CACHE_TTL_SEC = 60;
|
||||
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||
const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
|
||||
Mon: "mon",
|
||||
Tue: "tue",
|
||||
Wed: "wed",
|
||||
Thu: "thu",
|
||||
Fri: "fri",
|
||||
Sat: "sat",
|
||||
Sun: "sun",
|
||||
};
|
||||
|
||||
function round2(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function parseDate(input?: string | null) {
|
||||
if (!input) return null;
|
||||
const n = Number(input);
|
||||
if (Number.isFinite(n)) {
|
||||
const d = new Date(n);
|
||||
return Number.isFinite(d.getTime()) ? d : null;
|
||||
}
|
||||
const d = new Date(input);
|
||||
return Number.isFinite(d.getTime()) ? d : null;
|
||||
}
|
||||
|
||||
function parseHours(input: string | null) {
|
||||
const parsed = Math.trunc(Number(input ?? "24"));
|
||||
if (!Number.isFinite(parsed)) return 24;
|
||||
return Math.max(1, Math.min(72, parsed));
|
||||
}
|
||||
|
||||
function parseTimeMinutes(input?: string | null) {
|
||||
if (!input) return null;
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
||||
return null;
|
||||
}
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
function getLocalParts(ts: Date, timeZone: string) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
weekday: "short",
|
||||
hour12: false,
|
||||
}).formatToParts(ts);
|
||||
|
||||
const value = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
|
||||
const year = Number(value("year"));
|
||||
const month = Number(value("month"));
|
||||
const day = Number(value("day"));
|
||||
const hour = Number(value("hour"));
|
||||
const minute = Number(value("minute"));
|
||||
const weekday = value("weekday");
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()],
|
||||
minutesOfDay: hour * 60 + minute,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
year: ts.getUTCFullYear(),
|
||||
month: ts.getUTCMonth() + 1,
|
||||
day: ts.getUTCDate(),
|
||||
hour: ts.getUTCHours(),
|
||||
minute: ts.getUTCMinutes(),
|
||||
weekday: WEEKDAY_KEYS[ts.getUTCDay()],
|
||||
minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseOffsetMinutes(offsetLabel: string | null) {
|
||||
if (!offsetLabel) return null;
|
||||
const normalized = offsetLabel.replace("UTC", "GMT");
|
||||
const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized);
|
||||
if (!match) return null;
|
||||
const sign = match[1] === "-" ? -1 : 1;
|
||||
const hour = Number(match[2]);
|
||||
const minute = Number(match[3] ?? "0");
|
||||
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
||||
return sign * (hour * 60 + minute);
|
||||
}
|
||||
|
||||
function getTzOffsetMinutes(utcDate: Date, timeZone: string) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
timeZoneName: "shortOffset",
|
||||
hour: "2-digit",
|
||||
}).formatToParts(utcDate);
|
||||
const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null;
|
||||
return parseOffsetMinutes(offsetPart);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function zonedToUtcDate(input: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
timeZone: string;
|
||||
}) {
|
||||
const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0);
|
||||
const guessDate = new Date(baseUtc);
|
||||
const offsetA = getTzOffsetMinutes(guessDate, input.timeZone);
|
||||
if (offsetA == null) return guessDate;
|
||||
|
||||
let corrected = new Date(baseUtc - offsetA * 60000);
|
||||
const offsetB = getTzOffsetMinutes(corrected, input.timeZone);
|
||||
if (offsetB != null && offsetB !== offsetA) {
|
||||
corrected = new Date(baseUtc - offsetB * 60000);
|
||||
}
|
||||
|
||||
return corrected;
|
||||
}
|
||||
|
||||
function addDays(input: { year: number; month: number; day: number }, days: number) {
|
||||
const base = new Date(Date.UTC(input.year, input.month - 1, input.day));
|
||||
base.setUTCDate(base.getUTCDate() + days);
|
||||
return {
|
||||
year: base.getUTCFullYear(),
|
||||
month: base.getUTCMonth() + 1,
|
||||
day: base.getUTCDate(),
|
||||
};
|
||||
}
|
||||
|
||||
// Active stoppage = freshest macrostop episode whose latest event is "active"
|
||||
// and whose latest event timestamp is within ACTIVE_STALE_MS of rangeEnd.
|
||||
// Mirrors the same rules used by lib/recap/timeline.ts so the card status
|
||||
// agrees with the timeline rendering.
|
||||
const STOPPAGE_ACTIVE_STALE_MS = 2 * 60 * 1000;
|
||||
|
||||
function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: number) {
|
||||
if (!events || events.length === 0) return null;
|
||||
|
||||
type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string };
|
||||
const episodes = new Map<string, Episode>();
|
||||
|
||||
for (const event of events) {
|
||||
if (String(event.eventType || "").toLowerCase() !== "macrostop") continue;
|
||||
|
||||
// Defensive: parse data the same way timeline.ts does.
|
||||
let parsed: unknown = event.data;
|
||||
if (typeof parsed === "string") {
|
||||
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
|
||||
}
|
||||
const data: Record<string, unknown> =
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
// Drop only the auto-ack pings (same rule as timeline.ts Fix B).
|
||||
const isAutoAck =
|
||||
data.is_auto_ack === true ||
|
||||
data.isAutoAck === true ||
|
||||
data.is_auto_ack === "true" ||
|
||||
data.isAutoAck === "true";
|
||||
if (isAutoAck) continue;
|
||||
|
||||
const status = String(data.status ?? "").trim().toLowerCase();
|
||||
const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
|
||||
|| `macrostop:${event.ts.getTime()}`;
|
||||
const tsMs = event.ts.getTime();
|
||||
|
||||
const existing = episodes.get(incidentKey);
|
||||
if (!existing) {
|
||||
episodes.set(incidentKey, { firstTsMs: tsMs, lastTsMs: tsMs, lastStatus: status });
|
||||
continue;
|
||||
}
|
||||
existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
|
||||
if (tsMs >= existing.lastTsMs) {
|
||||
existing.lastTsMs = tsMs;
|
||||
existing.lastStatus = status;
|
||||
}
|
||||
}
|
||||
|
||||
let activeOngoingMin = 0;
|
||||
for (const ep of episodes.values()) {
|
||||
if (ep.lastStatus !== "active") continue;
|
||||
if (endMs - ep.lastTsMs > STOPPAGE_ACTIVE_STALE_MS) continue;
|
||||
const ongoingMin = Math.max(0, Math.floor((endMs - ep.firstTsMs) / 60000));
|
||||
if (ongoingMin > activeOngoingMin) activeOngoingMin = ongoingMin;
|
||||
}
|
||||
|
||||
return activeOngoingMin > 0 ? activeOngoingMin : null;
|
||||
}
|
||||
|
||||
function statusFromMachine(machine: RecapMachine, endMs: number, events?: TimelineEventRow[]) {
|
||||
const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
|
||||
const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
|
||||
const offline = !Number.isFinite(lastSeenMs ?? Number.NaN) || offlineForMs > OFFLINE_THRESHOLD_MS;
|
||||
|
||||
// ongoingStopMin from the legacy heartbeat-based path (typically null) OR
|
||||
// from the macrostop event detection (preferred — accurate)
|
||||
const macrostopOngoingMin = detectActiveMacrostop(events, endMs);
|
||||
const legacyOngoingStopMin = machine.downtime.ongoingStopMin ?? 0;
|
||||
const ongoingStopMin = macrostopOngoingMin ?? (legacyOngoingStopMin > 0 ? legacyOngoingStopMin : null);
|
||||
|
||||
const moldActive = machine.workOrders.moldChangeInProgress;
|
||||
|
||||
let status: RecapMachineStatus = "running";
|
||||
if (offline) status = "offline";
|
||||
else if (moldActive) status = "mold-change";
|
||||
else if (ongoingStopMin != null && ongoingStopMin > 0) status = "stopped";
|
||||
|
||||
return {
|
||||
status,
|
||||
lastSeenMs,
|
||||
offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null,
|
||||
ongoingStopMin,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTimelineRowsForMachines(params: {
|
||||
orgId: string;
|
||||
machineIds: string[];
|
||||
start: Date;
|
||||
end: Date;
|
||||
}) {
|
||||
if (!params.machineIds.length) {
|
||||
return {
|
||||
cyclesByMachine: new Map<string, TimelineCycleRow[]>(),
|
||||
eventsByMachine: new Map<string, TimelineEventRow[]>(),
|
||||
};
|
||||
}
|
||||
|
||||
const [cycles, events] = await Promise.all([
|
||||
prisma.machineCycle.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: { in: params.machineIds },
|
||||
ts: {
|
||||
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||
lte: params.end,
|
||||
},
|
||||
},
|
||||
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||
select: {
|
||||
machineId: true,
|
||||
ts: true,
|
||||
cycleCount: true,
|
||||
actualCycleTime: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
},
|
||||
}),
|
||||
prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: { in: params.machineIds },
|
||||
eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
|
||||
ts: {
|
||||
gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
|
||||
lte: params.end,
|
||||
},
|
||||
},
|
||||
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||
select: {
|
||||
machineId: true,
|
||||
ts: true,
|
||||
eventType: true,
|
||||
data: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const cyclesByMachine = new Map<string, TimelineCycleRow[]>();
|
||||
const eventsByMachine = new Map<string, TimelineEventRow[]>();
|
||||
|
||||
for (const row of cycles) {
|
||||
const list = cyclesByMachine.get(row.machineId) ?? [];
|
||||
list.push({
|
||||
ts: row.ts,
|
||||
cycleCount: row.cycleCount,
|
||||
actualCycleTime: row.actualCycleTime,
|
||||
workOrderId: row.workOrderId,
|
||||
sku: row.sku,
|
||||
});
|
||||
cyclesByMachine.set(row.machineId, list);
|
||||
}
|
||||
|
||||
for (const row of events) {
|
||||
const list = eventsByMachine.get(row.machineId) ?? [];
|
||||
list.push({
|
||||
ts: row.ts,
|
||||
eventType: row.eventType,
|
||||
data: row.data,
|
||||
});
|
||||
eventsByMachine.set(row.machineId, list);
|
||||
}
|
||||
|
||||
return { cyclesByMachine, eventsByMachine };
|
||||
}
|
||||
|
||||
function toSummaryMachine(params: {
|
||||
machine: RecapMachine;
|
||||
miniTimeline: ReturnType<typeof compressTimelineSegments>;
|
||||
rangeEndMs: number;
|
||||
events?: TimelineEventRow[];
|
||||
}): RecapSummaryMachine {
|
||||
const { machine, miniTimeline, rangeEndMs, events } = params;
|
||||
const status = statusFromMachine(machine, rangeEndMs, events);
|
||||
|
||||
return {
|
||||
machineId: machine.machineId,
|
||||
name: machine.machineName,
|
||||
location: machine.location,
|
||||
status: status.status,
|
||||
oee: machine.oee.avg,
|
||||
goodParts: machine.production.goodParts,
|
||||
scrap: machine.production.scrapParts,
|
||||
stopsCount: machine.downtime.stopsCount,
|
||||
lastSeenMs: status.lastSeenMs,
|
||||
lastActivityMin:
|
||||
status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
|
||||
offlineForMin: status.offlineForMin,
|
||||
ongoingStopMin: status.ongoingStopMin,
|
||||
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
||||
moldChange: {
|
||||
active: machine.workOrders.moldChangeInProgress,
|
||||
startMs: machine.workOrders.moldChangeStartMs,
|
||||
elapsedMin:
|
||||
machine.workOrders.moldChangeStartMs == null
|
||||
? null
|
||||
: Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)),
|
||||
},
|
||||
miniTimeline,
|
||||
};
|
||||
}
|
||||
|
||||
async function computeRecapSummary(params: { orgId: string; hours: number }) {
|
||||
const now = new Date();
|
||||
const end = new Date(Math.floor(now.getTime() / 60000) * 60000);
|
||||
const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000);
|
||||
|
||||
const recap = await getRecapDataCached({
|
||||
orgId: params.orgId,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
const machineIds = recap.machines.map((machine) => machine.machineId);
|
||||
const timelineRows = await loadTimelineRowsForMachines({
|
||||
orgId: params.orgId,
|
||||
machineIds,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
const machines = recap.machines.map((machine) => {
|
||||
const segments = buildTimelineSegments({
|
||||
cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [],
|
||||
events: timelineRows.eventsByMachine.get(machine.machineId) ?? [],
|
||||
rangeStart: start,
|
||||
rangeEnd: end,
|
||||
});
|
||||
const miniTimeline = compressTimelineSegments({
|
||||
segments,
|
||||
rangeStart: start,
|
||||
rangeEnd: end,
|
||||
maxSegments: 60,
|
||||
});
|
||||
|
||||
return toSummaryMachine({
|
||||
machine,
|
||||
miniTimeline,
|
||||
rangeEndMs: end.getTime(),
|
||||
events: timelineRows.eventsByMachine.get(machine.machineId),
|
||||
});
|
||||
});
|
||||
|
||||
const response: RecapSummaryResponse = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
range: {
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
hours: params.hours,
|
||||
},
|
||||
machines,
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function normalizedRangeMode(mode?: string | null): RecapRangeMode {
|
||||
const raw = String(mode ?? "").trim().toLowerCase();
|
||||
if (raw === "shift") return "shift";
|
||||
if (raw === "yesterday") return "yesterday";
|
||||
if (raw === "custom") return "custom";
|
||||
return "24h";
|
||||
}
|
||||
|
||||
async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
|
||||
const settings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: params.orgId },
|
||||
select: {
|
||||
timezone: true,
|
||||
shiftScheduleOverridesJson: true,
|
||||
},
|
||||
});
|
||||
const shifts = await prisma.orgShift.findMany({
|
||||
where: { orgId: params.orgId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: {
|
||||
name: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
enabled: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
});
|
||||
|
||||
const enabledShifts = shifts.filter((shift) => shift.enabled !== false);
|
||||
if (!enabledShifts.length) {
|
||||
return {
|
||||
hasEnabledShifts: false,
|
||||
range: null,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const timeZone = settings?.timezone || "UTC";
|
||||
const local = getLocalParts(params.now, timeZone);
|
||||
const overrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
|
||||
const dayOverrides = overrides?.[local.weekday];
|
||||
const activeShifts = (dayOverrides?.length
|
||||
? dayOverrides.map((shift) => ({
|
||||
enabled: shift.enabled !== false,
|
||||
start: shift.start,
|
||||
end: shift.end,
|
||||
}))
|
||||
: enabledShifts.map((shift) => ({
|
||||
enabled: shift.enabled !== false,
|
||||
start: shift.startTime,
|
||||
end: shift.endTime,
|
||||
}))
|
||||
).filter((shift) => shift.enabled);
|
||||
|
||||
for (const shift of activeShifts) {
|
||||
const startMin = parseTimeMinutes(shift.start ?? null);
|
||||
const endMin = parseTimeMinutes(shift.end ?? null);
|
||||
if (startMin == null || endMin == null) continue;
|
||||
|
||||
const minutesNow = local.minutesOfDay;
|
||||
let inRange = false;
|
||||
let startDate = { year: local.year, month: local.month, day: local.day };
|
||||
let endDate = { year: local.year, month: local.month, day: local.day };
|
||||
|
||||
if (startMin <= endMin) {
|
||||
inRange = minutesNow >= startMin && minutesNow < endMin;
|
||||
} else {
|
||||
inRange = minutesNow >= startMin || minutesNow < endMin;
|
||||
if (minutesNow >= startMin) {
|
||||
endDate = addDays(endDate, 1);
|
||||
} else {
|
||||
startDate = addDays(startDate, -1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!inRange) continue;
|
||||
|
||||
const start = zonedToUtcDate({
|
||||
...startDate,
|
||||
hours: Math.floor(startMin / 60),
|
||||
minutes: startMin % 60,
|
||||
timeZone,
|
||||
});
|
||||
const shiftEndUtc = zonedToUtcDate({
|
||||
...endDate,
|
||||
hours: Math.floor(endMin / 60),
|
||||
minutes: endMin % 60,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
if (shiftEndUtc <= start) continue;
|
||||
|
||||
// Cap end at "now" so we render shift-so-far, not shift-as-planned.
|
||||
// Without cap:
|
||||
// - timeline fills future minutes with idle (visual lie)
|
||||
// - offline calc = (shift_end_future - last_seen) = looks 5h offline
|
||||
// even on a machine producing right now
|
||||
const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;
|
||||
|
||||
return {
|
||||
hasEnabledShifts: true,
|
||||
range: { start, end },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasEnabledShifts: true,
|
||||
range: null,
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
|
||||
const now = new Date(Math.floor(Date.now() / 60000) * 60000);
|
||||
const requestedMode = normalizedRangeMode(params.input.mode);
|
||||
const shiftEnabledCount = await prisma.orgShift.count({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
enabled: { not: false },
|
||||
},
|
||||
});
|
||||
const shiftAvailable = shiftEnabledCount > 0;
|
||||
|
||||
if (requestedMode === "custom") {
|
||||
const start = parseDate(params.input.start);
|
||||
const end = parseDate(params.input.end);
|
||||
if (start && end && end > start) {
|
||||
return {
|
||||
requestedMode,
|
||||
mode: requestedMode,
|
||||
start,
|
||||
end,
|
||||
shiftAvailable,
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedMode === "yesterday") {
|
||||
const settings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: params.orgId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const timeZone = settings?.timezone || "America/Mexico_City";
|
||||
const localNow = getLocalParts(now, timeZone);
|
||||
const today = { year: localNow.year, month: localNow.month, day: localNow.day };
|
||||
const yesterday = addDays(today, -1);
|
||||
const start = zonedToUtcDate({
|
||||
...yesterday,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
timeZone,
|
||||
});
|
||||
const end = zonedToUtcDate({
|
||||
...today,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
timeZone,
|
||||
});
|
||||
return {
|
||||
requestedMode,
|
||||
mode: requestedMode,
|
||||
start,
|
||||
end,
|
||||
shiftAvailable,
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (requestedMode === "shift") {
|
||||
const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
|
||||
if (shiftRange.range) {
|
||||
return {
|
||||
requestedMode,
|
||||
mode: requestedMode,
|
||||
start: shiftRange.range.start,
|
||||
end: shiftRange.range.end,
|
||||
shiftAvailable,
|
||||
} as const;
|
||||
}
|
||||
if (!shiftRange.hasEnabledShifts) {
|
||||
return {
|
||||
requestedMode,
|
||||
mode: "24h" as const,
|
||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
shiftAvailable,
|
||||
fallbackReason: "shift-unavailable" as const,
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
requestedMode,
|
||||
mode: "24h" as const,
|
||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
shiftAvailable,
|
||||
fallbackReason: "shift-inactive" as const,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
requestedMode,
|
||||
mode: "24h" as const,
|
||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
shiftAvailable,
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function computeRecapMachineDetail(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
range: {
|
||||
requestedMode: RecapRangeMode;
|
||||
mode: RecapRangeMode;
|
||||
start: Date;
|
||||
end: Date;
|
||||
shiftAvailable: boolean;
|
||||
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||
};
|
||||
}) {
|
||||
const { range } = params;
|
||||
|
||||
const recap = await getRecapDataCached({
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
});
|
||||
|
||||
const machine = recap.machines.find((row) => row.machineId === params.machineId) ?? null;
|
||||
if (!machine) return null;
|
||||
|
||||
const timelineRows = await loadTimelineRowsForMachines({
|
||||
orgId: params.orgId,
|
||||
machineIds: [params.machineId],
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
});
|
||||
|
||||
const timeline = buildTimelineSegments({
|
||||
cycles: timelineRows.cyclesByMachine.get(params.machineId) ?? [],
|
||||
events: timelineRows.eventsByMachine.get(params.machineId) ?? [],
|
||||
rangeStart: range.start,
|
||||
rangeEnd: range.end,
|
||||
});
|
||||
|
||||
const status = statusFromMachine(
|
||||
machine,
|
||||
range.end.getTime(),
|
||||
timelineRows.eventsByMachine.get(params.machineId)
|
||||
);
|
||||
|
||||
const downtimeTotalMin = Math.max(0, machine.downtime.totalMin);
|
||||
const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({
|
||||
reasonLabel: row.reasonLabel,
|
||||
minutes: row.minutes,
|
||||
count: row.count,
|
||||
percent: downtimeTotalMin > 0 ? round2((row.minutes / downtimeTotalMin) * 100) : 0,
|
||||
}));
|
||||
|
||||
const machineDetail: RecapMachineDetail = {
|
||||
machineId: machine.machineId,
|
||||
name: machine.machineName,
|
||||
location: machine.location,
|
||||
status: status.status,
|
||||
oee: machine.oee.avg,
|
||||
goodParts: machine.production.goodParts,
|
||||
scrap: machine.production.scrapParts,
|
||||
stopsCount: machine.downtime.stopsCount,
|
||||
stopMinutes: downtimeTotalMin,
|
||||
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
||||
lastSeenMs: status.lastSeenMs,
|
||||
offlineForMin: status.offlineForMin,
|
||||
ongoingStopMin: status.ongoingStopMin,
|
||||
moldChange: {
|
||||
active: machine.workOrders.moldChangeInProgress,
|
||||
startMs: machine.workOrders.moldChangeStartMs,
|
||||
},
|
||||
timeline,
|
||||
productionBySku: machine.production.bySku,
|
||||
downtimeTop,
|
||||
workOrders: {
|
||||
completed: machine.workOrders.completed,
|
||||
active: machine.workOrders.active,
|
||||
},
|
||||
heartbeat: {
|
||||
lastSeenAt: machine.heartbeat.lastSeenAt,
|
||||
uptimePct: machine.heartbeat.uptimePct,
|
||||
connectionStatus: status.status === "offline" ? "offline" : "online",
|
||||
},
|
||||
};
|
||||
|
||||
const response: RecapDetailResponse = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
range: {
|
||||
requestedMode: range.requestedMode,
|
||||
mode: range.mode,
|
||||
start: range.start.toISOString(),
|
||||
end: range.end.toISOString(),
|
||||
shiftAvailable: range.shiftAvailable,
|
||||
fallbackReason: range.fallbackReason,
|
||||
},
|
||||
machine: machineDetail,
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function summaryCacheKey(params: { orgId: string; hours: number }) {
|
||||
return ["recap-summary-v1", params.orgId, String(params.hours)];
|
||||
}
|
||||
|
||||
function detailCacheKey(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
requestedMode: RecapRangeMode;
|
||||
mode: RecapRangeMode;
|
||||
shiftAvailable: boolean;
|
||||
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}) {
|
||||
return [
|
||||
"recap-detail-v1",
|
||||
params.orgId,
|
||||
params.machineId,
|
||||
params.requestedMode,
|
||||
params.mode,
|
||||
params.shiftAvailable ? "shift-on" : "shift-off",
|
||||
params.fallbackReason ?? "",
|
||||
String(Math.trunc(params.startMs / 60000)),
|
||||
String(Math.trunc(params.endMs / 60000)),
|
||||
];
|
||||
}
|
||||
|
||||
export function parseRecapSummaryHours(raw: string | null) {
|
||||
return parseHours(raw);
|
||||
}
|
||||
|
||||
export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record<string, string | string[] | undefined>) {
|
||||
if (searchParams instanceof URLSearchParams) {
|
||||
return {
|
||||
mode: searchParams.get("range") ?? undefined,
|
||||
start: searchParams.get("start") ?? undefined,
|
||||
end: searchParams.get("end") ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const pick = (key: string) => {
|
||||
const value = searchParams[key];
|
||||
if (Array.isArray(value)) return value[0] ?? undefined;
|
||||
return value ?? undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
mode: pick("range"),
|
||||
start: pick("start"),
|
||||
end: pick("end"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRecapSummaryCached(params: { orgId: string; hours: number }) {
|
||||
const cache = unstable_cache(
|
||||
() => computeRecapSummary(params),
|
||||
summaryCacheKey(params),
|
||||
{
|
||||
revalidate: RECAP_CACHE_TTL_SEC,
|
||||
tags: [`recap:${params.orgId}`],
|
||||
}
|
||||
);
|
||||
|
||||
return cache();
|
||||
}
|
||||
|
||||
export async function getRecapMachineDetailCached(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
input: DetailRangeInput;
|
||||
}) {
|
||||
const resolved = await resolveDetailRange({
|
||||
orgId: params.orgId,
|
||||
input: params.input,
|
||||
});
|
||||
|
||||
const cache = unstable_cache(
|
||||
() =>
|
||||
computeRecapMachineDetail({
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
range: {
|
||||
requestedMode: resolved.requestedMode,
|
||||
mode: resolved.mode,
|
||||
start: resolved.start,
|
||||
end: resolved.end,
|
||||
shiftAvailable: resolved.shiftAvailable,
|
||||
fallbackReason: resolved.fallbackReason,
|
||||
},
|
||||
}),
|
||||
detailCacheKey({
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
requestedMode: resolved.requestedMode,
|
||||
mode: resolved.mode,
|
||||
shiftAvailable: resolved.shiftAvailable,
|
||||
fallbackReason: resolved.fallbackReason,
|
||||
startMs: resolved.start.getTime(),
|
||||
endMs: resolved.end.getTime(),
|
||||
}),
|
||||
{
|
||||
revalidate: RECAP_CACHE_TTL_SEC,
|
||||
tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
|
||||
}
|
||||
);
|
||||
|
||||
return cache();
|
||||
}
|
||||
905
lib/recap/redesign.ts.bak.step4
Normal file
905
lib/recap/redesign.ts.bak.step4
Normal file
@@ -0,0 +1,905 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
|
||||
import { getRecapDataCached } from "@/lib/recap/getRecapData";
|
||||
import {
|
||||
buildTimelineSegments,
|
||||
compressTimelineSegments,
|
||||
TIMELINE_EVENT_TYPES,
|
||||
type TimelineCycleRow,
|
||||
type TimelineEventRow,
|
||||
} from "@/lib/recap/timeline";
|
||||
import { classifyMachineState, type MachineStateResult } from "@/lib/recap/machineState";
|
||||
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||
import type {
|
||||
RecapDetailResponse,
|
||||
RecapMachine,
|
||||
RecapMachineDetail,
|
||||
RecapMachineStatus,
|
||||
RecapRangeMode,
|
||||
RecapSummaryMachine,
|
||||
RecapSummaryResponse,
|
||||
} from "@/lib/recap/types";
|
||||
|
||||
type DetailRangeInput = {
|
||||
mode?: string | null;
|
||||
start?: string | null;
|
||||
end?: string | null;
|
||||
};
|
||||
|
||||
const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
|
||||
const RECAP_CACHE_TTL_SEC = 60;
|
||||
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||
const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
|
||||
Mon: "mon",
|
||||
Tue: "tue",
|
||||
Wed: "wed",
|
||||
Thu: "thu",
|
||||
Fri: "fri",
|
||||
Sat: "sat",
|
||||
Sun: "sun",
|
||||
};
|
||||
|
||||
function round2(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function parseDate(input?: string | null) {
|
||||
if (!input) return null;
|
||||
const n = Number(input);
|
||||
if (Number.isFinite(n)) {
|
||||
const d = new Date(n);
|
||||
return Number.isFinite(d.getTime()) ? d : null;
|
||||
}
|
||||
const d = new Date(input);
|
||||
return Number.isFinite(d.getTime()) ? d : null;
|
||||
}
|
||||
|
||||
function parseHours(input: string | null) {
|
||||
const parsed = Math.trunc(Number(input ?? "24"));
|
||||
if (!Number.isFinite(parsed)) return 24;
|
||||
return Math.max(1, Math.min(72, parsed));
|
||||
}
|
||||
|
||||
function parseTimeMinutes(input?: string | null) {
|
||||
if (!input) return null;
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
||||
return null;
|
||||
}
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
function getLocalParts(ts: Date, timeZone: string) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
weekday: "short",
|
||||
hour12: false,
|
||||
}).formatToParts(ts);
|
||||
|
||||
const value = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
|
||||
const year = Number(value("year"));
|
||||
const month = Number(value("month"));
|
||||
const day = Number(value("day"));
|
||||
const hour = Number(value("hour"));
|
||||
const minute = Number(value("minute"));
|
||||
const weekday = value("weekday");
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()],
|
||||
minutesOfDay: hour * 60 + minute,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
year: ts.getUTCFullYear(),
|
||||
month: ts.getUTCMonth() + 1,
|
||||
day: ts.getUTCDate(),
|
||||
hour: ts.getUTCHours(),
|
||||
minute: ts.getUTCMinutes(),
|
||||
weekday: WEEKDAY_KEYS[ts.getUTCDay()],
|
||||
minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseOffsetMinutes(offsetLabel: string | null) {
|
||||
if (!offsetLabel) return null;
|
||||
const normalized = offsetLabel.replace("UTC", "GMT");
|
||||
const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized);
|
||||
if (!match) return null;
|
||||
const sign = match[1] === "-" ? -1 : 1;
|
||||
const hour = Number(match[2]);
|
||||
const minute = Number(match[3] ?? "0");
|
||||
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
||||
return sign * (hour * 60 + minute);
|
||||
}
|
||||
|
||||
function getTzOffsetMinutes(utcDate: Date, timeZone: string) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
timeZoneName: "shortOffset",
|
||||
hour: "2-digit",
|
||||
}).formatToParts(utcDate);
|
||||
const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null;
|
||||
return parseOffsetMinutes(offsetPart);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function zonedToUtcDate(input: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
timeZone: string;
|
||||
}) {
|
||||
const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0);
|
||||
const guessDate = new Date(baseUtc);
|
||||
const offsetA = getTzOffsetMinutes(guessDate, input.timeZone);
|
||||
if (offsetA == null) return guessDate;
|
||||
|
||||
let corrected = new Date(baseUtc - offsetA * 60000);
|
||||
const offsetB = getTzOffsetMinutes(corrected, input.timeZone);
|
||||
if (offsetB != null && offsetB !== offsetA) {
|
||||
corrected = new Date(baseUtc - offsetB * 60000);
|
||||
}
|
||||
|
||||
return corrected;
|
||||
}
|
||||
|
||||
function addDays(input: { year: number; month: number; day: number }, days: number) {
|
||||
const base = new Date(Date.UTC(input.year, input.month - 1, input.day));
|
||||
base.setUTCDate(base.getUTCDate() + days);
|
||||
return {
|
||||
year: base.getUTCFullYear(),
|
||||
month: base.getUTCMonth() + 1,
|
||||
day: base.getUTCDate(),
|
||||
};
|
||||
}
|
||||
|
||||
// Detect active episodes (macrostop, mold-change) from event rows.
|
||||
// Returns the latest non-auto-ack episode whose final status is "active"
|
||||
// and that's been refreshed within ACTIVE_STALE_MS.
|
||||
const ACTIVE_STALE_MS = 2 * 60 * 1000;
|
||||
|
||||
type ActiveEpisode = { startedAtMs: number; lastTsMs: number };
|
||||
|
||||
function detectActiveEpisode(
|
||||
events: TimelineEventRow[] | undefined,
|
||||
eventType: "macrostop" | "mold-change",
|
||||
endMs: number
|
||||
): ActiveEpisode | null {
|
||||
if (!events || events.length === 0) return null;
|
||||
|
||||
type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string; lastCycleTs: number | null };
|
||||
const episodes = new Map<string, Episode>();
|
||||
|
||||
for (const event of events) {
|
||||
if (String(event.eventType || "").toLowerCase() !== eventType) continue;
|
||||
|
||||
let parsed: unknown = event.data;
|
||||
if (typeof parsed === "string") {
|
||||
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
|
||||
}
|
||||
const data: Record<string, unknown> =
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const isAutoAck =
|
||||
data.is_auto_ack === true ||
|
||||
data.isAutoAck === true ||
|
||||
data.is_auto_ack === "true" ||
|
||||
data.isAutoAck === "true";
|
||||
if (isAutoAck) continue;
|
||||
|
||||
const status = String(data.status ?? "").trim().toLowerCase();
|
||||
const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
|
||||
|| `${eventType}:${event.ts.getTime()}`;
|
||||
const tsMs = event.ts.getTime();
|
||||
const lastCycleTs = Number(data.last_cycle_timestamp);
|
||||
|
||||
const existing = episodes.get(incidentKey);
|
||||
if (!existing) {
|
||||
episodes.set(incidentKey, {
|
||||
firstTsMs: tsMs,
|
||||
lastTsMs: tsMs,
|
||||
lastStatus: status,
|
||||
lastCycleTs: Number.isFinite(lastCycleTs) && lastCycleTs > 0 ? lastCycleTs : null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
|
||||
if (tsMs >= existing.lastTsMs) {
|
||||
existing.lastTsMs = tsMs;
|
||||
existing.lastStatus = status;
|
||||
}
|
||||
}
|
||||
|
||||
let best: ActiveEpisode | null = null;
|
||||
for (const ep of episodes.values()) {
|
||||
if (ep.lastStatus !== "active") continue;
|
||||
if (endMs - ep.lastTsMs > ACTIVE_STALE_MS) continue;
|
||||
// Prefer the freshest active episode (highest lastTsMs)
|
||||
if (!best || ep.lastTsMs > best.lastTsMs) {
|
||||
best = {
|
||||
startedAtMs: ep.lastCycleTs ?? ep.firstTsMs,
|
||||
lastTsMs: ep.lastTsMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function statusFromMachine(
|
||||
machine: RecapMachine,
|
||||
endMs: number,
|
||||
events?: TimelineEventRow[]
|
||||
): {
|
||||
status: RecapMachineStatus;
|
||||
result: MachineStateResult;
|
||||
lastSeenMs: number | null;
|
||||
offlineForMin: number | null;
|
||||
ongoingStopMin: number | null;
|
||||
} {
|
||||
const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
|
||||
const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
|
||||
const heartbeatAlive = Number.isFinite(lastSeenMs ?? Number.NaN) && offlineForMs <= OFFLINE_THRESHOLD_MS;
|
||||
|
||||
const activeMacrostop = detectActiveEpisode(events, "macrostop", endMs);
|
||||
const activeMoldChange = detectActiveEpisode(events, "mold-change", endMs);
|
||||
|
||||
// Round 1 limitation: trackingEnabled and untrackedCycles inputs require KPI/cycle queries
|
||||
// we don't yet plumb here. We approximate from the legacy fields:
|
||||
// - trackingEnabled: true when there's an active macrostop (Pi only fires those when tracking on)
|
||||
// OR when an active WO exists and machine.workOrders.moldChangeInProgress is false.
|
||||
// This is a SIMPLIFICATION; Round 3 will replace with real KPI snapshot read.
|
||||
// - untrackedCycles: 0 (Round 3 will compute from MachineCycle vs latest KPI)
|
||||
//
|
||||
// Effect for Round 1: STOPPED `not_started` reason cannot trigger yet (we always assume tracking
|
||||
// is on when a WO exists). Only `machine_fault` STOPPED fires. DATA_LOSS cannot fire yet.
|
||||
// IDLE fires correctly when there's no WO and no recent activity.
|
||||
const hasActiveWorkOrder = machine.workOrders.active != null;
|
||||
const trackingEnabledApprox = hasActiveWorkOrder; // see comment above
|
||||
|
||||
const lastCycleTsMs = (() => {
|
||||
// Best-effort: use the machine's heartbeat as a "recent activity" proxy.
|
||||
// The Pi only heartbeats every minute regardless of cycles, so this is a weak signal.
|
||||
// Round 3 will pass the actual latest cycle ts.
|
||||
return lastSeenMs;
|
||||
})();
|
||||
|
||||
const result = classifyMachineState(
|
||||
{
|
||||
heartbeatAlive,
|
||||
lastSeenMs,
|
||||
offlineForMs,
|
||||
trackingEnabled: trackingEnabledApprox,
|
||||
hasActiveWorkOrder,
|
||||
activeMoldChange,
|
||||
activeMacrostop,
|
||||
untrackedCycles: { count: 0, oldestTsMs: null },
|
||||
lastCycleTsMs,
|
||||
},
|
||||
endMs
|
||||
);
|
||||
|
||||
// Map the rich classifier result back to the existing RecapMachineStatus union
|
||||
const status: RecapMachineStatus = result.state;
|
||||
|
||||
// Pull common fields out for the caller's convenience
|
||||
let ongoingStopMin: number | null = null;
|
||||
if (result.state === "stopped") ongoingStopMin = result.ongoingStopMin;
|
||||
|
||||
return {
|
||||
status,
|
||||
result,
|
||||
lastSeenMs,
|
||||
offlineForMin: result.state === "offline" ? result.offlineForMin : null,
|
||||
ongoingStopMin,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTimelineRowsForMachines(params: {
|
||||
orgId: string;
|
||||
machineIds: string[];
|
||||
start: Date;
|
||||
end: Date;
|
||||
}) {
|
||||
if (!params.machineIds.length) {
|
||||
return {
|
||||
cyclesByMachine: new Map<string, TimelineCycleRow[]>(),
|
||||
eventsByMachine: new Map<string, TimelineEventRow[]>(),
|
||||
};
|
||||
}
|
||||
|
||||
const [cycles, events] = await Promise.all([
|
||||
prisma.machineCycle.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: { in: params.machineIds },
|
||||
ts: {
|
||||
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||
lte: params.end,
|
||||
},
|
||||
},
|
||||
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||
select: {
|
||||
machineId: true,
|
||||
ts: true,
|
||||
cycleCount: true,
|
||||
actualCycleTime: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
},
|
||||
}),
|
||||
prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: { in: params.machineIds },
|
||||
eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
|
||||
ts: {
|
||||
gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
|
||||
lte: params.end,
|
||||
},
|
||||
},
|
||||
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||
select: {
|
||||
machineId: true,
|
||||
ts: true,
|
||||
eventType: true,
|
||||
data: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const cyclesByMachine = new Map<string, TimelineCycleRow[]>();
|
||||
const eventsByMachine = new Map<string, TimelineEventRow[]>();
|
||||
|
||||
for (const row of cycles) {
|
||||
const list = cyclesByMachine.get(row.machineId) ?? [];
|
||||
list.push({
|
||||
ts: row.ts,
|
||||
cycleCount: row.cycleCount,
|
||||
actualCycleTime: row.actualCycleTime,
|
||||
workOrderId: row.workOrderId,
|
||||
sku: row.sku,
|
||||
});
|
||||
cyclesByMachine.set(row.machineId, list);
|
||||
}
|
||||
|
||||
for (const row of events) {
|
||||
const list = eventsByMachine.get(row.machineId) ?? [];
|
||||
list.push({
|
||||
ts: row.ts,
|
||||
eventType: row.eventType,
|
||||
data: row.data,
|
||||
});
|
||||
eventsByMachine.set(row.machineId, list);
|
||||
}
|
||||
|
||||
return { cyclesByMachine, eventsByMachine };
|
||||
}
|
||||
|
||||
function toSummaryMachine(params: {
|
||||
machine: RecapMachine;
|
||||
miniTimeline: ReturnType<typeof compressTimelineSegments>;
|
||||
rangeEndMs: number;
|
||||
events?: TimelineEventRow[];
|
||||
}): RecapSummaryMachine {
|
||||
const { machine, miniTimeline, rangeEndMs, events } = params;
|
||||
const status = statusFromMachine(machine, rangeEndMs, events);
|
||||
|
||||
return {
|
||||
machineId: machine.machineId,
|
||||
name: machine.machineName,
|
||||
location: machine.location,
|
||||
status: status.status,
|
||||
oee: machine.oee.avg,
|
||||
goodParts: machine.production.goodParts,
|
||||
scrap: machine.production.scrapParts,
|
||||
stopsCount: machine.downtime.stopsCount,
|
||||
lastSeenMs: status.lastSeenMs,
|
||||
lastActivityMin:
|
||||
status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
|
||||
offlineForMin: status.offlineForMin,
|
||||
ongoingStopMin: status.ongoingStopMin,
|
||||
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
||||
moldChange: {
|
||||
active: machine.workOrders.moldChangeInProgress,
|
||||
startMs: machine.workOrders.moldChangeStartMs,
|
||||
elapsedMin:
|
||||
machine.workOrders.moldChangeStartMs == null
|
||||
? null
|
||||
: Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)),
|
||||
},
|
||||
miniTimeline,
|
||||
};
|
||||
}
|
||||
|
||||
async function computeRecapSummary(params: { orgId: string; hours: number }) {
|
||||
const now = new Date();
|
||||
const end = new Date(Math.floor(now.getTime() / 60000) * 60000);
|
||||
const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000);
|
||||
|
||||
const recap = await getRecapDataCached({
|
||||
orgId: params.orgId,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
const machineIds = recap.machines.map((machine) => machine.machineId);
|
||||
const timelineRows = await loadTimelineRowsForMachines({
|
||||
orgId: params.orgId,
|
||||
machineIds,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
const machines = recap.machines.map((machine) => {
|
||||
const segments = buildTimelineSegments({
|
||||
cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [],
|
||||
events: timelineRows.eventsByMachine.get(machine.machineId) ?? [],
|
||||
rangeStart: start,
|
||||
rangeEnd: end,
|
||||
});
|
||||
const miniTimeline = compressTimelineSegments({
|
||||
segments,
|
||||
rangeStart: start,
|
||||
rangeEnd: end,
|
||||
maxSegments: 60,
|
||||
});
|
||||
|
||||
return toSummaryMachine({
|
||||
machine,
|
||||
miniTimeline,
|
||||
rangeEndMs: end.getTime(),
|
||||
events: timelineRows.eventsByMachine.get(machine.machineId),
|
||||
});
|
||||
});
|
||||
|
||||
const response: RecapSummaryResponse = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
range: {
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
hours: params.hours,
|
||||
},
|
||||
machines,
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function normalizedRangeMode(mode?: string | null): RecapRangeMode {
|
||||
const raw = String(mode ?? "").trim().toLowerCase();
|
||||
if (raw === "shift") return "shift";
|
||||
if (raw === "yesterday") return "yesterday";
|
||||
if (raw === "custom") return "custom";
|
||||
return "24h";
|
||||
}
|
||||
|
||||
async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
|
||||
const settings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: params.orgId },
|
||||
select: {
|
||||
timezone: true,
|
||||
shiftScheduleOverridesJson: true,
|
||||
},
|
||||
});
|
||||
const shifts = await prisma.orgShift.findMany({
|
||||
where: { orgId: params.orgId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: {
|
||||
name: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
enabled: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
});
|
||||
|
||||
const enabledShifts = shifts.filter((shift) => shift.enabled !== false);
|
||||
if (!enabledShifts.length) {
|
||||
return {
|
||||
hasEnabledShifts: false,
|
||||
range: null,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const timeZone = settings?.timezone || "UTC";
|
||||
const local = getLocalParts(params.now, timeZone);
|
||||
const overrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
|
||||
const dayOverrides = overrides?.[local.weekday];
|
||||
const activeShifts = (dayOverrides?.length
|
||||
? dayOverrides.map((shift) => ({
|
||||
enabled: shift.enabled !== false,
|
||||
start: shift.start,
|
||||
end: shift.end,
|
||||
}))
|
||||
: enabledShifts.map((shift) => ({
|
||||
enabled: shift.enabled !== false,
|
||||
start: shift.startTime,
|
||||
end: shift.endTime,
|
||||
}))
|
||||
).filter((shift) => shift.enabled);
|
||||
|
||||
for (const shift of activeShifts) {
|
||||
const startMin = parseTimeMinutes(shift.start ?? null);
|
||||
const endMin = parseTimeMinutes(shift.end ?? null);
|
||||
if (startMin == null || endMin == null) continue;
|
||||
|
||||
const minutesNow = local.minutesOfDay;
|
||||
let inRange = false;
|
||||
let startDate = { year: local.year, month: local.month, day: local.day };
|
||||
let endDate = { year: local.year, month: local.month, day: local.day };
|
||||
|
||||
if (startMin <= endMin) {
|
||||
inRange = minutesNow >= startMin && minutesNow < endMin;
|
||||
} else {
|
||||
inRange = minutesNow >= startMin || minutesNow < endMin;
|
||||
if (minutesNow >= startMin) {
|
||||
endDate = addDays(endDate, 1);
|
||||
} else {
|
||||
startDate = addDays(startDate, -1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!inRange) continue;
|
||||
|
||||
const start = zonedToUtcDate({
|
||||
...startDate,
|
||||
hours: Math.floor(startMin / 60),
|
||||
minutes: startMin % 60,
|
||||
timeZone,
|
||||
});
|
||||
const shiftEndUtc = zonedToUtcDate({
|
||||
...endDate,
|
||||
hours: Math.floor(endMin / 60),
|
||||
minutes: endMin % 60,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
if (shiftEndUtc <= start) continue;
|
||||
|
||||
// Cap end at "now" so we render shift-so-far, not shift-as-planned.
|
||||
// Without cap:
|
||||
// - timeline fills future minutes with idle (visual lie)
|
||||
// - offline calc = (shift_end_future - last_seen) = looks 5h offline
|
||||
// even on a machine producing right now
|
||||
const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;
|
||||
|
||||
return {
|
||||
hasEnabledShifts: true,
|
||||
range: { start, end },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasEnabledShifts: true,
|
||||
range: null,
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
|
||||
const now = new Date(Math.floor(Date.now() / 60000) * 60000);
|
||||
const requestedMode = normalizedRangeMode(params.input.mode);
|
||||
const shiftEnabledCount = await prisma.orgShift.count({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
enabled: { not: false },
|
||||
},
|
||||
});
|
||||
const shiftAvailable = shiftEnabledCount > 0;
|
||||
|
||||
if (requestedMode === "custom") {
|
||||
const start = parseDate(params.input.start);
|
||||
const end = parseDate(params.input.end);
|
||||
if (start && end && end > start) {
|
||||
return {
|
||||
requestedMode,
|
||||
mode: requestedMode,
|
||||
start,
|
||||
end,
|
||||
shiftAvailable,
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedMode === "yesterday") {
|
||||
const settings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: params.orgId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const timeZone = settings?.timezone || "America/Mexico_City";
|
||||
const localNow = getLocalParts(now, timeZone);
|
||||
const today = { year: localNow.year, month: localNow.month, day: localNow.day };
|
||||
const yesterday = addDays(today, -1);
|
||||
const start = zonedToUtcDate({
|
||||
...yesterday,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
timeZone,
|
||||
});
|
||||
const end = zonedToUtcDate({
|
||||
...today,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
timeZone,
|
||||
});
|
||||
return {
|
||||
requestedMode,
|
||||
mode: requestedMode,
|
||||
start,
|
||||
end,
|
||||
shiftAvailable,
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (requestedMode === "shift") {
|
||||
const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
|
||||
if (shiftRange.range) {
|
||||
return {
|
||||
requestedMode,
|
||||
mode: requestedMode,
|
||||
start: shiftRange.range.start,
|
||||
end: shiftRange.range.end,
|
||||
shiftAvailable,
|
||||
} as const;
|
||||
}
|
||||
if (!shiftRange.hasEnabledShifts) {
|
||||
return {
|
||||
requestedMode,
|
||||
mode: "24h" as const,
|
||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
shiftAvailable,
|
||||
fallbackReason: "shift-unavailable" as const,
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
requestedMode,
|
||||
mode: "24h" as const,
|
||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
shiftAvailable,
|
||||
fallbackReason: "shift-inactive" as const,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
requestedMode,
|
||||
mode: "24h" as const,
|
||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
shiftAvailable,
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function computeRecapMachineDetail(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
range: {
|
||||
requestedMode: RecapRangeMode;
|
||||
mode: RecapRangeMode;
|
||||
start: Date;
|
||||
end: Date;
|
||||
shiftAvailable: boolean;
|
||||
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||
};
|
||||
}) {
|
||||
const { range } = params;
|
||||
|
||||
const recap = await getRecapDataCached({
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
});
|
||||
|
||||
const machine = recap.machines.find((row) => row.machineId === params.machineId) ?? null;
|
||||
if (!machine) return null;
|
||||
|
||||
const timelineRows = await loadTimelineRowsForMachines({
|
||||
orgId: params.orgId,
|
||||
machineIds: [params.machineId],
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
});
|
||||
|
||||
const timeline = buildTimelineSegments({
|
||||
cycles: timelineRows.cyclesByMachine.get(params.machineId) ?? [],
|
||||
events: timelineRows.eventsByMachine.get(params.machineId) ?? [],
|
||||
rangeStart: range.start,
|
||||
rangeEnd: range.end,
|
||||
});
|
||||
|
||||
const status = statusFromMachine(
|
||||
machine,
|
||||
range.end.getTime(),
|
||||
timelineRows.eventsByMachine.get(params.machineId)
|
||||
);
|
||||
|
||||
const downtimeTotalMin = Math.max(0, machine.downtime.totalMin);
|
||||
const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({
|
||||
reasonLabel: row.reasonLabel,
|
||||
minutes: row.minutes,
|
||||
count: row.count,
|
||||
percent: downtimeTotalMin > 0 ? round2((row.minutes / downtimeTotalMin) * 100) : 0,
|
||||
}));
|
||||
|
||||
const machineDetail: RecapMachineDetail = {
|
||||
machineId: machine.machineId,
|
||||
name: machine.machineName,
|
||||
location: machine.location,
|
||||
status: status.status,
|
||||
oee: machine.oee.avg,
|
||||
goodParts: machine.production.goodParts,
|
||||
scrap: machine.production.scrapParts,
|
||||
stopsCount: machine.downtime.stopsCount,
|
||||
stopMinutes: downtimeTotalMin,
|
||||
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
||||
lastSeenMs: status.lastSeenMs,
|
||||
offlineForMin: status.offlineForMin,
|
||||
ongoingStopMin: status.ongoingStopMin,
|
||||
moldChange: {
|
||||
active: machine.workOrders.moldChangeInProgress,
|
||||
startMs: machine.workOrders.moldChangeStartMs,
|
||||
},
|
||||
timeline,
|
||||
productionBySku: machine.production.bySku,
|
||||
downtimeTop,
|
||||
workOrders: {
|
||||
completed: machine.workOrders.completed,
|
||||
active: machine.workOrders.active,
|
||||
},
|
||||
heartbeat: {
|
||||
lastSeenAt: machine.heartbeat.lastSeenAt,
|
||||
uptimePct: machine.heartbeat.uptimePct,
|
||||
connectionStatus: status.status === "offline" ? "offline" : "online",
|
||||
},
|
||||
};
|
||||
|
||||
const response: RecapDetailResponse = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
range: {
|
||||
requestedMode: range.requestedMode,
|
||||
mode: range.mode,
|
||||
start: range.start.toISOString(),
|
||||
end: range.end.toISOString(),
|
||||
shiftAvailable: range.shiftAvailable,
|
||||
fallbackReason: range.fallbackReason,
|
||||
},
|
||||
machine: machineDetail,
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function summaryCacheKey(params: { orgId: string; hours: number }) {
|
||||
return ["recap-summary-v1", params.orgId, String(params.hours)];
|
||||
}
|
||||
|
||||
function detailCacheKey(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
requestedMode: RecapRangeMode;
|
||||
mode: RecapRangeMode;
|
||||
shiftAvailable: boolean;
|
||||
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}) {
|
||||
return [
|
||||
"recap-detail-v1",
|
||||
params.orgId,
|
||||
params.machineId,
|
||||
params.requestedMode,
|
||||
params.mode,
|
||||
params.shiftAvailable ? "shift-on" : "shift-off",
|
||||
params.fallbackReason ?? "",
|
||||
String(Math.trunc(params.startMs / 60000)),
|
||||
String(Math.trunc(params.endMs / 60000)),
|
||||
];
|
||||
}
|
||||
|
||||
export function parseRecapSummaryHours(raw: string | null) {
|
||||
return parseHours(raw);
|
||||
}
|
||||
|
||||
export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record<string, string | string[] | undefined>) {
|
||||
if (searchParams instanceof URLSearchParams) {
|
||||
return {
|
||||
mode: searchParams.get("range") ?? undefined,
|
||||
start: searchParams.get("start") ?? undefined,
|
||||
end: searchParams.get("end") ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const pick = (key: string) => {
|
||||
const value = searchParams[key];
|
||||
if (Array.isArray(value)) return value[0] ?? undefined;
|
||||
return value ?? undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
mode: pick("range"),
|
||||
start: pick("start"),
|
||||
end: pick("end"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRecapSummaryCached(params: { orgId: string; hours: number }) {
|
||||
const cache = unstable_cache(
|
||||
() => computeRecapSummary(params),
|
||||
summaryCacheKey(params),
|
||||
{
|
||||
revalidate: RECAP_CACHE_TTL_SEC,
|
||||
tags: [`recap:${params.orgId}`],
|
||||
}
|
||||
);
|
||||
|
||||
return cache();
|
||||
}
|
||||
|
||||
export async function getRecapMachineDetailCached(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
input: DetailRangeInput;
|
||||
}) {
|
||||
const resolved = await resolveDetailRange({
|
||||
orgId: params.orgId,
|
||||
input: params.input,
|
||||
});
|
||||
|
||||
const cache = unstable_cache(
|
||||
() =>
|
||||
computeRecapMachineDetail({
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
range: {
|
||||
requestedMode: resolved.requestedMode,
|
||||
mode: resolved.mode,
|
||||
start: resolved.start,
|
||||
end: resolved.end,
|
||||
shiftAvailable: resolved.shiftAvailable,
|
||||
fallbackReason: resolved.fallbackReason,
|
||||
},
|
||||
}),
|
||||
detailCacheKey({
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
requestedMode: resolved.requestedMode,
|
||||
mode: resolved.mode,
|
||||
shiftAvailable: resolved.shiftAvailable,
|
||||
fallbackReason: resolved.fallbackReason,
|
||||
startMs: resolved.start.getTime(),
|
||||
endMs: resolved.end.getTime(),
|
||||
}),
|
||||
{
|
||||
revalidate: RECAP_CACHE_TTL_SEC,
|
||||
tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
|
||||
}
|
||||
);
|
||||
|
||||
return cache();
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export type TimelineCycleRow = {
|
||||
ts: Date;
|
||||
cycleCount: number | null;
|
||||
actualCycleTime: number;
|
||||
theoreticalCycleTime: number | null;
|
||||
workOrderId: string | null;
|
||||
sku: string | null;
|
||||
};
|
||||
@@ -554,19 +555,21 @@ export function buildTimelineSegments(input: {
|
||||
let currentProduction: RawSegment | null = null;
|
||||
for (const cycle of dedupedCycles) {
|
||||
if (!cycle.workOrderId) continue;
|
||||
const cycleStartMs = cycle.ts.getTime();
|
||||
// Pi stores cycle.ts at COMPLETION time; the cycle ran in [ts - actual, ts].
|
||||
const completionMs = cycle.ts.getTime();
|
||||
const cycleDurationMs = Math.max(
|
||||
1000,
|
||||
Math.min(600000, Math.trunc((safeNum(cycle.actualCycleTime) ?? 1) * 1000))
|
||||
);
|
||||
const cycleEndMs = cycleStartMs + cycleDurationMs;
|
||||
const cycleStartMs = completionMs - cycleDurationMs;
|
||||
const cycleEndMs = completionMs;
|
||||
|
||||
if (
|
||||
currentProduction &&
|
||||
currentProduction.type === "production" &&
|
||||
currentProduction.workOrderId === cycle.workOrderId &&
|
||||
currentProduction.sku === cycle.sku &&
|
||||
cycleStartMs <= currentProduction.endMs + 5 * 60 * 1000
|
||||
cycleStartMs <= currentProduction.endMs + MERGE_GAP_MS
|
||||
) {
|
||||
currentProduction.endMs = Math.max(currentProduction.endMs, cycleEndMs);
|
||||
continue;
|
||||
@@ -622,15 +625,16 @@ export function buildTimelineSegments(input: {
|
||||
if (!TIMELINE_EVENT_TYPES.includes(eventType)) continue;
|
||||
|
||||
const data = extractData(event.data);
|
||||
const isUpdate = safeBool(data.is_update ?? data.isUpdate);
|
||||
const isAutoAck = safeBool(data.is_auto_ack ?? data.isAutoAck);
|
||||
if (isUpdate || isAutoAck) continue;
|
||||
if (isAutoAck) continue;
|
||||
|
||||
const tsMs = event.ts.getTime();
|
||||
const key = eventIncidentKey(eventType, data, tsMs);
|
||||
const status = String(data.status ?? "").trim().toLowerCase();
|
||||
|
||||
const episode = eventEpisodes.get(key) ?? {
|
||||
let episode = eventEpisodes.get(key);
|
||||
if (!episode) {
|
||||
episode = {
|
||||
type: eventType,
|
||||
firstTsMs: tsMs,
|
||||
lastTsMs: tsMs,
|
||||
@@ -643,10 +647,19 @@ export function buildTimelineSegments(input: {
|
||||
fromMoldId: null,
|
||||
toMoldId: null,
|
||||
};
|
||||
} else if ((PRIORITY[eventType] ?? 0) > (PRIORITY[episode.type] ?? 0)) {
|
||||
// Upgrade type when escalation is detected within the same incidentKey
|
||||
// (e.g. microstop → macrostop preserves the same key by design)
|
||||
episode.type = eventType;
|
||||
}
|
||||
episode.firstTsMs = Math.min(episode.firstTsMs, tsMs);
|
||||
episode.lastTsMs = Math.max(episode.lastTsMs, tsMs);
|
||||
|
||||
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
|
||||
const startMs =
|
||||
safeNum(data.start_ms) ??
|
||||
safeNum(data.startMs) ??
|
||||
safeNum(data.last_cycle_timestamp) ??
|
||||
safeNum(data.lastCycleTimestamp);
|
||||
const endMs = safeNum(data.end_ms) ?? safeNum(data.endMs);
|
||||
const durationSec =
|
||||
safeNum(data.duration_sec) ??
|
||||
@@ -673,7 +686,7 @@ export function buildTimelineSegments(input: {
|
||||
}
|
||||
|
||||
for (const episode of eventEpisodes.values()) {
|
||||
const startMs = Math.trunc(episode.startMs ?? episode.firstTsMs);
|
||||
let startMs = Math.trunc(episode.startMs ?? episode.firstTsMs);
|
||||
let endMs = Math.trunc(episode.endMs ?? episode.lastTsMs);
|
||||
|
||||
if (episode.statusActive && !episode.statusResolved) {
|
||||
@@ -688,8 +701,14 @@ export function buildTimelineSegments(input: {
|
||||
}
|
||||
}
|
||||
} else if (endMs <= startMs && episode.durationSec != null && episode.durationSec > 0) {
|
||||
// Event ts is end-of-stop; subtract duration to recover start.
|
||||
// Only adjust if we don't already have an explicit startMs from data.
|
||||
if (episode.startMs == null) {
|
||||
startMs = endMs - episode.durationSec * 1000;
|
||||
} else {
|
||||
endMs = startMs + episode.durationSec * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
if (endMs <= startMs) continue;
|
||||
|
||||
@@ -726,6 +745,34 @@ export function buildTimelineSegments(input: {
|
||||
const absorbed = absorbShortSegments(normalized, ABSORB_SHORT_SEGMENT_MS);
|
||||
const finalSegments = fillGapsWithIdle(absorbed, rangeStartMs, rangeEndMs);
|
||||
|
||||
// Live tail: machine cycling now, last cycle not yet completed.
|
||||
// Extend production through right edge until microstop threshold passes.
|
||||
const lastCycle = dedupedCycles[dedupedCycles.length - 1];
|
||||
const idealCT = safeNum(lastCycle?.theoreticalCycleTime) ?? 120;
|
||||
const MICRO_MS = idealCT * 1.5 * 1000;
|
||||
|
||||
// Live-tail: extend whatever the last real state was, until microstop threshold passes.
|
||||
if (finalSegments.length >= 2) {
|
||||
const last = finalSegments[finalSegments.length - 1];
|
||||
const prev = finalSegments[finalSegments.length - 2];
|
||||
if (last.type === "idle" && last.endMs >= rangeEndMs - 2000) {
|
||||
const gapMs = last.endMs - prev.endMs;
|
||||
let shouldExtend = false;
|
||||
if (prev.type === "production" && gapMs < MICRO_MS) {
|
||||
// mid-cycle: still running up to microstop threshold
|
||||
shouldExtend = true;
|
||||
} else if (prev.type === "microstop" || prev.type === "macrostop") {
|
||||
// stoppage in progress: extend until resolved/next cycle
|
||||
shouldExtend = true;
|
||||
}
|
||||
if (shouldExtend) {
|
||||
prev.endMs = last.endMs;
|
||||
prev.durationSec = Math.max(0, Math.trunc((prev.endMs - prev.startMs) / 1000));
|
||||
finalSegments.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalSegments;
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ export async function getRecapTimelineForMachine(params: {
|
||||
ts: true,
|
||||
cycleCount: true,
|
||||
actualCycleTime: true,
|
||||
theoreticalCycleTime: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
},
|
||||
@@ -151,10 +152,10 @@ export async function getRecapTimelineForMachine(params: {
|
||||
ts: row.ts,
|
||||
cycleCount: row.cycleCount,
|
||||
actualCycleTime: row.actualCycleTime,
|
||||
theoreticalCycleTime: row.theoreticalCycleTime,
|
||||
workOrderId: row.workOrderId,
|
||||
sku: row.sku,
|
||||
}));
|
||||
|
||||
const events: TimelineEventRow[] = eventsRaw.map((row) => ({
|
||||
ts: row.ts,
|
||||
eventType: row.eventType,
|
||||
|
||||
@@ -121,7 +121,15 @@ export type RecapQuery = {
|
||||
shift?: string;
|
||||
};
|
||||
|
||||
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "offline";
|
||||
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "offline" | "idle";
|
||||
|
||||
/**
|
||||
* Reason context — currently empty in practice because the only STOPPED cause
|
||||
* we can detect (given Node-RED's constraints) is machine_fault. Kept as a
|
||||
* struct so future expansion doesn't require a type change downstream.
|
||||
*/
|
||||
export type RecapStateContext = Record<string, never>;
|
||||
|
||||
|
||||
export type RecapSummaryMachine = {
|
||||
machineId: string;
|
||||
@@ -136,6 +144,7 @@ export type RecapSummaryMachine = {
|
||||
lastActivityMin: number | null;
|
||||
offlineForMin: number | null;
|
||||
ongoingStopMin: number | null;
|
||||
stateContext: RecapStateContext;
|
||||
activeWorkOrderId: string | null;
|
||||
moldChange: {
|
||||
active: boolean;
|
||||
@@ -193,6 +202,7 @@ export type RecapMachineDetail = {
|
||||
lastSeenMs: number | null;
|
||||
offlineForMin: number | null;
|
||||
ongoingStopMin: number | null;
|
||||
stateContext: RecapStateContext;
|
||||
moldChange: {
|
||||
active: boolean;
|
||||
startMs: number | null;
|
||||
|
||||
222
lib/recap/types.ts.bak
Normal file
222
lib/recap/types.ts.bak
Normal file
@@ -0,0 +1,222 @@
|
||||
export type RecapSkuRow = {
|
||||
machineName: string;
|
||||
sku: string;
|
||||
good: number;
|
||||
scrap: number;
|
||||
target: number | null;
|
||||
progressPct: number | null;
|
||||
};
|
||||
|
||||
export type RecapMachine = {
|
||||
machineId: string;
|
||||
machineName: string;
|
||||
location: string | null;
|
||||
production: {
|
||||
goodParts: number;
|
||||
scrapParts: number;
|
||||
totalCycles: number;
|
||||
bySku: RecapSkuRow[];
|
||||
};
|
||||
oee: {
|
||||
avg: number | null;
|
||||
availability: number | null;
|
||||
performance: number | null;
|
||||
quality: number | null;
|
||||
};
|
||||
downtime: {
|
||||
totalMin: number;
|
||||
stopsCount: number;
|
||||
topReasons: Array<{
|
||||
reasonLabel: string;
|
||||
minutes: number;
|
||||
count: number;
|
||||
}>;
|
||||
ongoingStopMin: number | null;
|
||||
};
|
||||
workOrders: {
|
||||
completed: Array<{
|
||||
id: string;
|
||||
sku: string | null;
|
||||
goodParts: number;
|
||||
durationHrs: number;
|
||||
}>;
|
||||
active: {
|
||||
id: string;
|
||||
sku: string | null;
|
||||
progressPct: number | null;
|
||||
startedAt: string | null;
|
||||
} | null;
|
||||
moldChangeInProgress: boolean;
|
||||
moldChangeStartMs: number | null;
|
||||
};
|
||||
heartbeat: {
|
||||
lastSeenAt: string | null;
|
||||
uptimePct: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type RecapTimelineSegment =
|
||||
| {
|
||||
type: "production";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
durationSec: number;
|
||||
workOrderId: string | null;
|
||||
sku: string | null;
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
type: "mold-change";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
fromMoldId: string | null;
|
||||
toMoldId: string | null;
|
||||
durationSec: number;
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
type: "macrostop" | "microstop" | "slow-cycle";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
reason: string | null;
|
||||
reasonLabel?: string | null;
|
||||
durationSec: number;
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
type: "idle";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
durationSec: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type RecapTimelineResponse = {
|
||||
range: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
segments: RecapTimelineSegment[];
|
||||
hasData: boolean;
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
export type RecapResponse = {
|
||||
range: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
availableShifts: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>;
|
||||
machines: RecapMachine[];
|
||||
};
|
||||
|
||||
export type RecapQuery = {
|
||||
orgId: string;
|
||||
machineId?: string;
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
shift?: string;
|
||||
};
|
||||
|
||||
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "offline";
|
||||
|
||||
export type RecapSummaryMachine = {
|
||||
machineId: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
status: RecapMachineStatus;
|
||||
oee: number | null;
|
||||
goodParts: number;
|
||||
scrap: number;
|
||||
stopsCount: number;
|
||||
lastSeenMs: number | null;
|
||||
lastActivityMin: number | null;
|
||||
offlineForMin: number | null;
|
||||
ongoingStopMin: number | null;
|
||||
activeWorkOrderId: string | null;
|
||||
moldChange: {
|
||||
active: boolean;
|
||||
startMs: number | null;
|
||||
elapsedMin: number | null;
|
||||
} | null;
|
||||
miniTimeline: RecapTimelineSegment[];
|
||||
};
|
||||
|
||||
export type RecapSummaryResponse = {
|
||||
generatedAt: string;
|
||||
range: {
|
||||
start: string;
|
||||
end: string;
|
||||
hours: number;
|
||||
};
|
||||
machines: RecapSummaryMachine[];
|
||||
};
|
||||
|
||||
export type RecapRangeMode = "24h" | "shift" | "yesterday" | "custom";
|
||||
|
||||
export type RecapDowntimeTopRow = {
|
||||
reasonLabel: string;
|
||||
minutes: number;
|
||||
count: number;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
export type RecapWorkOrders = {
|
||||
completed: Array<{
|
||||
id: string;
|
||||
sku: string | null;
|
||||
goodParts: number;
|
||||
durationHrs: number;
|
||||
}>;
|
||||
active: {
|
||||
id: string;
|
||||
sku: string | null;
|
||||
progressPct: number | null;
|
||||
startedAt: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type RecapMachineDetail = {
|
||||
machineId: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
status: RecapMachineStatus;
|
||||
oee: number | null;
|
||||
goodParts: number;
|
||||
scrap: number;
|
||||
stopsCount: number;
|
||||
stopMinutes: number;
|
||||
activeWorkOrderId: string | null;
|
||||
lastSeenMs: number | null;
|
||||
offlineForMin: number | null;
|
||||
ongoingStopMin: number | null;
|
||||
moldChange: {
|
||||
active: boolean;
|
||||
startMs: number | null;
|
||||
} | null;
|
||||
timeline: RecapTimelineSegment[];
|
||||
productionBySku: RecapSkuRow[];
|
||||
downtimeTop: RecapDowntimeTopRow[];
|
||||
workOrders: RecapWorkOrders;
|
||||
heartbeat: {
|
||||
lastSeenAt: string | null;
|
||||
uptimePct: number | null;
|
||||
connectionStatus: "online" | "offline";
|
||||
};
|
||||
};
|
||||
|
||||
export type RecapDetailResponse = {
|
||||
generatedAt: string;
|
||||
range: {
|
||||
requestedMode?: RecapRangeMode;
|
||||
mode: RecapRangeMode;
|
||||
start: string;
|
||||
end: string;
|
||||
shiftAvailable?: boolean;
|
||||
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||
};
|
||||
machine: RecapMachineDetail;
|
||||
};
|
||||
222
lib/recap/types.ts.bak.step4
Normal file
222
lib/recap/types.ts.bak.step4
Normal file
@@ -0,0 +1,222 @@
|
||||
export type RecapSkuRow = {
|
||||
machineName: string;
|
||||
sku: string;
|
||||
good: number;
|
||||
scrap: number;
|
||||
target: number | null;
|
||||
progressPct: number | null;
|
||||
};
|
||||
|
||||
export type RecapMachine = {
|
||||
machineId: string;
|
||||
machineName: string;
|
||||
location: string | null;
|
||||
production: {
|
||||
goodParts: number;
|
||||
scrapParts: number;
|
||||
totalCycles: number;
|
||||
bySku: RecapSkuRow[];
|
||||
};
|
||||
oee: {
|
||||
avg: number | null;
|
||||
availability: number | null;
|
||||
performance: number | null;
|
||||
quality: number | null;
|
||||
};
|
||||
downtime: {
|
||||
totalMin: number;
|
||||
stopsCount: number;
|
||||
topReasons: Array<{
|
||||
reasonLabel: string;
|
||||
minutes: number;
|
||||
count: number;
|
||||
}>;
|
||||
ongoingStopMin: number | null;
|
||||
};
|
||||
workOrders: {
|
||||
completed: Array<{
|
||||
id: string;
|
||||
sku: string | null;
|
||||
goodParts: number;
|
||||
durationHrs: number;
|
||||
}>;
|
||||
active: {
|
||||
id: string;
|
||||
sku: string | null;
|
||||
progressPct: number | null;
|
||||
startedAt: string | null;
|
||||
} | null;
|
||||
moldChangeInProgress: boolean;
|
||||
moldChangeStartMs: number | null;
|
||||
};
|
||||
heartbeat: {
|
||||
lastSeenAt: string | null;
|
||||
uptimePct: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type RecapTimelineSegment =
|
||||
| {
|
||||
type: "production";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
durationSec: number;
|
||||
workOrderId: string | null;
|
||||
sku: string | null;
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
type: "mold-change";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
fromMoldId: string | null;
|
||||
toMoldId: string | null;
|
||||
durationSec: number;
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
type: "macrostop" | "microstop" | "slow-cycle";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
reason: string | null;
|
||||
reasonLabel?: string | null;
|
||||
durationSec: number;
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
type: "idle";
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
durationSec: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type RecapTimelineResponse = {
|
||||
range: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
segments: RecapTimelineSegment[];
|
||||
hasData: boolean;
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
export type RecapResponse = {
|
||||
range: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
availableShifts: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>;
|
||||
machines: RecapMachine[];
|
||||
};
|
||||
|
||||
export type RecapQuery = {
|
||||
orgId: string;
|
||||
machineId?: string;
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
shift?: string;
|
||||
};
|
||||
|
||||
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "data-loss" | "offline" | "idle";
|
||||
|
||||
export type RecapSummaryMachine = {
|
||||
machineId: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
status: RecapMachineStatus;
|
||||
oee: number | null;
|
||||
goodParts: number;
|
||||
scrap: number;
|
||||
stopsCount: number;
|
||||
lastSeenMs: number | null;
|
||||
lastActivityMin: number | null;
|
||||
offlineForMin: number | null;
|
||||
ongoingStopMin: number | null;
|
||||
activeWorkOrderId: string | null;
|
||||
moldChange: {
|
||||
active: boolean;
|
||||
startMs: number | null;
|
||||
elapsedMin: number | null;
|
||||
} | null;
|
||||
miniTimeline: RecapTimelineSegment[];
|
||||
};
|
||||
|
||||
export type RecapSummaryResponse = {
|
||||
generatedAt: string;
|
||||
range: {
|
||||
start: string;
|
||||
end: string;
|
||||
hours: number;
|
||||
};
|
||||
machines: RecapSummaryMachine[];
|
||||
};
|
||||
|
||||
export type RecapRangeMode = "24h" | "shift" | "yesterday" | "custom";
|
||||
|
||||
export type RecapDowntimeTopRow = {
|
||||
reasonLabel: string;
|
||||
minutes: number;
|
||||
count: number;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
export type RecapWorkOrders = {
|
||||
completed: Array<{
|
||||
id: string;
|
||||
sku: string | null;
|
||||
goodParts: number;
|
||||
durationHrs: number;
|
||||
}>;
|
||||
active: {
|
||||
id: string;
|
||||
sku: string | null;
|
||||
progressPct: number | null;
|
||||
startedAt: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type RecapMachineDetail = {
|
||||
machineId: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
status: RecapMachineStatus;
|
||||
oee: number | null;
|
||||
goodParts: number;
|
||||
scrap: number;
|
||||
stopsCount: number;
|
||||
stopMinutes: number;
|
||||
activeWorkOrderId: string | null;
|
||||
lastSeenMs: number | null;
|
||||
offlineForMin: number | null;
|
||||
ongoingStopMin: number | null;
|
||||
moldChange: {
|
||||
active: boolean;
|
||||
startMs: number | null;
|
||||
} | null;
|
||||
timeline: RecapTimelineSegment[];
|
||||
productionBySku: RecapSkuRow[];
|
||||
downtimeTop: RecapDowntimeTopRow[];
|
||||
workOrders: RecapWorkOrders;
|
||||
heartbeat: {
|
||||
lastSeenAt: string | null;
|
||||
uptimePct: number | null;
|
||||
connectionStatus: "online" | "offline";
|
||||
};
|
||||
};
|
||||
|
||||
export type RecapDetailResponse = {
|
||||
generatedAt: string;
|
||||
range: {
|
||||
requestedMode?: RecapRangeMode;
|
||||
mode: RecapRangeMode;
|
||||
start: string;
|
||||
end: string;
|
||||
shiftAvailable?: boolean;
|
||||
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||
};
|
||||
machine: RecapMachineDetail;
|
||||
};
|
||||
@@ -81,10 +81,6 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
|
||||
const overrides = normalizeShiftOverrides(settings.shiftScheduleOverridesJson);
|
||||
|
||||
const defaults = normalizeDefaults(settings.defaultsJson);
|
||||
const reasonCatalog =
|
||||
isPlainObject(settings.defaultsJson) && "reasonCatalog" in settings.defaultsJson
|
||||
? (settings.defaultsJson as AnyRecord).reasonCatalog
|
||||
: null;
|
||||
|
||||
return {
|
||||
orgId: settings.orgId,
|
||||
@@ -105,9 +101,6 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
|
||||
},
|
||||
alerts: normalizeAlerts(settings.alertsJson),
|
||||
defaults,
|
||||
reasonCatalog: reasonCatalog ?? undefined,
|
||||
reasonCatalogData: reasonCatalog ?? undefined,
|
||||
reasonCatalogVersion: Number((reasonCatalog as AnyRecord | null)?.version ?? 1),
|
||||
updatedAt: settings.updatedAt,
|
||||
updatedBy: settings.updatedBy,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"test:downtime-reason-guard": "node scripts/test-downtime-reason-guard.mjs",
|
||||
"backfill:downtime-reasons": "node scripts/backfill-downtime-reasons.mjs",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate:deploy": "prisma migrate deploy"
|
||||
"prisma:migrate:deploy": "prisma migrate deploy",
|
||||
"seed:reason-catalog": "node scripts/seed-reason-catalog-from-xlsx.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.1",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Reason catalog: relational storage (replaces JSON in org_settings for new data).
|
||||
|
||||
CREATE TABLE "reason_catalog_category" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"code_prefix" TEXT NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "reason_catalog_category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "reason_catalog_item" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"category_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"code_suffix" TEXT NOT NULL,
|
||||
"reason_code" TEXT NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "reason_catalog_item_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "reason_catalog_category_org_id_kind_active_idx" ON "reason_catalog_category"("org_id", "kind", "active");
|
||||
|
||||
CREATE UNIQUE INDEX "reason_catalog_item_org_id_reason_code_key" ON "reason_catalog_item"("org_id", "reason_code");
|
||||
|
||||
CREATE INDEX "reason_catalog_item_org_id_category_id_idx" ON "reason_catalog_item"("org_id", "category_id");
|
||||
|
||||
ALTER TABLE "reason_catalog_category" ADD CONSTRAINT "reason_catalog_category_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "reason_catalog_item" ADD CONSTRAINT "reason_catalog_item_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "reason_catalog_item" ADD CONSTRAINT "reason_catalog_item_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "reason_catalog_category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -33,6 +33,8 @@ model Org {
|
||||
shifts OrgShift[]
|
||||
productCostOverrides ProductCostOverride[]
|
||||
settingsAudits SettingsAudit[]
|
||||
reasonCatalogCategories ReasonCatalogCategory[]
|
||||
reasonCatalogItems ReasonCatalogItem[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -135,6 +137,8 @@ model Machine {
|
||||
settings MachineSettings?
|
||||
workOrders MachineWorkOrder[]
|
||||
settingsAudits SettingsAudit[]
|
||||
productionEnabled Boolean @default(true) @map("production_enabled")
|
||||
|
||||
|
||||
@@unique([orgId, name])
|
||||
@@index([orgId])
|
||||
@@ -288,6 +292,42 @@ model IngestLog {
|
||||
@@index([machineId, seq])
|
||||
}
|
||||
|
||||
model ReasonCatalogCategory {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
kind String
|
||||
name String
|
||||
codePrefix String @map("code_prefix")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
items ReasonCatalogItem[]
|
||||
|
||||
@@index([orgId, kind, active])
|
||||
@@map("reason_catalog_category")
|
||||
}
|
||||
|
||||
model ReasonCatalogItem {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
categoryId String @map("category_id")
|
||||
name String
|
||||
codeSuffix String @map("code_suffix")
|
||||
reasonCode String @map("reason_code")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
category ReasonCatalogCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([orgId, reasonCode])
|
||||
@@index([orgId, categoryId])
|
||||
@@map("reason_catalog_item")
|
||||
}
|
||||
|
||||
model OrgSettings {
|
||||
orgId String @id @map("org_id")
|
||||
timezone String @default("UTC")
|
||||
|
||||
BIN
reasons/Claves Tiempo Muerto.xlsx
Executable file
BIN
reasons/Claves Tiempo Muerto.xlsx
Executable file
Binary file not shown.
BIN
reasons/Claves de Scrap.xlsx
Executable file
BIN
reasons/Claves de Scrap.xlsx
Executable file
Binary file not shown.
22
scripts/REASON_CATALOG_SYNC.md
Normal file
22
scripts/REASON_CATALOG_SYNC.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Reason catalog: Control Tower → settings → MySQL (Pi)
|
||||
|
||||
## Authority
|
||||
|
||||
- The canonical catalog lives in Control Tower: `org_settings.defaults_json.reasonCatalog` (and `reasonCatalogData` alias), merged in API responses with fallback from `downtime_menu.md`.
|
||||
- Each **detail** may include:
|
||||
- `reasonCode`: official printed code (e.g. `DTPRC-01`, `MX001`). If omitted, a slug `CATEGORY__DETAIL` is derived for backward compatibility.
|
||||
- `active`: `false` to hide from operator pickers while keeping history/report labels. **Never remove** a code from JSON once used; only set `active: false`.
|
||||
|
||||
## Raspberry Pi
|
||||
|
||||
1. Apply [`scripts/mysql/reason_catalog_mirror.sql`](mysql/reason_catalog_mirror.sql) on the same MySQL database used by Node-RED (`node-red-node-mysql`).
|
||||
2. Deploy [`flows_may_4_26.json`](../flows_may_4_26.json). After each successful **Apply settings + update UI**, the flow emits a message on output 3 to **Build reason catalog mirror SQL**, which reads `global.settings.reasonCatalog` and runs `INSERT ... ON DUPLICATE KEY UPDATE` into `reason_catalog_row` (no deletes).
|
||||
|
||||
## Operator payloads (printed codes)
|
||||
|
||||
- On downtime acknowledge and scrap entry, send `reason.reasonCode` (and labels) matching the printed sheet. Ingest already normalizes and stores uppercase codes.
|
||||
- Generate printable lists from the same JSON as CT: [`scripts/export-reason-catalog-csv.mjs`](export-reason-catalog-csv.mjs).
|
||||
|
||||
```bash
|
||||
node scripts/export-reason-catalog-csv.mjs path/to/reasonCatalog.json > claves.csv
|
||||
```
|
||||
76
scripts/export-reason-catalog-csv.mjs
Normal file
76
scripts/export-reason-catalog-csv.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Export reasonCatalog JSON (downtime + scrap) to CSV for printed operator sheets.
|
||||
* Usage: node scripts/export-reason-catalog-csv.mjs <path-to-catalog.json>
|
||||
* cat reasonCatalog.json | node scripts/export-reason-catalog-csv.mjs
|
||||
*
|
||||
* CSV columns: kind, reasonCode, categoryLabel, reasonLabel, active
|
||||
*/
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
|
||||
function escCsv(s) {
|
||||
const t = String(s ?? "");
|
||||
if (/[",\n\r]/.test(t)) return `"${t.replace(/"/g, '""')}"`;
|
||||
return t;
|
||||
}
|
||||
|
||||
function effectiveReasonCode(categoryId, detail) {
|
||||
const c = String(detail.reasonCode ?? detail.code ?? "").trim();
|
||||
if (c) return c.toUpperCase();
|
||||
const cat = String(categoryId ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
const det = String(detail.id ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return `${cat}__${det}`.toUpperCase();
|
||||
}
|
||||
|
||||
function walk(kind, categories, rows) {
|
||||
if (!Array.isArray(categories)) return;
|
||||
for (const cat of categories) {
|
||||
const cid = String(cat.id ?? "").trim();
|
||||
const clab = String(cat.label ?? "").trim();
|
||||
const details = Array.isArray(cat.details)
|
||||
? cat.details
|
||||
: Array.isArray(cat.children)
|
||||
? cat.children
|
||||
: [];
|
||||
for (const d of details) {
|
||||
const active = d.active === false ? "0" : "1";
|
||||
const dlab = String(d.label ?? "").trim();
|
||||
rows.push({
|
||||
kind,
|
||||
reasonCode: effectiveReasonCode(cid || clab, d),
|
||||
categoryLabel: clab,
|
||||
reasonLabel: dlab,
|
||||
active,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let raw = "";
|
||||
const arg = process.argv[2];
|
||||
if (arg && existsSync(arg)) {
|
||||
raw = readFileSync(arg, "utf8");
|
||||
} else {
|
||||
raw = readFileSync(0, "utf8");
|
||||
}
|
||||
|
||||
const catalog = JSON.parse(raw || "{}");
|
||||
const rows = [];
|
||||
walk("downtime", catalog.downtime, rows);
|
||||
walk("scrap", catalog.scrap, rows);
|
||||
|
||||
const header = ["kind", "reasonCode", "categoryLabel", "reasonLabel", "active"];
|
||||
console.log(header.map(escCsv).join(","));
|
||||
for (const r of rows) {
|
||||
console.log([r.kind, r.reasonCode, r.categoryLabel, r.reasonLabel, r.active].map(escCsv).join(","));
|
||||
}
|
||||
18
scripts/mysql/reason_catalog_mirror.sql
Normal file
18
scripts/mysql/reason_catalog_mirror.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Mirror of Control Tower reasonCatalog on the Raspberry Pi (MySQL / MariaDB).
|
||||
-- Policy: never DELETE rows by reason_code; only INSERT ... ON DUPLICATE KEY UPDATE
|
||||
-- and set active=0 when CT marks a code inactive.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reason_catalog_row (
|
||||
kind VARCHAR(16) NOT NULL COMMENT 'downtime | scrap',
|
||||
category_id VARCHAR(128) NOT NULL,
|
||||
category_label VARCHAR(255) NOT NULL,
|
||||
reason_code VARCHAR(64) NOT NULL,
|
||||
reason_label VARCHAR(512) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
catalog_version INT NOT NULL DEFAULT 1,
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (kind, reason_code),
|
||||
KEY idx_reason_catalog_kind_active (kind, active),
|
||||
KEY idx_reason_catalog_version (catalog_version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
213
scripts/patch-flows-reason-mirror.mjs
Normal file
213
scripts/patch-flows-reason-mirror.mjs
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Patches flows_may_4_26.json:
|
||||
* - Apply settings: pass reasonCode/active in catalog; 3 outputs; trigger MySQL mirror sync
|
||||
* - New nodes: Build reason catalog mirror SQL → mysql
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const path = new URL("../flows_may_4_26.json", import.meta.url).pathname;
|
||||
const j = JSON.parse(readFileSync(path, "utf8"));
|
||||
|
||||
const applyId = "abbec199700a5e29";
|
||||
const gateId = "f8e0d1c2b3a40911";
|
||||
const mysqlPersistId = "f8e0d1c2b3a40912";
|
||||
|
||||
const apply = j.find((n) => n.id === applyId);
|
||||
if (!apply || apply.type !== "function") {
|
||||
console.error("Apply settings node not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const oldDetails =
|
||||
"const details = detailsRaw.map((d, jdx) => ({\n id: String(d.id || d.detailId || (categoryId + \"_d\" + jdx)),\n label: String(d.label || d.detailLabel || (\"Detalle \" + (jdx + 1)))\n }));";
|
||||
|
||||
const newDetails = `const details = detailsRaw.map((d, jdx) => {
|
||||
const row = {
|
||||
id: String(d.id || d.detailId || (categoryId + "_d" + jdx)),
|
||||
label: String(d.label || d.detailLabel || ("Detalle " + (jdx + 1)))
|
||||
};
|
||||
if (d.reasonCode != null && String(d.reasonCode).trim()) {
|
||||
row.reasonCode = String(d.reasonCode).trim();
|
||||
} else if (d.code != null && String(d.code).trim()) {
|
||||
row.reasonCode = String(d.code).trim();
|
||||
}
|
||||
if (d.active === false) {
|
||||
row.active = false;
|
||||
}
|
||||
return row;
|
||||
});`;
|
||||
|
||||
if (!apply.func.includes(oldDetails)) {
|
||||
console.error("Expected normalizeCatalog details snippet not found; abort.");
|
||||
process.exit(1);
|
||||
}
|
||||
apply.func = apply.func.replace(oldDetails, newDetails);
|
||||
|
||||
apply.func = apply.func.replaceAll("node.send([uiConfigMsg, null]);", "node.send([uiConfigMsg, null, null]);");
|
||||
apply.func = apply.func.replaceAll("node.send([uiMoldMsg, null]);", "node.send([uiMoldMsg, null, null]);");
|
||||
apply.func = apply.func.replaceAll("node.send([uiReadOnlyMsg, null]);", "node.send([uiReadOnlyMsg, null, null]);");
|
||||
apply.func = apply.func.replaceAll("node.send([uiReasonCatalogMsg, null]);", "node.send([uiReasonCatalogMsg, null, null]);");
|
||||
|
||||
const oldReturnAck = `const ackMsg = {
|
||||
topic: ackTopic,
|
||||
payload: JSON.stringify({
|
||||
type: "settings_ack",
|
||||
orgId,
|
||||
machineId,
|
||||
version,
|
||||
source: "node-red",
|
||||
ts: new Date().toISOString()
|
||||
})
|
||||
};
|
||||
|
||||
return [null, ackMsg];
|
||||
`;
|
||||
|
||||
const newReturnAck = `const ackMsg = {
|
||||
topic: ackTopic,
|
||||
payload: JSON.stringify({
|
||||
type: "settings_ack",
|
||||
orgId,
|
||||
machineId,
|
||||
version,
|
||||
source: "node-red",
|
||||
ts: new Date().toISOString()
|
||||
})
|
||||
};
|
||||
|
||||
const mirrorTrigger = { payload: { _syncReasonCatalog: true } };
|
||||
return [null, ackMsg, mirrorTrigger];
|
||||
`;
|
||||
|
||||
if (!apply.func.includes(oldReturnAck.trim())) {
|
||||
console.error("Expected ack return block not found");
|
||||
process.exit(1);
|
||||
}
|
||||
apply.func = apply.func.replace(oldReturnAck.trim(), newReturnAck.trim());
|
||||
|
||||
apply.func = apply.func.replace(
|
||||
`if (!orgId || !machineId) {
|
||||
return [null, null];
|
||||
}`,
|
||||
`if (!orgId || !machineId) {
|
||||
return [null, null, null];
|
||||
}`
|
||||
);
|
||||
|
||||
apply.outputs = 3;
|
||||
apply.wires = [
|
||||
["2c8562b2471078ab", "dbfd127c516efa87", "9748899355370bae"],
|
||||
[],
|
||||
[gateId],
|
||||
];
|
||||
|
||||
const gateFunc = `const p = msg.payload || {};
|
||||
if (!p._syncReasonCatalog) {
|
||||
return null;
|
||||
}
|
||||
const settings = global.get("settings") || {};
|
||||
const cat = settings.reasonCatalog || {};
|
||||
const ver = Number(cat.version || 1);
|
||||
function esc(v) {
|
||||
return String(v ?? "").replace(/\\\\/g, "\\\\\\\\").replace(/'/g, "''");
|
||||
}
|
||||
const parts = [];
|
||||
function walk(kind, list) {
|
||||
if (!Array.isArray(list)) {
|
||||
return;
|
||||
}
|
||||
let sort = 0;
|
||||
list.forEach((c) => {
|
||||
const categoryId = esc(String(c.id || ""));
|
||||
const categoryLabel = esc(String(c.label || ""));
|
||||
const ch = c.children || c.details || [];
|
||||
if (!Array.isArray(ch)) {
|
||||
return;
|
||||
}
|
||||
ch.forEach((d) => {
|
||||
const id = String(d.id || "").trim();
|
||||
const label = String(d.label || "").trim();
|
||||
const rc = String(d.reasonCode || d.code || id || "").trim();
|
||||
if (!rc) {
|
||||
return;
|
||||
}
|
||||
const active = d.active === false ? 0 : 1;
|
||||
parts.push(
|
||||
"('" +
|
||||
kind +
|
||||
"','" +
|
||||
categoryId +
|
||||
"','" +
|
||||
categoryLabel +
|
||||
"','" +
|
||||
esc(rc) +
|
||||
"','" +
|
||||
esc(label) +
|
||||
"'," +
|
||||
sort +
|
||||
"," +
|
||||
active +
|
||||
"," +
|
||||
ver +
|
||||
")"
|
||||
);
|
||||
sort += 1;
|
||||
});
|
||||
});
|
||||
}
|
||||
walk("downtime", cat.downtime || []);
|
||||
walk("scrap", cat.scrap || []);
|
||||
if (!parts.length) {
|
||||
node.status({ fill: "yellow", shape: "ring", text: "No reason rows to mirror" });
|
||||
return null;
|
||||
}
|
||||
const sql =
|
||||
"INSERT INTO reason_catalog_row (kind,category_id,category_label,reason_code,reason_label,sort_order,active,catalog_version) VALUES " +
|
||||
parts.join(",") +
|
||||
" ON DUPLICATE KEY UPDATE category_id=VALUES(category_id),category_label=VALUES(category_label),reason_label=VALUES(reason_label),sort_order=VALUES(sort_order),active=VALUES(active),catalog_version=VALUES(catalog_version),updated_at=CURRENT_TIMESTAMP(3)";
|
||||
node.status({ fill: "green", shape: "dot", text: "Reason mirror SQL built" });
|
||||
msg.topic = sql;
|
||||
msg.payload = [];
|
||||
return msg;
|
||||
`;
|
||||
|
||||
const gateNode = {
|
||||
id: gateId,
|
||||
type: "function",
|
||||
z: "05d4cb231221b842",
|
||||
g: "a1b43a9e095c10db",
|
||||
name: "Build reason catalog mirror SQL",
|
||||
func: gateFunc,
|
||||
outputs: 1,
|
||||
timeout: 0,
|
||||
noerr: 0,
|
||||
initialize: "",
|
||||
finalize: "",
|
||||
libs: [],
|
||||
x: 1500,
|
||||
y: 1020,
|
||||
wires: [[mysqlPersistId]],
|
||||
};
|
||||
|
||||
const mysqlNode = {
|
||||
id: mysqlPersistId,
|
||||
type: "mysql",
|
||||
z: "05d4cb231221b842",
|
||||
g: "a1b43a9e095c10db",
|
||||
mydb: "fc9634aabefee16b",
|
||||
name: "Persist reason catalog mirror",
|
||||
x: 1820,
|
||||
y: 1020,
|
||||
wires: [[]],
|
||||
};
|
||||
|
||||
if (j.some((n) => n.id === gateId)) {
|
||||
console.log("Patch already applied (gate node exists). Skipping insert.");
|
||||
} else {
|
||||
const idx = j.findIndex((n) => n.id === applyId);
|
||||
j.splice(idx + 1, 0, gateNode, mysqlNode);
|
||||
}
|
||||
|
||||
writeFileSync(path, JSON.stringify(j, null, 4) + "\n");
|
||||
console.log("Patched", path);
|
||||
280
scripts/seed-reason-catalog-from-xlsx.mjs
Normal file
280
scripts/seed-reason-catalog-from-xlsx.mjs
Normal file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Load downtime + scrap catalogs from Excel under ./reasons/ into Postgres.
|
||||
*
|
||||
* npx dotenv -e .env -- node scripts/seed-reason-catalog-from-xlsx.mjs --org-id <uuid>
|
||||
* npx dotenv -e .env -- node scripts/seed-reason-catalog-from-xlsx.mjs --org-slug my-org --replace
|
||||
*
|
||||
* --dry-run parse and print counts only
|
||||
* --replace delete existing reason_catalog_* rows for the org before insert
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import * as XLSX from "xlsx";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function composeReasonCode(prefix, suffix) {
|
||||
const p = String(prefix ?? "").trim().toUpperCase();
|
||||
const s = String(suffix ?? "").trim();
|
||||
if (/^\d+$/.test(s) && p.length >= 3) {
|
||||
return `${p}-${s}`.toUpperCase();
|
||||
}
|
||||
return `${p}${s}`.toUpperCase();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {
|
||||
dryRun: false,
|
||||
replace: false,
|
||||
orgId: null,
|
||||
orgSlug: null,
|
||||
downtimePath: path.join(ROOT, "reasons", "Claves Tiempo Muerto.xlsx"),
|
||||
scrapPath: path.join(ROOT, "reasons", "Claves de Scrap.xlsx"),
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const t = argv[i];
|
||||
if (t === "--dry-run") out.dryRun = true;
|
||||
else if (t === "--replace") out.replace = true;
|
||||
else if (t === "--org-id") {
|
||||
out.orgId = argv[i + 1] || null;
|
||||
i += 1;
|
||||
} else if (t === "--org-slug") {
|
||||
out.orgSlug = argv[i + 1] || null;
|
||||
i += 1;
|
||||
} else if (t === "--downtime") {
|
||||
out.downtimePath = argv[i + 1] || out.downtimePath;
|
||||
i += 1;
|
||||
} else if (t === "--scrap") {
|
||||
out.scrapPath = argv[i + 1] || out.scrapPath;
|
||||
i += 1;
|
||||
} else {
|
||||
throw new Error(`Unknown arg: ${t}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readWorkbook(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
const buf = readFileSync(filePath);
|
||||
return XLSX.read(buf, { type: "buffer" });
|
||||
}
|
||||
|
||||
/** @returns {{ kind:'downtime', name:string, codePrefix:string, items: { suffix:string, name:string }[] }[]} */
|
||||
function parseDowntimeXlsx(filePath) {
|
||||
const wb = readWorkbook(filePath);
|
||||
const data = XLSX.utils.sheet_to_json(wb.Sheets["Sheet1"], { header: 1, defval: "" });
|
||||
const headerRowIdx = 3;
|
||||
const header = data[headerRowIdx] || [];
|
||||
const cols = [];
|
||||
for (let c = 0; c < header.length; c += 1) {
|
||||
if (String(header[c] || "").trim()) cols.push(c);
|
||||
}
|
||||
|
||||
const categoryByCol = {};
|
||||
cols.forEach((c) => {
|
||||
categoryByCol[c] = String(header[c]).trim();
|
||||
});
|
||||
|
||||
const CODE = /^([A-Z0-9][A-Za-z0-9-]*)-(\d+)\s+(.*)$/;
|
||||
const rawItems = [];
|
||||
|
||||
for (let r = headerRowIdx + 1; r < data.length; r += 1) {
|
||||
const row = data[r] || [];
|
||||
for (const c of cols) {
|
||||
const cell = String(row[c] ?? "").trim();
|
||||
if (!cell) continue;
|
||||
|
||||
const m = cell.match(CODE);
|
||||
if (m) {
|
||||
rawItems.push({
|
||||
col: c,
|
||||
categoryLabel: categoryByCol[c],
|
||||
prefix: m[1].toUpperCase(),
|
||||
suffix: m[2],
|
||||
name: m[3].trim(),
|
||||
row: r,
|
||||
});
|
||||
} else if (cell.length > 2 && cell === cell.toUpperCase() && !/\d/.test(cell)) {
|
||||
categoryByCol[c] = cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Map<string, { kind:'downtime', name:string, codePrefix:string, items: { suffix:string, name:string }[]}>} */
|
||||
const catMap = new Map();
|
||||
|
||||
function catKey(categoryName, prefix) {
|
||||
return `${categoryName}\0${prefix}`;
|
||||
}
|
||||
|
||||
for (const it of rawItems) {
|
||||
const key = catKey(it.categoryLabel, it.prefix);
|
||||
let bucket = catMap.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { kind: "downtime", name: it.categoryLabel, codePrefix: it.prefix, items: [] };
|
||||
catMap.set(key, bucket);
|
||||
}
|
||||
bucket.items.push({ suffix: it.suffix, name: it.name });
|
||||
}
|
||||
|
||||
/** Dedupe suffix per category (keep first description). */
|
||||
for (const b of catMap.values()) {
|
||||
const seen = new Map();
|
||||
const next = [];
|
||||
for (const row of b.items) {
|
||||
if (seen.has(row.suffix)) continue;
|
||||
seen.set(row.suffix, true);
|
||||
next.push(row);
|
||||
}
|
||||
b.items = next.sort((a, b) => Number(a.suffix) - Number(b.suffix));
|
||||
}
|
||||
|
||||
return [...catMap.values()];
|
||||
}
|
||||
|
||||
function parseScrapXlsx(filePath) {
|
||||
const wb = readWorkbook(filePath);
|
||||
const data = XLSX.utils.sheet_to_json(wb.Sheets["Sheet1"], { header: 1, defval: "" });
|
||||
/** @type { { suffix:string, name:string, full:string }[] } */
|
||||
const rows = [];
|
||||
for (let r = 0; r < data.length; r += 1) {
|
||||
const clave = String(data[r][0] ?? "").trim();
|
||||
const desc = String(data[r][1] ?? "").trim().replace(/\s+/g, " ");
|
||||
if (!clave || /^clave/i.test(clave)) continue;
|
||||
if (!desc || /Rev\.?\s*[A-Z]/i.test(desc)) continue;
|
||||
const m = clave.toUpperCase().match(/^([A-Z]+)(\d+)$/);
|
||||
if (!m) {
|
||||
console.warn(`[scrap] skip row ${r}:`, clave);
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
full: `${m[1]}${m[2]}`,
|
||||
suffix: m[2],
|
||||
name: desc,
|
||||
});
|
||||
}
|
||||
|
||||
/** Single category when all MX… */
|
||||
const prefixes = new Set(rows.map((x) => x.full.replace(/\d+$/, "")));
|
||||
if (prefixes.size !== 1) {
|
||||
console.warn("[scrap] multiple prefixes:", [...prefixes]);
|
||||
}
|
||||
const codePrefix = [...prefixes][0] || "MX";
|
||||
const items = rows.map(({ suffix, name }) => ({ suffix, name }));
|
||||
return [{ kind: "scrap", name: "Scrap", codePrefix, items }];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
let orgId = args.orgId;
|
||||
if (!orgId && args.orgSlug) {
|
||||
const org = await prisma.org.findUnique({ where: { slug: args.orgSlug }, select: { id: true } });
|
||||
if (!org) throw new Error(`Org slug not found: ${args.orgSlug}`);
|
||||
orgId = org.id;
|
||||
}
|
||||
if (!orgId) {
|
||||
console.error("Provide --org-id <uuid> or --org-slug <slug>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const downtimeCats = parseDowntimeXlsx(args.downtimePath);
|
||||
const scrapCats = parseScrapXlsx(args.scrapPath);
|
||||
|
||||
const totalItems =
|
||||
downtimeCats.reduce((n, c) => n + c.items.length, 0) + scrapCats.reduce((n, c) => n + c.items.length, 0);
|
||||
|
||||
console.log("[seed] downtime categories:", downtimeCats.length, "scrap categories:", scrapCats.length);
|
||||
console.log("[seed] total items:", totalItems);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log(JSON.stringify({ downtimeCats: downtimeCats.slice(0, 2), scrapCats }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await prisma.reasonCatalogCategory.count({ where: { orgId } });
|
||||
if (existing && !args.replace) {
|
||||
console.error(
|
||||
`Org already has ${existing} catalog categor(ies). Re-run with --replace to wipe and reload, or use Control Tower UI.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const bundled = [...downtimeCats, ...scrapCats];
|
||||
/** @type {string[]} */
|
||||
const dupCheck = [];
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (args.replace) {
|
||||
await tx.reasonCatalogItem.deleteMany({ where: { orgId } });
|
||||
await tx.reasonCatalogCategory.deleteMany({ where: { orgId } });
|
||||
}
|
||||
|
||||
let catOrder = 0;
|
||||
for (const block of bundled) {
|
||||
const category = await tx.reasonCatalogCategory.create({
|
||||
data: {
|
||||
orgId,
|
||||
kind: block.kind,
|
||||
name: block.name,
|
||||
codePrefix: block.codePrefix,
|
||||
sortOrder: catOrder++,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
let itOrder = 0;
|
||||
for (const row of block.items) {
|
||||
const reasonCode = composeReasonCode(block.codePrefix, row.suffix);
|
||||
dupCheck.push(reasonCode);
|
||||
|
||||
await tx.reasonCatalogItem.create({
|
||||
data: {
|
||||
orgId,
|
||||
categoryId: category.id,
|
||||
name: row.name,
|
||||
codeSuffix: row.suffix,
|
||||
reasonCode,
|
||||
sortOrder: itOrder++,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx.orgSettings.update({
|
||||
where: { orgId },
|
||||
data: { version: { increment: 1 } },
|
||||
});
|
||||
});
|
||||
|
||||
const seen = new Set();
|
||||
let dup = 0;
|
||||
for (const rc of dupCheck) {
|
||||
if (seen.has(rc)) dup++;
|
||||
seen.add(rc);
|
||||
}
|
||||
if (dup) console.warn("[seed] duplicate reason_code skipped by DB unique?", dup);
|
||||
|
||||
console.log("[seed] done. Bump org_settings.version (+1).");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
Reference in New Issue
Block a user