recent changes
This commit is contained in:
30
README.md
30
README.md
@@ -75,6 +75,36 @@ sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now mis-control-tower-reminders.timer
|
||||
```
|
||||
|
||||
## Downtime Reason Backfill
|
||||
|
||||
Control-Tower now preserves manual downtime reasons from `downtime-acknowledged` events when later default stop events (`PENDIENTE` / `UNCLASSIFIED`) arrive for the same incident.
|
||||
|
||||
If historical rows were already overwritten, run the one-time backfill:
|
||||
|
||||
1) Dry run (default lookback: 30 days):
|
||||
|
||||
```bash
|
||||
npm run backfill:downtime-reasons -- --dry-run --since 30d
|
||||
```
|
||||
|
||||
2) Apply updates:
|
||||
|
||||
```bash
|
||||
npm run backfill:downtime-reasons -- --since 30d
|
||||
```
|
||||
|
||||
Optional filters:
|
||||
|
||||
```bash
|
||||
npm run backfill:downtime-reasons -- --dry-run --since 14d --org-id <orgId> --machine-id <machineId>
|
||||
```
|
||||
|
||||
Quick verification query (shows recent incidents with reason + source):
|
||||
|
||||
```bash
|
||||
node -e 'const {PrismaClient}=require("@prisma/client");const p=new PrismaClient();(async()=>{const rows=await p.reasonEntry.findMany({where:{kind:"downtime"},orderBy:{capturedAt:"desc"},take:30,select:{id:true,orgId:true,machineId:true,episodeId:true,reasonCode:true,reasonLabel:true,capturedAt:true,meta:true}});console.log(JSON.stringify(rows,(_,v)=>typeof v==="bigint"?v.toString():v,2));})().finally(()=>p.$disconnect());'
|
||||
```
|
||||
|
||||
## Production build and deploy
|
||||
|
||||
**Dev uses Turbopack, production build uses Webpack.** Next.js 16 defaults to Turbopack for both, but Turbopack production builds have known issues. This project uses:
|
||||
|
||||
@@ -31,7 +31,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() > 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS)
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string) {
|
||||
|
||||
347
app/(app)/machines/MachinesClient.tsx.bak
Normal file
347
app/(app)/machines/MachinesClient.tsx.bak
Normal file
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, type KeyboardEvent } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type MachineRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
location?: string | null;
|
||||
latestHeartbeat: null | {
|
||||
ts: string;
|
||||
tsServer?: string | null;
|
||||
status: string;
|
||||
message?: string | null;
|
||||
ip?: string | null;
|
||||
fwVersion?: string | null;
|
||||
};
|
||||
};
|
||||
const LIVE_REFRESH_MS = 5000;
|
||||
|
||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
if (!ts) return fallback;
|
||||
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
if (diff < 60) return rtf.format(-diff, "second");
|
||||
return rtf.format(-Math.floor(diff / 60), "minute");
|
||||
}
|
||||
|
||||
function isOffline(ts?: string) {
|
||||
if (!ts) return true;
|
||||
return Date.now() - new Date(ts).getTime() > 30000; // 30s threshold
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string) {
|
||||
const s = (status ?? "").toUpperCase();
|
||||
if (s === "ONLINE") return "RUN";
|
||||
return s;
|
||||
}
|
||||
|
||||
function badgeClass(status?: string, offline?: boolean) {
|
||||
if (offline) return "bg-white/10 text-zinc-300";
|
||||
const s = (status ?? "").toUpperCase();
|
||||
if (s === "RUN") return "bg-emerald-500/15 text-emerald-300";
|
||||
if (s === "IDLE") return "bg-yellow-500/15 text-yellow-300";
|
||||
if (s === "STOP" || s === "DOWN") return "bg-red-500/15 text-red-300";
|
||||
return "bg-white/10 text-white";
|
||||
}
|
||||
|
||||
export default function MachinesClient({ initialMachines = [] }: { initialMachines?: MachineRow[] }) {
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
|
||||
const [loading, setLoading] = useState(() => initialMachines.length === 0);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createCode, setCreateCode] = useState("");
|
||||
const [createLocation, setCreateLocation] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [createdMachine, setCreatedMachine] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
pairingCode: string;
|
||||
pairingExpiresAt: string;
|
||||
} | null>(null);
|
||||
const [copyStatus, setCopyStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function load(initial: boolean) {
|
||||
try {
|
||||
if (!initial && typeof document !== "undefined" && document.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/machines", { cache: "no-store" });
|
||||
const json = await res.json();
|
||||
if (alive) {
|
||||
setMachines(json.machines ?? []);
|
||||
if (initial) setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (alive && initial) setLoading(false);
|
||||
} finally {
|
||||
if (!alive) return;
|
||||
timer = setTimeout(() => {
|
||||
void load(false);
|
||||
}, LIVE_REFRESH_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void load(initialMachines.length === 0);
|
||||
return () => {
|
||||
alive = false;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [initialMachines.length]);
|
||||
|
||||
async function createMachine() {
|
||||
if (!createName.trim()) {
|
||||
setCreateError(t("machines.create.error.nameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/machines", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: createName,
|
||||
code: createCode,
|
||||
location: createLocation,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
throw new Error(data.error || t("machines.create.error.failed"));
|
||||
}
|
||||
|
||||
const nextMachine = {
|
||||
...data.machine,
|
||||
latestHeartbeat: null,
|
||||
};
|
||||
setMachines((prev) => [nextMachine, ...prev]);
|
||||
setCreatedMachine({
|
||||
id: data.machine.id,
|
||||
name: data.machine.name,
|
||||
pairingCode: data.machine.pairingCode,
|
||||
pairingExpiresAt: data.machine.pairingCodeExpiresAt,
|
||||
});
|
||||
setCreateName("");
|
||||
setCreateCode("");
|
||||
setCreateLocation("");
|
||||
setShowCreate(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : null;
|
||||
setCreateError(message || t("machines.create.error.failed"));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopyStatus(t("machines.pairing.copied"));
|
||||
} else {
|
||||
setCopyStatus(t("machines.pairing.copyUnsupported"));
|
||||
}
|
||||
} catch {
|
||||
setCopyStatus(t("machines.pairing.copyFailed"));
|
||||
}
|
||||
setTimeout(() => setCopyStatus(null), 2000);
|
||||
}
|
||||
|
||||
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, machineId: string) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
router.push(`/machines/${machineId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const showCreateCard = showCreate || (!loading && machines.length === 0);
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">{t("machines.title")}</h1>
|
||||
<p className="text-sm text-zinc-400">{t("machines.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate((prev) => !prev)}
|
||||
className="w-full rounded-xl border border-emerald-400/40 bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/30 sm:w-auto"
|
||||
>
|
||||
{showCreate ? t("machines.cancel") : t("machines.addMachine")}
|
||||
</button>
|
||||
<Link
|
||||
href="/overview"
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{t("machines.backOverview")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreateCard && (
|
||||
<div className="mb-6 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{t("machines.addCardTitle")}</div>
|
||||
<div className="text-xs text-zinc-400">{t("machines.addCardSubtitle")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||
{t("machines.field.name")}
|
||||
<input
|
||||
value={createName}
|
||||
onChange={(event) => setCreateName(event.target.value)}
|
||||
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
|
||||
/>
|
||||
</label>
|
||||
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||
{t("machines.field.code")}
|
||||
<input
|
||||
value={createCode}
|
||||
onChange={(event) => setCreateCode(event.target.value)}
|
||||
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
|
||||
/>
|
||||
</label>
|
||||
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||
{t("machines.field.location")}
|
||||
<input
|
||||
value={createLocation}
|
||||
onChange={(event) => setCreateLocation(event.target.value)}
|
||||
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={createMachine}
|
||||
disabled={creating}
|
||||
className="rounded-xl border border-emerald-400/40 bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/30 disabled:opacity-60"
|
||||
>
|
||||
{creating ? t("machines.create.loading") : t("machines.create.default")}
|
||||
</button>
|
||||
{createError && <div className="text-xs text-red-200">{createError}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdMachine && (
|
||||
<div className="mb-6 rounded-2xl border border-emerald-500/20 bg-emerald-500/10 p-5">
|
||||
<div className="text-sm font-semibold text-white">{t("machines.pairing.title")}</div>
|
||||
<div className="mt-2 text-xs text-zinc-300">
|
||||
{t("machines.pairing.machine")} <span className="text-white">{createdMachine.name}</span>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/10 bg-black/30 p-4">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("machines.pairing.codeLabel")}</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-white">{createdMachine.pairingCode}</div>
|
||||
<div className="mt-2 text-xs text-zinc-400">
|
||||
{t("machines.pairing.expires")}{" "}
|
||||
{createdMachine.pairingExpiresAt
|
||||
? new Date(createdMachine.pairingExpiresAt).toLocaleString(locale)
|
||||
: t("machines.pairing.soon")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-zinc-300">
|
||||
{t("machines.pairing.instructions")}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(createdMachine.pairingCode)}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white hover:bg-white/10"
|
||||
>
|
||||
{t("machines.pairing.copy")}
|
||||
</button>
|
||||
{copyStatus && <div className="text-xs text-zinc-300">{copyStatus}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="mb-4 text-sm text-zinc-400">{t("machines.loading")}</div>}
|
||||
|
||||
{!loading && machines.length === 0 && (
|
||||
<div className="mb-4 text-sm text-zinc-400">{t("machines.empty")}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{(!loading ? machines : []).map((m) => {
|
||||
const hb = m.latestHeartbeat;
|
||||
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"));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
role="link"
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-lg font-semibold text-white">{m.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{m.code ? m.code : t("common.na")} - {t("machines.lastSeen", { time: lastSeen })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs ${badgeClass(
|
||||
normalizedStatus,
|
||||
offline
|
||||
)}`}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-zinc-400">{t("machines.status")}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm font-semibold text-white">
|
||||
{offline ? (
|
||||
<>
|
||||
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-zinc-500" aria-hidden="true" />
|
||||
<span>{t("machines.status.noHeartbeat")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="relative flex h-2.5 w-2.5" aria-hidden="true">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
<span>{t("machines.status.ok")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
formatDuration,
|
||||
formatTime,
|
||||
normalizeTimelineSegments,
|
||||
SEGMENT_MIN_WIDTH_PCT,
|
||||
TIMELINE_COLORS,
|
||||
} from "@/components/recap/timelineRender";
|
||||
|
||||
@@ -106,6 +107,9 @@ type WorkOrderUpload = {
|
||||
sku?: string;
|
||||
targetQty?: number;
|
||||
cycleTime?: number;
|
||||
mold?: string;
|
||||
cavitiesTotal?: number;
|
||||
cavitiesActive?: number;
|
||||
};
|
||||
|
||||
type WorkOrderRow = Record<string, string | number | boolean>;
|
||||
@@ -192,8 +196,31 @@ const WORK_ORDER_KEYS = {
|
||||
"theoretical_cycle_time",
|
||||
]),
|
||||
target: new Set(["targetquantity", "targetqty", "target", "target_qty"]),
|
||||
mold: new Set(["mold", "molde", "moldid", "mold_id"]),
|
||||
cavitiesTotal: new Set([
|
||||
"totalcavities",
|
||||
"cavitiestotal",
|
||||
"cavities_total",
|
||||
"total_cavities",
|
||||
]),
|
||||
cavitiesActive: new Set([
|
||||
"activecavities",
|
||||
"cavitiesactive",
|
||||
"cavities_active",
|
||||
"active_cavities",
|
||||
]),
|
||||
};
|
||||
|
||||
const WORK_ORDER_TEMPLATE_HEADERS = [
|
||||
"Work Order ID",
|
||||
"SKU",
|
||||
"Theoretical Cycle Time (Seconds)",
|
||||
"Target Quantity",
|
||||
"Mold",
|
||||
"Total Cavities",
|
||||
"Active Cavities",
|
||||
] as const;
|
||||
|
||||
function normalizeKey(value: string) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
@@ -278,7 +305,26 @@ function rowsToWorkOrders(rows: WorkOrderRow[]): WorkOrderUpload[] {
|
||||
const targetQty = Number.isFinite(Number(targetRaw)) ? Math.trunc(Number(targetRaw)) : undefined;
|
||||
const cycleTime = Number.isFinite(Number(cycleRaw)) ? Number(cycleRaw) : undefined;
|
||||
|
||||
out.push({ workOrderId, sku: sku || undefined, targetQty, cycleTime });
|
||||
const moldRaw = pickRowValue(row, WORK_ORDER_KEYS.mold);
|
||||
const mold = String(moldRaw ?? "").trim();
|
||||
const totalCavRaw = pickRowValue(row, WORK_ORDER_KEYS.cavitiesTotal);
|
||||
const activeCavRaw = pickRowValue(row, WORK_ORDER_KEYS.cavitiesActive);
|
||||
const cavitiesTotal = Number.isFinite(Number(totalCavRaw))
|
||||
? Math.trunc(Number(totalCavRaw))
|
||||
: undefined;
|
||||
const cavitiesActive = Number.isFinite(Number(activeCavRaw))
|
||||
? Math.trunc(Number(activeCavRaw))
|
||||
: undefined;
|
||||
|
||||
out.push({
|
||||
workOrderId,
|
||||
sku: sku || undefined,
|
||||
targetQty,
|
||||
cycleTime,
|
||||
mold: mold || undefined,
|
||||
cavitiesTotal,
|
||||
cavitiesActive,
|
||||
});
|
||||
});
|
||||
|
||||
return out;
|
||||
@@ -308,6 +354,14 @@ type MachineActivityTimelineProps = {
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
};
|
||||
|
||||
function getMinuteFlooredOneHourRange(referenceMs = Date.now()) {
|
||||
const endMs = Math.floor(referenceMs / 60000) * 60000;
|
||||
return {
|
||||
startMs: endMs - 60 * 60 * 1000,
|
||||
endMs,
|
||||
};
|
||||
}
|
||||
|
||||
function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) {
|
||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||
const [timelineLoading, setTimelineLoading] = useState(true);
|
||||
@@ -321,11 +375,18 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
|
||||
|
||||
async function loadTimeline() {
|
||||
try {
|
||||
const res = await fetch(`/api/recap/${machineId}/timeline?range=1h`, { cache: "no-store" });
|
||||
const range = getMinuteFlooredOneHourRange();
|
||||
const params = new URLSearchParams({
|
||||
start: String(range.startMs),
|
||||
end: String(range.endMs),
|
||||
});
|
||||
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;
|
||||
const nextTimeline = json as RecapTimelineResponse;
|
||||
const nextHash = JSON.stringify({
|
||||
start: nextTimeline.range.start,
|
||||
end: nextTimeline.range.end,
|
||||
hasData: nextTimeline.hasData,
|
||||
segments: nextTimeline.segments.map((segment) => ({
|
||||
type: segment.type,
|
||||
@@ -353,14 +414,18 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
|
||||
}, [machineId]);
|
||||
|
||||
const hasData = timeline?.hasData ?? false;
|
||||
const startMs = timeline ? new Date(timeline.range.start).getTime() : Date.now() - 60 * 60 * 1000;
|
||||
const endMs = timeline ? new Date(timeline.range.end).getTime() : Date.now();
|
||||
const fallbackRange = getMinuteFlooredOneHourRange();
|
||||
const startMs = timeline ? new Date(timeline.range.start).getTime() : fallbackRange.startMs;
|
||||
const endMs = timeline ? new Date(timeline.range.end).getTime() : fallbackRange.endMs;
|
||||
const totalMs = Math.max(1, endMs - startMs);
|
||||
const normalized = useMemo(() => {
|
||||
if (!timeline || !hasData) return [] as RecapTimelineSegment[];
|
||||
return normalizeTimelineSegments(timeline.segments, startMs, endMs);
|
||||
}, [timeline, hasData, startMs, endMs]);
|
||||
const widths = useMemo(() => computeWidths(normalized, totalMs, 1.5), [normalized, totalMs]);
|
||||
const widths = useMemo(
|
||||
() => computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT),
|
||||
[normalized, totalMs]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
@@ -586,6 +651,23 @@ export default function MachineDetailClient() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function downloadWorkOrderTemplate() {
|
||||
const xlsx = await import("xlsx");
|
||||
const wb = xlsx.utils.book_new();
|
||||
const ws = xlsx.utils.aoa_to_sheet([Array.from(WORK_ORDER_TEMPLATE_HEADERS)]);
|
||||
xlsx.utils.book_append_sheet(wb, ws, "Work Orders");
|
||||
const wbout = xlsx.write(wb, { bookType: "xlsx", type: "array" });
|
||||
const blob = new Blob([wbout], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "work-orders-template.xlsx";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function handleWorkOrderUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -1005,6 +1087,13 @@ export default function MachineDetailClient() {
|
||||
className="hidden"
|
||||
onChange={handleWorkOrderUpload}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void downloadWorkOrderTemplate()}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white transition hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{t("machine.detail.workOrders.downloadTemplate")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import type { EventRow, Heartbeat, MachineRow } from "./types";
|
||||
|
||||
const OFFLINE_MS = 30000;
|
||||
const OFFLINE_MS = 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS)
|
||||
const MAX_EVENT_MACHINES = 6;
|
||||
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
|
||||
|
||||
|
||||
388
app/(app)/overview/OverviewClient.tsx.bak
Normal file
388
app/(app)/overview/OverviewClient.tsx.bak
Normal file
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import type { EventRow, Heartbeat, MachineRow } from "./types";
|
||||
|
||||
const OFFLINE_MS = 30000;
|
||||
const MAX_EVENT_MACHINES = 6;
|
||||
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
|
||||
|
||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
if (!ts) return fallback;
|
||||
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
if (diff < 60) return rtf.format(-diff, "second");
|
||||
if (diff < 3600) return rtf.format(-Math.floor(diff / 60), "minute");
|
||||
return rtf.format(-Math.floor(diff / 3600), "hour");
|
||||
}
|
||||
|
||||
function isOffline(ts?: string) {
|
||||
if (!ts) return true;
|
||||
return Date.now() - new Date(ts).getTime() > OFFLINE_MS;
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string) {
|
||||
const s = (status ?? "").toUpperCase();
|
||||
if (s === "ONLINE") return "RUN";
|
||||
return s;
|
||||
}
|
||||
|
||||
function heartbeatTime(hb?: Heartbeat | null) {
|
||||
return hb?.tsServer ?? hb?.ts;
|
||||
}
|
||||
|
||||
function fmtPct(v?: number | null) {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) return "--";
|
||||
return `${v.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function fmtNum(v?: number | null) {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) return "--";
|
||||
return `${Math.round(v)}`;
|
||||
}
|
||||
|
||||
function OverviewTimelineSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="h-4 w-32 rounded bg-white/10" />
|
||||
<div className="h-3 w-20 rounded bg-white/5" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<div key={idx} className="h-20 rounded-xl border border-white/10 bg-black/20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewClient({
|
||||
initialMachines = [],
|
||||
initialEvents = [],
|
||||
}: {
|
||||
initialMachines?: MachineRow[];
|
||||
initialEvents?: EventRow[];
|
||||
}) {
|
||||
const { t, locale } = useI18n();
|
||||
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
|
||||
const [events, setEvents] = useState<EventRow[]>(() => initialEvents);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [eventsLoading, setEventsLoading] = useState(() => initialEvents.length === 0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setEventsLoading(true);
|
||||
const res = await fetch(
|
||||
`/api/overview?detail=1&events=critical&eventMachines=${MAX_EVENT_MACHINES}`,
|
||||
{
|
||||
cache: "no-cache",
|
||||
}
|
||||
);
|
||||
if (res.status === 304) {
|
||||
if (alive) setLoading(false);
|
||||
return;
|
||||
}
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!alive) return;
|
||||
setMachines(json.machines ?? []);
|
||||
setEvents(json.events ?? []);
|
||||
setLoading(false);
|
||||
} catch {
|
||||
if (!alive) return;
|
||||
setMachines([]);
|
||||
setEvents([]);
|
||||
setLoading(false);
|
||||
} finally {
|
||||
if (alive) setEventsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
const t = setInterval(load, 30000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = machines.length;
|
||||
let online = 0;
|
||||
let running = 0;
|
||||
let idle = 0;
|
||||
let stopped = 0;
|
||||
let oeeSum = 0;
|
||||
let oeeCount = 0;
|
||||
let availSum = 0;
|
||||
let availCount = 0;
|
||||
let perfSum = 0;
|
||||
let perfCount = 0;
|
||||
let qualSum = 0;
|
||||
let qualCount = 0;
|
||||
let goodSum = 0;
|
||||
let scrapSum = 0;
|
||||
let targetSum = 0;
|
||||
let hasKpi = false;
|
||||
|
||||
for (const m of machines) {
|
||||
const hb = m.latestHeartbeat;
|
||||
const offline = isOffline(heartbeatTime(hb));
|
||||
if (!offline) online += 1;
|
||||
|
||||
const status = normalizeStatus(hb?.status);
|
||||
if (!offline) {
|
||||
if (status === "RUN") running += 1;
|
||||
else if (status === "IDLE") idle += 1;
|
||||
else if (status === "STOP" || status === "DOWN") stopped += 1;
|
||||
}
|
||||
|
||||
const k = m.latestKpi;
|
||||
if (k?.oee != null) {
|
||||
oeeSum += Number(k.oee);
|
||||
oeeCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.availability != null) {
|
||||
availSum += Number(k.availability);
|
||||
availCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.performance != null) {
|
||||
perfSum += Number(k.performance);
|
||||
perfCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.quality != null) {
|
||||
qualSum += Number(k.quality);
|
||||
qualCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.good != null) {
|
||||
goodSum += Number(k.good);
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.scrap != null) {
|
||||
scrapSum += Number(k.scrap);
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.target != null) {
|
||||
targetSum += Number(k.target);
|
||||
hasKpi = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
online,
|
||||
offline: total - online,
|
||||
running,
|
||||
idle,
|
||||
stopped,
|
||||
oee: oeeCount ? oeeSum / oeeCount : null,
|
||||
availability: availCount ? availSum / availCount : null,
|
||||
performance: perfCount ? perfSum / perfCount : null,
|
||||
quality: qualCount ? qualSum / qualCount : null,
|
||||
goodSum: hasKpi ? goodSum : null,
|
||||
scrapSum: hasKpi ? scrapSum : null,
|
||||
targetSum: hasKpi ? targetSum : null,
|
||||
};
|
||||
}, [machines]);
|
||||
|
||||
const attention = useMemo(() => {
|
||||
const list = machines
|
||||
.map((m) => {
|
||||
const hb = m.latestHeartbeat;
|
||||
const offline = isOffline(heartbeatTime(hb));
|
||||
const k = m.latestKpi;
|
||||
const oee = k?.oee ?? null;
|
||||
let score = 0;
|
||||
if (offline) score += 100;
|
||||
if (oee != null && oee < 75) score += 50;
|
||||
if (oee != null && oee < 85) score += 25;
|
||||
return { machine: m, offline, oee, score };
|
||||
})
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 6);
|
||||
|
||||
return list;
|
||||
}, [machines]);
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">{t("overview.title")}</h1>
|
||||
<p className="text-sm text-zinc-400">{t("overview.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/machines"
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{t("overview.viewMachines")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading && <div className="mb-4 text-sm text-zinc-400">{t("overview.loading")}</div>}
|
||||
|
||||
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{t("overview.recap.title")}</div>
|
||||
<div className="text-xs text-zinc-400">{t("overview.recap.subtitle")}</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/recap"
|
||||
className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 hover:bg-emerald-500/20"
|
||||
>
|
||||
{t("overview.recap.cta")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.fleetHealth")}</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-white">{stats.total}</div>
|
||||
<div className="mt-2 text-xs text-zinc-400">{t("overview.machinesTotal")}</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs">
|
||||
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-emerald-300">
|
||||
{t("overview.online")} {stats.online}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-zinc-300">
|
||||
{t("overview.offline")} {stats.offline}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-emerald-200">
|
||||
{t("overview.run")} {stats.running}
|
||||
</span>
|
||||
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
||||
{t("overview.idle")} {stats.idle}
|
||||
</span>
|
||||
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-red-300">
|
||||
{t("overview.stop")} {stats.stopped}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.productionTotals")}</div>
|
||||
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-[11px] text-zinc-400">{t("overview.good")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.goodSum)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-[11px] text-zinc-400">{t("overview.scrap")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.scrapSum)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-[11px] text-zinc-400">{t("overview.target")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.targetSum)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-zinc-400">{t("overview.kpiSumNote")}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.activityFeed")}</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-white">{events.length}</div>
|
||||
<div className="mt-2 text-xs text-zinc-400">
|
||||
{eventsLoading ? t("overview.eventsRefreshing") : t("overview.eventsLast30")}
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{events.slice(0, 3).map((e) => (
|
||||
<div key={e.id} className="flex items-center justify-between text-xs text-zinc-300">
|
||||
<div className="truncate">
|
||||
{e.machineName ? `${e.machineName}: ` : ""}
|
||||
{e.title}
|
||||
</div>
|
||||
<div className="shrink-0 text-zinc-500">
|
||||
{secondsAgo(e.ts, locale, t("common.never"))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{events.length === 0 && !eventsLoading ? (
|
||||
<div className="text-xs text-zinc-500">{t("overview.eventsNone")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.oeeAvg")}</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-emerald-300">{fmtPct(stats.oee)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.availabilityAvg")}</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.availability)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.performanceAvg")}</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.performance)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.qualityAvg")}</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.quality)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold text-white">{t("overview.attentionList")}</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{attention.length} {t("overview.shown")}
|
||||
</div>
|
||||
</div>
|
||||
{attention.length === 0 ? (
|
||||
<div className="text-sm text-zinc-400">{t("overview.noUrgent")}</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{attention.map(({ machine, offline, oee }) => (
|
||||
<div key={machine.id} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{machine.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{machine.code ?? ""} {machine.location ? `- ${machine.location}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 ${
|
||||
offline ? "bg-white/10 text-zinc-300" : "bg-emerald-500/15 text-emerald-300"
|
||||
}`}
|
||||
>
|
||||
{offline ? t("overview.status.offline") : t("overview.status.online")}
|
||||
</span>
|
||||
{oee != null && (
|
||||
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
||||
OEE {fmtPct(oee)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<OverviewTimelineSkeleton />}>
|
||||
<OverviewTimeline events={events} eventsLoading={eventsLoading} locale={locale} t={t} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
||||
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));
|
||||
@@ -78,11 +79,13 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
||||
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 ?? machine.timeline;
|
||||
const timelineHasData = timeline?.hasData ?? true;
|
||||
const timelineSegments = timeline?.segments ?? [];
|
||||
const timelineHasData = timeline?.hasData ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setTimeline(null);
|
||||
setTimelineLoading(true);
|
||||
|
||||
async function loadTimeline() {
|
||||
try {
|
||||
@@ -95,6 +98,8 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
||||
if (!alive || !res.ok || !json) return;
|
||||
setTimeline(json as RecapTimelineResponse);
|
||||
} catch {
|
||||
} finally {
|
||||
if (alive) setTimelineLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +217,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
||||
rangeEnd={timelineEnd}
|
||||
segments={timelineSegments}
|
||||
hasData={timelineHasData}
|
||||
loading={timelineLoading}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,12 @@ const MAX_EVENTS = 100;
|
||||
|
||||
//when no cycle time is configed
|
||||
const DEFAULT_MACROSTOP_SEC = 300;
|
||||
const NON_AUTHORITATIVE_REASON_CODES = new Set(["PENDIENTE", "UNCLASSIFIED"]);
|
||||
|
||||
function isNonAuthoritativeReasonCode(code: unknown) {
|
||||
const normalized = clampText(code, 64)?.toUpperCase();
|
||||
return !!normalized && NON_AUTHORITATIVE_REASON_CODES.has(normalized);
|
||||
}
|
||||
|
||||
function clampText(value: unknown, maxLen: number) {
|
||||
if (value === null || value === undefined) return null;
|
||||
@@ -271,8 +276,10 @@ export async function POST(req: Request) {
|
||||
|
||||
const machine = await getMachineAuth(String(machineId), apiKey);
|
||||
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const bodySeq = parseSeqToBigInt(bodyRecord.seq);
|
||||
const bodySchemaVersion = clampText(bodyRecord.schemaVersion, 16);
|
||||
|
||||
const orgSettings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: machine.orgId },
|
||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
|
||||
@@ -602,6 +609,62 @@ export async function POST(req: Request) {
|
||||
numberFrom(evDowntime?.acknowledgedAtMs) ??
|
||||
null;
|
||||
|
||||
let guardedWrite = commonWrite;
|
||||
const incomingIsNonAuthoritative = isNonAuthoritativeReasonCode(resolved.reasonCode);
|
||||
const isManualAckEvent = finalType === "downtime-acknowledged";
|
||||
if (!isManualAckEvent && incomingIsNonAuthoritative) {
|
||||
const existingEpisode = await prisma.reasonEntry.findFirst({
|
||||
where: {
|
||||
orgId: machine.orgId,
|
||||
kind: "downtime",
|
||||
episodeId: incidentKey,
|
||||
},
|
||||
select: {
|
||||
reasonCode: true,
|
||||
reasonLabel: true,
|
||||
reasonText: true,
|
||||
meta: true,
|
||||
},
|
||||
});
|
||||
if (existingEpisode && !isNonAuthoritativeReasonCode(existingEpisode.reasonCode)) {
|
||||
const existingMeta = asRecord(existingEpisode.meta);
|
||||
const existingMetaReason = asRecord(existingMeta?.reason);
|
||||
guardedWrite = {
|
||||
...commonWrite,
|
||||
reasonCode: existingEpisode.reasonCode,
|
||||
reasonLabel: existingEpisode.reasonLabel ?? existingEpisode.reasonCode,
|
||||
reasonText:
|
||||
existingEpisode.reasonText ??
|
||||
existingEpisode.reasonLabel ??
|
||||
existingEpisode.reasonCode,
|
||||
meta: toJsonValue({
|
||||
source: "ingest:event",
|
||||
eventId: row.id,
|
||||
eventType: row.eventType,
|
||||
incidentKey: clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128),
|
||||
anomalyType:
|
||||
clampText(evRecord.anomalyType, 64) ??
|
||||
clampText(evDowntime?.anomalyType, 64) ??
|
||||
clampText(evRecord.anomaly_type, 64),
|
||||
reason: existingMetaReason ?? {
|
||||
type: resolved.type,
|
||||
categoryId: resolved.categoryId,
|
||||
categoryLabel: resolved.categoryLabel,
|
||||
detailId: resolved.detailId,
|
||||
detailLabel: resolved.detailLabel,
|
||||
reasonText:
|
||||
existingEpisode.reasonText ??
|
||||
existingEpisode.reasonLabel ??
|
||||
existingEpisode.reasonCode,
|
||||
catalogVersion: resolved.catalogVersion,
|
||||
},
|
||||
reasonPreservedFromManual: true,
|
||||
incomingReasonCode: resolved.reasonCode,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.reasonEntry.upsert({
|
||||
where: { reasonId },
|
||||
create: {
|
||||
@@ -612,14 +675,14 @@ export async function POST(req: Request) {
|
||||
episodeId: incidentKey,
|
||||
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
|
||||
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
|
||||
...commonWrite,
|
||||
...guardedWrite,
|
||||
},
|
||||
update: {
|
||||
kind: "downtime",
|
||||
episodeId: incidentKey,
|
||||
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
|
||||
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
|
||||
...commonWrite,
|
||||
...guardedWrite,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -43,6 +43,9 @@ export async function GET(
|
||||
sku: row.sku,
|
||||
targetQty: row.targetQty,
|
||||
cycleTime: row.cycleTime,
|
||||
mold: row.mold,
|
||||
cavitiesTotal: row.cavitiesTotal,
|
||||
cavitiesActive: row.cavitiesActive,
|
||||
status: row.status,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -12,8 +12,10 @@ function canManage(role?: string | null) {
|
||||
const MAX_WORK_ORDERS = 2000;
|
||||
const MAX_WORK_ORDER_ID_LENGTH = 64;
|
||||
const MAX_SKU_LENGTH = 64;
|
||||
const MAX_MOLD_LENGTH = 256;
|
||||
const MAX_TARGET_QTY = 2_000_000_000;
|
||||
const MAX_CYCLE_TIME = 86_400;
|
||||
const MAX_CAVITIES = 100_000;
|
||||
const WORK_ORDER_ID_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
const uploadBodySchema = z.object({
|
||||
@@ -51,6 +53,15 @@ type WorkOrderInput = {
|
||||
sku?: string | null;
|
||||
targetQty?: number | null;
|
||||
cycleTime?: number | null;
|
||||
mold?: string | null;
|
||||
cavitiesTotal?: number | null;
|
||||
cavitiesActive?: number | null;
|
||||
};
|
||||
|
||||
type RowIssue = {
|
||||
row: number;
|
||||
workOrderId: string | null;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
function normalizeWorkOrders(raw: unknown[]) {
|
||||
@@ -78,17 +89,98 @@ function normalizeWorkOrders(raw: unknown[]) {
|
||||
const cycleTime =
|
||||
cycleTimeRaw == null ? null : Math.min(Math.max(cycleTimeRaw, 0), MAX_CYCLE_TIME);
|
||||
|
||||
const mold = cleanText(
|
||||
record.mold ?? record.moldId ?? record.mold_id ?? null,
|
||||
MAX_MOLD_LENGTH
|
||||
);
|
||||
const cavitiesTotalRaw = toIntOrNull(
|
||||
record.cavitiesTotal ??
|
||||
record.cavities_total ??
|
||||
record.totalCavities ??
|
||||
record.total_cavities
|
||||
);
|
||||
const cavitiesActiveRaw = toIntOrNull(
|
||||
record.cavitiesActive ??
|
||||
record.cavities_active ??
|
||||
record.activeCavities ??
|
||||
record.active_cavities
|
||||
);
|
||||
const cavitiesTotal =
|
||||
cavitiesTotalRaw == null
|
||||
? null
|
||||
: Math.min(Math.max(cavitiesTotalRaw, 0), MAX_CAVITIES);
|
||||
const cavitiesActive =
|
||||
cavitiesActiveRaw == null
|
||||
? null
|
||||
: Math.min(Math.max(cavitiesActiveRaw, 0), MAX_CAVITIES);
|
||||
|
||||
cleaned.push({
|
||||
workOrderId: idRaw,
|
||||
sku: sku ?? null,
|
||||
targetQty: targetQty ?? null,
|
||||
cycleTime: cycleTime ?? null,
|
||||
mold: mold ?? null,
|
||||
cavitiesTotal: cavitiesTotal ?? null,
|
||||
cavitiesActive: cavitiesActive ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// ✨ NUEVO: validación estricta del Excel
|
||||
// Cada fila debe tener mold (no vacío), cavitiesTotal (>=1), cavitiesActive (>=1, <=cavitiesTotal)
|
||||
// Si UNA SOLA fila falla, se rechaza el archivo completo (Opción A)
|
||||
function validateRows(rows: WorkOrderInput[], rawList: unknown[]): RowIssue[] {
|
||||
const issues: RowIssue[] = [];
|
||||
|
||||
// Validar lista cruda primero (si hay duplicados o IDs inválidos no llegaron a `cleaned`)
|
||||
// Pero aquí enfocamos en la validación de mold/cavidades sobre filas ya normalizadas.
|
||||
rows.forEach((row, idx) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Mold requerido
|
||||
if (!row.mold || row.mold.length === 0) {
|
||||
errors.push("Mold is required");
|
||||
}
|
||||
|
||||
// Cavities Total requerido y >= 1
|
||||
if (row.cavitiesTotal == null) {
|
||||
errors.push("Total Cavities is required");
|
||||
} else if (row.cavitiesTotal < 1) {
|
||||
errors.push("Total Cavities must be at least 1");
|
||||
}
|
||||
|
||||
// Cavities Active requerido y >= 1
|
||||
if (row.cavitiesActive == null) {
|
||||
errors.push("Active Cavities is required");
|
||||
} else if (row.cavitiesActive < 1) {
|
||||
errors.push("Active Cavities must be at least 1");
|
||||
}
|
||||
|
||||
// Active <= Total
|
||||
if (
|
||||
row.cavitiesActive != null &&
|
||||
row.cavitiesTotal != null &&
|
||||
row.cavitiesActive > row.cavitiesTotal
|
||||
) {
|
||||
errors.push(
|
||||
`Active Cavities (${row.cavitiesActive}) cannot exceed Total Cavities (${row.cavitiesTotal})`
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
issues.push({
|
||||
row: idx + 1, // 1-indexed para el operador
|
||||
workOrderId: row.workOrderId,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await requireSession();
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
@@ -138,6 +230,21 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ ok: false, error: "No valid work orders provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
// ✨ NUEVO: validación estricta de mold/cavidades
|
||||
// Si una sola fila falla, rechazamos el archivo completo
|
||||
const issues = validateRows(cleaned, listRaw);
|
||||
if (issues.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Validation failed",
|
||||
summary: `Excel rejected: ${issues.length} of ${cleaned.length} work order(s) have errors. All work orders must include mold name, total cavities, and active cavities. Fix and re-upload.`,
|
||||
issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const created = await prisma.machineWorkOrder.createMany({
|
||||
data: cleaned.map((row) => ({
|
||||
orgId: session.orgId,
|
||||
@@ -146,6 +253,9 @@ export async function POST(req: NextRequest) {
|
||||
sku: row.sku ?? null,
|
||||
targetQty: row.targetQty ?? null,
|
||||
cycleTime: row.cycleTime ?? null,
|
||||
mold: row.mold ?? null,
|
||||
cavitiesTotal: row.cavitiesTotal ?? null,
|
||||
cavitiesActive: row.cavitiesActive ?? null,
|
||||
status: "PENDING",
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
@@ -167,4 +277,4 @@ export async function POST(req: NextRequest) {
|
||||
inserted: created.count,
|
||||
total: cleaned.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
formatTime,
|
||||
LABEL_MIN_WIDTH_PCT,
|
||||
normalizeTimelineSegments,
|
||||
SEGMENT_MIN_WIDTH_PCT,
|
||||
TIMELINE_COLORS,
|
||||
} from "@/components/recap/timelineRender";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
@@ -17,28 +18,47 @@ type Props = {
|
||||
segments: RecapTimelineSegment[];
|
||||
locale: string;
|
||||
hasData?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
const MIN_SEGMENT_PCT = 1.5;
|
||||
|
||||
export default function RecapFullTimeline({ rangeStart, rangeEnd, segments, locale, hasData = true }: Props) {
|
||||
export default function RecapFullTimeline({
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
segments,
|
||||
locale,
|
||||
hasData = false,
|
||||
loading = false,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const startMs = new Date(rangeStart).getTime();
|
||||
const endMs = new Date(rangeEnd).getTime();
|
||||
const totalMs = Math.max(1, endMs - startMs);
|
||||
|
||||
const normalized = normalizeTimelineSegments(segments, startMs, endMs);
|
||||
const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT);
|
||||
const normalized = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
|
||||
const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="mb-3 text-sm font-semibold text-white">{t("recap.timeline.title")}</div>
|
||||
{!hasData ? (
|
||||
{loading ? (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[560px]">
|
||||
<div className="flex h-14 w-full animate-pulse overflow-hidden rounded-xl bg-white/5">
|
||||
<div className="h-full w-[12%] bg-zinc-700/70" />
|
||||
<div className="h-full w-[8%] bg-orange-500/60" />
|
||||
<div className="h-full w-[14%] bg-zinc-700/70" />
|
||||
<div className="h-full w-[7%] bg-red-500/60" />
|
||||
<div className="h-full w-[59%] bg-zinc-700/70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !hasData ? (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 p-4 text-sm text-zinc-400">
|
||||
{t("recap.timeline.noData")}
|
||||
</div>
|
||||
) : null}
|
||||
{hasData ? (
|
||||
{!loading && hasData ? (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[560px]">
|
||||
<div className="flex h-14 w-full overflow-hidden rounded-xl">
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||
import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types";
|
||||
import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline";
|
||||
|
||||
@@ -35,7 +34,6 @@ function toInt(value: number | null | undefined) {
|
||||
export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Props) {
|
||||
const { t, locale } = useI18n();
|
||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
const zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
|
||||
const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`;
|
||||
@@ -43,8 +41,6 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
|
||||
const timelineStart = timeline?.range.start ?? rangeStart;
|
||||
const timelineEnd = timeline?.range.end ?? rangeEnd;
|
||||
const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0;
|
||||
const staleHeartbeat =
|
||||
machine.lastSeenMs == null ? true : nowMs - machine.lastSeenMs > RECAP_HEARTBEAT_STALE_MS;
|
||||
|
||||
const lastSeenLabel =
|
||||
machine.lastActivityMin == null
|
||||
@@ -57,11 +53,6 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
|
||||
|
||||
const moldMinutes = machine.moldChange?.active ? machine.moldChange.elapsedMin : null;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNowMs(Date.now()), 60000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
@@ -144,11 +135,6 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
|
||||
{t("recap.banner.offline", { min: toInt(machine.offlineForMin) })}
|
||||
</div>
|
||||
) : null}
|
||||
{staleHeartbeat ? (
|
||||
<div className="mt-2 rounded-lg border border-amber-400/40 bg-amber-400/10 px-2 py-1.5 text-xs text-amber-200">
|
||||
{t("recap.card.desynced")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 text-xs text-zinc-400">{footerText}</div>
|
||||
</Link>
|
||||
|
||||
@@ -10,6 +10,7 @@ export const TIMELINE_COLORS: Record<RecapTimelineSegment["type"], string> = {
|
||||
};
|
||||
|
||||
export const LABEL_MIN_WIDTH_PCT = 5;
|
||||
export const SEGMENT_MIN_WIDTH_PCT = 1.5;
|
||||
|
||||
export function formatTime(valueMs: number, locale: string) {
|
||||
return new Date(valueMs).toLocaleTimeString(locale, {
|
||||
|
||||
File diff suppressed because one or more lines are too long
4246
flows (64).json
Normal file
4246
flows (64).json
Normal file
File diff suppressed because one or more lines are too long
975
lib/i18n/en.json
975
lib/i18n/en.json
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -468,7 +468,7 @@ async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
|
||||
}
|
||||
|
||||
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
|
||||
const now = new Date();
|
||||
const now = new Date(Math.floor(Date.now() / 60000) * 60000);
|
||||
const requestedMode = normalizedRangeMode(params.input.mode);
|
||||
const shiftEnabledCount = await prisma.orgShift.count({
|
||||
where: {
|
||||
|
||||
@@ -55,7 +55,8 @@ function parseMaxSegments(searchParams: URLSearchParams) {
|
||||
}
|
||||
|
||||
export function parseRecapTimelineRange(searchParams: URLSearchParams) {
|
||||
const end = parseDateInput(searchParams.get("end")) ?? new Date();
|
||||
const defaultEnd = new Date(Math.floor(Date.now() / 60000) * 60000);
|
||||
const end = parseDateInput(searchParams.get("end")) ?? defaultEnd;
|
||||
const startParam = parseDateInput(searchParams.get("start"));
|
||||
if (startParam && startParam < end) {
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "machine_work_orders" ADD COLUMN "mold" TEXT;
|
||||
ALTER TABLE "machine_work_orders" ADD COLUMN "cavities_total" INTEGER;
|
||||
ALTER TABLE "machine_work_orders" ADD COLUMN "cavities_active" INTEGER;
|
||||
@@ -254,6 +254,9 @@ model MachineWorkOrder {
|
||||
goodParts Int @default(0) @map("good_parts")
|
||||
scrapParts Int @default(0) @map("scrap_parts")
|
||||
cycleCount Int @default(0) @map("cycle_count")
|
||||
mold String?
|
||||
cavitiesTotal Int? @map("cavities_total")
|
||||
cavitiesActive Int? @map("cavities_active")
|
||||
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
|
||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
308
scripts/backfill-downtime-reasons.mjs
Normal file
308
scripts/backfill-downtime-reasons.mjs
Normal file
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const NON_AUTHORITATIVE_REASON_CODES = new Set(["PENDIENTE", "UNCLASSIFIED"]);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {
|
||||
dryRun: false,
|
||||
since: "30d",
|
||||
orgId: null,
|
||||
machineId: null,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--dry-run") {
|
||||
out.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--since") {
|
||||
out.since = argv[i + 1] || out.since;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--org-id") {
|
||||
out.orgId = argv[i + 1] || null;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--machine-id") {
|
||||
out.machineId = argv[i + 1] || null;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown argument: ${token}`);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseSince(value) {
|
||||
const now = Date.now();
|
||||
const text = String(value || "30d").trim().toLowerCase();
|
||||
const relative = text.match(/^(\d+)\s*([dhm])$/);
|
||||
if (relative) {
|
||||
const amount = Number(relative[1]);
|
||||
const unit = relative[2];
|
||||
const factor = unit === "d" ? 24 * 60 * 60 * 1000 : unit === "h" ? 60 * 60 * 1000 : 60 * 1000;
|
||||
return new Date(now - amount * factor);
|
||||
}
|
||||
const dt = new Date(value);
|
||||
if (Number.isNaN(dt.getTime())) {
|
||||
throw new Error(`Invalid --since value: ${value}. Use ISO date, or relative like 30d / 12h / 90m.`);
|
||||
}
|
||||
return dt;
|
||||
}
|
||||
|
||||
function asRecord(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
function clampText(value, maxLen) {
|
||||
if (value === null || value === undefined) return null;
|
||||
const text = String(value).trim().replace(/[\u0000-\u001f\u007f]/g, "");
|
||||
if (!text) return null;
|
||||
return text.length > maxLen ? text.slice(0, maxLen) : text;
|
||||
}
|
||||
|
||||
function canonicalId(input) {
|
||||
const text = String(input ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function toReasonCode(categoryId, detailId) {
|
||||
const cat = canonicalId(categoryId);
|
||||
const det = canonicalId(detailId);
|
||||
if (!cat || !det) return null;
|
||||
return `${cat}__${det}`.toUpperCase();
|
||||
}
|
||||
|
||||
function isNonAuthoritativeReasonCode(code) {
|
||||
const normalized = clampText(code, 64)?.toUpperCase();
|
||||
return !!normalized && NON_AUTHORITATIVE_REASON_CODES.has(normalized);
|
||||
}
|
||||
|
||||
function extractReasonPayload(data) {
|
||||
const rec = asRecord(data);
|
||||
if (!rec) return null;
|
||||
const direct = asRecord(rec.reason);
|
||||
if (direct) return direct;
|
||||
const downtime = asRecord(rec.downtime);
|
||||
const nested = asRecord(downtime?.reason);
|
||||
return nested || null;
|
||||
}
|
||||
|
||||
function extractIncidentKey(data, reason) {
|
||||
const rec = asRecord(data);
|
||||
const downtime = asRecord(rec?.downtime);
|
||||
return (
|
||||
clampText(rec?.incidentKey, 128) ??
|
||||
clampText(downtime?.incidentKey, 128) ??
|
||||
clampText(reason?.incidentKey, 128) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAckReason(reasonRaw) {
|
||||
const categoryId = clampText(reasonRaw?.categoryId, 64);
|
||||
const detailId = clampText(reasonRaw?.detailId, 64);
|
||||
const categoryLabel = clampText(reasonRaw?.categoryLabel, 120);
|
||||
const detailLabel = clampText(reasonRaw?.detailLabel, 120);
|
||||
|
||||
const reasonCode =
|
||||
clampText(reasonRaw?.reasonCode, 64)?.toUpperCase() ??
|
||||
toReasonCode(categoryId ?? categoryLabel, detailId ?? detailLabel) ??
|
||||
null;
|
||||
if (!reasonCode) return null;
|
||||
|
||||
const reasonLabel =
|
||||
clampText(reasonRaw?.reasonText, 240) ??
|
||||
(categoryLabel && detailLabel ? `${categoryLabel} > ${detailLabel}` : null) ??
|
||||
detailLabel ??
|
||||
categoryLabel ??
|
||||
reasonCode;
|
||||
|
||||
return {
|
||||
type: "downtime",
|
||||
categoryId,
|
||||
categoryLabel,
|
||||
detailId,
|
||||
detailLabel,
|
||||
reasonCode,
|
||||
reasonLabel,
|
||||
reasonText: reasonLabel,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const since = parseSince(args.since);
|
||||
|
||||
const where = {
|
||||
eventType: "downtime-acknowledged",
|
||||
ts: { gte: since },
|
||||
...(args.orgId ? { orgId: args.orgId } : {}),
|
||||
...(args.machineId ? { machineId: args.machineId } : {}),
|
||||
};
|
||||
|
||||
const ackEvents = await prisma.machineEvent.findMany({
|
||||
where,
|
||||
orderBy: { ts: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
orgId: true,
|
||||
machineId: true,
|
||||
ts: true,
|
||||
data: true,
|
||||
},
|
||||
});
|
||||
|
||||
const latestByIncident = new Map();
|
||||
for (const event of ackEvents) {
|
||||
const reasonRaw = extractReasonPayload(event.data);
|
||||
if (!reasonRaw) continue;
|
||||
const normalized = normalizeAckReason(reasonRaw);
|
||||
if (!normalized) continue;
|
||||
if (isNonAuthoritativeReasonCode(normalized.reasonCode)) continue;
|
||||
|
||||
const incidentKey = extractIncidentKey(event.data, reasonRaw);
|
||||
if (!incidentKey) continue;
|
||||
|
||||
const mapKey = `${event.orgId}::${incidentKey}`;
|
||||
if (latestByIncident.has(mapKey)) continue;
|
||||
latestByIncident.set(mapKey, {
|
||||
orgId: event.orgId,
|
||||
machineId: event.machineId,
|
||||
incidentKey,
|
||||
eventId: event.id,
|
||||
eventTs: event.ts,
|
||||
reason: normalized,
|
||||
});
|
||||
}
|
||||
|
||||
let scanned = 0;
|
||||
let candidates = 0;
|
||||
let updated = 0;
|
||||
let missingReasonEntry = 0;
|
||||
let alreadyManual = 0;
|
||||
let skippedNonPendingIncoming = 0;
|
||||
const samples = [];
|
||||
|
||||
for (const item of latestByIncident.values()) {
|
||||
scanned += 1;
|
||||
const existing = await prisma.reasonEntry.findFirst({
|
||||
where: {
|
||||
orgId: item.orgId,
|
||||
kind: "downtime",
|
||||
episodeId: item.incidentKey,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
reasonCode: true,
|
||||
reasonLabel: true,
|
||||
reasonText: true,
|
||||
capturedAt: true,
|
||||
schemaVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
missingReasonEntry += 1;
|
||||
continue;
|
||||
}
|
||||
if (!isNonAuthoritativeReasonCode(existing.reasonCode)) {
|
||||
alreadyManual += 1;
|
||||
continue;
|
||||
}
|
||||
if (isNonAuthoritativeReasonCode(item.reason.reasonCode)) {
|
||||
skippedNonPendingIncoming += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates += 1;
|
||||
const next = {
|
||||
reasonCode: item.reason.reasonCode,
|
||||
reasonLabel: item.reason.reasonLabel ?? item.reason.reasonCode,
|
||||
reasonText: item.reason.reasonText ?? item.reason.reasonLabel ?? item.reason.reasonCode,
|
||||
schemaVersion: Math.max(1, Number(existing.schemaVersion || 1)),
|
||||
meta: {
|
||||
source: "backfill:downtime-acknowledged",
|
||||
eventId: item.eventId,
|
||||
eventTs: item.eventTs.toISOString(),
|
||||
incidentKey: item.incidentKey,
|
||||
reason: {
|
||||
type: "downtime",
|
||||
categoryId: item.reason.categoryId,
|
||||
categoryLabel: item.reason.categoryLabel,
|
||||
detailId: item.reason.detailId,
|
||||
detailLabel: item.reason.detailLabel,
|
||||
reasonText: item.reason.reasonText,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
samples.push({
|
||||
reasonEntryId: existing.id,
|
||||
orgId: item.orgId,
|
||||
machineId: item.machineId,
|
||||
incidentKey: item.incidentKey,
|
||||
from: {
|
||||
reasonCode: existing.reasonCode,
|
||||
reasonLabel: existing.reasonLabel,
|
||||
reasonText: existing.reasonText,
|
||||
},
|
||||
to: {
|
||||
reasonCode: next.reasonCode,
|
||||
reasonLabel: next.reasonLabel,
|
||||
reasonText: next.reasonText,
|
||||
},
|
||||
});
|
||||
|
||||
if (!args.dryRun) {
|
||||
await prisma.reasonEntry.update({
|
||||
where: { id: existing.id },
|
||||
data: next,
|
||||
});
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
ok: true,
|
||||
mode: args.dryRun ? "dry-run" : "apply",
|
||||
since: since.toISOString(),
|
||||
filters: {
|
||||
orgId: args.orgId,
|
||||
machineId: args.machineId,
|
||||
},
|
||||
eventsRead: ackEvents.length,
|
||||
incidentsDeduped: latestByIncident.size,
|
||||
scanned,
|
||||
candidates,
|
||||
updated,
|
||||
missingReasonEntry,
|
||||
alreadyManual,
|
||||
skippedNonPendingIncoming,
|
||||
sampleUpdates: samples.slice(0, 25),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error("[backfill-downtime-reasons] failed:", err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
8
scripts/pi-work_orders_add_mold_cavities.sql
Normal file
8
scripts/pi-work_orders_add_mold_cavities.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Run on the Pi/MariaDB instance used by Node-RED (local `work_orders` table).
|
||||
-- Required before importing updated flows that INSERT/UPDATE mold, cavities_total, cavities_active.
|
||||
|
||||
ALTER TABLE work_orders ADD COLUMN mold VARCHAR(256) NULL;
|
||||
ALTER TABLE work_orders ADD COLUMN cavities_total INT NULL;
|
||||
ALTER TABLE work_orders ADD COLUMN cavities_active INT NULL;
|
||||
|
||||
-- If columns already exist, skip this script or adjust manually.
|
||||
68
scripts/test-downtime-reason-guard.mjs
Normal file
68
scripts/test-downtime-reason-guard.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const NON_AUTHORITATIVE_REASON_CODES = new Set(["PENDIENTE", "UNCLASSIFIED"]);
|
||||
|
||||
function isNonAuthoritativeReasonCode(code) {
|
||||
const normalized = String(code ?? "").trim().toUpperCase();
|
||||
return !!normalized && NON_AUTHORITATIVE_REASON_CODES.has(normalized);
|
||||
}
|
||||
|
||||
function shouldPreserveManualReason({
|
||||
incomingReasonCode,
|
||||
existingReasonCode,
|
||||
isManualAckEvent,
|
||||
}) {
|
||||
if (isManualAckEvent) return false;
|
||||
if (!isNonAuthoritativeReasonCode(incomingReasonCode)) return false;
|
||||
if (!existingReasonCode) return false;
|
||||
return !isNonAuthoritativeReasonCode(existingReasonCode);
|
||||
}
|
||||
|
||||
function run() {
|
||||
// 1) pending -> manual ack -> later pending: preserve manual
|
||||
assert.equal(
|
||||
shouldPreserveManualReason({
|
||||
incomingReasonCode: "PENDIENTE",
|
||||
existingReasonCode: "OPERACION__OTRO",
|
||||
isManualAckEvent: false,
|
||||
}),
|
||||
true
|
||||
);
|
||||
|
||||
// 2) manual ack followed by another manual reason: latest manual should be allowed
|
||||
assert.equal(
|
||||
shouldPreserveManualReason({
|
||||
incomingReasonCode: "SERVICIOS__OTRO",
|
||||
existingReasonCode: "OPERACION__OTRO",
|
||||
isManualAckEvent: true,
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
// 3) no manual reason ever applied: pending stays pending
|
||||
assert.equal(
|
||||
shouldPreserveManualReason({
|
||||
incomingReasonCode: "UNCLASSIFIED",
|
||||
existingReasonCode: "PENDIENTE",
|
||||
isManualAckEvent: false,
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
testedAt: new Date().toISOString(),
|
||||
scenarios: 3,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
Reference in New Issue
Block a user