almost_done
This commit is contained in:
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, type KeyboardEvent } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||
|
||||
type MachineRow = {
|
||||
id: string;
|
||||
@@ -20,6 +21,7 @@ type MachineRow = {
|
||||
};
|
||||
};
|
||||
const LIVE_REFRESH_MS = 5000;
|
||||
const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||
|
||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
if (!ts) return fallback;
|
||||
@@ -31,7 +33,7 @@ function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
|
||||
function isOffline(ts?: string) {
|
||||
if (!ts) return true;
|
||||
return Date.now() - new Date(ts).getTime() > 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS)
|
||||
return Date.now() - new Date(ts).getTime() > OFFLINE_MS;
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||
import type { RecapTimelineResponse, RecapTimelineSegment } from "@/lib/recap/types";
|
||||
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||
import {
|
||||
computeWidths,
|
||||
formatDuration,
|
||||
@@ -375,6 +376,7 @@ function getMinuteFlooredOneHourRange(referenceMs = Date.now()) {
|
||||
function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) {
|
||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||
const [timelineLoading, setTimelineLoading] = useState(true);
|
||||
const [showWindowInfo, setShowWindowInfo] = useState(false);
|
||||
const timelineHashRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -444,7 +446,13 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
|
||||
<div className="text-sm font-semibold text-white">{t("machine.detail.activity.title")}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">{t("machine.detail.activity.subtitle")}</div>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">1h</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWindowInfo(true)}
|
||||
className="rounded-md border border-white/20 px-2 py-1 text-xs text-zinc-300 hover:border-white/40 hover:text-white"
|
||||
>
|
||||
{t("machine.detail.activity.windowBadge")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
|
||||
@@ -500,6 +508,31 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showWindowInfo ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="machine-timeline-window-title"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-2xl border border-white/15 bg-zinc-950 p-5">
|
||||
<h3 id="machine-timeline-window-title" className="text-sm font-semibold text-white">
|
||||
{t("machine.detail.activity.windowModalTitle")}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-zinc-300">{t("machine.detail.activity.windowModalBody")}</p>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWindowInfo(false)}
|
||||
className="rounded-lg border border-white/20 px-3 py-1.5 text-sm text-zinc-200 hover:border-white/40 hover:text-white"
|
||||
>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -800,7 +833,7 @@ export default function MachineDetailClient() {
|
||||
|
||||
function isOffline(ts?: string) {
|
||||
if (!ts) return true;
|
||||
return Date.now() - new Date(ts).getTime() > 30000;
|
||||
return Date.now() - new Date(ts).getTime() > RECAP_HEARTBEAT_STALE_MS;
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string) {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||
import type { EventRow, Heartbeat, MachineRow } from "./types";
|
||||
|
||||
const OFFLINE_MS = 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS)
|
||||
const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||
const MAX_EVENT_MACHINES = 6;
|
||||
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
|
||||
|
||||
@@ -199,20 +200,65 @@ export default function OverviewClient({
|
||||
.map((m) => {
|
||||
const hb = m.latestHeartbeat;
|
||||
const offline = isOffline(heartbeatTime(hb));
|
||||
const status = normalizeStatus(hb?.status);
|
||||
const k = m.latestKpi;
|
||||
const oee = k?.oee ?? null;
|
||||
const good = k?.good ?? null;
|
||||
const scrap = k?.scrap ?? null;
|
||||
const availability = k?.availability ?? null;
|
||||
|
||||
const reasons: string[] = [];
|
||||
let score = 0;
|
||||
if (offline) score += 100;
|
||||
if (oee != null && oee < 75) score += 50;
|
||||
if (oee != null && oee < 85) score += 25;
|
||||
return { machine: m, offline, oee, score };
|
||||
|
||||
// Trigger 1: offline (highest priority — can't tell what's wrong)
|
||||
if (offline) {
|
||||
score += 100;
|
||||
reasons.push(t("overview.attention.offline"));
|
||||
}
|
||||
|
||||
// Trigger 2: stopped right now (and online — operator should act)
|
||||
if (!offline && (status === "STOP" || status === "DOWN")) {
|
||||
score += 60;
|
||||
reasons.push(t("overview.attention.stopped"));
|
||||
}
|
||||
|
||||
// Trigger 3: low OEE
|
||||
if (!offline && oee != null) {
|
||||
if (oee < 50) {
|
||||
score += 50;
|
||||
reasons.push(t("overview.attention.oeeCritical", { value: oee.toFixed(0) }));
|
||||
} else if (oee < 75) {
|
||||
score += 30;
|
||||
reasons.push(t("overview.attention.oeeLow", { value: oee.toFixed(0) }));
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger 4: scrap rate >5% on active WO
|
||||
if (!offline && good != null && scrap != null && good + scrap > 0) {
|
||||
const scrapPct = (scrap / (good + scrap)) * 100;
|
||||
if (scrapPct > 10) {
|
||||
score += 40;
|
||||
reasons.push(t("overview.attention.scrapHigh", { value: scrapPct.toFixed(1) }));
|
||||
} else if (scrapPct > 5) {
|
||||
score += 20;
|
||||
reasons.push(t("overview.attention.scrapMod", { value: scrapPct.toFixed(1) }));
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger 5: availability collapse (often means undeclared stops)
|
||||
if (!offline && availability != null && availability < 60) {
|
||||
score += 25;
|
||||
reasons.push(t("overview.attention.availLow", { value: availability.toFixed(0) }));
|
||||
}
|
||||
|
||||
return { machine: m, offline, oee, score, reasons };
|
||||
})
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 6);
|
||||
|
||||
return list;
|
||||
}, [machines]);
|
||||
}, [machines, t]);
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
@@ -346,8 +392,12 @@ export default function OverviewClient({
|
||||
<div className="text-sm text-zinc-400">{t("overview.noUrgent")}</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{attention.map(({ machine, offline, oee }) => (
|
||||
<div key={machine.id} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
{attention.map(({ machine, offline, oee, reasons }) => (
|
||||
<Link
|
||||
key={machine.id}
|
||||
href={`/recap/${machine.id}`}
|
||||
className="block rounded-xl border border-white/10 bg-black/20 p-3 hover:border-white/20 hover:bg-black/30 transition"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{machine.name}</div>
|
||||
@@ -359,7 +409,7 @@ export default function OverviewClient({
|
||||
{secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 ${
|
||||
offline ? "bg-white/10 text-zinc-300" : "bg-emerald-500/15 text-emerald-300"
|
||||
@@ -367,13 +417,20 @@ export default function OverviewClient({
|
||||
>
|
||||
{offline ? t("overview.status.offline") : t("overview.status.online")}
|
||||
</span>
|
||||
{oee != null && (
|
||||
{oee != null && !offline && (
|
||||
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
||||
OEE {fmtPct(oee)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{reasons.length > 0 && (
|
||||
<ul className="mt-2 space-y-0.5 text-[11px] text-zinc-400">
|
||||
{reasons.map((r, i) => (
|
||||
<li key={i}>· {r}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -219,6 +219,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
||||
hasData={timelineHasData}
|
||||
loading={timelineLoading}
|
||||
locale={locale}
|
||||
rangeMode={initialData.range.mode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -298,25 +298,68 @@ export async function GET(req: NextRequest) {
|
||||
scrapRate: [],
|
||||
};
|
||||
|
||||
type TsBucket = {
|
||||
oeeSum: number; oeeCount: number;
|
||||
availSum: number; availCount: number;
|
||||
perfSum: number; perfCount: number;
|
||||
qualSum: number; qualCount: number;
|
||||
goodSum: number; scrapSum: number;
|
||||
anyProduction: boolean;
|
||||
};
|
||||
const tsBuckets = new Map<string, TsBucket>();
|
||||
|
||||
for (const k of kpiRows) {
|
||||
const t = k.ts.toISOString();
|
||||
if (!isProductionSnapshot(k.trackingEnabled, k.productionStarted)) {
|
||||
// Preserve timeline gaps across non-production windows for OEE-family charting.
|
||||
let b = tsBuckets.get(t);
|
||||
if (!b) {
|
||||
b = {
|
||||
oeeSum: 0, oeeCount: 0,
|
||||
availSum: 0, availCount: 0,
|
||||
perfSum: 0, perfCount: 0,
|
||||
qualSum: 0, qualCount: 0,
|
||||
goodSum: 0, scrapSum: 0,
|
||||
anyProduction: false,
|
||||
};
|
||||
tsBuckets.set(t, b);
|
||||
}
|
||||
|
||||
const isProd = isProductionSnapshot(k.trackingEnabled, k.productionStarted);
|
||||
if (isProd) {
|
||||
b.anyProduction = true;
|
||||
const oee = safeNum(k.oee);
|
||||
if (oee != null) { b.oeeSum += Number(oee); b.oeeCount += 1; }
|
||||
const avail = safeNum(k.availability);
|
||||
if (avail != null) { b.availSum += Number(avail); b.availCount += 1; }
|
||||
const perf = safeNum(k.performance);
|
||||
if (perf != null) { b.perfSum += Number(perf); b.perfCount += 1; }
|
||||
const qual = safeNum(k.quality);
|
||||
if (qual != null) { b.qualSum += Number(qual); b.qualCount += 1; }
|
||||
}
|
||||
|
||||
const good = safeNum(k.good);
|
||||
const scrap = safeNum(k.scrap);
|
||||
if (good != null) b.goodSum += Number(good);
|
||||
if (scrap != null) b.scrapSum += Number(scrap);
|
||||
}
|
||||
|
||||
// Iterate sorted ts. kpiRows already orderBy ts asc, but Map insertion
|
||||
// order matches that, so spreading keys preserves order.
|
||||
for (const [t, b] of tsBuckets) {
|
||||
if (!b.anyProduction) {
|
||||
// No machine producing at this ts -> gap, same as before.
|
||||
trend.oee.push({ t, v: null });
|
||||
trend.availability.push({ t, v: null });
|
||||
trend.performance.push({ t, v: null });
|
||||
trend.quality.push({ t, v: null });
|
||||
} else {
|
||||
trend.oee.push({ t, v: safeNum(k.oee) != null ? Number(k.oee) : null });
|
||||
trend.availability.push({ t, v: safeNum(k.availability) != null ? Number(k.availability) : null });
|
||||
trend.performance.push({ t, v: safeNum(k.performance) != null ? Number(k.performance) : null });
|
||||
trend.quality.push({ t, v: safeNum(k.quality) != null ? Number(k.quality) : null });
|
||||
trend.oee.push({ t, v: b.oeeCount ? b.oeeSum / b.oeeCount : null });
|
||||
trend.availability.push({ t, v: b.availCount ? b.availSum / b.availCount : null });
|
||||
trend.performance.push({ t, v: b.perfCount ? b.perfSum / b.perfCount : null });
|
||||
trend.quality.push({ t, v: b.qualCount ? b.qualSum / b.qualCount : null });
|
||||
}
|
||||
|
||||
const good = safeNum(k.good);
|
||||
const scrap = safeNum(k.scrap);
|
||||
if (good != null && scrap != null && good + scrap > 0) {
|
||||
trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 });
|
||||
const total = b.goodSum + b.scrapSum;
|
||||
if (total > 0) {
|
||||
trend.scrapRate.push({ t, v: (b.scrapSum / total) * 100 });
|
||||
}
|
||||
}
|
||||
const cycleRowsStart = nowMs();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { RecapTimelineSegment } from "@/lib/recap/types";
|
||||
import type { RecapRangeMode, RecapTimelineSegment } from "@/lib/recap/types";
|
||||
import {
|
||||
computeWidths,
|
||||
formatDuration,
|
||||
@@ -19,6 +19,7 @@ type Props = {
|
||||
locale: string;
|
||||
hasData?: boolean;
|
||||
loading?: boolean;
|
||||
rangeMode?: RecapRangeMode;
|
||||
};
|
||||
|
||||
export default function RecapFullTimeline({
|
||||
@@ -28,6 +29,7 @@ export default function RecapFullTimeline({
|
||||
locale,
|
||||
hasData = false,
|
||||
loading = false,
|
||||
rangeMode,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const startMs = new Date(rangeStart).getTime();
|
||||
@@ -36,10 +38,19 @@ export default function RecapFullTimeline({
|
||||
|
||||
const normalized = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
|
||||
const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
|
||||
const rangeSuffix =
|
||||
rangeMode === "shift"
|
||||
? t("recap.range.shiftCurrent")
|
||||
: rangeMode === "yesterday"
|
||||
? t("recap.range.yesterday")
|
||||
: rangeMode === "custom"
|
||||
? t("recap.range.custom")
|
||||
: t("recap.range.24h");
|
||||
const titleText = `${t("recap.timeline.title")} · ${rangeSuffix}`;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="mb-3 text-sm font-semibold text-white">{t("recap.timeline.title")}</div>
|
||||
<div className="mb-3 text-sm font-semibold text-white">{titleText}</div>
|
||||
{loading ? (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[560px]">
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
|
||||
async function loadTimeline() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=30`,
|
||||
`/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=60`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const json = await res.json().catch(() => null);
|
||||
|
||||
@@ -19,7 +19,7 @@ type Props = {
|
||||
hasData?: boolean;
|
||||
};
|
||||
|
||||
const MIN_SEGMENT_PCT = 1.5;
|
||||
const MIN_SEGMENT_PCT = 0.5;
|
||||
|
||||
export default function RecapMiniTimeline({
|
||||
rangeStart,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { formatRecapProgressPercent } from "@/lib/recap/progressDisplay";
|
||||
import type { RecapSkuRow } from "@/lib/recap/types";
|
||||
|
||||
type Props = {
|
||||
@@ -9,7 +8,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function RecapProductionBySku({ rows }: Props) {
|
||||
const { t, locale } = useI18n();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
@@ -24,25 +23,17 @@ export default function RecapProductionBySku({ rows }: Props) {
|
||||
<tr className="border-b border-white/10 text-left text-xs uppercase tracking-wide text-zinc-400">
|
||||
<th className="py-2 pr-3">{t("recap.production.sku")}</th>
|
||||
<th className="py-2 pr-3">{t("recap.production.good")}</th>
|
||||
<th className="py-2 pr-3">{t("recap.production.scrap")}</th>
|
||||
<th className="py-2 pr-3">{t("recap.production.target")}</th>
|
||||
<th className="py-2">{t("recap.production.progress")}</th>
|
||||
<th className="py-2">{t("recap.production.scrap")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, 10).map((row) => {
|
||||
const progress =
|
||||
row.progressPct == null ? "—" : formatRecapProgressPercent(row.progressPct, locale);
|
||||
return (
|
||||
{rows.slice(0, 10).map((row) => (
|
||||
<tr key={`${row.sku}:${row.machineName}`} className="border-b border-white/5">
|
||||
<td className="py-2 pr-3">{row.sku}</td>
|
||||
<td className="py-2 pr-3">{row.good}</td>
|
||||
<td className={`py-2 pr-3 ${row.scrap > 0 ? "text-red-300" : ""}`}>{row.scrap}</td>
|
||||
<td className="py-2 pr-3">{row.target ?? "--"}</td>
|
||||
<td className="py-2 text-emerald-300">{progress}</td>
|
||||
<td className={`py-2 ${row.scrap > 0 ? "text-red-300" : ""}`}>{row.scrap}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ const COLORS: Record<RecapTimelineSegment["type"], string> = {
|
||||
"slow-cycle": "bg-amber-500 text-black",
|
||||
idle: "bg-zinc-600 text-zinc-300",
|
||||
};
|
||||
const MIN_SEGMENT_PCT = 1.5;
|
||||
const MIN_SEGMENT_PCT = 0.3;
|
||||
const LABEL_MIN_PCT = 5;
|
||||
|
||||
function fmtTime(valueMs: number, locale: string) {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const TIMELINE_COLORS: Record<RecapTimelineSegment["type"], string> = {
|
||||
};
|
||||
|
||||
export const LABEL_MIN_WIDTH_PCT = 5;
|
||||
export const SEGMENT_MIN_WIDTH_PCT = 1.5;
|
||||
export const SEGMENT_MIN_WIDTH_PCT = 0.3;
|
||||
|
||||
export function formatTime(valueMs: number, locale: string) {
|
||||
return new Date(valueMs).toLocaleTimeString(locale, {
|
||||
|
||||
252
fix5.md
Normal file
252
fix5.md
Normal file
@@ -0,0 +1,252 @@
|
||||
Filter context noted: turno actual + ayer both flow through resolveDetailRange → getRecapDataCached → same loadWorkOrderCounterRows lifetime bug. Patch 1 below fixes lifetime numbers across all filter modes. The "Timeline 24h" label is hardcoded i18n key recap.timeline.title regardless of selected range — separate cosmetic bug. Patch list at end.
|
||||
|
||||
PATCH 1 — Window-scoped SKU + production totals
|
||||
File: lib/recap/getRecapData.ts
|
||||
Replaces: lines ~642–678 (the for (const row of machineWorkOrderCounters) loop, the openWorkOrders ensure loop, and the latestTelemetry ensure block)
|
||||
BEFORE:
|
||||
ts for (const row of machineWorkOrderCounters) {
|
||||
const safeGood = Math.max(0, Math.trunc(safeNum(row.goodParts) ?? 0));
|
||||
const safeScrap = Math.max(0, Math.trunc(safeNum(row.scrapParts) ?? 0));
|
||||
const safeCycleCount = Math.max(0, Math.trunc(safeNum(row.cycleCount) ?? 0));
|
||||
const target = safeNum(row.targetQty);
|
||||
|
||||
const skuAgg = ensureAuthoritativeSku(row.sku, target, false);
|
||||
skuAgg.good += safeGood;
|
||||
skuAgg.scrap += safeScrap;
|
||||
|
||||
goodParts += safeGood;
|
||||
scrapParts += safeScrap;
|
||||
authoritativeCycleCount += safeCycleCount;
|
||||
|
||||
const woKey = workOrderKey(row.workOrderId);
|
||||
if (!woKey) continue;
|
||||
const progress = authoritativeWorkOrderProgress.get(woKey) ?? {
|
||||
goodParts: 0,
|
||||
scrapParts: 0,
|
||||
cycleCount: 0,
|
||||
firstTs: null,
|
||||
lastTs: null,
|
||||
};
|
||||
progress.goodParts += safeGood;
|
||||
progress.scrapParts += safeScrap;
|
||||
progress.cycleCount += safeCycleCount;
|
||||
if (!progress.firstTs || row.createdAt < progress.firstTs) progress.firstTs = row.createdAt;
|
||||
if (!progress.lastTs || row.updatedAt > progress.lastTs) progress.lastTs = row.updatedAt;
|
||||
authoritativeWorkOrderProgress.set(woKey, progress);
|
||||
}
|
||||
|
||||
for (const wo of openWorkOrders) {
|
||||
ensureAuthoritativeSku(normalizeToken(wo.sku) || null);
|
||||
}
|
||||
if (latestTelemetry?.sku) {
|
||||
ensureAuthoritativeSku(latestTelemetry.sku);
|
||||
}
|
||||
AFTER:
|
||||
ts // Step 1: WO-level LIFETIME progress map.
|
||||
// Used downstream for completed-WO totals (goodParts/durationHrs) and active-WO progressPct,
|
||||
// both of which intentionally want lifetime, not window-scoped, values.
|
||||
for (const row of machineWorkOrderCounters) {
|
||||
const safeGood = Math.max(0, Math.trunc(safeNum(row.goodParts) ?? 0));
|
||||
const safeScrap = Math.max(0, Math.trunc(safeNum(row.scrapParts) ?? 0));
|
||||
const safeCycleCount = Math.max(0, Math.trunc(safeNum(row.cycleCount) ?? 0));
|
||||
const woKey = workOrderKey(row.workOrderId);
|
||||
if (!woKey) continue;
|
||||
const progress = authoritativeWorkOrderProgress.get(woKey) ?? {
|
||||
goodParts: 0,
|
||||
scrapParts: 0,
|
||||
cycleCount: 0,
|
||||
firstTs: null,
|
||||
lastTs: null,
|
||||
};
|
||||
progress.goodParts += safeGood;
|
||||
progress.scrapParts += safeScrap;
|
||||
progress.cycleCount += safeCycleCount;
|
||||
if (!progress.firstTs || row.createdAt < progress.firstTs) progress.firstTs = row.createdAt;
|
||||
if (!progress.lastTs || row.updatedAt > progress.lastTs) progress.lastTs = row.updatedAt;
|
||||
authoritativeWorkOrderProgress.set(woKey, progress);
|
||||
}
|
||||
|
||||
// Step 2: WINDOW-SCOPED production totals + per-SKU breakdown from in-window cycle deltas.
|
||||
// dedupedCycles is already filtered by ts >= start && ts <= end at the Prisma query level.
|
||||
// Each cycle row contributes its own goodDelta/scrapDelta to the SKU it belongs to.
|
||||
for (const cycle of dedupedCycles) {
|
||||
const skuRaw = normalizeToken(cycle.sku);
|
||||
const g = Math.max(0, Math.trunc(safeNum(cycle.goodDelta) ?? 0));
|
||||
const s = Math.max(0, Math.trunc(safeNum(cycle.scrapDelta) ?? 0));
|
||||
// Count the cycle row toward total cycles regardless of SKU (timing-only cycles still happened).
|
||||
authoritativeCycleCount += 1;
|
||||
if (g === 0 && s === 0) continue; // no production to attribute
|
||||
goodParts += g;
|
||||
scrapParts += s;
|
||||
if (!skuRaw) continue; // production exists but no SKU tag — count totals, skip SKU table row
|
||||
const skuAgg = ensureAuthoritativeSku(skuRaw, null, true);
|
||||
skuAgg.good += g;
|
||||
skuAgg.scrap += s;
|
||||
}
|
||||
What changes for the user:
|
||||
|
||||
BUENAS / SCRAP / SKU table = in-window only
|
||||
Empty SKUs (open WOs that produced nothing in window, latest telemetry SKU) no longer pad the table
|
||||
Completed WO list, active WO progress%, mold change logic = unchanged (still use lifetime via authoritativeWorkOrderProgress)
|
||||
|
||||
|
||||
PATCH 2 — Unify machine-detail timeline range to 24h
|
||||
File: app/(app)/machines/[machineId]/MachineDetailClient.tsx
|
||||
Change 1 — function rename + range: find getMinuteFlooredOneHourRange (around line 365–373):
|
||||
BEFORE:
|
||||
tsfunction getMinuteFlooredOneHourRange() {
|
||||
const endMs = Math.floor(Date.now() / 60000) * 60000;
|
||||
return {
|
||||
startMs: endMs - 60 * 60 * 1000,
|
||||
endMs,
|
||||
};
|
||||
}
|
||||
AFTER:
|
||||
tsfunction getMinuteFlooredDefaultRange() {
|
||||
const endMs = Math.floor(Date.now() / 60000) * 60000;
|
||||
return {
|
||||
startMs: endMs - 24 * 60 * 60 * 1000,
|
||||
endMs,
|
||||
};
|
||||
}
|
||||
Change 2 — call sites: there are two of them in MachineActivityTimeline (line ~388 inside loadTimeline, line ~427 for the fallback). Replace both:
|
||||
BEFORE:
|
||||
tsconst range = getMinuteFlooredOneHourRange();
|
||||
tsconst fallbackRange = getMinuteFlooredOneHourRange();
|
||||
AFTER:
|
||||
tsconst range = getMinuteFlooredDefaultRange();
|
||||
tsconst fallbackRange = getMinuteFlooredDefaultRange();
|
||||
Change 3 — UI label: line ~447:
|
||||
BEFORE:
|
||||
tsx<div className="text-xs text-zinc-400">1h</div>
|
||||
AFTER:
|
||||
tsx<div className="text-xs text-zinc-400">24h</div>
|
||||
After this, machine detail timeline = same backend, same range, same input as recap detail timeline → identical content (modulo cache age).
|
||||
|
||||
|
||||
|
||||
|
||||
PATCH 3 — Dynamic timeline title that reflects the active filter
|
||||
Reuses existing recap.range.* translation keys. No i18n file changes needed.
|
||||
File A: components/recap/RecapFullTimeline.tsx
|
||||
Change 1 — imports + type:
|
||||
BEFORE (lines 1–22):
|
||||
tsx"use client";
|
||||
|
||||
import type { RecapTimelineSegment } from "@/lib/recap/types";
|
||||
import {
|
||||
computeWidths,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
LABEL_MIN_WIDTH_PCT,
|
||||
normalizeTimelineSegments,
|
||||
SEGMENT_MIN_WIDTH_PCT,
|
||||
TIMELINE_COLORS,
|
||||
} from "@/components/recap/timelineRender";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type Props = {
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
segments: RecapTimelineSegment[];
|
||||
locale: string;
|
||||
hasData?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
AFTER:
|
||||
tsx"use client";
|
||||
|
||||
import type { RecapRangeMode, RecapTimelineSegment } from "@/lib/recap/types";
|
||||
import {
|
||||
computeWidths,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
LABEL_MIN_WIDTH_PCT,
|
||||
normalizeTimelineSegments,
|
||||
SEGMENT_MIN_WIDTH_PCT,
|
||||
TIMELINE_COLORS,
|
||||
} from "@/components/recap/timelineRender";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type Props = {
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
segments: RecapTimelineSegment[];
|
||||
locale: string;
|
||||
hasData?: boolean;
|
||||
loading?: boolean;
|
||||
rangeMode?: RecapRangeMode;
|
||||
};
|
||||
Change 2 — destructure prop + render dynamic title:
|
||||
BEFORE (lines 24–42):
|
||||
tsxexport default function RecapFullTimeline({
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
segments,
|
||||
locale,
|
||||
hasData = false,
|
||||
loading = false,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const startMs = new Date(rangeStart).getTime();
|
||||
const endMs = new Date(rangeEnd).getTime();
|
||||
const totalMs = Math.max(1, endMs - startMs);
|
||||
|
||||
const normalized = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
|
||||
const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="mb-3 text-sm font-semibold text-white">{t("recap.timeline.title")}</div>
|
||||
AFTER:
|
||||
tsxexport default function RecapFullTimeline({
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
segments,
|
||||
locale,
|
||||
hasData = false,
|
||||
loading = false,
|
||||
rangeMode,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const startMs = new Date(rangeStart).getTime();
|
||||
const endMs = new Date(rangeEnd).getTime();
|
||||
const totalMs = Math.max(1, endMs - startMs);
|
||||
|
||||
const normalized = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
|
||||
const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
|
||||
|
||||
const rangeSuffix =
|
||||
rangeMode === "shift"
|
||||
? t("recap.range.shiftCurrent")
|
||||
: rangeMode === "yesterday"
|
||||
? t("recap.range.yesterday")
|
||||
: rangeMode === "custom"
|
||||
? t("recap.range.custom")
|
||||
: t("recap.range.24h");
|
||||
const titleText = `${t("recap.timeline.title")} · ${rangeSuffix}`;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="mb-3 text-sm font-semibold text-white">{titleText}</div>
|
||||
File B: app/(app)/recap/[machineId]/RecapDetailClient.tsx
|
||||
BEFORE (around lines 215–222):
|
||||
tsx <RecapFullTimeline
|
||||
rangeStart={timelineStart}
|
||||
rangeEnd={timelineEnd}
|
||||
segments={timelineSegments}
|
||||
hasData={timelineHasData}
|
||||
loading={timelineLoading}
|
||||
locale={locale}
|
||||
/>
|
||||
AFTER:
|
||||
tsx <RecapFullTimeline
|
||||
rangeStart={timelineStart}
|
||||
rangeEnd={timelineEnd}
|
||||
segments={timelineSegments}
|
||||
hasData={timelineHasData}
|
||||
loading={timelineLoading}
|
||||
locale={locale}
|
||||
rangeMode={initialData.range.mode}
|
||||
/>
|
||||
Optional bonus — change i18n value: lib/i18n/es-MX.json and lib/i18n/en.json, find key recap.timeline.title and change value from "Timeline 24h" (or whatever it currently is) to just "Timeline". The dynamic suffix will append the actual range. If you don't strip the "24h" from the value, the title will read "Timeline 24h · Ayer" when ayer is selected — still better than current, but cleaner if stripped.
|
||||
107
fix6.md
Normal file
107
fix6.md
Normal file
@@ -0,0 +1,107 @@
|
||||
Patch 1 — Apply settings + update UI function node (PRIMARY)
|
||||
Node: Apply settings + update UI (function node)
|
||||
Action: Replace the entire normalizeCatalogItems definition.
|
||||
FIND this block (lines ~58–76 of the function):
|
||||
javascriptconst normalizeCatalogItems = (list, fallbackLabelPrefix) => {
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list
|
||||
.map((c, idx) => {
|
||||
const categoryId = String(c.id || c.categoryId || ("cat_" + idx));
|
||||
const categoryLabel = String(c.label || c.categoryLabel || (fallbackLabelPrefix + " " + (idx + 1)));
|
||||
const detailsRaw = Array.isArray(c.children) ? c.children : (Array.isArray(c.details) ? c.details : []);
|
||||
const details = detailsRaw.map((d, jdx) => ({
|
||||
id: String(d.id || d.detailId || (categoryId + "_d" + jdx)),
|
||||
label: String(d.label || d.detailLabel || ("Detalle " + (jdx + 1)))
|
||||
}));
|
||||
return {
|
||||
id: categoryId,
|
||||
label: categoryLabel,
|
||||
children: details
|
||||
};
|
||||
})
|
||||
.filter((c) => c.label && c.children.length > 0);
|
||||
};
|
||||
REPLACE with:
|
||||
javascript// ============================================================
|
||||
// CATALOG SANITIZER
|
||||
// Defense against leaked markdown/spec text being stored as
|
||||
// catalog labels in Control Tower. Rejects entries whose label
|
||||
// looks like documentation/notes rather than a real reason.
|
||||
// Tune MAX_LABEL_LEN if your real labels are longer.
|
||||
// ============================================================
|
||||
const MAX_LABEL_LEN = 40;
|
||||
|
||||
const isCleanLabel = (s) => {
|
||||
if (typeof s !== "string") return false;
|
||||
const t = s.trim();
|
||||
if (!t) return false;
|
||||
if (t.length > MAX_LABEL_LEN) return false; // sentence-length text
|
||||
if (/[\r\n\t]/.test(t)) return false; // multi-line content
|
||||
if (/^[-*#>|`\[\]]/.test(t)) return false; // markdown leaders: - * # > | ` [ ]
|
||||
if (/\*\*|__|```|~~~|###/.test(t)) return false; // markdown bold/code/heading
|
||||
if (/[(\[<{][^)\]>}]*$/.test(t)) return false; // unbalanced opening bracket → truncated
|
||||
if (/=/.test(t)) return false; // code-like assignment (e.g. type=event)
|
||||
return true;
|
||||
};
|
||||
|
||||
const normalizeCatalogItems = (list, fallbackLabelPrefix) => {
|
||||
if (!Array.isArray(list)) return [];
|
||||
|
||||
const dropped = [];
|
||||
|
||||
const cleaned = list
|
||||
.map((c, idx) => {
|
||||
const categoryId = String(c.id || c.categoryId || ("cat_" + idx));
|
||||
const categoryLabel = String(
|
||||
c.label || c.categoryLabel || (fallbackLabelPrefix + " " + (idx + 1))
|
||||
).trim();
|
||||
|
||||
const detailsRaw = Array.isArray(c.children)
|
||||
? c.children
|
||||
: (Array.isArray(c.details) ? c.details : []);
|
||||
|
||||
const details = detailsRaw
|
||||
.map((d, jdx) => ({
|
||||
id: String(d.id || d.detailId || (categoryId + "_d" + jdx)),
|
||||
label: String(d.label || d.detailLabel || ("Detalle " + (jdx + 1))).trim()
|
||||
}))
|
||||
.filter((d) => {
|
||||
if (isCleanLabel(d.label)) return true;
|
||||
dropped.push("detail<" + categoryLabel.slice(0, 20) + ">: " + d.label.slice(0, 50));
|
||||
return false;
|
||||
});
|
||||
|
||||
return { id: categoryId, label: categoryLabel, children: details };
|
||||
})
|
||||
.filter((c) => {
|
||||
if (!isCleanLabel(c.label)) {
|
||||
dropped.push("category: " + c.label.slice(0, 50));
|
||||
return false;
|
||||
}
|
||||
if (c.children.length === 0) {
|
||||
dropped.push("empty: " + c.label.slice(0, 50));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (dropped.length > 0) {
|
||||
node.warn(
|
||||
"[CATALOG SANITIZER " + fallbackLabelPrefix + "] Dropped " +
|
||||
dropped.length + " polluted entries:\n - " +
|
||||
dropped.slice(0, 15).join("\n - ") +
|
||||
(dropped.length > 15 ? "\n ... (+" + (dropped.length - 15) + " more)" : "")
|
||||
);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
Side effects:
|
||||
|
||||
Function signature unchanged → no other code in this node needs to change.
|
||||
The two call sites (incomingCatalog.downtime, incomingCatalog.scrap) work identically.
|
||||
node.warn will fire on every settings sync that has dirty data — this is intentional so you see when CT pushes garbage.
|
||||
A category whose children are all polluted will be dropped (it'd be useless anyway).
|
||||
dropped only logs first 15 to avoid debug-pane spam.
|
||||
|
||||
Risk on legit data: MAX_LABEL_LEN = 40 will reject labels longer than 40 chars. If your real catalog has labels like "Falla mecánica del extrusor principal con sensor" (49), bump this to 60. The shortest known false-negative in your current data ("Tap Acknowledge on anomaly panel", 32 chars) still slips through — see Patch 2 below or upstream cleanup.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -183,7 +183,7 @@
|
||||
"recap.banner.offline": "No signal for {min} min",
|
||||
"recap.banner.ongoingStop": "Machine stopped for {min} min",
|
||||
"recap.banner.stopped": "Machine stopped for {minutes} min",
|
||||
"recap.timeline.title": "24h timeline",
|
||||
"recap.timeline.title": "Timeline",
|
||||
"recap.timeline.noData": "No timeline data",
|
||||
"recap.timeline.type.production": "Production",
|
||||
"recap.timeline.type.moldChange": "Mold change",
|
||||
@@ -255,6 +255,9 @@
|
||||
"machine.detail.bucket.unknown": "Unknown",
|
||||
"machine.detail.activity.title": "Machine Activity Timeline",
|
||||
"machine.detail.activity.subtitle": "Real-time analysis of production cycles",
|
||||
"machine.detail.activity.windowBadge": "1h",
|
||||
"machine.detail.activity.windowModalTitle": "Timeline window",
|
||||
"machine.detail.activity.windowModalBody": "This timeline always shows the last 1 hour of machine activity.",
|
||||
"machine.detail.activity.noData": "No timeline data yet.",
|
||||
"machine.detail.tooltip.cycle": "Cycle: {label}",
|
||||
"machine.detail.tooltip.duration": "Duration",
|
||||
@@ -621,5 +624,12 @@
|
||||
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
|
||||
"settings.modules.screenless.title": "Screenless mode",
|
||||
"settings.modules.screenless.helper": "Hide the Downtime module from navigation (for plants without Node-RED reason capture).",
|
||||
"settings.modules.note": "This setting is org-wide."
|
||||
"settings.modules.note": "This setting is org-wide.",
|
||||
"overview.attention.offline": "Offline — no heartbeat",
|
||||
"overview.attention.stopped": "Currently stopped",
|
||||
"overview.attention.oeeCritical": "OEE critical: {value}%",
|
||||
"overview.attention.oeeLow": "OEE low: {value}%",
|
||||
"overview.attention.scrapHigh": "Scrap rate high: {value}%",
|
||||
"overview.attention.scrapMod": "Scrap rate elevated: {value}%",
|
||||
"overview.attention.availLow": "Availability low: {value}%"
|
||||
}
|
||||
|
||||
@@ -104,6 +104,13 @@
|
||||
"overview.event.macrostop": "macroparo",
|
||||
"overview.event.microstop": "microparo",
|
||||
"overview.event.slow-cycle": "ciclo lento",
|
||||
"overview.attention.offline": "Sin señal",
|
||||
"overview.attention.stopped": "Detenida ahora",
|
||||
"overview.attention.oeeCritical": "OEE crítica: {value}%",
|
||||
"overview.attention.oeeLow": "OEE baja: {value}%",
|
||||
"overview.attention.scrapHigh": "Scrap alto: {value}%",
|
||||
"overview.attention.scrapMod": "Scrap elevado: {value}%",
|
||||
"overview.attention.availLow": "Disponibilidad baja: {value}%",
|
||||
"overview.status.offline": "FUERA DE LÍNEA",
|
||||
"overview.status.online": "EN LÍNEA",
|
||||
"overview.recap.title": "Resumen diario de turno",
|
||||
@@ -183,7 +190,7 @@
|
||||
"recap.banner.offline": "Sin señal hace {min} min",
|
||||
"recap.banner.ongoingStop": "Máquina detenida hace {min} min",
|
||||
"recap.banner.stopped": "Máquina detenida hace {minutes} min",
|
||||
"recap.timeline.title": "Timeline 24h",
|
||||
"recap.timeline.title": "Timeline",
|
||||
"recap.timeline.noData": "Sin datos de línea de tiempo",
|
||||
"recap.timeline.type.production": "Producción",
|
||||
"recap.timeline.type.moldChange": "Cambio de molde",
|
||||
@@ -255,6 +262,9 @@
|
||||
"machine.detail.bucket.unknown": "Desconocido",
|
||||
"machine.detail.activity.title": "Línea de tiempo de actividad",
|
||||
"machine.detail.activity.subtitle": "Análisis en tiempo real de ciclos de producción",
|
||||
"machine.detail.activity.windowBadge": "1h",
|
||||
"machine.detail.activity.windowModalTitle": "Ventana de timeline",
|
||||
"machine.detail.activity.windowModalBody": "Este timeline siempre muestra la última 1 hora de actividad de la máquina.",
|
||||
"machine.detail.activity.noData": "Sin datos de línea de tiempo.",
|
||||
"machine.detail.tooltip.cycle": "Ciclo: {label}",
|
||||
"machine.detail.tooltip.duration": "Duración",
|
||||
|
||||
@@ -230,45 +230,6 @@ function moldStartMs(data: unknown, fallbackTs: Date) {
|
||||
return Math.trunc(safeNum(inner.start_ms) ?? safeNum(inner.startMs) ?? fallbackTs.getTime());
|
||||
}
|
||||
|
||||
type WorkOrderCounterRow = {
|
||||
machineId: string;
|
||||
workOrderId: string;
|
||||
sku: string | null;
|
||||
targetQty: number | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
goodParts: number;
|
||||
scrapParts: number;
|
||||
cycleCount: number;
|
||||
};
|
||||
|
||||
async function loadWorkOrderCounterRows(params: {
|
||||
orgId: string;
|
||||
machineIds: string[];
|
||||
}) {
|
||||
if (!params.machineIds.length) return [] as WorkOrderCounterRow[];
|
||||
|
||||
return prisma.machineWorkOrder.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: { in: params.machineIds },
|
||||
},
|
||||
select: {
|
||||
machineId: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
targetQty: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
goodParts: true,
|
||||
scrapParts: true,
|
||||
cycleCount: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function parseRecapQuery(input: {
|
||||
machineId?: string | null;
|
||||
start?: string | null;
|
||||
@@ -306,7 +267,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
|
||||
const machineIds = machines.map((m) => m.id);
|
||||
const moldStartLookback = new Date(params.end.getTime() - MOLD_LOOKBACK_MS);
|
||||
const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, workOrderCounterRowsRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] =
|
||||
const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] =
|
||||
await Promise.all([
|
||||
prisma.orgSettings.findUnique({
|
||||
where: { orgId: params.orgId },
|
||||
@@ -401,10 +362,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
loadWorkOrderCounterRows({
|
||||
orgId: params.orgId,
|
||||
machineIds,
|
||||
}),
|
||||
prisma.machineHeartbeat.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
@@ -473,7 +430,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
const eventsByMachine = new Map<string, typeof events>();
|
||||
const reasonsByMachine = new Map<string, typeof reasons>();
|
||||
const workOrdersByMachine = new Map<string, typeof workOrdersRaw>();
|
||||
const workOrderCountersByMachine = new Map<string, WorkOrderCounterRow[]>();
|
||||
const hbRangeByMachine = new Map<string, typeof hbRange>();
|
||||
const hbLatestByMachine = new Map(hbLatestRaw.map((row) => [row.machineId, row]));
|
||||
const moldEventsByMachine = new Map<string, typeof moldEventsRaw>();
|
||||
@@ -508,12 +464,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
workOrdersByMachine.set(row.machineId, list);
|
||||
}
|
||||
|
||||
for (const row of workOrderCounterRowsRaw ?? []) {
|
||||
const list = workOrderCountersByMachine.get(row.machineId) ?? [];
|
||||
list.push(row);
|
||||
workOrderCountersByMachine.set(row.machineId, list);
|
||||
}
|
||||
|
||||
for (const row of hbRange) {
|
||||
const list = hbRangeByMachine.get(row.machineId) ?? [];
|
||||
list.push(row);
|
||||
@@ -532,7 +482,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
const machineEvents = eventsByMachine.get(machine.id) ?? [];
|
||||
const machineReasons = reasonsByMachine.get(machine.id) ?? [];
|
||||
const machineWorkOrders = workOrdersByMachine.get(machine.id) ?? [];
|
||||
const machineWorkOrderCounters = workOrderCountersByMachine.get(machine.id) ?? [];
|
||||
const machineHbRange = hbRangeByMachine.get(machine.id) ?? [];
|
||||
const latestHb = hbLatestByMachine.get(machine.id) ?? null;
|
||||
const machineMoldEvents = moldEventsByMachine.get(machine.id) ?? [];
|
||||
@@ -599,7 +548,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
const openWorkOrders = machineWorkOrdersSorted.filter(
|
||||
(wo) => String(wo.status).toUpperCase() !== "COMPLETED"
|
||||
);
|
||||
const authoritativeWorkOrderProgress = new Map<
|
||||
const rangeWorkOrderProgress = new Map<
|
||||
string,
|
||||
{ goodParts: number; scrapParts: number; cycleCount: number; firstTs: Date | null; lastTs: Date | null }
|
||||
>();
|
||||
@@ -639,58 +588,45 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
return created;
|
||||
};
|
||||
|
||||
for (const row of machineWorkOrderCounters) {
|
||||
const safeGood = Math.max(0, Math.trunc(safeNum(row.goodParts) ?? 0));
|
||||
const safeScrap = Math.max(0, Math.trunc(safeNum(row.scrapParts) ?? 0));
|
||||
const safeCycleCount = Math.max(0, Math.trunc(safeNum(row.cycleCount) ?? 0));
|
||||
const target = safeNum(row.targetQty);
|
||||
|
||||
const skuAgg = ensureAuthoritativeSku(row.sku, target, false);
|
||||
skuAgg.good += safeGood;
|
||||
skuAgg.scrap += safeScrap;
|
||||
|
||||
goodParts += safeGood;
|
||||
scrapParts += safeScrap;
|
||||
authoritativeCycleCount += safeCycleCount;
|
||||
|
||||
const woKey = workOrderKey(row.workOrderId);
|
||||
if (!woKey) continue;
|
||||
const progress = authoritativeWorkOrderProgress.get(woKey) ?? {
|
||||
for (const cycle of dedupedCycles) {
|
||||
const skuRaw = normalizeToken(cycle.sku);
|
||||
const g = Math.max(0, Math.trunc(safeNum(cycle.goodDelta) ?? 0));
|
||||
const s = Math.max(0, Math.trunc(safeNum(cycle.scrapDelta) ?? 0));
|
||||
const woKey = workOrderKey(cycle.workOrderId);
|
||||
authoritativeCycleCount += 1;
|
||||
if (g === 0 && s === 0) continue;
|
||||
goodParts += g;
|
||||
scrapParts += s;
|
||||
if (woKey) {
|
||||
const progress = rangeWorkOrderProgress.get(woKey) ?? {
|
||||
goodParts: 0,
|
||||
scrapParts: 0,
|
||||
cycleCount: 0,
|
||||
firstTs: null,
|
||||
lastTs: null,
|
||||
};
|
||||
progress.goodParts += safeGood;
|
||||
progress.scrapParts += safeScrap;
|
||||
progress.cycleCount += safeCycleCount;
|
||||
if (!progress.firstTs || row.createdAt < progress.firstTs) progress.firstTs = row.createdAt;
|
||||
if (!progress.lastTs || row.updatedAt > progress.lastTs) progress.lastTs = row.updatedAt;
|
||||
authoritativeWorkOrderProgress.set(woKey, progress);
|
||||
progress.goodParts += g;
|
||||
progress.scrapParts += s;
|
||||
progress.cycleCount += 1;
|
||||
if (!progress.firstTs || cycle.ts < progress.firstTs) progress.firstTs = cycle.ts;
|
||||
if (!progress.lastTs || cycle.ts > progress.lastTs) progress.lastTs = cycle.ts;
|
||||
rangeWorkOrderProgress.set(woKey, progress);
|
||||
}
|
||||
|
||||
for (const wo of openWorkOrders) {
|
||||
ensureAuthoritativeSku(normalizeToken(wo.sku) || null);
|
||||
}
|
||||
if (latestTelemetry?.sku) {
|
||||
ensureAuthoritativeSku(latestTelemetry.sku);
|
||||
if (!skuRaw) continue;
|
||||
const skuAgg = ensureAuthoritativeSku(skuRaw, null, true);
|
||||
skuAgg.good += g;
|
||||
skuAgg.scrap += s;
|
||||
}
|
||||
|
||||
const bySku = [...authoritativeSkuMap.values()]
|
||||
.map((row) => {
|
||||
const target = row.target ?? targetBySku.get(skuKey(row.sku))?.target ?? null;
|
||||
const produced = row.good + row.scrap;
|
||||
const progressPct = target && target > 0 ? round2((produced / target) * 100) : null;
|
||||
return {
|
||||
.map((row) => ({
|
||||
machineName: row.machineName,
|
||||
sku: row.sku,
|
||||
good: row.good,
|
||||
scrap: row.scrap,
|
||||
target,
|
||||
progressPct,
|
||||
};
|
||||
})
|
||||
target: null as number | null,
|
||||
progressPct: null as number | null,
|
||||
}))
|
||||
.sort((a, b) => b.good - a.good);
|
||||
|
||||
const sortedKpis = [...dedupedKpis].sort((a, b) => a.ts.getTime() - b.ts.getTime());
|
||||
@@ -762,7 +698,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
.filter((wo) => String(wo.status).toUpperCase() === "COMPLETED")
|
||||
.filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end)
|
||||
.map((wo) => {
|
||||
const progress = authoritativeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? {
|
||||
const progress = rangeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? {
|
||||
goodParts: 0,
|
||||
scrapParts: 0,
|
||||
cycleCount: 0,
|
||||
@@ -801,19 +737,15 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
let activeProgressPct: number | null = null;
|
||||
let activeStartedAt: string | null = null;
|
||||
if (activeWorkOrderId) {
|
||||
const authoritativeProgress = activeWorkOrderKey
|
||||
? authoritativeWorkOrderProgress.get(activeWorkOrderKey) ?? null
|
||||
: null;
|
||||
const producedForProgress = authoritativeProgress
|
||||
? authoritativeProgress.goodParts + authoritativeProgress.scrapParts
|
||||
const rangeProgress = activeWorkOrderKey ? rangeWorkOrderProgress.get(activeWorkOrderKey) ?? null : null;
|
||||
const producedForProgress = rangeProgress
|
||||
? rangeProgress.goodParts + rangeProgress.scrapParts
|
||||
: 0;
|
||||
const targetQty = safeNum(activeTargetSource?.targetQty);
|
||||
if (targetQty && targetQty > 0) {
|
||||
activeProgressPct = round2((producedForProgress / targetQty) * 100);
|
||||
}
|
||||
activeStartedAt = toIso(
|
||||
authoritativeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null
|
||||
);
|
||||
activeStartedAt = toIso(rangeProgress?.firstTs ?? latestTelemetry?.ts ?? null);
|
||||
}
|
||||
|
||||
const firstProductionMsAfterMoldStart = (startMs: number) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
* Client-safe recap thresholds. Kept in sync with OFFLINE logic in lib/recap/redesign.ts.
|
||||
*/
|
||||
export const RECAP_HEARTBEAT_STALE_MS = 10 * 60 * 1000;
|
||||
export const RECAP_HEARTBEAT_STALE_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -28,6 +28,7 @@ type DetailRangeInput = {
|
||||
|
||||
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> = {
|
||||
@@ -213,7 +214,10 @@ async function loadTimelineRowsForMachines(params: {
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: { in: params.machineIds },
|
||||
ts: { gte: params.start, lte: params.end },
|
||||
ts: {
|
||||
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||
lte: params.end,
|
||||
},
|
||||
},
|
||||
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||
select: {
|
||||
@@ -338,7 +342,7 @@ async function computeRecapSummary(params: { orgId: string; hours: number }) {
|
||||
segments,
|
||||
rangeStart: start,
|
||||
rangeEnd: end,
|
||||
maxSegments: 30,
|
||||
maxSegments: 60,
|
||||
});
|
||||
|
||||
return toSummaryMachine({
|
||||
@@ -443,21 +447,25 @@ async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
|
||||
minutes: startMin % 60,
|
||||
timeZone,
|
||||
});
|
||||
const end = zonedToUtcDate({
|
||||
const shiftEndUtc = zonedToUtcDate({
|
||||
...endDate,
|
||||
hours: Math.floor(endMin / 60),
|
||||
minutes: endMin % 60,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
if (end <= start) continue;
|
||||
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,
|
||||
},
|
||||
range: { start, end },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -752,6 +752,7 @@ export function compressTimelineSegments(input: {
|
||||
if (bucketEnd <= bucketStart) continue;
|
||||
|
||||
let winner: RecapTimelineSegment | null = null;
|
||||
let winnerPriority = -1;
|
||||
let winnerOverlap = -1;
|
||||
|
||||
for (const segment of normalized) {
|
||||
@@ -760,11 +761,12 @@ export function compressTimelineSegments(input: {
|
||||
if (overlapEnd <= overlapStart) continue;
|
||||
|
||||
const overlap = overlapEnd - overlapStart;
|
||||
const priorityBonus = segmentPriority(segment.type) / 1000;
|
||||
const score = overlap + priorityBonus;
|
||||
if (score > winnerOverlap) {
|
||||
const priority = segmentPriority(segment.type);
|
||||
|
||||
if (priority > winnerPriority || (priority === winnerPriority && overlap > winnerOverlap)) {
|
||||
winner = segment;
|
||||
winnerOverlap = score;
|
||||
winnerPriority = priority;
|
||||
winnerOverlap = overlap;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { RecapTimelineResponse } from "@/lib/recap/types";
|
||||
|
||||
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
|
||||
const DEFAULT_RANGE_MS = 24 * 60 * 60 * 1000;
|
||||
const MIN_RANGE_MS = 60 * 1000;
|
||||
const MAX_RANGE_MS = 72 * 60 * 60 * 1000;
|
||||
@@ -94,7 +95,10 @@ export async function getRecapTimelineForMachine(params: {
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
ts: { gte: params.start, lte: params.end },
|
||||
ts: {
|
||||
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||
lte: params.end,
|
||||
},
|
||||
},
|
||||
orderBy: { ts: "asc" },
|
||||
select: {
|
||||
@@ -126,7 +130,10 @@ export async function getRecapTimelineForMachine(params: {
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
ts: { gte: params.start, lte: params.end },
|
||||
ts: {
|
||||
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||
lte: params.end,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.machineEvent.count({
|
||||
|
||||
Reference in New Issue
Block a user