recent changes

This commit is contained in:
Marcelo
2026-04-29 05:05:00 +00:00
parent 7e0fe5c2e1
commit 62169b163c
25 changed files with 6698 additions and 1013 deletions

View File

@@ -75,6 +75,36 @@ sudo systemctl daemon-reload
sudo systemctl enable --now mis-control-tower-reminders.timer 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 ## 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: **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:

View File

@@ -31,7 +31,7 @@ function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
function isOffline(ts?: string) { function isOffline(ts?: string) {
if (!ts) return true; 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) { function normalizeStatus(status?: string) {

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

View File

@@ -26,6 +26,7 @@ import {
formatDuration, formatDuration,
formatTime, formatTime,
normalizeTimelineSegments, normalizeTimelineSegments,
SEGMENT_MIN_WIDTH_PCT,
TIMELINE_COLORS, TIMELINE_COLORS,
} from "@/components/recap/timelineRender"; } from "@/components/recap/timelineRender";
@@ -106,6 +107,9 @@ type WorkOrderUpload = {
sku?: string; sku?: string;
targetQty?: number; targetQty?: number;
cycleTime?: number; cycleTime?: number;
mold?: string;
cavitiesTotal?: number;
cavitiesActive?: number;
}; };
type WorkOrderRow = Record<string, string | number | boolean>; type WorkOrderRow = Record<string, string | number | boolean>;
@@ -192,8 +196,31 @@ const WORK_ORDER_KEYS = {
"theoretical_cycle_time", "theoretical_cycle_time",
]), ]),
target: new Set(["targetquantity", "targetqty", "target", "target_qty"]), 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) { function normalizeKey(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]/g, ""); 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 targetQty = Number.isFinite(Number(targetRaw)) ? Math.trunc(Number(targetRaw)) : undefined;
const cycleTime = Number.isFinite(Number(cycleRaw)) ? Number(cycleRaw) : 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; return out;
@@ -308,6 +354,14 @@ type MachineActivityTimelineProps = {
t: (key: string, vars?: Record<string, string | number>) => string; 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) { function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) {
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null); const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const [timelineLoading, setTimelineLoading] = useState(true); const [timelineLoading, setTimelineLoading] = useState(true);
@@ -321,11 +375,18 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
async function loadTimeline() { async function loadTimeline() {
try { 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); const json = await res.json().catch(() => null);
if (!alive || !res.ok || !json) return; if (!alive || !res.ok || !json) return;
const nextTimeline = json as RecapTimelineResponse; const nextTimeline = json as RecapTimelineResponse;
const nextHash = JSON.stringify({ const nextHash = JSON.stringify({
start: nextTimeline.range.start,
end: nextTimeline.range.end,
hasData: nextTimeline.hasData, hasData: nextTimeline.hasData,
segments: nextTimeline.segments.map((segment) => ({ segments: nextTimeline.segments.map((segment) => ({
type: segment.type, type: segment.type,
@@ -353,14 +414,18 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
}, [machineId]); }, [machineId]);
const hasData = timeline?.hasData ?? false; const hasData = timeline?.hasData ?? false;
const startMs = timeline ? new Date(timeline.range.start).getTime() : Date.now() - 60 * 60 * 1000; const fallbackRange = getMinuteFlooredOneHourRange();
const endMs = timeline ? new Date(timeline.range.end).getTime() : Date.now(); 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 totalMs = Math.max(1, endMs - startMs);
const normalized = useMemo(() => { const normalized = useMemo(() => {
if (!timeline || !hasData) return [] as RecapTimelineSegment[]; if (!timeline || !hasData) return [] as RecapTimelineSegment[];
return normalizeTimelineSegments(timeline.segments, startMs, endMs); return normalizeTimelineSegments(timeline.segments, startMs, endMs);
}, [timeline, hasData, 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 ( return (
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
@@ -586,6 +651,23 @@ export default function MachineDetailClient() {
return null; 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>) { async function handleWorkOrderUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
@@ -1005,6 +1087,13 @@ export default function MachineDetailClient() {
className="hidden" className="hidden"
onChange={handleWorkOrderUpload} 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 <button
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}

View File

@@ -5,7 +5,7 @@ import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import type { EventRow, Heartbeat, MachineRow } from "./types"; 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 MAX_EVENT_MACHINES = 6;
const OverviewTimeline = lazy(() => import("./OverviewTimeline")); const OverviewTimeline = lazy(() => import("./OverviewTimeline"));

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

View File

@@ -37,6 +37,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null); const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const [timelineLoading, setTimelineLoading] = useState(true);
const [nowMs, setNowMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now());
const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start)); 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 freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null;
const timelineStart = timeline?.range.start ?? initialData.range.start; const timelineStart = timeline?.range.start ?? initialData.range.start;
const timelineEnd = timeline?.range.end ?? initialData.range.end; const timelineEnd = timeline?.range.end ?? initialData.range.end;
const timelineSegments = timeline?.segments ?? machine.timeline; const timelineSegments = timeline?.segments ?? [];
const timelineHasData = timeline?.hasData ?? true; const timelineHasData = timeline?.hasData ?? false;
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setTimeline(null);
setTimelineLoading(true);
async function loadTimeline() { async function loadTimeline() {
try { try {
@@ -95,6 +98,8 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
if (!alive || !res.ok || !json) return; if (!alive || !res.ok || !json) return;
setTimeline(json as RecapTimelineResponse); setTimeline(json as RecapTimelineResponse);
} catch { } catch {
} finally {
if (alive) setTimelineLoading(false);
} }
} }
@@ -212,6 +217,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
rangeEnd={timelineEnd} rangeEnd={timelineEnd}
segments={timelineSegments} segments={timelineSegments}
hasData={timelineHasData} hasData={timelineHasData}
loading={timelineLoading}
locale={locale} locale={locale}
/> />
</div> </div>

View File

@@ -63,7 +63,12 @@ const MAX_EVENTS = 100;
//when no cycle time is configed //when no cycle time is configed
const DEFAULT_MACROSTOP_SEC = 300; 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) { function clampText(value: unknown, maxLen: number) {
if (value === null || value === undefined) return null; if (value === null || value === undefined) return null;
@@ -271,8 +276,10 @@ export async function POST(req: Request) {
const machine = await getMachineAuth(String(machineId), apiKey); const machine = await getMachineAuth(String(machineId), apiKey);
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const bodySeq = parseSeqToBigInt(bodyRecord.seq); const bodySeq = parseSeqToBigInt(bodyRecord.seq);
const bodySchemaVersion = clampText(bodyRecord.schemaVersion, 16); const bodySchemaVersion = clampText(bodyRecord.schemaVersion, 16);
const orgSettings = await prisma.orgSettings.findUnique({ const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: machine.orgId }, where: { orgId: machine.orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true }, select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
@@ -602,6 +609,62 @@ export async function POST(req: Request) {
numberFrom(evDowntime?.acknowledgedAtMs) ?? numberFrom(evDowntime?.acknowledgedAtMs) ??
null; 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({ await prisma.reasonEntry.upsert({
where: { reasonId }, where: { reasonId },
create: { create: {
@@ -612,14 +675,14 @@ export async function POST(req: Request) {
episodeId: incidentKey, episodeId: incidentKey,
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null, durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null, episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
...commonWrite, ...guardedWrite,
}, },
update: { update: {
kind: "downtime", kind: "downtime",
episodeId: incidentKey, episodeId: incidentKey,
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null, durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null, episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
...commonWrite, ...guardedWrite,
}, },
}); });
} else { } else {

View File

@@ -43,6 +43,9 @@ export async function GET(
sku: row.sku, sku: row.sku,
targetQty: row.targetQty, targetQty: row.targetQty,
cycleTime: row.cycleTime, cycleTime: row.cycleTime,
mold: row.mold,
cavitiesTotal: row.cavitiesTotal,
cavitiesActive: row.cavitiesActive,
status: row.status, status: row.status,
})), })),
}); });

View File

@@ -12,8 +12,10 @@ function canManage(role?: string | null) {
const MAX_WORK_ORDERS = 2000; const MAX_WORK_ORDERS = 2000;
const MAX_WORK_ORDER_ID_LENGTH = 64; const MAX_WORK_ORDER_ID_LENGTH = 64;
const MAX_SKU_LENGTH = 64; const MAX_SKU_LENGTH = 64;
const MAX_MOLD_LENGTH = 256;
const MAX_TARGET_QTY = 2_000_000_000; const MAX_TARGET_QTY = 2_000_000_000;
const MAX_CYCLE_TIME = 86_400; const MAX_CYCLE_TIME = 86_400;
const MAX_CAVITIES = 100_000;
const WORK_ORDER_ID_RE = /^[A-Za-z0-9._-]+$/; const WORK_ORDER_ID_RE = /^[A-Za-z0-9._-]+$/;
const uploadBodySchema = z.object({ const uploadBodySchema = z.object({
@@ -51,6 +53,15 @@ type WorkOrderInput = {
sku?: string | null; sku?: string | null;
targetQty?: number | null; targetQty?: number | null;
cycleTime?: 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[]) { function normalizeWorkOrders(raw: unknown[]) {
@@ -78,17 +89,98 @@ function normalizeWorkOrders(raw: unknown[]) {
const cycleTime = const cycleTime =
cycleTimeRaw == null ? null : Math.min(Math.max(cycleTimeRaw, 0), MAX_CYCLE_TIME); 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({ cleaned.push({
workOrderId: idRaw, workOrderId: idRaw,
sku: sku ?? null, sku: sku ?? null,
targetQty: targetQty ?? null, targetQty: targetQty ?? null,
cycleTime: cycleTime ?? null, cycleTime: cycleTime ?? null,
mold: mold ?? null,
cavitiesTotal: cavitiesTotal ?? null,
cavitiesActive: cavitiesActive ?? null,
}); });
} }
return cleaned; 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) { export async function POST(req: NextRequest) {
const session = await requireSession(); const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); 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 }); 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({ const created = await prisma.machineWorkOrder.createMany({
data: cleaned.map((row) => ({ data: cleaned.map((row) => ({
orgId: session.orgId, orgId: session.orgId,
@@ -146,6 +253,9 @@ export async function POST(req: NextRequest) {
sku: row.sku ?? null, sku: row.sku ?? null,
targetQty: row.targetQty ?? null, targetQty: row.targetQty ?? null,
cycleTime: row.cycleTime ?? null, cycleTime: row.cycleTime ?? null,
mold: row.mold ?? null,
cavitiesTotal: row.cavitiesTotal ?? null,
cavitiesActive: row.cavitiesActive ?? null,
status: "PENDING", status: "PENDING",
})), })),
skipDuplicates: true, skipDuplicates: true,

View File

@@ -7,6 +7,7 @@ import {
formatTime, formatTime,
LABEL_MIN_WIDTH_PCT, LABEL_MIN_WIDTH_PCT,
normalizeTimelineSegments, normalizeTimelineSegments,
SEGMENT_MIN_WIDTH_PCT,
TIMELINE_COLORS, TIMELINE_COLORS,
} from "@/components/recap/timelineRender"; } from "@/components/recap/timelineRender";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
@@ -17,28 +18,47 @@ type Props = {
segments: RecapTimelineSegment[]; segments: RecapTimelineSegment[];
locale: string; locale: string;
hasData?: boolean; hasData?: boolean;
loading?: boolean;
}; };
const MIN_SEGMENT_PCT = 1.5; export default function RecapFullTimeline({
rangeStart,
export default function RecapFullTimeline({ rangeStart, rangeEnd, segments, locale, hasData = true }: Props) { rangeEnd,
segments,
locale,
hasData = false,
loading = false,
}: Props) {
const { t } = useI18n(); const { t } = useI18n();
const startMs = new Date(rangeStart).getTime(); const startMs = new Date(rangeStart).getTime();
const endMs = new Date(rangeEnd).getTime(); const endMs = new Date(rangeEnd).getTime();
const totalMs = Math.max(1, endMs - startMs); const totalMs = Math.max(1, endMs - startMs);
const normalized = normalizeTimelineSegments(segments, startMs, endMs); const normalized = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT); const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
return ( return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4"> <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> <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"> <div className="rounded-xl border border-dashed border-white/10 bg-black/20 p-4 text-sm text-zinc-400">
{t("recap.timeline.noData")} {t("recap.timeline.noData")}
</div> </div>
) : null} ) : null}
{hasData ? ( {!loading && hasData ? (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="min-w-[560px]"> <div className="min-w-[560px]">
<div className="flex h-14 w-full overflow-hidden rounded-xl"> <div className="flex h-14 w-full overflow-hidden rounded-xl">

View File

@@ -3,7 +3,6 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types"; import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types";
import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline"; import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline";
@@ -35,7 +34,6 @@ function toInt(value: number | null | undefined) {
export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Props) { export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Props) {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null); 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 zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`; 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 timelineStart = timeline?.range.start ?? rangeStart;
const timelineEnd = timeline?.range.end ?? rangeEnd; const timelineEnd = timeline?.range.end ?? rangeEnd;
const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0; const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0;
const staleHeartbeat =
machine.lastSeenMs == null ? true : nowMs - machine.lastSeenMs > RECAP_HEARTBEAT_STALE_MS;
const lastSeenLabel = const lastSeenLabel =
machine.lastActivityMin == null machine.lastActivityMin == null
@@ -57,11 +53,6 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
const moldMinutes = machine.moldChange?.active ? machine.moldChange.elapsedMin : null; const moldMinutes = machine.moldChange?.active ? machine.moldChange.elapsedMin : null;
useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 60000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -144,11 +135,6 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
{t("recap.banner.offline", { min: toInt(machine.offlineForMin) })} {t("recap.banner.offline", { min: toInt(machine.offlineForMin) })}
</div> </div>
) : null} ) : 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> <div className="mt-3 text-xs text-zinc-400">{footerText}</div>
</Link> </Link>

View File

@@ -10,6 +10,7 @@ export const TIMELINE_COLORS: Record<RecapTimelineSegment["type"], string> = {
}; };
export const LABEL_MIN_WIDTH_PCT = 5; export const LABEL_MIN_WIDTH_PCT = 5;
export const SEGMENT_MIN_WIDTH_PCT = 1.5;
export function formatTime(valueMs: number, locale: string) { export function formatTime(valueMs: number, locale: string) {
return new Date(valueMs).toLocaleTimeString(locale, { return new Date(valueMs).toLocaleTimeString(locale, {

File diff suppressed because one or more lines are too long

4246
flows (64).json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -234,9 +234,10 @@
"machine.detail.error.network": "Network error", "machine.detail.error.network": "Network error",
"machine.detail.back": "Back", "machine.detail.back": "Back",
"machine.detail.workOrders.upload": "Upload Work Orders", "machine.detail.workOrders.upload": "Upload Work Orders",
"machine.detail.workOrders.downloadTemplate": "Download Template",
"machine.detail.workOrders.uploading": "Uploading...", "machine.detail.workOrders.uploading": "Uploading...",
"machine.detail.workOrders.uploadParsing": "Parsing file...", "machine.detail.workOrders.uploadParsing": "Parsing file...",
"machine.detail.workOrders.uploadHint": "CSV or XLSX with Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity.", "machine.detail.workOrders.uploadHint": "CSV or XLSX: Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity, Mold, Total Cavities, Active Cavities (first four columns are enough for legacy files).",
"machine.detail.workOrders.uploadSuccess": "Uploaded {count} work orders", "machine.detail.workOrders.uploadSuccess": "Uploaded {count} work orders",
"machine.detail.workOrders.uploadError": "Upload failed", "machine.detail.workOrders.uploadError": "Upload failed",
"machine.detail.workOrders.uploadInvalid": "No valid work orders found", "machine.detail.workOrders.uploadInvalid": "No valid work orders found",

View File

@@ -234,9 +234,10 @@
"machine.detail.error.network": "Error de red", "machine.detail.error.network": "Error de red",
"machine.detail.back": "Volver", "machine.detail.back": "Volver",
"machine.detail.workOrders.upload": "Subir ordenes de trabajo", "machine.detail.workOrders.upload": "Subir ordenes de trabajo",
"machine.detail.workOrders.downloadTemplate": "Descargar plantilla",
"machine.detail.workOrders.uploading": "Subiendo...", "machine.detail.workOrders.uploading": "Subiendo...",
"machine.detail.workOrders.uploadParsing": "Leyendo archivo...", "machine.detail.workOrders.uploadParsing": "Leyendo archivo...",
"machine.detail.workOrders.uploadHint": "CSV o XLSX con Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity.", "machine.detail.workOrders.uploadHint": "CSV o XLSX: Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity, Molde, Total de cavidades, Cavidades activas (los primeros cuatro campos bastan para archivos antiguos).",
"machine.detail.workOrders.uploadSuccess": "Se cargaron {count} ordenes de trabajo", "machine.detail.workOrders.uploadSuccess": "Se cargaron {count} ordenes de trabajo",
"machine.detail.workOrders.uploadError": "No se pudo cargar", "machine.detail.workOrders.uploadError": "No se pudo cargar",
"machine.detail.workOrders.uploadInvalid": "No se encontraron ordenes de trabajo validas", "machine.detail.workOrders.uploadInvalid": "No se encontraron ordenes de trabajo validas",

View File

@@ -468,7 +468,7 @@ async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
} }
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) { 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 requestedMode = normalizedRangeMode(params.input.mode);
const shiftEnabledCount = await prisma.orgShift.count({ const shiftEnabledCount = await prisma.orgShift.count({
where: { where: {

View File

@@ -55,7 +55,8 @@ function parseMaxSegments(searchParams: URLSearchParams) {
} }
export function parseRecapTimelineRange(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")); const startParam = parseDateInput(searchParams.get("start"));
if (startParam && startParam < end) { if (startParam && startParam < end) {
return { return {

View File

@@ -7,6 +7,8 @@
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint", "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:generate": "prisma generate",
"prisma:migrate:deploy": "prisma migrate deploy" "prisma:migrate:deploy": "prisma migrate deploy"
}, },

View File

@@ -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;

View File

@@ -254,6 +254,9 @@ model MachineWorkOrder {
goodParts Int @default(0) @map("good_parts") goodParts Int @default(0) @map("good_parts")
scrapParts Int @default(0) @map("scrap_parts") scrapParts Int @default(0) @map("scrap_parts")
cycleCount Int @default(0) @map("cycle_count") 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) machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)

View 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();
});

View 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.

View 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();