almost_done
This commit is contained in:
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, type KeyboardEvent } from "react";
|
import { useEffect, useState, type KeyboardEvent } from "react";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
|
|
||||||
type MachineRow = {
|
type MachineRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,6 +21,7 @@ type MachineRow = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
const LIVE_REFRESH_MS = 5000;
|
const LIVE_REFRESH_MS = 5000;
|
||||||
|
const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||||
|
|
||||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||||
if (!ts) return fallback;
|
if (!ts) return fallback;
|
||||||
@@ -31,7 +33,7 @@ function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
|||||||
|
|
||||||
function isOffline(ts?: string) {
|
function isOffline(ts?: string) {
|
||||||
if (!ts) return true;
|
if (!ts) return true;
|
||||||
return Date.now() - new Date(ts).getTime() > 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS)
|
return Date.now() - new Date(ts).getTime() > OFFLINE_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatus(status?: string) {
|
function normalizeStatus(status?: string) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||||
import type { RecapTimelineResponse, RecapTimelineSegment } from "@/lib/recap/types";
|
import type { RecapTimelineResponse, RecapTimelineSegment } from "@/lib/recap/types";
|
||||||
|
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
import {
|
import {
|
||||||
computeWidths,
|
computeWidths,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
@@ -375,6 +376,7 @@ function getMinuteFlooredOneHourRange(referenceMs = Date.now()) {
|
|||||||
function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) {
|
function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) {
|
||||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||||
const [timelineLoading, setTimelineLoading] = useState(true);
|
const [timelineLoading, setTimelineLoading] = useState(true);
|
||||||
|
const [showWindowInfo, setShowWindowInfo] = useState(false);
|
||||||
const timelineHashRef = useRef("");
|
const timelineHashRef = useRef("");
|
||||||
|
|
||||||
useEffect(() => {
|
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="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 className="mt-1 text-xs text-zinc-400">{t("machine.detail.activity.subtitle")}</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
|
<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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -800,7 +833,7 @@ export default function MachineDetailClient() {
|
|||||||
|
|
||||||
function isOffline(ts?: string) {
|
function isOffline(ts?: string) {
|
||||||
if (!ts) return true;
|
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) {
|
function normalizeStatus(status?: string) {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
import type { EventRow, Heartbeat, MachineRow } from "./types";
|
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 MAX_EVENT_MACHINES = 6;
|
||||||
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
|
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
|
||||||
|
|
||||||
@@ -199,20 +200,65 @@ export default function OverviewClient({
|
|||||||
.map((m) => {
|
.map((m) => {
|
||||||
const hb = m.latestHeartbeat;
|
const hb = m.latestHeartbeat;
|
||||||
const offline = isOffline(heartbeatTime(hb));
|
const offline = isOffline(heartbeatTime(hb));
|
||||||
|
const status = normalizeStatus(hb?.status);
|
||||||
const k = m.latestKpi;
|
const k = m.latestKpi;
|
||||||
const oee = k?.oee ?? null;
|
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;
|
let score = 0;
|
||||||
if (offline) score += 100;
|
|
||||||
if (oee != null && oee < 75) score += 50;
|
// Trigger 1: offline (highest priority — can't tell what's wrong)
|
||||||
if (oee != null && oee < 85) score += 25;
|
if (offline) {
|
||||||
return { machine: m, offline, oee, score };
|
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)
|
.filter((x) => x.score > 0)
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.slice(0, 6);
|
.slice(0, 6);
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}, [machines]);
|
}, [machines, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6">
|
<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="text-sm text-zinc-400">{t("overview.noUrgent")}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{attention.map(({ machine, offline, oee }) => (
|
{attention.map(({ machine, offline, oee, reasons }) => (
|
||||||
<div key={machine.id} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
<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="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold text-white">{machine.name}</div>
|
<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"))}
|
{secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<span
|
||||||
className={`rounded-full px-2 py-0.5 ${
|
className={`rounded-full px-2 py-0.5 ${
|
||||||
offline ? "bg-white/10 text-zinc-300" : "bg-emerald-500/15 text-emerald-300"
|
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")}
|
{offline ? t("overview.status.offline") : t("overview.status.online")}
|
||||||
</span>
|
</span>
|
||||||
{oee != null && (
|
{oee != null && !offline && (
|
||||||
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
||||||
OEE {fmtPct(oee)}
|
OEE {fmtPct(oee)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
|||||||
hasData={timelineHasData}
|
hasData={timelineHasData}
|
||||||
loading={timelineLoading}
|
loading={timelineLoading}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
rangeMode={initialData.range.mode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -298,25 +298,68 @@ export async function GET(req: NextRequest) {
|
|||||||
scrapRate: [],
|
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) {
|
for (const k of kpiRows) {
|
||||||
const t = k.ts.toISOString();
|
const t = k.ts.toISOString();
|
||||||
if (!isProductionSnapshot(k.trackingEnabled, k.productionStarted)) {
|
let b = tsBuckets.get(t);
|
||||||
// Preserve timeline gaps across non-production windows for OEE-family charting.
|
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.oee.push({ t, v: null });
|
||||||
trend.availability.push({ t, v: null });
|
trend.availability.push({ t, v: null });
|
||||||
trend.performance.push({ t, v: null });
|
trend.performance.push({ t, v: null });
|
||||||
trend.quality.push({ t, v: null });
|
trend.quality.push({ t, v: null });
|
||||||
} else {
|
} else {
|
||||||
trend.oee.push({ t, v: safeNum(k.oee) != null ? Number(k.oee) : null });
|
trend.oee.push({ t, v: b.oeeCount ? b.oeeSum / b.oeeCount : null });
|
||||||
trend.availability.push({ t, v: safeNum(k.availability) != null ? Number(k.availability) : null });
|
trend.availability.push({ t, v: b.availCount ? b.availSum / b.availCount : null });
|
||||||
trend.performance.push({ t, v: safeNum(k.performance) != null ? Number(k.performance) : null });
|
trend.performance.push({ t, v: b.perfCount ? b.perfSum / b.perfCount : null });
|
||||||
trend.quality.push({ t, v: safeNum(k.quality) != null ? Number(k.quality) : null });
|
trend.quality.push({ t, v: b.qualCount ? b.qualSum / b.qualCount : null });
|
||||||
}
|
}
|
||||||
|
const total = b.goodSum + b.scrapSum;
|
||||||
const good = safeNum(k.good);
|
if (total > 0) {
|
||||||
const scrap = safeNum(k.scrap);
|
trend.scrapRate.push({ t, v: (b.scrapSum / total) * 100 });
|
||||||
if (good != null && scrap != null && good + scrap > 0) {
|
|
||||||
trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cycleRowsStart = nowMs();
|
const cycleRowsStart = nowMs();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { RecapTimelineSegment } from "@/lib/recap/types";
|
import type { RecapRangeMode, RecapTimelineSegment } from "@/lib/recap/types";
|
||||||
import {
|
import {
|
||||||
computeWidths,
|
computeWidths,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
@@ -19,6 +19,7 @@ type Props = {
|
|||||||
locale: string;
|
locale: string;
|
||||||
hasData?: boolean;
|
hasData?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
rangeMode?: RecapRangeMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RecapFullTimeline({
|
export default function RecapFullTimeline({
|
||||||
@@ -28,6 +29,7 @@ export default function RecapFullTimeline({
|
|||||||
locale,
|
locale,
|
||||||
hasData = false,
|
hasData = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
rangeMode,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const startMs = new Date(rangeStart).getTime();
|
const startMs = new Date(rangeStart).getTime();
|
||||||
@@ -36,10 +38,19 @@ export default function RecapFullTimeline({
|
|||||||
|
|
||||||
const normalized = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
|
const normalized = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
|
||||||
const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
|
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 (
|
return (
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||||
<div className="mb-3 text-sm font-semibold text-white">{t("recap.timeline.title")}</div>
|
<div className="mb-3 text-sm font-semibold text-white">{titleText}</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className="min-w-[560px]">
|
<div className="min-w-[560px]">
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
|
|||||||
async function loadTimeline() {
|
async function loadTimeline() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
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" }
|
{ cache: "no-store" }
|
||||||
);
|
);
|
||||||
const json = await res.json().catch(() => null);
|
const json = await res.json().catch(() => null);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type Props = {
|
|||||||
hasData?: boolean;
|
hasData?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MIN_SEGMENT_PCT = 1.5;
|
const MIN_SEGMENT_PCT = 0.5;
|
||||||
|
|
||||||
export default function RecapMiniTimeline({
|
export default function RecapMiniTimeline({
|
||||||
rangeStart,
|
rangeStart,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
import { formatRecapProgressPercent } from "@/lib/recap/progressDisplay";
|
|
||||||
import type { RecapSkuRow } from "@/lib/recap/types";
|
import type { RecapSkuRow } from "@/lib/recap/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -9,7 +8,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function RecapProductionBySku({ rows }: Props) {
|
export default function RecapProductionBySku({ rows }: Props) {
|
||||||
const { t, locale } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||||
@@ -24,29 +23,21 @@ export default function RecapProductionBySku({ rows }: Props) {
|
|||||||
<tr className="border-b border-white/10 text-left text-xs uppercase tracking-wide text-zinc-400">
|
<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.sku")}</th>
|
||||||
<th className="py-2 pr-3">{t("recap.production.good")}</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">{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>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.slice(0, 10).map((row) => {
|
{rows.slice(0, 10).map((row) => (
|
||||||
const progress =
|
<tr key={`${row.sku}:${row.machineName}`} className="border-b border-white/5">
|
||||||
row.progressPct == null ? "—" : formatRecapProgressPercent(row.progressPct, locale);
|
<td className="py-2 pr-3">{row.sku}</td>
|
||||||
return (
|
<td className="py-2 pr-3">{row.good}</td>
|
||||||
<tr key={`${row.sku}:${row.machineName}`} className="border-b border-white/5">
|
<td className={`py-2 ${row.scrap > 0 ? "text-red-300" : ""}`}>{row.scrap}</td>
|
||||||
<td className="py-2 pr-3">{row.sku}</td>
|
</tr>
|
||||||
<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>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ const COLORS: Record<RecapTimelineSegment["type"], string> = {
|
|||||||
"slow-cycle": "bg-amber-500 text-black",
|
"slow-cycle": "bg-amber-500 text-black",
|
||||||
idle: "bg-zinc-600 text-zinc-300",
|
idle: "bg-zinc-600 text-zinc-300",
|
||||||
};
|
};
|
||||||
const MIN_SEGMENT_PCT = 1.5;
|
const MIN_SEGMENT_PCT = 0.3;
|
||||||
const LABEL_MIN_PCT = 5;
|
const LABEL_MIN_PCT = 5;
|
||||||
|
|
||||||
function fmtTime(valueMs: number, locale: string) {
|
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 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) {
|
export function formatTime(valueMs: number, locale: string) {
|
||||||
return new Date(valueMs).toLocaleTimeString(locale, {
|
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.offline": "No signal for {min} min",
|
||||||
"recap.banner.ongoingStop": "Machine stopped for {min} min",
|
"recap.banner.ongoingStop": "Machine stopped for {min} min",
|
||||||
"recap.banner.stopped": "Machine stopped for {minutes} 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.noData": "No timeline data",
|
||||||
"recap.timeline.type.production": "Production",
|
"recap.timeline.type.production": "Production",
|
||||||
"recap.timeline.type.moldChange": "Mold change",
|
"recap.timeline.type.moldChange": "Mold change",
|
||||||
@@ -255,6 +255,9 @@
|
|||||||
"machine.detail.bucket.unknown": "Unknown",
|
"machine.detail.bucket.unknown": "Unknown",
|
||||||
"machine.detail.activity.title": "Machine Activity Timeline",
|
"machine.detail.activity.title": "Machine Activity Timeline",
|
||||||
"machine.detail.activity.subtitle": "Real-time analysis of production cycles",
|
"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.activity.noData": "No timeline data yet.",
|
||||||
"machine.detail.tooltip.cycle": "Cycle: {label}",
|
"machine.detail.tooltip.cycle": "Cycle: {label}",
|
||||||
"machine.detail.tooltip.duration": "Duration",
|
"machine.detail.tooltip.duration": "Duration",
|
||||||
@@ -621,5 +624,12 @@
|
|||||||
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
|
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
|
||||||
"settings.modules.screenless.title": "Screenless mode",
|
"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.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.macrostop": "macroparo",
|
||||||
"overview.event.microstop": "microparo",
|
"overview.event.microstop": "microparo",
|
||||||
"overview.event.slow-cycle": "ciclo lento",
|
"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.offline": "FUERA DE LÍNEA",
|
||||||
"overview.status.online": "EN LÍNEA",
|
"overview.status.online": "EN LÍNEA",
|
||||||
"overview.recap.title": "Resumen diario de turno",
|
"overview.recap.title": "Resumen diario de turno",
|
||||||
@@ -183,7 +190,7 @@
|
|||||||
"recap.banner.offline": "Sin señal hace {min} min",
|
"recap.banner.offline": "Sin señal hace {min} min",
|
||||||
"recap.banner.ongoingStop": "Máquina detenida hace {min} min",
|
"recap.banner.ongoingStop": "Máquina detenida hace {min} min",
|
||||||
"recap.banner.stopped": "Máquina detenida hace {minutes} 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.noData": "Sin datos de línea de tiempo",
|
||||||
"recap.timeline.type.production": "Producción",
|
"recap.timeline.type.production": "Producción",
|
||||||
"recap.timeline.type.moldChange": "Cambio de molde",
|
"recap.timeline.type.moldChange": "Cambio de molde",
|
||||||
@@ -255,6 +262,9 @@
|
|||||||
"machine.detail.bucket.unknown": "Desconocido",
|
"machine.detail.bucket.unknown": "Desconocido",
|
||||||
"machine.detail.activity.title": "Línea de tiempo de actividad",
|
"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.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.activity.noData": "Sin datos de línea de tiempo.",
|
||||||
"machine.detail.tooltip.cycle": "Ciclo: {label}",
|
"machine.detail.tooltip.cycle": "Ciclo: {label}",
|
||||||
"machine.detail.tooltip.duration": "Duración",
|
"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());
|
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: {
|
export function parseRecapQuery(input: {
|
||||||
machineId?: string | null;
|
machineId?: string | null;
|
||||||
start?: 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 machineIds = machines.map((m) => m.id);
|
||||||
const moldStartLookback = new Date(params.end.getTime() - MOLD_LOOKBACK_MS);
|
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([
|
await Promise.all([
|
||||||
prisma.orgSettings.findUnique({
|
prisma.orgSettings.findUnique({
|
||||||
where: { orgId: params.orgId },
|
where: { orgId: params.orgId },
|
||||||
@@ -401,10 +362,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
loadWorkOrderCounterRows({
|
|
||||||
orgId: params.orgId,
|
|
||||||
machineIds,
|
|
||||||
}),
|
|
||||||
prisma.machineHeartbeat.findMany({
|
prisma.machineHeartbeat.findMany({
|
||||||
where: {
|
where: {
|
||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
@@ -473,7 +430,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
const eventsByMachine = new Map<string, typeof events>();
|
const eventsByMachine = new Map<string, typeof events>();
|
||||||
const reasonsByMachine = new Map<string, typeof reasons>();
|
const reasonsByMachine = new Map<string, typeof reasons>();
|
||||||
const workOrdersByMachine = new Map<string, typeof workOrdersRaw>();
|
const workOrdersByMachine = new Map<string, typeof workOrdersRaw>();
|
||||||
const workOrderCountersByMachine = new Map<string, WorkOrderCounterRow[]>();
|
|
||||||
const hbRangeByMachine = new Map<string, typeof hbRange>();
|
const hbRangeByMachine = new Map<string, typeof hbRange>();
|
||||||
const hbLatestByMachine = new Map(hbLatestRaw.map((row) => [row.machineId, row]));
|
const hbLatestByMachine = new Map(hbLatestRaw.map((row) => [row.machineId, row]));
|
||||||
const moldEventsByMachine = new Map<string, typeof moldEventsRaw>();
|
const moldEventsByMachine = new Map<string, typeof moldEventsRaw>();
|
||||||
@@ -508,12 +464,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
workOrdersByMachine.set(row.machineId, list);
|
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) {
|
for (const row of hbRange) {
|
||||||
const list = hbRangeByMachine.get(row.machineId) ?? [];
|
const list = hbRangeByMachine.get(row.machineId) ?? [];
|
||||||
list.push(row);
|
list.push(row);
|
||||||
@@ -532,7 +482,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
const machineEvents = eventsByMachine.get(machine.id) ?? [];
|
const machineEvents = eventsByMachine.get(machine.id) ?? [];
|
||||||
const machineReasons = reasonsByMachine.get(machine.id) ?? [];
|
const machineReasons = reasonsByMachine.get(machine.id) ?? [];
|
||||||
const machineWorkOrders = workOrdersByMachine.get(machine.id) ?? [];
|
const machineWorkOrders = workOrdersByMachine.get(machine.id) ?? [];
|
||||||
const machineWorkOrderCounters = workOrderCountersByMachine.get(machine.id) ?? [];
|
|
||||||
const machineHbRange = hbRangeByMachine.get(machine.id) ?? [];
|
const machineHbRange = hbRangeByMachine.get(machine.id) ?? [];
|
||||||
const latestHb = hbLatestByMachine.get(machine.id) ?? null;
|
const latestHb = hbLatestByMachine.get(machine.id) ?? null;
|
||||||
const machineMoldEvents = moldEventsByMachine.get(machine.id) ?? [];
|
const machineMoldEvents = moldEventsByMachine.get(machine.id) ?? [];
|
||||||
@@ -599,7 +548,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
const openWorkOrders = machineWorkOrdersSorted.filter(
|
const openWorkOrders = machineWorkOrdersSorted.filter(
|
||||||
(wo) => String(wo.status).toUpperCase() !== "COMPLETED"
|
(wo) => String(wo.status).toUpperCase() !== "COMPLETED"
|
||||||
);
|
);
|
||||||
const authoritativeWorkOrderProgress = new Map<
|
const rangeWorkOrderProgress = new Map<
|
||||||
string,
|
string,
|
||||||
{ goodParts: number; scrapParts: number; cycleCount: number; firstTs: Date | null; lastTs: Date | null }
|
{ 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;
|
return created;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const row of machineWorkOrderCounters) {
|
for (const cycle of dedupedCycles) {
|
||||||
const safeGood = Math.max(0, Math.trunc(safeNum(row.goodParts) ?? 0));
|
const skuRaw = normalizeToken(cycle.sku);
|
||||||
const safeScrap = Math.max(0, Math.trunc(safeNum(row.scrapParts) ?? 0));
|
const g = Math.max(0, Math.trunc(safeNum(cycle.goodDelta) ?? 0));
|
||||||
const safeCycleCount = Math.max(0, Math.trunc(safeNum(row.cycleCount) ?? 0));
|
const s = Math.max(0, Math.trunc(safeNum(cycle.scrapDelta) ?? 0));
|
||||||
const target = safeNum(row.targetQty);
|
const woKey = workOrderKey(cycle.workOrderId);
|
||||||
|
authoritativeCycleCount += 1;
|
||||||
const skuAgg = ensureAuthoritativeSku(row.sku, target, false);
|
if (g === 0 && s === 0) continue;
|
||||||
skuAgg.good += safeGood;
|
goodParts += g;
|
||||||
skuAgg.scrap += safeScrap;
|
scrapParts += s;
|
||||||
|
if (woKey) {
|
||||||
goodParts += safeGood;
|
const progress = rangeWorkOrderProgress.get(woKey) ?? {
|
||||||
scrapParts += safeScrap;
|
goodParts: 0,
|
||||||
authoritativeCycleCount += safeCycleCount;
|
scrapParts: 0,
|
||||||
|
cycleCount: 0,
|
||||||
const woKey = workOrderKey(row.workOrderId);
|
firstTs: null,
|
||||||
if (!woKey) continue;
|
lastTs: null,
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
machineName: row.machineName,
|
|
||||||
sku: row.sku,
|
|
||||||
good: row.good,
|
|
||||||
scrap: row.scrap,
|
|
||||||
target,
|
|
||||||
progressPct,
|
|
||||||
};
|
};
|
||||||
})
|
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);
|
||||||
|
}
|
||||||
|
if (!skuRaw) continue;
|
||||||
|
const skuAgg = ensureAuthoritativeSku(skuRaw, null, true);
|
||||||
|
skuAgg.good += g;
|
||||||
|
skuAgg.scrap += s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bySku = [...authoritativeSkuMap.values()]
|
||||||
|
.map((row) => ({
|
||||||
|
machineName: row.machineName,
|
||||||
|
sku: row.sku,
|
||||||
|
good: row.good,
|
||||||
|
scrap: row.scrap,
|
||||||
|
target: null as number | null,
|
||||||
|
progressPct: null as number | null,
|
||||||
|
}))
|
||||||
.sort((a, b) => b.good - a.good);
|
.sort((a, b) => b.good - a.good);
|
||||||
|
|
||||||
const sortedKpis = [...dedupedKpis].sort((a, b) => a.ts.getTime() - b.ts.getTime());
|
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) => String(wo.status).toUpperCase() === "COMPLETED")
|
||||||
.filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end)
|
.filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end)
|
||||||
.map((wo) => {
|
.map((wo) => {
|
||||||
const progress = authoritativeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? {
|
const progress = rangeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? {
|
||||||
goodParts: 0,
|
goodParts: 0,
|
||||||
scrapParts: 0,
|
scrapParts: 0,
|
||||||
cycleCount: 0,
|
cycleCount: 0,
|
||||||
@@ -801,19 +737,15 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
let activeProgressPct: number | null = null;
|
let activeProgressPct: number | null = null;
|
||||||
let activeStartedAt: string | null = null;
|
let activeStartedAt: string | null = null;
|
||||||
if (activeWorkOrderId) {
|
if (activeWorkOrderId) {
|
||||||
const authoritativeProgress = activeWorkOrderKey
|
const rangeProgress = activeWorkOrderKey ? rangeWorkOrderProgress.get(activeWorkOrderKey) ?? null : null;
|
||||||
? authoritativeWorkOrderProgress.get(activeWorkOrderKey) ?? null
|
const producedForProgress = rangeProgress
|
||||||
: null;
|
? rangeProgress.goodParts + rangeProgress.scrapParts
|
||||||
const producedForProgress = authoritativeProgress
|
|
||||||
? authoritativeProgress.goodParts + authoritativeProgress.scrapParts
|
|
||||||
: 0;
|
: 0;
|
||||||
const targetQty = safeNum(activeTargetSource?.targetQty);
|
const targetQty = safeNum(activeTargetSource?.targetQty);
|
||||||
if (targetQty && targetQty > 0) {
|
if (targetQty && targetQty > 0) {
|
||||||
activeProgressPct = round2((producedForProgress / targetQty) * 100);
|
activeProgressPct = round2((producedForProgress / targetQty) * 100);
|
||||||
}
|
}
|
||||||
activeStartedAt = toIso(
|
activeStartedAt = toIso(rangeProgress?.firstTs ?? latestTelemetry?.ts ?? null);
|
||||||
authoritativeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstProductionMsAfterMoldStart = (startMs: number) => {
|
const firstProductionMsAfterMoldStart = (startMs: number) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* Client-safe recap thresholds. Kept in sync with OFFLINE logic in lib/recap/redesign.ts.
|
* 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 OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||||
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
|
||||||
const RECAP_CACHE_TTL_SEC = 60;
|
const RECAP_CACHE_TTL_SEC = 60;
|
||||||
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||||
const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
|
const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
|
||||||
@@ -213,7 +214,10 @@ async function loadTimelineRowsForMachines(params: {
|
|||||||
where: {
|
where: {
|
||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
machineId: { in: params.machineIds },
|
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" }],
|
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||||
select: {
|
select: {
|
||||||
@@ -338,7 +342,7 @@ async function computeRecapSummary(params: { orgId: string; hours: number }) {
|
|||||||
segments,
|
segments,
|
||||||
rangeStart: start,
|
rangeStart: start,
|
||||||
rangeEnd: end,
|
rangeEnd: end,
|
||||||
maxSegments: 30,
|
maxSegments: 60,
|
||||||
});
|
});
|
||||||
|
|
||||||
return toSummaryMachine({
|
return toSummaryMachine({
|
||||||
@@ -443,21 +447,25 @@ async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
|
|||||||
minutes: startMin % 60,
|
minutes: startMin % 60,
|
||||||
timeZone,
|
timeZone,
|
||||||
});
|
});
|
||||||
const end = zonedToUtcDate({
|
const shiftEndUtc = zonedToUtcDate({
|
||||||
...endDate,
|
...endDate,
|
||||||
hours: Math.floor(endMin / 60),
|
hours: Math.floor(endMin / 60),
|
||||||
minutes: endMin % 60,
|
minutes: endMin % 60,
|
||||||
timeZone,
|
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 {
|
return {
|
||||||
hasEnabledShifts: true,
|
hasEnabledShifts: true,
|
||||||
range: {
|
range: { start, end },
|
||||||
start,
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -751,22 +751,24 @@ export function compressTimelineSegments(input: {
|
|||||||
const bucketEnd = i === maxSegments - 1 ? rangeEndMs : Math.trunc(rangeStartMs + (i + 1) * bucketMs);
|
const bucketEnd = i === maxSegments - 1 ? rangeEndMs : Math.trunc(rangeStartMs + (i + 1) * bucketMs);
|
||||||
if (bucketEnd <= bucketStart) continue;
|
if (bucketEnd <= bucketStart) continue;
|
||||||
|
|
||||||
let winner: RecapTimelineSegment | null = null;
|
let winner: RecapTimelineSegment | null = null;
|
||||||
let winnerOverlap = -1;
|
let winnerPriority = -1;
|
||||||
|
let winnerOverlap = -1;
|
||||||
|
|
||||||
for (const segment of normalized) {
|
for (const segment of normalized) {
|
||||||
const overlapStart = Math.max(bucketStart, segment.startMs);
|
const overlapStart = Math.max(bucketStart, segment.startMs);
|
||||||
const overlapEnd = Math.min(bucketEnd, segment.endMs);
|
const overlapEnd = Math.min(bucketEnd, segment.endMs);
|
||||||
if (overlapEnd <= overlapStart) continue;
|
if (overlapEnd <= overlapStart) continue;
|
||||||
|
|
||||||
const overlap = overlapEnd - overlapStart;
|
const overlap = overlapEnd - overlapStart;
|
||||||
const priorityBonus = segmentPriority(segment.type) / 1000;
|
const priority = segmentPriority(segment.type);
|
||||||
const score = overlap + priorityBonus;
|
|
||||||
if (score > winnerOverlap) {
|
if (priority > winnerPriority || (priority === winnerPriority && overlap > winnerOverlap)) {
|
||||||
winner = segment;
|
winner = segment;
|
||||||
winnerOverlap = score;
|
winnerPriority = priority;
|
||||||
}
|
winnerOverlap = overlap;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!winner) {
|
if (!winner) {
|
||||||
buckets.push({
|
buckets.push({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import type { RecapTimelineResponse } from "@/lib/recap/types";
|
import type { RecapTimelineResponse } from "@/lib/recap/types";
|
||||||
|
|
||||||
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
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 DEFAULT_RANGE_MS = 24 * 60 * 60 * 1000;
|
||||||
const MIN_RANGE_MS = 60 * 1000;
|
const MIN_RANGE_MS = 60 * 1000;
|
||||||
const MAX_RANGE_MS = 72 * 60 * 60 * 1000;
|
const MAX_RANGE_MS = 72 * 60 * 60 * 1000;
|
||||||
@@ -94,7 +95,10 @@ export async function getRecapTimelineForMachine(params: {
|
|||||||
where: {
|
where: {
|
||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
machineId: params.machineId,
|
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" },
|
orderBy: { ts: "asc" },
|
||||||
select: {
|
select: {
|
||||||
@@ -126,7 +130,10 @@ export async function getRecapTimelineForMachine(params: {
|
|||||||
where: {
|
where: {
|
||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
machineId: params.machineId,
|
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({
|
prisma.machineEvent.count({
|
||||||
|
|||||||
Reference in New Issue
Block a user