state definitions
This commit is contained in:
36
MACHINE_STATE_PROGRESS.md
Normal file
36
MACHINE_STATE_PROGRESS.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Machine State Progress
|
||||||
|
|
||||||
|
## Final State Model (5 states + sub-reasons)
|
||||||
|
|
||||||
|
| State | Color | Trigger |
|
||||||
|
|---|---|---|
|
||||||
|
| OFFLINE | dark gray | Heartbeat dead >2 min |
|
||||||
|
| STOPPED | red, pulse >5min | Active WO + no cycles (regardless of tracking) |
|
||||||
|
| - reason `machine_fault` | | Tracking on, macrostop event active |
|
||||||
|
| - reason `not_started` | | Tracking off, has WO |
|
||||||
|
| DATA_LOSS | red + icon, pulse | Tracking off + cycles arriving (>5 cycles or >10 min) |
|
||||||
|
| MOLD_CHANGE | blue | Active mold-change event |
|
||||||
|
| - sub at >3h | yellow accent | (Round 2) |
|
||||||
|
| - sub at >5h | red accent | (Round 2) |
|
||||||
|
| IDLE | calm gray | No tracking, no WO, no cycles |
|
||||||
|
| RUNNING | green | Tracking + WO + recent cycles |
|
||||||
|
|
||||||
|
## Round 1 — Foundation: classifier + IDLE + STOPPED collapse + DATA_LOSS
|
||||||
|
- [x] Step 1: Add `"idle"` and `"data-loss"` to `RecapMachineStatus` union
|
||||||
|
- [x] Step 2: Create `lib/recap/machineState.ts` shared classifier with all reasons
|
||||||
|
- [x] Step 3: Refactor `statusFromMachine` in redesign.ts to call classifier
|
||||||
|
- [x] Step 4: Plumb new fields (status reason, ongoing min) through types/responses
|
||||||
|
- [x] Step 5: UI rendering: IDLE (calm gray) on /recap, /machines, detail
|
||||||
|
- [x] Step 6: UI rendering: DATA_LOSS (red + icon) on all surfaces
|
||||||
|
- [x] Step 7: STOPPED reason text: show `not_started` vs `machine_fault` distinction
|
||||||
|
- [x] Step 8: i18n keys (en + es-MX)
|
||||||
|
- [x] Step 9: End-to-end verify each state transitions correctly
|
||||||
|
|
||||||
|
## Round 2 — Mold change duration escalation (CT-only)
|
||||||
|
- [ ] MOLD_CHANGE >3h yellow accent
|
||||||
|
- [ ] MOLD_CHANGE >5h red accent
|
||||||
|
- [ ] i18n strings
|
||||||
|
|
||||||
|
## Notes / parked items
|
||||||
|
- Prisma drift on (orgId,machineId,seq) unique indexes — pre-existing, not related to this work. Address as separate housekeeping task.
|
||||||
|
- Node-RED incidentKey rotation behavior verified: 10 distinct keys per real stoppage = correct.
|
||||||
@@ -13,6 +13,8 @@ function statusLabel(status: RecapMachineStatus, t: (key: string) => string) {
|
|||||||
if (status === "running") return t("recap.status.running");
|
if (status === "running") return t("recap.status.running");
|
||||||
if (status === "mold-change") return t("recap.status.moldChange");
|
if (status === "mold-change") return t("recap.status.moldChange");
|
||||||
if (status === "stopped") return t("recap.status.stopped");
|
if (status === "stopped") return t("recap.status.stopped");
|
||||||
|
if (status === "data-loss") return t("recap.status.dataLoss");
|
||||||
|
if (status === "idle") return t("recap.status.idle");
|
||||||
return t("recap.status.offline");
|
return t("recap.status.offline");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +112,7 @@ export default function RecapGridClient({ initialData }: Props) {
|
|||||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||||
>
|
>
|
||||||
<option value="all">{t("recap.filter.allStatuses")}</option>
|
<option value="all">{t("recap.filter.allStatuses")}</option>
|
||||||
{(["running", "mold-change", "stopped", "offline"] as const).map((status) => (
|
{(["running", "mold-change", "stopped", "data-loss", "idle", "offline"] as const).map((status) => (
|
||||||
<option key={status} value={status}>
|
<option key={status} value={status}>
|
||||||
{statusLabel(status, t)}
|
{statusLabel(status, t)}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
153
app/(app)/recap/RecapGridClient.tsx.bak.step5
Normal file
153
app/(app)/recap/RecapGridClient.tsx.bak.step5
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import type { RecapMachineStatus, RecapSummaryResponse } from "@/lib/recap/types";
|
||||||
|
import RecapMachineCard from "@/components/recap/RecapMachineCard";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
initialData: RecapSummaryResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusLabel(status: RecapMachineStatus, t: (key: string) => string) {
|
||||||
|
if (status === "running") return t("recap.status.running");
|
||||||
|
if (status === "mold-change") return t("recap.status.moldChange");
|
||||||
|
if (status === "stopped") return t("recap.status.stopped");
|
||||||
|
return t("recap.status.offline");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecapGridClient({ initialData }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const [data, setData] = useState<RecapSummaryResponse>(initialData);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [locationFilter, setLocationFilter] = useState("all");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"all" | RecapMachineStatus>("all");
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/recap/summary?hours=${data.range.hours}`, { cache: "no-store" });
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
if (!alive || !json || !res.ok) return;
|
||||||
|
setData(json as RecapSummaryResponse);
|
||||||
|
} finally {
|
||||||
|
if (alive) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFocus = () => {
|
||||||
|
void refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = window.setInterval(onFocus, 60000);
|
||||||
|
window.addEventListener("focus", onFocus);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
window.clearInterval(interval);
|
||||||
|
window.removeEventListener("focus", onFocus);
|
||||||
|
};
|
||||||
|
}, [data.range.hours]);
|
||||||
|
|
||||||
|
const locationOptions = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const machine of data.machines) {
|
||||||
|
if (machine.location) set.add(machine.location);
|
||||||
|
}
|
||||||
|
return [...set].sort((a, b) => a.localeCompare(b));
|
||||||
|
}, [data.machines]);
|
||||||
|
|
||||||
|
const filteredMachines = useMemo(() => {
|
||||||
|
return data.machines.filter((machine) => {
|
||||||
|
if (locationFilter !== "all" && machine.location !== locationFilter) return false;
|
||||||
|
if (statusFilter !== "all" && machine.status !== statusFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [data.machines, locationFilter, statusFilter]);
|
||||||
|
|
||||||
|
const generatedAtMs = new Date(data.generatedAt).getTime();
|
||||||
|
const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-white">{t("recap.grid.title")}</h1>
|
||||||
|
<p className="text-sm text-zinc-400">{t("recap.grid.subtitle")}</p>
|
||||||
|
{freshAgeSec != null ? (
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">{t("recap.grid.updatedAgo", { sec: freshAgeSec })}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 text-sm">
|
||||||
|
<select
|
||||||
|
value={locationFilter}
|
||||||
|
onChange={(event) => setLocationFilter(event.target.value)}
|
||||||
|
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||||
|
>
|
||||||
|
<option value="all">{t("recap.filter.allLocations")}</option>
|
||||||
|
{locationOptions.map((location) => (
|
||||||
|
<option key={location} value={location}>
|
||||||
|
{location}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(event) => setStatusFilter(event.target.value as "all" | RecapMachineStatus)}
|
||||||
|
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||||
|
>
|
||||||
|
<option value="all">{t("recap.filter.allStatuses")}</option>
|
||||||
|
{(["running", "mold-change", "stopped", "offline"] as const).map((status) => (
|
||||||
|
<option key={status} value={status}>
|
||||||
|
{statusLabel(status, t)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && data.machines.length === 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, idx) => (
|
||||||
|
<div key={idx} className="h-[220px] animate-pulse rounded-2xl border border-white/10 bg-white/5" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading && data.machines.length > 0 ? (
|
||||||
|
<div className="mb-3 text-xs text-zinc-500">{t("common.loading")}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{filteredMachines.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/30 p-4 text-sm text-zinc-400">
|
||||||
|
{t("recap.grid.empty")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{filteredMachines.map((machine) => (
|
||||||
|
<RecapMachineCard
|
||||||
|
key={machine.machineId}
|
||||||
|
machine={machine}
|
||||||
|
rangeStart={data.range.start}
|
||||||
|
rangeEnd={data.range.end}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -90,18 +90,19 @@ export default function RecapFullTimeline({
|
|||||||
locale
|
locale
|
||||||
)} · ${formatDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
|
)} · ${formatDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
|
||||||
|
|
||||||
|
const showLabel = widthPct > LABEL_MIN_WIDTH_PCT;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
|
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
|
||||||
className={`flex h-full shrink-0 items-center justify-center truncate px-2 text-xs font-semibold ${
|
className={`flex h-full items-center justify-center overflow-hidden text-xs font-semibold ${
|
||||||
TIMELINE_COLORS[segment.type]
|
showLabel ? "truncate px-2" : ""
|
||||||
} ${index === 0 ? "rounded-l-xl" : ""} ${
|
} ${TIMELINE_COLORS[segment.type]} ${
|
||||||
index === normalized.length - 1 ? "rounded-r-xl" : ""
|
index === 0 ? "rounded-l-xl" : ""
|
||||||
}`}
|
} ${index === normalized.length - 1 ? "rounded-r-xl" : ""}`}
|
||||||
style={{ width: `${Math.max(0, widthPct)}%` }}
|
style={{ width: `${Math.max(0, widthPct)}%` }}
|
||||||
title={title}
|
title={title}
|
||||||
>
|
>
|
||||||
{widthPct > LABEL_MIN_WIDTH_PCT ? segment.label : ""}
|
{showLabel ? segment.label : ""}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -16,13 +16,17 @@ const STATUS_DOT: Record<RecapSummaryMachine["status"], string> = {
|
|||||||
running: "bg-emerald-400",
|
running: "bg-emerald-400",
|
||||||
"mold-change": "bg-amber-400",
|
"mold-change": "bg-amber-400",
|
||||||
stopped: "bg-red-500",
|
stopped: "bg-red-500",
|
||||||
|
"data-loss": "bg-red-500",
|
||||||
offline: "bg-zinc-500",
|
offline: "bg-zinc-500",
|
||||||
|
idle: "bg-zinc-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
function statusLabel(status: RecapSummaryMachine["status"], t: (key: string) => string) {
|
function statusLabel(status: RecapSummaryMachine["status"], t: (key: string) => string) {
|
||||||
if (status === "running") return t("recap.status.running");
|
if (status === "running") return t("recap.status.running");
|
||||||
if (status === "mold-change") return t("recap.status.moldChange");
|
if (status === "mold-change") return t("recap.status.moldChange");
|
||||||
if (status === "stopped") return t("recap.status.stopped");
|
if (status === "stopped") return t("recap.status.stopped");
|
||||||
|
if (status === "data-loss") return t("recap.status.dataLoss");
|
||||||
|
if (status === "idle") return t("recap.status.idle");
|
||||||
return t("recap.status.offline");
|
return t("recap.status.offline");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +42,8 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
|
|||||||
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)}%`;
|
||||||
const ongoingStopMin = machine.ongoingStopMin ?? 0;
|
const ongoingStopMin = machine.ongoingStopMin ?? 0;
|
||||||
const isUrgent = machine.status === "stopped" && ongoingStopMin >= 5;
|
const isUrgent = (machine.status === "stopped" && ongoingStopMin >= 5) || machine.status === "data-loss";
|
||||||
|
const isCalm = machine.status === "idle";
|
||||||
const timelineSegments = timeline?.segments ?? machine.miniTimeline;
|
const timelineSegments = timeline?.segments ?? machine.miniTimeline;
|
||||||
const timelineStart = timeline?.range.start ?? rangeStart;
|
const timelineStart = timeline?.range.start ?? rangeStart;
|
||||||
const timelineEnd = timeline?.range.end ?? rangeEnd;
|
const timelineEnd = timeline?.range.end ?? rangeEnd;
|
||||||
@@ -88,6 +93,8 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
|
|||||||
className={`rounded-2xl border p-4 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80 ${
|
className={`rounded-2xl border p-4 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80 ${
|
||||||
isUrgent
|
isUrgent
|
||||||
? "border-red-500/60 bg-red-500/10 hover:bg-red-500/15 ring-2 ring-red-500/40 animate-pulse"
|
? "border-red-500/60 bg-red-500/10 hover:bg-red-500/15 ring-2 ring-red-500/40 animate-pulse"
|
||||||
|
: isCalm
|
||||||
|
? "border-white/5 bg-white/[0.02] hover:bg-white/[0.04] opacity-70"
|
||||||
: "border-white/10 bg-white/5 hover:bg-white/10"
|
: "border-white/10 bg-white/5 hover:bg-white/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -142,10 +149,17 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className={`mt-3 text-xs ${isUrgent ? "text-red-200 font-semibold" : "text-zinc-400"}`}>
|
<div className={`mt-3 text-xs ${isUrgent ? "text-red-200 font-semibold" : isCalm ? "text-zinc-500" : "text-zinc-400"}`}>
|
||||||
{isUrgent
|
{machine.status === "data-loss"
|
||||||
? t("recap.card.stoppedFor", { min: ongoingStopMin })
|
? t("recap.card.dataLoss", { count: machine.stateContext.untrackedCycleCount ?? 0 })
|
||||||
+ (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "")
|
+ (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "")
|
||||||
|
: machine.status === "stopped" && ongoingStopMin >= 5
|
||||||
|
? (machine.stateContext.stoppedReason === "not_started"
|
||||||
|
? t("recap.card.notStarted")
|
||||||
|
: t("recap.card.stoppedFor", { min: ongoingStopMin }))
|
||||||
|
+ (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "")
|
||||||
|
: machine.status === "idle"
|
||||||
|
? t("recap.card.idle")
|
||||||
: footerText}
|
: footerText}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
153
components/recap/RecapMachineCard.tsx.bak.step5
Normal file
153
components/recap/RecapMachineCard.tsx.bak.step5
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types";
|
||||||
|
import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
machine: RecapSummaryMachine;
|
||||||
|
rangeStart: string;
|
||||||
|
rangeEnd: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_DOT: Record<RecapSummaryMachine["status"], string> = {
|
||||||
|
running: "bg-emerald-400",
|
||||||
|
"mold-change": "bg-amber-400",
|
||||||
|
stopped: "bg-red-500",
|
||||||
|
offline: "bg-zinc-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusLabel(status: RecapSummaryMachine["status"], t: (key: string) => string) {
|
||||||
|
if (status === "running") return t("recap.status.running");
|
||||||
|
if (status === "mold-change") return t("recap.status.moldChange");
|
||||||
|
if (status === "stopped") return t("recap.status.stopped");
|
||||||
|
return t("recap.status.offline");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInt(value: number | null | undefined) {
|
||||||
|
if (value == null || Number.isNaN(value)) return 0;
|
||||||
|
return Math.max(0, Math.round(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Props) {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||||
|
|
||||||
|
const zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
|
||||||
|
const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`;
|
||||||
|
const ongoingStopMin = machine.ongoingStopMin ?? 0;
|
||||||
|
const isUrgent = machine.status === "stopped" && ongoingStopMin >= 5;
|
||||||
|
const timelineSegments = timeline?.segments ?? machine.miniTimeline;
|
||||||
|
const timelineStart = timeline?.range.start ?? rangeStart;
|
||||||
|
const timelineEnd = timeline?.range.end ?? rangeEnd;
|
||||||
|
const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0;
|
||||||
|
|
||||||
|
const lastSeenLabel =
|
||||||
|
machine.lastActivityMin == null
|
||||||
|
? t("common.never")
|
||||||
|
: t("recap.card.lastActivity", { min: toInt(machine.lastActivityMin) });
|
||||||
|
|
||||||
|
const footerText = machine.activeWorkOrderId
|
||||||
|
? t("recap.card.activeWorkOrder", { id: machine.activeWorkOrderId })
|
||||||
|
: lastSeenLabel;
|
||||||
|
|
||||||
|
const moldMinutes = machine.moldChange?.active ? machine.moldChange.elapsedMin : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
async function loadTimeline() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=60`,
|
||||||
|
{ cache: "no-store" }
|
||||||
|
);
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
if (!alive || !res.ok || !json) return;
|
||||||
|
setTimeline(json as RecapTimelineResponse);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadTimeline();
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
void loadTimeline();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [machine.machineId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/recap/${machine.machineId}`}
|
||||||
|
className={`rounded-2xl border p-4 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80 ${
|
||||||
|
isUrgent
|
||||||
|
? "border-red-500/60 bg-red-500/10 hover:bg-red-500/15 ring-2 ring-red-500/40 animate-pulse"
|
||||||
|
: "border-white/10 bg-white/5 hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-lg font-semibold text-white">{machine.name}</div>
|
||||||
|
<div className="mt-1 truncate text-xs text-zinc-400">{machine.location || t("common.na")}</div>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 px-2 py-1 text-xs text-zinc-200">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2.5 w-2.5 rounded-full ${STATUS_DOT[machine.status]}`}
|
||||||
|
aria-label={statusLabel(machine.status, t)}
|
||||||
|
/>
|
||||||
|
{statusLabel(machine.status, t)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-baseline gap-2">
|
||||||
|
<div className={`text-3xl font-semibold ${machine.oee == null ? "text-zinc-400" : "text-white"}`}>{primaryMetric}</div>
|
||||||
|
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.card.oee")}</div>
|
||||||
|
</div>
|
||||||
|
{machine.oee == null ? <div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div> : null}
|
||||||
|
|
||||||
|
{zeroActivity ? <div className="mt-1 text-xs text-zinc-500">{t("recap.card.noProduction")}</div> : null}
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-300">
|
||||||
|
<span>{t("recap.card.good")}: {machine.goodParts}</span>
|
||||||
|
<span>{t("recap.card.scrap")}: {machine.scrap}</span>
|
||||||
|
<span>{t("recap.card.stops")}: {machine.stopsCount}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<RecapMiniTimeline
|
||||||
|
rangeStart={timelineStart}
|
||||||
|
rangeEnd={timelineEnd}
|
||||||
|
segments={timelineSegments}
|
||||||
|
locale={locale}
|
||||||
|
hasData={hasTimelineData}
|
||||||
|
muted={zeroActivity}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{machine.moldChange?.active ? (
|
||||||
|
<div className="mt-3 rounded-lg border border-amber-400/40 bg-amber-400/10 px-2 py-1.5 text-xs text-amber-200">
|
||||||
|
{t("recap.card.moldChangeActive", { min: toInt(moldMinutes) })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{machine.offlineForMin != null && machine.offlineForMin > 10 ? (
|
||||||
|
<div className="mt-2 rounded-lg border border-red-500/40 bg-red-500/10 px-2 py-1.5 text-xs text-red-200">
|
||||||
|
{t("recap.banner.offline", { min: toInt(machine.offlineForMin) })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={`mt-3 text-xs ${isUrgent ? "text-red-200 font-semibold" : "text-zinc-400"}`}>
|
||||||
|
{isUrgent
|
||||||
|
? t("recap.card.stoppedFor", { min: ongoingStopMin })
|
||||||
|
+ (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "")
|
||||||
|
: footerText}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -115,6 +115,11 @@
|
|||||||
"machines.status.stopped": "STOPPED",
|
"machines.status.stopped": "STOPPED",
|
||||||
"machines.stoppedFor": "Stopped for {min} min",
|
"machines.stoppedFor": "Stopped for {min} min",
|
||||||
"recap.grid.title": "Machine recap",
|
"recap.grid.title": "Machine recap",
|
||||||
|
"recap.status.dataLoss": "Data Loss",
|
||||||
|
"recap.status.idle": "Idle",
|
||||||
|
"recap.card.dataLoss": "{count} untracked cycles — press START",
|
||||||
|
"recap.card.notStarted": "Operator hasn't pressed START",
|
||||||
|
"recap.card.idle": "No active work order",
|
||||||
"recap.grid.subtitle": "Last 24h · click to open details",
|
"recap.grid.subtitle": "Last 24h · click to open details",
|
||||||
"recap.grid.updatedAgo": "Updated {sec}s ago",
|
"recap.grid.updatedAgo": "Updated {sec}s ago",
|
||||||
"recap.grid.empty": "No machines match the current filters.",
|
"recap.grid.empty": "No machines match the current filters.",
|
||||||
|
|||||||
@@ -121,6 +121,11 @@
|
|||||||
"recap.card.stoppedFor": "Detenida hace {min} min",
|
"recap.card.stoppedFor": "Detenida hace {min} min",
|
||||||
"machines.status.stopped": "DETENIDA",
|
"machines.status.stopped": "DETENIDA",
|
||||||
"machines.stoppedFor": "Detenida hace {min} min",
|
"machines.stoppedFor": "Detenida hace {min} min",
|
||||||
|
"recap.status.dataLoss": "Sin tracking",
|
||||||
|
"recap.status.idle": "Inactiva",
|
||||||
|
"recap.card.dataLoss": "{count} ciclos sin tracking — presione INICIAR",
|
||||||
|
"recap.card.notStarted": "Operador no ha presionado INICIAR",
|
||||||
|
"recap.card.idle": "Sin orden de trabajo activa",
|
||||||
"recap.grid.title": "Resumen de máquinas",
|
"recap.grid.title": "Resumen de máquinas",
|
||||||
"recap.grid.subtitle": "Últimas 24h · click para ver detalle",
|
"recap.grid.subtitle": "Últimas 24h · click para ver detalle",
|
||||||
"recap.grid.updatedAgo": "Actualizado hace {sec}s",
|
"recap.grid.updatedAgo": "Actualizado hace {sec}s",
|
||||||
|
|||||||
174
lib/recap/machineState.ts
Normal file
174
lib/recap/machineState.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import type { TimelineEventRow } from "@/lib/recap/timeline";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared classifier for machine state across /recap, /machines, /overview.
|
||||||
|
*
|
||||||
|
* State precedence (top wins):
|
||||||
|
* 1. OFFLINE — heartbeat dead
|
||||||
|
* 2. MOLD_CHANGE — operator initiated mold swap
|
||||||
|
* 3. STOPPED — should be producing, isn't
|
||||||
|
* 4. DATA_LOSS — producing but tracking off (operator forgot START)
|
||||||
|
* 5. IDLE — nothing loaded, nothing running, nothing expected
|
||||||
|
* 6. RUNNING — healthy
|
||||||
|
*
|
||||||
|
* Inputs are intentionally raw and computed by the caller, not fetched here,
|
||||||
|
* so this module stays pure (testable, no DB/Prisma dependency).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MachineStateName =
|
||||||
|
| "offline"
|
||||||
|
| "mold-change"
|
||||||
|
| "stopped"
|
||||||
|
| "data-loss"
|
||||||
|
| "idle"
|
||||||
|
| "running";
|
||||||
|
|
||||||
|
export type StoppedReason = "machine_fault" | "not_started";
|
||||||
|
export type DataLossReason = "untracked";
|
||||||
|
|
||||||
|
export type MachineStateResult =
|
||||||
|
| { state: "offline"; lastSeenMs: number | null; offlineForMin: number }
|
||||||
|
| {
|
||||||
|
state: "mold-change";
|
||||||
|
moldChangeStartMs: number | null;
|
||||||
|
moldChangeMin: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
state: "stopped";
|
||||||
|
reason: StoppedReason;
|
||||||
|
ongoingStopMin: number;
|
||||||
|
stopStartedAtMs: number | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
state: "data-loss";
|
||||||
|
reason: DataLossReason;
|
||||||
|
untrackedCycleCount: number;
|
||||||
|
untrackedSinceMs: number | null;
|
||||||
|
untrackedForMin: number;
|
||||||
|
}
|
||||||
|
| { state: "idle" }
|
||||||
|
| { state: "running" };
|
||||||
|
|
||||||
|
export type MachineStateInputs = {
|
||||||
|
/** Heartbeat freshness — true if the Pi has been seen within the offline threshold */
|
||||||
|
heartbeatAlive: boolean;
|
||||||
|
/** Last heartbeat timestamp in ms (or null if never seen) */
|
||||||
|
lastSeenMs: number | null;
|
||||||
|
/** Computed offline duration in ms — used when heartbeatAlive is false */
|
||||||
|
offlineForMs: number;
|
||||||
|
|
||||||
|
/** Operator pressed START — true if latest KPI snapshot has trackingEnabled=true */
|
||||||
|
trackingEnabled: boolean;
|
||||||
|
|
||||||
|
/** A work order with status RUNNING or PENDING is currently assigned */
|
||||||
|
hasActiveWorkOrder: boolean;
|
||||||
|
|
||||||
|
/** Active mold-change event (from timeline events) */
|
||||||
|
activeMoldChange: { startedAtMs: number } | null;
|
||||||
|
|
||||||
|
/** Active macrostop event (from timeline events) — fires when tracking on + no cycles */
|
||||||
|
activeMacrostop: { startedAtMs: number } | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Untracked cycles arriving while tracking is OFF.
|
||||||
|
* Caller computes by counting MachineCycle rows in the last UNTRACKED_WINDOW_MS
|
||||||
|
* where ts > latestKpi.ts (so they're "after" the tracking-off snapshot).
|
||||||
|
*/
|
||||||
|
untrackedCycles: { count: number; oldestTsMs: number | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most recent cycle timestamp regardless of tracking — used as a sanity check
|
||||||
|
* for IDLE classification.
|
||||||
|
*/
|
||||||
|
lastCycleTsMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger thresholds — tunable
|
||||||
|
const DATA_LOSS_MIN_CYCLES = 5;
|
||||||
|
const DATA_LOSS_MIN_DURATION_MS = 10 * 60 * 1000; // 10 min
|
||||||
|
const RECENT_CYCLE_MS = 15 * 60 * 1000; // for IDLE check — "no cycles in 15 min"
|
||||||
|
|
||||||
|
export function classifyMachineState(
|
||||||
|
inputs: MachineStateInputs,
|
||||||
|
nowMs: number
|
||||||
|
): MachineStateResult {
|
||||||
|
// 1. OFFLINE — wins over everything. If we can't see the Pi, nothing else is reliable.
|
||||||
|
if (!inputs.heartbeatAlive) {
|
||||||
|
return {
|
||||||
|
state: "offline",
|
||||||
|
lastSeenMs: inputs.lastSeenMs,
|
||||||
|
offlineForMin: Math.max(0, Math.floor(inputs.offlineForMs / 60000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. MOLD_CHANGE — operator-initiated, suppresses STOPPED/ATTENTION even if cycles missing
|
||||||
|
if (inputs.activeMoldChange) {
|
||||||
|
return {
|
||||||
|
state: "mold-change",
|
||||||
|
moldChangeStartMs: inputs.activeMoldChange.startedAtMs,
|
||||||
|
moldChangeMin: Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((nowMs - inputs.activeMoldChange.startedAtMs) / 60000)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. DATA_LOSS — tracking off but cycles arriving. Operator forgot START.
|
||||||
|
// Check this BEFORE STOPPED because cycles ARE arriving (so the "no cycles" branch
|
||||||
|
// would never fire), but we still want to flag it.
|
||||||
|
if (!inputs.trackingEnabled && inputs.untrackedCycles.count > 0) {
|
||||||
|
const oldest = inputs.untrackedCycles.oldestTsMs;
|
||||||
|
const durationMs = oldest != null ? nowMs - oldest : 0;
|
||||||
|
const tripped =
|
||||||
|
inputs.untrackedCycles.count >= DATA_LOSS_MIN_CYCLES ||
|
||||||
|
durationMs >= DATA_LOSS_MIN_DURATION_MS;
|
||||||
|
|
||||||
|
if (tripped) {
|
||||||
|
return {
|
||||||
|
state: "data-loss",
|
||||||
|
reason: "untracked",
|
||||||
|
untrackedCycleCount: inputs.untrackedCycles.count,
|
||||||
|
untrackedSinceMs: oldest,
|
||||||
|
untrackedForMin: Math.max(0, Math.floor(durationMs / 60000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Not yet tripped — fall through to other checks (likely RUNNING since cycles are coming)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. STOPPED — should be producing, isn't. Two reasons:
|
||||||
|
// a) machine_fault: operator pressed START, macrostop event active → mechanical issue
|
||||||
|
// b) not_started: operator never pressed START but a WO is loaded
|
||||||
|
if (inputs.activeMacrostop && inputs.trackingEnabled) {
|
||||||
|
const startedAt = inputs.activeMacrostop.startedAtMs;
|
||||||
|
return {
|
||||||
|
state: "stopped",
|
||||||
|
reason: "machine_fault",
|
||||||
|
ongoingStopMin: Math.max(0, Math.floor((nowMs - startedAt) / 60000)),
|
||||||
|
stopStartedAtMs: startedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputs.hasActiveWorkOrder && !inputs.trackingEnabled) {
|
||||||
|
// Operator hasn't started production despite a loaded WO.
|
||||||
|
// We don't have a precise "since when" for this — best estimate is "since latest
|
||||||
|
// KPI snapshot reported trackingEnabled=false," but that's not in the inputs.
|
||||||
|
// For now, report ongoingStopMin=0 and let the caller refine if needed.
|
||||||
|
return {
|
||||||
|
state: "stopped",
|
||||||
|
reason: "not_started",
|
||||||
|
ongoingStopMin: 0,
|
||||||
|
stopStartedAtMs: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. IDLE — no one expects this machine to be doing anything right now.
|
||||||
|
// No tracking, no WO, no recent cycles. Calm gray.
|
||||||
|
const cycledRecently =
|
||||||
|
inputs.lastCycleTsMs != null && nowMs - inputs.lastCycleTsMs <= RECENT_CYCLE_MS;
|
||||||
|
if (!inputs.trackingEnabled && !inputs.hasActiveWorkOrder && !cycledRecently) {
|
||||||
|
return { state: "idle" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. RUNNING — default. Tracking on, WO loaded, cycles flowing.
|
||||||
|
return { state: "running" };
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type TimelineCycleRow,
|
type TimelineCycleRow,
|
||||||
type TimelineEventRow,
|
type TimelineEventRow,
|
||||||
} from "@/lib/recap/timeline";
|
} from "@/lib/recap/timeline";
|
||||||
|
import { classifyMachineState, type MachineStateResult } from "@/lib/recap/machineState";
|
||||||
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
import type {
|
import type {
|
||||||
RecapDetailResponse,
|
RecapDetailResponse,
|
||||||
@@ -16,6 +17,7 @@ import type {
|
|||||||
RecapMachineDetail,
|
RecapMachineDetail,
|
||||||
RecapMachineStatus,
|
RecapMachineStatus,
|
||||||
RecapRangeMode,
|
RecapRangeMode,
|
||||||
|
RecapStateContext,
|
||||||
RecapSummaryMachine,
|
RecapSummaryMachine,
|
||||||
RecapSummaryResponse,
|
RecapSummaryResponse,
|
||||||
} from "@/lib/recap/types";
|
} from "@/lib/recap/types";
|
||||||
@@ -175,22 +177,26 @@ function addDays(input: { year: number; month: number; day: number }, days: numb
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active stoppage = freshest macrostop episode whose latest event is "active"
|
// Detect active episodes (macrostop, mold-change) from event rows.
|
||||||
// and whose latest event timestamp is within ACTIVE_STALE_MS of rangeEnd.
|
// Returns the latest non-auto-ack episode whose final status is "active"
|
||||||
// Mirrors the same rules used by lib/recap/timeline.ts so the card status
|
// and that's been refreshed within ACTIVE_STALE_MS.
|
||||||
// agrees with the timeline rendering.
|
const ACTIVE_STALE_MS = 2 * 60 * 1000;
|
||||||
const STOPPAGE_ACTIVE_STALE_MS = 2 * 60 * 1000;
|
|
||||||
|
|
||||||
function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: number) {
|
type ActiveEpisode = { startedAtMs: number; lastTsMs: number };
|
||||||
|
|
||||||
|
function detectActiveEpisode(
|
||||||
|
events: TimelineEventRow[] | undefined,
|
||||||
|
eventType: "macrostop" | "mold-change",
|
||||||
|
endMs: number
|
||||||
|
): ActiveEpisode | null {
|
||||||
if (!events || events.length === 0) return null;
|
if (!events || events.length === 0) return null;
|
||||||
|
|
||||||
type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string };
|
type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string; lastCycleTs: number | null };
|
||||||
const episodes = new Map<string, Episode>();
|
const episodes = new Map<string, Episode>();
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (String(event.eventType || "").toLowerCase() !== "macrostop") continue;
|
if (String(event.eventType || "").toLowerCase() !== eventType) continue;
|
||||||
|
|
||||||
// Defensive: parse data the same way timeline.ts does.
|
|
||||||
let parsed: unknown = event.data;
|
let parsed: unknown = event.data;
|
||||||
if (typeof parsed === "string") {
|
if (typeof parsed === "string") {
|
||||||
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
|
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
|
||||||
@@ -200,7 +206,6 @@ function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: nu
|
|||||||
? (parsed as Record<string, unknown>)
|
? (parsed as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
// Drop only the auto-ack pings (same rule as timeline.ts Fix B).
|
|
||||||
const isAutoAck =
|
const isAutoAck =
|
||||||
data.is_auto_ack === true ||
|
data.is_auto_ack === true ||
|
||||||
data.isAutoAck === true ||
|
data.isAutoAck === true ||
|
||||||
@@ -210,12 +215,18 @@ function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: nu
|
|||||||
|
|
||||||
const status = String(data.status ?? "").trim().toLowerCase();
|
const status = String(data.status ?? "").trim().toLowerCase();
|
||||||
const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
|
const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
|
||||||
|| `macrostop:${event.ts.getTime()}`;
|
|| `${eventType}:${event.ts.getTime()}`;
|
||||||
const tsMs = event.ts.getTime();
|
const tsMs = event.ts.getTime();
|
||||||
|
const lastCycleTs = Number(data.last_cycle_timestamp);
|
||||||
|
|
||||||
const existing = episodes.get(incidentKey);
|
const existing = episodes.get(incidentKey);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
episodes.set(incidentKey, { firstTsMs: tsMs, lastTsMs: tsMs, lastStatus: status });
|
episodes.set(incidentKey, {
|
||||||
|
firstTsMs: tsMs,
|
||||||
|
lastTsMs: tsMs,
|
||||||
|
lastStatus: status,
|
||||||
|
lastCycleTs: Number.isFinite(lastCycleTs) && lastCycleTs > 0 ? lastCycleTs : null,
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
|
existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
|
||||||
@@ -225,39 +236,108 @@ function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: nu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let activeOngoingMin = 0;
|
let best: ActiveEpisode | null = null;
|
||||||
for (const ep of episodes.values()) {
|
for (const ep of episodes.values()) {
|
||||||
if (ep.lastStatus !== "active") continue;
|
if (ep.lastStatus !== "active") continue;
|
||||||
if (endMs - ep.lastTsMs > STOPPAGE_ACTIVE_STALE_MS) continue;
|
if (endMs - ep.lastTsMs > ACTIVE_STALE_MS) continue;
|
||||||
const ongoingMin = Math.max(0, Math.floor((endMs - ep.firstTsMs) / 60000));
|
// Prefer the freshest active episode (highest lastTsMs)
|
||||||
if (ongoingMin > activeOngoingMin) activeOngoingMin = ongoingMin;
|
if (!best || ep.lastTsMs > best.lastTsMs) {
|
||||||
|
best = {
|
||||||
|
startedAtMs: ep.lastCycleTs ?? ep.firstTsMs,
|
||||||
|
lastTsMs: ep.lastTsMs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return activeOngoingMin > 0 ? activeOngoingMin : null;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusFromMachine(machine: RecapMachine, endMs: number, events?: TimelineEventRow[]) {
|
function statusFromMachine(
|
||||||
|
machine: RecapMachine,
|
||||||
|
endMs: number,
|
||||||
|
events?: TimelineEventRow[]
|
||||||
|
): {
|
||||||
|
status: RecapMachineStatus;
|
||||||
|
result: MachineStateResult;
|
||||||
|
stateContext: RecapStateContext;
|
||||||
|
lastSeenMs: number | null;
|
||||||
|
offlineForMin: number | null;
|
||||||
|
ongoingStopMin: number | null;
|
||||||
|
} {
|
||||||
const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
|
const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
|
||||||
const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
|
const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
|
||||||
const offline = !Number.isFinite(lastSeenMs ?? Number.NaN) || offlineForMs > OFFLINE_THRESHOLD_MS;
|
const heartbeatAlive = Number.isFinite(lastSeenMs ?? Number.NaN) && offlineForMs <= OFFLINE_THRESHOLD_MS;
|
||||||
|
|
||||||
// ongoingStopMin from the legacy heartbeat-based path (typically null) OR
|
const activeMacrostop = detectActiveEpisode(events, "macrostop", endMs);
|
||||||
// from the macrostop event detection (preferred — accurate)
|
const activeMoldChange = detectActiveEpisode(events, "mold-change", endMs);
|
||||||
const macrostopOngoingMin = detectActiveMacrostop(events, endMs);
|
|
||||||
const legacyOngoingStopMin = machine.downtime.ongoingStopMin ?? 0;
|
|
||||||
const ongoingStopMin = macrostopOngoingMin ?? (legacyOngoingStopMin > 0 ? legacyOngoingStopMin : null);
|
|
||||||
|
|
||||||
const moldActive = machine.workOrders.moldChangeInProgress;
|
// Round 1 limitation: trackingEnabled and untrackedCycles inputs require KPI/cycle queries
|
||||||
|
// we don't yet plumb here. We approximate from the legacy fields:
|
||||||
|
// - trackingEnabled: true when there's an active macrostop (Pi only fires those when tracking on)
|
||||||
|
// OR when an active WO exists and machine.workOrders.moldChangeInProgress is false.
|
||||||
|
// This is a SIMPLIFICATION; Round 3 will replace with real KPI snapshot read.
|
||||||
|
// - untrackedCycles: 0 (Round 3 will compute from MachineCycle vs latest KPI)
|
||||||
|
//
|
||||||
|
// Effect for Round 1: STOPPED `not_started` reason cannot trigger yet (we always assume tracking
|
||||||
|
// is on when a WO exists). Only `machine_fault` STOPPED fires. DATA_LOSS cannot fire yet.
|
||||||
|
// IDLE fires correctly when there's no WO and no recent activity.
|
||||||
|
const hasActiveWorkOrder = machine.workOrders.active != null;
|
||||||
|
const trackingEnabledApprox = hasActiveWorkOrder; // see comment above
|
||||||
|
|
||||||
let status: RecapMachineStatus = "running";
|
const lastCycleTsMs = (() => {
|
||||||
if (offline) status = "offline";
|
// Best-effort: use the machine's heartbeat as a "recent activity" proxy.
|
||||||
else if (moldActive) status = "mold-change";
|
// The Pi only heartbeats every minute regardless of cycles, so this is a weak signal.
|
||||||
else if (ongoingStopMin != null && ongoingStopMin > 0) status = "stopped";
|
// Round 3 will pass the actual latest cycle ts.
|
||||||
|
return lastSeenMs;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const result = classifyMachineState(
|
||||||
|
{
|
||||||
|
heartbeatAlive,
|
||||||
|
lastSeenMs,
|
||||||
|
offlineForMs,
|
||||||
|
trackingEnabled: trackingEnabledApprox,
|
||||||
|
hasActiveWorkOrder,
|
||||||
|
activeMoldChange,
|
||||||
|
activeMacrostop,
|
||||||
|
untrackedCycles: { count: 0, oldestTsMs: null },
|
||||||
|
lastCycleTsMs,
|
||||||
|
},
|
||||||
|
endMs
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map the rich classifier result back to the existing RecapMachineStatus union
|
||||||
|
const status: RecapMachineStatus = result.state;
|
||||||
|
|
||||||
|
// Pull common fields out for the caller's convenience
|
||||||
|
let ongoingStopMin: number | null = null;
|
||||||
|
if (result.state === "stopped") ongoingStopMin = result.ongoingStopMin;
|
||||||
|
|
||||||
|
let stateContext: RecapStateContext = {
|
||||||
|
stoppedReason: null,
|
||||||
|
dataLossReason: null,
|
||||||
|
untrackedCycleCount: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.state === "stopped") {
|
||||||
|
stateContext = {
|
||||||
|
stoppedReason: result.reason,
|
||||||
|
dataLossReason: null,
|
||||||
|
untrackedCycleCount: null,
|
||||||
|
};
|
||||||
|
} else if (result.state === "data-loss") {
|
||||||
|
stateContext = {
|
||||||
|
stoppedReason: null,
|
||||||
|
dataLossReason: result.reason,
|
||||||
|
untrackedCycleCount: result.untrackedCycleCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
|
result,
|
||||||
|
stateContext,
|
||||||
lastSeenMs,
|
lastSeenMs,
|
||||||
offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null,
|
offlineForMin: result.state === "offline" ? result.offlineForMin : null,
|
||||||
ongoingStopMin,
|
ongoingStopMin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -366,6 +446,7 @@ function toSummaryMachine(params: {
|
|||||||
status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
|
status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
|
||||||
offlineForMin: status.offlineForMin,
|
offlineForMin: status.offlineForMin,
|
||||||
ongoingStopMin: status.ongoingStopMin,
|
ongoingStopMin: status.ongoingStopMin,
|
||||||
|
stateContext: status.stateContext,
|
||||||
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
||||||
moldChange: {
|
moldChange: {
|
||||||
active: machine.workOrders.moldChangeInProgress,
|
active: machine.workOrders.moldChangeInProgress,
|
||||||
@@ -704,6 +785,8 @@ async function computeRecapMachineDetail(params: {
|
|||||||
lastSeenMs: status.lastSeenMs,
|
lastSeenMs: status.lastSeenMs,
|
||||||
offlineForMin: status.offlineForMin,
|
offlineForMin: status.offlineForMin,
|
||||||
ongoingStopMin: status.ongoingStopMin,
|
ongoingStopMin: status.ongoingStopMin,
|
||||||
|
stateContext: status.stateContext,
|
||||||
|
|
||||||
moldChange: {
|
moldChange: {
|
||||||
active: machine.workOrders.moldChangeInProgress,
|
active: machine.workOrders.moldChangeInProgress,
|
||||||
startMs: machine.workOrders.moldChangeStartMs,
|
startMs: machine.workOrders.moldChangeStartMs,
|
||||||
|
|||||||
848
lib/recap/redesign.ts.bak.step3
Normal file
848
lib/recap/redesign.ts.bak.step3
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
import { unstable_cache } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
|
||||||
|
import { getRecapDataCached } from "@/lib/recap/getRecapData";
|
||||||
|
import {
|
||||||
|
buildTimelineSegments,
|
||||||
|
compressTimelineSegments,
|
||||||
|
TIMELINE_EVENT_TYPES,
|
||||||
|
type TimelineCycleRow,
|
||||||
|
type TimelineEventRow,
|
||||||
|
} from "@/lib/recap/timeline";
|
||||||
|
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
|
import type {
|
||||||
|
RecapDetailResponse,
|
||||||
|
RecapMachine,
|
||||||
|
RecapMachineDetail,
|
||||||
|
RecapMachineStatus,
|
||||||
|
RecapRangeMode,
|
||||||
|
RecapSummaryMachine,
|
||||||
|
RecapSummaryResponse,
|
||||||
|
} from "@/lib/recap/types";
|
||||||
|
|
||||||
|
type DetailRangeInput = {
|
||||||
|
mode?: string | null;
|
||||||
|
start?: string | null;
|
||||||
|
end?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||||
|
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
|
||||||
|
const RECAP_CACHE_TTL_SEC = 60;
|
||||||
|
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||||
|
const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
|
||||||
|
Mon: "mon",
|
||||||
|
Tue: "tue",
|
||||||
|
Wed: "wed",
|
||||||
|
Thu: "thu",
|
||||||
|
Fri: "fri",
|
||||||
|
Sat: "sat",
|
||||||
|
Sun: "sun",
|
||||||
|
};
|
||||||
|
|
||||||
|
function round2(value: number) {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(input?: string | null) {
|
||||||
|
if (!input) return null;
|
||||||
|
const n = Number(input);
|
||||||
|
if (Number.isFinite(n)) {
|
||||||
|
const d = new Date(n);
|
||||||
|
return Number.isFinite(d.getTime()) ? d : null;
|
||||||
|
}
|
||||||
|
const d = new Date(input);
|
||||||
|
return Number.isFinite(d.getTime()) ? d : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHours(input: string | null) {
|
||||||
|
const parsed = Math.trunc(Number(input ?? "24"));
|
||||||
|
if (!Number.isFinite(parsed)) return 24;
|
||||||
|
return Math.max(1, Math.min(72, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeMinutes(input?: string | null) {
|
||||||
|
if (!input) return null;
|
||||||
|
const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
const hours = Number(match[1]);
|
||||||
|
const minutes = Number(match[2]);
|
||||||
|
if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalParts(ts: Date, timeZone: string) {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
weekday: "short",
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(ts);
|
||||||
|
|
||||||
|
const value = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
|
||||||
|
const year = Number(value("year"));
|
||||||
|
const month = Number(value("month"));
|
||||||
|
const day = Number(value("day"));
|
||||||
|
const hour = Number(value("hour"));
|
||||||
|
const minute = Number(value("minute"));
|
||||||
|
const weekday = value("weekday");
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()],
|
||||||
|
minutesOfDay: hour * 60 + minute,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
year: ts.getUTCFullYear(),
|
||||||
|
month: ts.getUTCMonth() + 1,
|
||||||
|
day: ts.getUTCDate(),
|
||||||
|
hour: ts.getUTCHours(),
|
||||||
|
minute: ts.getUTCMinutes(),
|
||||||
|
weekday: WEEKDAY_KEYS[ts.getUTCDay()],
|
||||||
|
minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOffsetMinutes(offsetLabel: string | null) {
|
||||||
|
if (!offsetLabel) return null;
|
||||||
|
const normalized = offsetLabel.replace("UTC", "GMT");
|
||||||
|
const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized);
|
||||||
|
if (!match) return null;
|
||||||
|
const sign = match[1] === "-" ? -1 : 1;
|
||||||
|
const hour = Number(match[2]);
|
||||||
|
const minute = Number(match[3] ?? "0");
|
||||||
|
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
||||||
|
return sign * (hour * 60 + minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTzOffsetMinutes(utcDate: Date, timeZone: string) {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
timeZoneName: "shortOffset",
|
||||||
|
hour: "2-digit",
|
||||||
|
}).formatToParts(utcDate);
|
||||||
|
const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null;
|
||||||
|
return parseOffsetMinutes(offsetPart);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zonedToUtcDate(input: {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
timeZone: string;
|
||||||
|
}) {
|
||||||
|
const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0);
|
||||||
|
const guessDate = new Date(baseUtc);
|
||||||
|
const offsetA = getTzOffsetMinutes(guessDate, input.timeZone);
|
||||||
|
if (offsetA == null) return guessDate;
|
||||||
|
|
||||||
|
let corrected = new Date(baseUtc - offsetA * 60000);
|
||||||
|
const offsetB = getTzOffsetMinutes(corrected, input.timeZone);
|
||||||
|
if (offsetB != null && offsetB !== offsetA) {
|
||||||
|
corrected = new Date(baseUtc - offsetB * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return corrected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(input: { year: number; month: number; day: number }, days: number) {
|
||||||
|
const base = new Date(Date.UTC(input.year, input.month - 1, input.day));
|
||||||
|
base.setUTCDate(base.getUTCDate() + days);
|
||||||
|
return {
|
||||||
|
year: base.getUTCFullYear(),
|
||||||
|
month: base.getUTCMonth() + 1,
|
||||||
|
day: base.getUTCDate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active stoppage = freshest macrostop episode whose latest event is "active"
|
||||||
|
// and whose latest event timestamp is within ACTIVE_STALE_MS of rangeEnd.
|
||||||
|
// Mirrors the same rules used by lib/recap/timeline.ts so the card status
|
||||||
|
// agrees with the timeline rendering.
|
||||||
|
const STOPPAGE_ACTIVE_STALE_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: number) {
|
||||||
|
if (!events || events.length === 0) return null;
|
||||||
|
|
||||||
|
type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string };
|
||||||
|
const episodes = new Map<string, Episode>();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (String(event.eventType || "").toLowerCase() !== "macrostop") continue;
|
||||||
|
|
||||||
|
// Defensive: parse data the same way timeline.ts does.
|
||||||
|
let parsed: unknown = event.data;
|
||||||
|
if (typeof parsed === "string") {
|
||||||
|
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
|
||||||
|
}
|
||||||
|
const data: Record<string, unknown> =
|
||||||
|
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// Drop only the auto-ack pings (same rule as timeline.ts Fix B).
|
||||||
|
const isAutoAck =
|
||||||
|
data.is_auto_ack === true ||
|
||||||
|
data.isAutoAck === true ||
|
||||||
|
data.is_auto_ack === "true" ||
|
||||||
|
data.isAutoAck === "true";
|
||||||
|
if (isAutoAck) continue;
|
||||||
|
|
||||||
|
const status = String(data.status ?? "").trim().toLowerCase();
|
||||||
|
const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
|
||||||
|
|| `macrostop:${event.ts.getTime()}`;
|
||||||
|
const tsMs = event.ts.getTime();
|
||||||
|
|
||||||
|
const existing = episodes.get(incidentKey);
|
||||||
|
if (!existing) {
|
||||||
|
episodes.set(incidentKey, { firstTsMs: tsMs, lastTsMs: tsMs, lastStatus: status });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
|
||||||
|
if (tsMs >= existing.lastTsMs) {
|
||||||
|
existing.lastTsMs = tsMs;
|
||||||
|
existing.lastStatus = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeOngoingMin = 0;
|
||||||
|
for (const ep of episodes.values()) {
|
||||||
|
if (ep.lastStatus !== "active") continue;
|
||||||
|
if (endMs - ep.lastTsMs > STOPPAGE_ACTIVE_STALE_MS) continue;
|
||||||
|
const ongoingMin = Math.max(0, Math.floor((endMs - ep.firstTsMs) / 60000));
|
||||||
|
if (ongoingMin > activeOngoingMin) activeOngoingMin = ongoingMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeOngoingMin > 0 ? activeOngoingMin : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFromMachine(machine: RecapMachine, endMs: number, events?: TimelineEventRow[]) {
|
||||||
|
const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
|
||||||
|
const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
|
||||||
|
const offline = !Number.isFinite(lastSeenMs ?? Number.NaN) || offlineForMs > OFFLINE_THRESHOLD_MS;
|
||||||
|
|
||||||
|
// ongoingStopMin from the legacy heartbeat-based path (typically null) OR
|
||||||
|
// from the macrostop event detection (preferred — accurate)
|
||||||
|
const macrostopOngoingMin = detectActiveMacrostop(events, endMs);
|
||||||
|
const legacyOngoingStopMin = machine.downtime.ongoingStopMin ?? 0;
|
||||||
|
const ongoingStopMin = macrostopOngoingMin ?? (legacyOngoingStopMin > 0 ? legacyOngoingStopMin : null);
|
||||||
|
|
||||||
|
const moldActive = machine.workOrders.moldChangeInProgress;
|
||||||
|
|
||||||
|
let status: RecapMachineStatus = "running";
|
||||||
|
if (offline) status = "offline";
|
||||||
|
else if (moldActive) status = "mold-change";
|
||||||
|
else if (ongoingStopMin != null && ongoingStopMin > 0) status = "stopped";
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
lastSeenMs,
|
||||||
|
offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null,
|
||||||
|
ongoingStopMin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTimelineRowsForMachines(params: {
|
||||||
|
orgId: string;
|
||||||
|
machineIds: string[];
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
}) {
|
||||||
|
if (!params.machineIds.length) {
|
||||||
|
return {
|
||||||
|
cyclesByMachine: new Map<string, TimelineCycleRow[]>(),
|
||||||
|
eventsByMachine: new Map<string, TimelineEventRow[]>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [cycles, events] = await Promise.all([
|
||||||
|
prisma.machineCycle.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: { in: params.machineIds },
|
||||||
|
ts: {
|
||||||
|
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||||
|
lte: params.end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
ts: true,
|
||||||
|
cycleCount: true,
|
||||||
|
actualCycleTime: true,
|
||||||
|
workOrderId: true,
|
||||||
|
sku: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.machineEvent.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: { in: params.machineIds },
|
||||||
|
eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
|
||||||
|
ts: {
|
||||||
|
gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
|
||||||
|
lte: params.end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
ts: true,
|
||||||
|
eventType: true,
|
||||||
|
data: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cyclesByMachine = new Map<string, TimelineCycleRow[]>();
|
||||||
|
const eventsByMachine = new Map<string, TimelineEventRow[]>();
|
||||||
|
|
||||||
|
for (const row of cycles) {
|
||||||
|
const list = cyclesByMachine.get(row.machineId) ?? [];
|
||||||
|
list.push({
|
||||||
|
ts: row.ts,
|
||||||
|
cycleCount: row.cycleCount,
|
||||||
|
actualCycleTime: row.actualCycleTime,
|
||||||
|
workOrderId: row.workOrderId,
|
||||||
|
sku: row.sku,
|
||||||
|
});
|
||||||
|
cyclesByMachine.set(row.machineId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of events) {
|
||||||
|
const list = eventsByMachine.get(row.machineId) ?? [];
|
||||||
|
list.push({
|
||||||
|
ts: row.ts,
|
||||||
|
eventType: row.eventType,
|
||||||
|
data: row.data,
|
||||||
|
});
|
||||||
|
eventsByMachine.set(row.machineId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cyclesByMachine, eventsByMachine };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSummaryMachine(params: {
|
||||||
|
machine: RecapMachine;
|
||||||
|
miniTimeline: ReturnType<typeof compressTimelineSegments>;
|
||||||
|
rangeEndMs: number;
|
||||||
|
events?: TimelineEventRow[];
|
||||||
|
}): RecapSummaryMachine {
|
||||||
|
const { machine, miniTimeline, rangeEndMs, events } = params;
|
||||||
|
const status = statusFromMachine(machine, rangeEndMs, events);
|
||||||
|
|
||||||
|
return {
|
||||||
|
machineId: machine.machineId,
|
||||||
|
name: machine.machineName,
|
||||||
|
location: machine.location,
|
||||||
|
status: status.status,
|
||||||
|
oee: machine.oee.avg,
|
||||||
|
goodParts: machine.production.goodParts,
|
||||||
|
scrap: machine.production.scrapParts,
|
||||||
|
stopsCount: machine.downtime.stopsCount,
|
||||||
|
lastSeenMs: status.lastSeenMs,
|
||||||
|
lastActivityMin:
|
||||||
|
status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
|
||||||
|
offlineForMin: status.offlineForMin,
|
||||||
|
ongoingStopMin: status.ongoingStopMin,
|
||||||
|
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
||||||
|
moldChange: {
|
||||||
|
active: machine.workOrders.moldChangeInProgress,
|
||||||
|
startMs: machine.workOrders.moldChangeStartMs,
|
||||||
|
elapsedMin:
|
||||||
|
machine.workOrders.moldChangeStartMs == null
|
||||||
|
? null
|
||||||
|
: Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)),
|
||||||
|
},
|
||||||
|
miniTimeline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeRecapSummary(params: { orgId: string; hours: number }) {
|
||||||
|
const now = new Date();
|
||||||
|
const end = new Date(Math.floor(now.getTime() / 60000) * 60000);
|
||||||
|
const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const recap = await getRecapDataCached({
|
||||||
|
orgId: params.orgId,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
|
||||||
|
const machineIds = recap.machines.map((machine) => machine.machineId);
|
||||||
|
const timelineRows = await loadTimelineRowsForMachines({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineIds,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
|
||||||
|
const machines = recap.machines.map((machine) => {
|
||||||
|
const segments = buildTimelineSegments({
|
||||||
|
cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [],
|
||||||
|
events: timelineRows.eventsByMachine.get(machine.machineId) ?? [],
|
||||||
|
rangeStart: start,
|
||||||
|
rangeEnd: end,
|
||||||
|
});
|
||||||
|
const miniTimeline = compressTimelineSegments({
|
||||||
|
segments,
|
||||||
|
rangeStart: start,
|
||||||
|
rangeEnd: end,
|
||||||
|
maxSegments: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toSummaryMachine({
|
||||||
|
machine,
|
||||||
|
miniTimeline,
|
||||||
|
rangeEndMs: end.getTime(),
|
||||||
|
events: timelineRows.eventsByMachine.get(machine.machineId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: RecapSummaryResponse = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
range: {
|
||||||
|
start: start.toISOString(),
|
||||||
|
end: end.toISOString(),
|
||||||
|
hours: params.hours,
|
||||||
|
},
|
||||||
|
machines,
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedRangeMode(mode?: string | null): RecapRangeMode {
|
||||||
|
const raw = String(mode ?? "").trim().toLowerCase();
|
||||||
|
if (raw === "shift") return "shift";
|
||||||
|
if (raw === "yesterday") return "yesterday";
|
||||||
|
if (raw === "custom") return "custom";
|
||||||
|
return "24h";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
|
||||||
|
const settings = await prisma.orgSettings.findUnique({
|
||||||
|
where: { orgId: params.orgId },
|
||||||
|
select: {
|
||||||
|
timezone: true,
|
||||||
|
shiftScheduleOverridesJson: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const shifts = await prisma.orgShift.findMany({
|
||||||
|
where: { orgId: params.orgId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
enabled: true,
|
||||||
|
sortOrder: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabledShifts = shifts.filter((shift) => shift.enabled !== false);
|
||||||
|
if (!enabledShifts.length) {
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: false,
|
||||||
|
range: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeZone = settings?.timezone || "UTC";
|
||||||
|
const local = getLocalParts(params.now, timeZone);
|
||||||
|
const overrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
|
||||||
|
const dayOverrides = overrides?.[local.weekday];
|
||||||
|
const activeShifts = (dayOverrides?.length
|
||||||
|
? dayOverrides.map((shift) => ({
|
||||||
|
enabled: shift.enabled !== false,
|
||||||
|
start: shift.start,
|
||||||
|
end: shift.end,
|
||||||
|
}))
|
||||||
|
: enabledShifts.map((shift) => ({
|
||||||
|
enabled: shift.enabled !== false,
|
||||||
|
start: shift.startTime,
|
||||||
|
end: shift.endTime,
|
||||||
|
}))
|
||||||
|
).filter((shift) => shift.enabled);
|
||||||
|
|
||||||
|
for (const shift of activeShifts) {
|
||||||
|
const startMin = parseTimeMinutes(shift.start ?? null);
|
||||||
|
const endMin = parseTimeMinutes(shift.end ?? null);
|
||||||
|
if (startMin == null || endMin == null) continue;
|
||||||
|
|
||||||
|
const minutesNow = local.minutesOfDay;
|
||||||
|
let inRange = false;
|
||||||
|
let startDate = { year: local.year, month: local.month, day: local.day };
|
||||||
|
let endDate = { year: local.year, month: local.month, day: local.day };
|
||||||
|
|
||||||
|
if (startMin <= endMin) {
|
||||||
|
inRange = minutesNow >= startMin && minutesNow < endMin;
|
||||||
|
} else {
|
||||||
|
inRange = minutesNow >= startMin || minutesNow < endMin;
|
||||||
|
if (minutesNow >= startMin) {
|
||||||
|
endDate = addDays(endDate, 1);
|
||||||
|
} else {
|
||||||
|
startDate = addDays(startDate, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inRange) continue;
|
||||||
|
|
||||||
|
const start = zonedToUtcDate({
|
||||||
|
...startDate,
|
||||||
|
hours: Math.floor(startMin / 60),
|
||||||
|
minutes: startMin % 60,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
const shiftEndUtc = zonedToUtcDate({
|
||||||
|
...endDate,
|
||||||
|
hours: Math.floor(endMin / 60),
|
||||||
|
minutes: endMin % 60,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shiftEndUtc <= start) continue;
|
||||||
|
|
||||||
|
// Cap end at "now" so we render shift-so-far, not shift-as-planned.
|
||||||
|
// Without cap:
|
||||||
|
// - timeline fills future minutes with idle (visual lie)
|
||||||
|
// - offline calc = (shift_end_future - last_seen) = looks 5h offline
|
||||||
|
// even on a machine producing right now
|
||||||
|
const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: true,
|
||||||
|
range: { start, end },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: true,
|
||||||
|
range: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
|
||||||
|
const now = new Date(Math.floor(Date.now() / 60000) * 60000);
|
||||||
|
const requestedMode = normalizedRangeMode(params.input.mode);
|
||||||
|
const shiftEnabledCount = await prisma.orgShift.count({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
enabled: { not: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const shiftAvailable = shiftEnabledCount > 0;
|
||||||
|
|
||||||
|
if (requestedMode === "custom") {
|
||||||
|
const start = parseDate(params.input.start);
|
||||||
|
const end = parseDate(params.input.end);
|
||||||
|
if (start && end && end > start) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedMode === "yesterday") {
|
||||||
|
const settings = await prisma.orgSettings.findUnique({
|
||||||
|
where: { orgId: params.orgId },
|
||||||
|
select: { timezone: true },
|
||||||
|
});
|
||||||
|
const timeZone = settings?.timezone || "America/Mexico_City";
|
||||||
|
const localNow = getLocalParts(now, timeZone);
|
||||||
|
const today = { year: localNow.year, month: localNow.month, day: localNow.day };
|
||||||
|
const yesterday = addDays(today, -1);
|
||||||
|
const start = zonedToUtcDate({
|
||||||
|
...yesterday,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
const end = zonedToUtcDate({
|
||||||
|
...today,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedMode === "shift") {
|
||||||
|
const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
|
||||||
|
if (shiftRange.range) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start: shiftRange.range.start,
|
||||||
|
end: shiftRange.range.end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
if (!shiftRange.hasEnabledShifts) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
fallbackReason: "shift-unavailable" as const,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
fallbackReason: "shift-inactive" as const,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeRecapMachineDetail(params: {
|
||||||
|
orgId: string;
|
||||||
|
machineId: string;
|
||||||
|
range: {
|
||||||
|
requestedMode: RecapRangeMode;
|
||||||
|
mode: RecapRangeMode;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
shiftAvailable: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const { range } = params;
|
||||||
|
|
||||||
|
const recap = await getRecapDataCached({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: params.machineId,
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
});
|
||||||
|
|
||||||
|
const machine = recap.machines.find((row) => row.machineId === params.machineId) ?? null;
|
||||||
|
if (!machine) return null;
|
||||||
|
|
||||||
|
const timelineRows = await loadTimelineRowsForMachines({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineIds: [params.machineId],
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeline = buildTimelineSegments({
|
||||||
|
cycles: timelineRows.cyclesByMachine.get(params.machineId) ?? [],
|
||||||
|
events: timelineRows.eventsByMachine.get(params.machineId) ?? [],
|
||||||
|
rangeStart: range.start,
|
||||||
|
rangeEnd: range.end,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = statusFromMachine(
|
||||||
|
machine,
|
||||||
|
range.end.getTime(),
|
||||||
|
timelineRows.eventsByMachine.get(params.machineId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const downtimeTotalMin = Math.max(0, machine.downtime.totalMin);
|
||||||
|
const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({
|
||||||
|
reasonLabel: row.reasonLabel,
|
||||||
|
minutes: row.minutes,
|
||||||
|
count: row.count,
|
||||||
|
percent: downtimeTotalMin > 0 ? round2((row.minutes / downtimeTotalMin) * 100) : 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const machineDetail: RecapMachineDetail = {
|
||||||
|
machineId: machine.machineId,
|
||||||
|
name: machine.machineName,
|
||||||
|
location: machine.location,
|
||||||
|
status: status.status,
|
||||||
|
oee: machine.oee.avg,
|
||||||
|
goodParts: machine.production.goodParts,
|
||||||
|
scrap: machine.production.scrapParts,
|
||||||
|
stopsCount: machine.downtime.stopsCount,
|
||||||
|
stopMinutes: downtimeTotalMin,
|
||||||
|
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
||||||
|
lastSeenMs: status.lastSeenMs,
|
||||||
|
offlineForMin: status.offlineForMin,
|
||||||
|
ongoingStopMin: status.ongoingStopMin,
|
||||||
|
moldChange: {
|
||||||
|
active: machine.workOrders.moldChangeInProgress,
|
||||||
|
startMs: machine.workOrders.moldChangeStartMs,
|
||||||
|
},
|
||||||
|
timeline,
|
||||||
|
productionBySku: machine.production.bySku,
|
||||||
|
downtimeTop,
|
||||||
|
workOrders: {
|
||||||
|
completed: machine.workOrders.completed,
|
||||||
|
active: machine.workOrders.active,
|
||||||
|
},
|
||||||
|
heartbeat: {
|
||||||
|
lastSeenAt: machine.heartbeat.lastSeenAt,
|
||||||
|
uptimePct: machine.heartbeat.uptimePct,
|
||||||
|
connectionStatus: status.status === "offline" ? "offline" : "online",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response: RecapDetailResponse = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
range: {
|
||||||
|
requestedMode: range.requestedMode,
|
||||||
|
mode: range.mode,
|
||||||
|
start: range.start.toISOString(),
|
||||||
|
end: range.end.toISOString(),
|
||||||
|
shiftAvailable: range.shiftAvailable,
|
||||||
|
fallbackReason: range.fallbackReason,
|
||||||
|
},
|
||||||
|
machine: machineDetail,
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryCacheKey(params: { orgId: string; hours: number }) {
|
||||||
|
return ["recap-summary-v1", params.orgId, String(params.hours)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailCacheKey(params: {
|
||||||
|
orgId: string;
|
||||||
|
machineId: string;
|
||||||
|
requestedMode: RecapRangeMode;
|
||||||
|
mode: RecapRangeMode;
|
||||||
|
shiftAvailable: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
"recap-detail-v1",
|
||||||
|
params.orgId,
|
||||||
|
params.machineId,
|
||||||
|
params.requestedMode,
|
||||||
|
params.mode,
|
||||||
|
params.shiftAvailable ? "shift-on" : "shift-off",
|
||||||
|
params.fallbackReason ?? "",
|
||||||
|
String(Math.trunc(params.startMs / 60000)),
|
||||||
|
String(Math.trunc(params.endMs / 60000)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRecapSummaryHours(raw: string | null) {
|
||||||
|
return parseHours(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record<string, string | string[] | undefined>) {
|
||||||
|
if (searchParams instanceof URLSearchParams) {
|
||||||
|
return {
|
||||||
|
mode: searchParams.get("range") ?? undefined,
|
||||||
|
start: searchParams.get("start") ?? undefined,
|
||||||
|
end: searchParams.get("end") ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pick = (key: string) => {
|
||||||
|
const value = searchParams[key];
|
||||||
|
if (Array.isArray(value)) return value[0] ?? undefined;
|
||||||
|
return value ?? undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: pick("range"),
|
||||||
|
start: pick("start"),
|
||||||
|
end: pick("end"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecapSummaryCached(params: { orgId: string; hours: number }) {
|
||||||
|
const cache = unstable_cache(
|
||||||
|
() => computeRecapSummary(params),
|
||||||
|
summaryCacheKey(params),
|
||||||
|
{
|
||||||
|
revalidate: RECAP_CACHE_TTL_SEC,
|
||||||
|
tags: [`recap:${params.orgId}`],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecapMachineDetailCached(params: {
|
||||||
|
orgId: string;
|
||||||
|
machineId: string;
|
||||||
|
input: DetailRangeInput;
|
||||||
|
}) {
|
||||||
|
const resolved = await resolveDetailRange({
|
||||||
|
orgId: params.orgId,
|
||||||
|
input: params.input,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cache = unstable_cache(
|
||||||
|
() =>
|
||||||
|
computeRecapMachineDetail({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: params.machineId,
|
||||||
|
range: {
|
||||||
|
requestedMode: resolved.requestedMode,
|
||||||
|
mode: resolved.mode,
|
||||||
|
start: resolved.start,
|
||||||
|
end: resolved.end,
|
||||||
|
shiftAvailable: resolved.shiftAvailable,
|
||||||
|
fallbackReason: resolved.fallbackReason,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
detailCacheKey({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: params.machineId,
|
||||||
|
requestedMode: resolved.requestedMode,
|
||||||
|
mode: resolved.mode,
|
||||||
|
shiftAvailable: resolved.shiftAvailable,
|
||||||
|
fallbackReason: resolved.fallbackReason,
|
||||||
|
startMs: resolved.start.getTime(),
|
||||||
|
endMs: resolved.end.getTime(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
revalidate: RECAP_CACHE_TTL_SEC,
|
||||||
|
tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return cache();
|
||||||
|
}
|
||||||
905
lib/recap/redesign.ts.bak.step4
Normal file
905
lib/recap/redesign.ts.bak.step4
Normal file
@@ -0,0 +1,905 @@
|
|||||||
|
import { unstable_cache } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
|
||||||
|
import { getRecapDataCached } from "@/lib/recap/getRecapData";
|
||||||
|
import {
|
||||||
|
buildTimelineSegments,
|
||||||
|
compressTimelineSegments,
|
||||||
|
TIMELINE_EVENT_TYPES,
|
||||||
|
type TimelineCycleRow,
|
||||||
|
type TimelineEventRow,
|
||||||
|
} from "@/lib/recap/timeline";
|
||||||
|
import { classifyMachineState, type MachineStateResult } from "@/lib/recap/machineState";
|
||||||
|
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
|
import type {
|
||||||
|
RecapDetailResponse,
|
||||||
|
RecapMachine,
|
||||||
|
RecapMachineDetail,
|
||||||
|
RecapMachineStatus,
|
||||||
|
RecapRangeMode,
|
||||||
|
RecapSummaryMachine,
|
||||||
|
RecapSummaryResponse,
|
||||||
|
} from "@/lib/recap/types";
|
||||||
|
|
||||||
|
type DetailRangeInput = {
|
||||||
|
mode?: string | null;
|
||||||
|
start?: string | null;
|
||||||
|
end?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||||
|
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
|
||||||
|
const RECAP_CACHE_TTL_SEC = 60;
|
||||||
|
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||||
|
const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
|
||||||
|
Mon: "mon",
|
||||||
|
Tue: "tue",
|
||||||
|
Wed: "wed",
|
||||||
|
Thu: "thu",
|
||||||
|
Fri: "fri",
|
||||||
|
Sat: "sat",
|
||||||
|
Sun: "sun",
|
||||||
|
};
|
||||||
|
|
||||||
|
function round2(value: number) {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(input?: string | null) {
|
||||||
|
if (!input) return null;
|
||||||
|
const n = Number(input);
|
||||||
|
if (Number.isFinite(n)) {
|
||||||
|
const d = new Date(n);
|
||||||
|
return Number.isFinite(d.getTime()) ? d : null;
|
||||||
|
}
|
||||||
|
const d = new Date(input);
|
||||||
|
return Number.isFinite(d.getTime()) ? d : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHours(input: string | null) {
|
||||||
|
const parsed = Math.trunc(Number(input ?? "24"));
|
||||||
|
if (!Number.isFinite(parsed)) return 24;
|
||||||
|
return Math.max(1, Math.min(72, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeMinutes(input?: string | null) {
|
||||||
|
if (!input) return null;
|
||||||
|
const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
const hours = Number(match[1]);
|
||||||
|
const minutes = Number(match[2]);
|
||||||
|
if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalParts(ts: Date, timeZone: string) {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
weekday: "short",
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(ts);
|
||||||
|
|
||||||
|
const value = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
|
||||||
|
const year = Number(value("year"));
|
||||||
|
const month = Number(value("month"));
|
||||||
|
const day = Number(value("day"));
|
||||||
|
const hour = Number(value("hour"));
|
||||||
|
const minute = Number(value("minute"));
|
||||||
|
const weekday = value("weekday");
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()],
|
||||||
|
minutesOfDay: hour * 60 + minute,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
year: ts.getUTCFullYear(),
|
||||||
|
month: ts.getUTCMonth() + 1,
|
||||||
|
day: ts.getUTCDate(),
|
||||||
|
hour: ts.getUTCHours(),
|
||||||
|
minute: ts.getUTCMinutes(),
|
||||||
|
weekday: WEEKDAY_KEYS[ts.getUTCDay()],
|
||||||
|
minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOffsetMinutes(offsetLabel: string | null) {
|
||||||
|
if (!offsetLabel) return null;
|
||||||
|
const normalized = offsetLabel.replace("UTC", "GMT");
|
||||||
|
const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized);
|
||||||
|
if (!match) return null;
|
||||||
|
const sign = match[1] === "-" ? -1 : 1;
|
||||||
|
const hour = Number(match[2]);
|
||||||
|
const minute = Number(match[3] ?? "0");
|
||||||
|
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
||||||
|
return sign * (hour * 60 + minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTzOffsetMinutes(utcDate: Date, timeZone: string) {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
timeZoneName: "shortOffset",
|
||||||
|
hour: "2-digit",
|
||||||
|
}).formatToParts(utcDate);
|
||||||
|
const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null;
|
||||||
|
return parseOffsetMinutes(offsetPart);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zonedToUtcDate(input: {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
timeZone: string;
|
||||||
|
}) {
|
||||||
|
const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0);
|
||||||
|
const guessDate = new Date(baseUtc);
|
||||||
|
const offsetA = getTzOffsetMinutes(guessDate, input.timeZone);
|
||||||
|
if (offsetA == null) return guessDate;
|
||||||
|
|
||||||
|
let corrected = new Date(baseUtc - offsetA * 60000);
|
||||||
|
const offsetB = getTzOffsetMinutes(corrected, input.timeZone);
|
||||||
|
if (offsetB != null && offsetB !== offsetA) {
|
||||||
|
corrected = new Date(baseUtc - offsetB * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return corrected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(input: { year: number; month: number; day: number }, days: number) {
|
||||||
|
const base = new Date(Date.UTC(input.year, input.month - 1, input.day));
|
||||||
|
base.setUTCDate(base.getUTCDate() + days);
|
||||||
|
return {
|
||||||
|
year: base.getUTCFullYear(),
|
||||||
|
month: base.getUTCMonth() + 1,
|
||||||
|
day: base.getUTCDate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect active episodes (macrostop, mold-change) from event rows.
|
||||||
|
// Returns the latest non-auto-ack episode whose final status is "active"
|
||||||
|
// and that's been refreshed within ACTIVE_STALE_MS.
|
||||||
|
const ACTIVE_STALE_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
type ActiveEpisode = { startedAtMs: number; lastTsMs: number };
|
||||||
|
|
||||||
|
function detectActiveEpisode(
|
||||||
|
events: TimelineEventRow[] | undefined,
|
||||||
|
eventType: "macrostop" | "mold-change",
|
||||||
|
endMs: number
|
||||||
|
): ActiveEpisode | null {
|
||||||
|
if (!events || events.length === 0) return null;
|
||||||
|
|
||||||
|
type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string; lastCycleTs: number | null };
|
||||||
|
const episodes = new Map<string, Episode>();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (String(event.eventType || "").toLowerCase() !== eventType) continue;
|
||||||
|
|
||||||
|
let parsed: unknown = event.data;
|
||||||
|
if (typeof parsed === "string") {
|
||||||
|
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
|
||||||
|
}
|
||||||
|
const data: Record<string, unknown> =
|
||||||
|
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const isAutoAck =
|
||||||
|
data.is_auto_ack === true ||
|
||||||
|
data.isAutoAck === true ||
|
||||||
|
data.is_auto_ack === "true" ||
|
||||||
|
data.isAutoAck === "true";
|
||||||
|
if (isAutoAck) continue;
|
||||||
|
|
||||||
|
const status = String(data.status ?? "").trim().toLowerCase();
|
||||||
|
const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
|
||||||
|
|| `${eventType}:${event.ts.getTime()}`;
|
||||||
|
const tsMs = event.ts.getTime();
|
||||||
|
const lastCycleTs = Number(data.last_cycle_timestamp);
|
||||||
|
|
||||||
|
const existing = episodes.get(incidentKey);
|
||||||
|
if (!existing) {
|
||||||
|
episodes.set(incidentKey, {
|
||||||
|
firstTsMs: tsMs,
|
||||||
|
lastTsMs: tsMs,
|
||||||
|
lastStatus: status,
|
||||||
|
lastCycleTs: Number.isFinite(lastCycleTs) && lastCycleTs > 0 ? lastCycleTs : null,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
|
||||||
|
if (tsMs >= existing.lastTsMs) {
|
||||||
|
existing.lastTsMs = tsMs;
|
||||||
|
existing.lastStatus = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let best: ActiveEpisode | null = null;
|
||||||
|
for (const ep of episodes.values()) {
|
||||||
|
if (ep.lastStatus !== "active") continue;
|
||||||
|
if (endMs - ep.lastTsMs > ACTIVE_STALE_MS) continue;
|
||||||
|
// Prefer the freshest active episode (highest lastTsMs)
|
||||||
|
if (!best || ep.lastTsMs > best.lastTsMs) {
|
||||||
|
best = {
|
||||||
|
startedAtMs: ep.lastCycleTs ?? ep.firstTsMs,
|
||||||
|
lastTsMs: ep.lastTsMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFromMachine(
|
||||||
|
machine: RecapMachine,
|
||||||
|
endMs: number,
|
||||||
|
events?: TimelineEventRow[]
|
||||||
|
): {
|
||||||
|
status: RecapMachineStatus;
|
||||||
|
result: MachineStateResult;
|
||||||
|
lastSeenMs: number | null;
|
||||||
|
offlineForMin: number | null;
|
||||||
|
ongoingStopMin: number | null;
|
||||||
|
} {
|
||||||
|
const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
|
||||||
|
const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
|
||||||
|
const heartbeatAlive = Number.isFinite(lastSeenMs ?? Number.NaN) && offlineForMs <= OFFLINE_THRESHOLD_MS;
|
||||||
|
|
||||||
|
const activeMacrostop = detectActiveEpisode(events, "macrostop", endMs);
|
||||||
|
const activeMoldChange = detectActiveEpisode(events, "mold-change", endMs);
|
||||||
|
|
||||||
|
// Round 1 limitation: trackingEnabled and untrackedCycles inputs require KPI/cycle queries
|
||||||
|
// we don't yet plumb here. We approximate from the legacy fields:
|
||||||
|
// - trackingEnabled: true when there's an active macrostop (Pi only fires those when tracking on)
|
||||||
|
// OR when an active WO exists and machine.workOrders.moldChangeInProgress is false.
|
||||||
|
// This is a SIMPLIFICATION; Round 3 will replace with real KPI snapshot read.
|
||||||
|
// - untrackedCycles: 0 (Round 3 will compute from MachineCycle vs latest KPI)
|
||||||
|
//
|
||||||
|
// Effect for Round 1: STOPPED `not_started` reason cannot trigger yet (we always assume tracking
|
||||||
|
// is on when a WO exists). Only `machine_fault` STOPPED fires. DATA_LOSS cannot fire yet.
|
||||||
|
// IDLE fires correctly when there's no WO and no recent activity.
|
||||||
|
const hasActiveWorkOrder = machine.workOrders.active != null;
|
||||||
|
const trackingEnabledApprox = hasActiveWorkOrder; // see comment above
|
||||||
|
|
||||||
|
const lastCycleTsMs = (() => {
|
||||||
|
// Best-effort: use the machine's heartbeat as a "recent activity" proxy.
|
||||||
|
// The Pi only heartbeats every minute regardless of cycles, so this is a weak signal.
|
||||||
|
// Round 3 will pass the actual latest cycle ts.
|
||||||
|
return lastSeenMs;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const result = classifyMachineState(
|
||||||
|
{
|
||||||
|
heartbeatAlive,
|
||||||
|
lastSeenMs,
|
||||||
|
offlineForMs,
|
||||||
|
trackingEnabled: trackingEnabledApprox,
|
||||||
|
hasActiveWorkOrder,
|
||||||
|
activeMoldChange,
|
||||||
|
activeMacrostop,
|
||||||
|
untrackedCycles: { count: 0, oldestTsMs: null },
|
||||||
|
lastCycleTsMs,
|
||||||
|
},
|
||||||
|
endMs
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map the rich classifier result back to the existing RecapMachineStatus union
|
||||||
|
const status: RecapMachineStatus = result.state;
|
||||||
|
|
||||||
|
// Pull common fields out for the caller's convenience
|
||||||
|
let ongoingStopMin: number | null = null;
|
||||||
|
if (result.state === "stopped") ongoingStopMin = result.ongoingStopMin;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
result,
|
||||||
|
lastSeenMs,
|
||||||
|
offlineForMin: result.state === "offline" ? result.offlineForMin : null,
|
||||||
|
ongoingStopMin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTimelineRowsForMachines(params: {
|
||||||
|
orgId: string;
|
||||||
|
machineIds: string[];
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
}) {
|
||||||
|
if (!params.machineIds.length) {
|
||||||
|
return {
|
||||||
|
cyclesByMachine: new Map<string, TimelineCycleRow[]>(),
|
||||||
|
eventsByMachine: new Map<string, TimelineEventRow[]>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [cycles, events] = await Promise.all([
|
||||||
|
prisma.machineCycle.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: { in: params.machineIds },
|
||||||
|
ts: {
|
||||||
|
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||||
|
lte: params.end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
ts: true,
|
||||||
|
cycleCount: true,
|
||||||
|
actualCycleTime: true,
|
||||||
|
workOrderId: true,
|
||||||
|
sku: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.machineEvent.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: { in: params.machineIds },
|
||||||
|
eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
|
||||||
|
ts: {
|
||||||
|
gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
|
||||||
|
lte: params.end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
ts: true,
|
||||||
|
eventType: true,
|
||||||
|
data: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cyclesByMachine = new Map<string, TimelineCycleRow[]>();
|
||||||
|
const eventsByMachine = new Map<string, TimelineEventRow[]>();
|
||||||
|
|
||||||
|
for (const row of cycles) {
|
||||||
|
const list = cyclesByMachine.get(row.machineId) ?? [];
|
||||||
|
list.push({
|
||||||
|
ts: row.ts,
|
||||||
|
cycleCount: row.cycleCount,
|
||||||
|
actualCycleTime: row.actualCycleTime,
|
||||||
|
workOrderId: row.workOrderId,
|
||||||
|
sku: row.sku,
|
||||||
|
});
|
||||||
|
cyclesByMachine.set(row.machineId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of events) {
|
||||||
|
const list = eventsByMachine.get(row.machineId) ?? [];
|
||||||
|
list.push({
|
||||||
|
ts: row.ts,
|
||||||
|
eventType: row.eventType,
|
||||||
|
data: row.data,
|
||||||
|
});
|
||||||
|
eventsByMachine.set(row.machineId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cyclesByMachine, eventsByMachine };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSummaryMachine(params: {
|
||||||
|
machine: RecapMachine;
|
||||||
|
miniTimeline: ReturnType<typeof compressTimelineSegments>;
|
||||||
|
rangeEndMs: number;
|
||||||
|
events?: TimelineEventRow[];
|
||||||
|
}): RecapSummaryMachine {
|
||||||
|
const { machine, miniTimeline, rangeEndMs, events } = params;
|
||||||
|
const status = statusFromMachine(machine, rangeEndMs, events);
|
||||||
|
|
||||||
|
return {
|
||||||
|
machineId: machine.machineId,
|
||||||
|
name: machine.machineName,
|
||||||
|
location: machine.location,
|
||||||
|
status: status.status,
|
||||||
|
oee: machine.oee.avg,
|
||||||
|
goodParts: machine.production.goodParts,
|
||||||
|
scrap: machine.production.scrapParts,
|
||||||
|
stopsCount: machine.downtime.stopsCount,
|
||||||
|
lastSeenMs: status.lastSeenMs,
|
||||||
|
lastActivityMin:
|
||||||
|
status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
|
||||||
|
offlineForMin: status.offlineForMin,
|
||||||
|
ongoingStopMin: status.ongoingStopMin,
|
||||||
|
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
||||||
|
moldChange: {
|
||||||
|
active: machine.workOrders.moldChangeInProgress,
|
||||||
|
startMs: machine.workOrders.moldChangeStartMs,
|
||||||
|
elapsedMin:
|
||||||
|
machine.workOrders.moldChangeStartMs == null
|
||||||
|
? null
|
||||||
|
: Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)),
|
||||||
|
},
|
||||||
|
miniTimeline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeRecapSummary(params: { orgId: string; hours: number }) {
|
||||||
|
const now = new Date();
|
||||||
|
const end = new Date(Math.floor(now.getTime() / 60000) * 60000);
|
||||||
|
const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const recap = await getRecapDataCached({
|
||||||
|
orgId: params.orgId,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
|
||||||
|
const machineIds = recap.machines.map((machine) => machine.machineId);
|
||||||
|
const timelineRows = await loadTimelineRowsForMachines({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineIds,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
|
||||||
|
const machines = recap.machines.map((machine) => {
|
||||||
|
const segments = buildTimelineSegments({
|
||||||
|
cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [],
|
||||||
|
events: timelineRows.eventsByMachine.get(machine.machineId) ?? [],
|
||||||
|
rangeStart: start,
|
||||||
|
rangeEnd: end,
|
||||||
|
});
|
||||||
|
const miniTimeline = compressTimelineSegments({
|
||||||
|
segments,
|
||||||
|
rangeStart: start,
|
||||||
|
rangeEnd: end,
|
||||||
|
maxSegments: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toSummaryMachine({
|
||||||
|
machine,
|
||||||
|
miniTimeline,
|
||||||
|
rangeEndMs: end.getTime(),
|
||||||
|
events: timelineRows.eventsByMachine.get(machine.machineId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: RecapSummaryResponse = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
range: {
|
||||||
|
start: start.toISOString(),
|
||||||
|
end: end.toISOString(),
|
||||||
|
hours: params.hours,
|
||||||
|
},
|
||||||
|
machines,
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedRangeMode(mode?: string | null): RecapRangeMode {
|
||||||
|
const raw = String(mode ?? "").trim().toLowerCase();
|
||||||
|
if (raw === "shift") return "shift";
|
||||||
|
if (raw === "yesterday") return "yesterday";
|
||||||
|
if (raw === "custom") return "custom";
|
||||||
|
return "24h";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
|
||||||
|
const settings = await prisma.orgSettings.findUnique({
|
||||||
|
where: { orgId: params.orgId },
|
||||||
|
select: {
|
||||||
|
timezone: true,
|
||||||
|
shiftScheduleOverridesJson: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const shifts = await prisma.orgShift.findMany({
|
||||||
|
where: { orgId: params.orgId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
enabled: true,
|
||||||
|
sortOrder: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabledShifts = shifts.filter((shift) => shift.enabled !== false);
|
||||||
|
if (!enabledShifts.length) {
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: false,
|
||||||
|
range: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeZone = settings?.timezone || "UTC";
|
||||||
|
const local = getLocalParts(params.now, timeZone);
|
||||||
|
const overrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
|
||||||
|
const dayOverrides = overrides?.[local.weekday];
|
||||||
|
const activeShifts = (dayOverrides?.length
|
||||||
|
? dayOverrides.map((shift) => ({
|
||||||
|
enabled: shift.enabled !== false,
|
||||||
|
start: shift.start,
|
||||||
|
end: shift.end,
|
||||||
|
}))
|
||||||
|
: enabledShifts.map((shift) => ({
|
||||||
|
enabled: shift.enabled !== false,
|
||||||
|
start: shift.startTime,
|
||||||
|
end: shift.endTime,
|
||||||
|
}))
|
||||||
|
).filter((shift) => shift.enabled);
|
||||||
|
|
||||||
|
for (const shift of activeShifts) {
|
||||||
|
const startMin = parseTimeMinutes(shift.start ?? null);
|
||||||
|
const endMin = parseTimeMinutes(shift.end ?? null);
|
||||||
|
if (startMin == null || endMin == null) continue;
|
||||||
|
|
||||||
|
const minutesNow = local.minutesOfDay;
|
||||||
|
let inRange = false;
|
||||||
|
let startDate = { year: local.year, month: local.month, day: local.day };
|
||||||
|
let endDate = { year: local.year, month: local.month, day: local.day };
|
||||||
|
|
||||||
|
if (startMin <= endMin) {
|
||||||
|
inRange = minutesNow >= startMin && minutesNow < endMin;
|
||||||
|
} else {
|
||||||
|
inRange = minutesNow >= startMin || minutesNow < endMin;
|
||||||
|
if (minutesNow >= startMin) {
|
||||||
|
endDate = addDays(endDate, 1);
|
||||||
|
} else {
|
||||||
|
startDate = addDays(startDate, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inRange) continue;
|
||||||
|
|
||||||
|
const start = zonedToUtcDate({
|
||||||
|
...startDate,
|
||||||
|
hours: Math.floor(startMin / 60),
|
||||||
|
minutes: startMin % 60,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
const shiftEndUtc = zonedToUtcDate({
|
||||||
|
...endDate,
|
||||||
|
hours: Math.floor(endMin / 60),
|
||||||
|
minutes: endMin % 60,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shiftEndUtc <= start) continue;
|
||||||
|
|
||||||
|
// Cap end at "now" so we render shift-so-far, not shift-as-planned.
|
||||||
|
// Without cap:
|
||||||
|
// - timeline fills future minutes with idle (visual lie)
|
||||||
|
// - offline calc = (shift_end_future - last_seen) = looks 5h offline
|
||||||
|
// even on a machine producing right now
|
||||||
|
const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: true,
|
||||||
|
range: { start, end },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: true,
|
||||||
|
range: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
|
||||||
|
const now = new Date(Math.floor(Date.now() / 60000) * 60000);
|
||||||
|
const requestedMode = normalizedRangeMode(params.input.mode);
|
||||||
|
const shiftEnabledCount = await prisma.orgShift.count({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
enabled: { not: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const shiftAvailable = shiftEnabledCount > 0;
|
||||||
|
|
||||||
|
if (requestedMode === "custom") {
|
||||||
|
const start = parseDate(params.input.start);
|
||||||
|
const end = parseDate(params.input.end);
|
||||||
|
if (start && end && end > start) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedMode === "yesterday") {
|
||||||
|
const settings = await prisma.orgSettings.findUnique({
|
||||||
|
where: { orgId: params.orgId },
|
||||||
|
select: { timezone: true },
|
||||||
|
});
|
||||||
|
const timeZone = settings?.timezone || "America/Mexico_City";
|
||||||
|
const localNow = getLocalParts(now, timeZone);
|
||||||
|
const today = { year: localNow.year, month: localNow.month, day: localNow.day };
|
||||||
|
const yesterday = addDays(today, -1);
|
||||||
|
const start = zonedToUtcDate({
|
||||||
|
...yesterday,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
const end = zonedToUtcDate({
|
||||||
|
...today,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedMode === "shift") {
|
||||||
|
const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
|
||||||
|
if (shiftRange.range) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start: shiftRange.range.start,
|
||||||
|
end: shiftRange.range.end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
if (!shiftRange.hasEnabledShifts) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
fallbackReason: "shift-unavailable" as const,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
fallbackReason: "shift-inactive" as const,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeRecapMachineDetail(params: {
|
||||||
|
orgId: string;
|
||||||
|
machineId: string;
|
||||||
|
range: {
|
||||||
|
requestedMode: RecapRangeMode;
|
||||||
|
mode: RecapRangeMode;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
shiftAvailable: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const { range } = params;
|
||||||
|
|
||||||
|
const recap = await getRecapDataCached({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: params.machineId,
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
});
|
||||||
|
|
||||||
|
const machine = recap.machines.find((row) => row.machineId === params.machineId) ?? null;
|
||||||
|
if (!machine) return null;
|
||||||
|
|
||||||
|
const timelineRows = await loadTimelineRowsForMachines({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineIds: [params.machineId],
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeline = buildTimelineSegments({
|
||||||
|
cycles: timelineRows.cyclesByMachine.get(params.machineId) ?? [],
|
||||||
|
events: timelineRows.eventsByMachine.get(params.machineId) ?? [],
|
||||||
|
rangeStart: range.start,
|
||||||
|
rangeEnd: range.end,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = statusFromMachine(
|
||||||
|
machine,
|
||||||
|
range.end.getTime(),
|
||||||
|
timelineRows.eventsByMachine.get(params.machineId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const downtimeTotalMin = Math.max(0, machine.downtime.totalMin);
|
||||||
|
const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({
|
||||||
|
reasonLabel: row.reasonLabel,
|
||||||
|
minutes: row.minutes,
|
||||||
|
count: row.count,
|
||||||
|
percent: downtimeTotalMin > 0 ? round2((row.minutes / downtimeTotalMin) * 100) : 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const machineDetail: RecapMachineDetail = {
|
||||||
|
machineId: machine.machineId,
|
||||||
|
name: machine.machineName,
|
||||||
|
location: machine.location,
|
||||||
|
status: status.status,
|
||||||
|
oee: machine.oee.avg,
|
||||||
|
goodParts: machine.production.goodParts,
|
||||||
|
scrap: machine.production.scrapParts,
|
||||||
|
stopsCount: machine.downtime.stopsCount,
|
||||||
|
stopMinutes: downtimeTotalMin,
|
||||||
|
activeWorkOrderId: machine.workOrders.active?.id ?? null,
|
||||||
|
lastSeenMs: status.lastSeenMs,
|
||||||
|
offlineForMin: status.offlineForMin,
|
||||||
|
ongoingStopMin: status.ongoingStopMin,
|
||||||
|
moldChange: {
|
||||||
|
active: machine.workOrders.moldChangeInProgress,
|
||||||
|
startMs: machine.workOrders.moldChangeStartMs,
|
||||||
|
},
|
||||||
|
timeline,
|
||||||
|
productionBySku: machine.production.bySku,
|
||||||
|
downtimeTop,
|
||||||
|
workOrders: {
|
||||||
|
completed: machine.workOrders.completed,
|
||||||
|
active: machine.workOrders.active,
|
||||||
|
},
|
||||||
|
heartbeat: {
|
||||||
|
lastSeenAt: machine.heartbeat.lastSeenAt,
|
||||||
|
uptimePct: machine.heartbeat.uptimePct,
|
||||||
|
connectionStatus: status.status === "offline" ? "offline" : "online",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response: RecapDetailResponse = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
range: {
|
||||||
|
requestedMode: range.requestedMode,
|
||||||
|
mode: range.mode,
|
||||||
|
start: range.start.toISOString(),
|
||||||
|
end: range.end.toISOString(),
|
||||||
|
shiftAvailable: range.shiftAvailable,
|
||||||
|
fallbackReason: range.fallbackReason,
|
||||||
|
},
|
||||||
|
machine: machineDetail,
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryCacheKey(params: { orgId: string; hours: number }) {
|
||||||
|
return ["recap-summary-v1", params.orgId, String(params.hours)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailCacheKey(params: {
|
||||||
|
orgId: string;
|
||||||
|
machineId: string;
|
||||||
|
requestedMode: RecapRangeMode;
|
||||||
|
mode: RecapRangeMode;
|
||||||
|
shiftAvailable: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
"recap-detail-v1",
|
||||||
|
params.orgId,
|
||||||
|
params.machineId,
|
||||||
|
params.requestedMode,
|
||||||
|
params.mode,
|
||||||
|
params.shiftAvailable ? "shift-on" : "shift-off",
|
||||||
|
params.fallbackReason ?? "",
|
||||||
|
String(Math.trunc(params.startMs / 60000)),
|
||||||
|
String(Math.trunc(params.endMs / 60000)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRecapSummaryHours(raw: string | null) {
|
||||||
|
return parseHours(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record<string, string | string[] | undefined>) {
|
||||||
|
if (searchParams instanceof URLSearchParams) {
|
||||||
|
return {
|
||||||
|
mode: searchParams.get("range") ?? undefined,
|
||||||
|
start: searchParams.get("start") ?? undefined,
|
||||||
|
end: searchParams.get("end") ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pick = (key: string) => {
|
||||||
|
const value = searchParams[key];
|
||||||
|
if (Array.isArray(value)) return value[0] ?? undefined;
|
||||||
|
return value ?? undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: pick("range"),
|
||||||
|
start: pick("start"),
|
||||||
|
end: pick("end"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecapSummaryCached(params: { orgId: string; hours: number }) {
|
||||||
|
const cache = unstable_cache(
|
||||||
|
() => computeRecapSummary(params),
|
||||||
|
summaryCacheKey(params),
|
||||||
|
{
|
||||||
|
revalidate: RECAP_CACHE_TTL_SEC,
|
||||||
|
tags: [`recap:${params.orgId}`],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecapMachineDetailCached(params: {
|
||||||
|
orgId: string;
|
||||||
|
machineId: string;
|
||||||
|
input: DetailRangeInput;
|
||||||
|
}) {
|
||||||
|
const resolved = await resolveDetailRange({
|
||||||
|
orgId: params.orgId,
|
||||||
|
input: params.input,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cache = unstable_cache(
|
||||||
|
() =>
|
||||||
|
computeRecapMachineDetail({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: params.machineId,
|
||||||
|
range: {
|
||||||
|
requestedMode: resolved.requestedMode,
|
||||||
|
mode: resolved.mode,
|
||||||
|
start: resolved.start,
|
||||||
|
end: resolved.end,
|
||||||
|
shiftAvailable: resolved.shiftAvailable,
|
||||||
|
fallbackReason: resolved.fallbackReason,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
detailCacheKey({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: params.machineId,
|
||||||
|
requestedMode: resolved.requestedMode,
|
||||||
|
mode: resolved.mode,
|
||||||
|
shiftAvailable: resolved.shiftAvailable,
|
||||||
|
fallbackReason: resolved.fallbackReason,
|
||||||
|
startMs: resolved.start.getTime(),
|
||||||
|
endMs: resolved.end.getTime(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
revalidate: RECAP_CACHE_TTL_SEC,
|
||||||
|
tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return cache();
|
||||||
|
}
|
||||||
@@ -121,7 +121,24 @@ export type RecapQuery = {
|
|||||||
shift?: string;
|
shift?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "offline";
|
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "data-loss" | "offline" | "idle";
|
||||||
|
|
||||||
|
export type RecapStoppedReason = "machine_fault" | "not_started";
|
||||||
|
export type RecapDataLossReason = "untracked";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reason context for STOPPED and DATA_LOSS states.
|
||||||
|
* - When status is "stopped": stoppedReason is set, dataLossReason is null.
|
||||||
|
* - When status is "data-loss": dataLossReason is set, stoppedReason is null.
|
||||||
|
* - All other states: both are null.
|
||||||
|
*/
|
||||||
|
export type RecapStateContext = {
|
||||||
|
stoppedReason: RecapStoppedReason | null;
|
||||||
|
dataLossReason: RecapDataLossReason | null;
|
||||||
|
/** For data-loss: how many untracked cycles have been detected so far. */
|
||||||
|
untrackedCycleCount: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type RecapSummaryMachine = {
|
export type RecapSummaryMachine = {
|
||||||
machineId: string;
|
machineId: string;
|
||||||
@@ -136,6 +153,7 @@ export type RecapSummaryMachine = {
|
|||||||
lastActivityMin: number | null;
|
lastActivityMin: number | null;
|
||||||
offlineForMin: number | null;
|
offlineForMin: number | null;
|
||||||
ongoingStopMin: number | null;
|
ongoingStopMin: number | null;
|
||||||
|
stateContext: RecapStateContext;
|
||||||
activeWorkOrderId: string | null;
|
activeWorkOrderId: string | null;
|
||||||
moldChange: {
|
moldChange: {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -193,6 +211,7 @@ export type RecapMachineDetail = {
|
|||||||
lastSeenMs: number | null;
|
lastSeenMs: number | null;
|
||||||
offlineForMin: number | null;
|
offlineForMin: number | null;
|
||||||
ongoingStopMin: number | null;
|
ongoingStopMin: number | null;
|
||||||
|
stateContext: RecapStateContext;
|
||||||
moldChange: {
|
moldChange: {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
startMs: number | null;
|
startMs: number | null;
|
||||||
|
|||||||
222
lib/recap/types.ts.bak
Normal file
222
lib/recap/types.ts.bak
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
export type RecapSkuRow = {
|
||||||
|
machineName: string;
|
||||||
|
sku: string;
|
||||||
|
good: number;
|
||||||
|
scrap: number;
|
||||||
|
target: number | null;
|
||||||
|
progressPct: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapMachine = {
|
||||||
|
machineId: string;
|
||||||
|
machineName: string;
|
||||||
|
location: string | null;
|
||||||
|
production: {
|
||||||
|
goodParts: number;
|
||||||
|
scrapParts: number;
|
||||||
|
totalCycles: number;
|
||||||
|
bySku: RecapSkuRow[];
|
||||||
|
};
|
||||||
|
oee: {
|
||||||
|
avg: number | null;
|
||||||
|
availability: number | null;
|
||||||
|
performance: number | null;
|
||||||
|
quality: number | null;
|
||||||
|
};
|
||||||
|
downtime: {
|
||||||
|
totalMin: number;
|
||||||
|
stopsCount: number;
|
||||||
|
topReasons: Array<{
|
||||||
|
reasonLabel: string;
|
||||||
|
minutes: number;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
ongoingStopMin: number | null;
|
||||||
|
};
|
||||||
|
workOrders: {
|
||||||
|
completed: Array<{
|
||||||
|
id: string;
|
||||||
|
sku: string | null;
|
||||||
|
goodParts: number;
|
||||||
|
durationHrs: number;
|
||||||
|
}>;
|
||||||
|
active: {
|
||||||
|
id: string;
|
||||||
|
sku: string | null;
|
||||||
|
progressPct: number | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
} | null;
|
||||||
|
moldChangeInProgress: boolean;
|
||||||
|
moldChangeStartMs: number | null;
|
||||||
|
};
|
||||||
|
heartbeat: {
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
uptimePct: number | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapTimelineSegment =
|
||||||
|
| {
|
||||||
|
type: "production";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
durationSec: number;
|
||||||
|
workOrderId: string | null;
|
||||||
|
sku: string | null;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "mold-change";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
fromMoldId: string | null;
|
||||||
|
toMoldId: string | null;
|
||||||
|
durationSec: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "macrostop" | "microstop" | "slow-cycle";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
reason: string | null;
|
||||||
|
reasonLabel?: string | null;
|
||||||
|
durationSec: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "idle";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
durationSec: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapTimelineResponse = {
|
||||||
|
range: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
segments: RecapTimelineSegment[];
|
||||||
|
hasData: boolean;
|
||||||
|
generatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapResponse = {
|
||||||
|
range: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
availableShifts: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
machines: RecapMachine[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapQuery = {
|
||||||
|
orgId: string;
|
||||||
|
machineId?: string;
|
||||||
|
start?: Date;
|
||||||
|
end?: Date;
|
||||||
|
shift?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "offline";
|
||||||
|
|
||||||
|
export type RecapSummaryMachine = {
|
||||||
|
machineId: string;
|
||||||
|
name: string;
|
||||||
|
location: string | null;
|
||||||
|
status: RecapMachineStatus;
|
||||||
|
oee: number | null;
|
||||||
|
goodParts: number;
|
||||||
|
scrap: number;
|
||||||
|
stopsCount: number;
|
||||||
|
lastSeenMs: number | null;
|
||||||
|
lastActivityMin: number | null;
|
||||||
|
offlineForMin: number | null;
|
||||||
|
ongoingStopMin: number | null;
|
||||||
|
activeWorkOrderId: string | null;
|
||||||
|
moldChange: {
|
||||||
|
active: boolean;
|
||||||
|
startMs: number | null;
|
||||||
|
elapsedMin: number | null;
|
||||||
|
} | null;
|
||||||
|
miniTimeline: RecapTimelineSegment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapSummaryResponse = {
|
||||||
|
generatedAt: string;
|
||||||
|
range: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
hours: number;
|
||||||
|
};
|
||||||
|
machines: RecapSummaryMachine[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapRangeMode = "24h" | "shift" | "yesterday" | "custom";
|
||||||
|
|
||||||
|
export type RecapDowntimeTopRow = {
|
||||||
|
reasonLabel: string;
|
||||||
|
minutes: number;
|
||||||
|
count: number;
|
||||||
|
percent: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapWorkOrders = {
|
||||||
|
completed: Array<{
|
||||||
|
id: string;
|
||||||
|
sku: string | null;
|
||||||
|
goodParts: number;
|
||||||
|
durationHrs: number;
|
||||||
|
}>;
|
||||||
|
active: {
|
||||||
|
id: string;
|
||||||
|
sku: string | null;
|
||||||
|
progressPct: number | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapMachineDetail = {
|
||||||
|
machineId: string;
|
||||||
|
name: string;
|
||||||
|
location: string | null;
|
||||||
|
status: RecapMachineStatus;
|
||||||
|
oee: number | null;
|
||||||
|
goodParts: number;
|
||||||
|
scrap: number;
|
||||||
|
stopsCount: number;
|
||||||
|
stopMinutes: number;
|
||||||
|
activeWorkOrderId: string | null;
|
||||||
|
lastSeenMs: number | null;
|
||||||
|
offlineForMin: number | null;
|
||||||
|
ongoingStopMin: number | null;
|
||||||
|
moldChange: {
|
||||||
|
active: boolean;
|
||||||
|
startMs: number | null;
|
||||||
|
} | null;
|
||||||
|
timeline: RecapTimelineSegment[];
|
||||||
|
productionBySku: RecapSkuRow[];
|
||||||
|
downtimeTop: RecapDowntimeTopRow[];
|
||||||
|
workOrders: RecapWorkOrders;
|
||||||
|
heartbeat: {
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
uptimePct: number | null;
|
||||||
|
connectionStatus: "online" | "offline";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapDetailResponse = {
|
||||||
|
generatedAt: string;
|
||||||
|
range: {
|
||||||
|
requestedMode?: RecapRangeMode;
|
||||||
|
mode: RecapRangeMode;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
shiftAvailable?: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
|
};
|
||||||
|
machine: RecapMachineDetail;
|
||||||
|
};
|
||||||
222
lib/recap/types.ts.bak.step4
Normal file
222
lib/recap/types.ts.bak.step4
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
export type RecapSkuRow = {
|
||||||
|
machineName: string;
|
||||||
|
sku: string;
|
||||||
|
good: number;
|
||||||
|
scrap: number;
|
||||||
|
target: number | null;
|
||||||
|
progressPct: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapMachine = {
|
||||||
|
machineId: string;
|
||||||
|
machineName: string;
|
||||||
|
location: string | null;
|
||||||
|
production: {
|
||||||
|
goodParts: number;
|
||||||
|
scrapParts: number;
|
||||||
|
totalCycles: number;
|
||||||
|
bySku: RecapSkuRow[];
|
||||||
|
};
|
||||||
|
oee: {
|
||||||
|
avg: number | null;
|
||||||
|
availability: number | null;
|
||||||
|
performance: number | null;
|
||||||
|
quality: number | null;
|
||||||
|
};
|
||||||
|
downtime: {
|
||||||
|
totalMin: number;
|
||||||
|
stopsCount: number;
|
||||||
|
topReasons: Array<{
|
||||||
|
reasonLabel: string;
|
||||||
|
minutes: number;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
ongoingStopMin: number | null;
|
||||||
|
};
|
||||||
|
workOrders: {
|
||||||
|
completed: Array<{
|
||||||
|
id: string;
|
||||||
|
sku: string | null;
|
||||||
|
goodParts: number;
|
||||||
|
durationHrs: number;
|
||||||
|
}>;
|
||||||
|
active: {
|
||||||
|
id: string;
|
||||||
|
sku: string | null;
|
||||||
|
progressPct: number | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
} | null;
|
||||||
|
moldChangeInProgress: boolean;
|
||||||
|
moldChangeStartMs: number | null;
|
||||||
|
};
|
||||||
|
heartbeat: {
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
uptimePct: number | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapTimelineSegment =
|
||||||
|
| {
|
||||||
|
type: "production";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
durationSec: number;
|
||||||
|
workOrderId: string | null;
|
||||||
|
sku: string | null;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "mold-change";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
fromMoldId: string | null;
|
||||||
|
toMoldId: string | null;
|
||||||
|
durationSec: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "macrostop" | "microstop" | "slow-cycle";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
reason: string | null;
|
||||||
|
reasonLabel?: string | null;
|
||||||
|
durationSec: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "idle";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
durationSec: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapTimelineResponse = {
|
||||||
|
range: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
segments: RecapTimelineSegment[];
|
||||||
|
hasData: boolean;
|
||||||
|
generatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapResponse = {
|
||||||
|
range: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
availableShifts: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
machines: RecapMachine[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapQuery = {
|
||||||
|
orgId: string;
|
||||||
|
machineId?: string;
|
||||||
|
start?: Date;
|
||||||
|
end?: Date;
|
||||||
|
shift?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "data-loss" | "offline" | "idle";
|
||||||
|
|
||||||
|
export type RecapSummaryMachine = {
|
||||||
|
machineId: string;
|
||||||
|
name: string;
|
||||||
|
location: string | null;
|
||||||
|
status: RecapMachineStatus;
|
||||||
|
oee: number | null;
|
||||||
|
goodParts: number;
|
||||||
|
scrap: number;
|
||||||
|
stopsCount: number;
|
||||||
|
lastSeenMs: number | null;
|
||||||
|
lastActivityMin: number | null;
|
||||||
|
offlineForMin: number | null;
|
||||||
|
ongoingStopMin: number | null;
|
||||||
|
activeWorkOrderId: string | null;
|
||||||
|
moldChange: {
|
||||||
|
active: boolean;
|
||||||
|
startMs: number | null;
|
||||||
|
elapsedMin: number | null;
|
||||||
|
} | null;
|
||||||
|
miniTimeline: RecapTimelineSegment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapSummaryResponse = {
|
||||||
|
generatedAt: string;
|
||||||
|
range: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
hours: number;
|
||||||
|
};
|
||||||
|
machines: RecapSummaryMachine[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapRangeMode = "24h" | "shift" | "yesterday" | "custom";
|
||||||
|
|
||||||
|
export type RecapDowntimeTopRow = {
|
||||||
|
reasonLabel: string;
|
||||||
|
minutes: number;
|
||||||
|
count: number;
|
||||||
|
percent: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapWorkOrders = {
|
||||||
|
completed: Array<{
|
||||||
|
id: string;
|
||||||
|
sku: string | null;
|
||||||
|
goodParts: number;
|
||||||
|
durationHrs: number;
|
||||||
|
}>;
|
||||||
|
active: {
|
||||||
|
id: string;
|
||||||
|
sku: string | null;
|
||||||
|
progressPct: number | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapMachineDetail = {
|
||||||
|
machineId: string;
|
||||||
|
name: string;
|
||||||
|
location: string | null;
|
||||||
|
status: RecapMachineStatus;
|
||||||
|
oee: number | null;
|
||||||
|
goodParts: number;
|
||||||
|
scrap: number;
|
||||||
|
stopsCount: number;
|
||||||
|
stopMinutes: number;
|
||||||
|
activeWorkOrderId: string | null;
|
||||||
|
lastSeenMs: number | null;
|
||||||
|
offlineForMin: number | null;
|
||||||
|
ongoingStopMin: number | null;
|
||||||
|
moldChange: {
|
||||||
|
active: boolean;
|
||||||
|
startMs: number | null;
|
||||||
|
} | null;
|
||||||
|
timeline: RecapTimelineSegment[];
|
||||||
|
productionBySku: RecapSkuRow[];
|
||||||
|
downtimeTop: RecapDowntimeTopRow[];
|
||||||
|
workOrders: RecapWorkOrders;
|
||||||
|
heartbeat: {
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
uptimePct: number | null;
|
||||||
|
connectionStatus: "online" | "offline";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapDetailResponse = {
|
||||||
|
generatedAt: string;
|
||||||
|
range: {
|
||||||
|
requestedMode?: RecapRangeMode;
|
||||||
|
mode: RecapRangeMode;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
shiftAvailable?: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
|
};
|
||||||
|
machine: RecapMachineDetail;
|
||||||
|
};
|
||||||
@@ -135,6 +135,8 @@ model Machine {
|
|||||||
settings MachineSettings?
|
settings MachineSettings?
|
||||||
workOrders MachineWorkOrder[]
|
workOrders MachineWorkOrder[]
|
||||||
settingsAudits SettingsAudit[]
|
settingsAudits SettingsAudit[]
|
||||||
|
productionEnabled Boolean @default(true) @map("production_enabled")
|
||||||
|
|
||||||
|
|
||||||
@@unique([orgId, name])
|
@@unique([orgId, name])
|
||||||
@@index([orgId])
|
@@index([orgId])
|
||||||
|
|||||||
Reference in New Issue
Block a user