Downtime catalog
This commit is contained in:
@@ -38,6 +38,7 @@ type AlertsInboxEvent = {
|
||||
status?: string | null;
|
||||
shift?: string | null;
|
||||
alertId?: string | null;
|
||||
incidentKey?: string | null;
|
||||
isUpdate?: boolean;
|
||||
isAutoAck?: boolean;
|
||||
};
|
||||
@@ -224,29 +225,34 @@ function resolveShift(
|
||||
}
|
||||
|
||||
function collapseAlertEvents(events: AlertsInboxEvent[]) {
|
||||
const byAlert = new Map<string, AlertsInboxEvent>();
|
||||
// Group by incidentKey (preferred — stable across the entire incident lifecycle)
|
||||
// OR alertId (fallback — for older or non-stoppage events).
|
||||
// Per group, keep AT MOST one "active" (oldest = when it first happened) and
|
||||
// one "resolved" (newest = when it actually ended). Result: max 2 entries per incident.
|
||||
const byGroup = new Map<string, AlertsInboxEvent>();
|
||||
const passthrough: AlertsInboxEvent[] = [];
|
||||
|
||||
for (const ev of events) {
|
||||
if (!ev.alertId) {
|
||||
const groupId = ev.incidentKey ?? ev.alertId;
|
||||
if (!groupId) {
|
||||
passthrough.push(ev);
|
||||
continue;
|
||||
}
|
||||
const statusKey = ev.status === "resolved" ? "resolved" : "active";
|
||||
const key = `${ev.alertId}:${statusKey}`;
|
||||
const existing = byAlert.get(key);
|
||||
const key = `${groupId}:${statusKey}`;
|
||||
const existing = byGroup.get(key);
|
||||
if (!existing) {
|
||||
byAlert.set(key, ev);
|
||||
byGroup.set(key, ev);
|
||||
continue;
|
||||
}
|
||||
const pickNewest = statusKey === "resolved";
|
||||
const shouldReplace = pickNewest
|
||||
? ev.ts.getTime() > existing.ts.getTime()
|
||||
: ev.ts.getTime() < existing.ts.getTime();
|
||||
if (shouldReplace) byAlert.set(key, ev);
|
||||
if (shouldReplace) byGroup.set(key, ev);
|
||||
}
|
||||
|
||||
const combined = [...passthrough, ...byAlert.values()];
|
||||
const combined = [...passthrough, ...byGroup.values()];
|
||||
combined.sort((a, b) => b.ts.getTime() - a.ts.getTime());
|
||||
return combined;
|
||||
}
|
||||
@@ -325,7 +331,12 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
const rawStatus = safeString(payload?.status ?? inner?.status);
|
||||
const isUpdate = safeBool(payload?.is_update ?? inner?.is_update);
|
||||
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
||||
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
|
||||
// Drop only auto-ack pings (every-10s refresh noise).
|
||||
// Keep is_update events: due to a Node-RED spread inheritance pattern,
|
||||
// virtually all events carry is_update=true even legitimate first-emission
|
||||
// and cycle-arrival resolved events. Dedup happens via collapseAlertEvents
|
||||
// grouping by incidentKey below.
|
||||
if (!includeUpdates && isAutoAck) continue;
|
||||
|
||||
const shiftName = resolveShift(shifts, shiftOverrides, ev.ts, timeZone);
|
||||
if (normalizedShift && shiftName !== normalizedShift) continue;
|
||||
@@ -349,6 +360,7 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
status: statusLabel,
|
||||
shift: shiftName,
|
||||
alertId: safeString(payload?.alert_id ?? inner?.alert_id),
|
||||
incidentKey: safeString(payload?.incidentKey ?? payload?.incident_key ?? inner?.incidentKey ?? inner?.incident_key),
|
||||
isUpdate,
|
||||
isAutoAck,
|
||||
});
|
||||
|
||||
363
lib/alerts/getAlertsInboxData.ts.bak
Normal file
363
lib/alerts/getAlertsInboxData.ts.bak
Normal file
@@ -0,0 +1,363 @@
|
||||
import { normalizeShiftOverrides } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const RANGE_MS: Record<string, number> = {
|
||||
"24h": 24 * 60 * 60 * 1000,
|
||||
"7d": 7 * 24 * 60 * 60 * 1000,
|
||||
"30d": 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
type AlertsInboxParams = {
|
||||
orgId: string;
|
||||
range?: string;
|
||||
start?: Date | null;
|
||||
end?: Date | null;
|
||||
machineId?: string;
|
||||
location?: string;
|
||||
eventType?: string;
|
||||
severity?: string;
|
||||
status?: string;
|
||||
shift?: string;
|
||||
includeUpdates?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
type AlertsInboxEvent = {
|
||||
id: string;
|
||||
ts: Date;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
machineId: string;
|
||||
machineName?: string | null;
|
||||
location?: string | null;
|
||||
workOrderId?: string | null;
|
||||
sku?: string | null;
|
||||
durationSec?: number | null;
|
||||
status?: string | null;
|
||||
shift?: string | null;
|
||||
alertId?: string | null;
|
||||
isUpdate?: boolean;
|
||||
isAutoAck?: boolean;
|
||||
};
|
||||
|
||||
function pickRange(range: string, start?: Date | null, end?: Date | null) {
|
||||
const now = new Date();
|
||||
if (range === "custom") {
|
||||
const startFallback = new Date(now.getTime() - RANGE_MS["24h"]);
|
||||
return {
|
||||
range,
|
||||
start: start ?? startFallback,
|
||||
end: end ?? now,
|
||||
};
|
||||
}
|
||||
const ms = RANGE_MS[range] ?? RANGE_MS["24h"];
|
||||
return { range, start: new Date(now.getTime() - ms), end: now };
|
||||
}
|
||||
|
||||
function safeString(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function safeNumber(value: unknown) {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function safeBool(value: unknown) {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function normalizeStatus(value?: string | null) {
|
||||
if (!value) return null;
|
||||
const raw = value.trim().toLowerCase();
|
||||
if (!raw) return null;
|
||||
if (raw === "in_progress" || raw === "in-progress" || raw === "open" || raw === "activa" || raw === "activo") {
|
||||
return "active";
|
||||
}
|
||||
if (raw === "resuelta" || raw === "resuelto" || raw === "closed" || raw === "ended" || raw === "done") {
|
||||
return "resolved";
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parsePayload(raw: unknown) {
|
||||
let parsed: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = raw;
|
||||
}
|
||||
}
|
||||
const payload =
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const innerCandidate = payload.data;
|
||||
const inner =
|
||||
innerCandidate && typeof innerCandidate === "object" && !Array.isArray(innerCandidate)
|
||||
? (innerCandidate as Record<string, unknown>)
|
||||
: payload;
|
||||
return { payload, inner };
|
||||
}
|
||||
|
||||
function extractDurationSec(raw: unknown) {
|
||||
const { payload, inner } = parsePayload(raw);
|
||||
const candidates = [
|
||||
inner?.duration_seconds,
|
||||
inner?.duration_sec,
|
||||
inner?.stoppage_duration_seconds,
|
||||
inner?.stop_duration_seconds,
|
||||
payload?.duration_seconds,
|
||||
payload?.duration_sec,
|
||||
payload?.stoppage_duration_seconds,
|
||||
payload?.stop_duration_seconds,
|
||||
];
|
||||
for (const val of candidates) {
|
||||
if (typeof val === "number" && Number.isFinite(val) && val >= 0) return val;
|
||||
}
|
||||
const msCandidates = [inner?.duration_ms, inner?.durationMs, payload?.duration_ms, payload?.durationMs];
|
||||
for (const val of msCandidates) {
|
||||
if (typeof val === "number" && Number.isFinite(val) && val >= 0) {
|
||||
return Math.round(val / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
const startMs = inner.start_ts ?? inner.startTs ?? payload.start_ts ?? payload.startTs ?? null;
|
||||
const endMs = inner.end_ts ?? inner.endTs ?? payload.end_ts ?? payload.endTs ?? null;
|
||||
if (typeof startMs === "number" && typeof endMs === "number" && endMs >= startMs) {
|
||||
return Math.round((endMs - startMs) / 1000);
|
||||
}
|
||||
|
||||
const actual = safeNumber(inner.actual_cycle_time ?? payload.actual_cycle_time);
|
||||
const theoretical = safeNumber(inner.theoretical_cycle_time ?? payload.theoretical_cycle_time);
|
||||
if (actual != null && theoretical != null) {
|
||||
return Math.max(0, actual - theoretical);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTimeMinutes(value?: string | null) {
|
||||
if (!value || !/^\d{2}:\d{2}$/.test(value)) return null;
|
||||
const [hh, mm] = value.split(":").map((n) => Number(n));
|
||||
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return null;
|
||||
return hh * 60 + mm;
|
||||
}
|
||||
|
||||
function getLocalMinutes(ts: Date, timeZone: string) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(ts);
|
||||
const hours = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
|
||||
const minutes = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
|
||||
return hours * 60 + minutes;
|
||||
} catch {
|
||||
return ts.getUTCHours() * 60 + ts.getUTCMinutes();
|
||||
}
|
||||
}
|
||||
|
||||
const WEEKDAY_KEY_MAP: Record<string, string> = {
|
||||
Sun: "sun",
|
||||
Mon: "mon",
|
||||
Tue: "tue",
|
||||
Wed: "wed",
|
||||
Thu: "thu",
|
||||
Fri: "fri",
|
||||
Sat: "sat",
|
||||
};
|
||||
|
||||
const WEEKDAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
|
||||
|
||||
function getLocalDayKey(ts: Date, timeZone: string) {
|
||||
try {
|
||||
const weekday = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
weekday: "short",
|
||||
}).format(ts);
|
||||
return WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()];
|
||||
} catch {
|
||||
return WEEKDAY_KEYS[ts.getUTCDay()];
|
||||
}
|
||||
}
|
||||
|
||||
type ShiftLike = {
|
||||
name: string;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
start?: string | null;
|
||||
end?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function resolveShift(
|
||||
shifts: ShiftLike[],
|
||||
overrides: Record<string, ShiftLike[]> | undefined,
|
||||
ts: Date,
|
||||
timeZone: string
|
||||
) {
|
||||
const dayKey = getLocalDayKey(ts, timeZone);
|
||||
const dayOverrides = overrides?.[dayKey];
|
||||
const activeShifts = dayOverrides ?? shifts;
|
||||
if (!activeShifts.length) return null;
|
||||
const nowMin = getLocalMinutes(ts, timeZone);
|
||||
for (const shift of activeShifts) {
|
||||
if (shift.enabled === false) continue;
|
||||
const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null);
|
||||
const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null);
|
||||
if (start == null || end == null) continue;
|
||||
if (start <= end) {
|
||||
if (nowMin >= start && nowMin < end) return shift.name;
|
||||
} else {
|
||||
if (nowMin >= start || nowMin < end) return shift.name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collapseAlertEvents(events: AlertsInboxEvent[]) {
|
||||
const byAlert = new Map<string, AlertsInboxEvent>();
|
||||
const passthrough: AlertsInboxEvent[] = [];
|
||||
|
||||
for (const ev of events) {
|
||||
if (!ev.alertId) {
|
||||
passthrough.push(ev);
|
||||
continue;
|
||||
}
|
||||
const statusKey = ev.status === "resolved" ? "resolved" : "active";
|
||||
const key = `${ev.alertId}:${statusKey}`;
|
||||
const existing = byAlert.get(key);
|
||||
if (!existing) {
|
||||
byAlert.set(key, ev);
|
||||
continue;
|
||||
}
|
||||
const pickNewest = statusKey === "resolved";
|
||||
const shouldReplace = pickNewest
|
||||
? ev.ts.getTime() > existing.ts.getTime()
|
||||
: ev.ts.getTime() < existing.ts.getTime();
|
||||
if (shouldReplace) byAlert.set(key, ev);
|
||||
}
|
||||
|
||||
const combined = [...passthrough, ...byAlert.values()];
|
||||
combined.sort((a, b) => b.ts.getTime() - a.ts.getTime());
|
||||
return combined;
|
||||
}
|
||||
|
||||
export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
const {
|
||||
orgId,
|
||||
range = "24h",
|
||||
start,
|
||||
end,
|
||||
machineId,
|
||||
location,
|
||||
eventType,
|
||||
severity,
|
||||
status,
|
||||
shift,
|
||||
includeUpdates = false,
|
||||
limit = 200,
|
||||
} = params;
|
||||
|
||||
const picked = pickRange(range, start, end);
|
||||
const normalizedStatus = safeString(status)?.toLowerCase();
|
||||
const normalizedShift = safeString(shift);
|
||||
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 500) : 200;
|
||||
|
||||
const where = {
|
||||
orgId,
|
||||
ts: { gte: picked.start, lte: picked.end },
|
||||
...(machineId ? { machineId } : {}),
|
||||
...(eventType ? { eventType } : {}),
|
||||
...(severity ? { severity } : {}),
|
||||
...(location ? { machine: { location } } : {}),
|
||||
};
|
||||
|
||||
const [events, shifts, settings] = await Promise.all([
|
||||
prisma.machineEvent.findMany({
|
||||
where,
|
||||
orderBy: { ts: "desc" },
|
||||
take: safeLimit,
|
||||
select: {
|
||||
id: true,
|
||||
ts: true,
|
||||
eventType: true,
|
||||
severity: true,
|
||||
title: true,
|
||||
description: true,
|
||||
data: true,
|
||||
machineId: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
machine: {
|
||||
select: {
|
||||
name: true,
|
||||
location: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.orgShift.findMany({
|
||||
where: { orgId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { name: true, startTime: true, endTime: true, enabled: true },
|
||||
}),
|
||||
prisma.orgSettings.findUnique({
|
||||
where: { orgId },
|
||||
select: { timezone: true, shiftScheduleOverridesJson: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const timeZone = settings?.timezone || "UTC";
|
||||
const shiftOverrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
|
||||
const mapped: AlertsInboxEvent[] = [];
|
||||
|
||||
for (const ev of events) {
|
||||
const { payload, inner } = parsePayload(ev.data);
|
||||
const rawStatus = safeString(payload?.status ?? inner?.status);
|
||||
const isUpdate = safeBool(payload?.is_update ?? inner?.is_update);
|
||||
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
||||
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
|
||||
|
||||
const shiftName = resolveShift(shifts, shiftOverrides, ev.ts, timeZone);
|
||||
if (normalizedShift && shiftName !== normalizedShift) continue;
|
||||
|
||||
const statusLabel = normalizeStatus(rawStatus) ?? "unknown";
|
||||
if (normalizedStatus && statusLabel !== normalizedStatus) continue;
|
||||
|
||||
mapped.push({
|
||||
id: ev.id,
|
||||
ts: ev.ts,
|
||||
eventType: ev.eventType,
|
||||
severity: ev.severity,
|
||||
title: ev.title,
|
||||
description: ev.description,
|
||||
machineId: ev.machineId,
|
||||
machineName: ev.machine?.name ?? null,
|
||||
location: ev.machine?.location ?? null,
|
||||
workOrderId: ev.workOrderId ?? null,
|
||||
sku: ev.sku ?? null,
|
||||
durationSec: extractDurationSec(ev.data),
|
||||
status: statusLabel,
|
||||
shift: shiftName,
|
||||
alertId: safeString(payload?.alert_id ?? inner?.alert_id),
|
||||
isUpdate,
|
||||
isAutoAck,
|
||||
});
|
||||
}
|
||||
|
||||
const finalEvents = includeUpdates ? mapped : collapseAlertEvents(mapped);
|
||||
|
||||
return {
|
||||
range: { range: picked.range, start: picked.start, end: picked.end },
|
||||
events: finalEvents,
|
||||
};
|
||||
}
|
||||
25
lib/auth/requireOrgAdminSession.ts
Normal file
25
lib/auth/requireOrgAdminSession.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
|
||||
export type OrgAdminSession = { orgId: string; userId: string };
|
||||
|
||||
export async function requireOrgAdminSession(): Promise<
|
||||
{ ok: true; session: OrgAdminSession } | { ok: false; response: NextResponse }
|
||||
> {
|
||||
const session = await requireSession();
|
||||
if (!session) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }),
|
||||
};
|
||||
}
|
||||
const membership = await prisma.orgUser.findUnique({
|
||||
where: { orgId_userId: { orgId: session.orgId, userId: session.userId } },
|
||||
select: { role: true },
|
||||
});
|
||||
if (membership?.role !== "OWNER" && membership?.role !== "ADMIN") {
|
||||
return { ok: false, response: NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }) };
|
||||
}
|
||||
return { ok: true, session: { orgId: session.orgId, userId: session.userId } };
|
||||
}
|
||||
@@ -115,10 +115,7 @@
|
||||
"machines.status.stopped": "STOPPED",
|
||||
"machines.stoppedFor": "Stopped for {min} min",
|
||||
"recap.grid.title": "Machine recap",
|
||||
"recap.status.dataLoss": "Data Loss",
|
||||
"recap.status.idle": "Idle",
|
||||
"recap.card.dataLoss": "{count} untracked cycles — press START",
|
||||
"recap.card.notStarted": "Operator hasn't pressed START",
|
||||
"recap.card.idle": "No active work order",
|
||||
"recap.grid.subtitle": "Last 24h · click to open details",
|
||||
"recap.grid.updatedAgo": "Updated {sec}s ago",
|
||||
@@ -467,6 +464,7 @@
|
||||
"settings.tabs.alerts": "Alerts",
|
||||
"settings.tabs.financial": "Financial",
|
||||
"settings.tabs.team": "Team",
|
||||
"settings.tabs.reasonCatalog": "Downtime & scrap",
|
||||
"settings.loading": "Loading settings...",
|
||||
"settings.loadingTeam": "Loading team...",
|
||||
"settings.refresh": "Refresh",
|
||||
@@ -522,6 +520,46 @@
|
||||
"settings.thresholds.macroStoppage": "Macro stoppage multiplier",
|
||||
"settings.alerts": "Alerts",
|
||||
"settings.alertsSubtitle": "Choose which alerts to notify.",
|
||||
"settings.reasonCatalog.title": "Downtime and scrap catalogs",
|
||||
"settings.reasonCatalog.subtitle": "Catalogs are stored in MIS (categories + codes). Changes bump settings version so machines pick them up. Deactivate retired codes instead of deleting them.",
|
||||
"settings.reasonCatalog.version": "Catalog version",
|
||||
"settings.reasonCatalog.hint": "Increase version when you change codes so edge devices can detect updates. Use \"Active\" to hide a code from new selections while keeping history labels.",
|
||||
"settings.reasonCatalog.downtime": "Downtime (stops)",
|
||||
"settings.reasonCatalog.scrap": "Scrap",
|
||||
"settings.reasonCatalog.addCategory": "Add category",
|
||||
"settings.reasonCatalog.emptyKind": "No categories yet.",
|
||||
"settings.reasonCatalog.categoryId": "Category id",
|
||||
"settings.reasonCatalog.categoryLabel": "Category name",
|
||||
"settings.reasonCatalog.reasons": "Reasons",
|
||||
"settings.reasonCatalog.addReason": "Add reason",
|
||||
"settings.reasonCatalog.removeCategory": "Remove category",
|
||||
"settings.reasonCatalog.detailId": "Detail id",
|
||||
"settings.reasonCatalog.reasonCode": "Printed code",
|
||||
"settings.reasonCatalog.detailLabel": "Description",
|
||||
"settings.reasonCatalog.active": "Active",
|
||||
"settings.reasonCatalog.removeRow": "Remove",
|
||||
"settings.reasonCatalog.removeDetailHint": "Prefer deactivating codes that were already used in production.",
|
||||
"settings.reasonCatalog.newCategory": "New category",
|
||||
"settings.reasonCatalog.newReason": "New reason",
|
||||
"settings.reasonCatalog.dbVersionHint": "Settings version (includes catalog): {version}",
|
||||
"settings.reasonCatalog.reload": "Reload",
|
||||
"settings.reasonCatalog.stepKind": "1. Catalog type",
|
||||
"settings.reasonCatalog.stepCategory": "2. Category and prefix",
|
||||
"settings.reasonCatalog.pickCategory": "Category",
|
||||
"settings.reasonCatalog.inactive": "inactive",
|
||||
"settings.reasonCatalog.categoryNameEdit": "Category name",
|
||||
"settings.reasonCatalog.codePrefixEdit": "Code prefix (letters; optional digits/hyphen after first letter)",
|
||||
"settings.reasonCatalog.categoryActive": "Category active",
|
||||
"settings.reasonCatalog.newCategorySection": "New category in this catalog type",
|
||||
"settings.reasonCatalog.codePrefixField": "Prefix (shown before the number)",
|
||||
"settings.reasonCatalog.stepReason": "3. Add reason (numbers only)",
|
||||
"settings.reasonCatalog.digitsOnlyHint": "Enter only the numeric part; the full printed code is prefix + number.",
|
||||
"settings.reasonCatalog.fullCodePreview": "Printed code",
|
||||
"settings.reasonCatalog.numericSuffix": "Number",
|
||||
"settings.reasonCatalog.reasonsInCategory": "Reasons in this category",
|
||||
"settings.reasonCatalog.noItemsYet": "No reasons yet.",
|
||||
"settings.reasonCatalog.prefixInvalid": "Prefix must start with a letter and use letters, digits, or hyphen.",
|
||||
|
||||
"settings.alerts.oeeDrop": "OEE drop alerts",
|
||||
"settings.alerts.oeeDropHelper": "Notify when OEE falls below threshold",
|
||||
"settings.alerts.performanceDegradation": "Performance degradation alerts",
|
||||
|
||||
@@ -121,10 +121,7 @@
|
||||
"recap.card.stoppedFor": "Detenida hace {min} min",
|
||||
"machines.status.stopped": "DETENIDA",
|
||||
"machines.stoppedFor": "Detenida hace {min} min",
|
||||
"recap.status.dataLoss": "Sin tracking",
|
||||
"recap.status.idle": "Inactiva",
|
||||
"recap.card.dataLoss": "{count} ciclos sin tracking — presione INICIAR",
|
||||
"recap.card.notStarted": "Operador no ha presionado INICIAR",
|
||||
"recap.card.idle": "Sin orden de trabajo activa",
|
||||
"recap.grid.title": "Resumen de máquinas",
|
||||
"recap.grid.subtitle": "Últimas 24h · click para ver detalle",
|
||||
@@ -474,6 +471,7 @@
|
||||
"settings.tabs.alerts": "Alertas",
|
||||
"settings.tabs.financial": "Finanzas",
|
||||
"settings.tabs.team": "Equipo",
|
||||
"settings.tabs.reasonCatalog": "Paros y scrap",
|
||||
"settings.loading": "Cargando configuración...",
|
||||
"settings.loadingTeam": "Cargando equipo...",
|
||||
"settings.refresh": "Actualizar",
|
||||
@@ -529,6 +527,46 @@
|
||||
"settings.thresholds.macroStoppage": "Multiplicador de macroparo",
|
||||
"settings.alerts": "Alertas",
|
||||
"settings.alertsSubtitle": "Elige qué alertas notificar.",
|
||||
"settings.reasonCatalog.title": "Catálogos de paros y scrap",
|
||||
"settings.reasonCatalog.subtitle": "Los catálogos viven en MIS (categorías y códigos). Los cambios suben la versión de ajustes para que las máquinas los reciban. Desactiva códigos retirados en lugar de borrarlos.",
|
||||
"settings.reasonCatalog.version": "Versión del catálogo",
|
||||
"settings.reasonCatalog.hint": "Sube la versión cuando cambies códigos para que el borde detecte actualizaciones. Usa \"Activo\" para ocultar un código en nuevas capturas sin perder etiquetas en histórico.",
|
||||
"settings.reasonCatalog.downtime": "Tiempo muerto (paros)",
|
||||
"settings.reasonCatalog.scrap": "Scrap",
|
||||
"settings.reasonCatalog.addCategory": "Agregar categoría",
|
||||
"settings.reasonCatalog.emptyKind": "Aún no hay categorías.",
|
||||
"settings.reasonCatalog.categoryId": "Id de categoría",
|
||||
"settings.reasonCatalog.categoryLabel": "Nombre de categoría",
|
||||
"settings.reasonCatalog.reasons": "Razones",
|
||||
"settings.reasonCatalog.addReason": "Agregar razón",
|
||||
"settings.reasonCatalog.removeCategory": "Quitar categoría",
|
||||
"settings.reasonCatalog.detailId": "Id del detalle",
|
||||
"settings.reasonCatalog.reasonCode": "Código impreso",
|
||||
"settings.reasonCatalog.detailLabel": "Descripción",
|
||||
"settings.reasonCatalog.active": "Activo",
|
||||
"settings.reasonCatalog.removeRow": "Quitar",
|
||||
"settings.reasonCatalog.removeDetailHint": "Para códigos ya usados en producción, preferir desactivar en lugar de quitar la fila.",
|
||||
"settings.reasonCatalog.newCategory": "Nueva categoría",
|
||||
"settings.reasonCatalog.newReason": "Nueva razón",
|
||||
"settings.reasonCatalog.dbVersionHint": "Versión de ajustes (incluye catálogo): {version}",
|
||||
"settings.reasonCatalog.reload": "Recargar",
|
||||
"settings.reasonCatalog.stepKind": "1. Tipo de catálogo",
|
||||
"settings.reasonCatalog.stepCategory": "2. Categoría y prefijo",
|
||||
"settings.reasonCatalog.pickCategory": "Categoría",
|
||||
"settings.reasonCatalog.inactive": "inactiva",
|
||||
"settings.reasonCatalog.categoryNameEdit": "Nombre de categoría",
|
||||
"settings.reasonCatalog.codePrefixEdit": "Prefijo de código (letras; opcional dígitos o guión después de la primera letra)",
|
||||
"settings.reasonCatalog.categoryActive": "Categoría activa",
|
||||
"settings.reasonCatalog.newCategorySection": "Nueva categoría en este tipo de catálogo",
|
||||
"settings.reasonCatalog.codePrefixField": "Prefijo (se muestra antes del número)",
|
||||
"settings.reasonCatalog.stepReason": "3. Agregar razón (solo números)",
|
||||
"settings.reasonCatalog.digitsOnlyHint": "Captura solo la parte numérica; el código impreso completo es prefijo + número.",
|
||||
"settings.reasonCatalog.fullCodePreview": "Código impreso",
|
||||
"settings.reasonCatalog.numericSuffix": "Número",
|
||||
"settings.reasonCatalog.reasonsInCategory": "Razones en esta categoría",
|
||||
"settings.reasonCatalog.noItemsYet": "Aún no hay razones.",
|
||||
"settings.reasonCatalog.prefixInvalid": "El prefijo debe empezar con letra y usar letras, dígitos o guión.",
|
||||
|
||||
"settings.alerts.oeeDrop": "Alertas por caída de OEE",
|
||||
"settings.alerts.oeeDropHelper": "Notificar cuando OEE esté por debajo del umbral",
|
||||
"settings.alerts.performanceDegradation": "Alertas por baja de Performance",
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
export type ReasonCatalogKind = "downtime" | "scrap";
|
||||
@@ -8,6 +5,10 @@ export type ReasonCatalogKind = "downtime" | "scrap";
|
||||
export type ReasonCatalogDetail = {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Official code (e.g. DTPRC-01, MX001). When set, used as reasonCode instead of slug. */
|
||||
reasonCode?: string;
|
||||
/** When false, hidden from operator pickers but kept for historical label resolution. Default true. */
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export type ReasonCatalogCategory = {
|
||||
@@ -22,6 +23,11 @@ export type ReasonCatalog = {
|
||||
scrap: ReasonCatalogCategory[];
|
||||
};
|
||||
|
||||
export type FlattenReasonCatalogOptions = {
|
||||
/** If true, omit details with active === false (operator / tactile UI). */
|
||||
activeOnly?: boolean;
|
||||
};
|
||||
|
||||
function isPlainObject(value: unknown): value is AnyRecord {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -40,6 +46,17 @@ function buildReasonCode(categoryId: string, detailId: string) {
|
||||
return `${canonicalId(categoryId)}__${canonicalId(detailId)}`.toUpperCase();
|
||||
}
|
||||
|
||||
/** Uppercase official or derived code for this detail row. */
|
||||
export function detailEffectiveReasonCode(category: ReasonCatalogCategory, detail: ReasonCatalogDetail): string {
|
||||
const explicit = String(detail.reasonCode ?? "").trim();
|
||||
if (explicit) return explicit.toUpperCase();
|
||||
return buildReasonCode(category.id, detail.id);
|
||||
}
|
||||
|
||||
export function isDetailActive(detail: ReasonCatalogDetail): boolean {
|
||||
return detail.active !== false;
|
||||
}
|
||||
|
||||
function toCategory(raw: unknown): ReasonCatalogCategory | null {
|
||||
if (!isPlainObject(raw)) return null;
|
||||
const labelRaw = String(raw.label ?? "").trim();
|
||||
@@ -57,7 +74,16 @@ function toCategory(raw: unknown): ReasonCatalogCategory | null {
|
||||
const detailLabel = String(detailRaw.label ?? "").trim();
|
||||
if (!detailLabel) continue;
|
||||
const detailId = String(detailRaw.id ?? "").trim() || canonicalId(detailLabel, "detail");
|
||||
details.push({ id: detailId, label: detailLabel });
|
||||
const reasonCodeRaw = detailRaw.reasonCode ?? detailRaw.code;
|
||||
const reasonCode =
|
||||
reasonCodeRaw != null && String(reasonCodeRaw).trim() ? String(reasonCodeRaw).trim() : undefined;
|
||||
const active = detailRaw.active === false ? false : true;
|
||||
details.push({
|
||||
id: detailId,
|
||||
label: detailLabel,
|
||||
...(reasonCode ? { reasonCode } : {}),
|
||||
...(active ? {} : { active: false }),
|
||||
});
|
||||
}
|
||||
|
||||
if (!details.length) return null;
|
||||
@@ -131,7 +157,7 @@ export function parseReasonCatalogMarkdown(markdown: string): ReasonCatalog {
|
||||
details: [] as ReasonCatalogDetail[],
|
||||
};
|
||||
if (!existing.details.some((d) => d.id === detailId)) {
|
||||
existing.details.push({ id: detailId, label: detailLabel });
|
||||
existing.details.push({ id: detailId, label: detailLabel, active: true });
|
||||
}
|
||||
buckets[activeKind].set(categoryId, existing);
|
||||
}
|
||||
@@ -143,29 +169,35 @@ export function parseReasonCatalogMarkdown(markdown: string): ReasonCatalog {
|
||||
};
|
||||
}
|
||||
|
||||
let catalogPromise: Promise<ReasonCatalog> | null = null;
|
||||
|
||||
export async function loadFallbackReasonCatalog() {
|
||||
if (!catalogPromise) {
|
||||
catalogPromise = readFile(path.join(process.cwd(), "downtime_menu.md"), "utf8")
|
||||
.then((raw) => parseReasonCatalogMarkdown(raw))
|
||||
.catch(() => ({ version: 1, downtime: [], scrap: [] }));
|
||||
}
|
||||
return catalogPromise;
|
||||
export function flattenReasonCatalog(
|
||||
catalog: ReasonCatalog,
|
||||
kind: ReasonCatalogKind,
|
||||
options?: FlattenReasonCatalogOptions
|
||||
) {
|
||||
const activeOnly = options?.activeOnly === true;
|
||||
return (catalog[kind] ?? []).flatMap((category) =>
|
||||
category.details
|
||||
.filter((d) => !activeOnly || isDetailActive(d))
|
||||
.map((detail) => ({
|
||||
kind,
|
||||
categoryId: category.id,
|
||||
categoryLabel: category.label,
|
||||
detailId: detail.id,
|
||||
detailLabel: detail.label,
|
||||
reasonCode: detailEffectiveReasonCode(category, detail),
|
||||
reasonLabel: `${category.label} > ${detail.label}`,
|
||||
active: isDetailActive(detail),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export function flattenReasonCatalog(catalog: ReasonCatalog, kind: ReasonCatalogKind) {
|
||||
return (catalog[kind] ?? []).flatMap((category) =>
|
||||
category.details.map((detail) => ({
|
||||
kind,
|
||||
categoryId: category.id,
|
||||
categoryLabel: category.label,
|
||||
detailId: detail.id,
|
||||
detailLabel: detail.label,
|
||||
reasonCode: buildReasonCode(category.id, detail.id),
|
||||
reasonLabel: `${category.label} > ${detail.label}`,
|
||||
}))
|
||||
);
|
||||
function canonicalText(value: unknown) {
|
||||
return String(value ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function findCatalogReason(
|
||||
@@ -187,11 +219,38 @@ export function findCatalogReason(
|
||||
categoryLabel: category.label,
|
||||
detailId: detail.id,
|
||||
detailLabel: detail.label,
|
||||
reasonCode: buildReasonCode(category.id, detail.id),
|
||||
reasonCode: detailEffectiveReasonCode(category, detail),
|
||||
reasonLabel: `${category.label} > ${detail.label}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve category/detail + labels by official or derived reasonCode (includes inactive details). */
|
||||
export function findCatalogReasonByReasonCode(
|
||||
catalog: ReasonCatalog | null | undefined,
|
||||
kind: ReasonCatalogKind,
|
||||
reasonCode: string | null | undefined
|
||||
) {
|
||||
if (!catalog) return null;
|
||||
const needle = String(reasonCode ?? "").trim().toUpperCase();
|
||||
if (!needle) return null;
|
||||
for (const category of catalog[kind] ?? []) {
|
||||
for (const detail of category.details) {
|
||||
const rc = detailEffectiveReasonCode(category, detail);
|
||||
if (rc === needle) {
|
||||
return {
|
||||
categoryId: category.id,
|
||||
categoryLabel: category.label,
|
||||
detailId: detail.id,
|
||||
detailLabel: detail.label,
|
||||
reasonCode: rc,
|
||||
reasonLabel: `${category.label} > ${detail.label}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toReasonCode(categoryId: unknown, detailId: unknown) {
|
||||
const cat = canonicalId(categoryId, "");
|
||||
const det = canonicalId(detailId, "");
|
||||
|
||||
98
lib/reasonCatalogDb.ts
Normal file
98
lib/reasonCatalogDb.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { ReasonCatalog, ReasonCatalogCategory, ReasonCatalogDetail } from "@/lib/reasonCatalog";
|
||||
import { normalizeReasonCatalog } from "@/lib/reasonCatalog";
|
||||
import { loadFallbackReasonCatalog } from "@/lib/reasonCatalogFallback";
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full printed code from category prefix + operator numeric suffix (or suffix digits from seed).
|
||||
* Downtime-style keys use a hyphen before the numeric part (e.g. DTPRC-01); short scrap-style
|
||||
* prefixes (e.g. MX) concatenate without hyphen (MX001).
|
||||
*/
|
||||
export function composeReasonCode(prefix: string, suffix: string): string {
|
||||
const p = String(prefix ?? "").trim().toUpperCase();
|
||||
const s = String(suffix ?? "").trim();
|
||||
if (/^\d+$/.test(s) && p.length >= 3) {
|
||||
return `${p}-${s}`.toUpperCase();
|
||||
}
|
||||
return `${p}${s}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function isNumericSuffix(value: string): boolean {
|
||||
return /^\d+$/.test(String(value ?? "").trim());
|
||||
}
|
||||
|
||||
function mapKind(kind: string): "downtime" | "scrap" | null {
|
||||
const k = String(kind).toLowerCase();
|
||||
if (k === "downtime" || k === "scrap") return k;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load catalog from Postgres tables. Returns null if org has no catalog rows yet.
|
||||
* Includes inactive rows for historical label resolution (same as prior JSON behavior).
|
||||
*/
|
||||
export async function loadReasonCatalogFromDb(
|
||||
orgId: string,
|
||||
catalogVersion: number
|
||||
): Promise<ReasonCatalog | null> {
|
||||
const rows = await prisma.reasonCatalogCategory.findMany({
|
||||
where: { orgId },
|
||||
include: {
|
||||
items: { orderBy: { sortOrder: "asc" } },
|
||||
},
|
||||
orderBy: [{ kind: "asc" }, { sortOrder: "asc" }],
|
||||
});
|
||||
if (!rows.length) return null;
|
||||
|
||||
const downtime: ReasonCatalogCategory[] = [];
|
||||
const scrap: ReasonCatalogCategory[] = [];
|
||||
|
||||
for (const cat of rows) {
|
||||
const k = mapKind(cat.kind);
|
||||
if (!k) continue;
|
||||
const details: ReasonCatalogDetail[] = cat.items.map((it) => ({
|
||||
id: it.id,
|
||||
label: it.name,
|
||||
reasonCode: it.reasonCode,
|
||||
active: it.active,
|
||||
}));
|
||||
const bucket: ReasonCatalogCategory = {
|
||||
id: cat.id,
|
||||
label: cat.name,
|
||||
details,
|
||||
};
|
||||
if (k === "downtime") downtime.push(bucket);
|
||||
else scrap.push(bucket);
|
||||
}
|
||||
|
||||
if (!downtime.length && !scrap.length) return null;
|
||||
return { version: Math.max(1, catalogVersion), downtime, scrap };
|
||||
}
|
||||
|
||||
/** DB first, then legacy JSON in defaults, then file fallback. */
|
||||
export async function effectiveReasonCatalogForOrg(
|
||||
orgId: string,
|
||||
defaultsJson: unknown,
|
||||
settingsVersion: number
|
||||
): Promise<ReasonCatalog> {
|
||||
const fromDb = await loadReasonCatalogFromDb(orgId, settingsVersion);
|
||||
if (fromDb) return fromDb;
|
||||
|
||||
const defs = isPlainObject(defaultsJson) ? defaultsJson : {};
|
||||
const fromJson = normalizeReasonCatalog(defs.reasonCatalog ?? defs.reasonCatalogData);
|
||||
if (fromJson) return fromJson;
|
||||
|
||||
return loadFallbackReasonCatalog();
|
||||
}
|
||||
|
||||
export async function bumpOrgSettingsVersion(tx: Prisma.TransactionClient, orgId: string, userId: string) {
|
||||
await tx.orgSettings.update({
|
||||
where: { orgId },
|
||||
data: { version: { increment: 1 }, updatedBy: userId },
|
||||
});
|
||||
}
|
||||
15
lib/reasonCatalogFallback.ts
Normal file
15
lib/reasonCatalogFallback.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { parseReasonCatalogMarkdown, type ReasonCatalog } from "@/lib/reasonCatalog";
|
||||
|
||||
let catalogPromise: Promise<ReasonCatalog> | null = null;
|
||||
|
||||
/** Server-only: reads downtime_menu.md from the repo root. */
|
||||
export async function loadFallbackReasonCatalog() {
|
||||
if (!catalogPromise) {
|
||||
catalogPromise = readFile(path.join(process.cwd(), "downtime_menu.md"), "utf8")
|
||||
.then((raw) => parseReasonCatalogMarkdown(raw))
|
||||
.catch(() => ({ version: 1, downtime: [], scrap: [] }));
|
||||
}
|
||||
return catalogPromise;
|
||||
}
|
||||
@@ -289,6 +289,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
ts: true,
|
||||
cycleCount: true,
|
||||
workOrderId: true,
|
||||
theoreticalCycleTime: true,
|
||||
sku: true,
|
||||
goodDelta: true,
|
||||
scrapDelta: true,
|
||||
|
||||
@@ -23,9 +23,6 @@ export type MachineStateName =
|
||||
| "idle"
|
||||
| "running";
|
||||
|
||||
export type StoppedReason = "machine_fault" | "not_started";
|
||||
export type DataLossReason = "untracked";
|
||||
|
||||
export type MachineStateResult =
|
||||
| { state: "offline"; lastSeenMs: number | null; offlineForMin: number }
|
||||
| {
|
||||
@@ -35,17 +32,9 @@ export type MachineStateResult =
|
||||
}
|
||||
| {
|
||||
state: "stopped";
|
||||
reason: StoppedReason;
|
||||
ongoingStopMin: number;
|
||||
stopStartedAtMs: number | null;
|
||||
}
|
||||
| {
|
||||
state: "data-loss";
|
||||
reason: DataLossReason;
|
||||
untrackedCycleCount: number;
|
||||
untrackedSinceMs: number | null;
|
||||
untrackedForMin: number;
|
||||
}
|
||||
| { state: "idle" }
|
||||
| { state: "running" };
|
||||
|
||||
@@ -74,8 +63,6 @@ export type MachineStateInputs = {
|
||||
* Caller computes by counting MachineCycle rows in the last UNTRACKED_WINDOW_MS
|
||||
* where ts > latestKpi.ts (so they're "after" the tracking-off snapshot).
|
||||
*/
|
||||
untrackedCycles: { count: number; oldestTsMs: number | null };
|
||||
|
||||
/**
|
||||
* Most recent cycle timestamp regardless of tracking — used as a sanity check
|
||||
* for IDLE classification.
|
||||
@@ -84,8 +71,7 @@ export type MachineStateInputs = {
|
||||
};
|
||||
|
||||
// Trigger thresholds — tunable
|
||||
const DATA_LOSS_MIN_CYCLES = 5;
|
||||
const DATA_LOSS_MIN_DURATION_MS = 10 * 60 * 1000; // 10 min
|
||||
|
||||
const RECENT_CYCLE_MS = 15 * 60 * 1000; // for IDLE check — "no cycles in 15 min"
|
||||
|
||||
export function classifyMachineState(
|
||||
@@ -116,51 +102,22 @@ export function classifyMachineState(
|
||||
// 3. DATA_LOSS — tracking off but cycles arriving. Operator forgot START.
|
||||
// Check this BEFORE STOPPED because cycles ARE arriving (so the "no cycles" branch
|
||||
// would never fire), but we still want to flag it.
|
||||
if (!inputs.trackingEnabled && inputs.untrackedCycles.count > 0) {
|
||||
const oldest = inputs.untrackedCycles.oldestTsMs;
|
||||
const durationMs = oldest != null ? nowMs - oldest : 0;
|
||||
const tripped =
|
||||
inputs.untrackedCycles.count >= DATA_LOSS_MIN_CYCLES ||
|
||||
durationMs >= DATA_LOSS_MIN_DURATION_MS;
|
||||
|
||||
if (tripped) {
|
||||
return {
|
||||
state: "data-loss",
|
||||
reason: "untracked",
|
||||
untrackedCycleCount: inputs.untrackedCycles.count,
|
||||
untrackedSinceMs: oldest,
|
||||
untrackedForMin: Math.max(0, Math.floor(durationMs / 60000)),
|
||||
};
|
||||
}
|
||||
// Not yet tripped — fall through to other checks (likely RUNNING since cycles are coming)
|
||||
}
|
||||
|
||||
// 4. STOPPED — should be producing, isn't. Two reasons:
|
||||
// a) machine_fault: operator pressed START, macrostop event active → mechanical issue
|
||||
// b) not_started: operator never pressed START but a WO is loaded
|
||||
if (inputs.activeMacrostop && inputs.trackingEnabled) {
|
||||
// 4. STOPPED — machine should be producing, isn't.
|
||||
// The Pi only emits macrostop events when tracking is on AND a WO is active,
|
||||
// so the presence of an active macrostop event is sufficient.
|
||||
if (inputs.activeMacrostop) {
|
||||
const startedAt = inputs.activeMacrostop.startedAtMs;
|
||||
return {
|
||||
state: "stopped",
|
||||
reason: "machine_fault",
|
||||
ongoingStopMin: Math.max(0, Math.floor((nowMs - startedAt) / 60000)),
|
||||
stopStartedAtMs: startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (inputs.hasActiveWorkOrder && !inputs.trackingEnabled) {
|
||||
// Operator hasn't started production despite a loaded WO.
|
||||
// We don't have a precise "since when" for this — best estimate is "since latest
|
||||
// KPI snapshot reported trackingEnabled=false," but that's not in the inputs.
|
||||
// For now, report ongoingStopMin=0 and let the caller refine if needed.
|
||||
return {
|
||||
state: "stopped",
|
||||
reason: "not_started",
|
||||
ongoingStopMin: 0,
|
||||
stopStartedAtMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. IDLE — no one expects this machine to be doing anything right now.
|
||||
// No tracking, no WO, no recent cycles. Calm gray.
|
||||
const cycledRecently =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@ export type TimelineCycleRow = {
|
||||
ts: Date;
|
||||
cycleCount: number | null;
|
||||
actualCycleTime: number;
|
||||
theoreticalCycleTime: number | null;
|
||||
workOrderId: string | null;
|
||||
sku: string | null;
|
||||
};
|
||||
@@ -554,19 +555,21 @@ export function buildTimelineSegments(input: {
|
||||
let currentProduction: RawSegment | null = null;
|
||||
for (const cycle of dedupedCycles) {
|
||||
if (!cycle.workOrderId) continue;
|
||||
const cycleStartMs = cycle.ts.getTime();
|
||||
// Pi stores cycle.ts at COMPLETION time; the cycle ran in [ts - actual, ts].
|
||||
const completionMs = cycle.ts.getTime();
|
||||
const cycleDurationMs = Math.max(
|
||||
1000,
|
||||
Math.min(600000, Math.trunc((safeNum(cycle.actualCycleTime) ?? 1) * 1000))
|
||||
);
|
||||
const cycleEndMs = cycleStartMs + cycleDurationMs;
|
||||
const cycleStartMs = completionMs - cycleDurationMs;
|
||||
const cycleEndMs = completionMs;
|
||||
|
||||
if (
|
||||
currentProduction &&
|
||||
currentProduction.type === "production" &&
|
||||
currentProduction.workOrderId === cycle.workOrderId &&
|
||||
currentProduction.sku === cycle.sku &&
|
||||
cycleStartMs <= currentProduction.endMs + 5 * 60 * 1000
|
||||
cycleStartMs <= currentProduction.endMs + MERGE_GAP_MS
|
||||
) {
|
||||
currentProduction.endMs = Math.max(currentProduction.endMs, cycleEndMs);
|
||||
continue;
|
||||
@@ -652,7 +655,11 @@ export function buildTimelineSegments(input: {
|
||||
episode.firstTsMs = Math.min(episode.firstTsMs, tsMs);
|
||||
episode.lastTsMs = Math.max(episode.lastTsMs, tsMs);
|
||||
|
||||
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
|
||||
const startMs =
|
||||
safeNum(data.start_ms) ??
|
||||
safeNum(data.startMs) ??
|
||||
safeNum(data.last_cycle_timestamp) ??
|
||||
safeNum(data.lastCycleTimestamp);
|
||||
const endMs = safeNum(data.end_ms) ?? safeNum(data.endMs);
|
||||
const durationSec =
|
||||
safeNum(data.duration_sec) ??
|
||||
@@ -679,7 +686,7 @@ export function buildTimelineSegments(input: {
|
||||
}
|
||||
|
||||
for (const episode of eventEpisodes.values()) {
|
||||
const startMs = Math.trunc(episode.startMs ?? episode.firstTsMs);
|
||||
let startMs = Math.trunc(episode.startMs ?? episode.firstTsMs);
|
||||
let endMs = Math.trunc(episode.endMs ?? episode.lastTsMs);
|
||||
|
||||
if (episode.statusActive && !episode.statusResolved) {
|
||||
@@ -694,7 +701,13 @@ export function buildTimelineSegments(input: {
|
||||
}
|
||||
}
|
||||
} else if (endMs <= startMs && episode.durationSec != null && episode.durationSec > 0) {
|
||||
endMs = startMs + episode.durationSec * 1000;
|
||||
// Event ts is end-of-stop; subtract duration to recover start.
|
||||
// Only adjust if we don't already have an explicit startMs from data.
|
||||
if (episode.startMs == null) {
|
||||
startMs = endMs - episode.durationSec * 1000;
|
||||
} else {
|
||||
endMs = startMs + episode.durationSec * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
if (endMs <= startMs) continue;
|
||||
@@ -730,7 +743,35 @@ export function buildTimelineSegments(input: {
|
||||
const clustered = absorbMicroStopClusters(withIdle, MICRO_CLUSTER_GAP_MS);
|
||||
const normalized = fillGapsWithIdle(clustered, rangeStartMs, rangeEndMs);
|
||||
const absorbed = absorbShortSegments(normalized, ABSORB_SHORT_SEGMENT_MS);
|
||||
const finalSegments = fillGapsWithIdle(absorbed, rangeStartMs, rangeEndMs);
|
||||
const finalSegments = fillGapsWithIdle(absorbed, rangeStartMs, rangeEndMs);
|
||||
|
||||
// Live tail: machine cycling now, last cycle not yet completed.
|
||||
// Extend production through right edge until microstop threshold passes.
|
||||
const lastCycle = dedupedCycles[dedupedCycles.length - 1];
|
||||
const idealCT = safeNum(lastCycle?.theoreticalCycleTime) ?? 120;
|
||||
const MICRO_MS = idealCT * 1.5 * 1000;
|
||||
|
||||
// Live-tail: extend whatever the last real state was, until microstop threshold passes.
|
||||
if (finalSegments.length >= 2) {
|
||||
const last = finalSegments[finalSegments.length - 1];
|
||||
const prev = finalSegments[finalSegments.length - 2];
|
||||
if (last.type === "idle" && last.endMs >= rangeEndMs - 2000) {
|
||||
const gapMs = last.endMs - prev.endMs;
|
||||
let shouldExtend = false;
|
||||
if (prev.type === "production" && gapMs < MICRO_MS) {
|
||||
// mid-cycle: still running up to microstop threshold
|
||||
shouldExtend = true;
|
||||
} else if (prev.type === "microstop" || prev.type === "macrostop") {
|
||||
// stoppage in progress: extend until resolved/next cycle
|
||||
shouldExtend = true;
|
||||
}
|
||||
if (shouldExtend) {
|
||||
prev.endMs = last.endMs;
|
||||
prev.durationSec = Math.max(0, Math.trunc((prev.endMs - prev.startMs) / 1000));
|
||||
finalSegments.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalSegments;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ export async function getRecapTimelineForMachine(params: {
|
||||
ts: true,
|
||||
cycleCount: true,
|
||||
actualCycleTime: true,
|
||||
theoreticalCycleTime: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
},
|
||||
@@ -151,10 +152,10 @@ export async function getRecapTimelineForMachine(params: {
|
||||
ts: row.ts,
|
||||
cycleCount: row.cycleCount,
|
||||
actualCycleTime: row.actualCycleTime,
|
||||
theoreticalCycleTime: row.theoreticalCycleTime,
|
||||
workOrderId: row.workOrderId,
|
||||
sku: row.sku,
|
||||
}));
|
||||
|
||||
const events: TimelineEventRow[] = eventsRaw.map((row) => ({
|
||||
ts: row.ts,
|
||||
eventType: row.eventType,
|
||||
|
||||
@@ -121,23 +121,14 @@ export type RecapQuery = {
|
||||
shift?: string;
|
||||
};
|
||||
|
||||
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "data-loss" | "offline" | "idle";
|
||||
|
||||
export type RecapStoppedReason = "machine_fault" | "not_started";
|
||||
export type RecapDataLossReason = "untracked";
|
||||
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "offline" | "idle";
|
||||
|
||||
/**
|
||||
* Reason context for STOPPED and DATA_LOSS states.
|
||||
* - When status is "stopped": stoppedReason is set, dataLossReason is null.
|
||||
* - When status is "data-loss": dataLossReason is set, stoppedReason is null.
|
||||
* - All other states: both are null.
|
||||
* Reason context — currently empty in practice because the only STOPPED cause
|
||||
* we can detect (given Node-RED's constraints) is machine_fault. Kept as a
|
||||
* struct so future expansion doesn't require a type change downstream.
|
||||
*/
|
||||
export type RecapStateContext = {
|
||||
stoppedReason: RecapStoppedReason | null;
|
||||
dataLossReason: RecapDataLossReason | null;
|
||||
/** For data-loss: how many untracked cycles have been detected so far. */
|
||||
untrackedCycleCount: number | null;
|
||||
};
|
||||
export type RecapStateContext = Record<string, never>;
|
||||
|
||||
|
||||
export type RecapSummaryMachine = {
|
||||
|
||||
@@ -81,10 +81,6 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
|
||||
const overrides = normalizeShiftOverrides(settings.shiftScheduleOverridesJson);
|
||||
|
||||
const defaults = normalizeDefaults(settings.defaultsJson);
|
||||
const reasonCatalog =
|
||||
isPlainObject(settings.defaultsJson) && "reasonCatalog" in settings.defaultsJson
|
||||
? (settings.defaultsJson as AnyRecord).reasonCatalog
|
||||
: null;
|
||||
|
||||
return {
|
||||
orgId: settings.orgId,
|
||||
@@ -105,9 +101,6 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
|
||||
},
|
||||
alerts: normalizeAlerts(settings.alertsJson),
|
||||
defaults,
|
||||
reasonCatalog: reasonCatalog ?? undefined,
|
||||
reasonCatalogData: reasonCatalog ?? undefined,
|
||||
reasonCatalogVersion: Number((reasonCatalog as AnyRecord | null)?.version ?? 1),
|
||||
updatedAt: settings.updatedAt,
|
||||
updatedBy: settings.updatedBy,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user