recent
This commit is contained in:
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
import type { RecapMachine, RecapResponse } from "@/lib/recap/types";
|
import type { RecapMachine, RecapResponse, RecapTimelineResponse } from "@/lib/recap/types";
|
||||||
import RecapKpiRow from "@/components/recap/RecapKpiRow";
|
import RecapKpiRow from "@/components/recap/RecapKpiRow";
|
||||||
import RecapProductionBySku from "@/components/recap/RecapProductionBySku";
|
import RecapProductionBySku from "@/components/recap/RecapProductionBySku";
|
||||||
import RecapDowntimeTop from "@/components/recap/RecapDowntimeTop";
|
import RecapDowntimeTop from "@/components/recap/RecapDowntimeTop";
|
||||||
import RecapWorkOrderStatus from "@/components/recap/RecapWorkOrderStatus";
|
import RecapWorkOrderStatus from "@/components/recap/RecapWorkOrderStatus";
|
||||||
import RecapMachineStatus from "@/components/recap/RecapMachineStatus";
|
import RecapMachineStatus from "@/components/recap/RecapMachineStatus";
|
||||||
|
import RecapTimeline from "@/components/recap/RecapTimeline";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialData: RecapResponse;
|
initialData: RecapResponse;
|
||||||
@@ -46,6 +47,25 @@ export default function RecapClient({ initialData, initialFilters }: Props) {
|
|||||||
return "24h";
|
return "24h";
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||||
|
|
||||||
|
const shiftOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
data.availableShifts?.length
|
||||||
|
? data.availableShifts
|
||||||
|
: [
|
||||||
|
{ id: "shift1", name: t("recap.shift.1") },
|
||||||
|
{ id: "shift2", name: t("recap.shift.2") },
|
||||||
|
{ id: "shift3", name: t("recap.shift.3") },
|
||||||
|
],
|
||||||
|
[data.availableShifts, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "shift") return;
|
||||||
|
if (shiftOptions.some((option) => option.id === shift)) return;
|
||||||
|
setShift(shiftOptions[0]?.id ?? "shift1");
|
||||||
|
}, [mode, shift, shiftOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -108,6 +128,41 @@ export default function RecapClient({ initialData, initialFilters }: Props) {
|
|||||||
return data.machines.find((m) => m.machineId === machineId) ?? data.machines[0];
|
return data.machines.find((m) => m.machineId === machineId) ?? data.machines[0];
|
||||||
}, [data.machines, machineId]);
|
}, [data.machines, machineId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
async function loadTimeline() {
|
||||||
|
if (mode !== "24h") {
|
||||||
|
if (alive) setTimeline(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedMachine?.machineId) {
|
||||||
|
if (alive) setTimeline(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
machineId: selectedMachine.machineId,
|
||||||
|
hours: "24",
|
||||||
|
start: data.range.start,
|
||||||
|
end: data.range.end,
|
||||||
|
});
|
||||||
|
const res = await fetch(`/api/recap/timeline?${qs.toString()}`, { cache: "no-cache" });
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
if (!alive) return;
|
||||||
|
if (res.ok && json && json.segments) {
|
||||||
|
setTimeline(json as RecapTimelineResponse);
|
||||||
|
} else {
|
||||||
|
setTimeline(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadTimeline();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [mode, selectedMachine?.machineId, data.range.start, data.range.end]);
|
||||||
|
|
||||||
const fleet = useMemo(() => {
|
const fleet = useMemo(() => {
|
||||||
let good = 0;
|
let good = 0;
|
||||||
let scrap = 0;
|
let scrap = 0;
|
||||||
@@ -132,6 +187,11 @@ export default function RecapClient({ initialData, initialFilters }: Props) {
|
|||||||
}, [data.machines]);
|
}, [data.machines]);
|
||||||
|
|
||||||
const bannerMold = selectedMachine?.workOrders.moldChangeInProgress;
|
const bannerMold = selectedMachine?.workOrders.moldChangeInProgress;
|
||||||
|
const moldStartMs = selectedMachine?.workOrders.moldChangeStartMs ?? null;
|
||||||
|
const moldStartLabel = moldStartMs
|
||||||
|
? new Date(moldStartMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })
|
||||||
|
: "--:--";
|
||||||
|
const moldElapsedMin = moldStartMs ? Math.max(0, Math.floor((Date.now() - moldStartMs) / 60000)) : null;
|
||||||
const bannerStop = (selectedMachine?.downtime.ongoingStopMin ?? 0) > 0;
|
const bannerStop = (selectedMachine?.downtime.ongoingStopMin ?? 0) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -174,9 +234,11 @@ export default function RecapClient({ initialData, initialFilters }: Props) {
|
|||||||
onChange={(event) => setShift(event.target.value)}
|
onChange={(event) => setShift(event.target.value)}
|
||||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||||
>
|
>
|
||||||
<option value="shift1">{t("recap.shift.1")}</option>
|
{shiftOptions.map((option) => (
|
||||||
<option value="shift2">{t("recap.shift.2")}</option>
|
<option key={option.id} value={option.id}>
|
||||||
<option value="shift3">{t("recap.shift.3")}</option>
|
{option.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -202,7 +264,8 @@ export default function RecapClient({ initialData, initialFilters }: Props) {
|
|||||||
|
|
||||||
{bannerMold ? (
|
{bannerMold ? (
|
||||||
<div className="mb-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-300">
|
<div className="mb-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-300">
|
||||||
{t("recap.banner.mold")} {selectedMachine?.workOrders.active?.startedAt ? new Date(selectedMachine.workOrders.active.startedAt).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }) : "--:--"}
|
{t("recap.banner.mold")} {moldStartLabel}
|
||||||
|
{moldElapsedMin != null ? ` · ${moldElapsedMin} min` : ""}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{bannerStop ? (
|
{bannerStop ? (
|
||||||
@@ -213,6 +276,15 @@ export default function RecapClient({ initialData, initialFilters }: Props) {
|
|||||||
|
|
||||||
{loading ? <div className="mb-3 text-sm text-zinc-400">{t("common.loading")}</div> : null}
|
{loading ? <div className="mb-3 text-sm text-zinc-400">{t("common.loading")}</div> : null}
|
||||||
|
|
||||||
|
{timeline ? (
|
||||||
|
<RecapTimeline
|
||||||
|
rangeStart={timeline.range.start}
|
||||||
|
rangeEnd={timeline.range.end}
|
||||||
|
segments={timeline.segments}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<RecapKpiRow oeeAvg={fleet.oeeAvg} goodParts={fleet.good} totalStops={fleet.stops} scrapParts={fleet.scrap} />
|
<RecapKpiRow oeeAvg={fleet.oeeAvg} goodParts={fleet.good} totalStops={fleet.stops} scrapParts={fleet.scrap} />
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
@@ -227,6 +299,7 @@ export default function RecapClient({ initialData, initialFilters }: Props) {
|
|||||||
completed: [],
|
completed: [],
|
||||||
active: null,
|
active: null,
|
||||||
moldChangeInProgress: false,
|
moldChangeInProgress: false,
|
||||||
|
moldChangeStartMs: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
const bad = (status: number, error: string) =>
|
const bad = (status: number, error: string) =>
|
||||||
NextResponse.json({ ok: false, error }, { status });
|
NextResponse.json({ ok: false, error }, { status });
|
||||||
@@ -24,6 +25,7 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
const machineId = url.searchParams.get("machineId"); // optional
|
const machineId = url.searchParams.get("machineId"); // optional
|
||||||
const reasonCode = url.searchParams.get("reasonCode"); // optional
|
const reasonCode = url.searchParams.get("reasonCode"); // optional
|
||||||
|
const includeMoldChange = url.searchParams.get("includeMoldChange") === "true";
|
||||||
|
|
||||||
const limitRaw = url.searchParams.get("limit");
|
const limitRaw = url.searchParams.get("limit");
|
||||||
const limit = Math.min(Math.max(Number(limitRaw || 200), 1), 500);
|
const limit = Math.min(Math.max(Number(limitRaw || 200), 1), 500);
|
||||||
@@ -44,10 +46,11 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
// ✅ Query ReasonEntry as the "episode" table for downtime
|
// ✅ Query ReasonEntry as the "episode" table for downtime
|
||||||
// We only return rows that have an episodeId (true downtime episodes)
|
// We only return rows that have an episodeId (true downtime episodes)
|
||||||
const where: any = {
|
const where: Prisma.ReasonEntryWhereInput = {
|
||||||
orgId,
|
orgId,
|
||||||
kind: "downtime",
|
kind: "downtime",
|
||||||
episodeId: { not: null },
|
episodeId: { not: null },
|
||||||
|
...(includeMoldChange ? {} : { reasonCode: { not: "MOLD_CHANGE" } }),
|
||||||
capturedAt: {
|
capturedAt: {
|
||||||
gte: start,
|
gte: start,
|
||||||
...(beforeDate ? { lt: beforeDate } : {}),
|
...(beforeDate ? { lt: beforeDate } : {}),
|
||||||
@@ -122,6 +125,7 @@ export async function GET(req: Request) {
|
|||||||
start,
|
start,
|
||||||
machineId: machineId ?? null,
|
machineId: machineId ?? null,
|
||||||
reasonCode: reasonCode ?? null,
|
reasonCode: reasonCode ?? null,
|
||||||
|
includeMoldChange,
|
||||||
limit,
|
limit,
|
||||||
before: before ?? null,
|
before: before ?? null,
|
||||||
nextBefore, // pass this back for pagination
|
nextBefore, // pass this back for pagination
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
const machineId = url.searchParams.get("machineId"); // optional
|
const machineId = url.searchParams.get("machineId"); // optional
|
||||||
const kind = (url.searchParams.get("kind") || "downtime").toLowerCase();
|
const kind = (url.searchParams.get("kind") || "downtime").toLowerCase();
|
||||||
|
const includeMoldChange = url.searchParams.get("includeMoldChange") === "true";
|
||||||
|
|
||||||
if (kind !== "downtime" && kind !== "scrap") {
|
if (kind !== "downtime" && kind !== "scrap" && kind !== "planned-downtime") {
|
||||||
return bad(400, "Invalid kind (downtime|scrap)");
|
return bad(400, "Invalid kind (downtime|scrap|planned-downtime)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ If machineId provided, verify it belongs to this org
|
// ✅ If machineId provided, verify it belongs to this org
|
||||||
@@ -40,7 +41,9 @@ export async function GET(req: Request) {
|
|||||||
where: {
|
where: {
|
||||||
orgId,
|
orgId,
|
||||||
...(machineId ? { machineId } : {}),
|
...(machineId ? { machineId } : {}),
|
||||||
kind,
|
kind: kind === "planned-downtime" ? "downtime" : kind,
|
||||||
|
...(kind === "downtime" && !includeMoldChange ? { reasonCode: { not: "MOLD_CHANGE" } } : {}),
|
||||||
|
...(kind === "planned-downtime" ? { reasonCode: "MOLD_CHANGE" } : {}),
|
||||||
capturedAt: { gte: start },
|
capturedAt: { gte: start },
|
||||||
},
|
},
|
||||||
_sum: {
|
_sum: {
|
||||||
@@ -53,7 +56,7 @@ export async function GET(req: Request) {
|
|||||||
const itemsRaw = grouped
|
const itemsRaw = grouped
|
||||||
.map((g) => {
|
.map((g) => {
|
||||||
const value =
|
const value =
|
||||||
kind === "downtime"
|
kind === "downtime" || kind === "planned-downtime"
|
||||||
? Math.round(((g._sum.durationSeconds ?? 0) / 60) * 10) / 10 // minutes, 1 decimal
|
? Math.round(((g._sum.durationSeconds ?? 0) / 60) * 10) / 10 // minutes, 1 decimal
|
||||||
: g._sum.scrapQty ?? 0;
|
: g._sum.scrapQty ?? 0;
|
||||||
|
|
||||||
@@ -64,7 +67,9 @@ export async function GET(req: Request) {
|
|||||||
count: g._count._all,
|
count: g._count._all,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((x) => (kind === "downtime" ? x.value > 0 || x.count > 0 : x.value > 0));
|
.filter((x) =>
|
||||||
|
kind === "downtime" || kind === "planned-downtime" ? x.value > 0 || x.count > 0 : x.value > 0
|
||||||
|
);
|
||||||
|
|
||||||
itemsRaw.sort((a, b) => b.value - a.value);
|
itemsRaw.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
@@ -83,7 +88,7 @@ export async function GET(req: Request) {
|
|||||||
return {
|
return {
|
||||||
reasonCode: x.reasonCode,
|
reasonCode: x.reasonCode,
|
||||||
reasonLabel: x.reasonLabel,
|
reasonLabel: x.reasonLabel,
|
||||||
minutesLost: kind === "downtime" ? x.value : undefined,
|
minutesLost: kind === "downtime" || kind === "planned-downtime" ? x.value : undefined,
|
||||||
scrapQty: kind === "scrap" ? x.value : undefined,
|
scrapQty: kind === "scrap" ? x.value : undefined,
|
||||||
pctOfTotal,
|
pctOfTotal,
|
||||||
cumulativePct,
|
cumulativePct,
|
||||||
@@ -106,9 +111,10 @@ export async function GET(req: Request) {
|
|||||||
orgId,
|
orgId,
|
||||||
machineId: machineId ?? null,
|
machineId: machineId ?? null,
|
||||||
kind,
|
kind,
|
||||||
|
includeMoldChange,
|
||||||
range, // ✅ now defined correctly
|
range, // ✅ now defined correctly
|
||||||
start, // ✅ now defined correctly
|
start, // ✅ now defined correctly
|
||||||
totalMinutesLost: kind === "downtime" ? total : undefined,
|
totalMinutesLost: kind === "downtime" || kind === "planned-downtime" ? total : undefined,
|
||||||
totalScrap: kind === "scrap" ? total : undefined,
|
totalScrap: kind === "scrap" ? total : undefined,
|
||||||
rows,
|
rows,
|
||||||
top3,
|
top3,
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ function normalizeCycleInput(raw: unknown): Record<string, unknown> | null {
|
|||||||
cycle_count: fromRowOrData(["cycle_count", "cycleCount"]),
|
cycle_count: fromRowOrData(["cycle_count", "cycleCount"]),
|
||||||
work_order_id: fromRowOrData(["work_order_id", "workOrderId"]),
|
work_order_id: fromRowOrData(["work_order_id", "workOrderId"]),
|
||||||
good_delta: fromRowOrData(["good_delta", "goodDelta"]),
|
good_delta: fromRowOrData(["good_delta", "goodDelta"]),
|
||||||
scrap_delta: fromRowOrData(["scrap_delta", "scrapDelta", "scrap_total"]),
|
// `scrap_total` is cumulative and should not be persisted as per-cycle delta.
|
||||||
|
scrap_delta: fromRowOrData(["scrap_delta", "scrapDelta"]),
|
||||||
timestamp: fromRowOrData(["timestamp", "tsMs"]),
|
timestamp: fromRowOrData(["timestamp", "tsMs"]),
|
||||||
ts: fromRowOrData(["ts", "tsMs"]),
|
ts: fromRowOrData(["ts", "tsMs"]),
|
||||||
event_timestamp: fromRowOrData(["event_timestamp", "eventTimestamp"]),
|
event_timestamp: fromRowOrData(["event_timestamp", "eventTimestamp"]),
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const CANON_TYPE: Record<string, string> = {
|
|||||||
"down": "stop",
|
"down": "stop",
|
||||||
"downtime-acknowledged": "downtime-acknowledged",
|
"downtime-acknowledged": "downtime-acknowledged",
|
||||||
"scrap-manual-entry": "scrap-manual-entry",
|
"scrap-manual-entry": "scrap-manual-entry",
|
||||||
|
"mold-change": "mold-change",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALLOWED_TYPES = new Set([
|
const ALLOWED_TYPES = new Set([
|
||||||
@@ -54,6 +55,7 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"predictive-oee-decline",
|
"predictive-oee-decline",
|
||||||
"downtime-acknowledged",
|
"downtime-acknowledged",
|
||||||
"scrap-manual-entry",
|
"scrap-manual-entry",
|
||||||
|
"mold-change",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const machineIdSchema = z.string().uuid();
|
const machineIdSchema = z.string().uuid();
|
||||||
@@ -441,9 +443,26 @@ export async function POST(req: Request) {
|
|||||||
// If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage.
|
// If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage.
|
||||||
if (evRecord.is_update || evRecord.is_auto_ack || dataObj.is_update || dataObj.is_auto_ack){
|
if (evRecord.is_update || evRecord.is_auto_ack || dataObj.is_update || dataObj.is_auto_ack){
|
||||||
// skip duplicate reasonEntry for refresh/ack
|
// skip duplicate reasonEntry for refresh/ack
|
||||||
} else if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged"){
|
} else if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged" || finalType === "mold-change"){
|
||||||
|
const moldIncidentKey =
|
||||||
|
clampText(evData.incidentKey ?? dataObj.incidentKey, 128) ??
|
||||||
|
(numberFrom(evData.start_ms ?? dataObj.start_ms) != null
|
||||||
|
? `mold-change:${Math.trunc(numberFrom(evData.start_ms ?? dataObj.start_ms) as number)}`
|
||||||
|
: null);
|
||||||
const reasonRaw: Record<string, unknown> =
|
const reasonRaw: Record<string, unknown> =
|
||||||
evReason ??
|
evReason ??
|
||||||
|
(finalType === "mold-change"
|
||||||
|
? ({
|
||||||
|
type: "downtime",
|
||||||
|
categoryId: "cambio-molde",
|
||||||
|
detailId: "cambio-molde",
|
||||||
|
categoryLabel: "Cambio molde",
|
||||||
|
detailLabel: "Cambio molde",
|
||||||
|
reasonCode: "MOLD_CHANGE",
|
||||||
|
reasonText: "Cambio molde",
|
||||||
|
incidentKey: moldIncidentKey ?? row.id,
|
||||||
|
} as Record<string, unknown>)
|
||||||
|
:
|
||||||
({
|
({
|
||||||
type: "downtime",
|
type: "downtime",
|
||||||
categoryId: "unclassified",
|
categoryId: "unclassified",
|
||||||
@@ -453,7 +472,7 @@ export async function POST(req: Request) {
|
|||||||
reasonCode: "UNCLASSIFIED",
|
reasonCode: "UNCLASSIFIED",
|
||||||
reasonText: "Unclassified",
|
reasonText: "Unclassified",
|
||||||
incidentKey: row.id,
|
incidentKey: row.id,
|
||||||
} as Record<string, unknown>);
|
} as Record<string, unknown>));
|
||||||
|
|
||||||
const inferredKind: ReasonCatalogKind =
|
const inferredKind: ReasonCatalogKind =
|
||||||
String(reasonRaw.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
|
String(reasonRaw.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
|
||||||
@@ -506,11 +525,13 @@ export async function POST(req: Request) {
|
|||||||
const incidentKey = clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id;
|
const incidentKey = clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id;
|
||||||
const durationSeconds =
|
const durationSeconds =
|
||||||
numberFrom(evDowntime?.durationSeconds) ??
|
numberFrom(evDowntime?.durationSeconds) ??
|
||||||
|
numberFrom(evData.duration_sec) ??
|
||||||
numberFrom(evData.stoppage_duration_seconds) ??
|
numberFrom(evData.stoppage_duration_seconds) ??
|
||||||
numberFrom(evData.stop_duration_seconds) ??
|
numberFrom(evData.stop_duration_seconds) ??
|
||||||
(stopSecForReason != null ? stopSecForReason : null) ??
|
(stopSecForReason != null ? stopSecForReason : null) ??
|
||||||
null;
|
null;
|
||||||
const episodeEndTsMs =
|
const episodeEndTsMs =
|
||||||
|
numberFrom(evData.end_ms) ??
|
||||||
numberFrom(evDowntime?.episodeEndTsMs) ??
|
numberFrom(evDowntime?.episodeEndTsMs) ??
|
||||||
numberFrom(evDowntime?.acknowledgedAtMs) ??
|
numberFrom(evDowntime?.acknowledgedAtMs) ??
|
||||||
null;
|
null;
|
||||||
|
|||||||
461
app/api/recap/timeline/route.ts
Normal file
461
app/api/recap/timeline/route.ts
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import type { RecapTimelineResponse, RecapTimelineSegment } from "@/lib/recap/types";
|
||||||
|
|
||||||
|
type RawSegment =
|
||||||
|
| {
|
||||||
|
type: "production";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
priority: number;
|
||||||
|
workOrderId: string | null;
|
||||||
|
sku: string | null;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "mold-change";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
priority: number;
|
||||||
|
fromMoldId: string | null;
|
||||||
|
toMoldId: string | null;
|
||||||
|
durationSec: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "macrostop" | "microstop" | "slow-cycle";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
priority: number;
|
||||||
|
reason: string | null;
|
||||||
|
durationSec: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_TYPES = ["mold-change", "macrostop", "microstop", "slow-cycle"] as const;
|
||||||
|
type TimelineEventType = (typeof EVENT_TYPES)[number];
|
||||||
|
const ACTIVE_STALE_MS = 2 * 60 * 1000;
|
||||||
|
const PRIORITY: Record<string, number> = {
|
||||||
|
idle: 0,
|
||||||
|
production: 1,
|
||||||
|
microstop: 2,
|
||||||
|
"slow-cycle": 2,
|
||||||
|
macrostop: 3,
|
||||||
|
"mold-change": 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
function bad(status: number, error: string) {
|
||||||
|
return NextResponse.json({ ok: false, error }, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeNum(value: unknown) {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToken(value: unknown) {
|
||||||
|
return String(value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeByKey<T>(rows: T[], keyFn: (row: T) => string) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = keyFn(row);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHours(raw: string | null) {
|
||||||
|
const value = Math.trunc(Number(raw || "24"));
|
||||||
|
if (!Number.isFinite(value)) return 24;
|
||||||
|
return Math.max(1, Math.min(72, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateInput(raw: string | null) {
|
||||||
|
if (!raw) return null;
|
||||||
|
const asNum = Number(raw);
|
||||||
|
if (Number.isFinite(asNum)) {
|
||||||
|
const d = new Date(asNum);
|
||||||
|
return Number.isFinite(d.getTime()) ? d : null;
|
||||||
|
}
|
||||||
|
const d = new Date(raw);
|
||||||
|
return Number.isFinite(d.getTime()) ? d : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractData(value: unknown) {
|
||||||
|
let parsed: unknown = value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const record = typeof parsed === "object" && parsed && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
|
||||||
|
const nested = record.data;
|
||||||
|
if (typeof nested === "object" && nested && !Array.isArray(nested)) return nested as Record<string, unknown>;
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampToRange(startMs: number, endMs: number, rangeStartMs: number, rangeEndMs: number) {
|
||||||
|
const clampedStart = Math.max(rangeStartMs, Math.min(rangeEndMs, startMs));
|
||||||
|
const clampedEnd = Math.max(rangeStartMs, Math.min(rangeEndMs, endMs));
|
||||||
|
if (clampedEnd <= clampedStart) return null;
|
||||||
|
return { startMs: clampedStart, endMs: clampedEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventIncidentKey(eventType: string, data: Record<string, unknown>, fallbackTsMs: number) {
|
||||||
|
const key = String(data.incidentKey ?? data.incident_key ?? "").trim();
|
||||||
|
if (key) return key;
|
||||||
|
const alertId = String(data.alert_id ?? data.alertId ?? "").trim();
|
||||||
|
if (alertId) return `${eventType}:${alertId}`;
|
||||||
|
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
|
||||||
|
if (startMs != null) return `${eventType}:${Math.trunc(startMs)}`;
|
||||||
|
return `${eventType}:${fallbackTsMs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonLabelFromData(data: Record<string, unknown>) {
|
||||||
|
const direct =
|
||||||
|
String(data.reasonText ?? data.reason_label ?? data.reasonLabel ?? "").trim() || null;
|
||||||
|
if (direct) return direct;
|
||||||
|
|
||||||
|
const reason = data.reason;
|
||||||
|
if (typeof reason === "string") {
|
||||||
|
const text = reason.trim();
|
||||||
|
return text || null;
|
||||||
|
}
|
||||||
|
if (reason && typeof reason === "object" && !Array.isArray(reason)) {
|
||||||
|
const rec = reason as Record<string, unknown>;
|
||||||
|
const reasonText =
|
||||||
|
String(rec.reasonText ?? rec.reason_label ?? rec.reasonLabel ?? "").trim() || null;
|
||||||
|
if (reasonText) return reasonText;
|
||||||
|
const detail =
|
||||||
|
String(rec.detailLabel ?? rec.detail_label ?? rec.detailId ?? rec.detail_id ?? "").trim() || null;
|
||||||
|
const category =
|
||||||
|
String(rec.categoryLabel ?? rec.category_label ?? rec.categoryId ?? rec.category_id ?? "").trim() || null;
|
||||||
|
if (category && detail) return `${category} > ${detail}`;
|
||||||
|
if (detail) return detail;
|
||||||
|
if (category) return category;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelForStop(type: "macrostop" | "microstop" | "slow-cycle", reason: string | null) {
|
||||||
|
if (type === "macrostop") return reason ? `Paro: ${reason}` : "Paro";
|
||||||
|
if (type === "microstop") return reason ? `Microparo: ${reason}` : "Microparo";
|
||||||
|
return "Ciclo lento";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEquivalent(a: RecapTimelineSegment, b: RecapTimelineSegment) {
|
||||||
|
if (a.type !== b.type) return false;
|
||||||
|
if (a.type === "idle" && b.type === "idle") return true;
|
||||||
|
if (a.type === "production" && b.type === "production") {
|
||||||
|
return a.workOrderId === b.workOrderId && a.sku === b.sku && a.label === b.label;
|
||||||
|
}
|
||||||
|
if (a.type === "mold-change" && b.type === "mold-change") {
|
||||||
|
return a.fromMoldId === b.fromMoldId && a.toMoldId === b.toMoldId;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(a.type === "macrostop" || a.type === "microstop" || a.type === "slow-cycle") &&
|
||||||
|
(b.type === "macrostop" || b.type === "microstop" || b.type === "slow-cycle")
|
||||||
|
) {
|
||||||
|
return a.type === b.type && a.reason === b.reason;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await requireSession();
|
||||||
|
if (!session) return bad(401, "Unauthorized");
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const machineId = url.searchParams.get("machineId");
|
||||||
|
if (!machineId) return bad(400, "machineId is required");
|
||||||
|
const hours = parseHours(url.searchParams.get("hours"));
|
||||||
|
const startParam = parseDateInput(url.searchParams.get("start"));
|
||||||
|
const endParam = parseDateInput(url.searchParams.get("end"));
|
||||||
|
|
||||||
|
const machine = await prisma.machine.findFirst({
|
||||||
|
where: { id: machineId, orgId: session.orgId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!machine) return bad(404, "Machine not found");
|
||||||
|
|
||||||
|
const end = endParam ?? new Date();
|
||||||
|
const start = startParam && startParam < end ? startParam : new Date(end.getTime() - hours * 60 * 60 * 1000);
|
||||||
|
const rangeStartMs = start.getTime();
|
||||||
|
const rangeEndMs = end.getTime();
|
||||||
|
|
||||||
|
const [cycles, events] = await Promise.all([
|
||||||
|
prisma.machineCycle.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: session.orgId,
|
||||||
|
machineId,
|
||||||
|
ts: { gte: start, lte: end },
|
||||||
|
},
|
||||||
|
orderBy: { ts: "asc" },
|
||||||
|
select: {
|
||||||
|
ts: true,
|
||||||
|
cycleCount: true,
|
||||||
|
actualCycleTime: true,
|
||||||
|
workOrderId: true,
|
||||||
|
sku: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.machineEvent.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: session.orgId,
|
||||||
|
machineId,
|
||||||
|
eventType: { in: EVENT_TYPES as unknown as string[] },
|
||||||
|
ts: { gte: new Date(start.getTime() - 24 * 60 * 60 * 1000), lte: end },
|
||||||
|
},
|
||||||
|
orderBy: { ts: "asc" },
|
||||||
|
select: {
|
||||||
|
ts: true,
|
||||||
|
eventType: true,
|
||||||
|
data: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dedupedCycles = dedupeByKey(
|
||||||
|
cycles,
|
||||||
|
(cycle) =>
|
||||||
|
`${cycle.ts.getTime()}:${safeNum(cycle.cycleCount) ?? "na"}:${normalizeToken(cycle.workOrderId).toUpperCase()}:${normalizeToken(cycle.sku).toUpperCase()}:${safeNum(cycle.actualCycleTime) ?? "na"}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawSegments: RawSegment[] = [];
|
||||||
|
|
||||||
|
let currentProduction: RawSegment | null = null;
|
||||||
|
for (const cycle of dedupedCycles) {
|
||||||
|
if (!cycle.workOrderId) continue;
|
||||||
|
const cycleStartMs = cycle.ts.getTime();
|
||||||
|
const cycleDurationMs = Math.max(1000, Math.min(600000, Math.trunc((safeNum(cycle.actualCycleTime) ?? 1) * 1000)));
|
||||||
|
const cycleEndMs = cycleStartMs + cycleDurationMs;
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentProduction &&
|
||||||
|
currentProduction.type === "production" &&
|
||||||
|
currentProduction.workOrderId === cycle.workOrderId &&
|
||||||
|
currentProduction.sku === cycle.sku &&
|
||||||
|
cycleStartMs <= currentProduction.endMs + 5 * 60 * 1000
|
||||||
|
) {
|
||||||
|
currentProduction.endMs = Math.max(currentProduction.endMs, cycleEndMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentProduction) rawSegments.push(currentProduction);
|
||||||
|
currentProduction = {
|
||||||
|
type: "production",
|
||||||
|
startMs: cycleStartMs,
|
||||||
|
endMs: cycleEndMs,
|
||||||
|
priority: PRIORITY.production,
|
||||||
|
workOrderId: cycle.workOrderId,
|
||||||
|
sku: cycle.sku,
|
||||||
|
label: cycle.workOrderId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (currentProduction) rawSegments.push(currentProduction);
|
||||||
|
|
||||||
|
const eventEpisodes = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
type: "mold-change" | "macrostop" | "microstop" | "slow-cycle";
|
||||||
|
firstTsMs: number;
|
||||||
|
lastTsMs: number;
|
||||||
|
startMs: number | null;
|
||||||
|
endMs: number | null;
|
||||||
|
durationSec: number | null;
|
||||||
|
statusActive: boolean;
|
||||||
|
statusResolved: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
fromMoldId: string | null;
|
||||||
|
toMoldId: string | null;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const eventType = String(event.eventType || "").toLowerCase() as TimelineEventType;
|
||||||
|
if (!EVENT_TYPES.includes(eventType)) continue;
|
||||||
|
|
||||||
|
const data = extractData(event.data);
|
||||||
|
const tsMs = event.ts.getTime();
|
||||||
|
const key = eventIncidentKey(eventType, data, tsMs);
|
||||||
|
const status = String(data.status ?? "").trim().toLowerCase();
|
||||||
|
|
||||||
|
const episode = eventEpisodes.get(key) ?? {
|
||||||
|
type: eventType,
|
||||||
|
firstTsMs: tsMs,
|
||||||
|
lastTsMs: tsMs,
|
||||||
|
startMs: null,
|
||||||
|
endMs: null,
|
||||||
|
durationSec: null,
|
||||||
|
statusActive: false,
|
||||||
|
statusResolved: false,
|
||||||
|
reason: null,
|
||||||
|
fromMoldId: null,
|
||||||
|
toMoldId: null,
|
||||||
|
};
|
||||||
|
episode.firstTsMs = Math.min(episode.firstTsMs, tsMs);
|
||||||
|
episode.lastTsMs = Math.max(episode.lastTsMs, tsMs);
|
||||||
|
|
||||||
|
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
|
||||||
|
const endMs = safeNum(data.end_ms) ?? safeNum(data.endMs);
|
||||||
|
const durationSec =
|
||||||
|
safeNum(data.duration_sec) ??
|
||||||
|
safeNum(data.stoppage_duration_seconds) ??
|
||||||
|
safeNum(data.stop_duration_seconds) ??
|
||||||
|
safeNum(data.duration_seconds);
|
||||||
|
|
||||||
|
if (startMs != null) episode.startMs = episode.startMs == null ? startMs : Math.min(episode.startMs, startMs);
|
||||||
|
if (endMs != null) episode.endMs = episode.endMs == null ? endMs : Math.max(episode.endMs, endMs);
|
||||||
|
if (durationSec != null) episode.durationSec = Math.max(0, Math.trunc(durationSec));
|
||||||
|
|
||||||
|
if (status === "active") episode.statusActive = true;
|
||||||
|
if (status === "resolved") episode.statusResolved = true;
|
||||||
|
|
||||||
|
const reason = reasonLabelFromData(data);
|
||||||
|
if (reason) episode.reason = reason;
|
||||||
|
const fromMoldId = String(data.from_mold_id ?? data.fromMoldId ?? "").trim() || null;
|
||||||
|
const toMoldId = String(data.to_mold_id ?? data.toMoldId ?? "").trim() || null;
|
||||||
|
if (fromMoldId) episode.fromMoldId = fromMoldId;
|
||||||
|
if (toMoldId) episode.toMoldId = toMoldId;
|
||||||
|
|
||||||
|
eventEpisodes.set(key, episode);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const episode of eventEpisodes.values()) {
|
||||||
|
const startMs = Math.trunc(episode.startMs ?? episode.firstTsMs);
|
||||||
|
let endMs = Math.trunc(episode.endMs ?? episode.lastTsMs);
|
||||||
|
if (episode.statusActive && !episode.statusResolved) {
|
||||||
|
const isFreshActive = rangeEndMs - episode.lastTsMs <= ACTIVE_STALE_MS;
|
||||||
|
endMs = isFreshActive ? rangeEndMs : episode.lastTsMs;
|
||||||
|
} else if (endMs <= startMs && episode.durationSec != null && episode.durationSec > 0) {
|
||||||
|
endMs = startMs + episode.durationSec * 1000;
|
||||||
|
}
|
||||||
|
if (endMs <= startMs) continue;
|
||||||
|
|
||||||
|
if (episode.type === "mold-change") {
|
||||||
|
rawSegments.push({
|
||||||
|
type: "mold-change",
|
||||||
|
startMs,
|
||||||
|
endMs,
|
||||||
|
priority: PRIORITY["mold-change"],
|
||||||
|
fromMoldId: episode.fromMoldId,
|
||||||
|
toMoldId: episode.toMoldId,
|
||||||
|
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
|
||||||
|
label: episode.toMoldId ? `Cambio molde ${episode.toMoldId}` : "Cambio molde",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopType = episode.type;
|
||||||
|
rawSegments.push({
|
||||||
|
type: stopType,
|
||||||
|
startMs,
|
||||||
|
endMs,
|
||||||
|
priority: PRIORITY[stopType],
|
||||||
|
reason: episode.reason,
|
||||||
|
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
|
||||||
|
label: labelForStop(stopType, episode.reason),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipped = rawSegments
|
||||||
|
.map((segment) => {
|
||||||
|
const range = clampToRange(segment.startMs, segment.endMs, rangeStartMs, rangeEndMs);
|
||||||
|
return range ? { ...segment, ...range } : null;
|
||||||
|
})
|
||||||
|
.filter((segment): segment is RawSegment => !!segment);
|
||||||
|
|
||||||
|
const boundaries = new Set<number>([rangeStartMs, rangeEndMs]);
|
||||||
|
for (const segment of clipped) {
|
||||||
|
boundaries.add(segment.startMs);
|
||||||
|
boundaries.add(segment.endMs);
|
||||||
|
}
|
||||||
|
const orderedBoundaries = Array.from(boundaries).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const timeline: RecapTimelineSegment[] = [];
|
||||||
|
for (let i = 0; i < orderedBoundaries.length - 1; i += 1) {
|
||||||
|
const intervalStart = orderedBoundaries[i];
|
||||||
|
const intervalEnd = orderedBoundaries[i + 1];
|
||||||
|
if (intervalEnd <= intervalStart) continue;
|
||||||
|
|
||||||
|
const covering = clipped
|
||||||
|
.filter((segment) => segment.startMs < intervalEnd && segment.endMs > intervalStart)
|
||||||
|
.sort((a, b) => b.priority - a.priority || b.startMs - a.startMs);
|
||||||
|
|
||||||
|
const winner = covering[0];
|
||||||
|
if (!winner) {
|
||||||
|
timeline.push({ type: "idle", startMs: intervalStart, endMs: intervalEnd, label: "Idle" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winner.type === "production") {
|
||||||
|
timeline.push({
|
||||||
|
type: "production",
|
||||||
|
startMs: intervalStart,
|
||||||
|
endMs: intervalEnd,
|
||||||
|
workOrderId: winner.workOrderId,
|
||||||
|
sku: winner.sku,
|
||||||
|
label: winner.label,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (winner.type === "mold-change") {
|
||||||
|
timeline.push({
|
||||||
|
type: "mold-change",
|
||||||
|
startMs: intervalStart,
|
||||||
|
endMs: intervalEnd,
|
||||||
|
fromMoldId: winner.fromMoldId,
|
||||||
|
toMoldId: winner.toMoldId,
|
||||||
|
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
|
||||||
|
label: winner.label,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeline.push({
|
||||||
|
type: winner.type,
|
||||||
|
startMs: intervalStart,
|
||||||
|
endMs: intervalEnd,
|
||||||
|
reason: winner.reason,
|
||||||
|
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
|
||||||
|
label: winner.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged: RecapTimelineSegment[] = [];
|
||||||
|
for (const segment of timeline) {
|
||||||
|
const prev = merged[merged.length - 1];
|
||||||
|
if (!prev || !isEquivalent(prev, segment) || prev.endMs !== segment.startMs) {
|
||||||
|
merged.push(segment);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
prev.endMs = segment.endMs;
|
||||||
|
if (prev.type === "mold-change") prev.durationSec = Math.max(0, Math.trunc((prev.endMs - prev.startMs) / 1000));
|
||||||
|
if (prev.type === "macrostop" || prev.type === "microstop" || prev.type === "slow-cycle") {
|
||||||
|
prev.durationSec = Math.max(0, Math.trunc((prev.endMs - prev.startMs) / 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: RecapTimelineResponse = {
|
||||||
|
range: {
|
||||||
|
start: start.toISOString(),
|
||||||
|
end: end.toISOString(),
|
||||||
|
},
|
||||||
|
segments: merged,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
redirect("/machines");
|
redirect("/recap");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ type NavItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const items: NavItem[] = [
|
const items: NavItem[] = [
|
||||||
|
{ href: "/recap", labelKey: "nav.recap", icon: Sunrise },
|
||||||
{ href: "/overview", labelKey: "nav.overview", icon: LayoutGrid },
|
{ href: "/overview", labelKey: "nav.overview", icon: LayoutGrid },
|
||||||
{ href: "/machines", labelKey: "nav.machines", icon: Wrench },
|
{ href: "/machines", labelKey: "nav.machines", icon: Wrench },
|
||||||
{ href: "/reports", labelKey: "nav.reports", icon: BarChart3 },
|
{ href: "/reports", labelKey: "nav.reports", icon: BarChart3 },
|
||||||
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
|
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
|
||||||
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
|
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
|
||||||
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
|
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
|
||||||
{ href: "/recap", labelKey: "nav.recap", icon: Sunrise },
|
|
||||||
];
|
];
|
||||||
const settingsItem: NavItem = { href: "/settings", labelKey: "nav.settings", icon: Settings };
|
const settingsItem: NavItem = { href: "/settings", labelKey: "nav.settings", icon: Settings };
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export default function RecapProductionBySku({ rows }: Props) {
|
|||||||
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
|
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-5 gap-2 border-b border-white/10 pb-2 text-xs uppercase tracking-wide text-zinc-400">
|
<div className="grid grid-cols-6 gap-2 border-b border-white/10 pb-2 text-xs uppercase tracking-wide text-zinc-400">
|
||||||
|
<div>Maquina</div>
|
||||||
<div>SKU</div>
|
<div>SKU</div>
|
||||||
<div>{t("recap.production.good")}</div>
|
<div>{t("recap.production.good")}</div>
|
||||||
<div>{t("recap.production.scrap")}</div>
|
<div>{t("recap.production.scrap")}</div>
|
||||||
@@ -27,7 +28,8 @@ export default function RecapProductionBySku({ rows }: Props) {
|
|||||||
{rows.slice(0, 8).map((row) => {
|
{rows.slice(0, 8).map((row) => {
|
||||||
const pct = row.progressPct == null ? "--" : `${Math.round(row.progressPct)}%`;
|
const pct = row.progressPct == null ? "--" : `${Math.round(row.progressPct)}%`;
|
||||||
return (
|
return (
|
||||||
<div key={row.sku} className="grid grid-cols-5 gap-2 text-sm text-zinc-200">
|
<div key={`${row.machineName}:${row.sku}`} className="grid grid-cols-6 gap-2 text-sm text-zinc-200">
|
||||||
|
<div className="truncate text-zinc-400">{row.machineName}</div>
|
||||||
<div className="truncate">{row.sku}</div>
|
<div className="truncate">{row.sku}</div>
|
||||||
<div>{row.good}</div>
|
<div>{row.good}</div>
|
||||||
<div className={row.scrap > 0 ? "text-red-400" : "text-zinc-200"}>{row.scrap}</div>
|
<div className={row.scrap > 0 ? "text-red-400" : "text-zinc-200"}>{row.scrap}</div>
|
||||||
|
|||||||
84
components/recap/RecapTimeline.tsx
Normal file
84
components/recap/RecapTimeline.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { RecapTimelineSegment } from "@/lib/recap/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rangeStart: string;
|
||||||
|
rangeEnd: string;
|
||||||
|
segments: RecapTimelineSegment[];
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLORS: Record<RecapTimelineSegment["type"], string> = {
|
||||||
|
production: "bg-emerald-500 text-emerald-50",
|
||||||
|
"mold-change": "bg-blue-400 text-blue-950",
|
||||||
|
macrostop: "bg-red-500 text-red-50",
|
||||||
|
microstop: "bg-orange-500 text-orange-50",
|
||||||
|
"slow-cycle": "bg-amber-500 text-amber-950",
|
||||||
|
idle: "bg-zinc-600 text-zinc-100",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtTime(valueMs: number, locale: string) {
|
||||||
|
return new Date(valueMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(startMs: number, endMs: number) {
|
||||||
|
const totalMin = Math.max(0, Math.round((endMs - startMs) / 60000));
|
||||||
|
if (totalMin < 60) return `${totalMin}m`;
|
||||||
|
const h = Math.floor(totalMin / 60);
|
||||||
|
const m = totalMin % 60;
|
||||||
|
return `${h}h ${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecapTimeline({ rangeStart, rangeEnd, segments, locale }: Props) {
|
||||||
|
const startMs = new Date(rangeStart).getTime();
|
||||||
|
const endMs = new Date(rangeEnd).getTime();
|
||||||
|
const totalMs = Math.max(1, endMs - startMs);
|
||||||
|
|
||||||
|
const bars: RecapTimelineSegment[] = [];
|
||||||
|
const dots: Array<{ leftPct: number; segment: RecapTimelineSegment }> = [];
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
const widthPct = ((segment.endMs - segment.startMs) / totalMs) * 100;
|
||||||
|
const leftPct = ((segment.startMs - startMs) / totalMs) * 100;
|
||||||
|
if (widthPct < 1) {
|
||||||
|
if (segment.type !== "idle" && leftPct > 0.5 && leftPct < 99.5) {
|
||||||
|
dots.push({ leftPct, segment });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bars.push(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-3">
|
||||||
|
<div className="mb-2 text-xs uppercase tracking-wide text-zinc-400">Timeline 24h</div>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex h-12 w-full overflow-hidden rounded-xl border border-white/10">
|
||||||
|
{bars.map((segment) => {
|
||||||
|
const widthPct = ((segment.endMs - segment.startMs) / totalMs) * 100;
|
||||||
|
const title = `${segment.type} · ${fmtTime(segment.startMs, locale)}-${fmtTime(segment.endMs, locale)} · ${fmtDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
|
||||||
|
className={`flex items-center justify-center truncate px-1 text-xs font-medium ${COLORS[segment.type]}`}
|
||||||
|
style={{ width: `${widthPct}%` }}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{widthPct >= 6 ? segment.label : ""}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{dots.map(({ leftPct, segment }) => (
|
||||||
|
<div
|
||||||
|
key={`dot:${segment.type}:${segment.startMs}:${segment.endMs}`}
|
||||||
|
className={`absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-black/30 ${COLORS[segment.type].split(" ")[0]}`}
|
||||||
|
style={{ left: `${Math.max(0.3, Math.min(99.7, leftPct))}%` }}
|
||||||
|
title={`${segment.type} · ${fmtTime(segment.startMs, locale)}-${fmtTime(segment.endMs, locale)} · ${fmtDuration(segment.startMs, segment.endMs)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -244,6 +244,7 @@ export async function computeFinancialImpact(params: FinancialImpactParams): Pro
|
|||||||
|
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
const eventType = String(ev.eventType ?? "").toLowerCase();
|
const eventType = String(ev.eventType ?? "").toLowerCase();
|
||||||
|
if (eventType === "mold-change") continue;
|
||||||
const { blob, inner } = parseBlob(ev.data);
|
const { blob, inner } = parseBlob(ev.data);
|
||||||
const status = String(blob?.status ?? inner?.status ?? "").toLowerCase();
|
const status = String(blob?.status ?? inner?.status ?? "").toLowerCase();
|
||||||
const severity = String(ev.severity ?? "").toLowerCase();
|
const severity = String(ev.severity ?? "").toLowerCase();
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
|
|||||||
const STOP_TYPES = new Set(["microstop", "macrostop"]);
|
const STOP_TYPES = new Set(["microstop", "macrostop"]);
|
||||||
const STOP_STATUS = new Set(["STOP", "DOWN", "OFFLINE"]);
|
const STOP_STATUS = new Set(["STOP", "DOWN", "OFFLINE"]);
|
||||||
const CACHE_TTL_SEC = 180;
|
const CACHE_TTL_SEC = 180;
|
||||||
const MOLD_IDLE_MIN = 10;
|
const MOLD_LOOKBACK_MS = 14 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function safeNum(value: unknown) {
|
function safeNum(value: unknown) {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
@@ -37,6 +37,32 @@ function safeNum(value: unknown) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeToken(value: unknown) {
|
||||||
|
return String(value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function workOrderKey(value: unknown) {
|
||||||
|
const token = normalizeToken(value);
|
||||||
|
return token ? token.toUpperCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function skuKey(value: unknown) {
|
||||||
|
const token = normalizeToken(value);
|
||||||
|
return token ? token.toUpperCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeByKey<T>(rows: T[], keyFn: (row: T) => string) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = keyFn(row);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function toIso(value?: Date | null) {
|
function toIso(value?: Date | null) {
|
||||||
return value ? value.toISOString() : null;
|
return value ? value.toISOString() : null;
|
||||||
}
|
}
|
||||||
@@ -134,6 +160,18 @@ function normalizeShiftAlias(shift?: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function eventDurationSec(data: unknown) {
|
function eventDurationSec(data: unknown) {
|
||||||
|
const inner = extractEventData(data);
|
||||||
|
return (
|
||||||
|
safeNum(inner.stoppage_duration_seconds) ??
|
||||||
|
safeNum(inner.stop_duration_seconds) ??
|
||||||
|
safeNum(inner.duration_seconds) ??
|
||||||
|
safeNum(inner.duration_sec) ??
|
||||||
|
safeNum(inner.durationSeconds) ??
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEventData(data: unknown) {
|
||||||
let blob = data;
|
let blob = data;
|
||||||
if (typeof blob === "string") {
|
if (typeof blob === "string") {
|
||||||
try {
|
try {
|
||||||
@@ -148,14 +186,26 @@ function eventDurationSec(data: unknown) {
|
|||||||
typeof innerCandidate === "object" && innerCandidate !== null
|
typeof innerCandidate === "object" && innerCandidate !== null
|
||||||
? (innerCandidate as Record<string, unknown>)
|
? (innerCandidate as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
function eventStatus(data: unknown) {
|
||||||
safeNum(inner.stoppage_duration_seconds) ??
|
const inner = extractEventData(data);
|
||||||
safeNum(inner.stop_duration_seconds) ??
|
return String(inner.status ?? "").trim().toLowerCase();
|
||||||
safeNum(inner.duration_seconds) ??
|
}
|
||||||
safeNum(record?.durationSeconds) ??
|
|
||||||
0
|
function eventIncidentKey(data: unknown, eventType: string, ts: Date) {
|
||||||
);
|
const inner = extractEventData(data);
|
||||||
|
const direct = String(inner.incidentKey ?? inner.incident_key ?? "").trim();
|
||||||
|
if (direct) return direct;
|
||||||
|
const startMs = safeNum(inner.start_ms) ?? safeNum(inner.startMs);
|
||||||
|
if (startMs != null) return `${eventType}:${Math.trunc(startMs)}`;
|
||||||
|
return `${eventType}:${ts.getTime()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moldStartMs(data: unknown, fallbackTs: Date) {
|
||||||
|
const inner = extractEventData(data);
|
||||||
|
return Math.trunc(safeNum(inner.start_ms) ?? safeNum(inner.startMs) ?? fallbackTs.getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
function avg(sum: number, count: number) {
|
function avg(sum: number, count: number) {
|
||||||
@@ -193,12 +243,14 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
if (!machines.length) {
|
if (!machines.length) {
|
||||||
return {
|
return {
|
||||||
range: { start: params.start.toISOString(), end: params.end.toISOString() },
|
range: { start: params.start.toISOString(), end: params.end.toISOString() },
|
||||||
|
availableShifts: [],
|
||||||
machines: [],
|
machines: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const machineIds = machines.map((m) => m.id);
|
const machineIds = machines.map((m) => m.id);
|
||||||
const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, hbRangeRaw, hbLatestRaw] =
|
const moldStartLookback = new Date(params.end.getTime() - MOLD_LOOKBACK_MS);
|
||||||
|
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 },
|
||||||
@@ -218,6 +270,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
select: {
|
select: {
|
||||||
machineId: true,
|
machineId: true,
|
||||||
ts: true,
|
ts: true,
|
||||||
|
cycleCount: true,
|
||||||
workOrderId: true,
|
workOrderId: true,
|
||||||
sku: true,
|
sku: true,
|
||||||
goodDelta: true,
|
goodDelta: true,
|
||||||
@@ -233,6 +286,13 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
select: {
|
select: {
|
||||||
machineId: true,
|
machineId: true,
|
||||||
ts: true,
|
ts: true,
|
||||||
|
workOrderId: true,
|
||||||
|
sku: true,
|
||||||
|
good: true,
|
||||||
|
scrap: true,
|
||||||
|
goodParts: true,
|
||||||
|
scrapParts: true,
|
||||||
|
cycleCount: true,
|
||||||
oee: true,
|
oee: true,
|
||||||
availability: true,
|
availability: true,
|
||||||
performance: true,
|
performance: true,
|
||||||
@@ -257,6 +317,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
machineId: { in: machineIds },
|
machineId: { in: machineIds },
|
||||||
kind: "downtime",
|
kind: "downtime",
|
||||||
|
reasonCode: { not: "MOLD_CHANGE" },
|
||||||
capturedAt: { gte: params.start, lte: params.end },
|
capturedAt: { gte: params.start, lte: params.end },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -312,6 +373,20 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
status: true,
|
status: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
prisma.machineEvent.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: { in: machineIds },
|
||||||
|
eventType: "mold-change",
|
||||||
|
ts: { gte: moldStartLookback, lte: params.end },
|
||||||
|
},
|
||||||
|
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
ts: true,
|
||||||
|
data: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const timeZone = settings?.timezone || "UTC";
|
const timeZone = settings?.timezone || "UTC";
|
||||||
@@ -333,13 +408,13 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
const hbRange = targetShiftName ? hbRangeRaw.filter((row) => inTargetShift(row.ts)) : hbRangeRaw;
|
const hbRange = targetShiftName ? hbRangeRaw.filter((row) => inTargetShift(row.ts)) : hbRangeRaw;
|
||||||
|
|
||||||
const cyclesByMachine = new Map<string, typeof cycles>();
|
const cyclesByMachine = new Map<string, typeof cycles>();
|
||||||
const cyclesAllByMachine = new Map<string, typeof cyclesRaw>();
|
|
||||||
const kpisByMachine = new Map<string, typeof kpis>();
|
const kpisByMachine = new Map<string, typeof kpis>();
|
||||||
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 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>();
|
||||||
|
|
||||||
for (const row of cycles) {
|
for (const row of cycles) {
|
||||||
const list = cyclesByMachine.get(row.machineId) ?? [];
|
const list = cyclesByMachine.get(row.machineId) ?? [];
|
||||||
@@ -347,12 +422,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
cyclesByMachine.set(row.machineId, list);
|
cyclesByMachine.set(row.machineId, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const row of cyclesRaw) {
|
|
||||||
const list = cyclesAllByMachine.get(row.machineId) ?? [];
|
|
||||||
list.push(row);
|
|
||||||
cyclesAllByMachine.set(row.machineId, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of kpis) {
|
for (const row of kpis) {
|
||||||
const list = kpisByMachine.get(row.machineId) ?? [];
|
const list = kpisByMachine.get(row.machineId) ?? [];
|
||||||
list.push(row);
|
list.push(row);
|
||||||
@@ -383,49 +452,227 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
hbRangeByMachine.set(row.machineId, list);
|
hbRangeByMachine.set(row.machineId, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const row of moldEventsRaw) {
|
||||||
|
const list = moldEventsByMachine.get(row.machineId) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
moldEventsByMachine.set(row.machineId, list);
|
||||||
|
}
|
||||||
|
|
||||||
const machineRows: RecapMachine[] = machines.map((machine) => {
|
const machineRows: RecapMachine[] = machines.map((machine) => {
|
||||||
const machineCycles = cyclesByMachine.get(machine.id) ?? [];
|
const machineCycles = cyclesByMachine.get(machine.id) ?? [];
|
||||||
const machineCyclesAll = cyclesAllByMachine.get(machine.id) ?? [];
|
|
||||||
const machineKpis = kpisByMachine.get(machine.id) ?? [];
|
const machineKpis = kpisByMachine.get(machine.id) ?? [];
|
||||||
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 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 targetBySku = new Map<string, number>();
|
const dedupedCycles = dedupeByKey(
|
||||||
for (const wo of machineWorkOrders) {
|
machineCycles,
|
||||||
if (!wo.sku || wo.targetQty == null) continue;
|
(cycle) =>
|
||||||
targetBySku.set(wo.sku, (targetBySku.get(wo.sku) ?? 0) + Number(wo.targetQty));
|
`${cycle.ts.getTime()}:${safeNum(cycle.cycleCount) ?? "na"}:${workOrderKey(cycle.workOrderId)}:${skuKey(cycle.sku)}:${safeNum(cycle.goodDelta) ?? "na"}:${safeNum(cycle.scrapDelta) ?? "na"}`
|
||||||
|
);
|
||||||
|
const dedupedKpis = dedupeByKey(
|
||||||
|
machineKpis,
|
||||||
|
(kpi) =>
|
||||||
|
`${kpi.ts.getTime()}:${workOrderKey(kpi.workOrderId)}:${skuKey(kpi.sku)}:${safeNum(kpi.goodParts) ?? safeNum(kpi.good) ?? "na"}:${safeNum(kpi.scrapParts) ?? safeNum(kpi.scrap) ?? "na"}:${safeNum(kpi.cycleCount) ?? "na"}`
|
||||||
|
);
|
||||||
|
const machineWorkOrdersSorted = [...machineWorkOrders].sort(
|
||||||
|
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetBySku = new Map<string, { sku: string; target: number }>();
|
||||||
|
for (const wo of machineWorkOrdersSorted) {
|
||||||
|
const sku = normalizeToken(wo.sku);
|
||||||
|
const target = safeNum(wo.targetQty);
|
||||||
|
if (!sku || target == null || target <= 0) continue;
|
||||||
|
const key = skuKey(sku);
|
||||||
|
const current = targetBySku.get(key);
|
||||||
|
if (current) {
|
||||||
|
current.target += Math.max(0, Math.trunc(target));
|
||||||
|
} else {
|
||||||
|
targetBySku.set(key, { sku, target: Math.max(0, Math.trunc(target)) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const skuMap = new Map<string, { sku: string; good: number; scrap: number; target: number | null }>();
|
type SkuAggregate = {
|
||||||
|
machineName: string;
|
||||||
|
sku: string;
|
||||||
|
good: number;
|
||||||
|
scrap: number;
|
||||||
|
target: number | null;
|
||||||
|
};
|
||||||
|
const skuMap = new Map<string, SkuAggregate>();
|
||||||
|
const rangeByWorkOrder = new Map<string, { goodParts: number; scrapParts: number; firstTs: Date | null; lastTs: Date | null }>();
|
||||||
|
const kpiLatestByWorkOrder = new Map<string, { good: number; scrap: number; ts: Date; sku: string | null }>();
|
||||||
|
let latestTelemetry: { ts: Date; workOrderId: string | null; sku: string | null } | null = null;
|
||||||
let goodParts = 0;
|
let goodParts = 0;
|
||||||
let scrapParts = 0;
|
let scrapParts = 0;
|
||||||
|
|
||||||
for (const cycle of machineCycles) {
|
const ensureSkuRow = (skuInput: string | null) => {
|
||||||
const sku = cycle.sku || "N/A";
|
const skuToken = normalizeToken(skuInput) || "N/A";
|
||||||
const good = safeNum(cycle.goodDelta) ?? 0;
|
const key = skuKey(skuToken);
|
||||||
const scrap = safeNum(cycle.scrapDelta) ?? 0;
|
const existing = skuMap.get(key);
|
||||||
goodParts += good;
|
if (existing) return existing;
|
||||||
scrapParts += scrap;
|
const target = targetBySku.get(key)?.target ?? null;
|
||||||
|
const created: SkuAggregate = {
|
||||||
const row = skuMap.get(sku) ?? {
|
machineName: machine.name,
|
||||||
sku,
|
sku: skuToken,
|
||||||
good: 0,
|
good: 0,
|
||||||
scrap: 0,
|
scrap: 0,
|
||||||
target: targetBySku.has(sku) ? targetBySku.get(sku) ?? null : null,
|
target,
|
||||||
};
|
};
|
||||||
row.good += good;
|
skuMap.set(key, created);
|
||||||
row.scrap += scrap;
|
return created;
|
||||||
skuMap.set(sku, row);
|
};
|
||||||
|
|
||||||
|
type KpiRangeAggregate = {
|
||||||
|
workOrderId: string | null;
|
||||||
|
sku: string | null;
|
||||||
|
minGood: number | null;
|
||||||
|
maxGood: number | null;
|
||||||
|
minScrap: number | null;
|
||||||
|
maxScrap: number | null;
|
||||||
|
firstTs: Date | null;
|
||||||
|
lastTs: Date | null;
|
||||||
|
};
|
||||||
|
const kpiRanges = new Map<string, KpiRangeAggregate>();
|
||||||
|
|
||||||
|
for (const kpi of dedupedKpis) {
|
||||||
|
if (!latestTelemetry || kpi.ts > latestTelemetry.ts) {
|
||||||
|
latestTelemetry = {
|
||||||
|
ts: kpi.ts,
|
||||||
|
workOrderId: normalizeToken(kpi.workOrderId) || null,
|
||||||
|
sku: normalizeToken(kpi.sku) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const workOrderId = normalizeToken(kpi.workOrderId) || null;
|
||||||
|
const sku = normalizeToken(kpi.sku) || null;
|
||||||
|
const goodCounterRaw = safeNum(kpi.goodParts) ?? safeNum(kpi.good);
|
||||||
|
const scrapCounterRaw = safeNum(kpi.scrapParts) ?? safeNum(kpi.scrap);
|
||||||
|
const goodCounter = goodCounterRaw != null ? Math.max(0, Math.trunc(goodCounterRaw)) : null;
|
||||||
|
const scrapCounter = scrapCounterRaw != null ? Math.max(0, Math.trunc(scrapCounterRaw)) : null;
|
||||||
|
|
||||||
|
const woKey = workOrderKey(workOrderId);
|
||||||
|
if (woKey) {
|
||||||
|
const existingLatest = kpiLatestByWorkOrder.get(woKey);
|
||||||
|
if (!existingLatest || kpi.ts > existingLatest.ts) {
|
||||||
|
kpiLatestByWorkOrder.set(woKey, {
|
||||||
|
good: goodCounter ?? 0,
|
||||||
|
scrap: scrapCounter ?? 0,
|
||||||
|
ts: kpi.ts,
|
||||||
|
sku,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((goodCounter == null && scrapCounter == null) || (!workOrderId && !sku)) continue;
|
||||||
|
|
||||||
|
const key = `${woKey || "__none"}::${skuKey(sku) || "__none"}`;
|
||||||
|
const current = kpiRanges.get(key) ?? {
|
||||||
|
workOrderId,
|
||||||
|
sku,
|
||||||
|
minGood: null,
|
||||||
|
maxGood: null,
|
||||||
|
minScrap: null,
|
||||||
|
maxScrap: null,
|
||||||
|
firstTs: null,
|
||||||
|
lastTs: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (goodCounter != null) {
|
||||||
|
current.minGood = current.minGood == null ? goodCounter : Math.min(current.minGood, goodCounter);
|
||||||
|
current.maxGood = current.maxGood == null ? goodCounter : Math.max(current.maxGood, goodCounter);
|
||||||
|
}
|
||||||
|
if (scrapCounter != null) {
|
||||||
|
current.minScrap = current.minScrap == null ? scrapCounter : Math.min(current.minScrap, scrapCounter);
|
||||||
|
current.maxScrap = current.maxScrap == null ? scrapCounter : Math.max(current.maxScrap, scrapCounter);
|
||||||
|
}
|
||||||
|
if (!current.firstTs || kpi.ts < current.firstTs) current.firstTs = kpi.ts;
|
||||||
|
if (!current.lastTs || kpi.ts > current.lastTs) current.lastTs = kpi.ts;
|
||||||
|
kpiRanges.set(key, current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kpiRanges.size > 0) {
|
||||||
|
for (const agg of kpiRanges.values()) {
|
||||||
|
const rangeGood = Math.max(0, (agg.maxGood ?? 0) - (agg.minGood ?? agg.maxGood ?? 0));
|
||||||
|
const rangeScrap = Math.max(0, (agg.maxScrap ?? 0) - (agg.minScrap ?? agg.maxScrap ?? 0));
|
||||||
|
const skuRow = ensureSkuRow(agg.sku);
|
||||||
|
skuRow.good += rangeGood;
|
||||||
|
skuRow.scrap += rangeScrap;
|
||||||
|
goodParts += rangeGood;
|
||||||
|
scrapParts += rangeScrap;
|
||||||
|
|
||||||
|
const woKey = workOrderKey(agg.workOrderId);
|
||||||
|
if (!woKey) continue;
|
||||||
|
const existing = rangeByWorkOrder.get(woKey) ?? {
|
||||||
|
goodParts: 0,
|
||||||
|
scrapParts: 0,
|
||||||
|
firstTs: null,
|
||||||
|
lastTs: null,
|
||||||
|
};
|
||||||
|
existing.goodParts += rangeGood;
|
||||||
|
existing.scrapParts += rangeScrap;
|
||||||
|
if (agg.firstTs && (!existing.firstTs || agg.firstTs < existing.firstTs)) existing.firstTs = agg.firstTs;
|
||||||
|
if (agg.lastTs && (!existing.lastTs || agg.lastTs > existing.lastTs)) existing.lastTs = agg.lastTs;
|
||||||
|
rangeByWorkOrder.set(woKey, existing);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const cycle of dedupedCycles) {
|
||||||
|
if (!latestTelemetry || cycle.ts > latestTelemetry.ts) {
|
||||||
|
latestTelemetry = {
|
||||||
|
ts: cycle.ts,
|
||||||
|
workOrderId: normalizeToken(cycle.workOrderId) || null,
|
||||||
|
sku: normalizeToken(cycle.sku) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const skuRow = ensureSkuRow(normalizeToken(cycle.sku) || null);
|
||||||
|
const good = Math.max(0, Math.trunc(safeNum(cycle.goodDelta) ?? 0));
|
||||||
|
const scrap = Math.max(0, Math.trunc(safeNum(cycle.scrapDelta) ?? 0));
|
||||||
|
skuRow.good += good;
|
||||||
|
skuRow.scrap += scrap;
|
||||||
|
goodParts += good;
|
||||||
|
scrapParts += scrap;
|
||||||
|
|
||||||
|
const woKey = workOrderKey(cycle.workOrderId);
|
||||||
|
if (!woKey) continue;
|
||||||
|
const existing = rangeByWorkOrder.get(woKey) ?? {
|
||||||
|
goodParts: 0,
|
||||||
|
scrapParts: 0,
|
||||||
|
firstTs: null,
|
||||||
|
lastTs: null,
|
||||||
|
};
|
||||||
|
existing.goodParts += good;
|
||||||
|
existing.scrapParts += scrap;
|
||||||
|
if (!existing.firstTs || cycle.ts < existing.firstTs) existing.firstTs = cycle.ts;
|
||||||
|
if (!existing.lastTs || cycle.ts > existing.lastTs) existing.lastTs = cycle.ts;
|
||||||
|
rangeByWorkOrder.set(woKey, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openWorkOrders = machineWorkOrdersSorted.filter(
|
||||||
|
(wo) => String(wo.status).toUpperCase() !== "COMPLETED"
|
||||||
|
);
|
||||||
|
for (const wo of openWorkOrders) {
|
||||||
|
ensureSkuRow(normalizeToken(wo.sku) || null);
|
||||||
|
}
|
||||||
|
if (latestTelemetry?.sku) ensureSkuRow(latestTelemetry.sku);
|
||||||
|
|
||||||
const bySku = [...skuMap.values()]
|
const bySku = [...skuMap.values()]
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
|
const target = row.target ?? targetBySku.get(skuKey(row.sku))?.target ?? null;
|
||||||
const produced = row.good + row.scrap;
|
const produced = row.good + row.scrap;
|
||||||
const progressPct = row.target && row.target > 0 ? round2((produced / row.target) * 100) : null;
|
const progressPct = target && target > 0 ? round2((produced / target) * 100) : null;
|
||||||
return { ...row, progressPct };
|
return {
|
||||||
|
machineName: row.machineName,
|
||||||
|
sku: row.sku,
|
||||||
|
good: row.good,
|
||||||
|
scrap: row.scrap,
|
||||||
|
target,
|
||||||
|
progressPct,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.good - a.good);
|
.sort((a, b) => b.good - a.good);
|
||||||
|
|
||||||
@@ -438,7 +685,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
let qualitySum = 0;
|
let qualitySum = 0;
|
||||||
let qualityCount = 0;
|
let qualityCount = 0;
|
||||||
|
|
||||||
for (const kpi of machineKpis) {
|
for (const kpi of dedupedKpis) {
|
||||||
const oee = safeNum(kpi.oee);
|
const oee = safeNum(kpi.oee);
|
||||||
const availability = safeNum(kpi.availability);
|
const availability = safeNum(kpi.availability);
|
||||||
const performance = safeNum(kpi.performance);
|
const performance = safeNum(kpi.performance);
|
||||||
@@ -508,29 +755,13 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
ongoingStopMin = round2(Math.max(0, (params.end.getTime() - downStart.getTime()) / 60000));
|
ongoingStopMin = round2(Math.max(0, (params.end.getTime() - downStart.getTime()) / 60000));
|
||||||
}
|
}
|
||||||
|
|
||||||
const cyclesByWorkOrder = new Map<
|
const completed = machineWorkOrdersSorted
|
||||||
string,
|
|
||||||
{ goodParts: number; firstTs: Date | null; lastTs: Date | null }
|
|
||||||
>();
|
|
||||||
for (const cycle of machineCycles) {
|
|
||||||
if (!cycle.workOrderId) continue;
|
|
||||||
const current = cyclesByWorkOrder.get(cycle.workOrderId) ?? {
|
|
||||||
goodParts: 0,
|
|
||||||
firstTs: null,
|
|
||||||
lastTs: null,
|
|
||||||
};
|
|
||||||
current.goodParts += safeNum(cycle.goodDelta) ?? 0;
|
|
||||||
if (!current.firstTs || cycle.ts < current.firstTs) current.firstTs = cycle.ts;
|
|
||||||
if (!current.lastTs || cycle.ts > current.lastTs) current.lastTs = cycle.ts;
|
|
||||||
cyclesByWorkOrder.set(cycle.workOrderId, current);
|
|
||||||
}
|
|
||||||
|
|
||||||
const completed = machineWorkOrders
|
|
||||||
.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 = cyclesByWorkOrder.get(wo.workOrderId) ?? {
|
const progress = rangeByWorkOrder.get(workOrderKey(wo.workOrderId)) ?? {
|
||||||
goodParts: 0,
|
goodParts: 0,
|
||||||
|
scrapParts: 0,
|
||||||
firstTs: null,
|
firstTs: null,
|
||||||
lastTs: null,
|
lastTs: null,
|
||||||
};
|
};
|
||||||
@@ -547,25 +778,53 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
})
|
})
|
||||||
.sort((a, b) => b.goodParts - a.goodParts);
|
.sort((a, b) => b.goodParts - a.goodParts);
|
||||||
|
|
||||||
const activeWo = machineWorkOrders.find((wo) => String(wo.status).toUpperCase() !== "COMPLETED") ?? null;
|
const telemetryWorkOrderKey = workOrderKey(latestTelemetry?.workOrderId);
|
||||||
|
const matchedTelemetryWo = telemetryWorkOrderKey
|
||||||
|
? openWorkOrders.find((wo) => workOrderKey(wo.workOrderId) === telemetryWorkOrderKey) ?? null
|
||||||
|
: null;
|
||||||
|
const activeWo = matchedTelemetryWo ?? openWorkOrders[0] ?? null;
|
||||||
|
const activeWorkOrderId =
|
||||||
|
normalizeToken(latestTelemetry?.workOrderId) || normalizeToken(activeWo?.workOrderId) || null;
|
||||||
|
const activeWorkOrderSku =
|
||||||
|
normalizeToken(latestTelemetry?.sku) || normalizeToken(activeWo?.sku) || null;
|
||||||
|
const activeWorkOrderKey = workOrderKey(activeWorkOrderId);
|
||||||
|
const activeTargetSource =
|
||||||
|
activeWorkOrderKey
|
||||||
|
? machineWorkOrdersSorted.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ?? activeWo
|
||||||
|
: activeWo;
|
||||||
|
|
||||||
let activeProgressPct: number | null = null;
|
let activeProgressPct: number | null = null;
|
||||||
let activeStartedAt: string | null = null;
|
let activeStartedAt: string | null = null;
|
||||||
if (activeWo) {
|
if (activeWorkOrderId) {
|
||||||
const progress = cyclesByWorkOrder.get(activeWo.workOrderId);
|
const rangeProgress = activeWorkOrderKey ? rangeByWorkOrder.get(activeWorkOrderKey) : null;
|
||||||
const produced = (progress?.goodParts ?? 0) + (machineCycles
|
const cumulativeProgress = activeWorkOrderKey ? kpiLatestByWorkOrder.get(activeWorkOrderKey) : null;
|
||||||
.filter((row) => row.workOrderId === activeWo.workOrderId)
|
const producedForProgress = cumulativeProgress
|
||||||
.reduce((sum, row) => sum + (safeNum(row.scrapDelta) ?? 0), 0));
|
? cumulativeProgress.good + cumulativeProgress.scrap
|
||||||
if (activeWo.targetQty && activeWo.targetQty > 0) {
|
: (rangeProgress?.goodParts ?? 0) + (rangeProgress?.scrapParts ?? 0);
|
||||||
activeProgressPct = round2((produced / activeWo.targetQty) * 100);
|
const targetQty = safeNum(activeTargetSource?.targetQty);
|
||||||
|
if (targetQty && targetQty > 0) {
|
||||||
|
activeProgressPct = round2((producedForProgress / targetQty) * 100);
|
||||||
}
|
}
|
||||||
activeStartedAt = toIso(progress?.firstTs ?? activeWo.createdAt);
|
activeStartedAt = toIso(rangeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cutoffTs = new Date(params.end.getTime() - MOLD_IDLE_MIN * 60000);
|
const moldActiveByIncident = new Map<string, number>();
|
||||||
const hasRecentCycle = machineCyclesAll.some((cycle) => cycle.ts >= cutoffTs && cycle.ts <= params.end);
|
for (const event of machineMoldEvents) {
|
||||||
const moldChangeInProgress =
|
const key = eventIncidentKey(event.data, "mold-change", event.ts);
|
||||||
!!activeWo && String(activeWo.status).toUpperCase() === "PENDING" && !hasRecentCycle;
|
const status = eventStatus(event.data);
|
||||||
|
if (status === "resolved") {
|
||||||
|
moldActiveByIncident.delete(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (status === "active" || !status) {
|
||||||
|
moldActiveByIncident.set(key, moldStartMs(event.data, event.ts));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let moldChangeStartMs: number | null = null;
|
||||||
|
for (const startMs of moldActiveByIncident.values()) {
|
||||||
|
if (moldChangeStartMs == null || startMs > moldChangeStartMs) moldChangeStartMs = startMs;
|
||||||
|
}
|
||||||
|
const moldChangeInProgress = moldChangeStartMs != null;
|
||||||
|
|
||||||
let uptimePct: number | null = null;
|
let uptimePct: number | null = null;
|
||||||
if (machineHbRange.length) {
|
if (machineHbRange.length) {
|
||||||
@@ -584,7 +843,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
production: {
|
production: {
|
||||||
goodParts,
|
goodParts,
|
||||||
scrapParts,
|
scrapParts,
|
||||||
totalCycles: machineCycles.length,
|
totalCycles: dedupedCycles.length,
|
||||||
bySku,
|
bySku,
|
||||||
},
|
},
|
||||||
oee: {
|
oee: {
|
||||||
@@ -601,15 +860,16 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
},
|
},
|
||||||
workOrders: {
|
workOrders: {
|
||||||
completed,
|
completed,
|
||||||
active: activeWo
|
active: activeWorkOrderId
|
||||||
? {
|
? {
|
||||||
id: activeWo.workOrderId,
|
id: activeWorkOrderId,
|
||||||
sku: activeWo.sku,
|
sku: activeWorkOrderSku,
|
||||||
progressPct: activeProgressPct,
|
progressPct: activeProgressPct,
|
||||||
startedAt: activeStartedAt,
|
startedAt: activeStartedAt,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
moldChangeInProgress,
|
moldChangeInProgress,
|
||||||
|
moldChangeStartMs,
|
||||||
},
|
},
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
lastSeenAt: toIso(latestTs),
|
lastSeenAt: toIso(latestTs),
|
||||||
@@ -623,6 +883,10 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
start: params.start.toISOString(),
|
start: params.start.toISOString(),
|
||||||
end: params.end.toISOString(),
|
end: params.end.toISOString(),
|
||||||
},
|
},
|
||||||
|
availableShifts: orderedEnabledShifts.map((shift, idx) => ({
|
||||||
|
id: `shift${idx + 1}`,
|
||||||
|
name: shift.name,
|
||||||
|
})),
|
||||||
machines: machineRows,
|
machines: machineRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type RecapSkuRow = {
|
export type RecapSkuRow = {
|
||||||
|
machineName: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
good: number;
|
good: number;
|
||||||
scrap: number;
|
scrap: number;
|
||||||
@@ -46,6 +47,7 @@ export type RecapMachine = {
|
|||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
moldChangeInProgress: boolean;
|
moldChangeInProgress: boolean;
|
||||||
|
moldChangeStartMs: number | null;
|
||||||
};
|
};
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
lastSeenAt: string | null;
|
lastSeenAt: string | null;
|
||||||
@@ -53,11 +55,56 @@ export type RecapMachine = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RecapTimelineSegment =
|
||||||
|
| {
|
||||||
|
type: "production";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
workOrderId: string | null;
|
||||||
|
sku: string | null;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "mold-change";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
fromMoldId: string | null;
|
||||||
|
toMoldId: string | null;
|
||||||
|
durationSec: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "macrostop" | "microstop" | "slow-cycle";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
reason: string | null;
|
||||||
|
durationSec: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "idle";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecapTimelineResponse = {
|
||||||
|
range: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
segments: RecapTimelineSegment[];
|
||||||
|
};
|
||||||
|
|
||||||
export type RecapResponse = {
|
export type RecapResponse = {
|
||||||
range: {
|
range: {
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
};
|
};
|
||||||
|
availableShifts: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
machines: RecapMachine[];
|
machines: RecapMachine[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user