recent
This commit is contained in:
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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 RecapProductionBySku from "@/components/recap/RecapProductionBySku";
|
||||
import RecapDowntimeTop from "@/components/recap/RecapDowntimeTop";
|
||||
import RecapWorkOrderStatus from "@/components/recap/RecapWorkOrderStatus";
|
||||
import RecapMachineStatus from "@/components/recap/RecapMachineStatus";
|
||||
import RecapTimeline from "@/components/recap/RecapTimeline";
|
||||
|
||||
type Props = {
|
||||
initialData: RecapResponse;
|
||||
@@ -46,6 +47,25 @@ export default function RecapClient({ initialData, initialFilters }: Props) {
|
||||
return "24h";
|
||||
});
|
||||
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(() => {
|
||||
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];
|
||||
}, [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(() => {
|
||||
let good = 0;
|
||||
let scrap = 0;
|
||||
@@ -132,6 +187,11 @@ export default function RecapClient({ initialData, initialFilters }: Props) {
|
||||
}, [data.machines]);
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -174,9 +234,11 @@ export default function RecapClient({ initialData, initialFilters }: Props) {
|
||||
onChange={(event) => setShift(event.target.value)}
|
||||
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>
|
||||
<option value="shift2">{t("recap.shift.2")}</option>
|
||||
<option value="shift3">{t("recap.shift.3")}</option>
|
||||
{shiftOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
|
||||
@@ -202,7 +264,8 @@ export default function RecapClient({ initialData, initialFilters }: Props) {
|
||||
|
||||
{bannerMold ? (
|
||||
<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>
|
||||
) : null}
|
||||
{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}
|
||||
|
||||
{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} />
|
||||
|
||||
<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: [],
|
||||
active: null,
|
||||
moldChangeInProgress: false,
|
||||
moldChangeStartMs: null,
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
const bad = (status: number, error: string) =>
|
||||
NextResponse.json({ ok: false, error }, { status });
|
||||
@@ -24,6 +25,7 @@ export async function GET(req: Request) {
|
||||
|
||||
const machineId = url.searchParams.get("machineId"); // optional
|
||||
const reasonCode = url.searchParams.get("reasonCode"); // optional
|
||||
const includeMoldChange = url.searchParams.get("includeMoldChange") === "true";
|
||||
|
||||
const limitRaw = url.searchParams.get("limit");
|
||||
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
|
||||
// We only return rows that have an episodeId (true downtime episodes)
|
||||
const where: any = {
|
||||
const where: Prisma.ReasonEntryWhereInput = {
|
||||
orgId,
|
||||
kind: "downtime",
|
||||
episodeId: { not: null },
|
||||
...(includeMoldChange ? {} : { reasonCode: { not: "MOLD_CHANGE" } }),
|
||||
capturedAt: {
|
||||
gte: start,
|
||||
...(beforeDate ? { lt: beforeDate } : {}),
|
||||
@@ -122,6 +125,7 @@ export async function GET(req: Request) {
|
||||
start,
|
||||
machineId: machineId ?? null,
|
||||
reasonCode: reasonCode ?? null,
|
||||
includeMoldChange,
|
||||
limit,
|
||||
before: before ?? null,
|
||||
nextBefore, // pass this back for pagination
|
||||
|
||||
@@ -20,9 +20,10 @@ export async function GET(req: Request) {
|
||||
|
||||
const machineId = url.searchParams.get("machineId"); // optional
|
||||
const kind = (url.searchParams.get("kind") || "downtime").toLowerCase();
|
||||
const includeMoldChange = url.searchParams.get("includeMoldChange") === "true";
|
||||
|
||||
if (kind !== "downtime" && kind !== "scrap") {
|
||||
return bad(400, "Invalid kind (downtime|scrap)");
|
||||
if (kind !== "downtime" && kind !== "scrap" && kind !== "planned-downtime") {
|
||||
return bad(400, "Invalid kind (downtime|scrap|planned-downtime)");
|
||||
}
|
||||
|
||||
// ✅ If machineId provided, verify it belongs to this org
|
||||
@@ -40,7 +41,9 @@ export async function GET(req: Request) {
|
||||
where: {
|
||||
orgId,
|
||||
...(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 },
|
||||
},
|
||||
_sum: {
|
||||
@@ -53,7 +56,7 @@ export async function GET(req: Request) {
|
||||
const itemsRaw = grouped
|
||||
.map((g) => {
|
||||
const value =
|
||||
kind === "downtime"
|
||||
kind === "downtime" || kind === "planned-downtime"
|
||||
? Math.round(((g._sum.durationSeconds ?? 0) / 60) * 10) / 10 // minutes, 1 decimal
|
||||
: g._sum.scrapQty ?? 0;
|
||||
|
||||
@@ -64,7 +67,9 @@ export async function GET(req: Request) {
|
||||
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);
|
||||
|
||||
@@ -83,7 +88,7 @@ export async function GET(req: Request) {
|
||||
return {
|
||||
reasonCode: x.reasonCode,
|
||||
reasonLabel: x.reasonLabel,
|
||||
minutesLost: kind === "downtime" ? x.value : undefined,
|
||||
minutesLost: kind === "downtime" || kind === "planned-downtime" ? x.value : undefined,
|
||||
scrapQty: kind === "scrap" ? x.value : undefined,
|
||||
pctOfTotal,
|
||||
cumulativePct,
|
||||
@@ -106,9 +111,10 @@ export async function GET(req: Request) {
|
||||
orgId,
|
||||
machineId: machineId ?? null,
|
||||
kind,
|
||||
includeMoldChange,
|
||||
range, // ✅ 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,
|
||||
rows,
|
||||
top3,
|
||||
|
||||
@@ -68,7 +68,8 @@ function normalizeCycleInput(raw: unknown): Record<string, unknown> | null {
|
||||
cycle_count: fromRowOrData(["cycle_count", "cycleCount"]),
|
||||
work_order_id: fromRowOrData(["work_order_id", "workOrderId"]),
|
||||
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"]),
|
||||
ts: fromRowOrData(["ts", "tsMs"]),
|
||||
event_timestamp: fromRowOrData(["event_timestamp", "eventTimestamp"]),
|
||||
|
||||
@@ -40,6 +40,7 @@ const CANON_TYPE: Record<string, string> = {
|
||||
"down": "stop",
|
||||
"downtime-acknowledged": "downtime-acknowledged",
|
||||
"scrap-manual-entry": "scrap-manual-entry",
|
||||
"mold-change": "mold-change",
|
||||
};
|
||||
|
||||
const ALLOWED_TYPES = new Set([
|
||||
@@ -54,6 +55,7 @@ const ALLOWED_TYPES = new Set([
|
||||
"predictive-oee-decline",
|
||||
"downtime-acknowledged",
|
||||
"scrap-manual-entry",
|
||||
"mold-change",
|
||||
]);
|
||||
|
||||
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 (evRecord.is_update || evRecord.is_auto_ack || dataObj.is_update || dataObj.is_auto_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> =
|
||||
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",
|
||||
categoryId: "unclassified",
|
||||
@@ -453,7 +472,7 @@ export async function POST(req: Request) {
|
||||
reasonCode: "UNCLASSIFIED",
|
||||
reasonText: "Unclassified",
|
||||
incidentKey: row.id,
|
||||
} as Record<string, unknown>);
|
||||
} as Record<string, unknown>));
|
||||
|
||||
const inferredKind: ReasonCatalogKind =
|
||||
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 durationSeconds =
|
||||
numberFrom(evDowntime?.durationSeconds) ??
|
||||
numberFrom(evData.duration_sec) ??
|
||||
numberFrom(evData.stoppage_duration_seconds) ??
|
||||
numberFrom(evData.stop_duration_seconds) ??
|
||||
(stopSecForReason != null ? stopSecForReason : null) ??
|
||||
null;
|
||||
const episodeEndTsMs =
|
||||
numberFrom(evData.end_ms) ??
|
||||
numberFrom(evDowntime?.episodeEndTsMs) ??
|
||||
numberFrom(evDowntime?.acknowledgedAtMs) ??
|
||||
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";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/machines");
|
||||
redirect("/recap");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user