pre-bemis
This commit is contained in:
@@ -46,6 +46,19 @@ function readBool(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 extractDurationSec(raw: unknown): number | null {
|
||||
const payload = asRecord(raw);
|
||||
if (!payload) return null;
|
||||
@@ -302,10 +315,11 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
const alertId = readString(payload?.alert_id ?? inner?.alert_id);
|
||||
const isUpdate = readBool(payload?.is_update ?? inner?.is_update);
|
||||
const isAutoAck = readBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
||||
const status = normalizeStatus(readString(payload?.status ?? inner?.status));
|
||||
const lastCycleTs = readNumber(payload?.last_cycle_timestamp ?? inner?.last_cycle_timestamp);
|
||||
const theoreticalSec = readNumber(payload?.theoretical_cycle_time ?? inner?.theoretical_cycle_time);
|
||||
if (isAutoAck) return;
|
||||
if (isUpdate && !(rule.repeatMinutes && rule.repeatMinutes > 0)) return;
|
||||
if (isUpdate && status !== "resolved") return;
|
||||
if ((eventType === "microstop" || eventType === "macrostop") && theoreticalSec && lastCycleTs == null) {
|
||||
return;
|
||||
}
|
||||
@@ -345,9 +359,11 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
const key = `${channel}:${recipient.userId ?? recipient.contactId ?? recipient.email ?? recipient.phone ?? ""}`;
|
||||
if (delivered.has(key)) continue;
|
||||
|
||||
const statusKey = status === "resolved" ? "resolved" : "active";
|
||||
const ruleKey = `${rule.id}:${statusKey}`;
|
||||
const allowed = await shouldSendNotification({
|
||||
eventIds: notificationEventIds,
|
||||
ruleId: rule.id,
|
||||
ruleId: ruleKey,
|
||||
role: roleName,
|
||||
channel,
|
||||
contactId: recipient.contactId,
|
||||
@@ -376,7 +392,7 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
machineId: event.machineId,
|
||||
eventId: event.id,
|
||||
eventType,
|
||||
ruleId: rule.id,
|
||||
ruleId: ruleKey,
|
||||
role: roleName,
|
||||
channel,
|
||||
contactId: recipient.contactId,
|
||||
@@ -391,7 +407,7 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
machineId: event.machineId,
|
||||
eventId: event.id,
|
||||
eventType,
|
||||
ruleId: rule.id,
|
||||
ruleId: ruleKey,
|
||||
role: roleName,
|
||||
channel,
|
||||
contactId: recipient.contactId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeShiftOverrides } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const RANGE_MS: Record<string, number> = {
|
||||
@@ -21,6 +22,26 @@ type AlertsInboxParams = {
|
||||
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") {
|
||||
@@ -50,6 +71,19 @@ 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") {
|
||||
@@ -131,17 +165,54 @@ function getLocalMinutes(ts: Date, timeZone: string) {
|
||||
}
|
||||
}
|
||||
|
||||
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: Array<{ name: string; startTime: string; endTime: string; enabled?: boolean }>,
|
||||
shifts: ShiftLike[],
|
||||
overrides: Record<string, ShiftLike[]> | undefined,
|
||||
ts: Date,
|
||||
timeZone: string
|
||||
) {
|
||||
if (!shifts.length) return null;
|
||||
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 shifts) {
|
||||
for (const shift of activeShifts) {
|
||||
if (shift.enabled === false) continue;
|
||||
const start = parseTimeMinutes(shift.startTime);
|
||||
const end = parseTimeMinutes(shift.endTime);
|
||||
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;
|
||||
@@ -152,6 +223,34 @@ function resolveShift(
|
||||
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,
|
||||
@@ -213,12 +312,13 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
}),
|
||||
prisma.orgSettings.findUnique({
|
||||
where: { orgId },
|
||||
select: { timezone: true },
|
||||
select: { timezone: true, shiftScheduleOverridesJson: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const timeZone = settings?.timezone || "UTC";
|
||||
const mapped = [];
|
||||
const shiftOverrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
|
||||
const mapped: AlertsInboxEvent[] = [];
|
||||
|
||||
for (const ev of events) {
|
||||
const { payload, inner } = parsePayload(ev.data);
|
||||
@@ -227,10 +327,10 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
||||
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
|
||||
|
||||
const shiftName = resolveShift(shifts, ev.ts, timeZone);
|
||||
const shiftName = resolveShift(shifts, shiftOverrides, ev.ts, timeZone);
|
||||
if (normalizedShift && shiftName !== normalizedShift) continue;
|
||||
|
||||
const statusLabel = rawStatus ? rawStatus.toLowerCase() : "unknown";
|
||||
const statusLabel = normalizeStatus(rawStatus) ?? "unknown";
|
||||
if (normalizedStatus && statusLabel !== normalizedStatus) continue;
|
||||
|
||||
mapped.push({
|
||||
@@ -254,8 +354,10 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
});
|
||||
}
|
||||
|
||||
const finalEvents = includeUpdates ? mapped : collapseAlertEvents(mapped);
|
||||
|
||||
return {
|
||||
range: { range: picked.range, start: picked.start, end: picked.end },
|
||||
events: mapped,
|
||||
events: finalEvents,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,43 +1,98 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logLine } from "@/lib/logger";
|
||||
|
||||
const COOKIE_NAME = "mis_session";
|
||||
const SESSION_CACHE_TTL_MS = 30000;
|
||||
const LAST_SEEN_TTL_MS = 300000;
|
||||
|
||||
export async function requireSession() {
|
||||
const jar = await cookies();
|
||||
const sessionId = jar.get(COOKIE_NAME)?.value;
|
||||
if (!sessionId) return null;
|
||||
type SessionPayload = {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
const session = await prisma.session.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { isActive: true, emailVerifiedAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
type CachedSession = {
|
||||
value: SessionPayload;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
if (!session) return null;
|
||||
const sessionCache = new Map<string, CachedSession>();
|
||||
const lastSeenCache = new Map<string, number>();
|
||||
|
||||
if (!session.user?.isActive || !session.user?.emailVerifiedAt) {
|
||||
await prisma.session
|
||||
.update({ where: { id: session.id }, data: { revokedAt: new Date() } })
|
||||
.catch(() => {});
|
||||
function readCache(sessionId: string, now: number) {
|
||||
const cached = sessionCache.get(sessionId);
|
||||
if (!cached) return null;
|
||||
if (cached.expiresAt <= now) {
|
||||
sessionCache.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
function writeCache(sessionId: string, value: SessionPayload, now: number) {
|
||||
sessionCache.set(sessionId, { value, expiresAt: now + SESSION_CACHE_TTL_MS });
|
||||
}
|
||||
|
||||
function shouldUpdateLastSeen(sessionId: string, now: number) {
|
||||
const last = lastSeenCache.get(sessionId) ?? 0;
|
||||
if (now - last < LAST_SEEN_TTL_MS) return false;
|
||||
lastSeenCache.set(sessionId, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function requireSession() {
|
||||
try {
|
||||
const jar = await cookies();
|
||||
const sessionId = jar.get(COOKIE_NAME)?.value;
|
||||
if (!sessionId) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const cached = readCache(sessionId, now);
|
||||
if (cached) return cached;
|
||||
|
||||
const session = await prisma.session.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { isActive: true, emailVerifiedAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
if (!session.user?.isActive || !session.user?.emailVerifiedAt) {
|
||||
void prisma.session
|
||||
.update({ where: { id: session.id }, data: { revokedAt: new Date() } })
|
||||
.catch(() => {});
|
||||
sessionCache.delete(sessionId);
|
||||
lastSeenCache.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shouldUpdateLastSeen(sessionId, now)) {
|
||||
void prisma.session
|
||||
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sessionId: session.id,
|
||||
userId: session.userId,
|
||||
orgId: session.orgId,
|
||||
};
|
||||
writeCache(sessionId, payload, now);
|
||||
return payload;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
logLine("requireSession.error", { message, stack });
|
||||
console.error("[requireSession]", err);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Optional: update lastSeenAt (useful later)
|
||||
await prisma.session
|
||||
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
|
||||
.catch(() => {});
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
userId: session.userId,
|
||||
orgId: session.orgId,
|
||||
};
|
||||
}
|
||||
|
||||
63
lib/financial/cache.ts
Normal file
63
lib/financial/cache.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { computeFinancialImpact, type FinancialImpactParams } from "@/lib/financial/impact";
|
||||
|
||||
export const FINANCIAL_CONFIG_TTL_SEC = 15;
|
||||
export const FINANCIAL_CONFIG_SWR_SEC = 45;
|
||||
export const FINANCIAL_IMPACT_TTL_SEC = 10;
|
||||
export const FINANCIAL_IMPACT_SWR_SEC = 30;
|
||||
|
||||
async function loadFinancialConfig(orgId: string) {
|
||||
const [org, locations, machines, products] = await Promise.all([
|
||||
prisma.orgFinancialProfile.findUnique({ where: { orgId } }),
|
||||
prisma.locationFinancialOverride.findMany({ where: { orgId }, orderBy: { location: "asc" } }),
|
||||
prisma.machineFinancialOverride.findMany({ where: { orgId }, orderBy: { createdAt: "desc" } }),
|
||||
prisma.productCostOverride.findMany({ where: { orgId }, orderBy: { sku: "asc" } }),
|
||||
]);
|
||||
|
||||
return { org, locations, machines, products };
|
||||
}
|
||||
|
||||
export type FinancialConfigPayload = Awaited<ReturnType<typeof loadFinancialConfig>>;
|
||||
|
||||
export async function getFinancialConfig(orgId: string, options?: { refresh?: boolean }) {
|
||||
if (options?.refresh) {
|
||||
return loadFinancialConfig(orgId);
|
||||
}
|
||||
|
||||
const cached = unstable_cache(
|
||||
() => loadFinancialConfig(orgId),
|
||||
["financial-config", orgId],
|
||||
{ revalidate: FINANCIAL_CONFIG_TTL_SEC, tags: [`financial-config:${orgId}`] }
|
||||
);
|
||||
return cached();
|
||||
}
|
||||
|
||||
export async function getFinancialImpactCached(
|
||||
params: FinancialImpactParams,
|
||||
options?: { refresh?: boolean }
|
||||
) {
|
||||
if (options?.refresh) {
|
||||
return computeFinancialImpact(params);
|
||||
}
|
||||
|
||||
const keyParts = [
|
||||
"financial-impact",
|
||||
params.orgId,
|
||||
String(params.start.getTime()),
|
||||
String(params.end.getTime()),
|
||||
params.machineId ?? "",
|
||||
params.location ?? "",
|
||||
params.sku ?? "",
|
||||
params.currency ?? "",
|
||||
params.includeEvents ? "1" : "0",
|
||||
];
|
||||
|
||||
const cached = unstable_cache(
|
||||
() => computeFinancialImpact(params),
|
||||
keyParts,
|
||||
{ revalidate: FINANCIAL_IMPACT_TTL_SEC, tags: [`financial-impact:${params.orgId}`] }
|
||||
);
|
||||
|
||||
return cached();
|
||||
}
|
||||
@@ -395,10 +395,24 @@
|
||||
"settings.minutes": "minutes",
|
||||
"settings.shiftHint": "Max 3 shifts, HH:mm",
|
||||
"settings.shiftTo": "to",
|
||||
"settings.shiftCompLabel": "Shift change compensation (min)",
|
||||
"settings.lunchBreakLabel": "Lunch break (min)",
|
||||
"settings.shift.defaultName": "Shift {index}",
|
||||
"settings.thresholds": "Alert thresholds",
|
||||
"settings.shiftCompLabel": "Shift change compensation (min)",
|
||||
"settings.lunchBreakLabel": "Lunch break (min)",
|
||||
"settings.shift.defaultName": "Shift {index}",
|
||||
"settings.shiftOverrides.title": "Day-specific shifts",
|
||||
"settings.shiftOverrides.subtitle": "Optional overrides for individual days.",
|
||||
"settings.shiftOverrides.useDefault": "Use default",
|
||||
"settings.shiftOverrides.customize": "Customize",
|
||||
"settings.shiftOverrides.inherits": "Uses default shift schedule.",
|
||||
"settings.shiftOverrides.dayOff": "Day off (no shifts)",
|
||||
"settings.shiftOverrides.clear": "Clear shifts",
|
||||
"settings.shiftOverrides.mon": "Monday",
|
||||
"settings.shiftOverrides.tue": "Tuesday",
|
||||
"settings.shiftOverrides.wed": "Wednesday",
|
||||
"settings.shiftOverrides.thu": "Thursday",
|
||||
"settings.shiftOverrides.fri": "Friday",
|
||||
"settings.shiftOverrides.sat": "Saturday",
|
||||
"settings.shiftOverrides.sun": "Sunday",
|
||||
"settings.thresholds": "Alert thresholds",
|
||||
"settings.thresholdsSubtitle": "Tune production health alerts.",
|
||||
"settings.thresholds.appliesAll": "Applies to all machines",
|
||||
"settings.thresholds.oee": "OEE alert threshold",
|
||||
@@ -453,11 +467,12 @@
|
||||
"financial.title": "Financial Impact",
|
||||
"financial.subtitle": "Translate downtime, slow cycles, and scrap into money.",
|
||||
"financial.ownerOnly": "Financial impact is available only to owners.",
|
||||
"financial.costsMoved": "Cost settings are now in",
|
||||
"financial.costsMovedLink": "Settings -> Financial",
|
||||
"financial.export.html": "HTML",
|
||||
"financial.export.csv": "CSV",
|
||||
"financial.totalLoss": "Total Loss",
|
||||
"financial.costsMoved": "Cost settings are now in",
|
||||
"financial.costsMovedLink": "Settings -> Financial",
|
||||
"financial.export.html": "HTML",
|
||||
"financial.export.csv": "CSV",
|
||||
"financial.refresh": "Refresh",
|
||||
"financial.totalLoss": "Total Loss",
|
||||
"financial.currencyLabel": "Currency: {currency}",
|
||||
"financial.noImpact": "No impact data yet.",
|
||||
"financial.chart.title": "Lost Money Over Time",
|
||||
|
||||
@@ -395,10 +395,24 @@
|
||||
"settings.minutes": "minutos",
|
||||
"settings.shiftHint": "Máx 3 turnos, HH:mm",
|
||||
"settings.shiftTo": "a",
|
||||
"settings.shiftCompLabel": "Compensación por cambio de turno (min)",
|
||||
"settings.lunchBreakLabel": "Comida (min)",
|
||||
"settings.shift.defaultName": "Turno {index}",
|
||||
"settings.thresholds": "Umbrales de alertas",
|
||||
"settings.shiftCompLabel": "Compensación por cambio de turno (min)",
|
||||
"settings.lunchBreakLabel": "Comida (min)",
|
||||
"settings.shift.defaultName": "Turno {index}",
|
||||
"settings.shiftOverrides.title": "Turnos por día",
|
||||
"settings.shiftOverrides.subtitle": "Sobrescrituras opcionales por día.",
|
||||
"settings.shiftOverrides.useDefault": "Usar predeterminado",
|
||||
"settings.shiftOverrides.customize": "Personalizar",
|
||||
"settings.shiftOverrides.inherits": "Usa el horario de turnos predeterminado.",
|
||||
"settings.shiftOverrides.dayOff": "Día libre (sin turnos)",
|
||||
"settings.shiftOverrides.clear": "Borrar turnos",
|
||||
"settings.shiftOverrides.mon": "Lunes",
|
||||
"settings.shiftOverrides.tue": "Martes",
|
||||
"settings.shiftOverrides.wed": "Miércoles",
|
||||
"settings.shiftOverrides.thu": "Jueves",
|
||||
"settings.shiftOverrides.fri": "Viernes",
|
||||
"settings.shiftOverrides.sat": "Sábado",
|
||||
"settings.shiftOverrides.sun": "Domingo",
|
||||
"settings.thresholds": "Umbrales de alertas",
|
||||
"settings.thresholdsSubtitle": "Ajusta alertas de salud de producción.",
|
||||
"settings.thresholds.appliesAll": "Aplica a todas las máquinas",
|
||||
"settings.thresholds.oee": "Umbral de alerta OEE",
|
||||
@@ -453,11 +467,12 @@
|
||||
"financial.title": "Impacto financiero",
|
||||
"financial.subtitle": "Convierte paros, ciclos lentos y scrap en dinero.",
|
||||
"financial.ownerOnly": "El impacto financiero solo está disponible para propietarios.",
|
||||
"financial.costsMoved": "Los costos ahora están en",
|
||||
"financial.costsMovedLink": "Configuración -> Finanzas",
|
||||
"financial.export.html": "HTML",
|
||||
"financial.export.csv": "CSV",
|
||||
"financial.totalLoss": "Pérdida total",
|
||||
"financial.costsMoved": "Los costos ahora están en",
|
||||
"financial.costsMovedLink": "Configuración -> Finanzas",
|
||||
"financial.export.html": "HTML",
|
||||
"financial.export.csv": "CSV",
|
||||
"financial.refresh": "Actualizar",
|
||||
"financial.totalLoss": "Pérdida total",
|
||||
"financial.currencyLabel": "Moneda: {currency}",
|
||||
"financial.noImpact": "Sin datos de impacto.",
|
||||
"financial.chart.title": "Pérdida de dinero en el tiempo",
|
||||
|
||||
@@ -7,6 +7,7 @@ const LOCALE_COOKIE = "mis_locale";
|
||||
const LOCALE_EVENT = "mis-locale-change";
|
||||
|
||||
function readCookieLocale(): Locale | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const match = document.cookie
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
@@ -18,6 +19,7 @@ function readCookieLocale(): Locale | null {
|
||||
}
|
||||
|
||||
function readLocale(): Locale {
|
||||
if (typeof document === "undefined") return defaultLocale;
|
||||
const docLang = document.documentElement.getAttribute("lang");
|
||||
if (docLang === "es-MX" || docLang === "en") return docLang;
|
||||
return readCookieLocale() ?? defaultLocale;
|
||||
|
||||
@@ -3,6 +3,10 @@ import path from "path";
|
||||
|
||||
const LOG_PATH = process.env.LOG_FILE || "/tmp/mis-control-tower.log";
|
||||
|
||||
export function getLogPath() {
|
||||
return LOG_PATH;
|
||||
}
|
||||
|
||||
export function logLine(event: string, data: Record<string, unknown> = {}) {
|
||||
const line = JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
type MachineAuth = { id: string; orgId: string };
|
||||
|
||||
const TTL_MS = 60_000;
|
||||
const TTL_MS = 10_000;
|
||||
const MAX_SIZE = 1000;
|
||||
const cache = new Map<string, { value: MachineAuth; expiresAt: number }>();
|
||||
|
||||
@@ -36,3 +36,12 @@ export async function getMachineAuth(machineId: string, apiKey: string) {
|
||||
cache.set(key, { value: machine, expiresAt: now + TTL_MS });
|
||||
return machine;
|
||||
}
|
||||
|
||||
export function invalidateMachineAuth(machineId: string) {
|
||||
const prefix = `${machineId}:`;
|
||||
for (const key of cache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
113
lib/machines/withLatest.ts
Normal file
113
lib/machines/withLatest.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { OverviewMachineRow } from "@/lib/overview/types";
|
||||
|
||||
type MachineBaseRow = Pick<
|
||||
OverviewMachineRow,
|
||||
"id" | "name" | "code" | "location" | "createdAt" | "updatedAt"
|
||||
>;
|
||||
|
||||
type LatestHeartbeatRow = {
|
||||
machineId: string;
|
||||
ts: Date;
|
||||
tsServer: Date | null;
|
||||
status: string;
|
||||
message?: string | null;
|
||||
ip?: string | null;
|
||||
fwVersion?: string | null;
|
||||
};
|
||||
|
||||
type LatestKpiRow = {
|
||||
machineId: string;
|
||||
ts: Date;
|
||||
oee?: number | null;
|
||||
availability?: number | null;
|
||||
performance?: number | null;
|
||||
quality?: number | null;
|
||||
workOrderId?: string | null;
|
||||
sku?: string | null;
|
||||
good?: number | null;
|
||||
scrap?: number | null;
|
||||
target?: number | null;
|
||||
cycleTime?: number | null;
|
||||
};
|
||||
|
||||
export async function fetchMachineBase(orgId: string): Promise<MachineBaseRow[]> {
|
||||
return prisma.machine.findMany({
|
||||
where: { orgId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
location: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchLatestHeartbeats(
|
||||
orgId: string,
|
||||
machineIds: string[]
|
||||
): Promise<LatestHeartbeatRow[]> {
|
||||
if (!machineIds.length) return [];
|
||||
return prisma.machineHeartbeat.findMany({
|
||||
where: { orgId, machineId: { in: machineIds } },
|
||||
orderBy: [{ machineId: "asc" }, { tsServer: "desc" }],
|
||||
distinct: ["machineId"],
|
||||
select: {
|
||||
machineId: true,
|
||||
ts: true,
|
||||
tsServer: true,
|
||||
status: true,
|
||||
message: true,
|
||||
ip: true,
|
||||
fwVersion: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchLatestKpis(
|
||||
orgId: string,
|
||||
machineIds: string[]
|
||||
): Promise<LatestKpiRow[]> {
|
||||
if (!machineIds.length) return [];
|
||||
return prisma.machineKpiSnapshot.findMany({
|
||||
where: { orgId, machineId: { in: machineIds } },
|
||||
orderBy: [{ machineId: "asc" }, { ts: "desc" }],
|
||||
distinct: ["machineId"],
|
||||
select: {
|
||||
machineId: true,
|
||||
ts: true,
|
||||
oee: true,
|
||||
availability: true,
|
||||
performance: true,
|
||||
quality: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
good: true,
|
||||
scrap: true,
|
||||
target: true,
|
||||
cycleTime: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeMachineOverviewRows(params: {
|
||||
machines: MachineBaseRow[];
|
||||
heartbeats: LatestHeartbeatRow[];
|
||||
kpis?: LatestKpiRow[];
|
||||
includeKpi?: boolean;
|
||||
}): OverviewMachineRow[] {
|
||||
const { machines, heartbeats, kpis = [], includeKpi = false } = params;
|
||||
const heartbeatMap = new Map(heartbeats.map((row) => [row.machineId, row]));
|
||||
const kpiMap = new Map(kpis.map((row) => [row.machineId, row]));
|
||||
|
||||
return machines.map((machine) => ({
|
||||
...machine,
|
||||
latestHeartbeat: (heartbeatMap.get(machine.id) ?? null) as OverviewMachineRow["latestHeartbeat"],
|
||||
latestKpi: includeKpi ? (kpiMap.get(machine.id) ?? null) : null,
|
||||
heartbeats: undefined,
|
||||
kpiSnapshots: undefined,
|
||||
}));
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { normalizeEvent } from "@/lib/events/normalizeEvent";
|
||||
import { logLine } from "@/lib/logger";
|
||||
import { elapsedMs, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
|
||||
import type { OverviewEventRow, OverviewMachineRow } from "@/lib/overview/types";
|
||||
import {
|
||||
fetchLatestHeartbeats,
|
||||
fetchLatestKpis,
|
||||
fetchMachineBase,
|
||||
mergeMachineOverviewRows,
|
||||
} from "@/lib/machines/withLatest";
|
||||
|
||||
const ALLOWED_TYPES = new Set([
|
||||
"slow-cycle",
|
||||
@@ -37,157 +46,169 @@ export async function getOverviewData({
|
||||
eventsWindowSec = 21600,
|
||||
eventMachines = 6,
|
||||
orgSettings,
|
||||
}: OverviewParams) {
|
||||
const machines = await prisma.machine.findMany({
|
||||
where: { orgId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
location: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
heartbeats: {
|
||||
orderBy: { tsServer: "desc" },
|
||||
take: 1,
|
||||
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
|
||||
},
|
||||
kpiSnapshots: {
|
||||
orderBy: { ts: "desc" },
|
||||
take: 1,
|
||||
select: {
|
||||
ts: true,
|
||||
oee: true,
|
||||
availability: true,
|
||||
performance: true,
|
||||
quality: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
good: true,
|
||||
scrap: true,
|
||||
target: true,
|
||||
cycleTime: true,
|
||||
}: OverviewParams): Promise<{ machines: OverviewMachineRow[]; events: OverviewEventRow[] }> {
|
||||
const perfEnabled = PERF_LOGS_ENABLED;
|
||||
const timings: Record<string, number> = {};
|
||||
const totalStart = nowMs();
|
||||
|
||||
try {
|
||||
const machinesStart = nowMs();
|
||||
const machines = await fetchMachineBase(orgId);
|
||||
if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
|
||||
|
||||
const heartbeatStart = nowMs();
|
||||
const machineIds = machines.map((machine) => machine.id);
|
||||
const heartbeats = await fetchLatestHeartbeats(orgId, machineIds);
|
||||
if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
|
||||
|
||||
const kpiStart = nowMs();
|
||||
const kpis = await fetchLatestKpis(orgId, machineIds);
|
||||
if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart);
|
||||
|
||||
const machineRows: OverviewMachineRow[] = mergeMachineOverviewRows({
|
||||
machines,
|
||||
heartbeats,
|
||||
kpis,
|
||||
includeKpi: true,
|
||||
});
|
||||
|
||||
const safeEventMachines = Number.isFinite(eventMachines) ? Math.max(1, Math.floor(eventMachines)) : 6;
|
||||
const safeWindowSec = Number.isFinite(eventsWindowSec) ? eventsWindowSec : 21600;
|
||||
|
||||
const topMachines = machineRows
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const at = heartbeatTime(a.latestHeartbeat);
|
||||
const bt = heartbeatTime(b.latestHeartbeat);
|
||||
const atMs = at ? at.getTime() : 0;
|
||||
const btMs = bt ? bt.getTime() : 0;
|
||||
return btMs - atMs;
|
||||
})
|
||||
.slice(0, safeEventMachines);
|
||||
|
||||
const targetIds = topMachines.map((m) => m.id);
|
||||
|
||||
let events: OverviewEventRow[] = [];
|
||||
|
||||
if (targetIds.length) {
|
||||
let settings = orgSettings ?? null;
|
||||
if (!settings) {
|
||||
const settingsStart = nowMs();
|
||||
settings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId },
|
||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
|
||||
});
|
||||
if (perfEnabled) timings.orgSettingsQuery = elapsedMs(settingsStart);
|
||||
}
|
||||
|
||||
const microMultiplier = Number(settings?.stoppageMultiplier ?? 1.5);
|
||||
const macroMultiplier = Math.max(microMultiplier, Number(settings?.macroStoppageMultiplier ?? 5));
|
||||
const windowStart = new Date(Date.now() - Math.max(0, safeWindowSec) * 1000);
|
||||
|
||||
const eventsStart = nowMs();
|
||||
const rawEvents = await prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
machineId: { in: targetIds },
|
||||
ts: { gte: windowStart },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
orderBy: { ts: "desc" },
|
||||
take: Math.min(300, Math.max(60, targetIds.length * 40)),
|
||||
select: {
|
||||
id: true,
|
||||
ts: true,
|
||||
topic: true,
|
||||
eventType: true,
|
||||
severity: true,
|
||||
title: true,
|
||||
description: true,
|
||||
requiresAck: true,
|
||||
data: true,
|
||||
workOrderId: true,
|
||||
machineId: true,
|
||||
machine: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
if (perfEnabled) timings.eventsQuery = elapsedMs(eventsStart);
|
||||
|
||||
const machineRows = machines.map((m) => ({
|
||||
...m,
|
||||
latestHeartbeat: m.heartbeats[0] ?? null,
|
||||
latestKpi: m.kpiSnapshots[0] ?? null,
|
||||
heartbeats: undefined,
|
||||
kpiSnapshots: undefined,
|
||||
}));
|
||||
const normalizeStart = nowMs();
|
||||
const normalized = rawEvents
|
||||
.map((row) => ({
|
||||
...normalizeEvent(row, { microMultiplier, macroMultiplier }),
|
||||
machineId: row.machineId,
|
||||
machineName: row.machine?.name ?? null,
|
||||
source: "ingested" as const,
|
||||
}))
|
||||
.filter((event) => event.ts);
|
||||
if (perfEnabled) timings.eventsNormalize = elapsedMs(normalizeStart);
|
||||
|
||||
const safeEventMachines = Number.isFinite(eventMachines) ? Math.max(1, Math.floor(eventMachines)) : 6;
|
||||
const safeWindowSec = Number.isFinite(eventsWindowSec) ? eventsWindowSec : 21600;
|
||||
const filterStart = nowMs();
|
||||
const allowed = normalized.filter((event) => ALLOWED_TYPES.has(event.eventType));
|
||||
const isCritical = (event: (typeof allowed)[number]) => {
|
||||
const severity = String(event.severity ?? "").toLowerCase();
|
||||
return (
|
||||
event.eventType === "macrostop" ||
|
||||
event.requiresAck === true ||
|
||||
severity === "critical" ||
|
||||
severity === "error" ||
|
||||
severity === "high"
|
||||
);
|
||||
};
|
||||
|
||||
const topMachines = machineRows
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const at = heartbeatTime(a.latestHeartbeat);
|
||||
const bt = heartbeatTime(b.latestHeartbeat);
|
||||
const atMs = at ? at.getTime() : 0;
|
||||
const btMs = bt ? bt.getTime() : 0;
|
||||
return btMs - atMs;
|
||||
})
|
||||
.slice(0, safeEventMachines);
|
||||
const filtered = eventsMode === "critical" ? allowed.filter(isCritical) : allowed;
|
||||
|
||||
const targetIds = topMachines.map((m) => m.id);
|
||||
const seen = new Set<string>();
|
||||
const deduped = filtered.filter((event) => {
|
||||
const key = `${event.machineId}-${event.eventType}-${event.ts ?? ""}-${event.title}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
let events = [] as Array<{
|
||||
id: string;
|
||||
ts: Date | null;
|
||||
topic: string;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
requiresAck: boolean;
|
||||
workOrderId?: string | null;
|
||||
machineId: string;
|
||||
machineName?: string | null;
|
||||
source: "ingested";
|
||||
}>;
|
||||
deduped.sort((a, b) => {
|
||||
const at = a.ts ? a.ts.getTime() : 0;
|
||||
const bt = b.ts ? b.ts.getTime() : 0;
|
||||
return bt - at;
|
||||
});
|
||||
|
||||
if (targetIds.length) {
|
||||
let settings = orgSettings ?? null;
|
||||
if (!settings) {
|
||||
settings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId },
|
||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
|
||||
events = deduped.slice(0, 30);
|
||||
if (perfEnabled) timings.eventsFilter = elapsedMs(filterStart);
|
||||
}
|
||||
|
||||
if (perfEnabled) {
|
||||
timings.total = elapsedMs(totalStart);
|
||||
logLine("perf.overview.getOverviewData", {
|
||||
orgId,
|
||||
eventsMode,
|
||||
eventsWindowSec,
|
||||
eventMachines,
|
||||
timings,
|
||||
counts: {
|
||||
machines: machineRows.length,
|
||||
events: events.length,
|
||||
targetMachines: targetIds.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const microMultiplier = Number(settings?.stoppageMultiplier ?? 1.5);
|
||||
const macroMultiplier = Math.max(microMultiplier, Number(settings?.macroStoppageMultiplier ?? 5));
|
||||
const windowStart = new Date(Date.now() - Math.max(0, safeWindowSec) * 1000);
|
||||
|
||||
const rawEvents = await prisma.machineEvent.findMany({
|
||||
where: {
|
||||
return { machines: machineRows, events };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
if (perfEnabled) {
|
||||
timings.total = elapsedMs(totalStart);
|
||||
logLine("perf.overview.getOverviewData.error", {
|
||||
orgId,
|
||||
machineId: { in: targetIds },
|
||||
ts: { gte: windowStart },
|
||||
},
|
||||
orderBy: { ts: "desc" },
|
||||
take: Math.min(300, Math.max(60, targetIds.length * 40)),
|
||||
select: {
|
||||
id: true,
|
||||
ts: true,
|
||||
topic: true,
|
||||
eventType: true,
|
||||
severity: true,
|
||||
title: true,
|
||||
description: true,
|
||||
requiresAck: true,
|
||||
data: true,
|
||||
workOrderId: true,
|
||||
machineId: true,
|
||||
machine: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const normalized = rawEvents
|
||||
.map((row) => ({
|
||||
...normalizeEvent(row, { microMultiplier, macroMultiplier }),
|
||||
machineId: row.machineId,
|
||||
machineName: row.machine?.name ?? null,
|
||||
source: "ingested" as const,
|
||||
}))
|
||||
.filter((event) => event.ts);
|
||||
|
||||
const allowed = normalized.filter((event) => ALLOWED_TYPES.has(event.eventType));
|
||||
const isCritical = (event: (typeof allowed)[number]) => {
|
||||
const severity = String(event.severity ?? "").toLowerCase();
|
||||
return (
|
||||
event.eventType === "macrostop" ||
|
||||
event.requiresAck === true ||
|
||||
severity === "critical" ||
|
||||
severity === "error" ||
|
||||
severity === "high"
|
||||
);
|
||||
};
|
||||
|
||||
const filtered = eventsMode === "critical" ? allowed.filter(isCritical) : allowed;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const deduped = filtered.filter((event) => {
|
||||
const key = `${event.machineId}-${event.eventType}-${event.ts ?? ""}-${event.title}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
deduped.sort((a, b) => {
|
||||
const at = a.ts ? a.ts.getTime() : 0;
|
||||
const bt = b.ts ? b.ts.getTime() : 0;
|
||||
return bt - at;
|
||||
});
|
||||
|
||||
events = deduped.slice(0, 30);
|
||||
eventsMode,
|
||||
eventsWindowSec,
|
||||
eventMachines,
|
||||
timings,
|
||||
message,
|
||||
stack,
|
||||
});
|
||||
}
|
||||
logLine("getOverviewData.error", { message, stack });
|
||||
console.error("[getOverviewData]", err);
|
||||
return { machines: [], events: [] };
|
||||
}
|
||||
|
||||
return { machines: machineRows, events };
|
||||
}
|
||||
|
||||
102
lib/overview/getOverviewSummary.ts
Normal file
102
lib/overview/getOverviewSummary.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { logLine } from "@/lib/logger";
|
||||
import { elapsedMs, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
|
||||
import type { OverviewMachineRow } from "@/lib/overview/types";
|
||||
import {
|
||||
fetchLatestHeartbeats,
|
||||
fetchMachineBase,
|
||||
mergeMachineOverviewRows,
|
||||
} from "@/lib/machines/withLatest";
|
||||
|
||||
type OverviewSummaryParams = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
const SUMMARY_CACHE_TTL_MS = 10000;
|
||||
const summaryCache = new Map<string, { value: OverviewMachineRow[]; expiresAt: number; cachedAt: number }>();
|
||||
const summaryInFlight = new Map<string, Promise<{ machines: OverviewMachineRow[] }>>();
|
||||
|
||||
export async function getOverviewSummary({
|
||||
orgId,
|
||||
}: OverviewSummaryParams): Promise<{ machines: OverviewMachineRow[] }> {
|
||||
const now = Date.now();
|
||||
const cached = summaryCache.get(orgId);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
if (PERF_LOGS_ENABLED) {
|
||||
logLine("perf.overview.summary", {
|
||||
orgId,
|
||||
cached: true,
|
||||
timings: { total: 0 },
|
||||
ageMs: now - cached.cachedAt,
|
||||
counts: { machines: cached.value.length },
|
||||
});
|
||||
}
|
||||
return { machines: cached.value };
|
||||
}
|
||||
|
||||
const inFlight = summaryInFlight.get(orgId);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const promise = fetchOverviewSummary({ orgId })
|
||||
.then((result) => {
|
||||
summaryCache.set(orgId, {
|
||||
value: result.machines,
|
||||
cachedAt: now,
|
||||
expiresAt: now + SUMMARY_CACHE_TTL_MS,
|
||||
});
|
||||
summaryInFlight.delete(orgId);
|
||||
return result;
|
||||
})
|
||||
.catch((err) => {
|
||||
summaryInFlight.delete(orgId);
|
||||
throw err;
|
||||
});
|
||||
|
||||
summaryInFlight.set(orgId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function fetchOverviewSummary({
|
||||
orgId,
|
||||
}: OverviewSummaryParams): Promise<{ machines: OverviewMachineRow[] }> {
|
||||
const perfEnabled = PERF_LOGS_ENABLED;
|
||||
const totalStart = nowMs();
|
||||
const timings: Record<string, number> = {};
|
||||
|
||||
try {
|
||||
const machinesStart = nowMs();
|
||||
const machines = await fetchMachineBase(orgId);
|
||||
if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
|
||||
|
||||
const heartbeatStart = nowMs();
|
||||
const machineIds = machines.map((machine) => machine.id);
|
||||
const heartbeats = await fetchLatestHeartbeats(orgId, machineIds);
|
||||
if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
|
||||
|
||||
const machineRows: OverviewMachineRow[] = mergeMachineOverviewRows({
|
||||
machines,
|
||||
heartbeats,
|
||||
includeKpi: false,
|
||||
});
|
||||
|
||||
if (perfEnabled) {
|
||||
timings.total = elapsedMs(totalStart);
|
||||
logLine("perf.overview.summary", {
|
||||
orgId,
|
||||
timings,
|
||||
counts: { machines: machineRows.length },
|
||||
});
|
||||
}
|
||||
|
||||
return { machines: machineRows };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
if (perfEnabled) {
|
||||
timings.total = elapsedMs(totalStart);
|
||||
logLine("perf.overview.summary.error", { orgId, timings, message, stack });
|
||||
}
|
||||
logLine("getOverviewSummary.error", { message, stack });
|
||||
console.error("[getOverviewSummary]", err);
|
||||
return { machines: [] };
|
||||
}
|
||||
}
|
||||
51
lib/overview/types.ts
Normal file
51
lib/overview/types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type OverviewLatestHeartbeat = {
|
||||
ts: Date;
|
||||
tsServer?: Date | null;
|
||||
status: string;
|
||||
message?: string | null;
|
||||
ip?: string | null;
|
||||
fwVersion?: string | null;
|
||||
};
|
||||
|
||||
export type OverviewLatestKpi = {
|
||||
ts: Date;
|
||||
oee?: number | null;
|
||||
availability?: number | null;
|
||||
performance?: number | null;
|
||||
quality?: number | null;
|
||||
workOrderId?: string | null;
|
||||
sku?: string | null;
|
||||
good?: number | null;
|
||||
scrap?: number | null;
|
||||
target?: number | null;
|
||||
cycleTime?: number | null;
|
||||
};
|
||||
|
||||
export type OverviewMachineRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
location?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
latestHeartbeat: OverviewLatestHeartbeat | null;
|
||||
latestKpi: OverviewLatestKpi | null;
|
||||
heartbeats?: undefined;
|
||||
kpiSnapshots?: undefined;
|
||||
};
|
||||
|
||||
export type OverviewEventRow = {
|
||||
id: string;
|
||||
ts: Date | null;
|
||||
topic: string;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
requiresAck: boolean;
|
||||
workOrderId?: string | null;
|
||||
machineId: string;
|
||||
machineName?: string | null;
|
||||
source: "ingested";
|
||||
};
|
||||
|
||||
18
lib/perf/serverTiming.ts
Normal file
18
lib/perf/serverTiming.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { performance } from "perf_hooks";
|
||||
|
||||
export const PERF_LOGS_ENABLED = process.env.PERF_LOGS === "1";
|
||||
|
||||
export function nowMs() {
|
||||
return performance.now();
|
||||
}
|
||||
|
||||
export function elapsedMs(startMs: number) {
|
||||
return Math.round((performance.now() - startMs) * 100) / 100;
|
||||
}
|
||||
|
||||
export function formatServerTiming(entries: Record<string, number>) {
|
||||
return Object.entries(entries)
|
||||
.filter(([, value]) => Number.isFinite(value))
|
||||
.map(([name, value]) => `${name};dur=${value.toFixed(1)}`)
|
||||
.join(", ");
|
||||
}
|
||||
200
lib/reasonCatalog.ts
Normal file
200
lib/reasonCatalog.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
export type ReasonCatalogKind = "downtime" | "scrap";
|
||||
|
||||
export type ReasonCatalogDetail = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ReasonCatalogCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
details: ReasonCatalogDetail[];
|
||||
};
|
||||
|
||||
export type ReasonCatalog = {
|
||||
version: number;
|
||||
downtime: ReasonCatalogCategory[];
|
||||
scrap: ReasonCatalogCategory[];
|
||||
};
|
||||
|
||||
function isPlainObject(value: unknown): value is AnyRecord {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function canonicalId(input: unknown, fallback = "item") {
|
||||
const text = String(input ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
function buildReasonCode(categoryId: string, detailId: string) {
|
||||
return `${canonicalId(categoryId)}__${canonicalId(detailId)}`.toUpperCase();
|
||||
}
|
||||
|
||||
function toCategory(raw: unknown): ReasonCatalogCategory | null {
|
||||
if (!isPlainObject(raw)) return null;
|
||||
const labelRaw = String(raw.label ?? "").trim();
|
||||
if (!labelRaw) return null;
|
||||
const idRaw = String(raw.id ?? "").trim() || canonicalId(labelRaw, "category");
|
||||
const detailsRaw =
|
||||
(Array.isArray(raw.details) && raw.details) ||
|
||||
(Array.isArray(raw.children) && raw.children) ||
|
||||
(Array.isArray(raw.items) && raw.items) ||
|
||||
[];
|
||||
|
||||
const details: ReasonCatalogDetail[] = [];
|
||||
for (const detailRaw of detailsRaw) {
|
||||
if (!isPlainObject(detailRaw)) continue;
|
||||
const detailLabel = String(detailRaw.label ?? "").trim();
|
||||
if (!detailLabel) continue;
|
||||
const detailId = String(detailRaw.id ?? "").trim() || canonicalId(detailLabel, "detail");
|
||||
details.push({ id: detailId, label: detailLabel });
|
||||
}
|
||||
|
||||
if (!details.length) return null;
|
||||
return { id: idRaw, label: labelRaw, details };
|
||||
}
|
||||
|
||||
function normalizeKind(raw: unknown): ReasonCatalogCategory[] {
|
||||
const arr =
|
||||
(Array.isArray(raw) && raw) ||
|
||||
(isPlainObject(raw) && Array.isArray(raw.categories) && raw.categories) ||
|
||||
[];
|
||||
const out: ReasonCatalogCategory[] = [];
|
||||
for (const candidate of arr) {
|
||||
const parsed = toCategory(candidate);
|
||||
if (parsed) out.push(parsed);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeReasonCatalog(raw: unknown): ReasonCatalog | null {
|
||||
if (!isPlainObject(raw)) return null;
|
||||
const downtime = normalizeKind(raw.downtime);
|
||||
const scrap = normalizeKind(raw.scrap);
|
||||
if (!downtime.length && !scrap.length) return null;
|
||||
const versionNum = Number(raw.version);
|
||||
const version = Number.isFinite(versionNum) ? Math.max(1, Math.trunc(versionNum)) : 1;
|
||||
return {
|
||||
version,
|
||||
downtime,
|
||||
scrap,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseReasonCatalogMarkdown(markdown: string): ReasonCatalog {
|
||||
const lines = markdown
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const buckets: Record<ReasonCatalogKind, Map<string, ReasonCatalogCategory>> = {
|
||||
downtime: new Map(),
|
||||
scrap: new Map(),
|
||||
};
|
||||
let activeKind: ReasonCatalogKind = "downtime";
|
||||
|
||||
for (const line of lines) {
|
||||
const lowered = line.toLowerCase();
|
||||
if (lowered === "downtime") {
|
||||
activeKind = "downtime";
|
||||
continue;
|
||||
}
|
||||
if (lowered === "scrap") {
|
||||
activeKind = "scrap";
|
||||
continue;
|
||||
}
|
||||
|
||||
const slash = line.indexOf("/");
|
||||
if (slash < 1 || slash === line.length - 1) continue;
|
||||
|
||||
const categoryLabel = line.slice(0, slash).trim();
|
||||
const detailLabel = line.slice(slash + 1).trim();
|
||||
if (!categoryLabel || !detailLabel) continue;
|
||||
|
||||
const categoryId = canonicalId(categoryLabel, "category");
|
||||
const detailId = canonicalId(detailLabel, "detail");
|
||||
|
||||
const existing =
|
||||
buckets[activeKind].get(categoryId) ?? {
|
||||
id: categoryId,
|
||||
label: categoryLabel,
|
||||
details: [] as ReasonCatalogDetail[],
|
||||
};
|
||||
if (!existing.details.some((d) => d.id === detailId)) {
|
||||
existing.details.push({ id: detailId, label: detailLabel });
|
||||
}
|
||||
buckets[activeKind].set(categoryId, existing);
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
downtime: [...buckets.downtime.values()],
|
||||
scrap: [...buckets.scrap.values()],
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
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}`,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export function findCatalogReason(
|
||||
catalog: ReasonCatalog | null | undefined,
|
||||
kind: ReasonCatalogKind,
|
||||
categoryId: unknown,
|
||||
detailId: unknown
|
||||
) {
|
||||
if (!catalog) return null;
|
||||
const catId = canonicalId(categoryId, "");
|
||||
const detId = canonicalId(detailId, "");
|
||||
if (!catId || !detId) return null;
|
||||
const category = (catalog[kind] ?? []).find((c) => canonicalId(c.id, "") === catId);
|
||||
if (!category) return null;
|
||||
const detail = category.details.find((d) => canonicalId(d.id, "") === detId);
|
||||
if (!detail) return null;
|
||||
return {
|
||||
categoryId: category.id,
|
||||
categoryLabel: category.label,
|
||||
detailId: detail.id,
|
||||
detailLabel: detail.label,
|
||||
reasonCode: buildReasonCode(category.id, detail.id),
|
||||
reasonLabel: `${category.label} > ${detail.label}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function toReasonCode(categoryId: unknown, detailId: unknown) {
|
||||
const cat = canonicalId(categoryId, "");
|
||||
const det = canonicalId(detailId, "");
|
||||
if (!cat || !det) return null;
|
||||
return buildReasonCode(cat, det);
|
||||
}
|
||||
@@ -18,6 +18,9 @@ export const DEFAULT_SHIFT = {
|
||||
end: "15:00",
|
||||
};
|
||||
|
||||
export const SHIFT_OVERRIDE_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
export type ShiftOverrideDay = (typeof SHIFT_OVERRIDE_DAYS)[number];
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
function isPlainObject(value: unknown): value is AnyRecord {
|
||||
@@ -40,6 +43,7 @@ type SettingsRow = {
|
||||
timezone: string;
|
||||
shiftChangeCompMin?: number | null;
|
||||
lunchBreakMin?: number | null;
|
||||
shiftScheduleOverridesJson?: unknown;
|
||||
stoppageMultiplier?: number | null;
|
||||
macroStoppageMultiplier?: number | null;
|
||||
oeeAlertThresholdPct?: number | null;
|
||||
@@ -59,6 +63,13 @@ type ShiftRow = {
|
||||
sortOrder?: number | null;
|
||||
};
|
||||
|
||||
type ShiftOverridePayload = {
|
||||
name: string;
|
||||
start: string;
|
||||
end: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[]) {
|
||||
const ordered = [...(shifts ?? [])].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
const mappedShifts = ordered.map((s, idx) => ({
|
||||
@@ -67,6 +78,13 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
|
||||
end: s.endTime,
|
||||
enabled: s.enabled !== false,
|
||||
}));
|
||||
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,
|
||||
@@ -74,6 +92,7 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
|
||||
timezone: settings.timezone,
|
||||
shiftSchedule: {
|
||||
shifts: mappedShifts,
|
||||
overrides: overrides && Object.keys(overrides).length ? overrides : undefined,
|
||||
shiftChangeCompensationMin: settings.shiftChangeCompMin,
|
||||
lunchBreakMin: settings.lunchBreakMin,
|
||||
},
|
||||
@@ -85,7 +104,10 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
|
||||
qualitySpikeDeltaPct: settings.qualitySpikeDeltaPct,
|
||||
},
|
||||
alerts: normalizeAlerts(settings.alertsJson),
|
||||
defaults: normalizeDefaults(settings.defaultsJson),
|
||||
defaults,
|
||||
reasonCatalog: reasonCatalog ?? undefined,
|
||||
reasonCatalogData: reasonCatalog ?? undefined,
|
||||
reasonCatalogVersion: Number((reasonCatalog as AnyRecord | null)?.version ?? 1),
|
||||
updatedAt: settings.updatedAt,
|
||||
updatedBy: settings.updatedBy,
|
||||
};
|
||||
@@ -169,6 +191,57 @@ export function validateShiftSchedule(shifts: unknown) {
|
||||
return { ok: true, shifts: normalized as NormalizedShift[] };
|
||||
}
|
||||
|
||||
export function validateShiftOverrides(overrides: unknown) {
|
||||
if (overrides === null) {
|
||||
return { ok: true, overrides: null as Record<string, ShiftOverridePayload[]> | null } as const;
|
||||
}
|
||||
if (!isPlainObject(overrides)) {
|
||||
return { ok: false, error: "shift overrides must be an object" } as const;
|
||||
}
|
||||
|
||||
const normalized: Record<string, ShiftOverridePayload[]> = {};
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (!SHIFT_OVERRIDE_DAYS.includes(key as ShiftOverrideDay)) {
|
||||
return { ok: false, error: `invalid shift override day: ${key}` } as const;
|
||||
}
|
||||
const shiftResult = validateShiftSchedule(value);
|
||||
if (!shiftResult.ok) {
|
||||
return { ok: false, error: `shift overrides ${key}: ${shiftResult.error}` } as const;
|
||||
}
|
||||
normalized[key] =
|
||||
shiftResult.shifts?.map((s) => ({
|
||||
name: s.name,
|
||||
start: s.startTime,
|
||||
end: s.endTime,
|
||||
enabled: s.enabled !== false,
|
||||
})) ?? [];
|
||||
}
|
||||
|
||||
return { ok: true, overrides: normalized } as const;
|
||||
}
|
||||
|
||||
export function normalizeShiftOverrides(raw: unknown) {
|
||||
if (!isPlainObject(raw)) return undefined;
|
||||
const out: Record<string, ShiftOverridePayload[]> = {};
|
||||
for (const day of SHIFT_OVERRIDE_DAYS) {
|
||||
const value = raw[day];
|
||||
if (!Array.isArray(value)) continue;
|
||||
const normalized = value
|
||||
.map((entry, idx) => {
|
||||
const record = isPlainObject(entry) ? entry : {};
|
||||
const start = String(record.start ?? record.startTime ?? "").trim();
|
||||
const end = String(record.end ?? record.endTime ?? "").trim();
|
||||
if (!TIME_RE.test(start) || !TIME_RE.test(end)) return null;
|
||||
const name = String(record.name ?? `Shift ${idx + 1}`).trim() || `Shift ${idx + 1}`;
|
||||
const enabled = record.enabled !== false;
|
||||
return { name, start, end, enabled };
|
||||
})
|
||||
.filter((entry): entry is ShiftOverridePayload => !!entry);
|
||||
out[day] = normalized;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function validateShiftFields(shiftChangeCompensationMin?: unknown, lunchBreakMin?: unknown) {
|
||||
if (shiftChangeCompensationMin != null) {
|
||||
const v = Number(shiftChangeCompensationMin);
|
||||
|
||||
Reference in New Issue
Block a user