From bfc1673d89d867c43ac550bf30d8842bee7df806 Mon Sep 17 00:00:00 2001 From: Marcelo Date: Wed, 6 May 2026 00:36:48 +0000 Subject: [PATCH] Downtime catalog --- MACHINE_STATE_PROGRESS.md | 7 + app/(app)/recap/RecapGridClient.tsx | 3 +- app/(app)/settings/page.tsx | 15 +- app/api/ingest/event/route.ts | 31 +- app/api/machines/[machineId]/route.ts | 63 +- app/api/machines/[machineId]/route.ts.bak | 492 ++ app/api/reasons/catalog/route.ts | 36 +- .../settings/machines/[machineId]/route.ts | 86 +- .../categories/[categoryId]/route.ts | 106 + .../reason-catalog/categories/route.ts | 64 + .../reason-catalog/items/[itemId]/route.ts | 69 + .../settings/reason-catalog/items/route.ts | 71 + app/api/settings/reason-catalog/route.ts | 43 + app/api/settings/route.ts | 66 +- components/recap/RecapMachineCard.tsx | 15 +- components/settings/ReasonCatalogConfig.tsx | 445 ++ flows_may_4_26.json | 4288 +++++++++++++++++ lib/alerts/getAlertsInboxData.ts | 28 +- lib/alerts/getAlertsInboxData.ts.bak | 363 ++ lib/auth/requireOrgAdminSession.ts | 25 + lib/i18n/en.json | 44 +- lib/i18n/es-MX.json | 44 +- lib/reasonCatalog.ts | 113 +- lib/reasonCatalogDb.ts | 98 + lib/reasonCatalogFallback.ts | 15 + lib/recap/getRecapData.ts | 1 + lib/recap/machineState.ts | 53 +- lib/recap/redesign.ts | 1668 ++++--- lib/recap/timeline.ts | 55 +- lib/recap/timelineApi.ts | 3 +- lib/recap/types.ts | 19 +- lib/settings.ts | 7 - package.json | 3 +- .../migration.sql | 42 + prisma/schema.prisma | 38 + reasons/Claves Tiempo Muerto.xlsx | Bin 0 -> 49272 bytes reasons/Claves de Scrap.xlsx | Bin 0 -> 35930 bytes scripts/REASON_CATALOG_SYNC.md | 22 + scripts/export-reason-catalog-csv.mjs | 76 + scripts/mysql/reason_catalog_mirror.sql | 18 + scripts/patch-flows-reason-mirror.mjs | 213 + scripts/seed-reason-catalog-from-xlsx.mjs | 280 ++ 42 files changed, 8035 insertions(+), 1093 deletions(-) create mode 100644 app/api/machines/[machineId]/route.ts.bak create mode 100644 app/api/settings/reason-catalog/categories/[categoryId]/route.ts create mode 100644 app/api/settings/reason-catalog/categories/route.ts create mode 100644 app/api/settings/reason-catalog/items/[itemId]/route.ts create mode 100644 app/api/settings/reason-catalog/items/route.ts create mode 100644 app/api/settings/reason-catalog/route.ts create mode 100644 components/settings/ReasonCatalogConfig.tsx create mode 100644 flows_may_4_26.json create mode 100644 lib/alerts/getAlertsInboxData.ts.bak create mode 100644 lib/auth/requireOrgAdminSession.ts create mode 100644 lib/reasonCatalogDb.ts create mode 100644 lib/reasonCatalogFallback.ts create mode 100644 prisma/migrations/20260505120000_reason_catalog_tables/migration.sql create mode 100755 reasons/Claves Tiempo Muerto.xlsx create mode 100755 reasons/Claves de Scrap.xlsx create mode 100644 scripts/REASON_CATALOG_SYNC.md create mode 100644 scripts/export-reason-catalog-csv.mjs create mode 100644 scripts/mysql/reason_catalog_mirror.sql create mode 100644 scripts/patch-flows-reason-mirror.mjs create mode 100644 scripts/seed-reason-catalog-from-xlsx.mjs diff --git a/MACHINE_STATE_PROGRESS.md b/MACHINE_STATE_PROGRESS.md index 46adfed..cfe4f82 100644 --- a/MACHINE_STATE_PROGRESS.md +++ b/MACHINE_STATE_PROGRESS.md @@ -34,3 +34,10 @@ ## Notes / parked items - Prisma drift on (orgId,machineId,seq) unique indexes — pre-existing, not related to this work. Address as separate housekeeping task. - Node-RED incidentKey rotation behavior verified: 10 distinct keys per real stoppage = correct. + +## Path A — dead state cleanup (post Round 1) +- [x] Removed `not_started` and `data-loss` branches from classifier +- [x] Removed `RecapStoppedReason` and `RecapDataLossReason` types +- [x] Simplified `RecapStateContext` to empty struct (kept for future use) +- [x] Updated UI rendering: 5 states only (offline/stopped/mold-change/idle/running) +- [x] i18n: removed dead keys diff --git a/app/(app)/recap/RecapGridClient.tsx b/app/(app)/recap/RecapGridClient.tsx index 3d4cdd6..6d7bdbf 100644 --- a/app/(app)/recap/RecapGridClient.tsx +++ b/app/(app)/recap/RecapGridClient.tsx @@ -13,7 +13,6 @@ function statusLabel(status: RecapMachineStatus, t: (key: string) => string) { if (status === "running") return t("recap.status.running"); if (status === "mold-change") return t("recap.status.moldChange"); if (status === "stopped") return t("recap.status.stopped"); - if (status === "data-loss") return t("recap.status.dataLoss"); if (status === "idle") return t("recap.status.idle"); return t("recap.status.offline"); } @@ -112,7 +111,7 @@ export default function RecapGridClient({ initialData }: Props) { className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200" > - {(["running", "mold-change", "stopped", "data-loss", "idle", "offline"] as const).map((status) => ( + {(["running", "mold-change", "stopped", "idle", "offline"] as const).map((status) => ( diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index b689b5f..9b33dab 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertsConfig } from "@/components/settings/AlertsConfig"; import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig"; +import { ReasonCatalogConfig } from "@/components/settings/ReasonCatalogConfig"; import { useI18n } from "@/lib/i18n/useI18n"; import { SHIFT_OVERRIDE_DAYS, type ShiftOverrideDay } from "@/lib/settings"; import { useScreenlessMode } from "@/lib/ui/screenlessMode"; @@ -122,6 +123,7 @@ const SETTINGS_TABS = [ { id: "thresholds", labelKey: "settings.tabs.thresholds" }, { id: "alerts", labelKey: "settings.tabs.alerts" }, { id: "financial", labelKey: "settings.tabs.financial" }, + { id: "reasonCatalog", labelKey: "settings.tabs.reasonCatalog" }, { id: "team", labelKey: "settings.tabs.team" }, ] as const; @@ -239,7 +241,6 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string const thresholds = asRecord(record.thresholds) ?? {}; const alerts = asRecord(record.alerts) ?? {}; const defaults = asRecord(record.defaults) ?? {}; - return { orgId: String(record.orgId ?? ""), version: Number(record.version ?? 0), @@ -1276,6 +1277,18 @@ export default function SettingsPage() { )} + {activeTab === "reasonCatalog" && ( +
+
+
{t("settings.reasonCatalog.title")}
+

{t("settings.reasonCatalog.subtitle")}

+
+ +
+
+
+ )} + {activeTab === "team" && (
diff --git a/app/api/ingest/event/route.ts b/app/api/ingest/event/route.ts index 8899a62..a7e2481 100644 --- a/app/api/ingest/event/route.ts +++ b/app/api/ingest/event/route.ts @@ -5,13 +5,14 @@ import { z } from "zod"; import { evaluateAlertsForEvent } from "@/lib/alerts/engine"; import { toJsonValue } from "@/lib/prismaJson"; import { + detailEffectiveReasonCode, findCatalogReason, - loadFallbackReasonCatalog, - normalizeReasonCatalog, + findCatalogReasonByReasonCode, toReasonCode, type ReasonCatalog, type ReasonCatalogKind, } from "@/lib/reasonCatalog"; +import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb"; const normalizeType = (t: unknown) => String(t ?? "") @@ -169,7 +170,7 @@ function findCatalogReasonFlexible( categoryLabel: category.label, detailId: detail.id, detailLabel: detail.label, - reasonCode: toReasonCode(category.id, detail.id), + reasonCode: detailEffectiveReasonCode(category, detail), reasonLabel: `${category.label} > ${detail.label}`, }; } @@ -177,12 +178,6 @@ function findCatalogReasonFlexible( return null; } -function getCatalogFromDefaults(defaultsJson: unknown) { - const defaults = asRecord(defaultsJson); - if (!defaults) return null; - return normalizeReasonCatalog(defaults.reasonCatalog ?? defaults.reasonCatalogData); -} - function resolveReason( raw: Record, kind: ReasonCatalogKind, @@ -193,7 +188,13 @@ function resolveReason( const reasonTextPath = parseReasonTextPath(raw.reasonText); const categoryIdRaw = clampText(raw.categoryId ?? reasonPath.category ?? reasonTextPath.category, 64); const detailIdRaw = clampText(raw.detailId ?? reasonPath.detail ?? reasonTextPath.detail, 64); - const fromCatalog = findCatalogReasonFlexible(catalog, kind, categoryIdRaw, detailIdRaw); + const fromCatalogFlexible = findCatalogReasonFlexible(catalog, kind, categoryIdRaw, detailIdRaw); + const rawReasonCodeEarly = clampText(raw.reasonCode, 64); + const fromCatalogByCode = + !fromCatalogFlexible && rawReasonCodeEarly + ? findCatalogReasonByReasonCode(catalog, kind, rawReasonCodeEarly) + : null; + const fromCatalog = fromCatalogFlexible ?? fromCatalogByCode; const categoryLabelRaw = clampText(raw.categoryLabel ?? reasonPath.category ?? reasonTextPath.category, 120); const detailLabelRaw = clampText(raw.detailLabel ?? reasonPath.detail ?? reasonTextPath.detail, 120); @@ -282,11 +283,13 @@ export async function POST(req: Request) { const orgSettings = await prisma.orgSettings.findUnique({ where: { orgId: machine.orgId }, - select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true }, + select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true, version: true }, }); - const fallbackCatalog = await loadFallbackReasonCatalog(); - const settingsCatalog = getCatalogFromDefaults(orgSettings?.defaultsJson); - const reasonCatalog = settingsCatalog ?? fallbackCatalog; + const reasonCatalog = await effectiveReasonCatalogForOrg( + machine.orgId, + orgSettings?.defaultsJson ?? null, + orgSettings?.version ?? 1 + ); const defaultMicroMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5); const defaultMacroMultiplier = Math.max( diff --git a/app/api/machines/[machineId]/route.ts b/app/api/machines/[machineId]/route.ts index 9ae57fe..5cbe9f6 100644 --- a/app/api/machines/[machineId]/route.ts +++ b/app/api/machines/[machineId]/route.ts @@ -258,14 +258,65 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach }) : allowed; - const seen = new Set(); - const deduped = filtered.filter((event) => { - const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`; - if (seen.has(key)) return false; - seen.add(key); - return true; + // Build a lookup of raw event metadata (incidentKey, status, is_auto_ack) + // by event id, so we can collapse the normalized events down to one + // "active" + one "resolved" per incident. + const rawMetaById = new Map(); + for (const row of rawEvents) { + let parsed: unknown = row.data; + if (typeof parsed === "string") { + try { parsed = JSON.parse(parsed); } catch { parsed = null; } + } + const data: Record = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + const isAutoAck = + data.is_auto_ack === true || + data.isAutoAck === true || + data.is_auto_ack === "true" || + data.isAutoAck === "true"; + const incidentKey = + typeof data.incidentKey === "string" ? data.incidentKey : + typeof data.incident_key === "string" ? data.incident_key : null; + const status = typeof data.status === "string" ? data.status.toLowerCase() : null; + rawMetaById.set(row.id, { incidentKey, status, isAutoAck }); + } + + // Drop pure auto-ack refresh pings. + const filteredNoAutoAck = filtered.filter((event) => { + const meta = rawMetaById.get(event.id); + return !meta?.isAutoAck; }); + // Group by incidentKey: keep at most one "active" (oldest = original happen) + // and one "resolved" (newest = actual end) per incident. Events without + // incidentKey pass through unchanged (mold-change, edge-case events). + const byGroup = new Map(); + const passthrough: typeof filteredNoAutoAck = []; + + for (const event of filteredNoAutoAck) { + const meta = rawMetaById.get(event.id); + const groupId = meta?.incidentKey; + if (!groupId) { + passthrough.push(event); + continue; + } + const statusKey = meta.status === "resolved" ? "resolved" : "active"; + const key = `${groupId}:${statusKey}`; + const existing = byGroup.get(key); + if (!existing) { + byGroup.set(key, event); + continue; + } + const existingTs = existing.ts ? existing.ts.getTime() : 0; + const eventTs = event.ts ? event.ts.getTime() : 0; + const pickNewest = statusKey === "resolved"; + const shouldReplace = pickNewest ? eventTs > existingTs : eventTs < existingTs; + if (shouldReplace) byGroup.set(key, event); + } + + const deduped = [...passthrough, ...byGroup.values()]; deduped.sort((a, b) => { const at = a.ts ? a.ts.getTime() : 0; const bt = b.ts ? b.ts.getTime() : 0; diff --git a/app/api/machines/[machineId]/route.ts.bak b/app/api/machines/[machineId]/route.ts.bak new file mode 100644 index 0000000..9ae57fe --- /dev/null +++ b/app/api/machines/[machineId]/route.ts.bak @@ -0,0 +1,492 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { Prisma } from "@prisma/client"; +import { z } from "zod"; +import { prisma } from "@/lib/prisma"; +import { requireSession } from "@/lib/auth/requireSession"; +import { normalizeEvent } from "@/lib/events/normalizeEvent"; +import { invalidateMachineAuth } from "@/lib/machineAuthCache"; + +const machineIdSchema = z.string().uuid(); + +const ALLOWED_EVENT_TYPES = new Set([ + "slow-cycle", + "microstop", + "macrostop", + "offline", + "error", + "oee-drop", + "quality-spike", + "performance-degradation", + "predictive-oee-decline", + "alert-delivery-failed", +]); + +function canManageMachines(role?: string | null) { + return role === "OWNER" || role === "ADMIN"; +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function parseNumber(value: string | null, fallback: number) { + if (value == null || value === "") return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +type MachineFkReference = { + tableName: string; + columnName: string; + deleteRule: string; +}; + +function quoteIdent(identifier: string) { + return `"${identifier.replace(/"/g, "\"\"")}"`; +} + +async function cleanupMachineReferences(machineId: string) { + const refs = await prisma.$queryRaw` + SELECT DISTINCT + tc.table_name AS "tableName", + kcu.column_name AS "columnName", + rc.delete_rule AS "deleteRule" + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.referential_constraints rc + ON tc.constraint_name = rc.constraint_name + AND tc.table_schema = rc.constraint_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = 'public' + AND rc.unique_constraint_schema = 'public' + AND rc.unique_constraint_name IN ( + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_schema = 'public' + AND table_name = 'Machine' + AND constraint_type IN ('PRIMARY KEY', 'UNIQUE') + ) + `; + + for (const ref of refs) { + if (ref.tableName === "Machine") continue; + const table = quoteIdent(ref.tableName); + const column = quoteIdent(ref.columnName); + const rule = String(ref.deleteRule ?? "").toUpperCase(); + + if (rule === "CASCADE") continue; + + if (rule === "SET NULL") { + await prisma.$executeRawUnsafe(`UPDATE ${table} SET ${column} = NULL WHERE ${column} = $1`, machineId); + continue; + } + + await prisma.$executeRawUnsafe(`DELETE FROM ${table} WHERE ${column} = $1`, machineId); + } +} + +export async function GET(req: NextRequest, { params }: { params: Promise<{ machineId: string }> }) { + const session = await requireSession(); + if (!session) { + return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + } + + const { machineId } = await params; + if (!machineIdSchema.safeParse(machineId).success) { + return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 }); + } + + const url = new URL(req.url); + const windowSec = Math.max(0, parseNumber(url.searchParams.get("windowSec"), 3600)); + const eventsWindowSec = Math.max(0, parseNumber(url.searchParams.get("eventsWindowSec"), 21600)); + const eventsMode = url.searchParams.get("events") ?? "critical"; + const eventsOnly = url.searchParams.get("eventsOnly") === "1"; + + const [machineRow, orgSettings, machineSettings] = await Promise.all([ + prisma.machine.findFirst({ + where: { id: machineId, orgId: session.orgId }, + 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, + }, + }, + }, + }), + prisma.orgSettings.findUnique({ + where: { orgId: session.orgId }, + select: { stoppageMultiplier: true, macroStoppageMultiplier: true }, + }), + prisma.machineSettings.findUnique({ + where: { machineId }, + select: { overridesJson: true }, + }), + ]); + + if (!machineRow) { + return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 }); + } + + const overrides = isPlainObject(machineSettings?.overridesJson) ? machineSettings?.overridesJson : {}; + const thresholdsOverride = isPlainObject(overrides.thresholds) ? overrides.thresholds : {}; + const stoppageMultiplier = + typeof thresholdsOverride.stoppageMultiplier === "number" + ? thresholdsOverride.stoppageMultiplier + : Number(orgSettings?.stoppageMultiplier ?? 1.5); + const macroStoppageMultiplier = + typeof thresholdsOverride.macroStoppageMultiplier === "number" + ? thresholdsOverride.macroStoppageMultiplier + : Number(orgSettings?.macroStoppageMultiplier ?? 5); + + const thresholds = { + stoppageMultiplier, + macroStoppageMultiplier, + }; + + const machine = { + ...machineRow, + effectiveCycleTime: null, + latestHeartbeat: machineRow.heartbeats[0] ?? null, + latestKpi: machineRow.kpiSnapshots[0] ?? null, + heartbeats: undefined, + kpiSnapshots: undefined, + }; + + const cycles = eventsOnly + ? [] + : await prisma.machineCycle.findMany({ + where: { + orgId: session.orgId, + machineId, + ts: { gte: new Date(Date.now() - windowSec * 1000) }, + }, + orderBy: { ts: "asc" }, + select: { + ts: true, + tsServer: true, + cycleCount: true, + actualCycleTime: true, + theoreticalCycleTime: true, + workOrderId: true, + sku: true, + }, + }); + + const cyclesOut = cycles.map((row) => { + const ts = row.tsServer ?? row.ts; + return { + ts, + t: ts.getTime(), + cycleCount: row.cycleCount ?? null, + actual: row.actualCycleTime, + ideal: row.theoreticalCycleTime ?? null, + workOrderId: row.workOrderId ?? null, + sku: row.sku ?? null, + }; + }); + + const eventWindowStart = new Date(Date.now() - eventsWindowSec * 1000); + const criticalSeverities = ["critical", "error", "high"]; + const eventWhereBase = { + orgId: session.orgId, + machineId, + ts: { gte: eventWindowStart }, + }; + + const [rawEvents, eventsCountAll] = await Promise.all([ + prisma.machineEvent.findMany({ + where: eventWhereBase, + orderBy: { ts: "desc" }, + take: eventsOnly ? 300 : 120, + select: { + id: true, + ts: true, + topic: true, + eventType: true, + severity: true, + title: true, + description: true, + requiresAck: true, + data: true, + workOrderId: true, + }, + }), + prisma.machineEvent.count({ where: eventWhereBase }), + ]); + + const normalized = rawEvents.map((row) => + normalizeEvent(row, { microMultiplier: stoppageMultiplier, macroMultiplier: macroStoppageMultiplier }) + ); + + const allowed = normalized.filter((event) => ALLOWED_EVENT_TYPES.has(event.eventType)); + const criticalEventTypes = new Set(["macrostop", "microstop", "slow-cycle", "offline", "error"]); + const filtered = + eventsMode === "critical" + ? allowed.filter((event) => { + const severity = String(event.severity ?? "").toLowerCase(); + return ( + criticalEventTypes.has(event.eventType) || + event.requiresAck === true || + criticalSeverities.includes(severity) + ); + }) + : allowed; + + const seen = new Set(); + const deduped = filtered.filter((event) => { + const key = `${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; + }); + + return NextResponse.json({ + ok: true, + machine, + events: deduped, + eventsCountAll, + cycles: cyclesOut, + thresholds, + activeStoppage: null, + }); +} + +export async function DELETE(_req: Request, { params }: { params: Promise<{ machineId: string }> }) { + const session = await requireSession(); + if (!session) { + return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + } + + const { machineId } = await params; + if (!machineIdSchema.safeParse(machineId).success) { + return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 }); + } + + const membership = await prisma.orgUser.findUnique({ + where: { + orgId_userId: { + orgId: session.orgId, + userId: session.userId, + }, + }, + select: { role: true }, + }); + + if (!canManageMachines(membership?.role)) { + return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }); + } + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + if (attempt === 0) { + // Revoke credentials first in a committed write so ingest auth fails immediately. + const revoked = await prisma.machine.updateMany({ + where: { + id: machineId, + orgId: session.orgId, + }, + data: { + apiKey: null, + }, + }); + + if (revoked.count === 0) { + return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 }); + } + + invalidateMachineAuth(machineId); + } + + // Avoid long interactive transactions on very large history tables (P2028 timeout). + // This sequence is idempotent and safe to retry because apiKey is revoked first. + await prisma.machineCycle.deleteMany({ + where: { + machineId, + }, + }); + + await prisma.machineHeartbeat.deleteMany({ + where: { + machineId, + }, + }); + + await prisma.machineKpiSnapshot.deleteMany({ + where: { + machineId, + }, + }); + + await prisma.machineEvent.deleteMany({ + where: { + machineId, + }, + }); + + await prisma.machineWorkOrder.deleteMany({ + where: { + machineId, + }, + }); + + await prisma.machineSettings.deleteMany({ + where: { + machineId, + }, + }); + + await prisma.settingsAudit.deleteMany({ + where: { + machineId, + }, + }); + + await prisma.alertNotification.deleteMany({ + where: { + machineId, + }, + }); + + await prisma.machineFinancialOverride.deleteMany({ + where: { + machineId, + }, + }); + + await prisma.reasonEntry.deleteMany({ + where: { + machineId, + }, + }); + + await prisma.downtimeAction.updateMany({ + where: { + machineId, + }, + data: { + machineId: null, + }, + }); + + const result = await prisma.machine.deleteMany({ + where: { + id: machineId, + orgId: session.orgId, + }, + }); + + if (result.count === 0) { + return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 }); + } + + invalidateMachineAuth(machineId); + return NextResponse.json({ ok: true }); + } catch (err: unknown) { + const code = err instanceof Prisma.PrismaClientKnownRequestError ? err.code : undefined; + const message = err instanceof Error ? err.message : String(err); + console.error("DELETE /api/machines/[machineId] failed", { + machineId, + orgId: session.orgId, + attempt, + code, + message, + }); + + if (code === "P2003") { + if (attempt < 2) { + try { + await cleanupMachineReferences(machineId); + } catch (cleanupErr: unknown) { + const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr); + console.error("DELETE /api/machines/[machineId] cleanup failed", { + machineId, + orgId: session.orgId, + attempt, + cleanupMessage, + }); + } + await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 150)); + continue; + } + + return NextResponse.json( + { + ok: false, + error: "Machine has dependent records and could not be removed", + code, + }, + { status: 409 } + ); + } + + if (code === "P2022") { + return NextResponse.json( + { + ok: false, + error: "Server schema is out of date for machine delete", + code, + }, + { status: 500 } + ); + } + + if (code === "P2028") { + return NextResponse.json( + { + ok: false, + error: "Delete timed out while removing machine history", + code, + }, + { status: 503 } + ); + } + + if (code) { + return NextResponse.json( + { + ok: false, + error: "Delete failed due to database error", + code, + }, + { status: 500 } + ); + } + + return NextResponse.json({ ok: false, error: "Delete failed" }, { status: 500 }); + } + } + + return NextResponse.json({ ok: false, error: "Delete failed", code: "DELETE_RETRY_EXHAUSTED" }, { status: 500 }); +} diff --git a/app/api/reasons/catalog/route.ts b/app/api/reasons/catalog/route.ts index 8b8da07..4502ef1 100644 --- a/app/api/reasons/catalog/route.ts +++ b/app/api/reasons/catalog/route.ts @@ -1,12 +1,8 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; -import { - flattenReasonCatalog, - loadFallbackReasonCatalog, - normalizeReasonCatalog, - type ReasonCatalogKind, -} from "@/lib/reasonCatalog"; +import { flattenReasonCatalog, normalizeReasonCatalog, type ReasonCatalogKind } from "@/lib/reasonCatalog"; +import { effectiveReasonCatalogForOrg, loadReasonCatalogFromDb } from "@/lib/reasonCatalogDb"; function asKind(value: string | null): ReasonCatalogKind | null { const kind = String(value ?? "").toLowerCase(); @@ -26,20 +22,30 @@ export async function GET(req: Request) { const orgSettings = await prisma.orgSettings.findUnique({ where: { orgId: session.orgId }, - select: { defaultsJson: true }, + select: { defaultsJson: true, version: true }, }); - const defaultsJson = - orgSettings?.defaultsJson && typeof orgSettings.defaultsJson === "object" && !Array.isArray(orgSettings.defaultsJson) - ? (orgSettings.defaultsJson as Record) + const version = orgSettings?.version ?? 1; + const defaultsJson = orgSettings?.defaultsJson ?? null; + + const fromDb = await loadReasonCatalogFromDb(session.orgId, version); + const catalog = await effectiveReasonCatalogForOrg(session.orgId, defaultsJson, version); + + const defs = + defaultsJson && typeof defaultsJson === "object" && !Array.isArray(defaultsJson) + ? (defaultsJson as Record) : {}; - const settingsCatalog = normalizeReasonCatalog(defaultsJson.reasonCatalog ?? defaultsJson.reasonCatalogData); - const fallbackCatalog = await loadFallbackReasonCatalog(); - const catalog = settingsCatalog ?? fallbackCatalog; - const rows = flattenReasonCatalog(catalog, kind); + const legacyJson = normalizeReasonCatalog(defs.reasonCatalog ?? defs.reasonCatalogData); + + let source: "db" | "legacy" | "fallback"; + if (fromDb) source = "db"; + else if (legacyJson) source = "legacy"; + else source = "fallback"; + + const rows = flattenReasonCatalog(catalog, kind, { activeOnly: true }); return NextResponse.json({ ok: true, - source: settingsCatalog ? "settings" : "fallback", + source, kind, catalogVersion: catalog.version, categories: catalog[kind], diff --git a/app/api/settings/machines/[machineId]/route.ts b/app/api/settings/machines/[machineId]/route.ts index 4dc139f..6486e6c 100644 --- a/app/api/settings/machines/[machineId]/route.ts +++ b/app/api/settings/machines/[machineId]/route.ts @@ -17,7 +17,7 @@ import { validateShiftOverrides, validateThresholds, } from "@/lib/settings"; -import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog"; +import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb"; import { publishSettingsUpdate } from "@/lib/mqtt"; import { z } from "zod"; @@ -46,21 +46,18 @@ function pickAllowedOverrides(raw: unknown) { return out; } -function withReasonCatalog>(payload: T, fallbackCatalog: ReasonCatalog) { - const base = (isPlainObject(payload) ? { ...payload } : {}) as T; - const defaults = isPlainObject(base.defaults) ? base.defaults : {}; - const parsed = - normalizeReasonCatalog(base.reasonCatalog) ?? - normalizeReasonCatalog(base.reasonCatalogData) ?? - normalizeReasonCatalog(defaults.reasonCatalog) ?? - normalizeReasonCatalog(defaults.reasonCatalogData) ?? - fallbackCatalog; - +async function attachReasonCatalog( + orgId: string, + defaultsJson: unknown, + settingsVersion: number, + base: Record +): Promise> { + const catalog = await effectiveReasonCatalogForOrg(orgId, defaultsJson, settingsVersion); return { ...base, - reasonCatalog: parsed, - reasonCatalogData: parsed, - reasonCatalogVersion: Number(parsed.version || 1), + reasonCatalog: catalog, + reasonCatalogData: catalog, + reasonCatalogVersion: Number(catalog.version || 1), }; } @@ -164,9 +161,7 @@ export async function GET( if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); orgId = machine.orgId; } - const fallbackCatalog = await loadFallbackReasonCatalog(); - - const { settings, overrides } = await prisma.$transaction(async (tx) => { + const { orgRow, shifts, rawOverrides } = await prisma.$transaction(async (tx) => { const orgSettings = await ensureOrgSettings(tx, orgId as string, userId); if (!orgSettings?.settings) throw new Error("SETTINGS_NOT_FOUND"); @@ -175,25 +170,24 @@ export async function GET( select: { overridesJson: true }, }); - const orgPayload = withReasonCatalog( - buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []), - fallbackCatalog - ); const rawOverrides = pickAllowedOverrides(machineSettings?.overridesJson ?? {}); - const effective = withReasonCatalog( - deepMerge(orgPayload, rawOverrides) as Record, - fallbackCatalog - ); - - return { settings: { org: orgPayload, effective }, overrides: rawOverrides }; + return { + orgRow: orgSettings.settings, + shifts: orgSettings.shifts ?? [], + rawOverrides, + }; }); + const baseOrg = buildSettingsPayload(orgRow, shifts) as Record; + const orgPayload = await attachReasonCatalog(orgId as string, orgRow.defaultsJson, orgRow.version, baseOrg); + const effective = deepMerge(orgPayload, rawOverrides) as Record; + return NextResponse.json({ ok: true, machineId, - orgSettings: settings.org, - effectiveSettings: settings.effective, - overrides, + orgSettings: orgPayload, + effectiveSettings: effective, + overrides: rawOverrides, }); } @@ -413,25 +407,23 @@ export async function PUT( }, }); - const fallbackCatalog = await loadFallbackReasonCatalog(); - const orgPayload = withReasonCatalog( - buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []), - fallbackCatalog - ); - const overrides = pickAllowedOverrides(saved.overridesJson ?? {}); - const effective = withReasonCatalog( - deepMerge(orgPayload, overrides) as Record, - fallbackCatalog - ); - return { - orgPayload, - overrides, - effective, + orgSettingsRow: orgSettings.settings, + shifts: orgSettings.shifts ?? [], + overrides: pickAllowedOverrides(saved.overridesJson ?? {}), overridesUpdatedAt: saved.updatedAt, }; }); + const baseOrg = buildSettingsPayload(result.orgSettingsRow, result.shifts) as Record; + const orgPayload = await attachReasonCatalog( + session.orgId, + result.orgSettingsRow.defaultsJson, + result.orgSettingsRow.version, + baseOrg + ); + const effective = deepMerge(orgPayload, result.overrides) as Record; + const overridesUpdatedAt = result.overridesUpdatedAt && result.overridesUpdatedAt instanceof Date ? result.overridesUpdatedAt.toISOString() @@ -440,7 +432,7 @@ export async function PUT( await publishSettingsUpdate({ orgId: session.orgId, machineId, - version: Number(result.orgPayload.version ?? 0), + version: Number(result.orgSettingsRow.version ?? 0), source, overridesUpdatedAt, }); @@ -451,8 +443,8 @@ export async function PUT( return NextResponse.json({ ok: true, machineId, - orgSettings: result.orgPayload, - effectiveSettings: result.effective, + orgSettings: orgPayload, + effectiveSettings: effective, overrides: result.overrides, }); } diff --git a/app/api/settings/reason-catalog/categories/[categoryId]/route.ts b/app/api/settings/reason-catalog/categories/[categoryId]/route.ts new file mode 100644 index 0000000..5265a87 --- /dev/null +++ b/app/api/settings/reason-catalog/categories/[categoryId]/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession"; +import { bumpOrgSettingsVersion, composeReasonCode } from "@/lib/reasonCatalogDb"; +import { z } from "zod"; + +const PREFIX_RE = /^[A-Za-z][A-Za-z0-9-]*$/; + +const patchSchema = z.object({ + name: z.string().trim().min(1).max(200).optional(), + codePrefix: z + .string() + .trim() + .min(1) + .max(32) + .transform((s) => s.toUpperCase()) + .optional(), + sortOrder: z.number().int().optional(), + active: z.boolean().optional(), +}); + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ categoryId: string }> } +) { + const auth = await requireOrgAdminSession(); + if (!auth.ok) return auth.response; + + const { categoryId } = await params; + const parsed = patchSchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 }); + } + + const existing = await prisma.reasonCatalogCategory.findFirst({ + where: { id: categoryId, orgId: auth.session.orgId }, + include: { items: true }, + }); + if (!existing) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 }); + + const nextPrefix = parsed.data.codePrefix ?? existing.codePrefix; + if (parsed.data.codePrefix !== undefined && !PREFIX_RE.test(nextPrefix)) { + return NextResponse.json( + { ok: false, error: "codePrefix must start with a letter; letters, digits, hyphen allowed." }, + { status: 400 } + ); + } + + if (parsed.data.codePrefix !== undefined && parsed.data.codePrefix !== existing.codePrefix) { + const proposed = new Set(); + for (const it of existing.items) { + proposed.add(composeReasonCode(nextPrefix, it.codeSuffix)); + } + const codes = [...proposed]; + const conflicts = await prisma.reasonCatalogItem.findMany({ + where: { + orgId: auth.session.orgId, + reasonCode: { in: codes }, + NOT: { categoryId: existing.id }, + }, + select: { reasonCode: true }, + }); + if (conflicts.length) { + return NextResponse.json( + { ok: false, error: "Prefix change would duplicate codes", conflicts: conflicts.map((c) => c.reasonCode) }, + { status: 409 } + ); + } + } + + try { + await prisma.$transaction(async (tx) => { + await tx.reasonCatalogCategory.update({ + where: { id: categoryId }, + data: { + ...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}), + ...(parsed.data.codePrefix !== undefined ? { codePrefix: parsed.data.codePrefix } : {}), + ...(parsed.data.sortOrder !== undefined ? { sortOrder: parsed.data.sortOrder } : {}), + ...(parsed.data.active !== undefined ? { active: parsed.data.active } : {}), + }, + }); + + if (parsed.data.codePrefix !== undefined && parsed.data.codePrefix !== existing.codePrefix) { + for (const it of existing.items) { + const reasonCode = composeReasonCode(nextPrefix, it.codeSuffix); + await tx.reasonCatalogItem.update({ + where: { id: it.id }, + data: { reasonCode }, + }); + } + } + + await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId); + }); + + const updated = await prisma.reasonCatalogCategory.findUnique({ + where: { id: categoryId }, + include: { items: { orderBy: [{ sortOrder: "asc" }, { reasonCode: "asc" }] } }, + }); + + return NextResponse.json({ ok: true, category: updated }); + } catch (e) { + console.error("[reason-catalog category PATCH]", e); + return NextResponse.json({ ok: false, error: "Update failed" }, { status: 500 }); + } +} diff --git a/app/api/settings/reason-catalog/categories/route.ts b/app/api/settings/reason-catalog/categories/route.ts new file mode 100644 index 0000000..1b6ec83 --- /dev/null +++ b/app/api/settings/reason-catalog/categories/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession"; +import { bumpOrgSettingsVersion } from "@/lib/reasonCatalogDb"; +import { z } from "zod"; + +const PREFIX_RE = /^[A-Za-z][A-Za-z0-9-]*$/; + +const bodySchema = z.object({ + kind: z.enum(["downtime", "scrap"]), + name: z.string().trim().min(1).max(200), + codePrefix: z + .string() + .trim() + .min(1) + .max(32) + .transform((s) => s.toUpperCase()), +}); + +export async function POST(req: Request) { + const auth = await requireOrgAdminSession(); + if (!auth.ok) return auth.response; + + const parsed = bodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 }); + } + const { kind, name, codePrefix } = parsed.data; + if (!PREFIX_RE.test(codePrefix)) { + return NextResponse.json( + { ok: false, error: "codePrefix must start with a letter; letters, digits, hyphen allowed." }, + { status: 400 } + ); + } + + try { + const row = await prisma.$transaction(async (tx) => { + const last = await tx.reasonCatalogCategory.findFirst({ + where: { orgId: auth.session.orgId, kind }, + orderBy: { sortOrder: "desc" }, + select: { sortOrder: true }, + }); + const sortOrder = (last?.sortOrder ?? -1) + 1; + + const created = await tx.reasonCatalogCategory.create({ + data: { + orgId: auth.session.orgId, + kind, + name, + codePrefix, + sortOrder, + active: true, + }, + }); + await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId); + return created; + }); + + return NextResponse.json({ ok: true, category: row }); + } catch (e) { + console.error("[reason-catalog categories POST]", e); + return NextResponse.json({ ok: false, error: "Create failed" }, { status: 500 }); + } +} diff --git a/app/api/settings/reason-catalog/items/[itemId]/route.ts b/app/api/settings/reason-catalog/items/[itemId]/route.ts new file mode 100644 index 0000000..06c9088 --- /dev/null +++ b/app/api/settings/reason-catalog/items/[itemId]/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession"; +import { bumpOrgSettingsVersion, composeReasonCode, isNumericSuffix } from "@/lib/reasonCatalogDb"; +import { z } from "zod"; + +const patchSchema = z.object({ + name: z.string().trim().min(1).max(500).optional(), + codeSuffix: z.string().trim().min(1).max(32).optional(), + sortOrder: z.number().int().optional(), + active: z.boolean().optional(), +}); + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ itemId: string }> } +) { + const auth = await requireOrgAdminSession(); + if (!auth.ok) return auth.response; + + const { itemId } = await params; + const parsed = patchSchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 }); + } + + const existing = await prisma.reasonCatalogItem.findFirst({ + where: { id: itemId, orgId: auth.session.orgId }, + include: { category: true }, + }); + if (!existing) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 }); + + const nextSuffix = parsed.data.codeSuffix ?? existing.codeSuffix; + if (parsed.data.codeSuffix !== undefined && !isNumericSuffix(nextSuffix)) { + return NextResponse.json({ ok: false, error: "codeSuffix must be digits only" }, { status: 400 }); + } + + const reasonCode = composeReasonCode(existing.category.codePrefix, nextSuffix); + if (reasonCode !== existing.reasonCode) { + const conflict = await prisma.reasonCatalogItem.findFirst({ + where: { orgId: auth.session.orgId, reasonCode, NOT: { id: itemId } }, + select: { id: true }, + }); + if (conflict) { + return NextResponse.json({ ok: false, error: "Duplicate reasonCode for this organization" }, { status: 409 }); + } + } + + try { + await prisma.$transaction(async (tx) => { + await tx.reasonCatalogItem.update({ + where: { id: itemId }, + data: { + ...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}), + ...(parsed.data.codeSuffix !== undefined ? { codeSuffix: nextSuffix, reasonCode } : {}), + ...(parsed.data.sortOrder !== undefined ? { sortOrder: parsed.data.sortOrder } : {}), + ...(parsed.data.active !== undefined ? { active: parsed.data.active } : {}), + }, + }); + await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId); + }); + + const updated = await prisma.reasonCatalogItem.findUnique({ where: { id: itemId } }); + return NextResponse.json({ ok: true, item: updated }); + } catch (e) { + console.error("[reason-catalog item PATCH]", e); + return NextResponse.json({ ok: false, error: "Update failed" }, { status: 500 }); + } +} diff --git a/app/api/settings/reason-catalog/items/route.ts b/app/api/settings/reason-catalog/items/route.ts new file mode 100644 index 0000000..9d64951 --- /dev/null +++ b/app/api/settings/reason-catalog/items/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession"; +import { bumpOrgSettingsVersion, composeReasonCode, isNumericSuffix } from "@/lib/reasonCatalogDb"; +import { z } from "zod"; + +const bodySchema = z.object({ + categoryId: z.string().uuid(), + codeSuffix: z.string().trim().min(1).max(32), + name: z.string().trim().min(1).max(500), + sortOrder: z.number().int().optional(), +}); + +export async function POST(req: Request) { + const auth = await requireOrgAdminSession(); + if (!auth.ok) return auth.response; + + const parsed = bodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 }); + } + + const { categoryId, codeSuffix, name, sortOrder } = parsed.data; + if (!isNumericSuffix(codeSuffix)) { + return NextResponse.json({ ok: false, error: "codeSuffix must be digits only" }, { status: 400 }); + } + + const category = await prisma.reasonCatalogCategory.findFirst({ + where: { id: categoryId, orgId: auth.session.orgId }, + }); + if (!category) return NextResponse.json({ ok: false, error: "Category not found" }, { status: 404 }); + + const reasonCode = composeReasonCode(category.codePrefix, codeSuffix); + + try { + const row = await prisma.$transaction(async (tx) => { + let nextOrder = sortOrder; + if (nextOrder === undefined) { + const last = await tx.reasonCatalogItem.findFirst({ + where: { categoryId }, + orderBy: { sortOrder: "desc" }, + select: { sortOrder: true }, + }); + nextOrder = (last?.sortOrder ?? -1) + 1; + } + + const created = await tx.reasonCatalogItem.create({ + data: { + orgId: auth.session.orgId, + categoryId, + name, + codeSuffix, + reasonCode, + sortOrder: nextOrder, + active: true, + }, + }); + await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId); + return created; + }); + + return NextResponse.json({ ok: true, item: row }); + } catch (e: unknown) { + const code = typeof e === "object" && e && "code" in e ? (e as { code: string }).code : ""; + if (code === "P2002") { + return NextResponse.json({ ok: false, error: "Duplicate reasonCode for this organization" }, { status: 409 }); + } + console.error("[reason-catalog items POST]", e); + return NextResponse.json({ ok: false, error: "Create failed" }, { status: 500 }); + } +} diff --git a/app/api/settings/reason-catalog/route.ts b/app/api/settings/reason-catalog/route.ts new file mode 100644 index 0000000..b437813 --- /dev/null +++ b/app/api/settings/reason-catalog/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession"; + +/** Full tree for Control Tower (includes inactive rows). */ +export async function GET() { + const auth = await requireOrgAdminSession(); + if (!auth.ok) return auth.response; + + const orgSettings = await prisma.orgSettings.findUnique({ + where: { orgId: auth.session.orgId }, + select: { version: true }, + }); + + const categories = await prisma.reasonCatalogCategory.findMany({ + where: { orgId: auth.session.orgId }, + include: { + items: { orderBy: [{ sortOrder: "asc" }, { reasonCode: "asc" }] }, + }, + orderBy: [{ kind: "asc" }, { sortOrder: "asc" }, { name: "asc" }], + }); + + return NextResponse.json({ + ok: true, + catalogVersion: orgSettings?.version ?? 1, + categories: categories.map((c) => ({ + id: c.id, + kind: c.kind, + name: c.name, + codePrefix: c.codePrefix, + sortOrder: c.sortOrder, + active: c.active, + items: c.items.map((it) => ({ + id: it.id, + name: it.name, + codeSuffix: it.codeSuffix, + reasonCode: it.reasonCode, + sortOrder: it.sortOrder, + active: it.active, + })), + })), + }); +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 4ee54c8..298b913 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -19,7 +19,7 @@ import { validateShiftOverrides, validateThresholds, } from "@/lib/settings"; -import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog"; +import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb"; import { publishSettingsUpdate } from "@/lib/mqtt"; import { z } from "zod"; @@ -39,21 +39,18 @@ function canManageSettings(role?: string | null) { return role === "OWNER" || role === "ADMIN"; } -function withReasonCatalog>(payload: T, fallbackCatalog: ReasonCatalog) { - const base = (isPlainObject(payload) ? { ...payload } : {}) as T; - const defaults = isPlainObject(base.defaults) ? base.defaults : {}; - const parsed = - normalizeReasonCatalog(base.reasonCatalog) ?? - normalizeReasonCatalog(base.reasonCatalogData) ?? - normalizeReasonCatalog(defaults.reasonCatalog) ?? - normalizeReasonCatalog(defaults.reasonCatalogData) ?? - fallbackCatalog; - +async function attachReasonCatalog( + orgId: string, + defaultsJson: unknown, + settingsVersion: number, + base: Record +): Promise> { + const catalog = await effectiveReasonCatalogForOrg(orgId, defaultsJson, settingsVersion); return { ...base, - reasonCatalog: parsed, - reasonCatalogData: parsed, - reasonCatalogVersion: Number(parsed.version || 1), + reasonCatalog: catalog, + reasonCatalogData: catalog, + reasonCatalogVersion: Number(catalog.version || 1), }; } @@ -66,7 +63,6 @@ const settingsPayloadSchema = z thresholds: z.any().optional(), alerts: z.any().optional(), defaults: z.any().optional(), - reasonCatalog: z.any().optional(), version: z.union([z.number(), z.string()]).optional(), }) .passthrough(); @@ -145,8 +141,13 @@ async function loadSettingsPayload(orgId: string, userId: string) { return found; }); - const fallbackCatalog = await loadFallbackReasonCatalog(); - const payload = withReasonCatalog(buildSettingsPayload(loaded.settings, loaded.shifts ?? []), fallbackCatalog); + const base = buildSettingsPayload(loaded.settings, loaded.shifts ?? []) as Record; + const payload = await attachReasonCatalog( + orgId, + loaded.settings.defaultsJson, + loaded.settings.version, + base + ); const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {}; const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {}; const modules = { screenlessMode: modulesRaw.screenlessMode === true }; @@ -221,7 +222,6 @@ export async function PUT(req: Request) { const thresholds = parsed.data.thresholds; const alerts = parsed.data.alerts; const defaults = parsed.data.defaults; - const reasonCatalogRaw = parsed.data.reasonCatalog; const expectedVersion = parsed.data.version; const modules = parsed.data.modules; @@ -233,7 +233,6 @@ export async function PUT(req: Request) { thresholds === undefined && alerts === undefined && defaults === undefined && - reasonCatalogRaw === undefined && modules === undefined ) { @@ -252,13 +251,6 @@ export async function PUT(req: Request) { if (defaults !== undefined && !isPlainObject(defaults)) { return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 }); } - const nextReasonCatalog = - reasonCatalogRaw === undefined || reasonCatalogRaw === null - ? reasonCatalogRaw - : normalizeReasonCatalog(reasonCatalogRaw); - if (reasonCatalogRaw !== undefined && reasonCatalogRaw !== null && !nextReasonCatalog) { - return NextResponse.json({ ok: false, error: "reasonCatalog must be a valid catalog payload" }, { status: 400 }); - } if (modules !== undefined && !isPlainObject(modules)) { return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 }); } @@ -333,20 +325,16 @@ export async function PUT(req: Request) { : { ...currentModulesRaw, screenlessMode }; // Write defaultsJson if either defaults changed OR modules changed - const shouldWriteDefaultsJson = - !!nextDefaultsCore || screenlessMode !== undefined || reasonCatalogRaw !== undefined; + const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined; const nextDefaultsJson = shouldWriteDefaultsJson ? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules } : undefined; - if (nextDefaultsJson && reasonCatalogRaw !== undefined) { + if (nextDefaultsJson) { const defaultsTarget = nextDefaultsJson as Record; - if (nextReasonCatalog === null) { - delete defaultsTarget.reasonCatalog; - } else if (nextReasonCatalog) { - defaultsTarget.reasonCatalog = nextReasonCatalog; - } + delete defaultsTarget.reasonCatalog; + delete defaultsTarget.reasonCatalogData; } @@ -444,12 +432,18 @@ export async function PUT(req: Request) { return NextResponse.json({ ok: false, error: updated.error }, { status: 400 }); } - const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []); + const baseOut = buildSettingsPayload(updated.settings, updated.shifts ?? []) as Record; + const payload = await attachReasonCatalog( + session.orgId, + updated.settings.defaultsJson, + updated.settings.version, + baseOut + ); const updatedAt = typeof payload.updatedAt === "string" ? payload.updatedAt : payload.updatedAt - ? payload.updatedAt.toISOString() + ? (payload.updatedAt as Date).toISOString() : undefined; try { await publishSettingsUpdate({ diff --git a/components/recap/RecapMachineCard.tsx b/components/recap/RecapMachineCard.tsx index c3df147..83a62dc 100644 --- a/components/recap/RecapMachineCard.tsx +++ b/components/recap/RecapMachineCard.tsx @@ -16,7 +16,6 @@ const STATUS_DOT: Record = { running: "bg-emerald-400", "mold-change": "bg-amber-400", stopped: "bg-red-500", - "data-loss": "bg-red-500", offline: "bg-zinc-500", idle: "bg-zinc-400", }; @@ -25,7 +24,6 @@ function statusLabel(status: RecapSummaryMachine["status"], t: (key: string) => if (status === "running") return t("recap.status.running"); if (status === "mold-change") return t("recap.status.moldChange"); if (status === "stopped") return t("recap.status.stopped"); - if (status === "data-loss") return t("recap.status.dataLoss"); if (status === "idle") return t("recap.status.idle"); return t("recap.status.offline"); } @@ -42,7 +40,7 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop const zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0; const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`; const ongoingStopMin = machine.ongoingStopMin ?? 0; - const isUrgent = (machine.status === "stopped" && ongoingStopMin >= 5) || machine.status === "data-loss"; + const isUrgent = machine.status === "stopped" && ongoingStopMin >= 5; const isCalm = machine.status === "idle"; const timelineSegments = timeline?.segments ?? machine.miniTimeline; const timelineStart = timeline?.range.start ?? rangeStart; @@ -66,7 +64,7 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop async function loadTimeline() { try { const res = await fetch( - `/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=60`, + `/api/recap/${machine.machineId}/timeline?range=24h`, { cache: "no-store" } ); const json = await res.json().catch(() => null); @@ -150,13 +148,8 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop ) : null}
- {machine.status === "data-loss" - ? t("recap.card.dataLoss", { count: machine.stateContext.untrackedCycleCount ?? 0 }) - + (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "") - : machine.status === "stopped" && ongoingStopMin >= 5 - ? (machine.stateContext.stoppedReason === "not_started" - ? t("recap.card.notStarted") - : t("recap.card.stoppedFor", { min: ongoingStopMin })) + {isUrgent + ? t("recap.card.stoppedFor", { min: ongoingStopMin }) + (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "") : machine.status === "idle" ? t("recap.card.idle") diff --git a/components/settings/ReasonCatalogConfig.tsx b/components/settings/ReasonCatalogConfig.tsx new file mode 100644 index 0000000..783ae7e --- /dev/null +++ b/components/settings/ReasonCatalogConfig.tsx @@ -0,0 +1,445 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useI18n } from "@/lib/i18n/useI18n"; + +type CatalogKind = "downtime" | "scrap"; + +type ApiItem = { + id: string; + name: string; + codeSuffix: string; + reasonCode: string; + sortOrder: number; + active: boolean; +}; + +type ApiCategory = { + id: string; + kind: string; + name: string; + codePrefix: string; + sortOrder: number; + active: boolean; + items: ApiItem[]; +}; + +const PREFIX_RE = /^[A-Za-z][A-Za-z0-9-]*$/; + +/** Matches composeReasonCode in reasonCatalogDb (client-safe). */ +function formatPrintedPreview(prefix: string, digits: string): string { + const p = String(prefix).trim().toUpperCase(); + const d = String(digits).trim(); + if (!d) return p.length >= 3 ? `${p}-…` : `${p}…`; + if (/^\d+$/.test(d) && p.length >= 3) return `${p}-${d}`; + return `${p}${d}`; +} + +async function readJson(res: Response) { + const data = await res.json().catch(() => null); + return data as Record | null; +} + +export function ReasonCatalogConfig({ disabled }: { disabled?: boolean }) { + const { t } = useI18n(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [catalogVersion, setCatalogVersion] = useState(1); + const [categories, setCategories] = useState([]); + const [kind, setKind] = useState("downtime"); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [newCatName, setNewCatName] = useState(""); + const [newCatPrefix, setNewCatPrefix] = useState(""); + const [newDigits, setNewDigits] = useState(""); + const [newItemName, setNewItemName] = useState(""); + const [busy, setBusy] = useState(false); + const [editCatName, setEditCatName] = useState(""); + const [editCatPrefix, setEditCatPrefix] = useState(""); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/settings/reason-catalog"); + const data = await readJson(res); + if (!res.ok || !data || data.ok !== true) { + const msg = typeof data?.error === "string" ? data.error : "Load failed"; + throw new Error(msg); + } + setCatalogVersion(Number(data.catalogVersion ?? 1)); + setCategories(Array.isArray(data.categories) ? (data.categories as ApiCategory[]) : []); + } catch (e) { + setError(e instanceof Error ? e.message : "Load failed"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const forKind = useMemo( + () => categories.filter((c) => String(c.kind).toLowerCase() === kind), + [categories, kind] + ); + + const selected = useMemo( + () => forKind.find((c) => c.id === selectedCategoryId) ?? null, + [forKind, selectedCategoryId] + ); + + useEffect(() => { + if (!selected) { + setEditCatName(""); + setEditCatPrefix(""); + return; + } + setEditCatName(selected.name); + setEditCatPrefix(selected.codePrefix); + }, [selected?.id, selected?.name, selected?.codePrefix]); + + useEffect(() => { + if (!forKind.length) { + setSelectedCategoryId(null); + return; + } + if (!selectedCategoryId || !forKind.some((c) => c.id === selectedCategoryId)) { + setSelectedCategoryId(forKind[0]?.id ?? null); + } + }, [forKind, selectedCategoryId]); + + const onDigitsChange = (raw: string) => { + setNewDigits(raw.replace(/\D/g, "")); + }; + + const createCategory = async () => { + const name = newCatName.trim(); + const codePrefix = newCatPrefix.trim().toUpperCase(); + if (!name || !codePrefix) return; + if (!PREFIX_RE.test(codePrefix)) { + setError(t("settings.reasonCatalog.prefixInvalid")); + return; + } + setBusy(true); + setError(null); + try { + const res = await fetch("/api/settings/reason-catalog/categories", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind, name, codePrefix }), + }); + const data = await readJson(res); + if (!res.ok || !data || data.ok !== true) { + const msg = typeof data?.error === "string" ? data.error : "Create failed"; + throw new Error(msg); + } + setNewCatName(""); + setNewCatPrefix(""); + await load(); + const cat = data.category as { id?: string } | undefined; + if (cat?.id) setSelectedCategoryId(cat.id); + } catch (e) { + setError(e instanceof Error ? e.message : "Create failed"); + } finally { + setBusy(false); + } + }; + + const addItem = async () => { + if (!selected) return; + const digits = newDigits.trim(); + const name = newItemName.trim(); + if (!digits || !name) return; + setBusy(true); + setError(null); + try { + const res = await fetch("/api/settings/reason-catalog/items", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ categoryId: selected.id, codeSuffix: digits, name }), + }); + const data = await readJson(res); + if (!res.ok || !data || data.ok !== true) { + const msg = typeof data?.error === "string" ? data.error : "Create failed"; + throw new Error(msg); + } + setNewDigits(""); + setNewItemName(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Create failed"); + } finally { + setBusy(false); + } + }; + + const patchItem = async (itemId: string, patch: Record) => { + setBusy(true); + setError(null); + try { + const res = await fetch(`/api/settings/reason-catalog/items/${itemId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + const data = await readJson(res); + if (!res.ok || !data || data.ok !== true) { + const msg = typeof data?.error === "string" ? data.error : "Update failed"; + throw new Error(msg); + } + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Update failed"); + } finally { + setBusy(false); + } + }; + + const patchCategory = async (categoryId: string, patch: Record) => { + setBusy(true); + setError(null); + try { + const res = await fetch(`/api/settings/reason-catalog/categories/${categoryId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + const data = await readJson(res); + if (!res.ok || !data || data.ok !== true) { + const msg = typeof data?.error === "string" ? data.error : "Update failed"; + throw new Error(msg); + } + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Update failed"); + } finally { + setBusy(false); + } + }; + + const inputCls = + "mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-xs text-white placeholder:text-zinc-600"; + + const kindBtn = (k: CatalogKind, label: string) => ( + + ); + + return ( +
+
+
+
+ {t("settings.reasonCatalog.dbVersionHint", { version: catalogVersion })} +
+ +
+ {loading ?

{t("settings.loading")}

: null} + {error ? ( +

{error}

+ ) : null} +
+ +
+
{t("settings.reasonCatalog.stepKind")}
+
+ {kindBtn("downtime", t("settings.reasonCatalog.downtime"))} + {kindBtn("scrap", t("settings.reasonCatalog.scrap"))} +
+
+ +
+
{t("settings.reasonCatalog.stepCategory")}
+
+ +
+ + {selected ? ( +
+ + + +
+ ) : null} + +
+
{t("settings.reasonCatalog.newCategorySection")}
+
+ + +
+ +
+
+ + {selected ? ( +
+
{t("settings.reasonCatalog.stepReason")}
+

{t("settings.reasonCatalog.digitsOnlyHint")}

+
+
+ {t("settings.reasonCatalog.fullCodePreview")} + + {formatPrintedPreview(selected.codePrefix, newDigits)} + +
+ + + +
+ +
+
{t("settings.reasonCatalog.reasonsInCategory")}
+
+ {selected.items.length === 0 ? ( +
{t("settings.reasonCatalog.noItemsYet")}
+ ) : ( + selected.items.map((it) => ( +
+
{it.reasonCode}
+
{it.name}
+ +
+ )) + )} +
+
+
+ ) : null} + +

{t("settings.reasonCatalog.hint")}

+
+ ); +} diff --git a/flows_may_4_26.json b/flows_may_4_26.json new file mode 100644 index 0000000..58ace9f --- /dev/null +++ b/flows_may_4_26.json @@ -0,0 +1,4288 @@ +[ + { + "id": "080d227df7fb2db1", + "type": "subflow", + "name": "Outbox Enqueue v1 (1) (4) (2) (3) (1) (3) (1)", + "info": "", + "category": "", + "in": [ + { + "x": 40, + "y": 40, + "wires": [ + { + "id": "e59e58cf20a7d000" + } + ] + } + ], + "out": [ + { + "x": 1160, + "y": 40, + "wires": [ + { + "id": "2a6a83800fd51968", + "port": 0 + } + ] + } + ], + "env": [], + "meta": {}, + "color": "#DDAA99" + }, + { + "id": "e59e58cf20a7d000", + "type": "function", + "z": "080d227df7fb2db1", + "name": "Prepare + Validate + Call next_seq", + "func": "// Outbox Enqueue v1 - Step 1\nconst config = global.get(\"config\") || {};\nconst out = msg.outbox || {};\nconst type = out.type;\nconst payload = out.payload;\n\nif (!type) throw new Error(\"Outbox Enqueue: missing msg.outbox.type\");\nif (!payload || typeof payload !== \"object\") throw new Error(\"Outbox Enqueue: missing/invalid msg.outbox.payload\");\n\n// Where to send later (Publisher will use this)\nconst endpointByType = {\n cycle: \"/api/ingest/cycle\",\n event: \"/api/ingest/event\",\n kpi: \"/api/ingest/kpi\",\n heartbeat: \"/api/ingest/heartbeat\",\n segment: \"/api/ingest/segment\", // future; ok to queue now even if not used yet\n};\n\nconst endpoint = out.endpoint || endpointByType[type];\nif (!endpoint) throw new Error(`Outbox Enqueue: unknown type '${type}' and no endpoint provided`);\n\n// Get machineId from msg or global config\nconst machineId = msg.machineId || config.machineId;\nif (!machineId) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Outbox waiting for pairing\" });\n return null;\n}\n\nconst schemaVersion = msg.schemaVersion || \"1.0\";\nconst tsMs = typeof msg.tsMs === \"number\" ? msg.tsMs : Date.now();\n\n// Stash meta for later nodes\nmsg._outboxMeta = { type, endpoint, machineId, schemaVersion, tsMs, payload };\n\nconst validators = {\n cycle: (p) => p && typeof p.cycle === \"object\",\n event: (p) => p && typeof p.event === \"object\",\n kpi: (p) => p && p.kpis && typeof p.kpis === \"object\",\n heartbeat: (p) => p && typeof p.status === \"string\",\n segment: (p) => p && typeof p === \"object\",\n};\n\nconst validator = validators[type];\nif (!validator || !validator(payload)) {\n node.warn(`Outbox Enqueue: invalid ${type} payload`);\n return null;\n}\n\n// Call stored procedure to get next seq (safe + persistent)\nmsg.topic = \"CALL next_seq(?);\";\nmsg.payload = [machineId];\n\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 220, + "y": 40, + "wires": [ + [ + "41ed55d743833df6" + ] + ] + }, + { + "id": "41ed55d743833df6", + "type": "mysql", + "z": "080d227df7fb2db1", + "mydb": "fc9634aabefee16b", + "name": "CALL next_seq", + "x": 460, + "y": 40, + "wires": [ + [ + "3aa8af5ed40c0681" + ] + ] + }, + { + "id": "3aa8af5ed40c0681", + "type": "function", + "z": "080d227df7fb2db1", + "name": "Build envelope + prepare INSERT", + "func": "// Outbox Enqueue v1 - Step 2\nconst meta = msg._outboxMeta;\nif (!meta) throw new Error(\"Outbox Enqueue: missing _outboxMeta\");\n\nfunction stripNil(obj) {\n if (!obj || typeof obj !== \"object\") return obj;\n const out = Array.isArray(obj) ? [] : {};\n for (const [k, v] of Object.entries(obj)) {\n if (v === undefined || v === null) continue; // <— key change\n out[k] = (v && typeof v === \"object\") ? stripNil(v) : v;\n }\n return out;\n}\n\n// Parse seq from MySQL result\n// mysql node often returns an array of result sets for CALL\nlet seq = null;\nconst res = msg.payload;\n\nif (Array.isArray(res)) {\n // common shape: [ [ { seq: 123 } ], ... ]\n if (Array.isArray(res[0]) && res[0][0] && res[0][0].seq != null) seq = res[0][0].seq;\n // sometimes: [ { seq: 123 } ]\n if (seq == null && res[0] && res[0].seq != null) seq = res[0].seq;\n}\n\nif (seq == null) throw new Error(\"Outbox Enqueue: could not parse seq from DB result\");\n\n\n\n\nfunction normalizeIsoDeep(obj) {\n if (!obj || typeof obj !== \"object\") return obj;\n if (Array.isArray(obj)) return obj.map(normalizeIsoDeep);\n\n for (const k of Object.keys(obj)) {\n const v = obj[k];\n\n // recurse first\n if (v && typeof v === \"object\") obj[k] = normalizeIsoDeep(v);\n\n // ISO fields: must be string|null\n if (k.toLowerCase().endsWith(\"iso\")) {\n const vv = obj[k];\n if (typeof vv === \"string\") continue;\n if (typeof vv === \"number\") obj[k] = new Date(vv).toISOString();\n else if (vv == null) obj[k] = null;\n else obj[k] = null; // <- kills {} forever\n }\n }\n return obj;\n}\n\n\n\n\n\nconst cleanPayload = normalizeIsoDeep(stripNil(meta.payload));\nconst envelope = {\n schemaVersion: meta.schemaVersion,\n machineId: meta.machineId,\n tsMs: meta.tsMs,\n seq: String(seq),\n type: meta.type,\n payload: cleanPayload\n};\n\n\n// Prepare insert into outbox_messages\nmsg.topic = `\nINSERT INTO outbox_messages\n(machine_id, msg_type, endpoint, schema_version, seq, ts_device_ms, payload_json, status, attempts, next_attempt_at)\nVALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, NULL);\n`.trim();\n\nmsg.payload = [\n meta.machineId,\n meta.type,\n meta.endpoint,\n meta.schemaVersion,\n Number(seq), // BIGINT\n meta.tsMs, // BIGINT\n JSON.stringify(envelope),\n];\n\n// Keep a copy for debugging\nmsg._envelope = envelope;\nmsg._seq = Number(seq);\n\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 720, + "y": 40, + "wires": [ + [ + "2a6a83800fd51968" + ] + ] + }, + { + "id": "2a6a83800fd51968", + "type": "mysql", + "z": "080d227df7fb2db1", + "mydb": "fc9634aabefee16b", + "name": "Insert outbox_messages", + "x": 1010, + "y": 40, + "wires": [ + [] + ] + }, + { + "id": "05d4cb231221b842", + "type": "tab", + "label": "Flow 2.1", + "disabled": false, + "info": "", + "env": [] + }, + { + "id": "6e514144a570aa72", + "type": "group", + "z": "05d4cb231221b842", + "name": "Start ", + "style": { + "stroke": "#92d04f", + "fill": "#addb7b", + "label": true + }, + "nodes": [ + "a819f394fe7fc6aa", + "86b533aacff8b212", + "093d9631fbd43003", + "8ebf807b7c65eb42", + "d7fce9cf6a8c8f9b", + "05112cf4f0821cfd", + "0f0afb7fd521f2c2", + "482feffe728ab41a" + ], + "x": 1134, + "y": 139, + "w": 942, + "h": 142 + }, + { + "id": "3d465304d3ddfb72", + "type": "group", + "z": "05d4cb231221b842", + "name": "Cavity Settings ", + "style": { + "stroke": "#ff7f7f", + "fill": "#ffbfbf", + "label": true + }, + "nodes": [ + "f75c03c8eb84ebf6" + ], + "x": 122, + "y": 507, + "w": 926, + "h": 306 + }, + { + "id": "def89ffb5f14d456", + "type": "group", + "z": "05d4cb231221b842", + "name": "UI/UX", + "style": { + "fill": "#d1d1d1", + "label": true + }, + "nodes": [ + "dbfd127c516efa87", + "d230dfcd0d152ded", + "765441c17d3d41b6", + "fd2616266cc640d6", + "afb514404a6ecda1", + "b1a448897989958f", + "44d2ce4b810b508b", + "5dd7945ae90715a0", + "bfa9fe745d22c79a", + "c0dd0940ec7f53ba", + "ad66f1edaba40aaa", + "5084f1955153578d", + "5201b1255d81c6a1", + "01705d77724ccc51", + "6b3c45059b9b7c6c", + "e6d76d15a304de1a", + "fbed0d5d49b02e4c", + "39d72779cd51be9a", + "fe77ffa843b0dcfb", + "b699e16ddfa987d9", + "a981c7f429b397f0", + "ebebd1c90b0e488a" + ], + "x": 214, + "y": 179, + "w": 672, + "h": 362 + }, + { + "id": "a1b43a9e095c10db", + "type": "group", + "z": "05d4cb231221b842", + "name": "Work Orders", + "style": { + "stroke": "#9363b7", + "fill": "#dbcbe7", + "label": true + }, + "nodes": [ + "eb61ec7045cb4fab", + "e19e54018a466662", + "0638171f0c347095", + "ca4956aff7158938", + "c09c71a7e4908231", + "79e027bf3befb2d9", + "245a057bdff1fc14", + "1212f599b9ab36f0", + "7b210ef16e508259", + "875d5f193c768af0", + "1f26139ec9079c2d", + "ff9a2476ccda2ac4", + "a3d41a656eb3b2ce", + "7e94b5651ed96f24", + "bb14a0293698c0e0", + "204c7a49f98a9944", + "6d19a182b174127e", + "5f1b67667a0c39f4", + "09c77467731a6a66", + "babe66a431cd1760", + "bfb9b7c7af23bd5c", + "f9bbe50ab55c42a1", + "afb78150ffe54054", + "d45345c05485d114", + "7b91e5cc70af6ff3", + "d569d16fc0423be8", + "7d0b25dedd60803a", + "dc24aea451e9b976", + "6de9cd8265d1e821", + "68709c57900ed80e", + "1b6eda85e72ecff1", + "7aac414134d40b3a", + "78925efc4a55f04d", + "5df5be609ff6e622", + "dcaa582c9b2277ba", + "8d2fcc3bc64141a0", + "16b778a1349b8102", + "abbec199700a5e29", + "4acf0c0395ed5cdc" + ], + "x": 1034, + "y": 279, + "w": 1432, + "h": 682 + }, + { + "id": "d9a9ee7bc71b0f53", + "type": "group", + "z": "05d4cb231221b842", + "name": "Anomaly System", + "style": { + "stroke": "#ffC000", + "fill": "#ffdf7f", + "label": true + }, + "nodes": [ + "9748899355370bae", + "4ee66ceb859b7cf1", + "8e60972fea4bd36a", + "3c80936f0a0918c3", + "f27352133669b6fa" + ], + "x": 224, + "y": 19, + "w": 922, + "h": 102 + }, + { + "id": "443b758222662fdf", + "type": "group", + "z": "05d4cb231221b842", + "name": "Alerts", + "style": { + "fill": "#bfdbef", + "label": true + }, + "nodes": [ + "4a62662fea532976", + "6e76f33c5b84574a", + "96dfd46a1435d111" + ], + "x": 234, + "y": 819, + "w": 512, + "h": 82 + }, + { + "id": "9221454c45afd1ba", + "type": "group", + "z": "05d4cb231221b842", + "name": "Graphs", + "style": { + "fill": "#bfbfbf", + "label": true + }, + "nodes": [ + "7df6eebd9b7c7c7b", + "cc31f7b315638ba5", + "25a2e7a04827039a", + "9f929db1f49b6e16", + "5109df0f8b1e20e3" + ], + "x": 1194, + "y": 39, + "w": 802, + "h": 82 + }, + { + "id": "f75c03c8eb84ebf6", + "type": "group", + "z": "05d4cb231221b842", + "g": "3d465304d3ddfb72", + "name": "Cavities Settings", + "style": { + "stroke": "#ffff00", + "fill": "#ffffbf", + "label": true + }, + "nodes": [ + "878b79013722e91f" + ], + "x": 148, + "y": 533, + "w": 874, + "h": 254 + }, + { + "id": "878b79013722e91f", + "type": "group", + "z": "05d4cb231221b842", + "g": "f75c03c8eb84ebf6", + "name": "Settings", + "style": { + "stroke": "#92d04f", + "fill": "#ffffbf", + "label": true + }, + "nodes": [ + "dc743dbc93f7b7ad", + "9e5a27b63b362466", + "5542672209bd0479", + "4056bc6a05189ca8", + "3a5788e9d86542d0", + "4101967d16ba7f8b", + "ba626f3a3b37e653", + "2c8562b2471078ab" + ], + "x": 174, + "y": 559, + "w": 822, + "h": 202 + }, + { + "id": "dbfd127c516efa87", + "type": "ui_template", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "group": "919b5b8d778e2b6c", + "name": "Home Template", + "order": 0, + "width": "25", + "height": "25", + "format": "\n
\n \n\n
\n
\n
\n
\n
OEE
\n
0%
\n
\n
\n
Disponibilidad
\n
0%
\n
\n
\n
Rendimiento
\n
0%
\n
\n
\n
Calidad
\n
0%
\n
\n
\n\n
\n

Orden de trabajo actual

\n
\n
\n
ID Orden trabajo
\n
 
\n
\n
\n
SKU
\n
 
\n
\n
\n
Tiempo de ciclo
\n
0
\n
\n
\n
\n
\n
0%
\n
\n
\n\n
\n
\n
Piezas buenas
\n
0
\n
de 0
\n
\n\n
\n
MaquinaOFFLINE
\n
ProducciónDETENIDA
\n
\n\n \n
\n Cambio de molde en curso desde {{ moldChange.startMs | date:'HH:mm' }} · {{ moldChangeElapsedMin() }} min\n
\n
\n \n
\n\n
\n
\n \n\n\n\n\n \n\n
\n\n\n\n
\n
\n

Orden de trabajo en proceso

\n

{{ resumePrompt.id }}

\n

\n {{ resumePrompt.goodParts }} of {{ resumePrompt.targetQty }} partes completadas\n ({{ resumePrompt.progressPercent }}%)\n

\n

Ciclos: {{ resumePrompt.cycleCount }}

\n\n
\n \n \n
\n
\n
\n
\n
\n

{{ scrapPrompt.title || 'Orden de trabajo completada' }}

\n

{{ scrapPrompt.orderId }}

\n

\n Producido {{ scrapPrompt.produced }} de {{ scrapPrompt.target }} piezas\n

\n\n

\n Scrap acumulado: {{ scrapPrompt.scrapSoFar }}\n

\n\n
\n {{ scrapPrompt.manual ? '¿Cuántas piezas de scrap quieres agregar ahora?' : 'Hubo piezas de scrap?' }}\n
\n\n \n
\n
{{ scrapPrompt.scrapCount || 0 }}
\n
{{ scrapPrompt.error }}
\n\n
\n \n \n \n\n \n \n \n\n \n \n \n\n \n \n \n
\n\n
\n \n
\n
\n\n \n
\n \n
\n \n \n
\n\n \n \n
\n
\n
\n\n
\n
\n

Selecciona razón de scrap

\n
\n Paso {{ scrapReasonPrompt.step }} de 2\n | {{ scrapReasonPrompt.selectedCategory.label }}\n
\n\n
\n \n
\n\n
\n \n
\n\n
\n \n \n
\n
\n
\n
\n\n\n\n", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 380, + "y": 280, + "wires": [ + [ + "44d2ce4b810b508b", + "ad66f1edaba40aaa", + "14c8fb75a042909e" + ] + ] + }, + { + "id": "d230dfcd0d152ded", + "type": "ui_template", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "group": "e2f3a4b5c6d7e8f9", + "name": "Alerts Template", + "order": 0, + "width": "25", + "height": "25", + "format": "\n
\n \n\n
\n
\n
\n

Incidentes

\n
\n\n
\n \n \n \n
\n\n
\n
\n \n \n
\n
\n \n \n
\n \n
\n
\n
\n
\n\n", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 380, + "y": 360, + "wires": [ + [ + "44d2ce4b810b508b", + "fa78b7dee85d560d" + ] + ] + }, + { + "id": "765441c17d3d41b6", + "type": "ui_template", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "group": "e3f4a5b6c7d8e9f0", + "name": "Graphs Template", + "order": 0, + "width": "25", + "height": "25", + "format": "\n\n
\n \n\n
\n
\n

Graphs

\n\n\n
\n
\n

OEE

\n
\n
\n\n
\n

Disponibilidad

\n
\n
\n\n
\n

Rendimiendo

\n
\n
\n\n
\n

Calidad

\n
\n
\n
\n
\n
\n
\n\n\n\n", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 390, + "y": 400, + "wires": [ + [ + "44d2ce4b810b508b" + ] + ] + }, + { + "id": "fd2616266cc640d6", + "type": "ui_template", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "group": "e4f5a6b7c8d9e0f1", + "name": "Help Template", + "order": 0, + "width": "25", + "height": "25", + "format": "\n
\n \n\n
\n
\n
\n

Help

\n
\n\n
\n

Acerca de este panel

\n

Esta interfaz monitorea los indicadores de Eficiencia Global del Equipo (OEE), el avance de producción y el registro de incidentes para operaciones de inyección de plástico. Navega entre las pestañas usando la barra lateral para acceder a órdenes de trabajo, monitoreo en tiempo real, gráficas de desempeño, registro de incidentes y configuración de máquina.

\n
\n\n
\n

Cómo empezar con una orden de trabajo

\n

Ve a la pestaña Órdenes de Trabajo y carga un archivo de Excel con tus órdenes, o selecciona una orden existente en la tabla. Haz clic en Cargar para activar una orden de trabajo y luego navega a la pestaña Inicio, donde verás los detalles de la orden actual. Antes de iniciar producción, configura tu molde en la pestaña Configuración, seleccionando un preset de molde o ingresando manualmente el número de cavidades.

\n
\n\n
\n

Ejecutando producción

\n

En la pestaña Inicio, presiona el botón INICIAR para comenzar la producción. El sistema registra conteo de ciclos, piezas buenas, scrap y el avance hacia tu cantidad objetivo. Presiona DETENER para pausar la producción. Monitorea en tiempo real los KPIs, incluyendo OEE, Disponibilidad, Rendimiento y Calidad mostrados en el tablero.

\n
\n\n
\n

Registro de incidentes

\n

Usa la pestaña Alertas para registrar incidentes de producción. Registra rápidamente problemas comunes con botones preconfigurados como Falta de Material, Máquina Detenida o Paro de Emergencia. Para un registro más detallado, selecciona un tipo de alerta en el menú desplegable, agrega notas y envía. Todos los incidentes se registran con sello de tiempo y se utilizan en los cálculos de Disponibilidad y OEE.

\n
\n\n
\n

Configuración de moldes

\n

En la pestaña Configuración, utiliza la sección de Preconfigurados de Molde para buscar tu molde por fabricante y nombre. Selecciona una configuración para cargar automáticamente el número de cavidades, o ajusta manualmente los campos de Configuración de Molde. Si tu molde no aparece, usa el botón Agregar Molde en la sección de Integraciones para crear un nuevo preset con fabricante, nombre y detalles de cavidades.

\n
\n\n
\n

Visualización de datos de desempeño

\n

La pestaña Gráficas muestra el historial de tendencias de OEE desglosado por Disponibilidad, Desempeño y Calidad. Usa estas gráficas para identificar patrones, dar seguimiento a mejoras y diagnosticar problemas recurrentes que afecten la eficiencia de tu producción

\n
\n
\n
\n
\n\n\n", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 380, + "y": 440, + "wires": [ + [ + "44d2ce4b810b508b" + ] + ] + }, + { + "id": "afb514404a6ecda1", + "type": "ui_template", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "group": "e5f6a7b8c9d0e1f2", + "name": "Settings Template", + "order": 0, + "width": "25", + "height": "25", + "format": "\n
\n \n\n
\n
\n
\n

Ajustes

\n
\n\n
\n

Moldes Preconfigurados

\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \n
\n

Seleccionar un fabricante y el molde de las siguientes opciones.

\n
\n\n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n\n
\n

Configuraciones de Molde

\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n\n
\n

Integraciones

\n
\n
\n \n
\n
\n

No encuentras lo que buscas?

\n \n
\n
\n
\n
\n

Enlace Control Tower

\n

Ingresa el codigo de 5 caracteres que aparece en Control Tower para enlazar esta Raspberry Pi.

\n
\n
\n \n \n
\n
\n \n
\n
\n

{{ pairingStatus }}

\n
\n
\n

WiFi

\n

Conecta esta Raspberry Pi a una red WiFi (sin salir del dashboard).

\n \n
\n
\n \n \n
\n \n
\n \n \n
\n
\n \n
\n
\n \n \n
\n \n
\n \n \n
\n
\n \n
\n \n \n \n \n \n
\n \n

\n {{ wifiStatusText }}\n

\n
\n
\n

Programación de Producción

\n\n \n
\n
\n \n
\n
\n\n
\n
\n \n \n
\n
\n \n \n
\n
1\">\n \n
\n
\n\n \n\n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n {{ saveStatus }}\n \n
\n\n
\n

Umbral OEE

\n
\n
\n \n \n

\n Gap > (Cycle Time × {{thresholdMultiplier || 1.5}}) = Stoppage\n

\n
\n
\n \n \n
\n
\n
\n
\n
\n
\n\n", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 390, + "y": 480, + "wires": [ + [ + "44d2ce4b810b508b", + "c0dd0940ec7f53ba", + "fe77ffa843b0dcfb" + ] + ] + }, + { + "id": "b1a448897989958f", + "type": "ui_template", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "group": "b99c269687d574aa", + "name": "WO Template", + "order": 1, + "width": "25", + "height": "25", + "format": "\n
\n \n\n
\n
\n
\n

Ordenes de trabajo

\n
\n \n 0 selected\n \n \n \n \n \n \n \n
\n
\n\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
IDSKUMETABUENASSCRAPPROGRESOSTATUSULTIMA ACTUALIZACIÓN
\n
\n
\n 0 items\n Tip: Dejale picado para seleccionar multiples ordenes\n
\n
\n
\n
\n
\n\n\n", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 380, + "y": 320, + "wires": [ + [ + "44d2ce4b810b508b", + "ad66f1edaba40aaa" + ] + ] + }, + { + "id": "44d2ce4b810b508b", + "type": "function", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "Tab navigation", + "func": "if (msg.ui_control && msg.ui_control.tab) {\n msg.payload = { tab: msg.ui_control.tab };\n delete msg.ui_control;\n return msg;\n}\nreturn null;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 620, + "y": 380, + "wires": [ + [ + "5dd7945ae90715a0" + ] + ] + }, + { + "id": "5dd7945ae90715a0", + "type": "ui_ui_control", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "ui_control", + "events": "all", + "x": 800, + "y": 380, + "wires": [ + [] + ] + }, + { + "id": "bfa9fe745d22c79a", + "type": "ui_template", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "group": "", + "name": "General Style", + "order": 0, + "width": 0, + "height": 0, + "format": "", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "global", + "className": "", + "x": 760, + "y": 300, + "wires": [ + [] + ] + }, + { + "id": "9748899355370bae", + "type": "ui_template", + "z": "05d4cb231221b842", + "g": "d9a9ee7bc71b0f53", + "group": "919b5b8d778e2b6c", + "name": "Anomaly Alert System (Global)", + "order": 1, + "width": "25", + "height": "25", + "format": "\n\n\n\n\n\n
\n
\n

Active Alerts

\n \n
\n\n
\n
\n
\n

No active alerts

\n

All systems operating normally

\n
\n\n
\n
\n

{{anomaly.title}}

\n {{anomaly.severity}}\n
\n

{{anomaly.description}}

\n

{{formatTimestamp(anomaly.tsMs)}}

\n
\n \n
\n
\n
\n
\n\n\n
\n
\n
\n

{{popup.title}}

\n \n
\n

{{popup.description}}

\n \n
\n
\n\n
\n
\n

Selecciona razón de paro

\n

{{reasonPrompt.anomalyTitle || 'Incidente de downtime'}}

\n
\n Paso {{reasonPrompt.step}} de 2\n | {{reasonPrompt.selectedCategory.label}}\n
\n\n
\n \n
\n\n
\n \n
\n\n
\n \n \n
\n
\n
\n\n\n", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 430, + "y": 60, + "wires": [ + [ + "4ee66ceb859b7cf1", + "adba20c2e33f1b18" + ] + ] + }, + { + "id": "4ee66ceb859b7cf1", + "type": "function", + "z": "05d4cb231221b842", + "g": "d9a9ee7bc71b0f53", + "name": "Handle Anomaly Acknowledgment", + "func": "// Handle anomaly acknowledgments and downtime-reason submit.\n// Output 1: SQL update for anomaly_events\n// Output 2: event payload for Build Event Outbox Payload\nconst anomaly = global.get(\"anomaly\") || {};\nconst topic = msg.topic || \"\";\n\nfunction persistAnomaly(next) {\n global.set(\"anomaly\", next);\n try {\n global.set(\"anomaly\", next, \"file\");\n } catch (err) {\n // ignore if file store is not configured\n }\n}\n\nfunction parseReason(payload) {\n const reasonPath = Array.isArray(payload.reasonPath) ? payload.reasonPath : [];\n const category = reasonPath[0] || {};\n const detail = reasonPath[1] || {};\n return {\n type: payload.reasonType || \"downtime\",\n categoryId: category.id || null,\n categoryLabel: category.label || null,\n detailId: detail.id || null,\n detailLabel: detail.label || null,\n reasonCode: detail.reasonCode || detail.code || null,\n reasonText: payload.reasonText || [\n category.label || \"\",\n detail.label || \"\"\n ].filter(Boolean).join(\" > \") || null,\n catalogVersion: payload.catalogVersion || null,\n incidentKey: payload.incidentKey || payload.incident_key || null\n };\n}\n\nfunction removeFromActive(eventId) {\n let activeAnomalies = anomaly.activeAnomalies || [];\n const idx = activeAnomalies.findIndex((a) => a.event_id === eventId || a.tsMs === eventId);\n if (idx !== -1) {\n activeAnomalies.splice(idx, 1);\n anomaly.activeAnomalies = activeAnomalies;\n }\n}\n\nif (topic === \"acknowledge-anomaly\" || topic === \"anomaly-reason-submit\") {\n const ackData = msg.payload || {};\n const eventId = ackData.event_id ?? ackData.tsMs;\n const ackTimestamp = typeof ackData.tsMs === \"number\" ? ackData.tsMs : Date.now();\n const incidentKey = ackData.incidentKey || ackData.incident_key || null;\n\n if (!eventId) {\n node.warn(\"[ANOMALY ACK] No event_id provided\");\n return null;\n }\n\n if (String(eventId).startsWith(\"temp_\")) {\n removeFromActive(eventId);\n persistAnomaly(anomaly);\n return null;\n }\n\n const sql = (\n \"UPDATE anomaly_events \" +\n \"SET status = 'acknowledged', acknowledged_at = \" + Number(ackTimestamp) + \" \" +\n \"WHERE event_timestamp = \" + Number(eventId)\n );\n const dbMsg = { topic: sql, payload: [] };\n\n removeFromActive(eventId);\n\n if (topic === \"anomaly-reason-submit\") {\n anomaly.reasonsByIncident = anomaly.reasonsByIncident || {};\n anomaly.ackedIncidentKeys = anomaly.ackedIncidentKeys || {};\n\n const reason = parseReason(ackData);\n if (incidentKey) {\n anomaly.reasonsByIncident[incidentKey] = reason;\n anomaly.ackedIncidentKeys[incidentKey] = true;\n }\n\n persistAnomaly(anomaly);\n\n const alreadySent = !!(incidentKey && anomaly.reasonEventsSent && anomaly.reasonEventsSent[incidentKey]);\n if (alreadySent) {\n return [dbMsg, null];\n }\n\n if (incidentKey) {\n anomaly.reasonEventsSent = anomaly.reasonEventsSent || {};\n anomaly.reasonEventsSent[incidentKey] = true;\n persistAnomaly(anomaly);\n }\n\n const eventTsMs = Number(ackData.tsMs) || Date.now();\n const anomalyType = ackData.anomalyType || ackData.anomaly_type || \"downtime\";\n const outEvent = {\n tsMs: eventTsMs,\n eventType: \"downtime-acknowledged\",\n anomalyType,\n eventId: eventId,\n incidentKey: incidentKey || null,\n // Keep reason attached to generic event payload for current ingest.\n reason,\n // Explicit separation for future split in Control Tower.\n downtime: {\n incidentKey: incidentKey || null,\n eventId: eventId,\n anomalyType,\n acknowledgedAtMs: eventTsMs,\n reason\n }\n };\n\n return [{ ...dbMsg }, { payload: outEvent, tsMs: eventTsMs }];\n }\n\n if (incidentKey) {\n anomaly.ackedIncidentKeys = anomaly.ackedIncidentKeys || {};\n anomaly.ackedIncidentKeys[incidentKey] = true;\n }\n persistAnomaly(anomaly);\n\n return [dbMsg, null];\n}\n\nreturn null;\n", + "outputs": 2, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 720, + "y": 60, + "wires": [ + [ + "8e60972fea4bd36a" + ], + [ + "bf17a2d4b88f7694" + ] + ] + }, + { + "id": "a819f394fe7fc6aa", + "type": "inject", + "z": "05d4cb231221b842", + "g": "6e514144a570aa72", + "name": "Simula Inyectora", + "props": [ + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "payload": "1", + "payloadType": "num", + "x": 1260, + "y": 180, + "wires": [ + [ + "05112cf4f0821cfd", + "25dfb62e7131af6b", + "b219495329321d63", + "482feffe728ab41a" + ] + ] + }, + { + "id": "86b533aacff8b212", + "type": "function", + "z": "05d4cb231221b842", + "g": "6e514144a570aa72", + "name": "1,0", + "func": "// Get current global value (default to 0 if not set)\nconst state = global.get(\"state\") || {};\nlet estado = state.machineToggle || 0;\nlet stop = flow.get('stop') || false;\n\nif (stop) {\n // Manual stop active → force 0, don't reschedule\n state.machineToggle = 0;\nglobal.set(\"state\", state);\n msg.payload = 0;\n node.send(msg);\n return;\n}\n\n// Toggle between 1 and 0\nestado = estado === 1 ? 0 : 1;\n\n// Update the global variable\nstate.machineToggle = estado;\nglobal.set(\"state\", state);\n\n// Send it out\nmsg.payload = estado;\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1710, + "y": 180, + "wires": [ + [ + "093d9631fbd43003" + ] + ] + }, + { + "id": "dc743dbc93f7b7ad", + "type": "function", + "z": "05d4cb231221b842", + "g": "878b79013722e91f", + "name": "Cavities Settings", + "func": "const settings = global.get(\"settings\") || {};\nconst state = global.get(\"state\") || {};\nconst moldByWorkOrder = state.moldByWorkOrder || {};\n\nconst persistSettings = () => {\n global.set(\"settings\", settings);\n try {\n global.set(\"settings\", settings, \"file\");\n } catch (err) {\n // ignore if file store is not configured\n }\n};\n\nconst persistMoldCache = () => {\n const cache = {\n lastMoldActive: state.lastMoldActive ?? null,\n lastMoldTotal: state.lastMoldTotal ?? null,\n moldByWorkOrder: state.moldByWorkOrder || {}\n };\n global.set(\"moldCache\", cache);\n try {\n global.set(\"moldCache\", cache, \"file\");\n } catch (err) {\n // ignore if file store is not configured\n }\n};\n\nconst persistState = () => {\n global.set(\"state\", state);\n};\n\nconst persistMoldSelection = (total, active) => {\n if (!Number.isFinite(active) || active <= 0) return;\n state.lastMoldActive = active;\n if (Number.isFinite(total) && total > 0) {\n state.lastMoldTotal = total;\n }\n if (state.activeWorkOrder?.id) {\n state.activeWorkOrder.cavities = active;\n if (Number.isFinite(total) && total > 0) {\n state.activeWorkOrder.cavities_total = total;\n }\n moldByWorkOrder[state.activeWorkOrder.id] = {\n total: Number.isFinite(total) && total > 0 ? total : (state.lastMoldTotal ?? null),\n active\n };\n }\n state.moldByWorkOrder = moldByWorkOrder;\n persistState();\n persistMoldCache();\n};\n\nconst buildCavitiesUpdate = (total, active) => {\n const activeId = state.activeWorkOrder?.id;\n if (!activeId || !Number.isFinite(active) || active <= 0) return null;\n const totalSafe = Number.isFinite(total) && total > 0 ? total : 0;\n return {\n topic: \"UPDATE work_orders SET cavities_total = COALESCE(NULLIF(?,0), cavities_total), cavities_active = COALESCE(NULLIF(?,0), cavities_active), updated_at = NOW() WHERE work_order_id = ?\",\n payload: [totalSafe, active, activeId]\n };\n};\n\nif (msg.topic === \"moldSettings\" && msg.payload) {\n const total = Number(msg.payload.total || 0);\n const active = Number(msg.payload.active || 0);\n\n // Store settings\n settings.moldTotal = total;\n settings.moldActive = active;\n persistSettings();\n\n persistMoldSelection(total, active);\n\n node.status({ fill: \"green\", shape: \"dot\", text: `Saved: ${active}/${total}` });\n\n const dbMsg = buildCavitiesUpdate(total, active);\n\n msg.payload = { saved: true, total, active };\n return [msg, dbMsg];\n}\n\n// Handle preset selection\nif (msg.topic === \"selectMoldPreset\" && msg.payload) {\n const preset = msg.payload;\n const total = Number(preset.theoretical_cavities || 0);\n const active = Number(preset.functional_cavities || 0);\n\n // Store settings\n settings.moldTotal = total;\n settings.moldActive = active;\n persistSettings();\n\n persistMoldSelection(total, active);\n\n node.status({ fill: \"blue\", shape: \"dot\", text: `Preset: ${preset.mold_name}` });\n\n // Send to UI to update fields\n msg.topic = \"moldPresetSelected\";\n msg.payload = { total, active, presetName: preset.mold_name };\n\n const dbMsg = buildCavitiesUpdate(total, active);\n\n return [msg, dbMsg];\n}\n\n//node.status({ fill: \"red\", shape: \"ring\", text: \"Invalid payload\" });\nreturn [null, null];", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 470, + "y": 600, + "wires": [ + [ + "4056bc6a05189ca8" + ] + ] + }, + { + "id": "9e5a27b63b362466", + "type": "function", + "z": "05d4cb231221b842", + "g": "878b79013722e91f", + "name": "Mold Presets Handler", + "func": "const topic = msg.topic || '';\nconst payload = msg.payload || {};\n\n// ===== IGNORE NON-MOLD TOPICS SILENTLY =====\n// These are KPI/dashboard messages not meant for this handler\nconst ignoredTopics = [\n 'machineStatus',\n 'kpis',\n 'chartsData',\n 'activeWorkOrder',\n 'workOrderCycle',\n 'workOrdersList',\n 'scrapPrompt',\n 'uploadStatus'\n];\n\nif (ignoredTopics.includes(topic) || topic === '') {\n return null; // Silent ignore\n}\n\n// Log only mold-related requests\nnode.warn(`Received: ${topic}`);\n\n// CRITICAL: Use a processing lock to prevent simultaneous requests\nlet dedupeKey = topic;\nif (topic === 'addMoldPreset') {\n dedupeKey = `add_${payload.manufacturer}_${payload.mold_name}`;\n} else if (topic === 'getMoldsByManufacturer') {\n dedupeKey = `getmolds_${payload.manufacturer}`;\n}\n\nconst lockKey = `lock_${dedupeKey}`;\nconst lastRequestKey = `last_request_${dedupeKey}`;\n\n// Check if currently processing this request\nif (flow.get(lockKey) === true) {\n node.warn(`${topic} already processing - duplicate blocked`);\n return null;\n}\n\n// Check timing\nconst now = Date.now();\nconst lastRequestTime = flow.get(lastRequestKey) || 0;\nif (now - lastRequestTime < 2000) {\n node.warn(`Duplicate ${topic} request ignored (within 2s)`);\n return null;\n}\n\n// Set lock IMMEDIATELY before any async operations\nflow.set(lockKey, true);\nflow.set(lastRequestKey, now);\n\n// Release lock after 3 seconds (safety timeout)\nsetTimeout(() => {\n flow.set(lockKey, false);\n}, 3000);\n\n// Load all presets (legacy)\nif (topic === 'loadMoldPresets') {\n msg._originalTopic = 'loadMoldPresets';\n msg.topic = 'SELECT * FROM mold_presets ORDER BY manufacturer, mold_name;';\n node.warn('Querying all presets');\n return msg;\n}\n\n// Search/filter presets (legacy)\nif (topic === 'searchMoldPresets') {\n const filters = msg.payload || {};\n const searchTerm = (filters.searchTerm || '').trim().replace(/['\\\"\\\\\\\\]/g, '');\n const manufacturer = (filters.manufacturer || '').replace(/['\\\"\\\\\\\\]/g, '');\n const theoreticalCavities = filters.theoreticalCavities || '';\n\n let query = 'SELECT * FROM mold_presets WHERE 1=1';\n\n if (searchTerm) {\n const searchPattern = `%${searchTerm}%`;\n query += ` AND (mold_name LIKE '${searchPattern.replace(/'/g, \"''\")}' OR manufacturer LIKE '${searchPattern.replace(/'/g, \"''\")}')`;\n }\n\n if (manufacturer && manufacturer !== 'All') {\n query += ` AND manufacturer = '${manufacturer.replace(/'/g, \"''\")}'`;\n }\n\n if (theoreticalCavities && theoreticalCavities !== '') {\n const cavities = Number(theoreticalCavities);\n if (!isNaN(cavities)) {\n query += ` AND theoretical_cavities = ${cavities}`;\n }\n }\n\n query += ' ORDER BY manufacturer, mold_name;';\n\n msg._originalTopic = 'searchMoldPresets';\n msg.topic = query;\n return msg;\n}\n\n// Get unique manufacturers for dropdown\nif (topic === 'getManufacturers') {\n msg._originalTopic = 'getManufacturers';\n msg.topic = 'SELECT DISTINCT manufacturer FROM mold_presets ORDER BY manufacturer;';\n node.warn('Querying manufacturers');\n return msg;\n}\n\n// Get molds for a specific manufacturer\nif (topic === 'getMoldsByManufacturer') {\n const data = msg.payload || {};\n const manufacturerRaw = (data.manufacturer || '').trim();\n if (!manufacturerRaw) {\n node.warn('No manufacturer provided');\n return null;\n }\n\n const manufacturerSafe = manufacturerRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n\n msg._originalTopic = 'getMoldsByManufacturer';\n msg.topic = `SELECT * FROM mold_presets WHERE manufacturer = '${manufacturerSafe}' ORDER BY mold_name;`;\n node.warn(`Querying molds for: ${manufacturerSafe}`);\n return msg;\n}\n\n// Add a new mold preset - CRITICAL: Strong deduplication\nif (topic === 'addMoldPreset') {\n const data = msg.payload || {};\n const manufacturerRaw = (data.manufacturer || '').trim();\n const moldNameRaw = (data.mold_name || '').trim();\n const theoreticalRaw = (data.theoretical || '').trim();\n const activeRaw = (data.active || '').trim();\n\n if (!manufacturerRaw || !moldNameRaw || !theoreticalRaw || !activeRaw) {\n node.status({ fill: 'red', shape: 'ring', text: 'Missing value' });\n node.warn('Missing required fields');\n return null;\n }\n\n // Additional safety check for already-processed flag\n if (msg._addMoldProcessed) {\n node.warn('addMoldPreset already processed flag detected, ignoring');\n return null;\n }\n msg._addMoldProcessed = true;\n\n const manufacturerSafe = manufacturerRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n const moldNameSafe = moldNameRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n const theoreticalSafe = theoreticalRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n const activeSafe = activeRaw.replace(/['\\\"\\\\\\\\]/g, '').replace(/'/g, \"''\");\n\n msg._originalTopic = 'addMoldPreset';\n msg.topic =\n \"INSERT INTO mold_presets (manufacturer, mold_name, theoretical_cavities, functional_cavities) \" +\n \"VALUES ('\" + manufacturerSafe + \"', '\" + moldNameSafe + \"', \" + theoreticalSafe + \", \" + activeSafe + \");\";\n\n node.status({ fill: 'blue', shape: 'dot', text: 'Inserting mold...' });\n node.warn(`Inserting: ${manufacturerSafe} - ${moldNameSafe}`);\n return msg;\n}\n\nnode.warn(`Unknown topic: ${topic}`);\nreturn null;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 400, + "y": 660, + "wires": [ + [ + "5542672209bd0479" + ] + ] + }, + { + "id": "5542672209bd0479", + "type": "mysql", + "z": "05d4cb231221b842", + "g": "878b79013722e91f", + "mydb": "fc9634aabefee16b", + "name": "Mold Presets DB", + "x": 610, + "y": 660, + "wires": [ + [ + "4056bc6a05189ca8" + ] + ] + }, + { + "id": "4056bc6a05189ca8", + "type": "function", + "z": "05d4cb231221b842", + "g": "878b79013722e91f", + "name": "Process DB Results", + "func": "// Replace function in \"Process DB Results\" node\n\nconst originalTopic = msg._originalTopic || '';\nconst dbResults = Array.isArray(msg.payload) ? msg.payload : [];\n\nif (!originalTopic) {\n return null;\n}\n\n// IMPORTANT: Clear socketid to prevent loops back to sender\ndelete msg._socketid;\ndelete msg.socketid;\n\n// Manufacturers query → list for first dropdown\nif (originalTopic === 'getManufacturers') {\n const manufacturers = dbResults\n .map(row => row.manufacturer)\n .filter((mfg, index, arr) => mfg && arr.indexOf(mfg) === index)\n .sort();\n\n msg.topic = 'manufacturersList';\n msg.payload = manufacturers;\n\n node.status({ fill: 'green', shape: 'dot', text: `${manufacturers.length} manufacturers` });\n return msg;\n}\n\n// Preset lists (legacy load/search)\nif (originalTopic === 'loadMoldPresets' || originalTopic === 'searchMoldPresets') {\n const presets = dbResults.map(row => ({\n mold_name: row.mold_name || '',\n manufacturer: row.manufacturer || '',\n theoretical_cavities: Number(row.theoretical_cavities) || 0,\n functional_cavities: Number(row.functional_cavities) || 0\n }));\n\n msg.topic = 'moldPresetsList';\n msg.payload = presets;\n\n node.status({ fill: 'green', shape: 'dot', text: `${presets.length} presets found` });\n return msg;\n}\n\n// Molds for selected manufacturer\nif (originalTopic === 'getMoldsByManufacturer') {\n const presets = dbResults.map(row => ({\n mold_name: row.mold_name || '',\n manufacturer: row.manufacturer || '',\n theoretical_cavities: Number(row.theoretical_cavities) || 0,\n functional_cavities: Number(row.functional_cavities) || 0\n }));\n\n msg.topic = 'moldPresetsList';\n msg.payload = presets;\n\n node.status({ fill: 'blue', shape: 'dot', text: `${presets.length} molds for manufacturer` });\n return msg;\n}\n\n// Result of inserting a new mold\nif (originalTopic === 'addMoldPreset') {\n msg.topic = 'addMoldResult';\n msg.payload = {\n success: true,\n result: msg.payload\n };\n\n node.status({ fill: 'green', shape: 'dot', text: 'Mold added' });\n return msg;\n}\n\nnode.status({ fill: 'yellow', shape: 'ring', text: 'Unknown topic: ' + originalTopic });\nreturn null;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 740, + "y": 600, + "wires": [ + [ + "4101967d16ba7f8b" + ] + ] + }, + { + "id": "c0dd0940ec7f53ba", + "type": "link out", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "link out 1", + "mode": "link", + "links": [ + "3a5788e9d86542d0" + ], + "x": 505, + "y": 480, + "wires": [] + }, + { + "id": "3a5788e9d86542d0", + "type": "link in", + "z": "05d4cb231221b842", + "g": "878b79013722e91f", + "name": "link in 1", + "links": [ + "c0dd0940ec7f53ba" + ], + "x": 215, + "y": 660, + "wires": [ + [ + "dc743dbc93f7b7ad", + "9e5a27b63b362466", + "ba626f3a3b37e653", + "063447515a79e473" + ] + ] + }, + { + "id": "204c7a49f98a9944", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Work Order buttons", + "func": "const config = global.get(\"config\") || {};\nconst settings = global.get(\"settings\") || {};\nconst state = global.get(\"state\") || {};\nconst reasonCatalog = settings.reasonCatalog || {};\n\n// ✨ La WO de la BD es la única fuente de cavidades.\n// attachMold solo normaliza nombres (cavities/cavitiesActive, cavities_total/cavitiesTotal)\n// para que el resto del código pueda leerlos con cualquier alias.\n// YA NO escribe en state.lastMoldActive ni state.moldByWorkOrder (cache global eliminado).\nconst attachMold = (order) => {\n if (!order || !order.id) return;\n\n const cavities = Number(\n order.cavitiesActive ??\n order.cavities_active ??\n order.cavities ??\n 0\n );\n const total = Number(\n order.cavitiesTotal ??\n order.cavities_total ??\n 0\n );\n\n if (Number.isFinite(cavities) && cavities > 0) {\n order.cavitiesActive = cavities;\n order.cavities = cavities; // alias para código viejo\n }\n if (Number.isFinite(total) && total > 0) {\n order.cavitiesTotal = total;\n order.cavities_total = total; // alias para código viejo\n }\n};\n\nconst finalize = (ret) => {\n global.set(\"state\", state);\n return ret;\n};\n\nconst normalizeReason = (payload, fallbackType) => {\n const path = Array.isArray(payload.reasonPath) ? payload.reasonPath : [];\n const category = path[0] || {};\n const detail = path[1] || {};\n return {\n type: payload.reasonType || fallbackType || \"downtime\",\n categoryId: category.id || null,\n categoryLabel: category.label || null,\n detailId: detail.id || null,\n detailLabel: detail.label || null,\n reasonText: payload.reasonText || [\n category.label || \"\",\n detail.label || \"\"\n ].filter(Boolean).join(\" > \") || null,\n catalogVersion: payload.catalogVersion || reasonCatalog.version || null,\n incidentKey: payload.incidentKey || null\n };\n};\n\n\n// ===== MOLD CHANGE AUTO-CLOSE =====\n// If a mold change is active and user is starting/resuming/restarting a WO, close it.\nif (state.moldChange && state.moldChange.active &&\n (msg.action === \"start\" || msg.action === \"start-work-order\" || msg.action === \"resume-work-order\" || msg.action === \"restart-work-order\")) {\n const now = Date.now();\n const mc = state.moldChange;\n const durationSec = Math.max(0, Math.round((now - (mc.startMs || now)) / 1000));\n const closedMc = {\n active: false,\n startMs: mc.startMs,\n endMs: now,\n fromMoldId: mc.fromMoldId || null,\n toMoldId: null,\n durationSec\n };\n state.moldChange = closedMc;\n global.set(\"state\", state);\n const closeEvent = {\n payload: {\n anomaly_type: \"mold-change\",\n eventType: \"mold-change\",\n severity: \"info\",\n requires_ack: false,\n title: \"Cambio de molde finalizado (automatico)\",\n description: \"Duracion: \" + Math.round(durationSec / 60) + \" min\",\n status: \"resolved\",\n tsMs: now,\n data: {\n status: \"resolved\",\n start_ms: closedMc.startMs,\n end_ms: now,\n duration_sec: durationSec,\n stoppage_duration_seconds: durationSec,\n from_mold_id: closedMc.fromMoldId,\n to_mold_id: closedMc.toMoldId,\n incidentKey: \"mold-change:\" + (closedMc.startMs || now)\n }\n },\n tsMs: now\n };\n const uiClose = { _mode: \"mold-change-status\", payload: { active: false, endMs: now, durationSec } };\n node.send([null, uiClose, null, null, null, closeEvent]);\n}\n\nconst mode = msg._mode || '';\nswitch (msg.action) {\n case \"upload-excel\":\n msg._mode = \"upload\";\n return finalize([msg, null, null, null]);\n case \"refresh-work-orders\": {\n const selectMsg = {\n ...msg,\n _mode: \"select\",\n topic: \"SELECT * FROM work_orders ORDER BY created_at DESC;\"\n };\n const fetchMsg = {\n ...msg,\n topic: \"workOrdersRefresh\",\n forceRefresh: true,\n payload: msg.payload && typeof msg.payload === \"object\" ? msg.payload : {}\n };\n return finalize([null, selectMsg, null, null, null, null, fetchMsg]);\n }\n case \"start-work-order\": {\n msg._mode = \"start-check-progress\";\n const order = msg.payload || {};\n if (!order.id) {\n node.error(\"No work order id supplied for start\", msg);\n return finalize([null, null, null, null]);\n }\n\n // ✨ FIX: pausar inmediatamente la WO actual antes de cargar la nueva.\n // Previene que se sumen ciclos a la WO anterior mientras llega el resume-prompt.\n if (state.activeWorkOrder && state.activeWorkOrder.id !== order.id) {\n node.warn(`[START-WO] Pausando WO actual ${state.activeWorkOrder.id} antes de cargar ${order.id}`);\n state.trackingEnabled = false;\n state.productionStarted = false;\n flow.set(\"lastMachineState\", 0);\n\n // ✨ También sincronizar state.lastState para que el polling cada 500ms\n // del HMI (get-current-state) no devuelva valores viejos al template\n // y reactive el botón DETENER.\n if (state.lastState && typeof state.lastState === \"object\") {\n state.lastState = {\n ...state.lastState,\n trackingEnabled: false,\n productionStarted: false,\n tsMs: Date.now()\n };\n }\n }\n flow.set(\"pendingWorkOrder\", order);\n\n msg.topic = \"SELECT cycle_count, good_parts, scrap_parts, progress_percent, target_qty, cavities_total, cavities_active FROM work_orders WHERE work_order_id = ? LIMIT 1\";\n msg.payload = [order.id];\n\n return finalize([null, msg, null, null]);\n }\n case \"resume-work-order\": {\n const now = Date.now();\n state.productionStartTime = now;\n state.actualRunTime = 0;\n state.lastStartChangeTime = now;\n state.runSeconds = 0;\n state.stopSeconds = 0;\n state.kpiLastTick = null;\n msg._mode = \"resume\";\n const order = msg.payload || {};\n if (!order.id) {\n node.error(\"No work order id supplied for resume\", msg);\n return finalize([null, null, null, null]);\n }\n\n // Set status to RUNNING without resetting progress\n msg.topic = \"UPDATE work_orders SET status = CASE WHEN work_order_id = ? THEN 'RUNNING' ELSE 'PENDING' END, updated_at = CASE WHEN work_order_id = ? THEN NOW() ELSE updated_at END, cavities_total = COALESCE(NULLIF(?,0), cavities_total), cavities_active = COALESCE(NULLIF(?,0), cavities_active) WHERE status <> 'DONE'\";\n // ✨ Acepta cavitiesActive (nuevo) o cavities (alias viejo)\n msg.payload = [\n order.id,\n order.id,\n Number(order.cavitiesTotal || order.cavities_total || 0),\n Number(order.cavitiesActive || order.cavities_active || order.cavities || 0)\n ];\n msg.startOrder = order;\n\n // Load existing values into global state\n order.scrapParts = Number(order.scrapParts) || 0;\n order.goodParts = Number(order.goodParts) || 0;\n\n attachMold(order);\n\n state.activeWorkOrder = order;\n state.cycleCount = Number(order.cycleCount) || 0;\n state.activeOrderHasProgress = true;\n flow.set(\"lastMachineState\", 0);\n state.scrapPromptIssuedFor = null;\n\n return finalize([null, null, msg, null]);\n }\n case \"restart-work-order\": {\n const now = Date.now();\n state.productionStartTime = now;\n state.actualRunTime = 0;\n state.lastStartChangeTime = now;\n state.runSeconds = 0;\n state.stopSeconds = 0;\n state.kpiLastTick = null;\n msg._mode = \"restart\";\n const order = msg.payload || {};\n if (!order.id) {\n node.error(\"No work order id supplied for restart\", msg);\n return finalize([null, null, null, null]);\n }\n\n // Reset progress in database AND set status to RUNNING\n msg.topic = \"UPDATE work_orders SET status = CASE WHEN work_order_id = ? THEN 'RUNNING' ELSE 'PENDING' END, cycle_count = 0, good_parts = 0, scrap_parts = 0, progress_percent = 0, updated_at = NOW(), cavities_total = COALESCE(NULLIF(?,0), cavities_total), cavities_active = COALESCE(NULLIF(?,0), cavities_active) WHERE work_order_id = ? OR status = 'RUNNING'\";\n // ✨ Acepta cavitiesActive (nuevo) o cavities (alias viejo)\n msg.payload = [\n order.id,\n order.id,\n Number(order.cavitiesTotal || order.cavities_total || 0),\n Number(order.cavitiesActive || order.cavities_active || order.cavities || 0),\n order.id\n ];\n msg.startOrder = order;\n\n // Initialize global state to 0\n order.scrapParts = 0;\n order.goodParts = 0;\n\n attachMold(order);\n\n state.activeWorkOrder = order;\n state.cycleCount = 0;\n state.activeOrderHasProgress = false;\n flow.set(\"lastMachineState\", 0);\n state.scrapPromptIssuedFor = null;\n\n return finalize([null, null, msg, null]);\n }\n case \"complete-work-order\": {\n state.productionStartTime = null;\n state.runSeconds = 0;\n state.stopSeconds = 0;\n state.kpiLastTick = null;\n msg._mode = \"complete\";\n const order = msg.payload || {};\n if (!order.id) {\n node.error(\"No work order id supplied for complete\", msg);\n return finalize([null, null, null, null]);\n }\n\n // Get final values from global state before clearing\n const activeOrder = state.activeWorkOrder || {};\n const finalCycleCount = Number(state.cycleCount || 0);\n const finalGoodParts = Number(activeOrder.goodParts) || 0;\n const finalScrapParts = Number(activeOrder.scrapParts) || 0;\n\n msg.completeOrder = order;\n\n // SQL: Persist final counts AND set status to DONE\n msg.topic = \"UPDATE work_orders SET status = 'DONE', cycle_count = ?, good_parts = ?, scrap_parts = ?, progress_percent = 100, updated_at = NOW() WHERE work_order_id = ?\";\n msg.payload = [finalCycleCount, finalGoodParts, finalScrapParts, order.id];\n\n // Clear ALL state on completion\n state.activeWorkOrder = null;\n state.activeOrderHasProgress = false;\n state.trackingEnabled = false;\n state.productionStarted = false;\n state.kpiStartupMode = false;\n state.operatingTime = 0;\n state.lastCycleTime = null;\n state.cycleCount = 0;\n flow.set(\"lastMachineState\", 0);\n state.scrapPromptIssuedFor = null;\n state.actualRunTime = 0;\n state.lastStateChangeTime = null;\n\n if (state.lastState && typeof state.lastState === \"object\") {\n state.lastState = {\n ...state.lastState,\n activeWorkOrder: null,\n cycleCount: 0,\n goodParts: 0,\n scrapParts: 0,\n cycleTime: 0,\n actualCycleTime: 0,\n trackingEnabled: false,\n productionStarted: false,\n tsMs: Date.now()\n };\n }\n\n // ============================================================\n // HIGH SCRAP DETECTION\n // ============================================================\n const targetQty = Number(activeOrder.target) || 0;\n const scrapCount = finalScrapParts;\n const scrapPercent = targetQty > 0 ? (scrapCount / targetQty) * 100 : 0;\n\n let anomalyMsg = null;\n if (scrapPercent > 10 && targetQty > 0) {\n const severity = scrapPercent > 25 ? 'critical' : 'warning';\n\n const highScrapAnomaly = {\n anomaly_type: 'high-scrap',\n severity: severity,\n title: `High Waste Detected`,\n description: `Work order completed with ${scrapCount} scrap parts (${scrapPercent.toFixed(1)}% of target ${targetQty}). Why is there so much waste?`,\n data: {\n scrap_count: scrapCount,\n target_quantity: targetQty,\n scrap_percent: Math.round(scrapPercent * 10) / 10,\n good_parts: finalGoodParts,\n total_cycles: finalCycleCount\n },\n kpi_snapshot: {\n oee: (msg.kpis && msg.kpis.oee) || state.currentKPIs?.oee || 0,\n availability: (msg.kpis && msg.kpis.availability) || state.currentKPIs?.availability || 0,\n performance: (msg.kpis && msg.kpis.performance) || state.currentKPIs?.performance || 0,\n quality: (msg.kpis && msg.kpis.quality) || state.currentKPIs?.quality || 0\n },\n work_order_id: order.id,\n cycle_count: finalCycleCount,\n tsMs: Date.now(),\n status: 'active'\n };\n\n anomalyMsg = {\n topic: \"anomaly-detected\",\n payload: [highScrapAnomaly]\n };\n }\n\n return finalize([null, null, null, msg, anomalyMsg]);\n }\n case \"get-current-state\": {\n // Single truth for UI: state.lastState\n const s = state.lastState || {};\n\n const validWorkOrders = state.workOrdersById || null;\n if (state.activeWorkOrder?.id && validWorkOrders && !validWorkOrders[state.activeWorkOrder.id]) {\n node.warn(`[STATE] activeWorkOrder ${state.activeWorkOrder.id} not in work order list; clearing`);\n state.activeWorkOrder = null;\n }\n\n const activeOrder = (state.activeWorkOrder && state.activeWorkOrder.id)\n ? state.activeWorkOrder\n : null;\n\n const trackingEnabled =\n (typeof s.trackingEnabled === \"boolean\" ? s.trackingEnabled : (state.trackingEnabled || false));\n\n const productionStarted =\n (typeof s.productionStarted === \"boolean\" ? s.productionStarted : (state.productionStarted || false));\n\n const kpis =\n (s.kpis && typeof s.kpis === \"object\")\n ? s.kpis\n : (state.currentKPIs || { oee: 0, availability: 0, performance: 0, quality: 0 });\n\n // ✨ Las cavidades vienen ÚNICAMENTE de la WO activa (sin fallback al global cache)\n const cavities = Number(\n activeOrder?.cavitiesActive ??\n activeOrder?.cavities ??\n s.cavities ??\n 0\n ) || null;\n\n msg._mode = \"current-state\";\n msg.payload = {\n machineId: s.machineId ?? config.machineId ?? undefined,\n\n activeWorkOrder: activeOrder,\n\n cycleCount: s.cycleCount,\n goodParts: s.goodParts,\n scrapParts: s.scrapParts,\n cavities,\n\n cycleTime: s.cycleTime,\n actualCycleTime: s.actualCycleTime,\n\n trackingEnabled,\n productionStarted,\n kpis,\n reasonCatalog,\n\n tsMs: s.tsMs\n };\n\n return finalize([null, msg, null, null]);\n }\n case \"restore-session\": {\n // Query DB for any RUNNING work order on startup\n msg._mode = \"restore-query\";\n msg.topic = \"SELECT * FROM work_orders WHERE status = 'RUNNING' LIMIT 1\";\n msg.payload = [];\n return finalize([null, msg, null, null]);\n }\n case \"scrap-entry\": {\n const { id, scrap } = msg.payload || {};\n const scrapNum = Number(scrap) || 0;\n\n if (!id) {\n node.error(\"No work order id supplied for scrap entry\", msg);\n return finalize([null, null, null, null]);\n }\n\n const activeOrder = state.activeWorkOrder;\n if (activeOrder && activeOrder.id === id) {\n activeOrder.scrapParts = (Number(activeOrder.scrapParts) || 0) + scrapNum;\n state.activeWorkOrder = activeOrder;\n }\n\n state.scrapPromptIssuedFor = null;\n\n msg._mode = \"scrap-update\";\n msg.scrapEntry = { id, scrap: scrapNum, reason: null };\n msg.topic = \"UPDATE work_orders SET scrap_parts = scrap_parts + ?, updated_at = NOW() WHERE work_order_id = ?\";\n msg.payload = [scrapNum, id];\n\n return finalize([null, null, msg, null]);\n }\n case \"scrap-entry-with-reason\": {\n const { id, scrap } = msg.payload || {};\n const scrapNum = Number(scrap) || 0;\n const reason = normalizeReason(msg.payload || {}, \"scrap\");\n\n if (!id) {\n node.error(\"No work order id supplied for scrap entry with reason\", msg);\n return finalize([null, null, null, null, null, null]);\n }\n\n const activeOrder = state.activeWorkOrder;\n if (activeOrder && activeOrder.id === id) {\n activeOrder.scrapParts = (Number(activeOrder.scrapParts) || 0) + scrapNum;\n state.activeWorkOrder = activeOrder;\n }\n\n state.scrapPromptIssuedFor = null;\n state.lastScrapReasonByOrder = state.lastScrapReasonByOrder || {};\n state.lastScrapReasonByOrder[id] = reason;\n\n const tsMs = Date.now();\n const outEvent = {\n tsMs,\n eventType: \"scrap-manual-entry\",\n workOrderId: id,\n scrapDelta: scrapNum,\n source: \"home-ui\",\n reason,\n downtime: null\n };\n\n msg._mode = \"scrap-update\";\n msg.scrapEntry = { id, scrap: scrapNum, reason };\n msg.topic = \"UPDATE work_orders SET scrap_parts = scrap_parts + ?, updated_at = NOW() WHERE work_order_id = ?\";\n msg.payload = [scrapNum, id];\n\n return finalize([null, null, msg, null, null, { payload: outEvent, tsMs }]);\n }\n case \"scrap-open\": {\n const active = state.activeWorkOrder || null;\n if (!active?.id) return finalize([null, null, null, null, null]);\n\n const good = Number(active.goodParts) || 0;\n const scrap = Number(active.scrapParts) || 0;\n const produced = good + scrap;\n\n msg._mode = \"scrap-prompt\";\n msg.scrapPrompt = {\n id: active.id,\n sku: active.sku || \"\",\n target: Number(active.target) || 0,\n produced,\n scrapSoFar: scrap,\n\n manual: true,\n title: \"Registrar scrap (parcial)\",\n enterMode: true,\n validateMax: false,\n reasonCatalog: reasonCatalog\n };\n\n delete msg.topic;\n delete msg.payload;\n\n return finalize([null, msg, null, null, null]);\n }\n case \"scrap-skip\": {\n const { id, remindAgain } = msg.payload || {};\n\n if (!id) {\n node.error(\"No work order id supplied for scrap skip\", msg);\n return finalize([null, null, null, null]);\n }\n\n if (remindAgain) {\n state.scrapPromptIssuedFor = null;\n }\n\n msg._mode = \"scrap-skipped\";\n return finalize([null, null, null, null]);\n }\n case \"start\": {\n // START with KPI timestamp init - FIXED\n const now = Date.now();\n const shifts = settings.shifts || [{ start: '06:00', end: '13:00' }];\n const shiftChangeComp = settings.shiftChangeCompensation || 10;\n const lunchBreak = settings.lunchBreakMinutes || 30;\n\n\n let totalShiftSeconds = 0;\n shifts.forEach(shift => {\n const [startH, startM] = (shift.start || '06:00').split(':').map(Number);\n const [endH, endM] = (shift.end || '15:00').split(':').map(Number);\n\n let startMinutes = startH * 60 + startM;\n let endMinutes = endH * 60 + endM;\n\n if (endMinutes <= startMinutes) {\n endMinutes += 24 * 60;\n }\n\n totalShiftSeconds += (endMinutes - startMinutes) * 60;\n });\n const compensationSeconds = shifts.length * shiftChangeComp * 60;\n const lunchSeconds = lunchBreak * 60;\n const plannedProductionTime = Math.max(0, totalShiftSeconds - compensationSeconds - lunchSeconds);\n state.plannedProductionTime = plannedProductionTime;\n\n const existingCycles = Number(state.cycleCount || 0);\n const activeOrderState = state.activeWorkOrder || {};\n const orderGood = Number(activeOrderState.goodParts) || 0;\n const orderScrap = Number(activeOrderState.scrapParts) || 0;\n\n const hasProgressFlag = state.activeOrderHasProgress;\n\n const hasProgress = (typeof hasProgressFlag === 'boolean')\n ? hasProgressFlag\n : (\n existingCycles > 0 ||\n (orderGood + orderScrap) > 0\n );\n\n if (!hasProgress) {\n state.stopTime = 0;\n state.trackingEnabled = true;\n state.productionStarted = true;\n state.kpiStartupMode = true;\n state.kpiBuffer = [];\n state.lastKPIRecordTime = now - 60000;\n state.productionStartTime = now;\n state.lastMachineCycleTime = now;\n state.lastCycleTime = now;\n state.operatingTime = 0;\n state.actualRunTime = 0;\n state.lastStartChangeTime = now;\n state.lastCycleCompletionTime = null;\n state.runSeconds = 0;\n state.stopSeconds = 0;\n state.kpiLastTick = null;\n } else {\n state.trackingEnabled = true;\n state.productionStarted = true;\n state.kpiStartupMode = false;\n }\n\n const activeOrder = state.activeWorkOrder || {};\n msg._mode = \"production-state\";\n\n msg.payload = msg.payload || {};\n\n msg.trackingEnabled = true;\n msg.productionStarted = true;\n msg.machineOnline = true;\n\n msg.payload.trackingEnabled = true;\n msg.payload.productionStarted = true;\n msg.payload.machineOnline = true;\n\n return finalize([null, msg, null, null]);\n }\n case \"stop\": {\n state.trackingEnabled = false;\n state.productionStarted = false;\n\n msg._mode = \"production-state\";\n msg.payload = msg.payload || {};\n msg.trackingEnabled = false;\n msg.productionStarted = false;\n msg.machineOnline = true;\n msg.payload.trackingEnabled = false;\n msg.payload.productionStarted = false;\n msg.payload.machineOnline = true;\n\n const s = state.lastState || {};\n s.trackingEnabled = false;\n s.productionStarted = false;\n s.tsMs = Date.now();\n state.lastState = s;\n\n return finalize([null, msg, null, null]);\n }\n case \"start-tracking\": {\n const activeOrder = state.activeWorkOrder || {};\n\n if (!activeOrder.id) {\n return finalize([null, { topic: \"alert\", payload: \"Error: No active work order loaded.\" }, null, null]);\n }\n\n const now = Date.now();\n state.trackingEnabled = true;\n\n state.kpiBuffer = [];\n state.lastKPIRecordTime = now - 60000;\n state.lastMachineCycleTime = now;\n state.lastCycleTime = now;\n state.operatingTime = 0.001;\n\n const stateMsg = {\n _mode: \"production-state\",\n payload: {\n trackingEnabled: true,\n machineOnline: true\n }\n };\n\n return finalize([null, stateMsg, null, null]);\n }\n case \"start-mold-change\": {\n const now = Date.now();\n const p = msg.payload || {};\n const mc = {\n active: true,\n startMs: now,\n endMs: null,\n fromMoldId: p.fromMoldId || null,\n toMoldId: null,\n operator: p.operator || null\n };\n state.moldChange = mc;\n // Disable tracking during mold change so KPI/anomaly are paused\n state.trackingEnabled = false;\n state.productionStarted = false;\n const event = {\n payload: {\n anomaly_type: \"mold-change\",\n eventType: \"mold-change\",\n severity: \"info\",\n requires_ack: false,\n title: \"Cambio de molde iniciado\",\n description: \"Inicio de cambio de molde\",\n status: \"active\",\n tsMs: now,\n data: {\n status: \"active\",\n start_ms: now,\n from_mold_id: mc.fromMoldId,\n operator: mc.operator,\n incidentKey: \"mold-change:\" + now\n }\n },\n tsMs: now\n };\n const uiMsg = { _mode: \"mold-change-status\", payload: { active: true, startMs: now, fromMoldId: mc.fromMoldId } };\n return finalize([null, uiMsg, null, null, null, event]);\n }\n case \"end-mold-change\": {\n const now = Date.now();\n const p = msg.payload || {};\n const mc = state.moldChange || {};\n if (!mc.active) {\n return finalize([null, null, null, null, null, null]);\n }\n const durationSec = Math.max(0, Math.round((now - (mc.startMs || now)) / 1000));\n const closed = {\n active: false,\n startMs: mc.startMs,\n endMs: now,\n fromMoldId: mc.fromMoldId || null,\n toMoldId: p.toMoldId || null,\n durationSec\n };\n state.moldChange = closed;\n const event = {\n payload: {\n anomaly_type: \"mold-change\",\n eventType: \"mold-change\",\n severity: \"info\",\n requires_ack: false,\n title: \"Cambio de molde finalizado\",\n description: \"Duracion: \" + Math.round(durationSec / 60) + \" min\",\n status: \"resolved\",\n tsMs: now,\n data: {\n status: \"resolved\",\n start_ms: closed.startMs,\n end_ms: now,\n duration_sec: durationSec,\n stoppage_duration_seconds: durationSec,\n from_mold_id: closed.fromMoldId,\n to_mold_id: closed.toMoldId,\n incidentKey: \"mold-change:\" + (closed.startMs || now)\n }\n },\n tsMs: now\n };\n const uiMsg = { _mode: \"mold-change-status\", payload: { active: false, endMs: now, durationSec } };\n return finalize([null, uiMsg, null, null, null, event]);\n }\n\n}", + "outputs": 7, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1150, + "y": 680, + "wires": [ + [ + "ff9a2476ccda2ac4" + ], + [ + "a3d41a656eb3b2ce", + "babe66a431cd1760", + "5df5be609ff6e622", + "d098028be97741ba", + "d447044432536eca" + ], + [ + "bfb9b7c7af23bd5c", + "a3d41a656eb3b2ce", + "d098028be97741ba", + "b4a971f8826b7422" + ], + [ + "bfb9b7c7af23bd5c" + ], + [ + "1212f599b9ab36f0" + ], + [ + "bf17a2d4b88f7694" + ], + [ + "c09c71a7e4908231" + ] + ] + }, + { + "id": "ad66f1edaba40aaa", + "type": "link out", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "link out 2", + "mode": "link", + "links": [ + "7b210ef16e508259" + ], + "x": 585, + "y": 300, + "wires": [] + }, + { + "id": "7b210ef16e508259", + "type": "link in", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "link in 2", + "links": [ + "ad66f1edaba40aaa" + ], + "x": 1305, + "y": 540, + "wires": [ + [ + "204c7a49f98a9944" + ] + ] + }, + { + "id": "7b91e5cc70af6ff3", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Build Insert SQL", + "func": "const rows = Array.isArray(msg.payload) ? msg.payload : [];\n\nconst values = rows\n .map((r) => {\n const id = String(r[\"Work Order ID\"] ?? \"\").trim();\n if (!id) return null;\n\n const sku = String(r[\"SKU\"] ?? \"\").trim();\n const targetQty = Number(r[\"Target Quantity\"]) || 0;\n const cycleTime =\n Number(r[\"Theoretical Cycle Time (Seconds)\"]) || 0;\n\n return [id, sku, targetQty, cycleTime, \"PENDING\"];\n })\n .filter(Boolean);\n\nif (!values.length) {\n return null;\n}\n\nmsg.topic = `\n INSERT INTO work_orders\n (work_order_id, sku, target_qty, cycle_time, status)\n VALUES ?\n ON DUPLICATE KEY UPDATE\n sku = VALUES(sku),\n target_qty = VALUES(target_qty),\n cycle_time = VALUES(cycle_time);\n`;\n\nmsg.payload = [values];\n\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1800, + "y": 500, + "wires": [ + [ + "bfb9b7c7af23bd5c" + ] + ] + }, + { + "id": "bfb9b7c7af23bd5c", + "type": "mysql", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "mydb": "fc9634aabefee16b", + "name": "mariaDB", + "x": 1820, + "y": 920, + "wires": [ + [ + "16b778a1349b8102" + ] + ] + }, + { + "id": "7df6eebd9b7c7c7b", + "type": "mysql", + "z": "05d4cb231221b842", + "g": "9221454c45afd1ba", + "mydb": "fc9634aabefee16b", + "name": "mariaDB (Graph Data)", + "x": 1600, + "y": 80, + "wires": [ + [ + "9f929db1f49b6e16" + ] + ] + }, + { + "id": "babe66a431cd1760", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Back to UI", + "func": "const mode = msg._mode || '';\nconst started = msg.startOrder || null;\nconst completed = msg.completeOrder || null;\nconst state = global.get(\"state\") || {};\nconst settings = global.get(\"settings\") || {};\n\n// ✨ La WO de la BD es la única fuente de cavidades.\n// attachMold solo normaliza nombres entre cavities/cavitiesActive y cavities_total/cavitiesTotal\n// para compatibilidad con código que aún lee los nombres viejos.\n// YA NO escribe en state.lastMoldActive ni state.moldByWorkOrder (cache global eliminado).\nconst attachMold = (order) => {\n if (!order || !order.id) return;\n\n const cavities = Number(\n order.cavitiesActive ??\n order.cavities_active ??\n order.cavities ??\n 0\n );\n const total = Number(\n order.cavitiesTotal ??\n order.cavities_total ??\n 0\n );\n\n if (Number.isFinite(cavities) && cavities > 0) {\n order.cavitiesActive = cavities;\n order.cavities = cavities; // alias para código viejo\n }\n if (Number.isFinite(total) && total > 0) {\n order.cavitiesTotal = total;\n order.cavities_total = total; // alias para código viejo\n }\n};\n\nconst finalize = (ret) => {\n global.set(\"state\", state);\n return ret;\n};\n\nconst toIsoString = (v) => {\n if (!v) return null;\n if (typeof v === \"string\") return v;\n if (v instanceof Date) return v.toISOString();\n if (typeof v.toISOString === \"function\") return v.toISOString();\n if (typeof v.toISO === \"function\") return v.toISO();\n if (typeof v.format === \"function\") return v.format();\n return String(v);\n};\n\ndelete msg._mode;\ndelete msg.startOrder;\ndelete msg.completeOrder;\ndelete msg.action;\ndelete msg.filename;\n\n// ========================================================\n// MODE: UPLOAD\n// ========================================================\nif (mode === \"upload\") {\n msg.topic = \"uploadStatus\";\n msg.payload = { message: \"✅ Work orders uploaded successfully.\" };\n return finalize([msg, null, null, null]);\n}\n\n// ========================================================\n// MODE: SELECT (Load Work Orders)\n// ========================================================\nif (mode === \"select\") {\n const rawRows = Array.isArray(msg.payload) ? msg.payload : [];\n msg.topic = \"workOrdersList\";\n msg.payload = rawRows.map(row => ({\n id: row.work_order_id ?? row.id ?? \"\",\n sku: row.sku ?? \"\",\n target: Number(row.target_qty ?? row.target ?? 0),\n goodParts: Number(row.good_parts ?? row.goodParts ?? 0),\n scrapParts: Number(row.scrap_count ?? row.scrap_parts ?? row.scrapParts ?? 0),\n progressPercent: Number(row.progress_percent ?? row.progress ?? 0),\n status: (row.status ?? \"PENDING\").toUpperCase(),\n lastUpdateIso: toIsoString(row.updated_at ?? row.last_update),\n cycleTime: Number(row.cycle_time ?? row.theoretical_cycle_time ?? 0)\n }));\n return finalize([msg, null, null, null]);\n}\n\n// ========================================================\n// MODE: START WORK ORDER\n// ========================================================\nif (mode === \"start\") {\n const order = started || {};\n attachMold(order);\n const kpis = msg.kpis || state.currentKPIs || {\n oee: 0, availability: 0, performance: 0, quality: 0\n };\n\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: {\n id: order.id || \"\",\n sku: order.sku || \"\",\n target: Number(order.target) || 0,\n goodParts: Number(order.goodParts) || 0,\n scrapParts: Number(order.scrapParts) || 0,\n cycleTime: Number(order.cycleTime || order.theoreticalCycleTime || 0),\n progressPercent: Number(order.progressPercent) || 0,\n lastUpdateIso: toIsoString(order.lastUpdateIso) || null,\n kpis: kpis\n }\n };\n\n return finalize([null, homeMsg, null, null]);\n}\n\n// ========================================================\n// MODE: COMPLETE WORK ORDER\n// ========================================================\nif (mode === \"complete\") {\n const homeMsg = { topic: \"activeWorkOrder\", payload: null };\n return finalize([null, homeMsg, null, null]);\n}\n\n// ========================================================\n// MODE: CYCLE UPDATE DURING PRODUCTION\n// ========================================================\nif (mode === \"cycle\") {\n const cycle = msg.cycle || {};\n\n const workOrderMsg = {\n topic: \"workOrderCycle\",\n payload: {\n id: cycle.id || \"\",\n sku: cycle.sku || \"\",\n target: Number(cycle.target) || 0,\n goodParts: Number(cycle.goodParts) || 0,\n scrapParts: Number(cycle.scrapParts) || 0,\n progressPercent: Number(cycle.progressPercent) || 0,\n lastUpdateIso: toIsoString(cycle.lastUpdateIso) || null,\n status: cycle.progressPercent >= 100 ? \"DONE\" : \"RUNNING\"\n }\n };\n\n const kpis = msg.kpis || state.currentKPIs || {\n oee: 0, availability: 0, performance: 0, quality: 0\n };\n\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: {\n id: cycle.id || \"\",\n sku: cycle.sku || \"\",\n target: Number(cycle.target) || 0,\n goodParts: Number(cycle.goodParts) || 0,\n scrapParts: Number(cycle.scrapParts) || 0,\n cycleTime: Number(cycle.cycleTime) || 0,\n progressPercent: Number(cycle.progressPercent) || 0,\n lastUpdateIso: toIsoString(cycle.lastUpdateIso) || new Date().toISOString(),\n kpis: kpis\n }\n };\n\n return finalize([workOrderMsg, homeMsg, null, null]);\n}\n\n// ========================================================\n// MODE: MACHINE PRODUCTION STATE\n// ========================================================\nif (mode === \"production-state\") {\n const homeMsg = {\n topic: \"machineStatus\",\n payload: {\n machineOnline: msg.machineOnline ?? true,\n productionStarted: !!msg.productionStarted,\n trackingEnabled: msg.payload?.trackingEnabled ?? msg.trackingEnabled ?? false\n }\n };\n return finalize([null, homeMsg, null, null]);\n}\n\n// ========================================================\n// MODE: CURRENT STATE (for tab switch sync)\n// ========================================================\nif (mode === \"current-state\") {\n const stateData = msg.payload || {};\n const homeMsg = {\n topic: \"currentState\",\n payload: {\n activeWorkOrder: stateData.activeWorkOrder,\n trackingEnabled: stateData.trackingEnabled,\n productionStarted: stateData.productionStarted,\n kpis: stateData.kpis\n }\n };\n return finalize([null, homeMsg, null, null]);\n}\n\n// ========================================================\n// MODE: RESTORE QUERY (startup state recovery)\n// ========================================================\n// ✨ Cuando arranca Node-RED después de reinicio del Pi, se recupera la WO en RUNNING\n// junto con sus cavidades de la BD. La BD es la fuente de verdad.\nif (mode === \"restore-query\") {\n const rows = Array.isArray(msg.payload) ? msg.payload : [];\n\n if (rows.length > 0) {\n const row = rows[0];\n\n // ✨ Lee cavidades de la BD y las pone con TODOS los aliases (camelCase y snake_case)\n const cavitiesActive = Number(row.cavities_active || 0);\n const cavitiesTotal = Number(row.cavities_total || 0);\n\n const restoredOrder = {\n id: row.work_order_id || row.id || \"\",\n sku: row.sku || \"\",\n target: Number(row.target_qty || row.target || 0),\n goodParts: Number(row.good_parts || row.goodParts || 0),\n scrapParts: Number(row.scrap_parts || row.scrapParts || 0),\n progressPercent: Number(row.progress_percent || 0),\n cycleTime: Number(row.cycle_time || 0),\n lastUpdateIso: toIsoString(row.updated_at ?? row.last_update),\n mold: row.mold || null,\n // ✨ Cavidades con todos los nombres (nuevos + aliases viejos)\n cavitiesActive: cavitiesActive > 0 ? cavitiesActive : null,\n cavities: cavitiesActive > 0 ? cavitiesActive : null,\n cavitiesTotal: cavitiesTotal > 0 ? cavitiesTotal : null,\n cavities_total: cavitiesTotal > 0 ? cavitiesTotal : null\n };\n\n // attachMold normaliza nombres (sin tocar cache global)\n attachMold(restoredOrder);\n\n const restoredCycleCount = Number(row.cycle_count) || 0;\n const restoredGood = Number(row.good_parts || 0);\n const restoredScrap = Number(row.scrap_parts || 0);\n const hasProgress = restoredCycleCount > 0 || (restoredGood + restoredScrap) > 0;\n\n // Restore global state\n state.activeWorkOrder = restoredOrder;\n state.cycleCount = restoredCycleCount;\n state.activeOrderHasProgress = hasProgress;\n // Auto-resume tracking for RUNNING work order\n state.trackingEnabled = true;\n state.productionStarted = true;\n state.kpiStartupMode = !hasProgress;\n\n // Reset tick so KPI integration doesn't jump\n state.kpiLastTick = null;\n\n let restoreTs = Date.parse(row.updated_at || \"\");\n if (!isFinite(restoreTs)) {\n restoreTs = Date.now();\n }\n state.lastMachineCycleTime = restoreTs;\n state.lastCycleCompletionTime = restoreTs;\n\n node.warn('[RESTORE] Restored work order: ' + restoredOrder.id +\n ' with ' + state.cycleCount + ' cycles, cavitiesActive=' + cavitiesActive +\n ', cavitiesTotal=' + cavitiesTotal + ', mold=' + (row.mold || 'null'));\n\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: restoredOrder\n };\n\n const stateMsg = {\n topic: \"machineStatus\",\n payload: {\n machineOnline: true,\n productionStarted: true,\n trackingEnabled: true\n }\n };\n\n // Set status back to RUNNING in database (if not already DONE)\n const dbMsg = {\n topic: \"UPDATE work_orders SET status = 'RUNNING', updated_at = NOW() WHERE work_order_id = ? AND status != 'DONE'\",\n payload: [restoredOrder.id]\n };\n\n return finalize([dbMsg, [homeMsg, stateMsg], null, null]);\n } else {\n node.warn('[RESTORE] No running work order found');\n }\n return finalize([null, null, null, null]);\n}\n\n// ========================================================\n// MODE: SCRAP PROMPT\n// ========================================================\nif (mode === \"scrap-prompt\") {\n const prompt = msg.scrapPrompt || {};\n\n const homeMsg = { topic: \"scrapPrompt\", payload: prompt };\n const tabMsg = { ui_control: { tab: \"Home\" } };\n\n return finalize([null, homeMsg, tabMsg, null]);\n}\n\n// ========================================================\n// MODE: SCRAP UPDATE\n// ========================================================\nif (mode === \"scrap-update\") {\n const activeOrder = state.activeWorkOrder || {};\n const kpis = msg.kpis || state.currentKPIs || {\n oee: 0, availability: 0, performance: 0, quality: 0\n };\n\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: {\n id: activeOrder.id || \"\",\n sku: activeOrder.sku || \"\",\n target: Number(activeOrder.target) || 0,\n goodParts: Number(activeOrder.goodParts) || 0,\n scrapParts: Number(activeOrder.scrapParts) || 0,\n cycleTime: Number(activeOrder.cycleTime) || 0,\n progressPercent: Number(activeOrder.progressPercent) || 0,\n lastUpdateIso: toIsoString(activeOrder.lastUpdateIso) || new Date().toISOString(),\n kpis: kpis\n }\n };\n\n return finalize([null, homeMsg, null, null]);\n}\n\n// ========================================================\n// MODE: SCRAP COMPLETE\n// ========================================================\nif (mode === \"scrap-complete\") {\n const homeMsg = { topic: \"activeWorkOrder\", payload: null };\n return finalize([null, homeMsg, null, null]);\n}\n\n// ========================================================\n// MODE: RESUME-PROMPT\n// ========================================================\n// Cuando el operador selecciona una WO con progreso existente,\n// el Progress Check Handler envía este mode para que la UI muestre\n// el prompt de \"¿reanudar o reiniciar?\"\n// ========================================================\n// MODE: RESUME-PROMPT\n// ========================================================\nif (mode === \"resume-prompt\") {\n const order = msg.payload.order || null;\n\n if (order) {\n attachMold(order);\n\n // ✨ FIX: actualiza el state CONSISTENTEMENTE con la nueva WO\n // - cycleCount sincronizado para que Machine cycles no sume al contador anterior\n // - tracking apagado: el operador debe presionar COMENZAR explícitamente\n // (esto previene que se sumen ciclos a la WO equivocada si el modal no aparece)\n state.activeWorkOrder = order;\n state.cycleCount = Number(order.cycleCount) || 0;\n state.activeOrderHasProgress = true;\n state.trackingEnabled = false;\n state.productionStarted = false;\n state.scrapPromptIssuedFor = null;\n flow.set(\"lastMachineState\", 0);\n // ✨ FIX: sincronizar state.lastState para que el polling cada 500ms\n // del HMI (get-current-state) no devuelva valores viejos al template\n // y reactive el botón DETENER. State Accumulator solo actualiza lastState\n // cuando hay ciclos, así que tenemos que hacerlo aquí explícitamente.\n if (state.lastState && typeof state.lastState === \"object\") {\n state.lastState = {\n ...state.lastState,\n activeWorkOrder: order,\n cycleCount: Number(order.cycleCount) || 0,\n goodParts: Number(order.goodParts) || 0,\n scrapParts: Number(order.scrapParts) || 0,\n trackingEnabled: false,\n productionStarted: false,\n tsMs: Date.now()\n };\n }\n node.warn(`[RESUME-PROMPT] activeWorkOrder=${order.id} cycleCount=${state.cycleCount} tracking=OFF`);\n }\n\n const homeMsg = {\n topic: msg.topic || \"resumePrompt\",\n payload: msg.payload,\n };\n\n const kpis =\n msg.kpis ||\n state.currentKPIs || {\n oee: 0,\n availability: 0,\n performance: 0,\n quality: 0,\n };\n\n const activeMsg = order\n ? {\n topic: \"activeWorkOrder\",\n payload: {\n id: order.id || \"\",\n sku: order.sku || \"\",\n target: Number(order.target) || 0,\n goodParts: Number(order.goodParts) || 0,\n scrapParts: Number(order.scrapParts) || 0,\n cycleTime: Number(\n order.cycleTime ||\n order.theoreticalCycleTime ||\n 0\n ),\n progressPercent: Number(\n msg.payload?.progressPercent ??\n order.progressPercent ??\n 0\n ),\n lastUpdateIso: order.lastUpdateIso || null,\n kpis,\n },\n }\n : null;\n\n // ✨ FIX: también enviar el estado de tracking apagado al HMI\n // para que el botón muestre COMENZAR (no DETENER)\n const machineMsg = {\n topic: \"machineStatus\",\n payload: {\n machineOnline: true,\n productionStarted: false,\n trackingEnabled: false\n }\n };\n\n // ✨ TEST: solo homeMsg para descartar problema de array\n return finalize([\n null,\n activeMsg ? [activeMsg, homeMsg, machineMsg] : [homeMsg, machineMsg],\n null,\n null,\n ]);\n}\n\n// ========================================================\n// MODE: MOLD CHANGE STATUS\n// ========================================================\nif (mode === \"mold-change-status\") {\n const homeMsg = {\n topic: \"moldChangeStatus\",\n payload: msg.payload || {}\n };\n return finalize([null, homeMsg, null, null]);\n}\n\n// ========================================================\n// DEFAULT\n// ========================================================\nreturn finalize([null, null, null, null]);", + "outputs": 4, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 2190, + "y": 560, + "wires": [ + [ + "eb61ec7045cb4fab" + ], + [ + "8d2fcc3bc64141a0" + ], + [ + "7d0b25dedd60803a" + ], + [] + ] + }, + { + "id": "eb61ec7045cb4fab", + "type": "link out", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "link out 3", + "mode": "link", + "links": [ + "5084f1955153578d" + ], + "x": 2305, + "y": 520, + "wires": [] + }, + { + "id": "5084f1955153578d", + "type": "link in", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "link in 3", + "links": [ + "eb61ec7045cb4fab" + ], + "x": 275, + "y": 320, + "wires": [ + [ + "b1a448897989958f" + ] + ] + }, + { + "id": "ca4956aff7158938", + "type": "book", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "", + "raw": false, + "x": 1810, + "y": 460, + "wires": [ + [ + "afb78150ffe54054" + ] + ] + }, + { + "id": "afb78150ffe54054", + "type": "sheet", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "", + "sheetName": "Sheet1", + "x": 1930, + "y": 460, + "wires": [ + [ + "6d19a182b174127e" + ] + ] + }, + { + "id": "6d19a182b174127e", + "type": "sheet-to-json", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "", + "raw": "false", + "range": "", + "header": "default", + "blankrows": false, + "x": 2070, + "y": 460, + "wires": [ + [ + "7b91e5cc70af6ff3" + ] + ] + }, + { + "id": "ff9a2476ccda2ac4", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Base64", + "func": "const filename =\n msg.filename ||\n (msg.meta && msg.meta.filename) ||\n (msg.payload && msg.payload.filename) ||\n msg.name ||\n 'upload.xlsx';\n\nconst candidates = [];\nif (typeof msg.payload === 'string') candidates.push(msg.payload);\nif (msg.payload && typeof msg.payload.payload === 'string') candidates.push(msg.payload.payload);\nif (msg.payload && typeof msg.payload.file === 'string') candidates.push(msg.payload.file);\nif (msg.payload && typeof msg.payload.base64 === 'string') candidates.push(msg.payload.base64);\nif (typeof msg.file === 'string') candidates.push(msg.file);\nif (typeof msg.data === 'string') candidates.push(msg.data);\n\nfunction stripDataUrl(s) {\n return (s && s.startsWith('data:')) ? s.split(',')[1] : s;\n}\n\nlet b64 = candidates.map(stripDataUrl).find(s => typeof s === 'string' && s.length > 0);\nif (!b64 && Buffer.isBuffer(msg.payload)) { msg.filename = filename; return msg; }\nif (!b64) { node.error('No base64 data found on msg', msg); return null; }\n\nmsg.payload = Buffer.from(b64, 'base64');\nmsg.filename = filename;\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1680, + "y": 460, + "wires": [ + [ + "ca4956aff7158938" + ] + ] + }, + { + "id": "8d2fcc3bc64141a0", + "type": "link out", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "link out 4", + "mode": "link", + "links": [ + "5201b1255d81c6a1" + ], + "x": 2305, + "y": 560, + "wires": [] + }, + { + "id": "5201b1255d81c6a1", + "type": "link in", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "link in 4", + "links": [ + "8d2fcc3bc64141a0" + ], + "x": 275, + "y": 280, + "wires": [ + [ + "dbfd127c516efa87" + ] + ] + }, + { + "id": "d569d16fc0423be8", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Refresh Trigger", + "func": "if (msg._mode === \"sync-work-orders\") {\n msg._mode = \"select\";\n msg.topic = \"SELECT * FROM work_orders ORDER BY updated_at DESC;\";\n return [msg, null];\n}\nif (msg._mode === \"start\" || msg._mode === \"complete\" || msg._mode === \"resume\" || msg._mode === \"restart\") {\n // Preserve original message for Back to UI (output 2)\n const originalMsg = { ...msg };\n\n // ✨ FIX: Si el msg trae un UPDATE/INSERT/DELETE en topic, ejecutarlo PRIMERO,\n // y después disparar el SELECT para refrescar la tabla. Antes se descartaba el UPDATE.\n const incomingTopic = msg.topic || \"\";\n const isWriteQuery = /^\\s*(UPDATE|INSERT|DELETE)/i.test(incomingTopic);\n\n if (isWriteQuery) {\n // 1) Mandar el UPDATE/INSERT/DELETE original a MariaDB\n node.send([{ ...msg }, null]);\n\n // 2) Después, disparar el SELECT para refrescar\n const selectMsg = {\n _mode: \"select\",\n topic: \"SELECT * FROM work_orders ORDER BY updated_at DESC;\",\n payload: []\n };\n return [selectMsg, originalMsg];\n }\n\n // Comportamiento original: solo disparar el SELECT\n msg._mode = \"select\";\n msg.topic = \"SELECT * FROM work_orders ORDER BY updated_at DESC;\";\n return [msg, originalMsg];\n}\nif (msg._mode === \"cycle\" || msg._mode === \"production-state\") {\n return [null, msg];\n}\nif (msg._mode === \"scrap-prompt\") {\n return [null, msg];\n}\nif (msg._mode === \"restore-query\") {\n // Pass restore query results to Back to UI\n return [null, msg];\n}\nif (msg._mode === \"current-state\") {\n // Pass current state to Back to UI\n return [null, msg];\n}\nif (msg._mode === \"scrap-complete\") {\n // Preserve original message for Back to UI (output 2)\n const originalMsg = {...msg};\n // Create select message for refreshing WO table (output 1)\n msg._mode = \"select\";\n msg.topic = \"SELECT * FROM work_orders ORDER BY updated_at DESC;\";\n return [msg, originalMsg];\n}\nreturn [null, msg];", + "outputs": 2, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1960, + "y": 600, + "wires": [ + [ + "bfb9b7c7af23bd5c" + ], + [ + "babe66a431cd1760" + ] + ] + }, + { + "id": "093d9631fbd43003", + "type": "function", + "z": "05d4cb231221b842", + "g": "6e514144a570aa72", + "name": "Machine cycles", + "func": "const current = Number(msg.payload) || 0;\nconst now = Date.now();\n\nconst state = global.get(\"state\") || {};\nconst settings = global.get(\"settings\") || {};\nconst saveState = () => global.set(\"state\", state);\nconst finalize = (ret) => { saveState(); return ret; };\n\nlet zeroStreak = flow.get(\"zeroStreak\") || 0;\nzeroStreak = current === 0 ? zeroStreak + 1 : 0;\nflow.set(\"zeroStreak\", zeroStreak);\n\nconst prev = flow.get(\"lastMachineState\") ?? 0;\n\nconst activeOrder = state.activeWorkOrder;\nconst trackingEnabled = !!state.trackingEnabled;\n\n// ALWAYS update state tracking (before any early returns)\nstate.lastStateChangeTime = now;\nflow.set(\"lastMachineState\", current);\n\n// =============================================\n// MACHINE ONLINE STATUS\n// =============================================\nstate.machineOnline = true;\n\nlet productionRunning = !!state.productionStarted;\nlet stateChanged = false;\n\nif (current === 1 && !productionRunning) {\n productionRunning = true;\n stateChanged = true;\n} else if (current === 0 && zeroStreak >= 2 && productionRunning) {\n productionRunning = false;\n stateChanged = true;\n}\n\nstate.productionStarted = productionRunning;\n\nconst stateMsg = stateChanged\n ? {\n _mode: \"production-state\",\n machineOnline: true,\n productionStarted: productionRunning\n }\n : null;\n\n// =============================================\n// EARLY EXIT CONDITIONS\n// =============================================\nconst cavities = Number(\n activeOrder?.cavitiesActive ??\n activeOrder?.cavities ??\n 0\n);\n\nif (!activeOrder || !activeOrder.id || !Number.isFinite(cavities) || cavities <= 0) {\n return finalize([null, stateMsg, { _triggerKPI: true }, null]);\n}\n\nactiveOrder.cavities = cavities;\nactiveOrder.cavitiesActive = cavities;\n\nif (!trackingEnabled) {\n return finalize([null, stateMsg, { _triggerKPI: true }, null]);\n}\n\n// Only count cycles on rising edge (0→1)\nif (prev === 1 || current !== 1) {\n return finalize([null, stateMsg, { _triggerKPI: true }, null]);\n}\n\n// =============================================\n// DEBOUNCE: ignorar rebotes del sensor\n// Si pasaron menos del 30% del cycle_time esperado desde el último ciclo,\n// es un rebote del sensor — no contamos.\n// =============================================\nconst expectedCycleTimeSec = Number(activeOrder.cycleTime || 0);\nconst lastCompletion = state.lastCycleCompletionTime;\nif (lastCompletion && expectedCycleTimeSec > 0) {\n const elapsedSec = (now - lastCompletion) / 1000;\n const minIntervalSec = expectedCycleTimeSec * 0.3;\n if (elapsedSec < minIntervalSec) {\n node.warn(`[DEBOUNCE] Ciclo ignorado (rebote): ${elapsedSec.toFixed(2)}s desde último ciclo, mínimo ${minIntervalSec.toFixed(1)}s. wo=${activeOrder.id}`);\n return finalize([null, stateMsg, { _triggerKPI: true }, null]);\n }\n}\n\n// =============================================\n// CYCLE COUNTING\n// =============================================\nif (lastCompletion) {\n const actualCycleTime = (now - lastCompletion) / 1000;\n state.lastActualCycleTime = actualCycleTime;\n}\n\nlet cycles = Number(state.cycleCount || 0) + 1;\nstate.cycleCount = cycles;\nstate.lastCycleCompletionTime = now;\n\nif (state.kpiStartupMode) {\n state.kpiStartupMode = false;\n node.warn('[MACHINE CYCLE] First cycle - cleared kpiStartupMode');\n}\n\nstate.lastMachineCycleTime = now;\nstate.saveKpis = 1;\n\n// =============================================\n// PRODUCTION METRICS\n// =============================================\nconst scrapTotal = Number(activeOrder.scrapParts) || 0;\nconst totalProduced = cycles * cavities;\nconst produced = Math.max(0, totalProduced - scrapTotal);\nconst target = Number(activeOrder.target) || 0;\nconst progress = target > 0 ? Math.min(100, Math.round((produced / target) * 100)) : 0;\n\nactiveOrder.goodParts = produced;\nactiveOrder.progressPercent = progress;\nactiveOrder.lastUpdateIso = new Date().toISOString();\nstate.activeWorkOrder = activeOrder;\n\n// =============================================\n// SCRAP PROMPT CHECK\n// =============================================\nconst promptIssued = state.scrapPromptIssuedFor || null;\nif (!promptIssued && target > 0 && produced >= target) {\n state.scrapPromptIssuedFor = activeOrder.id;\n msg._mode = \"scrap-prompt\";\n msg.scrapPrompt = {\n id: activeOrder.id,\n sku: activeOrder.sku || \"\",\n target,\n produced\n };\n return finalize([null, msg, null, null]);\n}\n\n// =============================================\n// DATABASE UPDATE\n// =============================================\nconst dbMsg = {\n _mode: \"cycle\",\n cycle: {\n id: activeOrder.id,\n sku: activeOrder.sku || \"\",\n target,\n goodParts: produced,\n scrapParts: scrapTotal,\n cycleTime: Number(activeOrder.cycleTime || activeOrder.theoreticalCycleTime || 0),\n progressPercent: progress,\n lastUpdateIso: activeOrder.lastUpdateIso,\n machineOnline: true,\n productionStarted: productionRunning\n },\n topic: \"UPDATE work_orders SET good_parts = ?, progress_percent = ?, updated_at = NOW() WHERE work_order_id = ?\",\n payload: [produced, progress, activeOrder.id]\n};\n\nconst kpiTrigger = { _triggerKPI: true };\n\n// ✨ Persistir cycle_count en cada ciclo (antes solo se persistía good_parts)\n// Esto evita perder ciclos si reinicia Node-RED\nconst persistWorkOrder = {\n topic: \"UPDATE work_orders SET cycle_count = ?, good_parts = ?, scrap_parts = ?, progress_percent = ?, updated_at = NOW() WHERE work_order_id = ?\",\n payload: [cycles, produced, scrapTotal, progress, activeOrder.id]\n};\n\ndbMsg.cycleRow = {\n tsMs: now,\n cycle_count: cycles,\n actual_cycle_time: Number(state.lastActualCycleTime || 0),\n theoretical_cycle_time: Number(activeOrder.cycleTime || 0),\n work_order_id: String(activeOrder.id),\n sku: String(activeOrder.sku || \"\"),\n cavities: cavities,\n good_delta: cavities,\n scrap_total: scrapTotal,\n};\n\nreturn finalize([dbMsg, stateMsg, kpiTrigger, persistWorkOrder]);", + "outputs": 4, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1860, + "y": 220, + "wires": [ + [ + "8ebf807b7c65eb42", + "3c78c68f64843c22" + ], + [ + "d7fce9cf6a8c8f9b", + "ed8c202dde4b6ff9" + ], + [], + [ + "d45345c05485d114" + ] + ] + }, + { + "id": "8ebf807b7c65eb42", + "type": "link out", + "z": "05d4cb231221b842", + "g": "6e514144a570aa72", + "name": "link out 5", + "mode": "link", + "links": [ + "5f1b67667a0c39f4" + ], + "x": 2015, + "y": 200, + "wires": [] + }, + { + "id": "5f1b67667a0c39f4", + "type": "link in", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "link in 5", + "links": [ + "8ebf807b7c65eb42" + ], + "x": 1525, + "y": 480, + "wires": [ + [ + "bfb9b7c7af23bd5c" + ] + ] + }, + { + "id": "d7fce9cf6a8c8f9b", + "type": "link out", + "z": "05d4cb231221b842", + "g": "6e514144a570aa72", + "name": "link out 6", + "mode": "link", + "links": [ + "1f26139ec9079c2d" + ], + "x": 2035, + "y": 240, + "wires": [] + }, + { + "id": "1f26139ec9079c2d", + "type": "link in", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "link in 6", + "links": [ + "d7fce9cf6a8c8f9b" + ], + "x": 1755, + "y": 580, + "wires": [ + [ + "d569d16fc0423be8" + ] + ] + }, + { + "id": "7d0b25dedd60803a", + "type": "link out", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "link out 7", + "mode": "link", + "links": [ + "01705d77724ccc51" + ], + "x": 2305, + "y": 600, + "wires": [] + }, + { + "id": "01705d77724ccc51", + "type": "link in", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "link in 7", + "links": [ + "7d0b25dedd60803a" + ], + "x": 535, + "y": 420, + "wires": [ + [ + "44d2ce4b810b508b" + ] + ] + }, + { + "id": "4101967d16ba7f8b", + "type": "link out", + "z": "05d4cb231221b842", + "g": "878b79013722e91f", + "name": "link out 8", + "mode": "link", + "links": [ + "6b3c45059b9b7c6c" + ], + "x": 955, + "y": 660, + "wires": [] + }, + { + "id": "6b3c45059b9b7c6c", + "type": "link in", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "link in 8", + "links": [ + "4101967d16ba7f8b", + "2c8562b2471078ab" + ], + "x": 275, + "y": 480, + "wires": [ + [ + "afb514404a6ecda1" + ] + ] + }, + { + "id": "a3d41a656eb3b2ce", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Calculate KPIs", + "func": "// ========================================\n// KPI HEARTBEAT - Continuous OEE calculation\n// Runs every second from a dedicated inject node\n// ========================================\n//\n// Definiciones (estándar OEE):\n// Planned Production Time (PPT) = tiempo del turno transcurrido\n// - lunch y compensación (prorrateados)\n// Run Time = PPT transcurrido - downtime no planeado\n// Availability = Run Time / PPT transcurrido × 100\n// Performance = (idealCT × cycles) / runSeconds × 100\n// Quality = goodParts / totalParts × 100\n// OEE = Availability × Performance × Quality\n//\n// Cambios vs versión anterior:\n// - Availability ahora usa PPT TRANSCURRIDO del turno actual (no jornada completa)\n// - Performance ahora penaliza microstops (usa runSeconds, no perf.runSeconds)\n// - Cavidades solo se leen de activeOrder (sin caches globales obsoletos)\n// ========================================\n\nconst state = global.get(\"state\") || {};\nconst settings = global.get(\"settings\") || {};\n\n// 1) Basic guards: only run when tracking an active work order\nconst trackingEnabled = state.trackingEnabled || false;\nconst activeOrder = state.activeWorkOrder || {};\nif (!trackingEnabled || !activeOrder.id) {\n return null;\n}\n\n// Detect work-order change and restore/reset KPI counters\nconst currentId = activeOrder.id;\nif (state.kpiOrderId !== currentId) {\n const byOrder = state.kpiByWorkOrder || {};\n const saved = byOrder[currentId];\n\n if (saved && state.activeOrderHasProgress) {\n state.productionStartTime = saved.productionStartTime;\n state.runSeconds = saved.runSeconds;\n state.stopSeconds = saved.stopSeconds;\n state.kpiStartupMode = !!saved.kpiStartupMode;\n state.kpiLastTick = null; // avoid huge dt jump\n // ✨ P1 adaptado: restaurar baseline de Performance si existía\n state.kpiSessionStartCycles = (saved.kpiSessionStartCycles != null)\n ? saved.kpiSessionStartCycles\n : null;\n } else {\n state.productionStartTime = Date.now();\n state.runSeconds = 0;\n state.stopSeconds = 0;\n state.kpiStartupMode = true;\n state.kpiLastTick = null;\n // ✨ Sin saved: el baseline se captura en el primer tick\n state.kpiSessionStartCycles = null;\n }\n\n state.kpiOrderId = currentId;\n}\n\n// Optional startup mode: keep at zero until first real cycle\nlet kpiStartupMode = !!state.kpiStartupMode;\n\nif (kpiStartupMode && Number(state.cycleCount || 0) > 0) {\n state.kpiStartupMode = false;\n kpiStartupMode = false;\n}\n\n// 2) Time step (dt) since last KPI tick\nconst now = Date.now();\nlet lastTick = state.kpiLastTick;\nlet dt;\nif (!lastTick) {\n dt = 0;\n} else {\n dt = (now - lastTick) / 1000;\n}\nstate.kpiLastTick = now;\n\n// Clamp weird dt values (e.g. after long pause)\nif (dt < 0 || dt > 5) {\n dt = 1;\n}\n\nlet productionStartTime = state.productionStartTime;\nif (!productionStartTime) {\n productionStartTime = now;\n state.productionStartTime = productionStartTime;\n}\n\n// 3) Determine machine state from last cycle timestamp\nconst lastCycleTime = state.lastMachineCycleTime || null;\nconst idealCycleTime = Number(activeOrder.cycleTime) || 1;\nconst thresholdMultiplier = Number(settings.thresholdMultiplier || 1.5);\nconst stopThreshold = idealCycleTime * thresholdMultiplier;\n\nlet machineState = state.machineState || \"IDLE\";\nif (lastCycleTime) {\n const sinceLastCycle = (now - lastCycleTime) / 1000;\n machineState = (sinceLastCycle <= stopThreshold) ? \"RUNNING\" : \"STOPPED\";\n state.machineState = machineState;\n\n if (kpiStartupMode && sinceLastCycle > stopThreshold) {\n state.kpiStartupMode = false;\n kpiStartupMode = false;\n }\n}\n\n// 4) Integrate run / stop time (numerators)\nlet runSeconds = Number(state.runSeconds || 0);\nlet stopSeconds = Number(state.stopSeconds || 0);\n\nif (!kpiStartupMode && dt > 0) {\n if (machineState === \"RUNNING\") {\n runSeconds += dt;\n } else if (machineState === \"STOPPED\") {\n stopSeconds += dt;\n }\n}\n\nstate.runSeconds = runSeconds;\nstate.stopSeconds = stopSeconds;\n\n// ========================================\n// 5) AVAILABILITY (NUEVO: por turno con PPT transcurrido prorrateado)\n// ========================================\nconst shifts = settings.shifts || [{ start: '06:00', end: '15:00' }];\nconst shiftChangeComp = Number(settings.shiftChangeCompensation ?? 10);\nconst lunchBreak = Number(settings.lunchBreakMinutes ?? 30);\n\nfunction hmToMinutes(hm) {\n const parts = String(hm || '00:00').split(':').map(Number);\n return (parts[0] || 0) * 60 + (parts[1] || 0);\n}\n\n// Encontrar el turno que contiene `now` y devolver { startMs, endMs }\nfunction findCurrentShift(nowMs) {\n const d = new Date(nowMs);\n const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0).getTime();\n const ONE_DAY_MS = 24 * 60 * 60000;\n\n // Probamos primero los turnos que empiezan HOY, después los que empezaron AYER\n // (necesario para turnos overnight: ej. turno 23:00-06:00 a las 00:45 AM\n // pertenece al turno que empezó AYER a las 23:00)\n for (const offsetDays of [0, -1]) {\n const baseDay = dayStart + offsetDays * ONE_DAY_MS;\n for (const s of shifts) {\n const startM = hmToMinutes(s.start);\n const endM = hmToMinutes(s.end);\n let start = baseDay + startM * 60000;\n let end = baseDay + endM * 60000;\n if (end <= start) end += ONE_DAY_MS; // overnight shift cruza medianoche\n if (nowMs >= start && nowMs <= end) {\n return { startMs: start, endMs: end };\n }\n }\n }\n // Fuera de cualquier turno definido (no debería pasar si shifts cubren 24h)\n return null;\n}\n\nconst currentShift = findCurrentShift(now);\n\n// PPT transcurrido prorrateado dentro del turno actual\nlet pptElapsed = 0;\nlet shiftElapsedSec = 0;\n\nif (currentShift) {\n shiftElapsedSec = Math.max(0, (now - currentShift.startMs) / 1000);\n const shiftTotalSec = (currentShift.endMs - currentShift.startMs) / 1000;\n // PPT del turno completo = total - lunch - compensación\n const pptTotalSec = Math.max(0, shiftTotalSec - (shiftChangeComp * 60) - (lunchBreak * 60));\n // Prorrateado: PPT_transcurrido = elapsed × (PPT_total / shift_total)\n if (shiftTotalSec > 0) {\n pptElapsed = shiftElapsedSec * (pptTotalSec / shiftTotalSec);\n }\n}\n\n// runSecondsInShift: solo el runSeconds que cae dentro del turno actual\n// Reseteamos al cambiar de turno\nconst shiftKey = currentShift\n ? `${currentShift.startMs}`\n : 'no-shift';\n\nif (state.availShiftKey !== shiftKey) {\n state.availShiftKey = shiftKey;\n state.runSecondsInShift = 0;\n state.shiftStartTrackingTime = now;\n}\n\n// Acumular runSeconds del turno solo si estamos en turno y la máquina está corriendo\nif (currentShift && !kpiStartupMode && dt > 0 && machineState === \"RUNNING\") {\n state.runSecondsInShift = Number(state.runSecondsInShift || 0) + dt;\n}\n\n// Availability = runSecondsInShift / pptElapsed × 100\nlet availabilityPct = 0;\nif (pptElapsed > 0) {\n availabilityPct = (Number(state.runSecondsInShift || 0) / pptElapsed) * 100;\n}\n\n// ========================================\n// 6) Pull counters for Performance & Quality\n// ========================================\nconst cycleCount = Number(state.cycleCount || 0);\n\n// ✨ Cavidades: SOLO de activeOrder (sin caches globales)\nconst cavities = Number(\n activeOrder.cavitiesActive ??\n activeOrder.cavities ??\n 0\n);\n\nconst totalParts = cycleCount * cavities;\nconst scrapTotal = Number(activeOrder.scrapParts) || 0;\nconst goodParts = Math.max(0, totalParts - scrapTotal);\n\n// ========================================\n// 7) Compute KPIs\n// ========================================\n\nfunction clampPercent(v) {\n if (!isFinite(v) || isNaN(v)) return 0;\n return Math.max(0, Math.min(100, v));\n}\n\nlet availability = 0;\nlet performance = 0;\nlet quality = 100;\nlet oee = 0;\n\nif (kpiStartupMode) {\n global.set(\"state\", state);\n return null;\n}\n\n// Availability: ya calculado arriba como availabilityPct\navailability = availabilityPct;\n\n// ========================================\n// Performance = (idealCT × sessionCycles) / runSeconds × 100\n// ========================================\n// IMPORTANTE: comparamos solo los ciclos hechos EN ESTA SESIÓN de tracking\n// contra el runSeconds DE ESTA SESIÓN. Si la WO ya venía con ciclos de antes,\n// no los contamos (sus tiempos no se midieron en esta sesión).\n//\n// También requerimos que haya pasado al menos un cycleTime de runSeconds\n// para evitar que el primer ciclo dispare 100% (división por casi-cero).\nif (state.kpiSessionStartCycles == null) {\n // Primer tick de esta sesión: capturar el cycleCount inicial\n state.kpiSessionStartCycles = cycleCount;\n}\nconst sessionCycles = Math.max(0, cycleCount - state.kpiSessionStartCycles);\n\nif (runSeconds >= idealCycleTime && sessionCycles > 0) {\n const idealTime = idealCycleTime * sessionCycles;\n performance = (idealTime / runSeconds) * 100;\n}\n\n// Quality = Good Parts / Total Parts × 100\nif (totalParts > 0) {\n quality = (goodParts / totalParts) * 100;\n}\n\navailability = clampPercent(availability);\nperformance = clampPercent(performance);\nquality = clampPercent(quality);\n\n// OEE = A × P × Q\noee = (availability * performance * quality) / 10000;\noee = clampPercent(oee);\n\nmsg.kpis = {\n availability: Math.round(availability * 10) / 10,\n performance: Math.round(performance * 10) / 10,\n quality: Math.round(quality * 10) / 10,\n oee: Math.round(oee * 10) / 10\n};\n\nstate.currentKPIs = msg.kpis;\nstate.kpiByWorkOrder = state.kpiByWorkOrder || {};\nstate.kpiByWorkOrder[activeOrder.id] = {\n productionStartTime: state.productionStartTime,\n runSeconds: state.runSeconds,\n stopSeconds: state.stopSeconds,\n kpiStartupMode: state.kpiStartupMode,\n kpiLastTick: state.kpiLastTick,\n kpiSessionStartCycles: state.kpiSessionStartCycles // ✨ P1 adaptado\n};\nglobal.set(\"state\", state);\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1380, + "y": 680, + "wires": [ + [ + "d569d16fc0423be8", + "bb14a0293698c0e0", + "68709c57900ed80e", + "dcaa582c9b2277ba", + "79e027bf3befb2d9" + ] + ] + }, + { + "id": "4a62662fea532976", + "type": "function", + "z": "05d4cb231221b842", + "g": "443b758222662fdf", + "name": "Process Alert for DB", + "func": "// Process incoming alert\nif (msg.payload && msg.payload.action === 'alert') {\n const alert = msg.payload;\n\n const tsMs = typeof alert.tsMs === \"number\" ? alert.tsMs : Date.now();\n const timestamp = new Date(tsMs).toISOString().slice(0, 19).replace('T', ' ');\n\n // Prepare INSERT query\n const alertType = (alert.type || 'Unknown').replace(/'/g, \"''\"); // Escape quotes\n const description = (alert.description || '').replace(/'/g, \"''\"); // Escape quotes\n\n msg.topic = `\n INSERT INTO alerts_log (timestamp, alert_type, description)\n VALUES ('${timestamp}', '${alertType}', '${description}')\n `;\n\n node.status({\n fill: 'green',\n shape: 'dot',\n text: `Logging: ${alertType}`\n });\n\n // Store original message for passthrough\n msg._originalAlert = alert;\n\n return msg;\n}\n\nreturn null;", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 440, + "y": 860, + "wires": [ + [ + "6e76f33c5b84574a" + ] + ] + }, + { + "id": "6e76f33c5b84574a", + "type": "mysql", + "z": "05d4cb231221b842", + "g": "443b758222662fdf", + "mydb": "fc9634aabefee16b", + "name": "Log Alert to DB", + "x": 640, + "y": 860, + "wires": [ + [] + ] + }, + { + "id": "87ed4eae56c5ef99", + "type": "link in", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "link in 9", + "links": [ + "6de9cd8265d1e821", + "cc31f7b315638ba5" + ], + "x": 275, + "y": 400, + "wires": [ + [ + "765441c17d3d41b6" + ] + ] + }, + { + "id": "6de9cd8265d1e821", + "type": "link out", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "link out 9", + "mode": "link", + "links": [ + "87ed4eae56c5ef99" + ], + "x": 1935, + "y": 660, + "wires": [] + }, + { + "id": "bb14a0293698c0e0", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Record KPI History", + "func": "// Complete Record KPI History function with robust initialization and averaging\n\nconst state = global.get(\"state\") || {};\nconst saveState = () => global.set(\"state\", state);\n\n// ========== INITIALIZATION ==========\n// Initialize buffer\nlet buffer = state.kpiBuffer;\nif (!buffer || !Array.isArray(buffer)) {\n buffer = [];\n state.kpiBuffer = buffer;\n node.warn('[KPI History] Initialized kpiBuffer');\n}\n\n// Initialize last record time\nlet lastRecordTime = state.lastKPIRecordTime;\nif (!lastRecordTime || typeof lastRecordTime !== 'number') {\n // Set to 1 minute ago to ensure immediate recording on startup\n lastRecordTime = Date.now() - 60000;\n state.lastKPIRecordTime = lastRecordTime;\n node.warn('[KPI History] Initialized lastKPIRecordTime');\n}\n\n// ========== ACCUMULATE ==========\nconst kpis = msg.payload?.kpis || msg.kpis;\nif (!kpis) {\n node.warn('[KPI History] No KPIs in message, skipping');\n saveState();\n return null;\n}\n\nbuffer.push({\n tsMs: Date.now(),\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n});\n\n// Prevent buffer from growing too large (safety limit)\nif (buffer.length > 100) {\n buffer = buffer.slice(-60); // Keep last 60 entries\n node.warn('[KPI History] Buffer exceeded 100 entries, trimmed to 60');\n}\n\nstate.kpiBuffer = buffer;\nsaveState();\n\n// ========== CHECK IF TIME TO RECORD ==========\nconst now = Date.now();\nconst timeSinceLastRecord = now - lastRecordTime;\nconst ONE_MINUTE = 60 * 1000;\n\nif (timeSinceLastRecord < ONE_MINUTE) {\n // Not time to record yet\n return null; // Don't send to charts yet\n}\n\n// ========== CALCULATE AVERAGES ==========\nif (buffer.length === 0) {\n node.warn('[KPI History] Buffer empty at recording time, skipping');\n return null;\n}\n\nconst avg = {\n oee: buffer.reduce((sum, d) => sum + d.oee, 0) / buffer.length,\n availability: buffer.reduce((sum, d) => sum + d.availability, 0) / buffer.length,\n performance: buffer.reduce((sum, d) => sum + d.performance, 0) / buffer.length,\n quality: buffer.reduce((sum, d) => sum + d.quality, 0) / buffer.length\n};\n\nnode.warn(`[KPI History] Recording averaged KPIs from ${buffer.length} samples: OEE=${avg.oee.toFixed(1)}%`);\n\n// ========== RECORD TO HISTORY ==========\n// Load history arrays\nlet oeeHist = state.realOEE || [];\nlet availHist = state.realAvailability || [];\nlet perfHist = state.realPerformance || [];\nlet qualHist = state.realQuality || [];\n\n// Append averaged values\noeeHist.push({ tsMs: now, value: Math.round(avg.oee * 10) / 10 });\navailHist.push({ tsMs: now, value: Math.round(avg.availability * 10) / 10 });\nperfHist.push({ tsMs: now, value: Math.round(avg.performance * 10) / 10 });\nqualHist.push({ tsMs: now, value: Math.round(avg.quality * 10) / 10 });\n\n// Trim arrays (avoid memory explosion)\noeeHist = oeeHist.slice(-300);\navailHist = availHist.slice(-300);\nperfHist = perfHist.slice(-300);\nqualHist = qualHist.slice(-300);\n\n// Save\nstate.realOEE = oeeHist;\nstate.realAvailability = availHist;\nstate.realPerformance = perfHist;\nstate.realQuality = qualHist;\n\n// Update state\nstate.lastKPIRecordTime = now;\nstate.kpiBuffer = []; // Clear buffer\nsaveState();\n\n// Send to graphs\nreturn {\n topic: \"chartsData\",\n payload: {\n oee: oeeHist,\n availability: availHist,\n performance: perfHist,\n quality: qualHist\n }\n};\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1750, + "y": 660, + "wires": [ + [ + "6de9cd8265d1e821" + ] + ] + }, + { + "id": "fbed0d5d49b02e4c", + "type": "inject", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "Init on Deploy", + "props": [ + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 380, + "y": 220, + "wires": [ + [ + "e6d76d15a304de1a" + ] + ] + }, + { + "id": "e6d76d15a304de1a", + "type": "function", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "Initialize Global Variables", + "func": "node.warn(\"[INIT] Initializing global variables\");\n\nconst settings = global.get(\"settings\") || {};\nconst state = global.get(\"state\") || {};\n\nlet fileSettings = null;\ntry {\n fileSettings = global.get(\"settings\", \"file\");\n} catch (err) {\n fileSettings = null;\n}\nif (fileSettings && typeof fileSettings === \"object\") {\n // ✨ Eliminado: import de moldTotal/moldActive (ya no se usa).\n // Las cavidades vienen ÚNICAMENTE de la WO de la BD.\n if (!Array.isArray(settings.shifts) && Array.isArray(fileSettings.shifts)) {\n settings.shifts = fileSettings.shifts;\n }\n if (settings.shiftChangeCompensation == null && fileSettings.shiftChangeCompensation != null) {\n settings.shiftChangeCompensation = fileSettings.shiftChangeCompensation;\n }\n if (settings.lunchBreakMinutes == null && fileSettings.lunchBreakMinutes != null) {\n settings.lunchBreakMinutes = fileSettings.lunchBreakMinutes;\n }\n if (settings.thresholdMultiplier == null && fileSettings.thresholdMultiplier != null) {\n settings.thresholdMultiplier = fileSettings.thresholdMultiplier;\n }\n}\n\n// ✨ Eliminado: lectura del moldCache (cache global viejo).\n// Limpiar variables del cache viejo del state si quedaron de versiones previas.\ndelete state.lastMoldActive;\ndelete state.lastMoldTotal;\ndelete state.moldByWorkOrder;\n\n// KPI Buffer for averaging\nif (!Array.isArray(state.kpiBuffer)) {\n state.kpiBuffer = [];\n node.warn(\"[INIT] Set state.kpiBuffer to []\");\n}\n\n// Last KPI record time - set to 1 min ago for immediate first record\nif (typeof state.lastKPIRecordTime !== \"number\") {\n state.lastKPIRecordTime = Date.now() - 60000;\n node.warn(\"[INIT] Set state.lastKPIRecordTime\");\n}\n\n// Last machine cycle time - set to now to prevent immediate 0% availability\nif (typeof state.lastMachineCycleTime !== \"number\") {\n state.lastMachineCycleTime = Date.now();\n node.warn(\"[INIT] Set state.lastMachineCycleTime to prevent 0% availability on startup\");\n}\n\n// Last KPI values\nif (!state.lastKPIValues || typeof state.lastKPIValues !== \"object\") {\n state.lastKPIValues = {};\n node.warn(\"[INIT] Set state.lastKPIValues to {}\");\n}\n\n// KPI Startup Mode - ensure clean state on deploy\nstate.kpiStartupMode = false;\nnode.warn(\"[INIT] Set state.kpiStartupMode to false\");\n\n// Tracking flags - ensure clean state\nif (typeof state.trackingEnabled !== \"boolean\") {\n state.trackingEnabled = false;\n}\nif (typeof state.productionStarted !== \"boolean\") {\n state.productionStarted = false;\n}\n\n// Settings defaults\nif (!Array.isArray(settings.shifts) || settings.shifts.length === 0) {\n settings.shifts = [{ start: \"06:00\", end: \"15:00\" }];\n node.warn(\"[INIT] Set default shift: 06:00-15:00\");\n}\n\nif (typeof settings.shiftChangeCompensation !== \"number\") {\n settings.shiftChangeCompensation = 10;\n}\n\nif (typeof settings.lunchBreakMinutes !== \"number\") {\n settings.lunchBreakMinutes = 30;\n}\n\nif (typeof settings.thresholdMultiplier !== \"number\") {\n settings.thresholdMultiplier = 1.5;\n}\n\n// State defaults\nif (typeof state.operatingTime !== \"number\") {\n state.operatingTime = 0;\n}\n\nif (typeof state.stopTime !== \"number\") {\n state.stopTime = 0;\n}\n\nif (typeof state.plannedProductionTime !== \"number\") {\n state.plannedProductionTime = 0;\n}\n\nif (!Object.prototype.hasOwnProperty.call(state, \"lastCycleCompletionTime\")) {\n state.lastCycleCompletionTime = null;\n}\n\nglobal.set(\"settings\", settings);\ntry {\n global.set(\"settings\", settings, \"file\");\n} catch (err) {\n // ignore if file store is not configured\n}\nglobal.set(\"state\", state);\n\n// ✨ Borra el moldCache de disco (variable obsoleta)\ntry {\n global.set(\"moldCache\", null, \"file\");\n} catch (err) {\n // ignore\n}\nglobal.set(\"moldCache\", null);\n\nnode.warn(\"[INIT] Global variable initialization complete\");\n\n// Trigger restore-session to check for running work orders\nconst restoreMsg = { action: \"restore-session\" };\nreturn [null, restoreMsg];", + "outputs": 2, + "timeout": "", + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 630, + "y": 220, + "wires": [ + [], + [ + "ad66f1edaba40aaa" + ] + ] + }, + { + "id": "d45345c05485d114", + "type": "switch", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "DB Guard (Cycles)", + "property": "topic", + "propertyType": "msg", + "rules": [ + { + "t": "istype", + "v": "string", + "vt": "string" + } + ], + "checkall": "true", + "repair": false, + "outputs": 1, + "x": 1690, + "y": 380, + "wires": [ + [ + "bfb9b7c7af23bd5c" + ] + ] + }, + { + "id": "cc31f7b315638ba5", + "type": "link out", + "z": "05d4cb231221b842", + "g": "9221454c45afd1ba", + "name": "link out 10", + "mode": "link", + "links": [ + "87ed4eae56c5ef99" + ], + "x": 1955, + "y": 80, + "wires": [] + }, + { + "id": "16b778a1349b8102", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Progress Check Handler", + "func": "// Handle DB result from start-work-order progress check\nconst state = global.get(\"state\") || {};\n\nconst persistState = () => {\n global.set(\"state\", state);\n};\n\nif (msg._mode === \"start-check-progress\") {\n node.warn(`[PROGRESS-DEBUG] Entered. msg._mode=${msg._mode}, has dbRow=${Array.isArray(msg.payload) && msg.payload.length > 0}`);\n const order = flow.get(\"pendingWorkOrder\");\n\n if (!order || !order.id) {\n node.error(\"No pending work order found\", msg);\n return [null, null];\n }\n\n // Get progress from DB query result\n const dbRow = (Array.isArray(msg.payload) && msg.payload.length > 0) ? msg.payload[0] : null;\n const cycleCount = dbRow ? (Number(dbRow.cycle_count) || 0) : 0;\n const goodParts = dbRow ? (Number(dbRow.good_parts) || 0) : 0;\n const scrapParts = dbRow ? (Number(dbRow.scrap_parts) || 0) : 0;\n const targetQty = dbRow ? (Number(dbRow.target_qty) || 0) : (Number(order.target) || 0);\n const cavitiesTotal = dbRow ? (Number(dbRow.cavities_total) || 0) : 0;\n const cavitiesActive = dbRow ? (Number(dbRow.cavities_active) || 0) : 0;\n\n const hasProgress = cycleCount > 0 || (goodParts + scrapParts) > 0;\n state.activeOrderHasProgress = hasProgress;\n state.activeOrderId = order.id;\n\n node.warn(`[PROGRESS-CHECK] WO ${order.id}: cycles=${cycleCount}, good=${goodParts}, target=${targetQty}, cavitiesActive=${cavitiesActive}, cavitiesTotal=${cavitiesTotal}`);\n\n // ✨ La WO de la BD es la única fuente de cavidades\n // Se popula con todos los aliases para compatibilidad con código existente\n if (Number.isFinite(cavitiesActive) && cavitiesActive > 0) {\n order.cavitiesActive = cavitiesActive;\n order.cavities = cavitiesActive; // alias\n }\n if (Number.isFinite(cavitiesTotal) && cavitiesTotal > 0) {\n order.cavitiesTotal = cavitiesTotal;\n order.cavities_total = cavitiesTotal; // alias\n }\n\n // Check if work order has existing progress\n // Check if work order has existing progress\n if (hasProgress) {\n // ✨ WORKAROUND: el modal resumePrompt no renderiza correctamente (bug en hold).\n // Mientras se arregla, asumimos \"resume\" automáticamente (preserva progreso).\n // Esto también dispara el UPDATE de status en BD.\n node.warn(`[PROGRESS-CHECK] Work order has progress (cycles=${cycleCount}). Auto-resuming.`);\n\n // Update order with DB values (DB es source of truth)\n order.cycleCount = cycleCount;\n order.goodParts = goodParts;\n order.scrapParts = scrapParts;\n order.target = targetQty;\n\n const resumeMsg = {\n _mode: \"resume\",\n startOrder: order,\n topic: \"UPDATE work_orders SET status = CASE WHEN work_order_id = ? THEN 'RUNNING' ELSE 'PENDING' END, updated_at = CASE WHEN work_order_id = ? THEN NOW() ELSE updated_at END, cavities_total = COALESCE(NULLIF(?,0), cavities_total), cavities_active = COALESCE(NULLIF(?,0), cavities_active) WHERE status <> 'DONE'\",\n payload: [\n order.id,\n order.id,\n Number(order.cavitiesTotal || order.cavities_total || 0),\n Number(order.cavitiesActive || order.cavities_active || order.cavities || 0)\n ]\n };\n\n // Initialize state with DB values\n state.activeWorkOrder = order;\n state.cycleCount = cycleCount;\n state.scrapPromptIssuedFor = null;\n // ✨ El auto-resume requiere que el operador presione \"Comenzar\" después\n state.trackingEnabled = false;\n state.productionStarted = false;\n flow.set(\"lastMachineState\", 0);\n\n // ✨ FIX: sincronizar state.lastState para que el polling cada 500ms del HMI\n // (get-current-state) no devuelva valores viejos al template y revierta el UI.\n // Sin esto, el botón \"Comenzar\" rebota porque lastState apunta a la WO anterior.\n if (state.lastState && typeof state.lastState === \"object\") {\n state.lastState = {\n ...state.lastState,\n activeWorkOrder: order,\n cycleCount: cycleCount,\n goodParts: goodParts,\n scrapParts: scrapParts,\n trackingEnabled: false,\n productionStarted: false,\n tsMs: Date.now()\n };\n }\n\n persistState();\n\n return [resumeMsg, null];\n } else {\n // No existing progress - proceed with normal start\n // But still use DB values (even if 0) to ensure DB is source of truth\n node.warn(`[PROGRESS-CHECK] No existing progress - proceeding with normal start`);\n\n state.activeOrderHasProgress = false;\n\n // Update order object with DB values (makes DB the source of truth)\n order.cycleCount = cycleCount; // Will be 0 from DB\n order.goodParts = goodParts; // Will be 0 from DB\n order.scrapParts = scrapParts; // Will be 0 from DB\n order.target = targetQty; // From DB\n\n const startMsg = {\n _mode: \"start\",\n startOrder: order,\n topic: \"UPDATE work_orders SET status = CASE WHEN work_order_id = ? THEN 'RUNNING' ELSE 'PENDING' END, updated_at = CASE WHEN work_order_id = ? THEN NOW() ELSE updated_at END, cavities_total = COALESCE(NULLIF(?,0), cavities_total), cavities_active = COALESCE(NULLIF(?,0), cavities_active) WHERE status <> 'DONE'\",\n payload: [\n order.id,\n order.id,\n Number(order.cavitiesTotal || order.cavities_total || 0),\n Number(order.cavitiesActive || order.cavities_active || order.cavities || 0)\n ]\n };\n\n // Initialize global state with DB values (even if 0)\n state.activeWorkOrder = order;\n state.cycleCount = cycleCount;\n flow.set(\"lastMachineState\", 0);\n state.scrapPromptIssuedFor = null;\n persistState();\n\n node.warn(`[PROGRESS-CHECK] Initialized from DB: cycles=${cycleCount}, good=${goodParts}, scrap=${scrapParts}, cavitiesActive=${cavitiesActive}`);\n node.warn(`[PROGRESS-DEBUG] Sending startMsg to Output 1. SQL=${startMsg.topic.substring(0, 100)}...`);\n return [startMsg, null];\n }\n}\n\n// Pass through all other messages\nreturn [msg, null];", + "outputs": 2, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1910, + "y": 540, + "wires": [ + [ + "d569d16fc0423be8" + ], + [ + "babe66a431cd1760" + ] + ] + }, + { + "id": "68709c57900ed80e", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Merge Cycle + KPI Data", + "func": "// ============================================================\n// DATA MERGER - Combines Cycle + KPI data for Anomaly Detector\n// ============================================================\n\nconst state = global.get(\"state\") || {};\nconst settings = global.get(\"settings\") || {};\n\n// Get KPIs from incoming message (from Calculate KPIs node)\nconst kpis = msg.kpis || msg.payload?.kpis || {};\n\n// Get cycle data from global context\nconst activeOrder = state.activeWorkOrder || {};\nconst cycleCount = state.cycleCount || 0;\nconst moldByWorkOrder = state.moldByWorkOrder || {};\nconst cavities = Number(\n activeOrder.cavities ??\n moldByWorkOrder[activeOrder.id]?.active ??\n state.lastMoldActive ??\n settings.moldActive ??\n 0\n);\nconst lastActualCycleTime = Number(state.lastActualCycleTime || 0);\n\n\n// Build cycle object with all necessary data\nconst cycle = {\n id: activeOrder.id,\n sku: activeOrder.sku || \"\",\n cycles: cycleCount,\n goodParts: Number(activeOrder.goodParts) || 0,\n scrapParts: Number(activeOrder.scrapParts) || 0,\n target: Number(activeOrder.target) || 0,\n cycleTime: Number(activeOrder.cycleTime || activeOrder.theoreticalCycleTime || 0),\n progressPercent: Number(activeOrder.progressPercent) || 0,\n cavities: cavities,\n actualCycleTime: lastActualCycleTime\n\n};\n\n// Merge both into the message\nmsg.cycle = cycle;\nmsg.kpis = kpis;\n\n//node.warn(`[DATA MERGER] Merged cycle (count: ${cycleCount}) + KPIs (OEE: ${kpis.oee || 0}%) for anomaly detection`);\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1330, + "y": 480, + "wires": [ + [ + "dc24aea451e9b976" + ] + ] + }, + { + "id": "dc24aea451e9b976", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Anomaly Detector", + "func": "// ============================================================\n// ANOMALY DETECTOR - P0 FIXED VERSION\n// Key fixes:\n// 1. Removed duplicate suppression that blocks microstop alerts\n// 2. Added periodic updates for active macrostops\n// 3. Improved logging for debugging\n// 4. ✨ P0.1+P0.2: incidentKey unificado downtime:: en todo el episodio\n// (mismo key se mantiene en micro→macro, refresh, auto-ack, resolved)\n// 5. ✨ P0.4: Removido applyDefaultDowntimeReason que inyectaba PENDIENTE\n// ============================================================\n\nconst state = global.get(\"state\") || {};\nconst settings = global.get(\"settings\") || {};\nconst anomaly = global.get(\"anomaly\") || {};\n\n// Suppress anomaly detection during mold change\nif (state.moldChange && state.moldChange.active) return null;\n\nconst cycle = msg.cycle || {};\nconst kpis = msg.kpis || {};\nconst activeOrder = state.activeWorkOrder || {};\nconst cycleCountNow = Number(cycle.cycles || 0);\n\n\n// Must have active work order to detect anomalies\nif (!activeOrder.id) {\n return null;\n}\n\nconst theoreticalCycleTime = Number(activeOrder.cycleTime) || 0;\nconst now = Date.now();\n\n// Get or initialize anomaly tracking state\nlet anomalyState = global.get(\"anomalyState\") || {\n lastCycleTime: Number(state.lastMachineCycleTime || now),\n lastCycleCount: 0,\n activeStoppageEvent: null,\n oeeHistory: [],\n performanceHistory: [],\n qualityHistory: [],\n activeOeeDrop: false,\n oeeLowStreak: 0,\n activeQualitySpike: false,\n qualityHighStreak: 0,\n lastSlowCycleCount: 0,\n lastCycleEventCount: 0,\n lastStoppageUpdateMs: 0\n};\n\n// Reset anomaly cycle baseline when work order changes\nif (anomalyState.work_order_id && anomalyState.work_order_id !== activeOrder.id) {\n node.warn(`[RESET] Work order changed ${anomalyState.work_order_id} -> ${activeOrder.id}. Resetting anomalyState counters.`);\n anomalyState.lastCycleCount = 0;\n anomalyState.lastCycleEventCount = 0;\n anomalyState.lastSlowCycleCount = 0;\n anomalyState.activeStoppageEvent = null;\n anomalyState.lastStoppageUpdateMs = 0;\n}\nanomalyState.work_order_id = activeOrder.id;\n\nif (!isFinite(anomalyState.lastCycleTime)) {\n anomalyState.lastCycleTime = Number(state.lastMachineCycleTime || now);\n}\nif (!isFinite(anomalyState.lastCycleCount)) {\n anomalyState.lastCycleCount = cycleCountNow;\n}\n\nconst stateLastCycleTime = Number(state.lastMachineCycleTime || 0);\nlet lastCycleTime = Number(anomalyState.lastCycleTime || 0);\nif (stateLastCycleTime > 0 && (lastCycleTime <= 0 || stateLastCycleTime > lastCycleTime)) {\n lastCycleTime = stateLastCycleTime;\n}\nconst prevCount = Number(anomalyState.lastCycleCount || 0);\n\n// If PLC/logic reset the counter (or new batch), re-baseline\nif (cycleCountNow > 0 && prevCount > 0 && cycleCountNow < prevCount) {\n node.warn(`[RESET] cycle counter reset detected: ${prevCount} -> ${cycleCountNow}. Re-baselining.`);\n anomalyState.lastCycleCount = cycleCountNow;\n anomalyState.lastCycleTime = stateLastCycleTime > 0 ? stateLastCycleTime : now;\n anomalyState.lastCycleEventCount = cycleCountNow;\n anomalyState.lastSlowCycleCount = cycleCountNow;\n anomalyState.activeStoppageEvent = null;\n anomalyState.lastStoppageUpdateMs = 0;\n}\n\nconst hasNewCycle = cycleCountNow > Number(anomalyState.lastCycleCount || 0);\n\nif (hasNewCycle) {\n anomalyState.lastCycleCount = cycleCountNow;\n anomalyState.lastCycleTime = stateLastCycleTime > 0 ? stateLastCycleTime : now;\n lastCycleTime = anomalyState.lastCycleTime;\n}\n\n// Configuration\nconst OEE_THRESHOLD = settings.oeeAlertThreshold || 90;\nconst HISTORY_WINDOW = 20;\nconst QUALITY_SPIKE_THRESHOLD = 5;\nconst PERFORMANCE_THRESHOLD = settings.performanceThreshold || 85;\nconst microMultiplier = Number(settings.thresholdMultiplier || 1.5);\nconst macroMultiplier = Math.max(\n microMultiplier,\n Number(settings.macroStoppageMultiplier || 5)\n);\n\n// Update interval for macrostop notifications (default 10 seconds)\nconst updateIntervalMs = Number(settings.stoppageUpdateIntervalMs || 10000);\n\nconst detectedAnomalies = [];\n\n// ✨ P0.4: REMOVIDO applyDefaultDowntimeReason que inyectaba PENDIENTE en eventos\n// La razón \"pendiente\" debe ser placeholder solo del UI render layer, NO viajar\n// en payloads de outbox/ingest. Si el evento llega sin razón, queda sin razón.\n// El Control Tower y el UI manejan ese caso.\n\n// ============================================================\n// TIER 1: CYCLE CLASSIFICATION + STOPPAGE WATCHDOG\n// ============================================================\nif (theoreticalCycleTime > 0) {\n const actualCycleTime = Number(cycle.actualCycleTime || 0);\n const currentCycleCount = Number(cycle.cycles || 0);\n const lastCycleEventCount = Number(\n anomalyState.lastCycleEventCount ?? anomalyState.lastSlowCycleCount ?? 0\n );\n\n const microThresholdSec = theoreticalCycleTime * microMultiplier;\n const macroThresholdSec = theoreticalCycleTime * macroMultiplier;\n const timeSinceLastCycleSec = lastCycleTime > 0 ? (now - lastCycleTime) / 1000 : 0;\n\n let resolvedStoppageThisCycle = false;\n\n // Resolve any active stoppage when a new cycle arrives\n if (anomalyState.activeStoppageEvent && hasNewCycle) {\n const resolvedDurationSec = actualCycleTime > 0 ? actualCycleTime : timeSinceLastCycleSec;\n\n // ✨ P0: preservar el incidentKey en el resolved final\n const resolvedIncidentKey = anomalyState.activeStoppageEvent.incidentKey\n || `downtime:${activeOrder.id}:${lastCycleTime}`;\n\n detectedAnomalies.push({\n ...anomalyState.activeStoppageEvent,\n incidentKey: resolvedIncidentKey,\n status: \"resolved\",\n resolved_at: now,\n auto_resolved: true,\n data: {\n ...anomalyState.activeStoppageEvent.data,\n stoppage_duration_seconds: Math.round(resolvedDurationSec),\n incidentKey: resolvedIncidentKey\n },\n tsMs: now\n });\n\n node.warn(`[RESOLVED] Stoppage resolved: ${anomalyState.activeStoppageEvent.anomaly_type}, duration: ${resolvedDurationSec}s`);\n anomalyState.activeStoppageEvent = null;\n anomalyState.lastStoppageUpdateMs = 0;\n resolvedStoppageThisCycle = true;\n }\n\n // Per-cycle classification (uses actualCycleTime)\n if (actualCycleTime > 0 && currentCycleCount > lastCycleEventCount) {\n let cycleEvent = null;\n\n const SLOW_MARGIN = Number(settings.slowCycleMarginPercent ?? 5);\n\n if (\n actualCycleTime > 0 &&\n actualCycleTime > theoreticalCycleTime * (1 + SLOW_MARGIN / 100) &&\n actualCycleTime < microThresholdSec\n ) {\n const deltaPercent = ((actualCycleTime - theoreticalCycleTime) / theoreticalCycleTime) * 100;\n\n cycleEvent = {\n anomaly_type: \"slow-cycle\",\n severity: \"warning\",\n requires_ack: false,\n title: \"Slow Cycle Detected\",\n description: `Cycle took ${actualCycleTime.toFixed(1)}s (+${deltaPercent.toFixed(0)}% vs ${theoreticalCycleTime.toFixed(1)}s target)`,\n data: {\n actual_cycle_time: actualCycleTime,\n theoretical_cycle_time: theoreticalCycleTime,\n delta_percent: Math.round(deltaPercent),\n micro_threshold_multiplier: microMultiplier,\n macro_threshold_multiplier: macroMultiplier\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: currentCycleCount,\n tsMs: now\n };\n }\n\n if (cycleEvent) {\n detectedAnomalies.push(cycleEvent);\n }\n\n anomalyState.lastCycleEventCount = currentCycleCount;\n anomalyState.lastSlowCycleCount = currentCycleCount;\n }\n\n // No new cycle yet: start or escalate stoppage\n if (!hasNewCycle && lastCycleTime > 0) {\n // Start stoppage once when we cross micro threshold\n if (!anomalyState.activeStoppageEvent && timeSinceLastCycleSec >= microThresholdSec) {\n const isMacro = timeSinceLastCycleSec >= macroThresholdSec;\n const anomalyType = isMacro ? \"macrostop\" : \"microstop\";\n const severity = isMacro ? \"critical\" : \"warning\";\n\n // ✨ P0: incidentKey unificado para todo el episodio de stoppage.\n // Mismo key se mantiene en micro→macro escalación y en refresh/resolved.\n const stoppageIncidentKey = `downtime:${activeOrder.id}:${lastCycleTime}`;\n\n const stoppageEvent = {\n alert_id: `${anomalyType}:${activeOrder.id}:${lastCycleTime}`,\n incidentKey: stoppageIncidentKey,\n anomaly_type: anomalyType,\n severity,\n requires_ack: true,\n title: isMacro ? \"Macrostop In Progress\" : \"Microstop In Progress\",\n description: `No cycles for ${timeSinceLastCycleSec.toFixed(0)}s (expected cycle every ${theoreticalCycleTime}s)`,\n data: {\n stoppage_duration_seconds: Math.round(timeSinceLastCycleSec),\n theoretical_cycle_time: theoreticalCycleTime,\n last_cycle_timestamp: lastCycleTime,\n micro_threshold_multiplier: microMultiplier,\n macro_threshold_multiplier: macroMultiplier,\n incidentKey: stoppageIncidentKey\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: \"active\"\n };\n\n detectedAnomalies.push(stoppageEvent);\n anomalyState.activeStoppageEvent = stoppageEvent;\n anomalyState.lastStoppageUpdateMs = now;\n }\n\n // Escalate micro -> macro once\n if (\n anomalyState.activeStoppageEvent &&\n anomalyState.activeStoppageEvent.anomaly_type === \"microstop\" &&\n timeSinceLastCycleSec >= macroThresholdSec\n ) {\n // ✨ P0: preservar el incidentKey del microstop original\n const escalationIncidentKey = anomalyState.activeStoppageEvent.incidentKey\n || `downtime:${activeOrder.id}:${lastCycleTime}`;\n\n detectedAnomalies.push({\n ...anomalyState.activeStoppageEvent,\n incidentKey: escalationIncidentKey,\n status: \"resolved\",\n resolved_at: now,\n auto_resolved: true,\n requires_ack: false,\n data: {\n ...anomalyState.activeStoppageEvent.data,\n stoppage_duration_seconds: Math.round(timeSinceLastCycleSec),\n incidentKey: escalationIncidentKey\n },\n tsMs: now\n });\n\n const macroEvent = {\n alert_id: `macrostop:${activeOrder.id}:${lastCycleTime}`,\n incidentKey: escalationIncidentKey,\n anomaly_type: \"macrostop\",\n severity: \"critical\",\n requires_ack: true,\n title: \"Macrostop In Progress\",\n description: `No cycles for ${timeSinceLastCycleSec.toFixed(0)}s (expected cycle every ${theoreticalCycleTime}s)`,\n data: {\n stoppage_duration_seconds: Math.round(timeSinceLastCycleSec),\n theoretical_cycle_time: theoreticalCycleTime,\n last_cycle_timestamp: lastCycleTime,\n micro_threshold_multiplier: microMultiplier,\n macro_threshold_multiplier: macroMultiplier,\n incidentKey: escalationIncidentKey\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: \"active\"\n };\n\n detectedAnomalies.push(macroEvent);\n anomalyState.activeStoppageEvent = macroEvent;\n anomalyState.lastStoppageUpdateMs = now;\n }\n\n // Send periodic updates for active macrostops\n if (\n anomalyState.activeStoppageEvent &&\n anomalyState.activeStoppageEvent.anomaly_type === \"macrostop\"\n ) {\n const timeSinceLastUpdate = now - (anomalyState.lastStoppageUpdateMs || 0);\n\n if (timeSinceLastUpdate >= updateIntervalMs) {\n const prev = anomalyState.activeStoppageEvent;\n\n // ✨ P0: preservar el incidentKey en refresh/auto-ack\n const refreshIncidentKey = prev.incidentKey\n || `downtime:${activeOrder.id}:${lastCycleTime}`;\n\n // 1) AUTO-ACK the previous active macrostop alert\n const autoAck = {\n ...prev,\n incidentKey: refreshIncidentKey,\n status: \"resolved\",\n resolved_at: now,\n auto_resolved: true,\n requires_ack: false,\n title: \"Macrostop Updated\",\n description: \"Auto-acknowledged due to periodic refresh\",\n data: {\n ...prev.data,\n incidentKey: refreshIncidentKey\n },\n tsMs: now,\n is_auto_ack: true\n };\n\n // 2) Send a NEW active macrostop alert with updated duration + NEW alert_id\n // (mismo incidentKey para que el CT lo trate como mismo episodio)\n const refreshed = {\n ...prev,\n alert_id: `macrostop:${activeOrder.id}:${now}`,\n incidentKey: refreshIncidentKey,\n status: \"active\",\n requires_ack: true,\n title: \"Macrostop Ongoing\",\n description: `Machine still stopped for ${timeSinceLastCycleSec.toFixed(0)}s (expected cycle every ${theoreticalCycleTime}s)`,\n data: {\n ...prev.data,\n stoppage_duration_seconds: Math.round(timeSinceLastCycleSec),\n incidentKey: refreshIncidentKey\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n tsMs: now,\n is_update: true\n };\n\n detectedAnomalies.push(autoAck, refreshed);\n\n anomalyState.activeStoppageEvent = refreshed;\n anomalyState.lastStoppageUpdateMs = now;\n }\n }\n }\n}\n\n\n// ============================================================\n// TIER 2: OEE DROP DETECTION\n// ============================================================\nconst currentOEE = Number(kpis.oee) || 0;\n\nif (currentOEE > 0) {\n const lowThreshold = OEE_THRESHOLD;\n const recoveryThreshold = OEE_THRESHOLD + 2;\n\n if (currentOEE < lowThreshold) {\n anomalyState.oeeLowStreak = (anomalyState.oeeLowStreak || 0) + 1;\n\n const REQUIRED_STREAK = 3;\n\n if (!anomalyState.activeOeeDrop &&\n anomalyState.oeeLowStreak >= REQUIRED_STREAK) {\n\n let severity = 'warning';\n if (currentOEE < 75) severity = 'critical';\n\n detectedAnomalies.push({\n anomaly_type: 'oee-drop',\n severity,\n title: 'OEE Below Threshold',\n description: `OEE at ${currentOEE.toFixed(1)}% (threshold: ${OEE_THRESHOLD}%)`,\n data: {\n current_oee: currentOEE,\n threshold: OEE_THRESHOLD,\n delta: OEE_THRESHOLD - currentOEE\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: 'active'\n });\n\n anomalyState.activeOeeDrop = true;\n }\n\n } else if (currentOEE >= recoveryThreshold) {\n anomalyState.oeeLowStreak = 0;\n\n if (anomalyState.activeOeeDrop) {\n detectedAnomalies.push({\n anomaly_type: 'oee-drop',\n severity: 'info',\n title: 'OEE Recovered',\n description: `OEE recovered to ${currentOEE.toFixed(1)}% (threshold: ${OEE_THRESHOLD}%)`,\n data: {\n current_oee: currentOEE,\n threshold: OEE_THRESHOLD\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: 'resolved'\n });\n\n anomalyState.activeOeeDrop = false;\n }\n }\n}\n\n// Update OEE history for trend analysis\nanomalyState.oeeHistory.push({ tsMs: now, value: currentOEE });\nif (anomalyState.oeeHistory.length > HISTORY_WINDOW) {\n anomalyState.oeeHistory.shift();\n}\n\n// ============================================================\n// TIER 2: QUALITY SPIKE DETECTION\n// ============================================================\nconst totalParts = (cycle.goodParts || 0) + (cycle.scrapParts || 0);\nconst currentScrapRate =\n totalParts > 0 ? ((cycle.scrapParts || 0) / totalParts) * 100 : 0;\n\nanomalyState.qualityHistory.push({ tsMs: now, value: currentScrapRate });\nif (anomalyState.qualityHistory.length > HISTORY_WINDOW) {\n anomalyState.qualityHistory.shift();\n}\n\nconst MIN_SAMPLES = 5;\nconst MIN_PARTS_THIS_CYCLE = 10;\n\nif (\n anomalyState.qualityHistory.length >= MIN_SAMPLES &&\n totalParts >= MIN_PARTS_THIS_CYCLE\n) {\n const recentHistory = anomalyState.qualityHistory.slice(0, -1);\n const avgScrapRate =\n recentHistory.reduce((sum, p) => sum + p.value, 0) /\n recentHistory.length;\n\n const scrapRateIncrease = currentScrapRate - avgScrapRate;\n\n const SPIKE_DELTA = QUALITY_SPIKE_THRESHOLD || 5;\n const MIN_SCRAP_RATE = 5;\n const RECOVERY_MARGIN = 2;\n\n if (\n currentScrapRate > MIN_SCRAP_RATE &&\n scrapRateIncrease > SPIKE_DELTA\n ) {\n anomalyState.qualityHighStreak =\n (anomalyState.qualityHighStreak || 0) + 1;\n\n const REQUIRED_STREAK = 2;\n\n if (\n !anomalyState.activeQualitySpike &&\n anomalyState.qualityHighStreak >= REQUIRED_STREAK\n ) {\n let severity = \"warning\";\n if (scrapRateIncrease > 10 || currentScrapRate > 15) {\n severity = \"critical\";\n }\n\n detectedAnomalies.push({\n anomaly_type: \"quality-spike\",\n severity,\n title: \"Quality Issue Detected\",\n description: `Scrap rate at ${currentScrapRate.toFixed(1)}% (avg: ${avgScrapRate.toFixed(1)}%, +${scrapRateIncrease.toFixed(1)}%)`,\n data: {\n current_scrap_rate: currentScrapRate,\n average_scrap_rate: avgScrapRate,\n increase: scrapRateIncrease,\n scrap_parts: cycle.scrapParts || 0,\n total_parts: totalParts\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: \"active\"\n });\n\n anomalyState.activeQualitySpike = true;\n }\n } else {\n anomalyState.qualityHighStreak = 0;\n\n if (\n anomalyState.activeQualitySpike &&\n currentScrapRate <= avgScrapRate + RECOVERY_MARGIN\n ) {\n detectedAnomalies.push({\n anomaly_type: \"quality-spike\",\n severity: \"info\",\n title: \"Quality Issue Resolved\",\n description: `Scrap rate back to ${currentScrapRate.toFixed(1)}% (avg: ${avgScrapRate.toFixed(1)}%)`,\n data: {\n current_scrap_rate: currentScrapRate,\n average_scrap_rate: avgScrapRate\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: \"resolved\"\n });\n\n anomalyState.activeQualitySpike = false;\n }\n }\n}\n\n// ============================================================\n// TIER 2: PERFORMANCE DEGRADATION\n// ============================================================\nconst currentPerformance = Number(kpis.performance) || 0;\nanomalyState.performanceHistory.push({ tsMs: now, value: currentPerformance });\nif (anomalyState.performanceHistory.length > HISTORY_WINDOW) {\n anomalyState.performanceHistory.shift();\n}\n\nif (anomalyState.performanceHistory.length >= 10) {\n const recent10 = anomalyState.performanceHistory.slice(-10);\n const avgPerformance = recent10.reduce((sum, point) => sum + point.value, 0) / recent10.length;\n\n const PERF_LOW_THRESHOLD = PERFORMANCE_THRESHOLD;\n const PERF_RECOVERY_THRESHOLD = PERFORMANCE_THRESHOLD + 3;\n\n if (avgPerformance > 0 && avgPerformance < PERF_LOW_THRESHOLD) {\n anomalyState.performanceLowStreak = (anomalyState.performanceLowStreak || 0) + 1;\n\n const REQUIRED_STREAK = 3;\n\n if (!anomalyState.activePerformanceDegradation &&\n anomalyState.performanceLowStreak >= REQUIRED_STREAK) {\n\n let severity = 'warning';\n if (avgPerformance < 75) {\n severity = 'critical';\n }\n\n detectedAnomalies.push({\n anomaly_type: 'performance-degradation',\n severity: severity,\n title: `Performance Degradation`,\n description: `Performance at ${avgPerformance.toFixed(1)}% (sustained over last 10 cycles)`,\n data: {\n average_performance: avgPerformance,\n current_performance: currentPerformance,\n threshold: PERF_LOW_THRESHOLD,\n sample_size: 10\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: 'active'\n });\n\n anomalyState.activePerformanceDegradation = true;\n }\n\n } else if (avgPerformance >= PERF_RECOVERY_THRESHOLD) {\n anomalyState.performanceLowStreak = 0;\n\n if (anomalyState.activePerformanceDegradation) {\n detectedAnomalies.push({\n anomaly_type: 'performance-degradation',\n severity: 'info',\n title: 'Performance Recovered',\n description: `Performance recovered to ${avgPerformance.toFixed(1)}% (threshold: ${PERF_LOW_THRESHOLD}%)`,\n data: {\n average_performance: avgPerformance,\n current_performance: currentPerformance,\n threshold: PERF_LOW_THRESHOLD\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now,\n status: 'resolved'\n });\n\n anomalyState.activePerformanceDegradation = false;\n }\n }\n}\n\n// ============================================================\n// TIER 3: PREDICTIVE ALERTS (Trend Analysis)\n// ============================================================\nif (anomalyState.oeeHistory.length >= 15) {\n const recent15 = anomalyState.oeeHistory.slice(-15);\n const firstHalf = recent15.slice(0, 7);\n const secondHalf = recent15.slice(-7);\n\n const avgFirstHalf = firstHalf.reduce((sum, p) => sum + p.value, 0) / firstHalf.length;\n const avgSecondHalf = secondHalf.reduce((sum, p) => sum + p.value, 0) / secondHalf.length;\n\n const oeeTrend = avgSecondHalf - avgFirstHalf;\n\n if (oeeTrend < -5 && avgSecondHalf > OEE_THRESHOLD * 0.95 && avgSecondHalf < OEE_THRESHOLD * 1.05) {\n detectedAnomalies.push({\n anomaly_type: 'predictive-oee-decline',\n severity: 'info',\n title: `Declining OEE Trend Detected`,\n description: `OEE trending down ${Math.abs(oeeTrend).toFixed(1)}% over last 15 cycles. Current: ${avgSecondHalf.toFixed(1)}%`,\n data: {\n trend: oeeTrend,\n first_half_avg: avgFirstHalf,\n second_half_avg: avgSecondHalf,\n prediction: 'OEE may drop below threshold soon'\n },\n kpi_snapshot: {\n oee: kpis.oee || 0,\n availability: kpis.availability || 0,\n performance: kpis.performance || 0,\n quality: kpis.quality || 0\n },\n work_order_id: activeOrder.id,\n cycle_count: cycle.cycles || 0,\n tsMs: now\n });\n }\n}\n\n// Update last cycle time for next iteration\nanomalyState.lastCycleTime = lastCycleTime;\nglobal.set(\"anomalyState\", anomalyState);\n\n\n// ============================================================\n// OUTPUT\n// ============================================================\n// ✨ P0.4: Sin applyDefaultDowntimeReason - los eventos viajan sin razón inyectada\nif (detectedAnomalies.length > 0) {\n node.warn(`[ANOMALY DETECTOR] Detected ${detectedAnomalies.length} anomaly/ies`);\n\n detectedAnomalies.forEach((a, i) => {\n node.warn(` [${i + 1}] ${a.anomaly_type} - ${a.title} - ${a.status || 'N/A'}`);\n });\n\n msg.topic = \"anomaly-detected\";\n msg.payload = detectedAnomalies;\n msg.originalMsg = msg.originalMsg || null;\n msg._anomaly_source = \"anomaly_detector\";\n return msg;\n}\nreturn null;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1350, + "y": 440, + "wires": [ + [ + "1212f599b9ab36f0", + "245a057bdff1fc14", + "bf17a2d4b88f7694", + "e2cb9e6a86c0d549" + ] + ] + }, + { + "id": "1212f599b9ab36f0", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Event Logger (Simplified)", + "func": "// ============================================================\n// EVENT LOGGER - SIMPLIFIED (INSERTS ONLY)\n// Every anomaly gets inserted as a new row\n// ============================================================\n\nconst anomalies = msg.payload || [];\nconst settings = global.get(\"settings\") || {};\nconst anomalyStateStore = global.get(\"anomaly\") || {};\nconst reasonsByIncident = anomalyStateStore.reasonsByIncident || {};\n\nif (!Array.isArray(anomalies) || anomalies.length === 0) {\n return null;\n}\n\n// SQL escape helper\nconst esc = (v) => {\n if (v === null || v === undefined) return 'NULL';\n return \"'\" + String(v).replace(/\\\\/g, '\\\\').replace(/'/g, \"''\") + \"'\";\n};\n\nconst dbInserts = [];\nconst activeAnomalies = [];\nconst softNotifications = []; \n\n\nanomalies.forEach(anomaly => {\n const tsMs = Number(anomaly.tsMs) || Date.now();\n const woId = anomaly.work_order_id || '';\n const aType = anomaly.anomaly_type || 'unknown';\n const sev = anomaly.severity || 'warning';\n const title = anomaly.title || '';\n const desc = anomaly.description || '';\n const dataJson = JSON.stringify(anomaly.data || {});\n const kpiJson = JSON.stringify(anomaly.kpi_snapshot || {});\n const cycle = Number(anomaly.cycle_count) || 0;\n const requiresAck = anomaly.requires_ack !== false;\n // ✨ P0.1: incidentKey unificado. Prioridad:\n // 1. anomaly.incidentKey (puesto por Anomaly Detector)\n // 2. anomaly.data.incidentKey (compatibilidad con CT)\n // 3. fallback construido como downtime:: (sin anomaly_type)\n // 4. anomaly.alert_id como último recurso\n const incidentKey = anomaly.incidentKey\n || (anomaly.data && anomaly.data.incidentKey)\n || (anomaly.data && anomaly.data.last_cycle_timestamp\n ? [\"downtime\", woId, String(anomaly.data.last_cycle_timestamp)].join(\":\")\n : (anomaly.alert_id || null));\n const reason = incidentKey ? (reasonsByIncident[incidentKey] || null) : null;\n\n // Build INSERT query\n const insertQuery = \n \"INSERT INTO anomaly_events \" +\n \"(`event_timestamp`, `work_order_id`, `anomaly_type`, `severity`, `title`, `description`, \" +\n \"`data_json`, `kpi_snapshot_json`, `status`, `cycle_count`, `occurrence_count`, `last_occurrence`) VALUES (\" +\n tsMs + \", \" +\n esc(woId) + \", \" +\n esc(aType) + \", \" +\n esc(sev) + \", \" +\n esc(title) + \", \" +\n esc(desc) + \", \" +\n esc(dataJson) + \", \" +\n esc(kpiJson) + \", \" +\n \"'active', \" +\n cycle + \", \" +\n \"1, \" +\n tsMs + \")\";\n\n dbInserts.push({ topic: insertQuery, payload: [] });\n\n // Add to active list for UI\n if (requiresAck) {\n // Hard alerts that go to the panel + require acknowledgment\n activeAnomalies.push({\n event_id: null,\n tsMs: tsMs,\n work_order_id: woId,\n anomaly_type: aType,\n incidentKey: incidentKey || null,\n severity: sev,\n title: title,\n description: desc,\n status: 'active',\n reason: reason,\n kpi_snapshot: anomaly.kpi_snapshot || {}\n });\n\n node.warn(`[EVENT LOGGER] Inserting ${aType}: ${title} (requires ack)`);\n } else {\n // Soft alerts (e.g. slow-cycle) -> just show a transient popup\n softNotifications.push({\n tsMs: tsMs,\n anomaly_type: aType,\n severity: sev,\n title: title,\n description: desc,\n requires_ack: false\n });\n\n node.warn(`[EVENT LOGGER] Logging soft anomaly ${aType}: ${title}`);\n }\n});\n\n// UI update message\nconst uiMsg = {\n topic: \"anomaly-ui-update\",\n payload: {\n activeCount: activeAnomalies.length,\n activeAnomalies: activeAnomalies,\n updates: activeAnomalies.map(a => ({ status: 'new', anomaly: a })),\n softNotifications: softNotifications,\n reasonCatalog: settings.reasonCatalog || null\n }\n};\n\nreturn [dbInserts, uiMsg];\n", + "outputs": 2, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1370, + "y": 400, + "wires": [ + [ + "7e94b5651ed96f24", + "1b6eda85e72ecff1", + "dc14ef2f723f75b7" + ], + [ + "78925efc4a55f04d", + "2fd4067492e5549b" + ] + ] + }, + { + "id": "1b6eda85e72ecff1", + "type": "split", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Split DB Inserts", + "splt": "\\n", + "spltType": "str", + "arraySplt": 1, + "arraySpltType": "len", + "stream": false, + "addname": "", + "x": 1940, + "y": 380, + "wires": [ + [ + "875d5f193c768af0" + ] + ] + }, + { + "id": "8e60972fea4bd36a", + "type": "mysql", + "z": "05d4cb231221b842", + "g": "d9a9ee7bc71b0f53", + "mydb": "fc9634aabefee16b", + "name": "Anomaly Events DB", + "x": 1020, + "y": 60, + "wires": [ + [] + ] + }, + { + "id": "ba6de546969ea278", + "type": "inject", + "z": "05d4cb231221b842", + "name": "Initialize OEE Threshold (90%)", + "props": [ + { + "p": "payload" + } + ], + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "90", + "payloadType": "num", + "x": 360, + "y": 940, + "wires": [ + [ + "4f9cf7814f1575f2" + ] + ] + }, + { + "id": "4f9cf7814f1575f2", + "type": "function", + "z": "05d4cb231221b842", + "name": "Set OEE Threshold Global", + "func": "// Initialize OEE alert threshold\nconst settings = global.get(\"settings\") || {};\nconst threshold = Number(msg.payload) || 90;\nsettings.oeeAlertThreshold = threshold;\nglobal.set(\"settings\", settings);\n\nnode.warn(`[CONFIG] OEE Alert Threshold set to ${threshold}%`);\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 670, + "y": 1080, + "wires": [ + [] + ] + }, + { + "id": "dcaa582c9b2277ba", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Save KPIs to Database", + "func": "// ============================================================\n// SAVE KPIs TO DATABASE - 1 SNAPSHOT PER CYCLE\n// ============================================================\n\nconst state = global.get(\"state\") || {};\nconst kpis = msg.kpis || {};\n\n// Rising edge guard: only save once per cycle\nconst saveFlag = state.saveKpis || 0;\nif (!saveFlag) {\n return null;\n}\nstate.saveKpis = 0;\nglobal.set(\"state\", state);\n\nconst dbInserts = [];\n\nconst activeOrder = state.activeWorkOrder || {};\nconst workorder_id = activeOrder.id;\nconst oee = Number(kpis.oee);\nconst performance = Number(kpis.performance);\nconst availability = Number(kpis.availability);\nconst quality = Number(kpis.quality);\nconst tsMs = Date.now();\n\nif (!workorder_id) {\n return null;\n}\n\nconst insertQuery =\n \"INSERT INTO kpi_snapshots \" +\n \"(work_order_id,oee_percent, performance_percent, availability_percent, quality_percent, timestamp) VALUES (\" +\n \"'\" + workorder_id + \"', \" +\n oee + \", \" +\n performance + \", \" +\n availability + \", \" +\n quality + \", \" +\n tsMs + \")\";\n\ndbInserts.push({ topic: insertQuery, payload: [] });\n\nreturn [dbInserts];\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1710, + "y": 720, + "wires": [ + [ + "e19e54018a466662" + ] + ] + }, + { + "id": "e19e54018a466662", + "type": "mysql", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "mydb": "fc9634aabefee16b", + "name": "Save kpis to database", + "x": 1980, + "y": 700, + "wires": [ + [ + "7aac414134d40b3a" + ] + ] + }, + { + "id": "25a2e7a04827039a", + "type": "template", + "z": "05d4cb231221b842", + "d": true, + "g": "9221454c45afd1ba", + "name": "Format query 1", + "field": "topic", + "fieldType": "msg", + "format": "handlebars", + "syntax": "mustache", + "template": "SELECT\n oee_percent,\n availability_percent,\n quality_percent,\n performance_percent,\n tsMs\nFROM (\n SELECT\n oee_percent,\n availability_percent,\n quality_percent,\n performance_percent,\n timestamp AS tsMs\n FROM kpi_snapshots\n ORDER BY timestamp DESC\n LIMIT 50\n) AS t\nORDER BY tsMs ASC;\n", + "output": "str", + "x": 1380, + "y": 80, + "wires": [ + [ + "7df6eebd9b7c7c7b" + ] + ] + }, + { + "id": "9f929db1f49b6e16", + "type": "function", + "z": "05d4cb231221b842", + "g": "9221454c45afd1ba", + "name": "Format Graph Data", + "func": "// Format Graph Data for KPI charts\n\n // Build labels and data arrays\n const labels = [];\n const oeeData = [];\n const availData = [];\n const perfData = [];\n const qualData = [];\n\n function bucketSeries(source, size) {\n const bucketSize = size || 5; // 3 points → 1 smoother point\n if (!Array.isArray(source) || source.length <= bucketSize) return source;\n\n const result = [];\n for (let i = 0; i < source.length; i += bucketSize) {\n const bucket = source.slice(i, i + bucketSize);\n const avgY = bucket.reduce((sum, p) => sum + Number(p.y || 0), 0) / bucket.length;\n const midPoint = bucket[Math.floor(bucket.length / 2)] || bucket[0];\n result.push({ x: midPoint.x, y: avgY });\n }\n return result;\n}\n\n \n msg.payload.forEach(row => {\n let x_value = new Date(row.tsMs); \n const dateObject = new Date(x_value);\n const year = dateObject.getFullYear();\n const month = dateObject.getMonth() + 1; // Months are 0-indexed\n const day = dateObject.getDate();\n const hours = dateObject.getHours();\n const minutes = dateObject.getMinutes();\n const seconds = dateObject.getSeconds();\n const formattedx_value = `${day.toString().padStart(2, '0')}-${month.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;\n\n oeeData.push({ x: formattedx_value, y: row.oee_percent });\n availData.push({ x: formattedx_value, y: row.availability_percent });\n perfData.push({ x: formattedx_value, y: row.performance_percent });\n qualData.push({ x: formattedx_value, y: row.quality_percent });\n });\n\n const smoothOee = bucketSeries(oeeData, 5);\n const smoothAvail = bucketSeries(availData, 5);\n const smoothPerf = bucketSeries(perfData, 5);\n const smoothQual = bucketSeries(qualData, 5);\n\n msg.graphData = {\n labels: labels,\n datasets: [\n { label: 'OEE %', data: smoothOee },\n { label: 'Availability %', data: smoothAvail },\n { label: 'Performance %', data: smoothPerf },\n { label: 'Quality %', data: smoothQual }\n ]\n };\n\n //node.warn(`[GRAPH DATA] Formatted ${labels.length} KPI history points`);\n\n delete msg.topic;\n delete msg.payload;\n return msg;\n\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1830, + "y": 80, + "wires": [ + [ + "cc31f7b315638ba5" + ] + ] + }, + { + "id": "fa78b7dee85d560d", + "type": "link out", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "link out 11", + "mode": "link", + "links": [ + "96dfd46a1435d111" + ], + "x": 485, + "y": 360, + "wires": [] + }, + { + "id": "96dfd46a1435d111", + "type": "link in", + "z": "05d4cb231221b842", + "g": "443b758222662fdf", + "name": "link in 10", + "links": [ + "fa78b7dee85d560d" + ], + "x": 275, + "y": 860, + "wires": [ + [ + "4a62662fea532976" + ] + ] + }, + { + "id": "78925efc4a55f04d", + "type": "link out", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "link out 12", + "mode": "link", + "links": [ + "3c80936f0a0918c3" + ], + "x": 1685, + "y": 420, + "wires": [] + }, + { + "id": "3c80936f0a0918c3", + "type": "link in", + "z": "05d4cb231221b842", + "g": "d9a9ee7bc71b0f53", + "name": "link in 11", + "links": [ + "78925efc4a55f04d" + ], + "x": 265, + "y": 60, + "wires": [ + [ + "9748899355370bae" + ] + ] + }, + { + "id": "875d5f193c768af0", + "type": "link out", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "link out 13", + "mode": "link", + "links": [ + "f27352133669b6fa" + ], + "x": 2065, + "y": 380, + "wires": [] + }, + { + "id": "f27352133669b6fa", + "type": "link in", + "z": "05d4cb231221b842", + "g": "d9a9ee7bc71b0f53", + "name": "link in 12", + "links": [ + "875d5f193c768af0", + "7e94b5651ed96f24" + ], + "x": 895, + "y": 80, + "wires": [ + [ + "8e60972fea4bd36a" + ] + ] + }, + { + "id": "7e94b5651ed96f24", + "type": "link out", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "link out 14", + "mode": "link", + "links": [ + "f27352133669b6fa" + ], + "x": 2215, + "y": 420, + "wires": [] + }, + { + "id": "05112cf4f0821cfd", + "type": "link out", + "z": "05d4cb231221b842", + "g": "6e514144a570aa72", + "name": "link out 15", + "mode": "link", + "links": [ + "5109df0f8b1e20e3" + ], + "x": 1505, + "y": 200, + "wires": [] + }, + { + "id": "5109df0f8b1e20e3", + "type": "link in", + "z": "05d4cb231221b842", + "g": "9221454c45afd1ba", + "name": "link in 13", + "links": [ + "05112cf4f0821cfd" + ], + "x": 1235, + "y": 80, + "wires": [ + [ + "25a2e7a04827039a" + ] + ] + }, + { + "id": "245a057bdff1fc14", + "type": "link out", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "link out 16", + "mode": "link", + "links": [], + "x": 1535, + "y": 440, + "wires": [] + }, + { + "id": "ba626f3a3b37e653", + "type": "function", + "z": "05d4cb231221b842", + "g": "878b79013722e91f", + "name": "Shift Config Handler", + "func": "// Shift Config Handler\nconst topic = msg.topic || \"\";\nconst config = global.get(\"config\") || {};\nconst readOnly = config.settingsReadOnly !== false;\nconst settings = global.get(\"settings\") || {};\n\nif (readOnly && (topic === \"saveShiftConfig\" || topic === \"saveThresholdConfig\" || topic === \"saveAllSettings\")) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Read-only\" });\n return null;\n}\n\n// Save shift config\nif (topic === \"saveShiftConfig\") {\n const config = msg.payload || {};\n settings.shifts = config.shifts || [];\n settings.shiftChangeCompensation = config.shiftChangeCompensation || 10;\n settings.lunchBreakMinutes = config.lunchBreakMinutes || 30;\n global.set(\"settings\", settings);\n\n node.status({ fill: \"green\", shape: \"dot\", text: `${config.shifts.length} sh ${config.shiftChangeCompensation} ch ${config.shiftChangeCompensation} lu` });\n return null;\n}\n\n// Save threshold config\nif (topic === \"saveThresholdConfig\") {\n const config = msg.payload || {};\n settings.thresholdMultiplier = config.thresholdMultiplier || 1.5;\n settings.macroStoppageMultiplier = config.macroStoppageMultiplier || 5;\n settings.oeeAlertThreshold = config.oeeAlertThreshold || 90;\n global.set(\"settings\", settings);\n\n node.status({ fill: \"green\", shape: \"dot\", text: `Threshold: ${config.thresholdMultiplier}x / ${config.macroStoppageMultiplier}x` });\n return null;\n}\n\n// Load shift config\nif (topic === \"getShiftConfig\") {\n msg.topic = \"shiftConfigData\";\n msg.payload = {\n shifts: settings.shifts || [{ start: \"08:00\", end: \"16:00\" }],\n shiftChangeCompensation: settings.shiftChangeCompensation || 10,\n lunchBreakMinutes: settings.lunchBreakMinutes || 30,\n thresholdMultiplier: settings.thresholdMultiplier || 1.5,\n macroStoppageMultiplier: settings.macroStoppageMultiplier || 5,\n oeeAlertThreshold: settings.oeeAlertThreshold || 90\n };\n return msg; // Send back to UI\n}\n\n// Save all settings at once\nif (topic === \"getShiftConfig\") {\n msg.topic = \"shiftConfigData\";\n msg.payload = {\n shifts: settings.shifts || [{ start: \"08:00\", end: \"16:00\" }],\n shiftChangeCompensation: settings.shiftChangeCompensation || 10,\n lunchBreakMinutes: settings.lunchBreakMinutes || 30,\n thresholdMultiplier: settings.thresholdMultiplier || 1.5,\n macroStoppageMultiplier: settings.macroStoppageMultiplier || 5,\n oeeAlertThreshold: settings.oeeAlertThreshold || 90\n };\n return msg; // Send back to UI\n}\n\nreturn null;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 400, + "y": 720, + "wires": [ + [ + "2c8562b2471078ab" + ] + ] + }, + { + "id": "2c8562b2471078ab", + "type": "link out", + "z": "05d4cb231221b842", + "g": "878b79013722e91f", + "name": "link out 17", + "mode": "link", + "links": [ + "6b3c45059b9b7c6c" + ], + "x": 525, + "y": 720, + "wires": [] + }, + { + "id": "09c77467731a6a66", + "type": "inject", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "KPI Tick", + "props": [ + { + "p": "payload" + } + ], + "repeat": "1", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "1", + "payloadType": "num", + "x": 1200, + "y": 600, + "wires": [ + [ + "a3d41a656eb3b2ce" + ] + ] + }, + { + "id": "5fa1dfb48ee969e0", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 2", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "false", + "statusVal": "", + "statusType": "auto", + "x": 2360, + "y": 140, + "wires": [] + }, + { + "id": "ce36a3271d9df8ae", + "type": "function", + "z": "05d4cb231221b842", + "name": "function 1", + "func": "let last = context.get('last') || 0;\nlet current = Number(msg.payload);\nif (current !== last) {\n context.set('last', current);\n return msg;\n}\nreturn null;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 2400, + "y": 240, + "wires": [ + [ + "5fa1dfb48ee969e0", + "093d9631fbd43003" + ] + ] + }, + { + "id": "5df5be609ff6e622", + "type": "switch", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "", + "property": "topic", + "propertyType": "msg", + "rules": [ + { + "t": "istype", + "v": "string", + "vt": "string" + } + ], + "checkall": "true", + "repair": false, + "outputs": 1, + "x": 1530, + "y": 820, + "wires": [ + [ + "bfb9b7c7af23bd5c" + ] + ] + }, + { + "id": "7aac414134d40b3a", + "type": "change", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Reset saveKpis flag", + "rules": [ + { + "t": "set", + "p": "state", + "pt": "global", + "to": "$merge([($globalContext(\"state\") ? $globalContext(\"state\") : {}), {\"saveKpis\": 0}])", + "tot": "jsonata" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 2200, + "y": 660, + "wires": [ + [] + ] + }, + { + "id": "25dfb62e7131af6b", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 4", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 2140, + "y": 80, + "wires": [] + }, + { + "id": "472e0f6f0c888c6c", + "type": "rpi-gpio in", + "z": "05d4cb231221b842", + "d": true, + "name": "", + "pin": "17", + "intype": "up", + "debounce": "25", + "read": true, + "bcm": true, + "x": 2220, + "y": 200, + "wires": [ + [ + "ce36a3271d9df8ae" + ] + ] + }, + { + "id": "d098028be97741ba", + "type": "function", + "z": "05d4cb231221b842", + "name": "Build Current State Snapshot", + "func": "// Build Current State Snapshot (outbox payload)\n\nconst config = global.get(\"config\") || {};\nconst machineId = config.machineId;\nconst now = Date.now();\nconst state = global.get(\"state\") || {};\nconst snapshot = state.lastState;\nconst settings = global.get(\"settings\") || {};\nconst lastMoldActive = Number(state.lastMoldActive ?? 0);\nconst moldActive = Number(\n snapshot?.activeWorkOrder?.cavities ??\n lastMoldActive ??\n snapshot?.cavities ??\n settings.moldActive ??\n global.get(\"moldActive\") ??\n 0\n);\nconst cavities = moldActive > 0 ? moldActive : null;\nmsg.tsMs = now;\n\n// This is the state you already have (from UI or from your state builder)\n// ✨ FIX: usar state.lastState como fuente de verdad (no msg.payload).\n// msg.payload puede traer datos viejos/incompletos del nodo anterior.\n// state.lastState SÍ se sincroniza en cada cambio relevante (Work Order buttons,\n// Back to UI, Progress Check Handler, State Accumulator).\nconst s = state.lastState || msg.payload || {};\n\nmsg._mode = \"current-state\";\n\n// what you POST to cloud\nmsg.payload = {\n machineId: machineId,\n tsMs: now,\n activeWorkOrder: s.activeWorkOrder ?? null,\n cycle_count: s.cycleCount ?? null,\n good_parts: s.goodParts ?? null,\n scrap_parts: s.scrapParts ?? null,\n cavities,\n cycleTime: s.cycleTime ?? null,\n actualCycleTime: s.actualCycleTime ?? null,\n trackingEnabled: s.trackingEnabled ?? null,\n productionStarted: s.productionStarted ?? null,\n kpis: s.kpis ?? null,\n};\n\n// what goes into outbox (MUST MATCH what you intend to send)\nconst p = msg.payload || {};\n\nmsg.outbox = {\n type: \"kpi\",\n payload: {\n tsMs: p.tsMs,\n activeWorkOrder: p.activeWorkOrder,\n cycle_count: p.cycle_count,\n good_parts: p.good_parts,\n scrap_parts: p.scrap_parts,\n cavities: p.cavities,\n cycleTime: p.cycleTime,\n actualCycleTime: p.actualCycleTime,\n trackingEnabled: p.trackingEnabled,\n productionStarted: p.productionStarted,\n kpis: p.kpis,\n },\n};\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 2420, + "y": 980, + "wires": [ + [ + "d90934911557dde5" + ] + ] + }, + { + "id": "bf17a2d4b88f7694", + "type": "function", + "z": "05d4cb231221b842", + "name": "Build Event Outbox Payload", + "func": "// Build event outbox payload(s) — handle both single event and array (from Anomaly Detector).\n// Original single-event behavior preserved byte-for-byte for scrap-entry and\n// downtime-acknowledged paths. Array input emits one outbox message per anomaly.\n\nconst anomaly = global.get(\"anomaly\") || {};\n\nconst incoming = msg.payload;\nconst rawEvents = Array.isArray(incoming)\n ? incoming\n : (incoming && typeof incoming === \"object\" ? [incoming] : []);\nif (rawEvents.length === 0) return null;\n\nfor (const raw of rawEvents) {\n if (!raw || typeof raw !== \"object\") continue;\n\n const event = { ...raw };\n const tsMs = typeof event.tsMs === \"number\" ? event.tsMs : Date.now();\n\n // ✨ P0.1: incidentKey unificado. Prioridad:\n // 1. event.incidentKey (puesto explícitamente por Anomaly Detector)\n // 2. event.incident_key\n // 3. event.data.incidentKey (compatibilidad con CT)\n // 4. fallback construido como downtime:: (sin anomaly_type)\n const incidentKey =\n event.incidentKey ||\n event.incident_key ||\n (event.data && event.data.incidentKey) ||\n (event.data && event.data.last_cycle_timestamp\n ? [\"downtime\",\n event.work_order_id || event.workOrderId || \"\",\n String(event.data.last_cycle_timestamp)].join(\":\")\n : null);\n\n const reasonFromStore = incidentKey && anomaly.reasonsByIncident\n ? anomaly.reasonsByIncident[incidentKey]\n : null;\n if (!event.reason && reasonFromStore) event.reason = reasonFromStore;\n\n const anomalyType = event.anomaly_type || event.anomalyType || null;\n const isDowntimeType = anomalyType === \"microstop\" || anomalyType === \"macrostop\";\n if (!event.downtime) {\n event.downtime = isDowntimeType ? {\n incidentKey: incidentKey || null,\n anomalyType,\n durationSeconds: event.data && Number(event.data.stoppage_duration_seconds) || null\n } : null;\n }\n\n node.send({\n ...msg,\n tsMs,\n outbox: { type: \"event\", payload: { event } }\n });\n}\n\nreturn null;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 2540, + "y": 780, + "wires": [ + [ + "e120c11f093ebd9b" + ] + ] + }, + { + "id": "ea46abcb46717837", + "type": "inject", + "z": "05d4cb231221b842", + "name": "", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "120", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 2290, + "y": 700, + "wires": [ + [ + "f2998a8d050e7d3f" + ] + ] + }, + { + "id": "f2998a8d050e7d3f", + "type": "function", + "z": "05d4cb231221b842", + "name": "Online HeartBeat", + "func": "// Heartbeat Producer (feeds Outbox Enqueue v1)\n\nconst config = global.get(\"config\") || {};\nconst machineId = msg.machineId || config.machineId;\nif (!machineId) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Heartbeat waiting for pairing\" });\n return null;\n}\nmsg.machineId = machineId;\n\n// Edge heartbeat = \"I am alive and running Node-RED\"\nconst status = \"ONLINE\";\n\n// pull these from globals if possible (set once at boot)\nconst ip = config.edgeIp || msg.ip || \"192.168.18.33\";\nconst fwVersion = config.fwVersion || \"raspi-nodered-1.0\";\nconst message = \"NR heartbeat\";\n\n// ---- DEDUPE / THROTTLE ----\n// Only enqueue if changed OR interval elapsed\nconst now = Date.now();\nmsg.tsMs = now;\nmsg.tsDevice = now; // epoch ms for API v1 (same instant as tsMs)\nconst intervalMs = Number(config.heartbeatIntervalMs || 15000);\n\n// Only include \"stable\" fields in signature; don't include timestamps\nconst signature = JSON.stringify({ status, ip, fwVersion });\n\nconst last = flow.get(\"hb_last\");\nif (last && last.signature === signature && (now - last.tsMs) < intervalMs) {\n return null; // skip enqueue (prevents spamming)\n}\nflow.set(\"hb_last\", { signature, tsMs: now });\n\n// This is what Outbox Enqueue v1 consumes\nmsg.outbox = {\n type: \"heartbeat\",\n payload: { status, message, ip, fwVersion },\n};\n\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 2540, + "y": 640, + "wires": [ + [ + "5b485289491bb538" + ] + ] + }, + { + "id": "3c78c68f64843c22", + "type": "function", + "z": "05d4cb231221b842", + "name": "Build Cycle Outbox Payload", + "func": "// Build cycle outbox payload (direct HTTP disabled)\n\nconst config = global.get(\"config\") || {};\nconst machineId = config.machineId;\n\nif (!msg.cycleRow) return null;\n\nmsg.tsMs = typeof msg.cycleRow.tsMs === \"number\" ? msg.cycleRow.tsMs : Date.now();\n\n// Deduplicate (extra safety)\nconst dedupeKey = `cc:${msg.cycleRow.cycle_count}`;\nconst lastKey = flow.get(\"lastCyclePostedKey\");\nif (lastKey === dedupeKey) return null;\nflow.set(\"lastCyclePostedKey\", dedupeKey);\n\n// For debugging visibility\nmsg._debug = {\n machineId: machineId,\n cycle_count: msg.cycleRow.cycle_count,\n actual_cycle_time: msg.cycleRow.actual_cycle_time,\n theoretical_cycle_time: msg.cycleRow.theoretical_cycle_time,\n};\n\nmsg.outbox = {\n type: \"cycle\",\n payload: { cycle: msg.cycleRow },\n};\n\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 2540, + "y": 720, + "wires": [ + [ + "e120c11f093ebd9b" + ] + ] + }, + { + "id": "f197ef50dc5354d4", + "type": "http request", + "z": "05d4cb231221b842", + "d": true, + "name": "Legacy Direct Cycle HTTP (disabled)", + "method": "POST", + "ret": "txt", + "paytoqs": "ignore", + "url": "http://mis.maliountech.com.mx/api/ingest/cycle", + "tls": "", + "persist": false, + "proxy": "", + "insecureHTTPParser": false, + "authType": "", + "senderr": false, + "headers": [ + { + "keyType": "Content-Type", + "keyValue": "", + "valueType": "other", + "valueValue": "application/json" + }, + { + "keyType": "other", + "keyValue": "x-api-key", + "valueType": "other", + "valueValue": "e0113ab0688769179fce30a44d21e4fd0576747b0886e9a1" + } + ], + "x": 2770, + "y": 320, + "wires": [ + [ + "fa101d173bfce159" + ] + ] + }, + { + "id": "fa101d173bfce159", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 9", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 2660, + "y": 180, + "wires": [] + }, + { + "id": "83a5536b1b938477", + "type": "inject", + "z": "05d4cb231221b842", + "name": "", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "0", + "payload": "1", + "payloadType": "num", + "x": 2500, + "y": 360, + "wires": [ + [ + "ce36a3271d9df8ae" + ] + ] + }, + { + "id": "e120c11f093ebd9b", + "type": "subflow:080d227df7fb2db1", + "z": "05d4cb231221b842", + "name": "Outbox Enqueue v1", + "x": 2920, + "y": 780, + "wires": [ + [ + "6ae3a59730db0e5c", + "b5ddb99732d4fd14" + ] + ] + }, + { + "id": "915121c1c41661ba", + "type": "inject", + "z": "05d4cb231221b842", + "name": "Publisher tick", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "10", + "crontab": "", + "once": true, + "onceDelay": 0.5, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 2480, + "y": 920, + "wires": [ + [ + "b5ddb99732d4fd14" + ] + ] + }, + { + "id": "01785ce1bc3d7919", + "type": "mysql", + "z": "05d4cb231221b842", + "mydb": "fc9634aabefee16b", + "name": "Fetch pending outbox", + "x": 3040, + "y": 880, + "wires": [ + [ + "ef6299b3d440c194" + ] + ] + }, + { + "id": "b5ddb99732d4fd14", + "type": "function", + "z": "05d4cb231221b842", + "name": "Select pending batch", + "func": "// Lock de flow: si la ronda anterior aún no termina, saltar este tick\nif (flow.get(\"publisherBusy\")) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"busy, skipping\" });\n return null;\n}\nflow.set(\"publisherBusy\", true);\n\n// Marcar como \"sending\" hasta 25 filas pendientes (atómico)\nmsg.topic = `\nUPDATE outbox_messages\nSET status='sending'\nWHERE status='pending'\n AND (next_attempt_at IS NULL OR next_attempt_at <= NOW())\nORDER BY id ASC\nLIMIT 25;\n`.trim();\n\nmsg._stage = \"claim\";\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 2800, + "y": 880, + "wires": [ + [ + "01785ce1bc3d7919", + "a6bc13525d5bfc63" + ] + ] + }, + { + "id": "85a333219d51a809", + "type": "switch", + "z": "05d4cb231221b842", + "name": "Has rows?", + "property": "payload", + "propertyType": "msg", + "rules": [ + { + "t": "eq", + "v": "", + "vt": "str" + }, + { + "t": "neq", + "v": "", + "vt": "str" + } + ], + "checkall": "true", + "repair": false, + "outputs": 2, + "x": 3270, + "y": 880, + "wires": [ + [ + "5dbbcccbadfb8038" + ], + [ + "96288a2a99c5611f" + ] + ] + }, + { + "id": "96288a2a99c5611f", + "type": "split", + "z": "05d4cb231221b842", + "name": "Split rows", + "splt": "\\n", + "spltType": "str", + "arraySplt": 1, + "arraySpltType": "len", + "stream": false, + "addname": "", + "property": "payload", + "x": 3440, + "y": 880, + "wires": [ + [ + "391080652f38d9eb" + ] + ] + }, + { + "id": "391080652f38d9eb", + "type": "function", + "z": "05d4cb231221b842", + "name": "Build HTTP request", + "func": "const row = msg.payload; // row from DB\n\nconst config = global.get(\"config\") || {};\nconst base = config.cloudBaseUrl;\nconst apiKey = config.apiKey;\n\nconst machineId = config.machineId;\n\nif (machineId && row && row.machine_id && String(row.machine_id) !== String(machineId)) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Stale row (machine mismatch)\" });\n msg.topic = `\nUPDATE outbox_messages\nSET status='failed',\n last_http_status=?,\n last_error=?,\n next_attempt_at=NULL\nWHERE id=?;\n`.trim();\n msg.payload = [409, \"stale_machine\", Number(row.id)];\n return [null, msg];\n}\n\n\n\nif (!base || !apiKey) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Publisher waiting for pairing\" });\n return null;\n}\n\nconst baseUrl = String(base).replace(/\\/+$/, \"\");\nconst endpoint = String(row.endpoint || \"\");\nif (!endpoint.startsWith(\"/\")) throw new Error(\"Publisher: bad endpoint on row \" + row.id);\n\nmsg._row = row;\n\nmsg.method = row.method || \"POST\";\nmsg.url = baseUrl + endpoint;\n\nmsg.headers = {\n \"Content-Type\": \"application/json\",\n \"x-api-key\": apiKey,\n};\n\n// payload_json may be string (most common) or object depending on mysql node\nlet payload = row.payload_json;\nif (typeof payload === \"string\") {\n try { payload = JSON.parse(payload); } catch (e) { }\n}\nmsg.payload = payload;\n\nreturn msg;\n", + "outputs": 2, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 3610, + "y": 880, + "wires": [ + [ + "b00c9fddfd900764", + "01fa15279b7facf2" + ], + [ + "039f8b69df0eae25" + ] + ] + }, + { + "id": "4770e1bb4693f2f2", + "type": "http request", + "z": "05d4cb231221b842", + "name": "Send outbox HTTP", + "method": "use", + "ret": "txt", + "paytoqs": "ignore", + "url": "", + "tls": "", + "persist": false, + "proxy": "", + "insecureHTTPParser": false, + "authType": "", + "senderr": false, + "headers": [], + "x": 3710, + "y": 680, + "wires": [ + [ + "2a40881a42a2bc54" + ] + ] + }, + { + "id": "2a40881a42a2bc54", + "type": "switch", + "z": "05d4cb231221b842", + "name": "HTTP 200?", + "property": "statusCode", + "propertyType": "msg", + "rules": [ + { + "t": "eq", + "v": "200", + "vt": "str" + }, + { + "t": "neq", + "v": "200", + "vt": "str" + } + ], + "checkall": "true", + "repair": false, + "outputs": 2, + "x": 3850, + "y": 880, + "wires": [ + [ + "ee9c83b346454502" + ], + [ + "219c83a26541c43e" + ] + ] + }, + { + "id": "ee9c83b346454502", + "type": "function", + "z": "05d4cb231221b842", + "name": "Mark Sent", + "func": "const row = msg._row;\nconst status = Number(msg.statusCode ?? 0);\n\nmsg.topic = `\nUPDATE outbox_messages\nSET status='sent',\n sent_at=NOW(),\n last_http_status=?,\n last_error=NULL\nWHERE id=? AND status='sending';\n`.trim();\n\nmsg.payload = [status, Number(row.id)];\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 4020, + "y": 860, + "wires": [ + [ + "039f8b69df0eae25" + ] + ] + }, + { + "id": "219c83a26541c43e", + "type": "function", + "z": "05d4cb231221b842", + "name": "Retry", + "func": "const row = msg._row;\nconst attempts = Number(row.attempts || 0) + 1;\n\nconst retryConfig = {\n shortDelaySec: 5,\n mediumDelaySec: 30,\n longDelaySec: 180,\n mediumAfter: 5,\n longAfter: 20,\n errorMaxLen: 450,\n};\n\nconst status = Number(msg.statusCode || 0);\nlet err = \"\";\nif (msg.payload && typeof msg.payload === \"object\") {\n err = JSON.stringify(msg.payload).slice(0, retryConfig.errorMaxLen);\n} else if (msg.payload != null) {\n err = String(msg.payload).slice(0, retryConfig.errorMaxLen);\n} else {\n err = \"request_failed\";\n}\n\nif (status === 401 || status === 403) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Unauthorized - re-pair\" });\n msg.topic = `\nUPDATE outbox_messages\nSET status='failed',\n last_http_status=?,\n last_error=?,\n next_attempt_at=NULL\nWHERE id=?;\n`.trim();\n msg.payload = [ status || null, err, Number(row.id) ];\n return msg;\n}\n\n// Backoff policy\nlet delaySec = retryConfig.shortDelaySec;\nif (attempts <= retryConfig.mediumAfter) delaySec = retryConfig.shortDelaySec;\nelse if (attempts <= retryConfig.longAfter) delaySec = retryConfig.mediumDelaySec;\nelse delaySec = retryConfig.longDelaySec;\n\nmsg.topic = `\nUPDATE outbox_messages\nSET status='pending',\n attempts=?,\n next_attempt_at=DATE_ADD(NOW(), INTERVAL ? SECOND),\n last_http_status=?,\n last_error=?\nWHERE id=? AND status='sending';\n`.trim();\n\nmsg.payload = [ attempts, delaySec, status || null, err, Number(row.id) ];\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 4010, + "y": 900, + "wires": [ + [ + "039f8b69df0eae25" + ] + ] + }, + { + "id": "039f8b69df0eae25", + "type": "mysql", + "z": "05d4cb231221b842", + "mydb": "fc9634aabefee16b", + "name": "Update outbox status", + "x": 4360, + "y": 900, + "wires": [ + [ + "acacb8894c9125bc", + "c7f59092785681d1" + ] + ] + }, + { + "id": "79e027bf3befb2d9", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "State Accumulator global", + "func": "const config = global.get(\"config\") || {};\nconst state = global.get(\"state\") || {};\nconst settings = global.get(\"settings\") || {};\nconst machineId = config.machineId; // set this once at boot (see below)\nif (!machineId) { node.warn(\"lastState: missing config.machineId\"); return null; }\nconst active = state.activeWorkOrder || null;\nconst trackingEnabled = !!state.trackingEnabled;\nconst productionStarted = !!state.productionStarted;\nconst kpis = msg.kpis || state.currentKPIs || { oee: 0, availability: 0, performance: 0, quality: 0 };\nconst moldByWorkOrder = state.moldByWorkOrder || {};\nconst moldActive = Number(\n active?.cavities ??\n moldByWorkOrder[active?.id]?.active ??\n state.lastMoldActive ??\n settings.moldActive ??\n global.get(\"moldActive\") ??\n 0\n);\nconst cavities = moldActive > 0 ? moldActive : null;\n\nconst lastState = {\n machineId,\n activeWorkOrder: active,\n cycleCount: Number(state.cycleCount ?? 0),\n goodParts: Number(active?.goodParts ?? 0),\n scrapParts: Number(active?.scrapParts ?? 0),\n cavities,\n cycleTime: Number(active?.cycleTime ?? state.lastCycleTime ?? 0),\n actualCycleTime: Number(state.lastActualCycleTime ?? 0),\n trackingEnabled,\n productionStarted,\n kpis,\n tsMs: Date.now(),\n};\n\nstate.lastState = lastState;\nglobal.set(\"state\", state);\nmsg.lastState = lastState;\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1690, + "y": 760, + "wires": [ + [] + ] + }, + { + "id": "7c214a102e0172e1", + "type": "inject", + "z": "05d4cb231221b842", + "name": "KPI state getter", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "60", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 2210, + "y": 1020, + "wires": [ + [ + "37c463333a5b3799" + ] + ] + }, + { + "id": "37c463333a5b3799", + "type": "function", + "z": "05d4cb231221b842", + "name": "Build KPI Outbox from lastState", + "func": "// Build KPI Outbox from lastState\n\nconst config = global.get(\"config\") || {};\nconst state = global.get(\"state\") || {};\nconst snapshot = state.lastState;\nconst settings = global.get(\"settings\") || {};\nconst moldByWorkOrder = state.moldByWorkOrder || {};\nconst moldActive = Number(settings.moldActive ?? global.get(\"moldActive\") ?? snapshot?.activeWorkOrder?.cavities ?? 0);\n//const cavities = moldActive > 0 ? moldActive : snapshot?.cavities ?? null;\n\nif (!snapshot) return null;\n\nconst machineId = snapshot.machineId || config.machineId;\nif (!machineId) return null;\n\nmsg.machineId = machineId;\n\nconst activeWorkOrder = snapshot?.activeWorkOrder ? { ...snapshot.activeWorkOrder } : null;\nif (activeWorkOrder?.lastUpdateIso && typeof activeWorkOrder.lastUpdateIso !== \"string\") {\n delete activeWorkOrder.lastUpdateIso;\n}\n\nconst cavitiesRaw = Number(\n activeWorkOrder?.cavities ??\n moldByWorkOrder[activeWorkOrder?.id]?.active ??\n snapshot?.cavities ??\n state.lastMoldActive ??\n settings.moldActive ??\n global.get(\"moldActive\") ??\n 0\n);\nconst cavities = cavitiesRaw > 0 ? cavitiesRaw : null;\n\n\n\nmsg.outbox = {\n type: \"kpi\",\n payload: {\n tsMs: snapshot.tsMs,\n activeWorkOrder,\n cycle_count: snapshot.cycleCount,\n good_parts: snapshot.goodParts,\n scrap_parts: snapshot.scrapParts,\n cavities,\n cycleTime: snapshot.cycleTime,\n actualCycleTime: snapshot.actualCycleTime,\n trackingEnabled: snapshot.trackingEnabled,\n productionStarted: snapshot.productionStarted,\n kpis: snapshot.kpis,\n },\n};\n\nmsg.tsMs = typeof snapshot.tsMs === \"number\" ? snapshot.tsMs : Date.now();\n\n// keep timestamp so you can see it ticking\nmsg.payload = msg.tsMs;\nmsg.topic = \"\";\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 2450, + "y": 1020, + "wires": [ + [ + "20bb0702bcbc8ed8" + ] + ] + }, + { + "id": "6ae3a59730db0e5c", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 1", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 3190, + "y": 1120, + "wires": [] + }, + { + "id": "acacb8894c9125bc", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 3", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 4540, + "y": 1100, + "wires": [] + }, + { + "id": "d90934911557dde5", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 5", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 2590, + "y": 1220, + "wires": [] + }, + { + "id": "20bb0702bcbc8ed8", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 6", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 3030, + "y": 1200, + "wires": [] + }, + { + "id": "b00c9fddfd900764", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 7", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 3400, + "y": 1080, + "wires": [] + }, + { + "id": "a6bc13525d5bfc63", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 8", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 3050, + "y": 1020, + "wires": [] + }, + { + "id": "16ccb5b864e17142", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 10", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 2010, + "y": 1080, + "wires": [] + }, + { + "id": "ed8c202dde4b6ff9", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 11", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 2000, + "y": 1140, + "wires": [] + }, + { + "id": "b245b3157fa7ee3c", + "type": "inject", + "z": "05d4cb231221b842", + "name": "Init config inject", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 3250, + "y": 220, + "wires": [ + [ + "4e7a2904be0f9a37" + ] + ] + }, + { + "id": "063447515a79e473", + "type": "function", + "z": "05d4cb231221b842", + "g": "878b79013722e91f", + "name": "Pair Machine Request", + "func": "const topic = msg.topic || \"\";\nif (topic != \"pairMachine\") {\n return null;\n}\n\nconst raw = (msg.payload && (msg.payload.code || msg.payload.pairingCode || msg.payload)) || \"\";\nconst code = String(raw).trim().toUpperCase().replace(/[^A-Z0-9]/g, \"\");\n\nif (code.length != 5) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Bad code\" });\n return [null, { topic: \"pairMachineResult\", payload: { ok: false, error: \"Codigo invalido.\" } }];\n}\n\nconst config = global.get(\"config\") || {};\nconst baseUrl = String(config.cloudBaseUrl || \"https://mis.maliountech.com.mx\").replace(/\\/+$/, \"\");\n\nmsg.method = \"POST\";\nmsg.url = baseUrl + \"/api/machines/pair\";\nmsg.headers = { \"Content-Type\": \"application/json\" };\nmsg.payload = { code: code };\n\nnode.status({ fill: \"blue\", shape: \"dot\", text: \"Pairing...\" });\nreturn [msg, null];\n", + "outputs": 2, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 430, + "y": 780, + "wires": [ + [ + "43893f8a2123b078" + ], + [ + "2c8562b2471078ab" + ] + ] + }, + { + "id": "43893f8a2123b078", + "type": "http request", + "z": "05d4cb231221b842", + "name": "Pair Machine HTTP", + "method": "use", + "ret": "obj", + "paytoqs": "ignore", + "url": "", + "tls": "", + "persist": false, + "proxy": "", + "insecureHTTPParser": false, + "authType": "", + "senderr": false, + "headers": [], + "x": 640, + "y": 780, + "wires": [ + [ + "2d87bd0e7c2e45af" + ] + ] + }, + { + "id": "2d87bd0e7c2e45af", + "type": "function", + "z": "05d4cb231221b842", + "g": "878b79013722e91f", + "name": "Pair Machine Response", + "func": "let res = msg.payload;\nif (typeof res == \"string\") {\n try { res = JSON.parse(res); } catch (e) { res = { error: res }; }\n}\nres = res || {};\n\nconst ok = res.ok === true || res.success === true;\nconst cfg = res.config || res;\n\nif (ok && cfg && cfg.machineId && cfg.apiKey) {\n const current = global.get(\"config\") || {};\n current.cloudBaseUrl = cfg.cloudBaseUrl || current.cloudBaseUrl;\n current.machineId = cfg.machineId;\n current.apiKey = cfg.apiKey;\n current.orgId = cfg.orgId || current.orgId;\n global.set(\"config\", current);\n\n node.status({ fill: \"green\", shape: \"dot\", text: \"Paired\" });\n msg.topic = \"pairMachineResult\";\n msg.payload = { ok: true, machineId: current.machineId };\n return msg;\n}\n\nconst error = (res && (res.error || res.message)) || (msg.statusCode ? (\"HTTP \" + msg.statusCode) : \"Pairing failed\");\nnode.status({ fill: \"red\", shape: \"ring\", text: \"Pair failed\" });\nmsg.topic = \"pairMachineResult\";\nmsg.payload = { ok: false, error: error };\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 860, + "y": 780, + "wires": [ + [ + "2c8562b2471078ab", + "5a84fb165be942a5" + ] + ] + }, + { + "id": "39d72779cd51be9a", + "type": "link in", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "link into settings", + "links": [], + "x": 255, + "y": 440, + "wires": [ + [ + "afb514404a6ecda1" + ] + ] + }, + { + "id": "fe77ffa843b0dcfb", + "type": "switch", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "Wifi switch", + "property": "topic", + "propertyType": "msg", + "rules": [ + { + "t": "eq", + "v": "wifi:scan", + "vt": "str" + }, + { + "t": "eq", + "v": "wifi:status", + "vt": "str" + }, + { + "t": "eq", + "v": "wifi:apply", + "vt": "str" + } + ], + "checkall": "true", + "repair": false, + "outputs": 3, + "x": 700, + "y": 460, + "wires": [ + [ + "a981c7f429b397f0" + ], + [ + "ebebd1c90b0e488a" + ], + [ + "b699e16ddfa987d9" + ] + ] + }, + { + "id": "ebebd1c90b0e488a", + "type": "link out", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "wifi:status from settings", + "mode": "link", + "links": [], + "x": 815, + "y": 460, + "wires": [] + }, + { + "id": "a981c7f429b397f0", + "type": "link out", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "wifi:scan from settings", + "mode": "link", + "links": [], + "x": 815, + "y": 420, + "wires": [] + }, + { + "id": "b699e16ddfa987d9", + "type": "link out", + "z": "05d4cb231221b842", + "g": "def89ffb5f14d456", + "name": "wifi:apply from settings", + "mode": "link", + "links": [], + "x": 815, + "y": 500, + "wires": [] + }, + { + "id": "0be8578d40ec4541", + "type": "function", + "z": "05d4cb231221b842", + "name": "Parse settings update", + "func": "let payload = msg.payload;\n\nif (Buffer.isBuffer(payload)) {\n payload = payload.toString(\"utf8\");\n}\n\nif (typeof payload === \"string\") {\n try {\n payload = JSON.parse(payload);\n } catch (err) {\n payload = {};\n }\n}\n\nif (!payload || typeof payload !== \"object\") {\n payload = {};\n}\n\nconst topic = String(msg.topic || \"\");\nconst parts = topic.split(\"/\");\n\nfunction pickPart(label) {\n const idx = parts.indexOf(label);\n if (idx === -1) return null;\n return parts[idx + 1] || null;\n}\n\nconst orgId = payload.orgId || pickPart(\"org\");\nconst machineId = payload.machineId || pickPart(\"machines\");\n\nif (orgId) payload.orgId = orgId;\nif (machineId) payload.machineId = machineId;\n\nmsg.payload = payload;\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 420, + "y": 900, + "wires": [ + [ + "a71ab02358e3321d" + ] + ] + }, + { + "id": "a71ab02358e3321d", + "type": "function", + "z": "05d4cb231221b842", + "name": "Fetch settings from Control Tower", + "func": "const config = global.get(\"config\") || {};\nconst base = config.cloudBaseUrl;\nconst apiKey = config.apiKey;\nconst machineId = config.machineId;\n\nif (!base || !apiKey || !machineId) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Settings fetch waiting for pairing\" });\n return null;\n}\n\nconst update = msg.payload || {};\nconst forced = msg.topic === \"settingsRefresh\" || msg.forceRefresh === true;\n\nif (!forced) {\n if (update.machineId && update.machineId !== machineId) {\n return null;\n }\n if (config.orgId && update.orgId && update.orgId !== config.orgId) {\n return null;\n }\n}\n\nconst baseUrl = String(base).replace(/\\/+$/, \"\");\nmsg.method = \"GET\";\nmsg.url = baseUrl + \"/api/settings/machines/\" + machineId;\nmsg.headers = {\n \"x-api-key\": apiKey,\n \"Accept\": \"application/json\"\n};\n\nmsg._settingsUpdate = {\n orgId: update.orgId || config.orgId,\n machineId: update.machineId || machineId,\n version: update.version\n};\nmsg._settingsRequestedAt = Date.now();\n\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 720, + "y": 980, + "wires": [ + [ + "a959b56beda0970f" + ] + ] + }, + { + "id": "a959b56beda0970f", + "type": "http request", + "z": "05d4cb231221b842", + "name": "Fetch settings HTTP", + "method": "use", + "ret": "obj", + "paytoqs": "ignore", + "url": "", + "tls": "", + "persist": false, + "proxy": "", + "insecureHTTPParser": false, + "authType": "", + "senderr": false, + "headers": [], + "x": 1020, + "y": 920, + "wires": [ + [ + "abbec199700a5e29" + ] + ] + }, + { + "id": "abbec199700a5e29", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Apply settings + update UI", + "func": "let payload = msg.payload;\n\nif (Buffer.isBuffer(payload)) {\n payload = payload.toString(\"utf8\");\n}\n\nif (typeof payload === \"string\") {\n try {\n payload = JSON.parse(payload);\n } catch (err) {\n payload = {};\n }\n}\n\nif (!payload || payload.ok === false) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings fetch failed\" });\n return [null, null, null];\n}\n\nconst effective = payload.effectiveSettings || payload.settings || payload;\nif (!effective || typeof effective !== \"object\") {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Settings payload missing\" });\n return [null, null, null];\n}\n\nconst defaults = effective.defaults || {};\nconst shiftSchedule = effective.shiftSchedule || {};\nconst thresholds = effective.thresholds || {};\nconst incomingCatalog = effective.reasonCatalog || null;\n\nconst settings = global.get(\"settings\") || {};\n\nconst nextMoldTotal = Number(defaults.moldTotal ?? settings.moldTotal ?? 0);\nconst nextMoldActive = Number(defaults.moldActive ?? settings.moldActive ?? 0);\n\nsettings.moldTotal = nextMoldTotal;\nsettings.moldActive = nextMoldActive;\n\nif (Array.isArray(shiftSchedule.shifts) && shiftSchedule.shifts.length) {\n settings.shifts = shiftSchedule.shifts.map((s) => ({\n start: s.start,\n end: s.end\n }));\n} else if (!Array.isArray(settings.shifts)) {\n settings.shifts = [{ start: \"08:00\", end: \"16:00\" }];\n}\n\nsettings.shiftChangeCompensation = Number(\n shiftSchedule.shiftChangeCompensationMin ?? settings.shiftChangeCompensation ?? 10\n);\nsettings.lunchBreakMinutes = Number(\n shiftSchedule.lunchBreakMin ?? settings.lunchBreakMinutes ?? 30\n);\n\nsettings.thresholdMultiplier = Number(\n thresholds.stoppageMultiplier ?? settings.thresholdMultiplier ?? 1.5\n);\nsettings.macroStoppageMultiplier = Number(\n thresholds.macroStoppageMultiplier ?? settings.macroStoppageMultiplier ?? 5\n);\nsettings.oeeAlertThreshold = Number(\n thresholds.oeeAlertThresholdPct ?? settings.oeeAlertThreshold ?? 90\n);\n\nconst normalizeCatalogItems = (list, fallbackLabelPrefix) => {\n if (!Array.isArray(list)) return [];\n return list\n .map((c, idx) => {\n const categoryId = String(c.id || c.categoryId || (\"cat_\" + idx));\n const categoryLabel = String(c.label || c.categoryLabel || (fallbackLabelPrefix + \" \" + (idx + 1)));\n const detailsRaw = Array.isArray(c.children) ? c.children : (Array.isArray(c.details) ? c.details : []);\n const details = detailsRaw.map((d, jdx) => {\n const row = {\n id: String(d.id || d.detailId || (categoryId + \"_d\" + jdx)),\n label: String(d.label || d.detailLabel || (\"Detalle \" + (jdx + 1)))\n };\n if (d.reasonCode != null && String(d.reasonCode).trim()) {\n row.reasonCode = String(d.reasonCode).trim();\n } else if (d.code != null && String(d.code).trim()) {\n row.reasonCode = String(d.code).trim();\n }\n if (d.active === false) {\n row.active = false;\n }\n return row;\n });\n return {\n id: categoryId,\n label: categoryLabel,\n children: details\n };\n })\n .filter((c) => c.label && c.children.length > 0);\n};\n\nconst currentCatalog = settings.reasonCatalog || {};\nconst nextCatalogVersion =\n Number(\n (incomingCatalog && incomingCatalog.version) ??\n effective.reasonCatalogVersion ??\n currentCatalog.version ??\n 1\n ) || 1;\n\nconst hasIncomingCatalog = !!(incomingCatalog && (Array.isArray(incomingCatalog.downtime) || Array.isArray(incomingCatalog.scrap)));\nconst normalizedIncoming = hasIncomingCatalog ? {\n version: nextCatalogVersion,\n downtime: normalizeCatalogItems(incomingCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(incomingCatalog.scrap || [], \"Scrap\")\n} : null;\n\nconst fallbackCatalog = {\n version: Number(currentCatalog.version || nextCatalogVersion || 1),\n downtime: normalizeCatalogItems(currentCatalog.downtime || [], \"Paro\"),\n scrap: normalizeCatalogItems(currentCatalog.scrap || [], \"Scrap\")\n};\n\nsettings.reasonCatalog = normalizedIncoming || fallbackCatalog;\nsettings.reasonCatalog.version = Number(settings.reasonCatalog.version || 1);\n\n\nif (effective.version !== undefined) {\n settings.version = Number(effective.version);\n}\n\nglobal.set(\"settings\", settings);\ntry {\n global.set(\"settings\", settings, \"file\");\n} catch (err) {\n // ignore if file store is not configured\n}\nglobal.set(\"moldActive\", settings.moldActive);\nglobal.set(\"moldTotal\", settings.moldTotal);\n\nconst config = global.get(\"config\") || {};\nif (effective.orgId && !config.orgId) {\n config.orgId = effective.orgId;\n global.set(\"config\", config);\n}\n\nnode.status({ fill: \"green\", shape: \"dot\", text: \"Settings synced\" });\n\nconst uiConfigMsg = {\n topic: \"shiftConfigData\",\n payload: {\n shifts: settings.shifts || [],\n shiftChangeCompensation: settings.shiftChangeCompensation || 10,\n lunchBreakMinutes: settings.lunchBreakMinutes || 30,\n thresholdMultiplier: settings.thresholdMultiplier || 1.5,\n macroStoppageMultiplier: settings.macroStoppageMultiplier || 5,\n oeeAlertThreshold: settings.oeeAlertThreshold || 90\n }\n};\n\n\nconst uiMoldMsg = {\n topic: \"moldPresetSelected\",\n payload: {\n total: settings.moldTotal || 0,\n active: settings.moldActive || 0\n }\n};\n\nconst readOnly = config.settingsReadOnly !== false;\nconst uiReadOnlyMsg = { topic: \"settingsReadOnly\", payload: readOnly };\nconst uiReasonCatalogMsg = {\n topic: \"reasonCatalogData\",\n payload: settings.reasonCatalog\n};\n\nnode.send([uiConfigMsg, null, null]);\nnode.send([uiMoldMsg, null, null]);\nnode.send([uiReadOnlyMsg, null, null]);\nnode.send([uiReasonCatalogMsg, null, null]);\n\nconst update = msg._settingsUpdate || {};\nconst orgId = update.orgId || config.orgId || effective.orgId;\nconst machineId = update.machineId || config.machineId;\nconst version = Number(effective.version ?? update.version ?? 0);\n\nif (!orgId || !machineId) {\n return [null, null, null];\n}\n\nconst prefix = String(config.mqttTopicPrefix || \"mis\").replace(/\\/+$/, \"\");\nconst ackTopic = prefix + \"/org/\" + orgId + \"/machines/\" + machineId + \"/settings/ack\";\n\nconst ackMsg = {\n topic: ackTopic,\n payload: JSON.stringify({\n type: \"settings_ack\",\n orgId,\n machineId,\n version,\n source: \"node-red\",\n ts: new Date().toISOString()\n })\n};\n\nconst mirrorTrigger = { payload: { _syncReasonCatalog: true } };\nreturn [null, ackMsg, mirrorTrigger];\n", + "outputs": 3, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1280, + "y": 920, + "wires": [ + [ + "2c8562b2471078ab", + "dbfd127c516efa87", + "9748899355370bae" + ], + [], + [ + "f8e0d1c2b3a40911" + ] + ] + }, + { + "id": "f8e0d1c2b3a40911", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Build reason catalog mirror SQL", + "func": "const p = msg.payload || {};\nif (!p._syncReasonCatalog) {\n return null;\n}\nconst settings = global.get(\"settings\") || {};\nconst cat = settings.reasonCatalog || {};\nconst ver = Number(cat.version || 1);\nfunction esc(v) {\n return String(v ?? \"\").replace(/\\\\/g, \"\\\\\\\\\").replace(/'/g, \"''\");\n}\nconst parts = [];\nfunction walk(kind, list) {\n if (!Array.isArray(list)) {\n return;\n }\n let sort = 0;\n list.forEach((c) => {\n const categoryId = esc(String(c.id || \"\"));\n const categoryLabel = esc(String(c.label || \"\"));\n const ch = c.children || c.details || [];\n if (!Array.isArray(ch)) {\n return;\n }\n ch.forEach((d) => {\n const id = String(d.id || \"\").trim();\n const label = String(d.label || \"\").trim();\n const rc = String(d.reasonCode || d.code || id || \"\").trim();\n if (!rc) {\n return;\n }\n const active = d.active === false ? 0 : 1;\n parts.push(\n \"('\" +\n kind +\n \"','\" +\n categoryId +\n \"','\" +\n categoryLabel +\n \"','\" +\n esc(rc) +\n \"','\" +\n esc(label) +\n \"',\" +\n sort +\n \",\" +\n active +\n \",\" +\n ver +\n \")\"\n );\n sort += 1;\n });\n });\n}\nwalk(\"downtime\", cat.downtime || []);\nwalk(\"scrap\", cat.scrap || []);\nif (!parts.length) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"No reason rows to mirror\" });\n return null;\n}\nconst sql =\n \"INSERT INTO reason_catalog_row (kind,category_id,category_label,reason_code,reason_label,sort_order,active,catalog_version) VALUES \" +\n parts.join(\",\") +\n \" ON DUPLICATE KEY UPDATE category_id=VALUES(category_id),category_label=VALUES(category_label),reason_label=VALUES(reason_label),sort_order=VALUES(sort_order),active=VALUES(active),catalog_version=VALUES(catalog_version),updated_at=CURRENT_TIMESTAMP(3)\";\nnode.status({ fill: \"green\", shape: \"dot\", text: \"Reason mirror SQL built\" });\nmsg.topic = sql;\nmsg.payload = [];\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1500, + "y": 1020, + "wires": [ + [ + "f8e0d1c2b3a40912" + ] + ] + }, + { + "id": "f8e0d1c2b3a40912", + "type": "mysql", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "mydb": "fc9634aabefee16b", + "name": "Persist reason catalog mirror", + "x": 1820, + "y": 1020, + "wires": [ + [] + ] + }, + { + "id": "3d0065431c1b254c", + "type": "inject", + "z": "05d4cb231221b842", + "name": "Refresh settings (poll)", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "600", + "crontab": "", + "once": true, + "onceDelay": 1, + "topic": "settingsRefresh", + "payload": "", + "payloadType": "date", + "x": 240, + "y": 980, + "wires": [ + [ + "a71ab02358e3321d" + ] + ] + }, + { + "id": "c09c71a7e4908231", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Fetch work orders from Control Tower", + "func": "const config = global.get(\"config\") || {};\nconst base = config.cloudBaseUrl;\nconst apiKey = config.apiKey;\nconst machineId = config.machineId;\n\nif (!base || !apiKey || !machineId) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Work orders fetch waiting for pairing\" });\n return null;\n}\n\nconst update = msg.payload || {};\nif (update.machineId && update.machineId !== machineId) {\n return null;\n}\nif (config.orgId && update.orgId && update.orgId !== config.orgId) {\n return null;\n}\n\nconst baseUrl = String(base).replace(/\\/+$/, \"\");\nmsg.method = \"GET\";\nmsg.url = baseUrl + \"/api/work-orders/machines/\" + machineId;\nmsg.headers = {\n \"x-api-key\": apiKey,\n \"Accept\": \"application/json\"\n};\n\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1770, + "y": 320, + "wires": [ + [ + "f9bbe50ab55c42a1" + ] + ] + }, + { + "id": "f9bbe50ab55c42a1", + "type": "http request", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Fetch work orders HTTP", + "method": "use", + "ret": "obj", + "paytoqs": "ignore", + "url": "", + "tls": "", + "persist": false, + "proxy": "", + "insecureHTTPParser": false, + "authType": "", + "senderr": false, + "headers": [], + "x": 2060, + "y": 320, + "wires": [ + [ + "0638171f0c347095", + "d5155c4988b7c5f0" + ] + ] + }, + { + "id": "0638171f0c347095", + "type": "function", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "Upsert work orders to local DB", + "func": "let payload = msg.payload;\nif (Buffer.isBuffer(payload)) {\n payload = payload.toString(\"utf8\");\n}\nif (typeof payload === \"string\") {\n try {\n payload = JSON.parse(payload);\n } catch (err) {\n payload = {};\n }\n}\nif (!payload || payload.ok === false) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Work orders fetch failed\" });\n return null;\n}\n\nconst list = Array.isArray(payload.workOrders)\n ? payload.workOrders\n : Array.isArray(payload.orders)\n ? payload.orders\n : [];\n\nif (!list.length) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"No work orders\" });\n return null;\n}\n\nfunction intOrNull(value) {\n if (value === null || value === undefined || value === \"\") return null;\n const n = Number(value);\n if (!Number.isFinite(n) || n < 0) return null;\n return Math.trunc(n);\n}\n\nfunction strOrNull(value, maxLen) {\n if (value === null || value === undefined) return null;\n const s = String(value).trim();\n if (!s) return null;\n return maxLen ? s.slice(0, maxLen) : s;\n}\n\nconst seen = new Set();\nconst values = [];\n\nlist.forEach((order) => {\n const id = String(\n order.workOrderId || order.id || order.work_order_id || \"\"\n ).trim();\n if (!id || seen.has(id)) return;\n seen.add(id);\n\n const sku = String(order.sku || \"\").trim();\n\n const targetQtyRaw = order.targetQty ?? order.target_qty ?? order.target ?? 0;\n const cycleTimeRaw = order.cycleTime ?? order.theoreticalCycleTime ?? order.theoretical_cycle_time ?? 0;\n\n const targetQty = Number.isFinite(Number(targetQtyRaw)) ? Math.trunc(Number(targetQtyRaw)) : 0;\n const cycleTime = Number.isFinite(Number(cycleTimeRaw)) ? Number(cycleTimeRaw) : 0;\n\n // ✨ NUEVO: leer mold y cavidades\n const mold = strOrNull(order.mold ?? order.moldId ?? order.mold_id, 50);\n const cavitiesTotal = intOrNull(order.cavitiesTotal ?? order.cavities_total);\n const cavitiesActive = intOrNull(order.cavitiesActive ?? order.cavities_active);\n\n values.push([id, sku, targetQty, cycleTime, \"PENDING\", mold, cavitiesTotal, cavitiesActive]);\n});\n\nif (!values.length) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"No valid work orders\" });\n return null;\n}\n\nmsg.topic = `\n INSERT INTO work_orders\n (work_order_id, sku, target_qty, cycle_time, status, mold, cavities_total, cavities_active)\n VALUES ?\n ON DUPLICATE KEY UPDATE\n sku = VALUES(sku),\n target_qty = VALUES(target_qty),\n cycle_time = VALUES(cycle_time),\n mold = COALESCE(VALUES(mold), mold),\n cavities_total = COALESCE(VALUES(cavities_total), cavities_total),\n cavities_active = COALESCE(VALUES(cavities_active), cavities_active);\n`;\nmsg.payload = [values];\nmsg._mode = \"sync-work-orders\";\n\nnode.status({\n fill: \"green\",\n shape: \"dot\",\n text: `Synced ${values.length} work orders`,\n});\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 2310, + "y": 320, + "wires": [ + [ + "bfb9b7c7af23bd5c", + "211007c33d60f7ab" + ] + ] + }, + { + "id": "15aea8f70f04da01", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 15", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 1670, + "y": 1340, + "wires": [] + }, + { + "id": "d5155c4988b7c5f0", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 16", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 1860, + "y": 1300, + "wires": [] + }, + { + "id": "211007c33d60f7ab", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 17", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 2020, + "y": 1300, + "wires": [] + }, + { + "id": "07cd5623ad17abdb", + "type": "inject", + "z": "05d4cb231221b842", + "name": "Refresh work orders (poll)", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "86400", + "crontab": "", + "once": true, + "onceDelay": 1, + "topic": "workOrdersRefresh", + "payload": "", + "payloadType": "date", + "x": 1350, + "y": 1240, + "wires": [ + [ + "c09c71a7e4908231" + ] + ] + }, + { + "id": "b4a971f8826b7422", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 18", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 1690, + "y": 1120, + "wires": [] + }, + { + "id": "e2cb9e6a86c0d549", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 19", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 2270, + "y": 1220, + "wires": [] + }, + { + "id": "adba20c2e33f1b18", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 20", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 960, + "y": 220, + "wires": [] + }, + { + "id": "2fd4067492e5549b", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 21", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 1710, + "y": 1060, + "wires": [] + }, + { + "id": "dc14ef2f723f75b7", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 22", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 1770, + "y": 1000, + "wires": [] + }, + { + "id": "01fa15279b7facf2", + "type": "function", + "z": "05d4cb231221b842", + "name": "function 3", + "func": "const awo = msg.payload?.activeWorkOrder;\nif (awo) {\n const v = awo.lastUpdateIso;\n\n // If it's already a string, keep it\n if (typeof v === \"string\") {\n // ok\n } else if (v instanceof Date) {\n awo.lastUpdateIso = v.toISOString();\n } else if (v && typeof v.toISO === \"function\") {\n // Luxon DateTime\n awo.lastUpdateIso = v.toISO();\n } else if (v && typeof v.toISOString === \"function\") {\n // Some date-like objects\n awo.lastUpdateIso = v.toISOString();\n } else if (v && typeof v.format === \"function\") {\n // Moment-like\n awo.lastUpdateIso = v.format();\n } else {\n // Last resort: try to stringify, otherwise null it out\n awo.lastUpdateIso = v ? String(v) : null;\n }\n node.warn(`[lastUpdateIso] typeof=${typeof v} value=${v}`);\n node.warn(`[lastUpdateIso] JSON=${JSON.stringify(v)}`);\n}\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 3560, + "y": 740, + "wires": [ + [ + "4770e1bb4693f2f2" + ] + ] + }, + { + "id": "5b485289491bb538", + "type": "function", + "z": "05d4cb231221b842", + "name": "Build Heartbeat HTTP", + "func": "// Build direct heartbeat HTTP request\nconst config = global.get(\"config\") || {};\nconst base = config.cloudBaseUrl;\nconst apiKey = config.apiKey;\n\nif (!base || !apiKey) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Heartbeat waiting for pairing\" });\n return null;\n}\n\nconst baseUrl = String(base).replace(/\\/+$/, \"\");\nmsg.method = \"POST\";\nmsg.url = baseUrl + \"/api/ingest/heartbeat\";\nmsg.headers = {\n \"Content-Type\": \"application/json\",\n \"x-api-key\": apiKey,\n};\n\n// Use the same payload you already build in Online HeartBeat\nmsg.payload = {\n machineId: msg.machineId,\n tsDevice: msg.tsMs,\n tsMs: msg.tsMs, // device time; same as tsDevice for preprocess\n status: \"ONLINE\",\n message: \"NR heartbeat\",\n ip: (config.edgeIp || msg.ip || \"192.168.18.33\"),\n fwVersion: (config.fwVersion || \"raspi-nodered-1.0\"),\n};\n\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 2800, + "y": 640, + "wires": [ + [ + "dbaeb91d1ca63eb2" + ] + ] + }, + { + "id": "dbaeb91d1ca63eb2", + "type": "http request", + "z": "05d4cb231221b842", + "name": "Send Heartbeat", + "method": "use", + "ret": "txt", + "paytoqs": "ignore", + "url": "", + "tls": "", + "persist": false, + "proxy": "", + "insecureHTTPParser": false, + "authType": "", + "senderr": false, + "headers": [], + "x": 3040, + "y": 640, + "wires": [ + [ + "d43f0a9277605f85" + ] + ] + }, + { + "id": "d43f0a9277605f85", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 24", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 3210, + "y": 660, + "wires": [] + }, + { + "id": "14c8fb75a042909e", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 25", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 970, + "y": 380, + "wires": [] + }, + { + "id": "d447044432536eca", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 26", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 960, + "y": 420, + "wires": [] + }, + { + "id": "0f0afb7fd521f2c2", + "type": "inject", + "z": "05d4cb231221b842", + "d": true, + "g": "6e514144a570aa72", + "name": "", + "props": [ + { + "p": "payload" + } + ], + "repeat": "15", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "1", + "payloadType": "num", + "x": 1570, + "y": 180, + "wires": [ + [ + "482feffe728ab41a", + "86b533aacff8b212" + ] + ] + }, + { + "id": "482feffe728ab41a", + "type": "function", + "z": "05d4cb231221b842", + "d": true, + "g": "6e514144a570aa72", + "name": "Simula Inyectora", + "func": "/**\n * Machine Cycle Simulator (0/1 square wave with realistic variance)\n *\n * How to use:\n * - Trigger this node ONCE (Inject once after deploy). It will start emitting 0/1 on its own.\n * - To stop: send a message with msg.payload = \"stop\" (or msg.topic=\"stop\")\n * - To reset state: msg.payload = \"reset\"\n *\n * Output:\n * - msg.payload = 0 or 1\n */\n\nconst CFG = {\n halfPeriodMs: 7250,\n jitterMs: 250,\n perfectRunMs: 30 * 60 * 1000,\n pEnterPerfect: 0, // was 0.12 — disable perfect runs\n pSlowPhase: 0, // was 0.10 — disable slow phases\n pMicroStop: 0.5, // was 0.06 — force frequent microstops\n pMacroStop: 0.2, // was 0.008 — force frequent macrostops\n microStopMs: [5000, 15000],\n macroStopMs: [60 * 1000, 120 * 1000], // shorter macros (1-2 min) so you don't wait 8 min\n slowFactor: [1.25, 1.9],\n slowPhaseToggles: [6, 25],\n minGapMicroMs: 10 * 1000, // was 20s — allow faster retriggering\n minGapMacroMs: 30 * 1000, // was 60s\n};\n\nfunction randInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }\nfunction randFloat(min, max) { return Math.random() * (max - min) + min; }\nfunction pickMs(range) { return randInt(range[0], range[1]); }\nfunction clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }\n\nfunction clearTimer() {\n const t = context.get(\"timer\");\n if (t) clearTimeout(t);\n context.set(\"timer\", null);\n context.set(\"running\", false);\n}\n\nif (msg && (msg.payload === \"stop\" || msg.topic === \"stop\")) {\n clearTimer();\n node.status({ fill: \"grey\", shape: \"ring\", text: \"stopped\" });\n return null;\n}\n\nif (msg && msg.payload === \"reset\") {\n context.set(\"state\", 0);\n node.status({ fill: \"grey\", shape: \"dot\", text: \"reset to 0\" });\n return null;\n}\n\n// Store a template msg (so you can pass machineId/topic/etc. once)\nif (msg && typeof msg === \"object\") {\n const template = Object.assign({}, msg);\n delete template.payload; // we'll overwrite payload each emit\n context.set(\"template\", template);\n}\n\nlet running = context.get(\"running\") || false;\nlet timer = context.get(\"timer\");\nlet state = context.get(\"state\");\nif (state === undefined || state === null) state = 0;\n\nlet phase = context.get(\"phase\") || { type: \"idle\", until: 0, slowLeft: 0 };\nlet lastStopAt = context.get(\"lastStopAt\") || 0;\n\nfunction decideNextDelayMs() {\n const now = Date.now();\n\n // Perfect run active\n if (phase.type === \"perfect\" && now < phase.until) {\n const jitter = randInt(-CFG.jitterMs, CFG.jitterMs);\n return clamp(CFG.halfPeriodMs + jitter, 300, 30 * 60 * 1000);\n }\n\n // Slow phase active (counted in toggles)\n if (phase.type === \"slow\" && phase.slowLeft > 0) {\n phase.slowLeft -= 1;\n context.set(\"phase\", phase);\n\n const factor = randFloat(CFG.slowFactor[0], CFG.slowFactor[1]);\n const jitter = randInt(-CFG.jitterMs, CFG.jitterMs);\n node.status({ fill: \"blue\", shape: \"dot\", text: `slow (${phase.slowLeft} left)` });\n return clamp(Math.round(CFG.halfPeriodMs * factor) + jitter, 300, 30 * 60 * 1000);\n }\n\n // Phase ended → maybe enter a new perfect run\n if (Math.random() < CFG.pEnterPerfect) {\n phase = { type: \"perfect\", until: now + CFG.perfectRunMs, slowLeft: 0 };\n context.set(\"phase\", phase);\n node.status({ fill: \"green\", shape: \"dot\", text: \"perfect run\" });\n return decideNextDelayMs();\n }\n\n // Event selection (macro > micro > slow > normal)\n const sinceStop = now - lastStopAt;\n\n if (sinceStop > CFG.minGapMacroMs && Math.random() < CFG.pMacroStop) {\n lastStopAt = now;\n context.set(\"lastStopAt\", lastStopAt);\n node.status({ fill: \"red\", shape: \"dot\", text: \"MACRO STOP\" });\n return pickMs(CFG.macroStopMs);\n }\n\n if (sinceStop > CFG.minGapMicroMs && Math.random() < CFG.pMicroStop) {\n lastStopAt = now;\n context.set(\"lastStopAt\", lastStopAt);\n node.status({ fill: \"yellow\", shape: \"dot\", text: \"micro stop\" });\n return pickMs(CFG.microStopMs);\n }\n\n if (Math.random() < CFG.pSlowPhase) {\n phase = { type: \"slow\", until: 0, slowLeft: randInt(CFG.slowPhaseToggles[0], CFG.slowPhaseToggles[1]) };\n context.set(\"phase\", phase);\n return decideNextDelayMs();\n }\n\n // Normal running\n node.status({ fill: \"green\", shape: \"dot\", text: \"running\" });\n const jitter = randInt(-CFG.jitterMs, CFG.jitterMs);\n return clamp(CFG.halfPeriodMs + jitter, 300, 30 * 60 * 1000);\n}\n\nfunction emitTick() {\n // Toggle 0/1\n state = state ? 0 : 1;\n context.set(\"state\", state);\n\n const template = context.get(\"template\") || {};\n const out = Object.assign({}, template, { payload: state });\n\n node.send(out);\n\n // Schedule next emission\n const delay = decideNextDelayMs();\n const t = setTimeout(emitTick, delay);\n context.set(\"timer\", t);\n}\n\nif (!running) {\n context.set(\"running\", true);\n node.status({ fill: \"green\", shape: \"dot\", text: \"started\" });\n}\n\n// Don’t start multiple timers if you accidentally keep the old repeating inject\n// before\n// after — always start fresh on trigger; ignore stale post-deploy refs\nclearTimeout(context.get(\"timer\"));\ncontext.set(\"timer\", setTimeout(emitTick, 100));\n\nreturn null;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1440, + "y": 240, + "wires": [ + [ + "1b3bf7d736a4afd3" + ] + ] + }, + { + "id": "5a84fb165be942a5", + "type": "function", + "z": "05d4cb231221b842", + "name": "Persist pairing config", + "func": "const result = msg.payload || {};\nif (!result.ok) return null;\n\nconst config = global.get(\"config\") || {};\nif (!config.machineId || !config.apiKey) return null;\n\nconst settings = global.get(\"settings\") || {};\nconst shifts = settings.shifts || [{ start: \"08:00\", end: \"16:00\" }];\n\nmsg.topic = `\nINSERT INTO current_config (\n machine_id,\n api_key,\n org_id,\n cloud_base_url,\n shifts_json,\n shift_change_comp_min,\n lunch_break_min,\n threshold_multiplier,\n oee_alert_threshold,\n mold_total,\n mold_active,\n updated_at\n) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())\nON DUPLICATE KEY UPDATE\n api_key=VALUES(api_key),\n org_id=VALUES(org_id),\n cloud_base_url=VALUES(cloud_base_url),\n updated_at=NOW();\n`.trim();\n\nmsg.payload = [\n config.machineId,\n config.apiKey,\n config.orgId || null,\n config.cloudBaseUrl || null,\n JSON.stringify(shifts),\n Number(settings.shiftChangeCompensation ?? 10),\n Number(settings.lunchBreakMinutes ?? 30),\n Number(settings.thresholdMultiplier ?? 1.5),\n Number(settings.oeeAlertThreshold ?? 90),\n Number(settings.moldTotal ?? 0),\n Number(settings.moldActive ?? 0)\n];\n\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1000, + "y": 1060, + "wires": [ + [ + "b686a55196a3aecb", + "1784a31b62b3d253" + ] + ] + }, + { + "id": "b686a55196a3aecb", + "type": "mysql", + "z": "05d4cb231221b842", + "mydb": "fc9634aabefee16b", + "name": "Mold Presets DB", + "x": 1230, + "y": 1060, + "wires": [ + [ + "8a19aa0866695a2d" + ] + ] + }, + { + "id": "49bd3489167d68a3", + "type": "mysql", + "z": "05d4cb231221b842", + "mydb": "fc9634aabefee16b", + "name": "Mold Presets DB", + "x": 3850, + "y": 220, + "wires": [ + [ + "bdd693b849e6f67a", + "023a802a851a13c3" + ] + ] + }, + { + "id": "2cae7ffd21b50dff", + "type": "function", + "z": "05d4cb231221b842", + "name": "load config from DB", + "func": "msg.topic = `\nSELECT machine_id, api_key, org_id, cloud_base_url\nFROM current_config\nWHERE machine_id IS NOT NULL AND machine_id <> '' AND api_key IS NOT NULL AND api_key <> ''\nORDER BY updated_at DESC\nLIMIT 1;\n`.trim();\nmsg.payload = [];\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 3650, + "y": 220, + "wires": [ + [ + "49bd3489167d68a3" + ] + ] + }, + { + "id": "bdd693b849e6f67a", + "type": "function", + "z": "05d4cb231221b842", + "name": "apply config from DB", + "func": "const rows = msg.payload;\n\nif (!Array.isArray(rows) || rows.length === 0) {\n msg._configRetry = (msg._configRetry || 0) + 1;\n msg._configLoaded = false;\n return [null, msg]; // retry path\n}\n\nconst row = rows[0] || {};\nconst machineId = (row.machine_id || \"\").toString().trim();\nconst apiKey = (row.api_key || \"\").toString().trim();\n\nif (!machineId || !apiKey) {\n msg._configRetry = (msg._configRetry || 0) + 1;\n msg._configLoaded = false;\n return [null, msg]; // retry path\n}\n\nif (msg._configRetry > 12) return [null, null]; // optional stop after 12 tries\n\nconst config = global.get(\"config\") || {};\nconfig.machineId = machineId;\nconfig.apiKey = apiKey;\nif (row.org_id) config.orgId = row.org_id;\nif (row.cloud_base_url) config.cloudBaseUrl = row.cloud_base_url;\nif (config.settingsReadOnly === undefined) config.settingsReadOnly = true;\nif (!config.mqttTopicPrefix) config.mqttTopicPrefix = \"mis\";\n\nglobal.set(\"config\", config);\nmsg._configLoaded = true;\nreturn [msg, null];\n", + "outputs": 2, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 4080, + "y": 220, + "wires": [ + [], + [ + "e96d4a126d54edcb" + ] + ] + }, + { + "id": "79e70ba16106b462", + "type": "function", + "z": "05d4cb231221b842", + "d": true, + "name": "throttle + dedupe", + "func": "// KPI Gate: 1/min throttle + dedupe + fresh timestamp\nconst now = Date.now();\nconst minIntervalMs = 60000;\n\nconst payload = msg.outbox?.payload;\nif (!payload) return null;\n\n// stable signature (no tsMs)\nconst sig = JSON.stringify({\n workOrderId: payload.activeWorkOrder?.id || null,\n cycle_count: payload.cycle_count || 0,\n good_parts: payload.good_parts || 0,\n scrap_parts: payload.scrap_parts || 0,\n cavities: payload.cavities || null,\n cycleTime: payload.cycleTime || 0,\n actualCycleTime: payload.actualCycleTime || 0,\n trackingEnabled: !!payload.trackingEnabled,\n productionStarted: !!payload.productionStarted,\n kpis: payload.kpis || {},\n});\n\nconst lastSentAt = flow.get(\"kpi_lastSentAt\") || 0;\nconst lastSig = flow.get(\"kpi_lastSig\") || \"\";\n\n// Drop if too soon and unchanged\nif ((now - lastSentAt) < minIntervalMs && sig === lastSig) {\n return null;\n}\n\n// Update gate state\nflow.set(\"kpi_lastSentAt\", now);\nflow.set(\"kpi_lastSig\", sig);\n\n// Ensure fresh timestamp to avoid timeline weirdness\npayload.tsMs = now;\nmsg.outbox.payload = payload;\nmsg.tsMs = now;\n\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 2410, + "y": 1080, + "wires": [ + [] + ] + }, + { + "id": "4acf0c0395ed5cdc", + "type": "inject", + "z": "05d4cb231221b842", + "g": "a1b43a9e095c10db", + "name": "KPI minute tick", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "60", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 2360, + "y": 840, + "wires": [ + [ + "83e321bafb545a0a" + ] + ] + }, + { + "id": "83e321bafb545a0a", + "type": "function", + "z": "05d4cb231221b842", + "name": "Build KPI Minute Snapshot", + "func": "// Build KPI Minute Snapshot (single source of KPI outbox)\nconst config = global.get(\"config\") || {};\nconst state = global.get(\"state\") || {};\nconst machineId = config.machineId;\nif (!machineId) return null;\n\nconst now = Date.now();\n// Optional: align to minute boundary for clean timeline\nconst tsMs = now - (now % 60000);\n\nconst rawActive = state.activeWorkOrder || null;\nconst active = rawActive ? { ...rawActive } : null;\n\n// Normalize activeWorkOrder.lastUpdateIso to string if present\nif (active && active.lastUpdateIso != null) {\n const v = active.lastUpdateIso;\n if (typeof v === \"string\") {\n // ok\n } else if (v instanceof Date) {\n active.lastUpdateIso = v.toISOString();\n } else if (typeof v === \"number\") {\n active.lastUpdateIso = new Date(v).toISOString();\n } else if (v && typeof v.toISOString === \"function\") {\n active.lastUpdateIso = v.toISOString();\n } else if (v && typeof v.toISO === \"function\") {\n active.lastUpdateIso = v.toISO();\n } else if (v && typeof v.format === \"function\") {\n active.lastUpdateIso = v.format();\n } else {\n delete active.lastUpdateIso; // avoid invalid payload\n }\n}\n\nconst kpis = state.currentKPIs || { oee: 0, availability: 0, performance: 0, quality: 0 };\n\nconst cavitiesRaw = Number(\n active?.cavities ??\n state.lastMoldActive ??\n 0\n);\nconst cavities = cavitiesRaw > 0 ? cavitiesRaw : null;\n\nmsg.machineId = machineId;\nmsg.tsMs = tsMs;\n\nmsg.outbox = {\n type: \"kpi\",\n payload: {\n tsMs,\n activeWorkOrder: active,\n cycle_count: Number(state.cycleCount ?? 0),\n good_parts: Number(active?.goodParts ?? 0),\n scrap_parts: Number(active?.scrapParts ?? 0),\n cavities,\n cycleTime: Number(active?.cycleTime ?? state.lastCycleTime ?? 0),\n actualCycleTime: Number(state.lastActualCycleTime ?? 0),\n trackingEnabled: !!state.trackingEnabled,\n productionStarted: !!state.productionStarted,\n kpis,\n },\n};\n\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 2640, + "y": 820, + "wires": [ + [ + "e120c11f093ebd9b" + ] + ] + }, + { + "id": "4e7a2904be0f9a37", + "type": "delay", + "z": "05d4cb231221b842", + "name": "", + "pauseType": "delay", + "timeout": "2", + "timeoutUnits": "seconds", + "rate": "1", + "nbRateUnits": "1", + "rateUnits": "second", + "randomFirst": "1", + "randomLast": "5", + "randomUnits": "seconds", + "drop": false, + "allowrate": false, + "outputs": 1, + "x": 3460, + "y": 220, + "wires": [ + [ + "2cae7ffd21b50dff" + ] + ] + }, + { + "id": "e96d4a126d54edcb", + "type": "switch", + "z": "05d4cb231221b842", + "name": "", + "property": "config.apiKey", + "propertyType": "global", + "rules": [ + { + "t": "nempty" + }, + { + "t": "empty" + } + ], + "checkall": "true", + "repair": false, + "outputs": 2, + "x": 4290, + "y": 220, + "wires": [ + [], + [ + "4e7a2904be0f9a37" + ] + ] + }, + { + "id": "1784a31b62b3d253", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 12", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 1210, + "y": 1160, + "wires": [] + }, + { + "id": "8a19aa0866695a2d", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 27", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 1050, + "y": 1240, + "wires": [] + }, + { + "id": "023a802a851a13c3", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 13", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "false", + "statusVal": "", + "statusType": "auto", + "x": 4030, + "y": 140, + "wires": [] + }, + { + "id": "1b3bf7d736a4afd3", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 14", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 2710, + "y": 460, + "wires": [] + }, + { + "id": "b219495329321d63", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 23", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "false", + "statusVal": "", + "statusType": "auto", + "x": 2310, + "y": 60, + "wires": [] + }, + { + "id": "d83778d03dc54c39", + "type": "16inpind", + "z": "05d4cb231221b842", + "d": true, + "name": "", + "stack": "0", + "channel": "5", + "x": 2740, + "y": 120, + "wires": [ + [ + "ce36a3271d9df8ae", + "601d2022200f67c1" + ] + ] + }, + { + "id": "6d2a75aa076da9b6", + "type": "inject", + "z": "05d4cb231221b842", + "name": "", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "0.01", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 2550, + "y": 120, + "wires": [ + [ + "d83778d03dc54c39", + "bfcff0fe51b169c8" + ] + ] + }, + { + "id": "ef6299b3d440c194", + "type": "function", + "z": "05d4cb231221b842", + "name": "Fetch claimed rows", + "func": "// Después del UPDATE...status='sending', leer las filas que acabamos de reservar\nmsg.topic = `\nSELECT id, machine_id, msg_type, endpoint, schema_version, seq, ts_device_ms,\n payload_json, attempts, next_attempt_at\nFROM outbox_messages\nWHERE status='sending'\nORDER BY id ASC\nLIMIT 25;\n`.trim();\n\nmsg._stage = \"fetch\";\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 3190, + "y": 720, + "wires": [ + [ + "b81555cd8e9fcc31" + ] + ] + }, + { + "id": "b81555cd8e9fcc31", + "type": "mysql", + "z": "05d4cb231221b842", + "mydb": "fc9634aabefee16b", + "name": "Fetch pending outbox", + "x": 3360, + "y": 780, + "wires": [ + [ + "85a333219d51a809" + ] + ] + }, + { + "id": "5dbbcccbadfb8038", + "type": "function", + "z": "05d4cb231221b842", + "name": "Release lock no rows", + "func": "flow.set(\"publisherBusy\", false);\nnode.status({ fill: \"grey\", shape: \"dot\", text: \"idle\" });\nreturn null;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 3460, + "y": 840, + "wires": [ + [] + ] + }, + { + "id": "c7f59092785681d1", + "type": "function", + "z": "05d4cb231221b842", + "name": "Release dock", + "func": "flow.set(\"publisherBusy\", false);\nreturn null;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 4530, + "y": 800, + "wires": [ + [] + ] + }, + { + "id": "36a23edb4d1ac99a", + "type": "function", + "z": "05d4cb231221b842", + "name": "Watchdog cleanup", + "func": "msg.topic = `\nUPDATE outbox_messages\nSET status=?\nWHERE status=?\n AND updated_at < DATE_SUB(NOW(), INTERVAL ? SECOND);\n`.trim();\n\nmsg.payload = ['pending', 'sending', 60];\n\nflow.set(\"publisherBusy\", false);\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 4130, + "y": 580, + "wires": [ + [ + "ff8cf061308cbbcd" + ] + ] + }, + { + "id": "ff8cf061308cbbcd", + "type": "mysql", + "z": "05d4cb231221b842", + "mydb": "fc9634aabefee16b", + "name": "Update outbox status", + "x": 4360, + "y": 580, + "wires": [ + [ + "c57fd79d84b867c4" + ] + ] + }, + { + "id": "d33dd7b6ed5d7fe3", + "type": "inject", + "z": "05d4cb231221b842", + "name": "", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "60", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 3920, + "y": 520, + "wires": [ + [ + "36a23edb4d1ac99a" + ] + ] + }, + { + "id": "66f93cb8bab967d0", + "type": "inject", + "z": "05d4cb231221b842", + "name": "", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 4120, + "y": 380, + "wires": [ + [ + "64a1a270cb5c06e0" + ] + ] + }, + { + "id": "307af4023994831e", + "type": "function", + "z": "05d4cb231221b842", + "name": "function 2", + "func": "const state = global.get(\"state\") || {};\nnode.warn(\"activeWorkOrder: \" + JSON.stringify(state.activeWorkOrder, null, 2));\nnode.warn(\"trackingEnabled: \" + state.trackingEnabled);\nnode.warn(\"productionStarted: \" + state.productionStarted);\nreturn null;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 4320, + "y": 380, + "wires": [ + [] + ] + }, + { + "id": "c57fd79d84b867c4", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 28", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "false", + "statusVal": "", + "statusType": "auto", + "x": 4660, + "y": 580, + "wires": [] + }, + { + "id": "a81eeab71b69af87", + "type": "function", + "z": "05d4cb231221b842", + "name": "function 4", + "func": "const state = global.get(\"state\") || {};\nnode.warn(\"activeWorkOrder.id: \" + state.activeWorkOrder?.id);\nnode.warn(\"cavitiesActive: \" + state.activeWorkOrder?.cavitiesActive);\nnode.warn(\"trackingEnabled: \" + state.trackingEnabled);\nnode.warn(\"productionStarted: \" + state.productionStarted);\nnode.warn(\"cycleCount: \" + state.cycleCount);\nreturn null;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 4330, + "y": 440, + "wires": [ + [] + ] + }, + { + "id": "ac109fb4fedbe7ad", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 29", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "false", + "statusVal": "", + "statusType": "auto", + "x": 4510, + "y": 440, + "wires": [] + }, + { + "id": "946c834f8b50e15a", + "type": "function", + "z": "05d4cb231221b842", + "name": "function 5", + "func": "const state = global.get(\"state\") || {};\nnode.warn(\"=== DIAGNÓSTICO STATE ===\");\nnode.warn(\"activeWorkOrder.id: \" + state.activeWorkOrder?.id);\nnode.warn(\"cycleCount: \" + state.cycleCount);\nnode.warn(\"trackingEnabled: \" + state.trackingEnabled);\nnode.warn(\"productionStarted: \" + state.productionStarted);\nnode.warn(\"flow.lastMachineState: \" + flow.get(\"lastMachineState\"));\nreturn null;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 4320, + "y": 320, + "wires": [ + [ + "b567a5cf1a50ab28" + ] + ] + }, + { + "id": "64a1a270cb5c06e0", + "type": "function", + "z": "05d4cb231221b842", + "name": "function 6", + "func": "// Reset state\nconst state = global.get(\"state\") || {};\nstate.activeWorkOrder = null;\nstate.cycleCount = 0;\nstate.trackingEnabled = false;\nstate.productionStarted = false;\nstate.lastState = null;\nstate.kpiOrderId = null;\nstate.activeOrderId = null;\nstate.activeOrderHasProgress = false;\ndelete state.kpiByWorkOrder;\nglobal.set(\"state\", state);\nflow.set(\"lastMachineState\", 0);\nflow.set(\"pendingWorkOrder\", null);\n\n// Reset BD: poner el 230537 en PENDING para empezar limpio\nnode.warn(\"State limpio. Reset el WO en BD a PENDING.\");\nreturn null;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 4320, + "y": 500, + "wires": [ + [] + ] + }, + { + "id": "601d2022200f67c1", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 30", + "active": false, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "payload", + "targetType": "msg", + "statusVal": "", + "statusType": "auto", + "x": 2960, + "y": 80, + "wires": [] + }, + { + "id": "bfcff0fe51b169c8", + "type": "exec", + "z": "05d4cb231221b842", + "command": "16inpind 0 rd 5", + "addpay": "", + "append": "", + "useSpawn": "false", + "timer": "", + "winHide": false, + "oldrc": false, + "name": "Read sensor", + "x": 2720, + "y": 60, + "wires": [ + [ + "601d2022200f67c1", + "ce36a3271d9df8ae" + ], + [], + [] + ] + }, + { + "id": "b567a5cf1a50ab28", + "type": "debug", + "z": "05d4cb231221b842", + "name": "debug 31", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "false", + "statusVal": "", + "statusType": "auto", + "x": 4490, + "y": 320, + "wires": [] + }, + { + "id": "092651abe2113f4e", + "type": "function", + "z": "05d4cb231221b842", + "name": "function 7", + "func": "// Test: mandar SOLO machineStatus, sin nada más\nreturn [{\n topic: \"machineStatus\",\n payload: {\n machineOnline: true,\n productionStarted: false,\n trackingEnabled: false\n }\n}];", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 100, + "y": 320, + "wires": [ + [ + "dbfd127c516efa87" + ] + ] + }, + { + "id": "be9412bfa9abb589", + "type": "inject", + "z": "05d4cb231221b842", + "name": "", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 80, + "y": 200, + "wires": [ + [ + "092651abe2113f4e" + ] + ] + }, + { + "id": "919b5b8d778e2b6c", + "type": "ui_group", + "name": "Default", + "tab": "c567195d86466cd5", + "order": 1, + "disp": false, + "width": "25", + "collapse": false, + "className": "" + }, + { + "id": "e2f3a4b5c6d7e8f9", + "type": "ui_group", + "name": "Alerts Group", + "tab": "a1b2c3d4e5f60718", + "order": 1, + "disp": false, + "width": "25", + "collapse": false, + "className": "" + }, + { + "id": "e3f4a5b6c7d8e9f0", + "type": "ui_group", + "name": "Graphs Group", + "tab": "b2c3d4e5f6a70182", + "order": 1, + "disp": false, + "width": "25", + "collapse": false, + "className": "" + }, + { + "id": "e4f5a6b7c8d9e0f1", + "type": "ui_group", + "name": "Help Group", + "tab": "c3d4e5f6a7b80192", + "order": 1, + "disp": false, + "width": "25", + "collapse": false, + "className": "" + }, + { + "id": "e5f6a7b8c9d0e1f2", + "type": "ui_group", + "name": "Settings Group", + "tab": "d4e5f6a7b8c90123", + "order": 1, + "disp": false, + "width": "25", + "collapse": false, + "className": "" + }, + { + "id": "b99c269687d574aa", + "type": "ui_group", + "name": "Work Orders Group", + "tab": "d1a1e2f3a4b5c6d7", + "order": 1, + "disp": false, + "width": 25, + "collapse": false, + "className": "" + }, + { + "id": "fc9634aabefee16b", + "type": "MySQLdatabase", + "name": "Edge Outbox", + "host": "127.0.0.1", + "port": "3306", + "db": "edge_outbox", + "tz": "", + "charset": "UTF8" + }, + { + "id": "c567195d86466cd5", + "type": "ui_tab", + "name": "Home", + "icon": "dashboard", + "order": 1, + "disabled": false, + "hidden": false + }, + { + "id": "a1b2c3d4e5f60718", + "type": "ui_tab", + "name": "Alerts", + "icon": "warning", + "order": 3, + "disabled": false, + "hidden": false + }, + { + "id": "b2c3d4e5f6a70182", + "type": "ui_tab", + "name": "Graphs", + "icon": "show_chart", + "order": 4, + "disabled": false, + "hidden": false + }, + { + "id": "c3d4e5f6a7b80192", + "type": "ui_tab", + "name": "Help", + "icon": "help", + "order": 5, + "disabled": false, + "hidden": false + }, + { + "id": "d4e5f6a7b8c90123", + "type": "ui_tab", + "name": "Settings", + "icon": "settings", + "order": 6, + "disabled": false, + "hidden": false + }, + { + "id": "d1a1e2f3a4b5c6d7", + "type": "ui_tab", + "name": "Work Orders", + "icon": "list", + "order": 2, + "disabled": false, + "hidden": false + }, + { + "id": "a55448a13a0b625f", + "type": "global-config", + "env": [], + "modules": { + "node-red-node-mysql": "2.0.0", + "node-red-dashboard": "3.6.6", + "node-red-contrib-spreadsheet-in": "0.7.2", + "node-red-node-pi-gpio": "2.0.6", + "node-red-contrib-sm-16inpind": "1.0.1" + } + } +] diff --git a/lib/alerts/getAlertsInboxData.ts b/lib/alerts/getAlertsInboxData.ts index 5c4d8e6..3761ab2 100644 --- a/lib/alerts/getAlertsInboxData.ts +++ b/lib/alerts/getAlertsInboxData.ts @@ -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(); + // 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(); 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, }); diff --git a/lib/alerts/getAlertsInboxData.ts.bak b/lib/alerts/getAlertsInboxData.ts.bak new file mode 100644 index 0000000..5c4d8e6 --- /dev/null +++ b/lib/alerts/getAlertsInboxData.ts.bak @@ -0,0 +1,363 @@ +import { normalizeShiftOverrides } from "@/lib/settings"; +import { prisma } from "@/lib/prisma"; + +const RANGE_MS: Record = { + "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) + : {}; + const innerCandidate = payload.data; + const inner = + innerCandidate && typeof innerCandidate === "object" && !Array.isArray(innerCandidate) + ? (innerCandidate as Record) + : 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 = { + 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 | 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(); + 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, + }; +} diff --git a/lib/auth/requireOrgAdminSession.ts b/lib/auth/requireOrgAdminSession.ts new file mode 100644 index 0000000..12bd851 --- /dev/null +++ b/lib/auth/requireOrgAdminSession.ts @@ -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 } }; +} diff --git a/lib/i18n/en.json b/lib/i18n/en.json index 170d115..8732eed 100644 --- a/lib/i18n/en.json +++ b/lib/i18n/en.json @@ -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", diff --git a/lib/i18n/es-MX.json b/lib/i18n/es-MX.json index ab24b10..68c3a91 100644 --- a/lib/i18n/es-MX.json +++ b/lib/i18n/es-MX.json @@ -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", diff --git a/lib/reasonCatalog.ts b/lib/reasonCatalog.ts index c2e12d0..8a787e6 100644 --- a/lib/reasonCatalog.ts +++ b/lib/reasonCatalog.ts @@ -1,6 +1,3 @@ -import { readFile } from "fs/promises"; -import path from "path"; - type AnyRecord = Record; 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 | 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, ""); diff --git a/lib/reasonCatalogDb.ts b/lib/reasonCatalogDb.ts new file mode 100644 index 0000000..d55691d --- /dev/null +++ b/lib/reasonCatalogDb.ts @@ -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 { + 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 { + 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 { + 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 }, + }); +} diff --git a/lib/reasonCatalogFallback.ts b/lib/reasonCatalogFallback.ts new file mode 100644 index 0000000..12cbeb6 --- /dev/null +++ b/lib/reasonCatalogFallback.ts @@ -0,0 +1,15 @@ +import { readFile } from "fs/promises"; +import path from "path"; +import { parseReasonCatalogMarkdown, type ReasonCatalog } from "@/lib/reasonCatalog"; + +let catalogPromise: Promise | 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; +} diff --git a/lib/recap/getRecapData.ts b/lib/recap/getRecapData.ts index dc272cd..890a516 100644 --- a/lib/recap/getRecapData.ts +++ b/lib/recap/getRecapData.ts @@ -289,6 +289,7 @@ async function computeRecap(params: Required> & { ts: true, cycleCount: true, workOrderId: true, + theoreticalCycleTime: true, sku: true, goodDelta: true, scrapDelta: true, diff --git a/lib/recap/machineState.ts b/lib/recap/machineState.ts index 8727026..54c6e27 100644 --- a/lib/recap/machineState.ts +++ b/lib/recap/machineState.ts @@ -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 = diff --git a/lib/recap/redesign.ts b/lib/recap/redesign.ts index 9a31ce8..bab4de8 100644 --- a/lib/recap/redesign.ts +++ b/lib/recap/redesign.ts @@ -1,644 +1,659 @@ -import { unstable_cache } from "next/cache"; -import { prisma } from "@/lib/prisma"; -import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings"; -import { getRecapDataCached } from "@/lib/recap/getRecapData"; -import { - buildTimelineSegments, - compressTimelineSegments, - TIMELINE_EVENT_TYPES, - type TimelineCycleRow, - type TimelineEventRow, -} from "@/lib/recap/timeline"; -import { classifyMachineState, type MachineStateResult } from "@/lib/recap/machineState"; -import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants"; -import type { - RecapDetailResponse, - RecapMachine, - RecapMachineDetail, - RecapMachineStatus, - RecapRangeMode, - RecapStateContext, - RecapSummaryMachine, - RecapSummaryResponse, -} from "@/lib/recap/types"; + import { unstable_cache } from "next/cache"; + import { prisma } from "@/lib/prisma"; + import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings"; + import { getRecapDataCached } from "@/lib/recap/getRecapData"; + import { + buildTimelineSegments, + compressTimelineSegments, + TIMELINE_EVENT_TYPES, + type TimelineCycleRow, + type TimelineEventRow, + } from "@/lib/recap/timeline"; + import { classifyMachineState, type MachineStateResult } from "@/lib/recap/machineState"; + import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants"; + import type { + RecapDetailResponse, + RecapMachine, + RecapMachineDetail, + RecapMachineStatus, + RecapRangeMode, + RecapStateContext, + RecapSummaryMachine, + RecapSummaryResponse, + } from "@/lib/recap/types"; -type DetailRangeInput = { - mode?: string | null; - start?: string | null; - end?: string | null; -}; + type DetailRangeInput = { + mode?: string | null; + start?: string | null; + end?: string | null; + }; -const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS; -const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000; -const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000; -const RECAP_CACHE_TTL_SEC = 60; -const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; -const WEEKDAY_KEY_MAP: Record = { - Mon: "mon", - Tue: "tue", - Wed: "wed", - Thu: "thu", - Fri: "fri", - Sat: "sat", - Sun: "sun", -}; + const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS; + const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000; + const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000; + const RECAP_CACHE_TTL_SEC = 60; + const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; + const WEEKDAY_KEY_MAP: Record = { + Mon: "mon", + Tue: "tue", + Wed: "wed", + Thu: "thu", + Fri: "fri", + Sat: "sat", + Sun: "sun", + }; -function round2(value: number) { - return Math.round(value * 100) / 100; -} + function round2(value: number) { + return Math.round(value * 100) / 100; + } -function parseDate(input?: string | null) { - if (!input) return null; - const n = Number(input); - if (Number.isFinite(n)) { - const d = new Date(n); + function parseDate(input?: string | null) { + if (!input) return null; + const n = Number(input); + if (Number.isFinite(n)) { + const d = new Date(n); + return Number.isFinite(d.getTime()) ? d : null; + } + const d = new Date(input); return Number.isFinite(d.getTime()) ? d : null; } - const d = new Date(input); - return Number.isFinite(d.getTime()) ? d : null; -} -function parseHours(input: string | null) { - const parsed = Math.trunc(Number(input ?? "24")); - if (!Number.isFinite(parsed)) return 24; - return Math.max(1, Math.min(72, parsed)); -} - -function parseTimeMinutes(input?: string | null) { - if (!input) return null; - const match = /^(\d{2}):(\d{2})$/.exec(input.trim()); - if (!match) return null; - const hours = Number(match[1]); - const minutes = Number(match[2]); - if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) { - return null; - } - return hours * 60 + minutes; -} - -function getLocalParts(ts: Date, timeZone: string) { - try { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - weekday: "short", - hour12: false, - }).formatToParts(ts); - - const value = (type: string) => parts.find((part) => part.type === type)?.value ?? ""; - const year = Number(value("year")); - const month = Number(value("month")); - const day = Number(value("day")); - const hour = Number(value("hour")); - const minute = Number(value("minute")); - const weekday = value("weekday"); - - return { - year, - month, - day, - hour, - minute, - weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()], - minutesOfDay: hour * 60 + minute, - }; - } catch { - return { - year: ts.getUTCFullYear(), - month: ts.getUTCMonth() + 1, - day: ts.getUTCDate(), - hour: ts.getUTCHours(), - minute: ts.getUTCMinutes(), - weekday: WEEKDAY_KEYS[ts.getUTCDay()], - minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(), - }; - } -} - -function parseOffsetMinutes(offsetLabel: string | null) { - if (!offsetLabel) return null; - const normalized = offsetLabel.replace("UTC", "GMT"); - const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized); - if (!match) return null; - const sign = match[1] === "-" ? -1 : 1; - const hour = Number(match[2]); - const minute = Number(match[3] ?? "0"); - if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null; - return sign * (hour * 60 + minute); -} - -function getTzOffsetMinutes(utcDate: Date, timeZone: string) { - try { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - timeZoneName: "shortOffset", - hour: "2-digit", - }).formatToParts(utcDate); - const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null; - return parseOffsetMinutes(offsetPart); - } catch { - return null; - } -} - -function zonedToUtcDate(input: { - year: number; - month: number; - day: number; - hours: number; - minutes: number; - timeZone: string; -}) { - const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0); - const guessDate = new Date(baseUtc); - const offsetA = getTzOffsetMinutes(guessDate, input.timeZone); - if (offsetA == null) return guessDate; - - let corrected = new Date(baseUtc - offsetA * 60000); - const offsetB = getTzOffsetMinutes(corrected, input.timeZone); - if (offsetB != null && offsetB !== offsetA) { - corrected = new Date(baseUtc - offsetB * 60000); + function parseHours(input: string | null) { + const parsed = Math.trunc(Number(input ?? "24")); + if (!Number.isFinite(parsed)) return 24; + return Math.max(1, Math.min(72, parsed)); } - return corrected; -} - -function addDays(input: { year: number; month: number; day: number }, days: number) { - const base = new Date(Date.UTC(input.year, input.month - 1, input.day)); - base.setUTCDate(base.getUTCDate() + days); - return { - year: base.getUTCFullYear(), - month: base.getUTCMonth() + 1, - day: base.getUTCDate(), - }; -} - -// Detect active episodes (macrostop, mold-change) from event rows. -// Returns the latest non-auto-ack episode whose final status is "active" -// and that's been refreshed within ACTIVE_STALE_MS. -const ACTIVE_STALE_MS = 2 * 60 * 1000; - -type ActiveEpisode = { startedAtMs: number; lastTsMs: number }; - -function detectActiveEpisode( - events: TimelineEventRow[] | undefined, - eventType: "macrostop" | "mold-change", - endMs: number -): ActiveEpisode | null { - if (!events || events.length === 0) return null; - - type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string; lastCycleTs: number | null }; - const episodes = new Map(); - - for (const event of events) { - if (String(event.eventType || "").toLowerCase() !== eventType) continue; - - let parsed: unknown = event.data; - if (typeof parsed === "string") { - try { parsed = JSON.parse(parsed); } catch { parsed = null; } - } - const data: Record = - parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : {}; - - const isAutoAck = - data.is_auto_ack === true || - data.isAutoAck === true || - data.is_auto_ack === "true" || - data.isAutoAck === "true"; - if (isAutoAck) continue; - - const status = String(data.status ?? "").trim().toLowerCase(); - const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim() - || `${eventType}:${event.ts.getTime()}`; - const tsMs = event.ts.getTime(); - const lastCycleTs = Number(data.last_cycle_timestamp); - - const existing = episodes.get(incidentKey); - if (!existing) { - episodes.set(incidentKey, { - firstTsMs: tsMs, - lastTsMs: tsMs, - lastStatus: status, - lastCycleTs: Number.isFinite(lastCycleTs) && lastCycleTs > 0 ? lastCycleTs : null, - }); - continue; - } - existing.firstTsMs = Math.min(existing.firstTsMs, tsMs); - if (tsMs >= existing.lastTsMs) { - existing.lastTsMs = tsMs; - existing.lastStatus = status; + function parseTimeMinutes(input?: string | null) { + if (!input) return null; + const match = /^(\d{2}):(\d{2})$/.exec(input.trim()); + if (!match) return null; + const hours = Number(match[1]); + const minutes = Number(match[2]); + if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) { + return null; } + return hours * 60 + minutes; } - let best: ActiveEpisode | null = null; - for (const ep of episodes.values()) { - if (ep.lastStatus !== "active") continue; - if (endMs - ep.lastTsMs > ACTIVE_STALE_MS) continue; - // Prefer the freshest active episode (highest lastTsMs) - if (!best || ep.lastTsMs > best.lastTsMs) { - best = { - startedAtMs: ep.lastCycleTs ?? ep.firstTsMs, - lastTsMs: ep.lastTsMs, + function getLocalParts(ts: Date, timeZone: string) { + try { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + weekday: "short", + hour12: false, + }).formatToParts(ts); + + const value = (type: string) => parts.find((part) => part.type === type)?.value ?? ""; + const year = Number(value("year")); + const month = Number(value("month")); + const day = Number(value("day")); + const hour = Number(value("hour")); + const minute = Number(value("minute")); + const weekday = value("weekday"); + + return { + year, + month, + day, + hour, + minute, + weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()], + minutesOfDay: hour * 60 + minute, + }; + } catch { + return { + year: ts.getUTCFullYear(), + month: ts.getUTCMonth() + 1, + day: ts.getUTCDate(), + hour: ts.getUTCHours(), + minute: ts.getUTCMinutes(), + weekday: WEEKDAY_KEYS[ts.getUTCDay()], + minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(), }; } } - return best; -} -function statusFromMachine( - machine: RecapMachine, - endMs: number, - events?: TimelineEventRow[] -): { - status: RecapMachineStatus; - result: MachineStateResult; - stateContext: RecapStateContext; - lastSeenMs: number | null; - offlineForMin: number | null; - ongoingStopMin: number | null; -} { - const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null; - const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs); - const heartbeatAlive = Number.isFinite(lastSeenMs ?? Number.NaN) && offlineForMs <= OFFLINE_THRESHOLD_MS; + function parseOffsetMinutes(offsetLabel: string | null) { + if (!offsetLabel) return null; + const normalized = offsetLabel.replace("UTC", "GMT"); + const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized); + if (!match) return null; + const sign = match[1] === "-" ? -1 : 1; + const hour = Number(match[2]); + const minute = Number(match[3] ?? "0"); + if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null; + return sign * (hour * 60 + minute); + } - const activeMacrostop = detectActiveEpisode(events, "macrostop", endMs); - const activeMoldChange = detectActiveEpisode(events, "mold-change", endMs); + function getTzOffsetMinutes(utcDate: Date, timeZone: string) { + try { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + timeZoneName: "shortOffset", + hour: "2-digit", + }).formatToParts(utcDate); + const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null; + return parseOffsetMinutes(offsetPart); + } catch { + return null; + } + } - // Round 1 limitation: trackingEnabled and untrackedCycles inputs require KPI/cycle queries - // we don't yet plumb here. We approximate from the legacy fields: - // - trackingEnabled: true when there's an active macrostop (Pi only fires those when tracking on) - // OR when an active WO exists and machine.workOrders.moldChangeInProgress is false. - // This is a SIMPLIFICATION; Round 3 will replace with real KPI snapshot read. - // - untrackedCycles: 0 (Round 3 will compute from MachineCycle vs latest KPI) - // - // Effect for Round 1: STOPPED `not_started` reason cannot trigger yet (we always assume tracking - // is on when a WO exists). Only `machine_fault` STOPPED fires. DATA_LOSS cannot fire yet. - // IDLE fires correctly when there's no WO and no recent activity. - const hasActiveWorkOrder = machine.workOrders.active != null; - const trackingEnabledApprox = hasActiveWorkOrder; // see comment above + function zonedToUtcDate(input: { + year: number; + month: number; + day: number; + hours: number; + minutes: number; + timeZone: string; + }) { + const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0); + const guessDate = new Date(baseUtc); + const offsetA = getTzOffsetMinutes(guessDate, input.timeZone); + if (offsetA == null) return guessDate; - const lastCycleTsMs = (() => { - // Best-effort: use the machine's heartbeat as a "recent activity" proxy. - // The Pi only heartbeats every minute regardless of cycles, so this is a weak signal. - // Round 3 will pass the actual latest cycle ts. - return lastSeenMs; - })(); + let corrected = new Date(baseUtc - offsetA * 60000); + const offsetB = getTzOffsetMinutes(corrected, input.timeZone); + if (offsetB != null && offsetB !== offsetA) { + corrected = new Date(baseUtc - offsetB * 60000); + } - const result = classifyMachineState( - { - heartbeatAlive, + return corrected; + } + + function addDays(input: { year: number; month: number; day: number }, days: number) { + const base = new Date(Date.UTC(input.year, input.month - 1, input.day)); + base.setUTCDate(base.getUTCDate() + days); + return { + year: base.getUTCFullYear(), + month: base.getUTCMonth() + 1, + day: base.getUTCDate(), + }; + } + + // Detect active episodes (macrostop, mold-change) from event rows. + // Returns the latest non-auto-ack episode whose final status is "active" + // and that's been refreshed within ACTIVE_STALE_MS. + const ACTIVE_STALE_MS = 2 * 60 * 1000; + + type ActiveEpisode = { startedAtMs: number; lastTsMs: number }; + + function detectActiveEpisode( + events: TimelineEventRow[] | undefined, + eventType: "macrostop" | "mold-change", + endMs: number + ): ActiveEpisode | null { + if (!events || events.length === 0) return null; + + type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string; lastCycleTs: number | null }; + const episodes = new Map(); + + for (const event of events) { + if (String(event.eventType || "").toLowerCase() !== eventType) continue; + + let parsed: unknown = event.data; + if (typeof parsed === "string") { + try { parsed = JSON.parse(parsed); } catch { parsed = null; } + } + const data: Record = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + + const isAutoAck = + data.is_auto_ack === true || + data.isAutoAck === true || + data.is_auto_ack === "true" || + data.isAutoAck === "true"; + if (isAutoAck) continue; + + const status = String(data.status ?? "").trim().toLowerCase(); + const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim() + || `${eventType}:${event.ts.getTime()}`; + const tsMs = event.ts.getTime(); + const lastCycleTs = Number(data.last_cycle_timestamp); + + const existing = episodes.get(incidentKey); + if (!existing) { + episodes.set(incidentKey, { + firstTsMs: tsMs, + lastTsMs: tsMs, + lastStatus: status, + lastCycleTs: Number.isFinite(lastCycleTs) && lastCycleTs > 0 ? lastCycleTs : null, + }); + continue; + } + existing.firstTsMs = Math.min(existing.firstTsMs, tsMs); + if (tsMs >= existing.lastTsMs) { + existing.lastTsMs = tsMs; + existing.lastStatus = status; + } + } + + let best: ActiveEpisode | null = null; + for (const ep of episodes.values()) { + if (ep.lastStatus !== "active") continue; + if (endMs - ep.lastTsMs > ACTIVE_STALE_MS) continue; + // Prefer the freshest active episode (highest lastTsMs) + if (!best || ep.lastTsMs > best.lastTsMs) { + best = { + startedAtMs: ep.lastCycleTs ?? ep.firstTsMs, + lastTsMs: ep.lastTsMs, + }; + } + } + return best; + } + + function statusFromMachine( + machine: RecapMachine, + endMs: number, + events?: TimelineEventRow[] + ): { + status: RecapMachineStatus; + result: MachineStateResult; + stateContext: RecapStateContext; + lastSeenMs: number | null; + offlineForMin: number | null; + ongoingStopMin: number | null; + } { + const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null; + const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs); + const heartbeatAlive = Number.isFinite(lastSeenMs ?? Number.NaN) && offlineForMs <= OFFLINE_THRESHOLD_MS; + + const activeMacrostop = detectActiveEpisode(events, "macrostop", endMs); + const activeMoldChange = detectActiveEpisode(events, "mold-change", endMs); + + // Round 1 limitation: trackingEnabled and untrackedCycles inputs require KPI/cycle queries + // we don't yet plumb here. We approximate from the legacy fields: + // - trackingEnabled: true when there's an active macrostop (Pi only fires those when tracking on) + // OR when an active WO exists and machine.workOrders.moldChangeInProgress is false. + // This is a SIMPLIFICATION; Round 3 will replace with real KPI snapshot read. + // - untrackedCycles: 0 (Round 3 will compute from MachineCycle vs latest KPI) + // + // Effect for Round 1: STOPPED `not_started` reason cannot trigger yet (we always assume tracking + // is on when a WO exists). Only `machine_fault` STOPPED fires. DATA_LOSS cannot fire yet. + // IDLE fires correctly when there's no WO and no recent activity. + const hasActiveWorkOrder = machine.workOrders.active != null; + const trackingEnabledApprox = hasActiveWorkOrder; // see comment above + + const lastCycleTsMs = (() => { + // Best-effort: use the machine's heartbeat as a "recent activity" proxy. + // The Pi only heartbeats every minute regardless of cycles, so this is a weak signal. + // Round 3 will pass the actual latest cycle ts. + return lastSeenMs; + })(); + + const result = classifyMachineState( + { + heartbeatAlive, + lastSeenMs, + offlineForMs, + trackingEnabled: trackingEnabledApprox, + hasActiveWorkOrder, + activeMoldChange, + activeMacrostop, + lastCycleTsMs, + }, + endMs + ); + + // Map the rich classifier result back to the existing RecapMachineStatus union + const status: RecapMachineStatus = result.state; + + // Pull common fields out for the caller's convenience + let ongoingStopMin: number | null = null; + if (result.state === "stopped") ongoingStopMin = result.ongoingStopMin; + + const stateContext: RecapStateContext = {}; + + + return { + status, + result, + stateContext, lastSeenMs, - offlineForMs, - trackingEnabled: trackingEnabledApprox, - hasActiveWorkOrder, - activeMoldChange, - activeMacrostop, - untrackedCycles: { count: 0, oldestTsMs: null }, - lastCycleTsMs, - }, - endMs - ); - - // Map the rich classifier result back to the existing RecapMachineStatus union - const status: RecapMachineStatus = result.state; - - // Pull common fields out for the caller's convenience - let ongoingStopMin: number | null = null; - if (result.state === "stopped") ongoingStopMin = result.ongoingStopMin; - - let stateContext: RecapStateContext = { - stoppedReason: null, - dataLossReason: null, - untrackedCycleCount: null, - }; - - if (result.state === "stopped") { - stateContext = { - stoppedReason: result.reason, - dataLossReason: null, - untrackedCycleCount: null, - }; - } else if (result.state === "data-loss") { - stateContext = { - stoppedReason: null, - dataLossReason: result.reason, - untrackedCycleCount: result.untrackedCycleCount, + offlineForMin: result.state === "offline" ? result.offlineForMin : null, + ongoingStopMin, }; } - return { - status, - result, - stateContext, - lastSeenMs, - offlineForMin: result.state === "offline" ? result.offlineForMin : null, - ongoingStopMin, - }; -} + async function loadTimelineRowsForMachines(params: { + orgId: string; + machineIds: string[]; + start: Date; + end: Date; + }) { + if (!params.machineIds.length) { + return { + cyclesByMachine: new Map(), + eventsByMachine: new Map(), + }; + } + + const [cycles, events] = await Promise.all([ + prisma.machineCycle.findMany({ + where: { + orgId: params.orgId, + machineId: { in: params.machineIds }, + ts: { + gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS), + lte: params.end, + }, + }, + orderBy: [{ machineId: "asc" }, { ts: "asc" }], + select: { + machineId: true, + ts: true, + cycleCount: true, + actualCycleTime: true, + theoreticalCycleTime: true, + workOrderId: true, + sku: true, + }, + }), + prisma.machineEvent.findMany({ + where: { + orgId: params.orgId, + machineId: { in: params.machineIds }, + eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] }, + ts: { + gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS), + lte: params.end, + }, + }, + orderBy: [{ machineId: "asc" }, { ts: "asc" }], + select: { + machineId: true, + ts: true, + eventType: true, + data: true, + }, + }), + ]); + + const cyclesByMachine = new Map(); + const eventsByMachine = new Map(); + + for (const row of cycles) { + const list = cyclesByMachine.get(row.machineId) ?? []; + list.push({ + ts: row.ts, + cycleCount: row.cycleCount, + actualCycleTime: row.actualCycleTime, + theoreticalCycleTime: row.theoreticalCycleTime ?? null, + workOrderId: row.workOrderId, + sku: row.sku, + }); + cyclesByMachine.set(row.machineId, list); + } + + for (const row of events) { + const list = eventsByMachine.get(row.machineId) ?? []; + list.push({ + ts: row.ts, + eventType: row.eventType, + data: row.data, + }); + eventsByMachine.set(row.machineId, list); + } + + return { cyclesByMachine, eventsByMachine }; + } + + function toSummaryMachine(params: { + machine: RecapMachine; + miniTimeline: ReturnType; + rangeEndMs: number; + events?: TimelineEventRow[]; + }): RecapSummaryMachine { + const { machine, miniTimeline, rangeEndMs, events } = params; + const status = statusFromMachine(machine, rangeEndMs, events); -async function loadTimelineRowsForMachines(params: { - orgId: string; - machineIds: string[]; - start: Date; - end: Date; -}) { - if (!params.machineIds.length) { return { - cyclesByMachine: new Map(), - eventsByMachine: new Map(), - }; - } - - const [cycles, events] = await Promise.all([ - prisma.machineCycle.findMany({ - where: { - orgId: params.orgId, - machineId: { in: params.machineIds }, - ts: { - gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS), - lte: params.end, - }, + machineId: machine.machineId, + name: machine.machineName, + location: machine.location, + status: status.status, + oee: machine.oee.avg, + goodParts: machine.production.goodParts, + scrap: machine.production.scrapParts, + stopsCount: machine.downtime.stopsCount, + lastSeenMs: status.lastSeenMs, + lastActivityMin: + status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)), + offlineForMin: status.offlineForMin, + ongoingStopMin: status.ongoingStopMin, + stateContext: status.stateContext, + activeWorkOrderId: machine.workOrders.active?.id ?? null, + moldChange: { + active: machine.workOrders.moldChangeInProgress, + startMs: machine.workOrders.moldChangeStartMs, + elapsedMin: + machine.workOrders.moldChangeStartMs == null + ? null + : Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)), }, - orderBy: [{ machineId: "asc" }, { ts: "asc" }], - select: { - machineId: true, - ts: true, - cycleCount: true, - actualCycleTime: true, - workOrderId: true, - sku: true, - }, - }), - prisma.machineEvent.findMany({ - where: { - orgId: params.orgId, - machineId: { in: params.machineIds }, - eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] }, - ts: { - gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS), - lte: params.end, - }, - }, - orderBy: [{ machineId: "asc" }, { ts: "asc" }], - select: { - machineId: true, - ts: true, - eventType: true, - data: true, - }, - }), - ]); - - const cyclesByMachine = new Map(); - const eventsByMachine = new Map(); - - for (const row of cycles) { - const list = cyclesByMachine.get(row.machineId) ?? []; - list.push({ - ts: row.ts, - cycleCount: row.cycleCount, - actualCycleTime: row.actualCycleTime, - workOrderId: row.workOrderId, - sku: row.sku, - }); - cyclesByMachine.set(row.machineId, list); - } - - for (const row of events) { - const list = eventsByMachine.get(row.machineId) ?? []; - list.push({ - ts: row.ts, - eventType: row.eventType, - data: row.data, - }); - eventsByMachine.set(row.machineId, list); - } - - return { cyclesByMachine, eventsByMachine }; -} - -function toSummaryMachine(params: { - machine: RecapMachine; - miniTimeline: ReturnType; - rangeEndMs: number; - events?: TimelineEventRow[]; -}): RecapSummaryMachine { - const { machine, miniTimeline, rangeEndMs, events } = params; - const status = statusFromMachine(machine, rangeEndMs, events); - - return { - machineId: machine.machineId, - name: machine.machineName, - location: machine.location, - status: status.status, - oee: machine.oee.avg, - goodParts: machine.production.goodParts, - scrap: machine.production.scrapParts, - stopsCount: machine.downtime.stopsCount, - lastSeenMs: status.lastSeenMs, - lastActivityMin: - status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)), - offlineForMin: status.offlineForMin, - ongoingStopMin: status.ongoingStopMin, - stateContext: status.stateContext, - activeWorkOrderId: machine.workOrders.active?.id ?? null, - moldChange: { - active: machine.workOrders.moldChangeInProgress, - startMs: machine.workOrders.moldChangeStartMs, - elapsedMin: - machine.workOrders.moldChangeStartMs == null - ? null - : Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)), - }, - miniTimeline, - }; -} - -async function computeRecapSummary(params: { orgId: string; hours: number }) { - const now = new Date(); - const end = new Date(Math.floor(now.getTime() / 60000) * 60000); - const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000); - - const recap = await getRecapDataCached({ - orgId: params.orgId, - start, - end, - }); - - const machineIds = recap.machines.map((machine) => machine.machineId); - const timelineRows = await loadTimelineRowsForMachines({ - orgId: params.orgId, - machineIds, - start, - end, - }); - - const machines = recap.machines.map((machine) => { - const segments = buildTimelineSegments({ - cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [], - events: timelineRows.eventsByMachine.get(machine.machineId) ?? [], - rangeStart: start, - rangeEnd: end, - }); - const miniTimeline = compressTimelineSegments({ - segments, - rangeStart: start, - rangeEnd: end, - maxSegments: 60, - }); - - return toSummaryMachine({ - machine, miniTimeline, - rangeEndMs: end.getTime(), - events: timelineRows.eventsByMachine.get(machine.machineId), + }; + } + + async function computeRecapSummary(params: { orgId: string; hours: number }) { + const now = new Date(); + const end = new Date(Math.floor(now.getTime() / 60000) * 60000); + const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000); + + const recap = await getRecapDataCached({ + orgId: params.orgId, + start, + end, }); - }); - const response: RecapSummaryResponse = { - generatedAt: new Date().toISOString(), - range: { - start: start.toISOString(), - end: end.toISOString(), - hours: params.hours, - }, - machines, - }; + const machineIds = recap.machines.map((machine) => machine.machineId); + const timelineRows = await loadTimelineRowsForMachines({ + orgId: params.orgId, + machineIds, + start, + end, + }); - return response; -} + const machines = recap.machines.map((machine) => { + const segments = buildTimelineSegments({ + cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [], + events: timelineRows.eventsByMachine.get(machine.machineId) ?? [], + rangeStart: start, + rangeEnd: end, + }); + const miniTimeline = compressTimelineSegments({ + segments, + rangeStart: start, + rangeEnd: end, + maxSegments: 60, + }); -function normalizedRangeMode(mode?: string | null): RecapRangeMode { - const raw = String(mode ?? "").trim().toLowerCase(); - if (raw === "shift") return "shift"; - if (raw === "yesterday") return "yesterday"; - if (raw === "custom") return "custom"; - return "24h"; -} + return toSummaryMachine({ + machine, + miniTimeline, + rangeEndMs: end.getTime(), + events: timelineRows.eventsByMachine.get(machine.machineId), + }); + }); -async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) { - const settings = await prisma.orgSettings.findUnique({ - where: { orgId: params.orgId }, - select: { - timezone: true, - shiftScheduleOverridesJson: true, - }, - }); - const shifts = await prisma.orgShift.findMany({ - where: { orgId: params.orgId }, - orderBy: { sortOrder: "asc" }, - select: { - name: true, - startTime: true, - endTime: true, - enabled: true, - sortOrder: true, - }, - }); + const response: RecapSummaryResponse = { + generatedAt: new Date().toISOString(), + range: { + start: start.toISOString(), + end: end.toISOString(), + hours: params.hours, + }, + machines, + }; + + return response; + } + + function normalizedRangeMode(mode?: string | null): RecapRangeMode { + const raw = String(mode ?? "").trim().toLowerCase(); + if (raw === "shift") return "shift"; + if (raw === "yesterday") return "yesterday"; + if (raw === "custom") return "custom"; + return "24h"; + } + + async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) { + const settings = await prisma.orgSettings.findUnique({ + where: { orgId: params.orgId }, + select: { + timezone: true, + shiftScheduleOverridesJson: true, + }, + }); + const shifts = await prisma.orgShift.findMany({ + where: { orgId: params.orgId }, + orderBy: { sortOrder: "asc" }, + select: { + name: true, + startTime: true, + endTime: true, + enabled: true, + sortOrder: true, + }, + }); + + const enabledShifts = shifts.filter((shift) => shift.enabled !== false); + if (!enabledShifts.length) { + return { + hasEnabledShifts: false, + range: null, + } as const; + } + + const timeZone = settings?.timezone || "UTC"; + const local = getLocalParts(params.now, timeZone); + const overrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson); + const dayOverrides = overrides?.[local.weekday]; + const activeShifts = (dayOverrides?.length + ? dayOverrides.map((shift) => ({ + enabled: shift.enabled !== false, + start: shift.start, + end: shift.end, + })) + : enabledShifts.map((shift) => ({ + enabled: shift.enabled !== false, + start: shift.startTime, + end: shift.endTime, + })) + ).filter((shift) => shift.enabled); + + for (const shift of activeShifts) { + const startMin = parseTimeMinutes(shift.start ?? null); + const endMin = parseTimeMinutes(shift.end ?? null); + if (startMin == null || endMin == null) continue; + + const minutesNow = local.minutesOfDay; + let inRange = false; + let startDate = { year: local.year, month: local.month, day: local.day }; + let endDate = { year: local.year, month: local.month, day: local.day }; + + if (startMin <= endMin) { + inRange = minutesNow >= startMin && minutesNow < endMin; + } else { + inRange = minutesNow >= startMin || minutesNow < endMin; + if (minutesNow >= startMin) { + endDate = addDays(endDate, 1); + } else { + startDate = addDays(startDate, -1); + } + } + + if (!inRange) continue; + + const start = zonedToUtcDate({ + ...startDate, + hours: Math.floor(startMin / 60), + minutes: startMin % 60, + timeZone, + }); + const shiftEndUtc = zonedToUtcDate({ + ...endDate, + hours: Math.floor(endMin / 60), + minutes: endMin % 60, + timeZone, + }); + + if (shiftEndUtc <= start) continue; + + // Cap end at "now" so we render shift-so-far, not shift-as-planned. + // Without cap: + // - timeline fills future minutes with idle (visual lie) + // - offline calc = (shift_end_future - last_seen) = looks 5h offline + // even on a machine producing right now + const end = params.now < shiftEndUtc ? params.now : shiftEndUtc; + + return { + hasEnabledShifts: true, + range: { start, end }, + }; + } - const enabledShifts = shifts.filter((shift) => shift.enabled !== false); - if (!enabledShifts.length) { return { - hasEnabledShifts: false, + hasEnabledShifts: true, range: null, } as const; } - const timeZone = settings?.timezone || "UTC"; - const local = getLocalParts(params.now, timeZone); - const overrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson); - const dayOverrides = overrides?.[local.weekday]; - const activeShifts = (dayOverrides?.length - ? dayOverrides.map((shift) => ({ - enabled: shift.enabled !== false, - start: shift.start, - end: shift.end, - })) - : enabledShifts.map((shift) => ({ - enabled: shift.enabled !== false, - start: shift.startTime, - end: shift.endTime, - })) - ).filter((shift) => shift.enabled); + async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) { + const now = new Date(Math.floor(Date.now() / 60000) * 60000); + const requestedMode = normalizedRangeMode(params.input.mode); + const shiftEnabledCount = await prisma.orgShift.count({ + where: { + orgId: params.orgId, + enabled: { not: false }, + }, + }); + const shiftAvailable = shiftEnabledCount > 0; - for (const shift of activeShifts) { - const startMin = parseTimeMinutes(shift.start ?? null); - const endMin = parseTimeMinutes(shift.end ?? null); - if (startMin == null || endMin == null) continue; - - const minutesNow = local.minutesOfDay; - let inRange = false; - let startDate = { year: local.year, month: local.month, day: local.day }; - let endDate = { year: local.year, month: local.month, day: local.day }; - - if (startMin <= endMin) { - inRange = minutesNow >= startMin && minutesNow < endMin; - } else { - inRange = minutesNow >= startMin || minutesNow < endMin; - if (minutesNow >= startMin) { - endDate = addDays(endDate, 1); - } else { - startDate = addDays(startDate, -1); + if (requestedMode === "custom") { + const start = parseDate(params.input.start); + const end = parseDate(params.input.end); + if (start && end && end > start) { + return { + requestedMode, + mode: requestedMode, + start, + end, + shiftAvailable, + } as const; } } - if (!inRange) continue; - - const start = zonedToUtcDate({ - ...startDate, - hours: Math.floor(startMin / 60), - minutes: startMin % 60, - timeZone, - }); - const shiftEndUtc = zonedToUtcDate({ - ...endDate, - hours: Math.floor(endMin / 60), - minutes: endMin % 60, - timeZone, - }); - - if (shiftEndUtc <= start) continue; - - // Cap end at "now" so we render shift-so-far, not shift-as-planned. - // Without cap: - // - timeline fills future minutes with idle (visual lie) - // - offline calc = (shift_end_future - last_seen) = looks 5h offline - // even on a machine producing right now - const end = params.now < shiftEndUtc ? params.now : shiftEndUtc; - - return { - hasEnabledShifts: true, - range: { start, end }, - }; - } - - return { - hasEnabledShifts: true, - range: null, - } as const; -} - -async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) { - const now = new Date(Math.floor(Date.now() / 60000) * 60000); - const requestedMode = normalizedRangeMode(params.input.mode); - const shiftEnabledCount = await prisma.orgShift.count({ - where: { - orgId: params.orgId, - enabled: { not: false }, - }, - }); - const shiftAvailable = shiftEnabledCount > 0; - - if (requestedMode === "custom") { - const start = parseDate(params.input.start); - const end = parseDate(params.input.end); - if (start && end && end > start) { + if (requestedMode === "yesterday") { + const settings = await prisma.orgSettings.findUnique({ + where: { orgId: params.orgId }, + select: { timezone: true }, + }); + const timeZone = settings?.timezone || "America/Mexico_City"; + const localNow = getLocalParts(now, timeZone); + const today = { year: localNow.year, month: localNow.month, day: localNow.day }; + const yesterday = addDays(today, -1); + const start = zonedToUtcDate({ + ...yesterday, + hours: 0, + minutes: 0, + timeZone, + }); + const end = zonedToUtcDate({ + ...today, + hours: 0, + minutes: 0, + timeZone, + }); return { requestedMode, mode: requestedMode, @@ -647,285 +662,254 @@ async function resolveDetailRange(params: { orgId: string; input: DetailRangeInp shiftAvailable, } as const; } - } - if (requestedMode === "yesterday") { - const settings = await prisma.orgSettings.findUnique({ - where: { orgId: params.orgId }, - select: { timezone: true }, - }); - const timeZone = settings?.timezone || "America/Mexico_City"; - const localNow = getLocalParts(now, timeZone); - const today = { year: localNow.year, month: localNow.month, day: localNow.day }; - const yesterday = addDays(today, -1); - const start = zonedToUtcDate({ - ...yesterday, - hours: 0, - minutes: 0, - timeZone, - }); - const end = zonedToUtcDate({ - ...today, - hours: 0, - minutes: 0, - timeZone, - }); - return { - requestedMode, - mode: requestedMode, - start, - end, - shiftAvailable, - } as const; - } - - if (requestedMode === "shift") { - const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now }); - if (shiftRange.range) { - return { - requestedMode, - mode: requestedMode, - start: shiftRange.range.start, - end: shiftRange.range.end, - shiftAvailable, - } as const; - } - if (!shiftRange.hasEnabledShifts) { + if (requestedMode === "shift") { + const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now }); + if (shiftRange.range) { + return { + requestedMode, + mode: requestedMode, + start: shiftRange.range.start, + end: shiftRange.range.end, + shiftAvailable, + } as const; + } + if (!shiftRange.hasEnabledShifts) { + return { + requestedMode, + mode: "24h" as const, + start: new Date(now.getTime() - 24 * 60 * 60 * 1000), + end: now, + shiftAvailable, + fallbackReason: "shift-unavailable" as const, + } as const; + } return { requestedMode, mode: "24h" as const, start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end: now, shiftAvailable, - fallbackReason: "shift-unavailable" as const, + fallbackReason: "shift-inactive" as const, } as const; } + return { requestedMode, mode: "24h" as const, start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end: now, shiftAvailable, - fallbackReason: "shift-inactive" as const, } as const; } - return { - requestedMode, - mode: "24h" as const, - start: new Date(now.getTime() - 24 * 60 * 60 * 1000), - end: now, - shiftAvailable, - } as const; -} + async function computeRecapMachineDetail(params: { + orgId: string; + machineId: string; + range: { + requestedMode: RecapRangeMode; + mode: RecapRangeMode; + start: Date; + end: Date; + shiftAvailable: boolean; + fallbackReason?: "shift-unavailable" | "shift-inactive"; + }; + }) { + const { range } = params; -async function computeRecapMachineDetail(params: { - orgId: string; - machineId: string; - range: { + const recap = await getRecapDataCached({ + orgId: params.orgId, + machineId: params.machineId, + start: range.start, + end: range.end, + }); + + const machine = recap.machines.find((row) => row.machineId === params.machineId) ?? null; + if (!machine) return null; + + const timelineRows = await loadTimelineRowsForMachines({ + orgId: params.orgId, + machineIds: [params.machineId], + start: range.start, + end: range.end, + }); + + const timeline = buildTimelineSegments({ + cycles: timelineRows.cyclesByMachine.get(params.machineId) ?? [], + events: timelineRows.eventsByMachine.get(params.machineId) ?? [], + rangeStart: range.start, + rangeEnd: range.end, + }); + + const status = statusFromMachine( + machine, + range.end.getTime(), + timelineRows.eventsByMachine.get(params.machineId) + ); + + const downtimeTotalMin = Math.max(0, machine.downtime.totalMin); + const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({ + reasonLabel: row.reasonLabel, + minutes: row.minutes, + count: row.count, + percent: downtimeTotalMin > 0 ? round2((row.minutes / downtimeTotalMin) * 100) : 0, + })); + + const machineDetail: RecapMachineDetail = { + machineId: machine.machineId, + name: machine.machineName, + location: machine.location, + status: status.status, + oee: machine.oee.avg, + goodParts: machine.production.goodParts, + scrap: machine.production.scrapParts, + stopsCount: machine.downtime.stopsCount, + stopMinutes: downtimeTotalMin, + activeWorkOrderId: machine.workOrders.active?.id ?? null, + lastSeenMs: status.lastSeenMs, + offlineForMin: status.offlineForMin, + ongoingStopMin: status.ongoingStopMin, + stateContext: status.stateContext, + + moldChange: { + active: machine.workOrders.moldChangeInProgress, + startMs: machine.workOrders.moldChangeStartMs, + }, + timeline, + productionBySku: machine.production.bySku, + downtimeTop, + workOrders: { + completed: machine.workOrders.completed, + active: machine.workOrders.active, + }, + heartbeat: { + lastSeenAt: machine.heartbeat.lastSeenAt, + uptimePct: machine.heartbeat.uptimePct, + connectionStatus: status.status === "offline" ? "offline" : "online", + }, + }; + + const response: RecapDetailResponse = { + generatedAt: new Date().toISOString(), + range: { + requestedMode: range.requestedMode, + mode: range.mode, + start: range.start.toISOString(), + end: range.end.toISOString(), + shiftAvailable: range.shiftAvailable, + fallbackReason: range.fallbackReason, + }, + machine: machineDetail, + }; + + return response; + } + + function summaryCacheKey(params: { orgId: string; hours: number }) { + return ["recap-summary-v1", params.orgId, String(params.hours)]; + } + + function detailCacheKey(params: { + orgId: string; + machineId: string; requestedMode: RecapRangeMode; mode: RecapRangeMode; - start: Date; - end: Date; shiftAvailable: boolean; fallbackReason?: "shift-unavailable" | "shift-inactive"; - }; -}) { - const { range } = params; + startMs: number; + endMs: number; + }) { + return [ + "recap-detail-v1", + params.orgId, + params.machineId, + params.requestedMode, + params.mode, + params.shiftAvailable ? "shift-on" : "shift-off", + params.fallbackReason ?? "", + String(Math.trunc(params.startMs / 60000)), + String(Math.trunc(params.endMs / 60000)), + ]; + } - const recap = await getRecapDataCached({ - orgId: params.orgId, - machineId: params.machineId, - start: range.start, - end: range.end, - }); + export function parseRecapSummaryHours(raw: string | null) { + return parseHours(raw); + } - const machine = recap.machines.find((row) => row.machineId === params.machineId) ?? null; - if (!machine) return null; + export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record) { + if (searchParams instanceof URLSearchParams) { + return { + mode: searchParams.get("range") ?? undefined, + start: searchParams.get("start") ?? undefined, + end: searchParams.get("end") ?? undefined, + }; + } - const timelineRows = await loadTimelineRowsForMachines({ - orgId: params.orgId, - machineIds: [params.machineId], - start: range.start, - end: range.end, - }); + const pick = (key: string) => { + const value = searchParams[key]; + if (Array.isArray(value)) return value[0] ?? undefined; + return value ?? undefined; + }; - const timeline = buildTimelineSegments({ - cycles: timelineRows.cyclesByMachine.get(params.machineId) ?? [], - events: timelineRows.eventsByMachine.get(params.machineId) ?? [], - rangeStart: range.start, - rangeEnd: range.end, - }); - - const status = statusFromMachine( - machine, - range.end.getTime(), - timelineRows.eventsByMachine.get(params.machineId) - ); - - const downtimeTotalMin = Math.max(0, machine.downtime.totalMin); - const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({ - reasonLabel: row.reasonLabel, - minutes: row.minutes, - count: row.count, - percent: downtimeTotalMin > 0 ? round2((row.minutes / downtimeTotalMin) * 100) : 0, - })); - - const machineDetail: RecapMachineDetail = { - machineId: machine.machineId, - name: machine.machineName, - location: machine.location, - status: status.status, - oee: machine.oee.avg, - goodParts: machine.production.goodParts, - scrap: machine.production.scrapParts, - stopsCount: machine.downtime.stopsCount, - stopMinutes: downtimeTotalMin, - activeWorkOrderId: machine.workOrders.active?.id ?? null, - lastSeenMs: status.lastSeenMs, - offlineForMin: status.offlineForMin, - ongoingStopMin: status.ongoingStopMin, - stateContext: status.stateContext, - - moldChange: { - active: machine.workOrders.moldChangeInProgress, - startMs: machine.workOrders.moldChangeStartMs, - }, - timeline, - productionBySku: machine.production.bySku, - downtimeTop, - workOrders: { - completed: machine.workOrders.completed, - active: machine.workOrders.active, - }, - heartbeat: { - lastSeenAt: machine.heartbeat.lastSeenAt, - uptimePct: machine.heartbeat.uptimePct, - connectionStatus: status.status === "offline" ? "offline" : "online", - }, - }; - - const response: RecapDetailResponse = { - generatedAt: new Date().toISOString(), - range: { - requestedMode: range.requestedMode, - mode: range.mode, - start: range.start.toISOString(), - end: range.end.toISOString(), - shiftAvailable: range.shiftAvailable, - fallbackReason: range.fallbackReason, - }, - machine: machineDetail, - }; - - return response; -} - -function summaryCacheKey(params: { orgId: string; hours: number }) { - return ["recap-summary-v1", params.orgId, String(params.hours)]; -} - -function detailCacheKey(params: { - orgId: string; - machineId: string; - requestedMode: RecapRangeMode; - mode: RecapRangeMode; - shiftAvailable: boolean; - fallbackReason?: "shift-unavailable" | "shift-inactive"; - startMs: number; - endMs: number; -}) { - return [ - "recap-detail-v1", - params.orgId, - params.machineId, - params.requestedMode, - params.mode, - params.shiftAvailable ? "shift-on" : "shift-off", - params.fallbackReason ?? "", - String(Math.trunc(params.startMs / 60000)), - String(Math.trunc(params.endMs / 60000)), - ]; -} - -export function parseRecapSummaryHours(raw: string | null) { - return parseHours(raw); -} - -export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record) { - if (searchParams instanceof URLSearchParams) { return { - mode: searchParams.get("range") ?? undefined, - start: searchParams.get("start") ?? undefined, - end: searchParams.get("end") ?? undefined, + mode: pick("range"), + start: pick("start"), + end: pick("end"), }; } - const pick = (key: string) => { - const value = searchParams[key]; - if (Array.isArray(value)) return value[0] ?? undefined; - return value ?? undefined; - }; + export async function getRecapSummaryCached(params: { orgId: string; hours: number }) { + const cache = unstable_cache( + () => computeRecapSummary(params), + summaryCacheKey(params), + { + revalidate: RECAP_CACHE_TTL_SEC, + tags: [`recap:${params.orgId}`], + } + ); - return { - mode: pick("range"), - start: pick("start"), - end: pick("end"), - }; -} + return cache(); + } -export async function getRecapSummaryCached(params: { orgId: string; hours: number }) { - const cache = unstable_cache( - () => computeRecapSummary(params), - summaryCacheKey(params), - { - revalidate: RECAP_CACHE_TTL_SEC, - tags: [`recap:${params.orgId}`], - } - ); + export async function getRecapMachineDetailCached(params: { + orgId: string; + machineId: string; + input: DetailRangeInput; + }) { + const resolved = await resolveDetailRange({ + orgId: params.orgId, + input: params.input, + }); - return cache(); -} - -export async function getRecapMachineDetailCached(params: { - orgId: string; - machineId: string; - input: DetailRangeInput; -}) { - const resolved = await resolveDetailRange({ - orgId: params.orgId, - input: params.input, - }); - - const cache = unstable_cache( - () => - computeRecapMachineDetail({ + const cache = unstable_cache( + () => + computeRecapMachineDetail({ + orgId: params.orgId, + machineId: params.machineId, + range: { + requestedMode: resolved.requestedMode, + mode: resolved.mode, + start: resolved.start, + end: resolved.end, + shiftAvailable: resolved.shiftAvailable, + fallbackReason: resolved.fallbackReason, + }, + }), + detailCacheKey({ orgId: params.orgId, machineId: params.machineId, - range: { - requestedMode: resolved.requestedMode, - mode: resolved.mode, - start: resolved.start, - end: resolved.end, - shiftAvailable: resolved.shiftAvailable, - fallbackReason: resolved.fallbackReason, - }, + requestedMode: resolved.requestedMode, + mode: resolved.mode, + shiftAvailable: resolved.shiftAvailable, + fallbackReason: resolved.fallbackReason, + startMs: resolved.start.getTime(), + endMs: resolved.end.getTime(), }), - detailCacheKey({ - orgId: params.orgId, - machineId: params.machineId, - requestedMode: resolved.requestedMode, - mode: resolved.mode, - shiftAvailable: resolved.shiftAvailable, - fallbackReason: resolved.fallbackReason, - startMs: resolved.start.getTime(), - endMs: resolved.end.getTime(), - }), - { - revalidate: RECAP_CACHE_TTL_SEC, - tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`], - } - ); + { + revalidate: RECAP_CACHE_TTL_SEC, + tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`], + } + ); - return cache(); -} + return cache(); + } diff --git a/lib/recap/timeline.ts b/lib/recap/timeline.ts index 47544f7..f222e01 100644 --- a/lib/recap/timeline.ts +++ b/lib/recap/timeline.ts @@ -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; } diff --git a/lib/recap/timelineApi.ts b/lib/recap/timelineApi.ts index 6313877..aa37bc3 100644 --- a/lib/recap/timelineApi.ts +++ b/lib/recap/timelineApi.ts @@ -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, diff --git a/lib/recap/types.ts b/lib/recap/types.ts index e65f608..60399da 100644 --- a/lib/recap/types.ts +++ b/lib/recap/types.ts @@ -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; export type RecapSummaryMachine = { diff --git a/lib/settings.ts b/lib/settings.ts index 4b920fe..3e94733 100644 --- a/lib/settings.ts +++ b/lib/settings.ts @@ -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, }; diff --git a/package.json b/package.json index 0e088f0..9dc875c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "test:downtime-reason-guard": "node scripts/test-downtime-reason-guard.mjs", "backfill:downtime-reasons": "node scripts/backfill-downtime-reasons.mjs", "prisma:generate": "prisma generate", - "prisma:migrate:deploy": "prisma migrate deploy" + "prisma:migrate:deploy": "prisma migrate deploy", + "seed:reason-catalog": "node scripts/seed-reason-catalog-from-xlsx.mjs" }, "dependencies": { "@prisma/client": "^6.19.1", diff --git a/prisma/migrations/20260505120000_reason_catalog_tables/migration.sql b/prisma/migrations/20260505120000_reason_catalog_tables/migration.sql new file mode 100644 index 0000000..dabbfc9 --- /dev/null +++ b/prisma/migrations/20260505120000_reason_catalog_tables/migration.sql @@ -0,0 +1,42 @@ +-- Reason catalog: relational storage (replaces JSON in org_settings for new data). + +CREATE TABLE "reason_catalog_category" ( + "id" TEXT NOT NULL, + "org_id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "name" TEXT NOT NULL, + "code_prefix" TEXT NOT NULL, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "reason_catalog_category_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "reason_catalog_item" ( + "id" TEXT NOT NULL, + "org_id" TEXT NOT NULL, + "category_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "code_suffix" TEXT NOT NULL, + "reason_code" TEXT NOT NULL, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "reason_catalog_item_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "reason_catalog_category_org_id_kind_active_idx" ON "reason_catalog_category"("org_id", "kind", "active"); + +CREATE UNIQUE INDEX "reason_catalog_item_org_id_reason_code_key" ON "reason_catalog_item"("org_id", "reason_code"); + +CREATE INDEX "reason_catalog_item_org_id_category_id_idx" ON "reason_catalog_item"("org_id", "category_id"); + +ALTER TABLE "reason_catalog_category" ADD CONSTRAINT "reason_catalog_category_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "reason_catalog_item" ADD CONSTRAINT "reason_catalog_item_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "reason_catalog_item" ADD CONSTRAINT "reason_catalog_item_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "reason_catalog_category"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2501ce3..06e87ba 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,6 +33,8 @@ model Org { shifts OrgShift[] productCostOverrides ProductCostOverride[] settingsAudits SettingsAudit[] + reasonCatalogCategories ReasonCatalogCategory[] + reasonCatalogItems ReasonCatalogItem[] } model User { @@ -290,6 +292,42 @@ model IngestLog { @@index([machineId, seq]) } +model ReasonCatalogCategory { + id String @id @default(uuid()) + orgId String @map("org_id") + kind String + name String + codePrefix String @map("code_prefix") + sortOrder Int @default(0) @map("sort_order") + active Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + items ReasonCatalogItem[] + + @@index([orgId, kind, active]) + @@map("reason_catalog_category") +} + +model ReasonCatalogItem { + id String @id @default(uuid()) + orgId String @map("org_id") + categoryId String @map("category_id") + name String + codeSuffix String @map("code_suffix") + reasonCode String @map("reason_code") + sortOrder Int @default(0) @map("sort_order") + active Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + category ReasonCatalogCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) + + @@unique([orgId, reasonCode]) + @@index([orgId, categoryId]) + @@map("reason_catalog_item") +} + model OrgSettings { orgId String @id @map("org_id") timezone String @default("UTC") diff --git a/reasons/Claves Tiempo Muerto.xlsx b/reasons/Claves Tiempo Muerto.xlsx new file mode 100755 index 0000000000000000000000000000000000000000..7bae8d3cb2a0cd116bdb1e7c50343bd169f538fe GIT binary patch literal 49272 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

gtQGepU56>YA_?iFWBlGgfk;6-X&9gdGQHYW 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 MOab3THvHWCKaLJk6951J literal 0 HcmV?d00001 diff --git a/reasons/Claves de Scrap.xlsx b/reasons/Claves de Scrap.xlsx new file mode 100755 index 0000000000000000000000000000000000000000..5fe923b7ec5b917850bfd9621b1940218f93764a GIT binary patch literal 35930 zcmeFYgOg>?wk^ENHoELCcGuFSO~ zSFDjUbF7hbX71mTAfTuKZ~!C#03Zb5k?Goo0097%U;qFL01{Y3(8k))$l6gy(aqM# zL5s%K%97wOC@^^r0QkH8|4aWDTcBKV#JZObu|wsQSEy6DzdpFM6q4JKy-{o$f$kT)S!S6bvvV7u2O$jZk%?af|8%-sdKi` z$uGYGcwyL?E3a;$39M0`!Gti?;tY)r(OJ9rPzwGw`$Y!F zC?4NgRgm9n`#c!Nu!01;{lA9;GB%YxG72Np_Fi{F0C3u*4ts)=u@q?KkwXwHXOM#H z?3aC{m%+4j7;r0mV6V#5Z9kJEHJj;hIaw!U12oNz8JKMnYcw{DWp&VG`lFb0a1fnP5+l1)2|H$dkE_djTFXm(GlN&&q}Fpe!_ecSjXPT(t(!dpX>i4?*GMX{cl|_kCT?|qk|2)5_=CGep=Xw zK@^a5;TLNoRP^>0Uq@(&%p<|x=pw^GRKyAZ5%uZteji=i;EFsQCVbpwDi23S?ue!=Yc9@}9N8iknY$6HL7bvd!Gc05 zzzIU=jqvgaUj(=oK{z7tCCLizBdmdO~FCt^Z=W|$Hk`kC|Kqh8N+GnVPr z&kEI7HheweAC6zV`K@LDlO)!JJHwSA002D{0D$mq8COeM7aMyEeH$B#e-y4<$;u{= z4e`@^_5%X`fog!14_Kmhm%3HrqEuqeS+-QMWgE53*I|&Ur1ay8Gbn~MeU4(OgYuyb zpO^7&obI!h0_GwyEbYep@18@xMZJXEVChk)cUuZ3u~ym75(fF}FqwN+^)sEqt}hmC zhnw5}mvC@^68DS)g-WN{qM3}OeD)n$g|5knusDg-K%H6@=ufpw1M5g>C(^U?Qr5^PR++dX?y9H{P6tpUUn|^50E%o18R0?0a zExZ0)Xu6N!oU0<;Q-6FSa%x_abZ~S0bhIrWEMp(eS zj3|RRm3u!(s9b^%ynXfV$4oKu7t9ABj=Yd3R_yBTPh3Z!-Iv5wh|3mKbW#3XU%+^$-r=J=Tjyq3rM$$LEh4qF7$KI_+g&K)Cl_p}A830pAOMM*eFns7*+U z_+pt}`(yilX0I)v+K9|f*R_(9@mvqFm%I^<)^2xZ8m^BY*%N{)@T@3pu1GiOzP)oQ|wiDZCU)-?fc z>#$O;F7)!aXrOuXM0N^_)gPz~vqc*gJ{e#p%e<1489U4CeLzk)oj^?r!{iGTfOEb1 z0_=_9F?E@t>3|eWHqbS445BHP-wd{}9{Xy8!+5G9hC|A`u<>kAxCM|84L4uWX={fG zTRwxRV;PVwh$LQq(OVfbg7N{*oIil4ylQh9p=SW(tBkO>19%L*(+zNBS#TMIQF;BT zUNK6Cw7hCJBE}x+$SZuzzyIW9zLjQLsknrXS?gg>1~5Gx)y(!jK6c@@1D(OR@9wN@ zghJ$1sf3rIOlgyb5529F1P7GS!OQUCk015ugzsy71-?YXGq3rUH71s5~G1%L$lruM%j@V^uMKXM54y^j6XjG%H^dc`@h$5FrUG6F6qu_ zNGp!CMCVFp2#_Q76lbbbD9GohXJr>K@M#VxEj)38g`zz!9k)egH?~(qWQwB`8WdIW#0GRs;0APH#_%AhZFf}rAbfEoLLH{pVNZYVk z6+<1^Qd#}#x#h(0xh2do7nnKJQ&})>d$Sw|C|d?KNQ&2J)4(q)hYV39-jI}Y5vsr zzNAw~rpCR%Nr(l3^Y}2n!91b2>*mymVx<0bz<<6&hOL%p2C}8BQN^Q3q;*D?_~!>K zc&JL@%cd1B>4FE>zHz*XK4F~!#iO)^d?=9>fltR6!oy0%_@;qM;lkX_^q8R=AY4g% zf1-@VDU%_c%krsKLIv#nZ6JDf+^u9=7U0u&$MDLgRx(~Ev1mVc-?L)DICl}}SsYRglPArgX?4bKALBQspV>u%X> zb|H^kXc1Di-zC!oaeQ5{70rcjW6s1Ws(-|(Q$mf$xTLiZs@wbJ5dq8`NEWwS4(v%{ zl&oPChxtkj*jG86SD=(~p^aY-2PYgWHP(i1G6jzgHzAu`+h^&b zOx{JoOM^2e#<46Xp6Vm6z1dec`*+-wc zOb=N*5hgZi18R9RQ02~Q$T9;LaqpO1>Aug#p*KdMQN*A{>1^4fV-E~k<#$;L;XH(_ zFz_>3Y|_2K@|tmsZ=Jtc$<$j+X{-eX$WeqVKD@Q21vq-|9EH6nhc< z?@PhxRj~rg-;@z8+R$dODQ}ib-Y&$Z*!4t*8FLtHPja|TRe-H*i4{kMNV*euihTN< z%P*mVt@@%yek%QC_Q+6Y&dkfXUOc2#%zx?ymve*vsaIUijsBv|_H>02~A_j%pGOZCzZH5A!MGSJd>*p?33q249Q=Db~>kho2_g_$Q|2ve6Zzv~) zVgJTr`i%un;?Fmff8N*TU4~RZR6l1OEmgmE@)+~zEc72FVji4C|3Q-9f%{*b-@u#Q zg#QN#pNHW;y-I#WoSQU*trvA})mqC@{5O)?Z(RRD68jI54Mm`TSta`4RvBJ9zj!i(xk!K{(jR~JCJnKLP-O>-AW7Z<+MlcxVd6!PfI_a8(;9&2R(LG-ud0_EE_ zCxSmYH}I&w$KW_#HW4Gz?{WEiNU#&2{{?F3zk_Q0$5`pmeUoCa&FtpjBC<-|yGop{^v8XBB6#!VV4AIo zmb$tE6CrGz7&gdsSpY_i%DUKEb{`O8$k97Frl9}_F@(SF<6XU=VU(NB=tiJMgk?A) zZ1?0SQkBD{C9~~*Mr7vJh3=|vl*g?SvUC|v+^d6K5e5_gy9aK0y9Q+P?VE!V{u4s` zmz+478d(|9{_FZLB{^4sho2{<;mAEMW75|4MELrpy_P=L zl&Y4~Xn*b7^-fbD#ogAUC{`%}3(Z1h&pK{G;YElRDEsF3o}e;j#mQ6?F+A@NW`8;2 zUX`Sffckk-KG~9mB&we$j@bkEscP@R#P!-R(jb>_%Z-mI_)YQI^o}-26v)ZYNrNQp zg3fPdP8ncS$Pyz`HA>8HR{n zUfYZfK+0+_XhP3mdHI+Tz?Irv%1Bc=XB>G1o2QB&~(4l#O6Z$VRsFl zaDvcuohOH1e!LAw-Y2T@T)cD%r}+V%u!F7fY+S$znI7d05{4-)+c=$AEO%9<(&jhL zw)WJ!CdX#JXnz0({Z|jg%smWKc0qWTrYE`?14tEyzT7ly-kjgq6}V<|C;|^A4E~ra zh(L2YK@y{Ko-$7Y_w!8Fv=j4cm)oD(YywGjq=Ik)ZX`4ZW&z6DZ?W6I-Th|wU@lvm z=i}v|&V)AGJA2AXk=_1{EpyG@=JRFg%k%E%>r?cX>)pG}%KXvI>|odDW!X~K`zy;s z^=>a})#qp5S+=+9!-LM}!-YfiV`cXci>DL{(G0GA(B&N{`tgoz)WDl~gn<+;6i$G3 z^l5Gw?sg`cF^^_+GpQ#z!QQhkYjQMBrD%;R<~NM&*Tc)bdAn=d7TYxurTtG3N}(VxV)q(tLH4dHlD{hggCUfpS6mClLX>XA{fSreeSxXB{&b`r!+;#;V6JCf#X1cPqcK4L16e^8)u-<)uD zXRHka@tzE^skySxF*Vj?kD@@zMO@k)1mN4d1RtARgUGnFz7hV6YTL?$#N)GXpg;rq z>!Su8O>2XEc{zlZuD3hJpcTuu(m++r%HclNg0cz`Naq<;6qo~%?T&H(+*{+u|EdhD zW|aL5e1`69pu}4wgk1bLXK+O(7DADO&_n3Z6C%5Bp{2jB*UW!_X2PZWOy;a?EWb_vmi%+RVDj(zLQt(&s)E=C;RMC29wyG0g>r z&Zeqp`7zW37)2K0p`m-9IG6?>(%n1^+8k*`X1JM_aiyuB>b6iD#Eh0{Rr?v%a{=n4!JcVOd}T@-7vtj; zHI#!?GsZX^%9MhvX~HTtd7Jm*M};VDq&Y@g!GX-Fl7BA=XEYJ~MK?uy%g^Oi>$!ia ztADI2S~oJ0EBd*A~7 zDOyWAn>;vKFv@iXlI0{Rw{;L#)x+qMw8P7Erut;?+Mv-qot`UHy$bRdse7RJtUwuU zS?`wTRH#PHbn+MB_V-AE(suO6bd27ysP$creT9RqW7nLQN*HE&4>nBt6Jzdcl!3WU zlKBgpL=njkLz=@+`;!z))!aU0N4bJu;CUm?LQwssBNn8nO@_1%GnIVpVlkxA@evY} zXAEsqC-ONspn*ikzb$(A1e3#9(hgrRM0lcvmnPF<1G}QVyr6>}vyvTDxciIXCZ|VW)EnRlQ1%F}HUU~M{q!d}% zvBF%o6z?XXO${ZyT(DA7>%?2bFMBPI%rk=D9Yy_|G8-^BkmwEi48du}?U_MNlar!a z=E*|3t_G?Bhgr0+v{GI)2Ddkwtm;QQ}%!UOORO2a2R1|B_I^`8>pM{jckRNPv4Gj=*HWS84mR!Y0dA0AS6S%##SeVSiTt9-nc;zd44d;5}; zzyC_0KbSRf>*xXA5h;*@Iz`e?qal0euD1gAXJ$Nr;G)hF8cj-W5 zqje-Gx6Q*^G*~a6HwNiFiEGdLqSY$Kt$|5dv28xn{;zHx#;N6)=XcBi1{wf>`?oGT zIJ#LHIsD_>wJU2xr*j~BSIvLvzGNQM#z+(8n6rc{mQ+w)D4&*rkk*{SFia-hAHP3E z!I9K^n6)O3f-LXdux@E`k$TNRMO~Iyr^aEz(*j2n|56&*XX(qng0_j0vj{1NREaZw z=F@m5_4%N*n(&)j!z};@3bbTwXDn2E_13s7Cb;-xUL2UHZpIHQ^{{AzEiJyVD((L! zhdA!ZpVH6WE1z)W3;DO}61z(AFJX`shRNY}H68N7?S?X?Gz(6#v!0a1{2xZ#T$yN* zjC()a9*GKdrF59?2PLh#IG+5r>mNK6vSPmP4Ec070mo!poF4-D%S-oJEB8BG6^TsL zlndHvusXnU*!~A9C7mO^H2gsj6e8Tl!P+=#eq)b0WElOM&|YJCCh{>;eX;i|sZxTqivrjwZ5nx>qjOy9hCU`j<230iT#_78sn&eFx} zb3h|i$%UCQo;Iodi(A3K<)C&;=DnQ2F?k76X_HWyV-`_>?zf_yP?WI<-dn!nY|o46A~i zjvKB&lMAvlGG>psVk=21y&QkAfiAiGMnn5W8q2aLM-(mvSF;?xWW^#;HG>F?;(K38 zETh1DEd|G}MLni&p`s_U?AV^86JT|fH%cJ2RG1zo9S_{=77j62z%ySMoND@~t*F@$5jX-WVK0HFRCO-=RejSLkW?ai!B{>9cnkT~JTSWBD{?ja$Rx-YItCF;~^{rP9!Q9Mf>N?vEa;i9rJK_JcS^XCF^kMf>9A{a^IG`Gu6JywW! zkVJF(pXVAAOcy2#M+tC$K@$t?#e@82!u1~4CGOo7SHwzJA@1GQ6p0zw1_nW8`-+CN zh5_5E*&jv6umx&v$3j{^yxlQbGBtz2ZWdwhxl}xfnzNw8$z$g~V_?OdYm#l+k<9U@<8Nhu9Ax>GzSnPzIN2bdetG2nS)+BxEb-*np8DYob9 z&(}q%zQeNWPMeajGRRvd2t#Xo}!xj=evfg0* zC<(FNl_4OZ#rIwxMx)A$aJ_eX>lLo|`5Fx&`ZUCH&$lTW2_fQnlM!N5n_H>Y;8CIG zM9;-|`ITwDn^_Ijr#a@-l;$*7{pG9y`sA{{u=OI6)ZDkn5cOjMD{f`Q-lC>`eY7@I zwbxw(C*(*$Vg1^q>wn!{JF-PZw)_siw}SqE@l->5J(qvTOZ#7ye|f^{1EX zqCQO1*Mr*=;v$vw*JI>*3ja@2uNd}fVtp*`)HU|fv-jJ}+@XBweK`U&5Py-v^SG^* zRQ7A9F|$ocXFT^6pd#PA#838NH#scJuhr^3CZ>O>W4C^u`RI_2z;!?Gt7lPuW~V~I zc#`2ry6#Ms8f~|Ljx$rwR#$e2GcoSMHhsiclBKi3P;dy)(^R@j<8QlVei0!Z?Bi3PM`^v}wS*Yv*t*r!Gd&K4x+^$y2*ph}T@eB+bxpgjH zSJoW$=HWg+hMW;ce|`qt7`E6=2?uPNJnc5zZQeLyx(pf9n>NfAA3uDUir|*0HoEC^k5Jzt0##d{nm|yzwkILj;?|2et`KN}QU?VaV`otQ0vyb_}=-S*R1c za_$B1YS)%uxuuCGusX*r)???!_WG}37s0-mJu)i(@|Lw{k@u?;O%S2RRNY4|Cet5q zZiz?9Jefo03+-o_rwmM2^r-O)2t)WK+!<-IZ0wmIfBimkOCm+vzV2zjhag6@H8xXs z?8rBp2T#=Rv4|6IA5;4><^@kcXB4pq;L5CeprL&rY68xvwiPFP%fU7@u9!L$p%=3Y ze`9GeoTKCBHNm!lHAZPYA%f5mjM;Xm8}tLB#RdFr&#(GtsjNC}SBS8EMPhavgR^oj z0*X(d#|;*Dde4k&MSMR%nCAc23I~n#&ogl1VnXuYW%YMffc)Ml0Pvbb+J6^74)US` zfXYdnlkXc)6Mkua0H7us=3Vdm@Du>bR!q$S0QiCQPX-z#z2gM{)E>o!_!V8XFT5ah zRTfrQF}EA7Ev(LpooUyaY?7@S8XJ^!G&I)K6H{?}9k2et$n^k0*NPK_2Ua83u6SJ0 z0fPz<@FCYOUA=u|IoN!D5j0Z1E?jxMb;o;LwV3c6xn$jO9c6iZX|1lU6~)2udaSE2 zFK$X%!v!{gIiwGmnPdRYR$*-R8m2OLk2{AlZ|~TO;jQ>_AfN8cC@n24epy=CoEMuM zxkjNaN?1R#vB?~@e>}E-c^|RyP*sLkd8W4;5M-6*;fuOGKTOldn9xBS;g6Grzu6pJ zY6)?zL{8x}-04uQP77G?wTN2vjPqXXDjN(GRl5b$I9XZ{vT|Ahc{Al2K`{)iRgR2bv$i?R({9NFuH8p<&NHrFPs{VZ){Xb}d*JpIK@E4d5#Wna zp9~4H@L^f6(^zilENW|kEUx#Wv*5-6eAua_7sQu&VuJ>5v)`iT@&o0TW5c~8po8mI zMcKjI%1@^1I%^r2S{mmGCU8sMthY1u9FDV29oYqq8%><-$n*XJE(!?31=*t^`1P0b zX%qbJ9{KZ_oc@*ZF%dj$xA_*17$m8=Qjmp}%D%+$E_;sO#-ZK&HQx?%&w2JUUoFD` zF48A$oKE5CZbLudVoCf$iIeLZ97uJ(WDxg9>y@PNI#M3-=*V-O$k!EsEkrY>P#KzbQ)D%4$ z!W!2Sli*ye4%B$*ozPE3<^8@TV@Rwz(~6^vX~o{Qqfp+~d)&3snK z?-qCJ5~G*k6A|#KMu|%fvw^aV+oqe1OHs*3RWXSyJAIxF_WC_Z|0Ya(;;znsk&57F z405QXSg*p>=B8SzCzan3%%atVN^Gt=wWF1dMH6@J6U;8254MFj>WMvyz{+Su^28_Y zOuGEoed>d;`v_lo-eF|CsTi8Iod}q-9mpZkT%?8iF+jHSzFhLGDa9}uDm}PDKWA$^ zu9(qN&8Y8`6~<^V=Sm`4bnDG9`7?XS^~a#}K!I2M^}y$x?LrQ!=R=R9=Ti;l^G)4K zvz;lBwT}T%YgZEvl(@yU;UajuTs5vZm1Jy31Mt{rlmRw`3fNj%BWOG=@kl>@7qXuL zkZNPYA^C(l&b3wRBN4{Qa%{NE<<^_!vrGh4&~YM@zmllzX-hNhEQf9hmv7^5UJ0a` z<@eg19RplfnWNW=fwk*%L<;P5@^)rjOq$%T9G!P!;NCoj@Yc9;ftM)CyPpwo7~0iv zW)@@B)%SFbxD^9PqkQ_uLRqe`^Bjy9r$JpBTDiW4#;zo(Z|ds1T%lrL756p})qc}^ zC?il)Uq_&(ME6C^6*p-n_|wxpilRK-S?U^U;cDq)bmpJR>4zSj8iDD-lRXGo=)Z0O?6^u@+#y1}6<~6dwZ89S5Ac-@X z#MwT0l{D}CX$kp@%k#^Hx_0rZuk~Yryvl+(eov^R%{wLnH>Lt%*glmP+86CA`{9C2 zc>HY(WE%yW{INvAl`n}#4iTza)Xj#oLDKF-PD-r?e`;dY|1vLm*JNrkS43j)WM;3V zgtXeh2>5rp2vRY97JB3l#C&!zczd5Ll)y_afTn?#} zz#RB1W~3iOGQCdGQbYKnsI&XaA$F2a`ZJe(%Ur?r2E3&6JLWn$f@p9#U_(GgZ?`eil3w>s0JZMYIb!}S%W8_1YvI%-v*RHgeCXRJrz$#QzxHi8u# zf@X0br4g4Mb#D64U26($%?#5A-Ke7;*(Ay-G6A8nZacUJu1pF6Pl=0C@cvxdX&l)` zG149$N3+}DB5dhYl>%k6aDD!ob8E~@$5LS)a-(3 zK9#I}N0Geq{`{WEUY-CCX@di|Lw0~UU@F2%$K|6?JahEK+z7<6ONn2;X0>Q~)ll>< zD7F8#zW&liOA^~zX~6*RqGjop_3zWN@H2bEc`|tZ-Z*(GFsKzO5LM1VZ2%aKQ!`T% z1pJw$-`Kgh{{#+2Vd(yS*}d`8Y&+td9LxzLTCP_dq6j>hOL&f;C{Ms#-E;XcNrp7U z)vpQ`p`SbTrh}nrVU@55Chc^GD5IW&`#HkDQARc8+>k)y*xd~mgv;HLGGwU6etmB} zf%=GP()8}KUBiyg4udw;)2OYoC)A0Po1mCo!~~|oDm2eZf{%`kMF;emxh4*A2-QuL zAE}k!Ed#f9w#l+atI_>TIRrzdq=;w~wpB#`n=NO}7T zXu4K|=b>K;^{42+Ki`}@3v`*W5eJd-Gx?!Q>!v)S*shf9ve9!&tkQ`*QuCuwYb4`& zDKhkqZ2V0Sg`SL*95^}wXl?MqZ8g)PY$a)c5l$f~SN%*5MGp-wQcyqS_WAl=fGi!!rllMWfmKuH&5$Y=I8_8e^<((~ z*Xy2Mu1+`L>fy~9{su|M_(f0tBQ|QN*q;^GLg4fjq!IotJo#|@_H))%p?ISB=fcSC zB=f{r>F3m2I)C5?nwPx6&y-x9ve5JL8Nch$H0=xovmT-`{{ z@H)Zar8hwJ-8Slv`)i45x&bh;qX|4j;ljqSs&{Qc7V#~*lhVKw=9zVWwA6PZd5pYq z(cDEruL&yS3@D|+Nww|%=03bC6M1|T6w_(?fs$m|cjj1_HL4o*8K1np>4Ls}n9KfL zMP{;HhG1_yJJU`OWffVtG0JaT7r>rsnb&;?#1`1-d<;l~1E%LUI|o@Z#s!{NtwR8k zsKBO7sv}>^F8sqNy-pdzD4L(j9>SLQM{Y6sF*8tyD^sST9rgqpJ~zJ*&Iy-bU(U<& z?2IeKsg(GtR2J+8U6}C`7DSb9AyH}S5F=9fhc*2?ps{j;uZ&`-Kw)fNwT5fgE7FR5 zU2X(^)3;3&ZFenNh7~c$HZA_qaa9p3EQcB5=S1Pl95ITW_dAoPoh?@9lOekI)2-%v zzzK@!8aKTI3T~|Rjk~T2jFe~TzuntoK^lO5SW!(gsonA@vPCS|UKM4yesT%Bh<$1e zXyBLqnm2xQgZa6!DkW#ymT+eAChF!{3b3}YSo^SpkwZ39(@@)P04e8?CvfINFAk_v z?kj*2rCpA?%!9e^7-F#1ZdZnfoYe*`%d&jfXYM#;^dQ@<4HSCcE;(tw%Ov8WGoaqo zR`kczfY(=Bp||9f$dQj;@$?^xEI2I9(uzH%>1hs5RmFe)>6d; zlmT&YLn{VyWy*-F>hEsE&it?#fT7fk8$e2%H^+>v0-q9O?M1paDv&HTO2ra@#R8*M z!rwMae=cD~bwry0uSGr)J^eXb%ODK-`l+7<)6Ya9um^LDc!(XdVIIuXo^tbQKNwGo zlqh22gw;8^a(QL5zqbLlR-G6oFi{WQP%}s!iE6^wLlxZN(B(7h7zTKB@1Z=Qq?9H? zEXg|`W&P2o!!KbyISlm$%&HX;??Bf=2ccPr zK(J!4xhVJY@-AEK*Yu>qth-YQ9idha@)4J`H<)}hy(l|?DK|gfK`vV1DWotaQ73;F zB$pS2RIIMyyq%BxE7FQ!QLeGU(wN;fi-K?)%hyOy(gNnWos6;4g|gL-<$csk{Ft5x zVQ%qikcY#g^03!`T(~H7i!4euMh3Tgwi?T+(9FxBHx5{WY(srRh_-X&%m>eQrm@ccr7YD*KcQ3LhWTJf$}L5U{4 zl?6JbGls2I%_I3ZM99Jj;$BUqtbn(o!RJZ8l*u{5B)~(Nm%SO5cHEd~BwREVYjAwZ z`|7ZRnXaGGn8n91xVfL|B%WFTK~w;N%n7OrLD5Da^+Wpa(l(fu3AFd z=h%E8$U>B5Aink3&>H;HI=LaM&zlv9%m$?wsOrjzU6e1U$;5Jw5U*|ZEWh^vzcU%d zAR0~h);JYepEU)3TbmJ@L^rjA4;-q77a*Pfv8QZKh6SF^jnLTr9 zqI1UP;D%0nYz`QGAm7X#nGouaGDMJXhPLA-*59OAB3!McIqs@a ziQe}4l>Qne%cN?|Io>`VLVa=JZsgz7&p6L6`=i6itN5iUgZt7+OTb4H;se+22PX2T zY#$zxk)(N2|EsDvt>_GXQExWPD@0P$@5|j7eKH&+rs1%>!Jz{CEYuK>9b(7M0n!vm zjS6MH^Y9+3Uj`-^abItCw>I-&9ctg0lX1MBX>WD(H*%-F4}(70q-f8hAlv3RR+|W> z7h)C<4LvW!wlc6PpQE#SPE0(SDFKnfS1R8I2qe^+bWQ=YbV{>1{-tXjAbCJqsL7}O zPn}0Z@6x4gZ>G(`Zb1gz@P-#`o7p&2eWv8^fB{(Mi+!*)sJ1Z<<$%Gq3*j41xueG+ z8Jw5oOn*)=NQO^e$bRT3dm@+4XR`f)nVtnMwp1TyqBp_ra9qbR!zrIjEfiV2!8oW3 znN%|LmDtKrtksx~14KLtwV-SsX_M461|iO?`mw0)dZ&ZV7Es4dkor_~b@%wj!Ve1s zQA3_%y|7)BcE`{9I3zq-(V6b-u{U=`ncq)Y5ilN|es83--FO_!QJR@DK}8CLhev?* z*~QMaNJ}!Fwh|XLRHi&AX?1Bua>PP)6d?F|#zCceJ{4-@ z)mGx%x;_rHHQHcz-##}iZ}xuYqx(|DbsAE@Hx>!aq;Uoire|JQ*s84h6)KOfD*N$| zNJ8#2X(FT-Y%mKu|j}}6niR!%BczIh4ifR z&&E4Y8r$F5f($DLXz^uxjkve^Rd$DV9+w<$-rFluRNP_TPSa-`^+6mj{+wJD&2)cl zi8F9wa#s9`;bbO;l5Cv~_P|c3w=lp2mr)Bm!^@0f%rjJm>~8^A68HK?_@{_5h6`UYY%vBLF+C?lQKLudCOL6>$m9lh)pxOD-XRQK!l z1N59u#T5%<2*DA%sC&A{AknJMR#<4bz{yAUWX zspyn0?!-GR)b(Q7Jijk&a@C|$!LN1qsaM6-6jAAPJSf$+XjUSl5B__cCLEp4=*O~) zp<$t7cE?pzA-YlVGufmLi|)h#GHEG?T>KVZ{_(fR7$IldUp-SmH)LtiW1|hGtEs?F zZ-yam?0}}G#>++#EMLxeypKkE4)d9r6Nx*zAIZx4)6W9$Yx zrA-ULG7C(E^4O*T*GM+ZuV5Yy_7yXc1PcDVC@<4bzc#Da;rbZAlyZmY`+J+d8E{yg zW(Zc;iJp}ih2xMJo~XIiDqjhY6ax6U&2aD9(2h)Nb;d=?Kp@pL)iN}&`A)`*kM7{Y zs$M+!7?hLvOOwL`kmg9hqi;U;CCeAgyfo}mt3OW$=y{n{Z~lvg-4cw#^;l0H5-%n8 zoXSXd_8KBWEFAS)WG24f2RL7Hoq51jIo7u|R&<$uI@=TVs7vohmDkKIfNYhjlM_@ss z+Up4jd3x^^8k{F^Xvwen*JQU>QXx)h$i@+i=E)7me38*8^S%1HD6}?sT~!f!_s+D4 zz*xqZW?K}A=tU>s%E_O!@sUt-R=akUp~PMMWVb%YdvS&)aW1v>j6d`zTVDOJS{piI zy>g>_rm+rZCc^x@SHyqbFTNwM-_LSoV3%}|F;SVK9|MJW449$gHm0&sTZ3C|V!|fJ zlUYf>u^Q8~3L9-FsT}EjZW{1+;O5A-W~U~D(Bal^P)_U2Junv{wC2H4ldo5!RFNL- zo`|nt@cN)M;*`?!zmSm0BA}F1T@<&cgx1h1#%_hg-}oFW4v)~PhAkEumLPOkPw#$M zBqEUmGg8CNF}+dW0z*B<;SMYhu&Qh3AK5* zJ4Z)g^i#t8eF!5z2ij})W4zrH(1hHbX3J)5MJc~GZ<}Pc$Zzk&_o(J|Xe`W|IdK1% z>T+<@or}|ixbw-BwAWDHbgF^deX=M%S#(llsncO(wM7IVTDVPxP`Eeor|oyi(4QnE zMiL)&uW>Ffl)ryS11%N(RGbqWO*v21)oZ+uwD)N=f*Ux;jx$;U7lp!Uz0NW?H|(+2 zPV~0!um9ZfM?TkMJ#~eWaN}oWRa3Y>{;7aCTmSRc)bH(y(gEhI=?a^`+Nd`kxAA-s z)52B}&BwK&!K1-9jZT;`sgbhTktYHlo*IU&ll3P+N21Q4vT*T$1aJ zi#D{2X^WYYj!v`4%Ss}n{36>b_oXp1vdSyR8Jsh2ba;EY$g&JFqn_-rTc5yEF7Gn=tTuC!1|}JlA212Uv=L0_4B}F!^05GbBJU{HcPDM){vcD$D~opLpw5 zM?KY5QJ&;1phhK4F^j>I)Ob2Ml5_eWH$R+dJ_z!NfKqA=?ulmmrguR9P z&ASe^_jkc7#^egZwQzLsS{J`^=zjDwK0r7i#yF+tV}P|;*dC?-c`T`iSm8_o__~FaUD>ds|T=0kOS3m3S5xjGqLkVL47lbl|0AiTGVNnb?Sg|%J-0Y z*0wzR)w4NpClD~nLqs8IKwB}go*b}b3_~{7V$i%rO!H@ByJrCHtdbYh(%BK|B62TP z17WAB@3{NJ3AOV{jDvv%r#^{1m+v$o7JHc^%nOIy#^|69U$)$WI5P!Pu#u^@ z%P#2AYJ0K@6)Zbk#(k=-B4El7#f6UA;-=!2iF;K}ZYxk4ZK(RNTh5Zas#hd>Nkvi5 zNjUO`Bg=-%HDYI|I<(zyskQ3b33kh|8d}4(v!WJyEqWoRzOkqC4!hn{Mjwt`j9UnAaWw5))C^;n-ij`D=V_AQ_HujGc}rXYqU>C1bsIu7UsH0?(XJDPPkd&X_K-) z6ZY$lbKp6i{;ESMqHXvo>X$$?J=igKF2JjZx*w%(X*cuF&*3$9fDtot6R}wUIG zZswV}vHq>$UF@>#oSjixr64fB8@R;HTq_58(4?jm?GM_;A>xC-_cT7VqGid2#@ewT zG)`WJyBa!_Tp@5x90#F*Gw!xKV+91wd~RlGjQBd}c>Cl%v3M8-{vb|cT_b1Ia`aN? zD5ciXCXQuZmBOGzSOf^y#H0Vy-dlgg5p4b5IKd&f6EwKHhv4q+I=H(NAh^2+5AN>n zGU(v$?wXf#&a>`0C-=Gk!P`H~tnQip?XFr?UAwDm@6X=e+JyyFX-C7I3ixTb`o2J$ zlDE-MJCyiFBJOCSe0Vh93wP(1P&xng`C0Ay?dTYulM?&vlND4)jN8#>AN+3{UsD67 zv(r9Zq(xt@E6>n7ex8g>i@e!Mupj6KJPrVX%of+*##gkW@jNm}lP?59DnQ|L%(h~e z#SJI}_frk5_JhcIS+YTqL+BS}Yw7kbyX-fpR$?rELJ~FWahY>9kp~Y8;-rFhdwKmd z@*)d>WiS(!vdw^#q?0cTW!c7URn5)?xCX>NTg)VkuwBMOC-%*)ShD3!s@mdNC4_nz z&O>&|(b4Vqcx|3#e7tpzIzNvIe||Y0_IG*w0d_h`09#0U(;1*{1Xb&nydy4uNFn4n zPSAX@VzxXMat|$9BT!_x(Tb^2+~&j&eJs0hvaKkCoiNo59hq^qEzTy|K z)futDmlIY>`;hy4#HzKxk_5DTV8kA{-Z;Zlavg*UExz7PW;n3;&cLW&1O6rxU8 z6gh^jX5XEq4{r#z-B97xT%#vAlQ6=TZAA79RQx(jY6RbwoBgElRArhWAp(8x0^pxjl-aFGSEjoY}pAABA%#quN zNpm7~*d=cJ_{WiTXnK?KSphpAoMN(#b;i6l0I^wS<0G8|aoU&p3Y!uOi^26B*(J?+ ze?sw->KhGy*DD=umyfVr>q7;gUX~XAFdk01n*tD|ogqjMb~B1zpue6>=5oky%Pbd@ zdz8y4McK?qeQYZHU>IfnW58t73S~lQd^gFR-i}uc^P+cUfcH}EO08Y58cp7$qMz_$ zf9D#u_zQV79=8Cn(+X8=^Vp~I@&_L>gk7JDGbO{%tUz&=1)~^O+X%i(OQ&}MkR9!) zWsSQ&eey+>lg@rk7V9b(S2^-nsMr9&I?E>$6qP|HHb_)x|mBQcx;iVP` zgy4G!B*4Y#ri`(!hS|b zY^;IdPH);F@qKotyzN*+^?@k8kU4xHyA6n9fE=#TV?F1dV|l7v-& zoZiA=w&SQeE?_wel-6K$U~e&h?;W1%DN z@^qzqzx9S+&u+j7wSpE=%tfaNok&7*=X1gIeQ{*)y*EPh&1(na6ppxIY~$oEa@%NT8c)Kpz(xpCLb6I5-Ne#)E3 zC7Nwz5fRwkRcHAG=Ha{Rmr6fR4qyJnGeRwY{kjRu2U*75O|fUZm}0u2L6M;fWT}l_ z0i{mgsuTWU*`x+{yDiu6(e%Aj*%?u^Fy@=RKb&9c&$yX^!;)OQJa7kM@&nKgKzq$D z4DYu*x>r{XaH6qQ|K2Qd`*O`jzOhYWpS0l|LG6_jK_oC*ne^!U%=5ToF6VXX^+?7$ zATYzk>wuQuTifXU9@i&@V4cV>3J_Fk9F7*AV zv_>ytV47>a52gn|)n{J02KPe;^S8DR^P=NE1o$h`Ur zk^CZ9jU0Q^X0Av%Q!d!d=sd;JPb~-hI%vFjBIXs}FdO2BGgP81H646d?n7gkFNnfE zg{5v?hwWR>`alRQnnP?;(uu+~T#A1|?=r6(nMrSI_}t~g47+xS6FLotY>um|YR5CM zwMMnQC{oMRb(=b(2&iAy?E}5)V*d=n_`|Acbve)+_Nm2-bg#Qnyl#C4U`^X%PC9wS zjt7^JV!&&TA)aU*`=qqK6b(2TbyysR@HXwn$VBh)K5fHb`xB@ss6+Ng)T}Vo@#u40 zu);HMP6SQ_Z%7fDn{p|Yc=4WUcuB)sqU@)O=^1xSvddERS0n$~-&%+gI{tN}RI}cf zdh>GjoAW2jW{~0qJ0~&YgC!I%7L0AjtpG_P-YV`^n@{oeoaaxccg^0p1RZGvV4gkp zl;57w?C(LG4NR~jxcoyTjxt0X3esN-e}A3Gu5Cp8^F|K5GkE^W9pgwj;7G}xdJCuS zM&+L(dQ<^PQrkI?bn?JJ=4|`Ci zy0Nutc&Y#;H(F*8fTrv)q>WJXY9JzxN0=pdV|nrJrT$9W;vVNRT`W&F#L(?AlaMK5 zb#?_p>*@KN6u2T@iRRwJ|!7#((!hTApKqKIKiWo%N6| zMQn<%slcU)#Yy4<%a z^QFsG7YrkKaG9RNP>ts!WBm8xIE#2x1MDI4?W@T7C3U;^nt2O9V_KVP*~i&#Hf^LD zJEE@@9XkW9w?K6~4?^xJJYxv<9`_%2>Hd}clq8vic(Yb4yh&!L4Y9sx!^!XGa^IW0 z26K5E-X?kEgB&-P=guXI+z*&vla;G{WaAsXhJ}(>a_YW!;T~dTSPB|mMquB>Dl5%z zl}LnW^>75|_H>`Jh0Npmb^SzXg><2J_q-Pe}TthRxgI@nf1O{#~_ z`0WEX){_`-paz531W~3B_co$Tpu@js<$m;n-V9k{@o77Dm|RDY4P}`(rgg!kT&Lc6 zY{rZ)y#`r*Lp;~@AQJz}C6FpCEzH)ryULp!x(hEmsEvw2^tGN{LP94fdq-`%NFBj` z9}E;Zirbq{>e6 zc`rMP!|hR9*E1n(AO&A`mN{{h@eXO7J#sKUHvo=9Sy@vFA9L2IoULyD_7H1k#lfG( z)_&F~lb8y;kW~;F`2~j%`jpsSRE6{u z+w5*lOZ}ADrYES;O*zAbSUs|B2xDh`=4K{KJowFL{M)N4imKS;kc}NfH)F_nFFj*{ zu}lvS!2@1pMTwRVsw(gcUYvg5*vn^~XAL(&OIBKWTE;pwq9N)|x6hq}&y*DcLgrI@ zTVQk&anvY=GSQLRfZVJz0H04U;C1`6_lq!+RC3RFvGpax?h6ZUug#ayj?pEMC{&eQ>J^|a(iMn* zcz>2dV~D_wkoJn{6)B~y9#Z~fSqGDj?3(V5az=~_jJ1rmr<7aXZD`qNQ)wc44b1d+#4r1)h%<8 z!}g;G3a-LnP!j?lmV3V0v~PMK4#)yO8b;55)~KzM_7!NJCI-j{Sf$-BEaIg_Bj=s6 z$ok}~pXM8!3^!hc__I)JlbB=mtX&JMQWiQ_tlk%s?J-ER&BE0zu7W8LKHB4H(|1t&pSvGKY|J*} z*7pcKWB1u&%3DoUrpAN!LC%965Fwzd zS7ZkPvV{A9jM<$D2?@O*ryYR$%+kedm;ZRFzJDyPQ0R`c+*y~00KO(vS(T3{#Gn;) zhQl67pt`c)iE$1=VF>*Ex*7i= z&LmCirjha2&`|}9rSCJ$!L4-PW@6Hk_J!=#Z(de*BuRfsZi&jqA{oh}`|F^FOu}#n zF}pzZs#k@w&=r5@E&|IOWlVo=gRZwtwn*JtboOZa6vKOHK!WK{JW#tZX{m1!gteN-!O?DRg|Lb z`Cj_{HXP~zu_$`0R#zxsK&zN_@hlgO`8tLs*~!;T<}guLYS4;209Ur!1KDYrtUBgo>&lHsRWnUZ9)CJF@rP z7jBA726mF?u>j!jpn7u}x%`f2WgZLTlv4_WxBAMyMs;=)&)#pD_=OjN+$)+@5usM7 zifzMk?YV#0_&BT}M^D!L9grNATY`$0kTk@35^^zz(5nHgu!i*0;&NIt6QZ!mlZieV zi=7plY^ECJFD2R5{lH(v+je9(+*Xf4L$DP*iZa}^+VUSkvNpXzz{RZ_ID*LQ-6T#} zv1XXUS1@pSaUQ3E^>rH}=adXEW!8y>Blif5L) zQ%}90_~v1meXwuS$k+^

ZI6%vqn;e9A>M3JSCn=#4Jw!zPk`QEkj(E0#%E2in{2 zlE0o0EZ4U@EFpx66}7B=omz20&+OXIt1(WgYhCEYPW9Ys^qOtA-9d32e1EhxBAo=9`AN z{QKZqZRrGmfCS6sNh^t2&^-$0iC&ZHj^(`6J!qURLQHsnS@3o~k-+{UYI&e%t5}hh zPQ1eVniwJT8yt!V))S}UMz-&oItb4WPp$1y+#oreDoJILEFSVT(t@9Uz6wt<1N{}E zrej?4_+^7F;KygSVy6WR#4x_P^?5GzBR9n2Xabyim;rdD90 zGR1@1UH&tT`CI9@SmSav#nUlS5VVy(rGSg|{?k{mdsfwzvnv0SI<_CS?)9a$;QWIO zzs%y!#Dz27q=lEv!#?3TRI%yYSs-WYH=~?XT~{T%45nZ< zFRy5N%Gu#JQho?L>)AI0;7GfRJ415fV(Trrsi8C{Mm_7NVb@B)gx`e}(-7Y)>#MLjTN zybMR&@A2muoiz}gh=#sea5Hvecfdu8yPjh@2#v8Y$7E{#w{XQ%9q0V=b9XCnHC0qL z27FiSDa7_lL4*f_o+>@k$2)v)nS9k>_8ks%nsEKhM~{QXN2#@~h_{P87V`m8uSfLM zV3RD)672pmEr*<3OBY%UFKe_dsjc^5qxfu(qH;!Nfl^m+c}DAVT0fPeqcM~RG~;|p z!nj@^!R{G`$Je`Q_gmoB=S?Y#qfYdd`nXJ~s{6*R?U4fj`aGsYS&ZRsby$k6UrXPelY9=|Vhyz1rOOkM*y(I4o^Yw4?Z^VR;F;CyXata!3oNSiiy4 zIpemuf{%7de$_s@Q~rRg;cZ375_3%-#k5ZhHGEpJFHCkNO;}Qdih}w*Ig3{)fHgFk z&{43#hY%$ziW5L2rmZM=hWC4q+3TAKec77X^PViYtUlVoljy_VW=I_V<@KzHhDfM% zOcr5{GM7(Nt$^p~E-;4))@Tgx-SL4rcwu%G>?4h^boqzZ=UpZTZ?|9DJG=s^m6f&kXY=BlPh>pDcsx@ zM}GDuaT3e(?%Tt^9qAtMQFidVhEJTQ#Wtk^es?h)Ea0Csd{uZ;WaXtTKDA8L z7d4z(pvpy+9T-i@wd}EvG{!OOW=3sLp&A08di<#Z-U6jM8}waKdyEpZ8Y5IWwNub4 z8V>q*Qba^)gPprEMK=9vXkk)RKoxX@V|cvoz%$p_;@fUd^Il=IV=h}Vkm0;<0#9W; zVbb)I&2|;PZY1n}vf*gDV?o7o1fcA}VD`69YO^;ACBt%5O@y2A+%`XkFZFtQ6I`8I zVs0R%JS?Uw2wuxF_UT6`Dl_Ut_GqB|CL| z52<{M7x343S6_`746C39QD5W1*r$jRIT&wRamAGt351mO-l4Fg=*jb94 z(Q*)`*oU|1bZL(R&I3CEh{nZM6KuWoqoEq&3T4&CxQtJj81PM%*@6YiF%cgMiqLRZt2Lmsq<%s zUhr`YWkdKpTp@bzjHEq2=ht1z2JW7qm~G)*s|L+pM!oM`P8WPT71(Xdh6tpUk0ly9 zdi08MG`Po!QVsvRL1o zXKpFb@FR9@9=1ra==K&GX7s*ysq`?b9Xtl>Vn8*ujcyEKKeg6-TqN~ z^H9cLe9-t!ce@ElQ0uC8_50DgYg$ePv+ML0Fvk2De^NiWJJKiZmTpA(?4K zbV?EDxtX4O~38CPPe_`jWjwDn5GNWgK*?5yCr zO+vEJa-s#>Tfi9i&wE#(+nD+6utRpOdmwRX9Ui=(AGd(44n(|P>5SmTubj`l`7x8DX5s3o+tjI5&MIs=vsdL~7huPERfnp}GUObQ|+ais2N<{EG&%Qnw8 z>?_(c(%JkCnZ6m^N+%PoW|}^qnAR=?kPd)FUXFuWDocswJ*S&xpPPv6#Ksf%xEST| z^zSd6Gv!uZrtBug2eWzjlGFofxi3}RGT^LRq_f7ZOBJWW@M6Txt-HaelxM$$vG&Ad z@2beeHczmZa@8$rylAdv1|i`zG(zZ~%D)I$^4$C?w=t-#Ous7m)cL1hMn<=zYp3kU zQ)n0E^12m^FQoxoe;P~mOx+-B^_lCWko~xXE)y;7r$By0b=!_&%RZ;(?wJZfg?qj` zY~`sq*h6xch~}~weT6~w6tq#}4E=@TzVBLnT(LL}!!JRg&n@XBgg75+o$p*Xo)vO6 zpQ5UZy($M!VFxN97{^c)-K8hky7Z2N0Z7YIc^N=34;Aqy@pCyFD!zyt6s*o48_<+| zCPtBP6wv2D6f?U{TV>U|Flsrzsue9Qdy9#vA)U^J~N_*RevDXR*}jYFzD zbGFX>I7VCd5p@}b9ynh(@SV@~N=}D6wRe_*HH`&=F}PHG*prlUXlVU-x+=((>fv@A zmzzTCeO8&bW1al+J~yO|7j46X9O$Y|?FYnJcc$^;_EF~cW&2?kKK=zDl>9=$^OJE( zFO*-lBDLO=xKrfJ_P!Zxyb;HLuR*yk_`3%y4bOe~t`y88oiTPCMoXSQ}*0O$IATAZ}O&n-uGvnK% z;!T$$A4|TTwT#K+**nPmHi0^O$&yj8#+Mc3%HkPCKlt2d>s9nDpYz=w?Ed}m+vyB7 zL@FJ)`<2QO(1;^o*geM{dlr^W<5eLR;idj&w`5AIwV!iLW&4 zAM?Bv35++AM7bYPilEa#`I124Q!@u6*BAW$nEZDNsrfG^^ z0*TINX`uy);v-tx$~ubeq$;9LZQ*2W7KQZ0_GSbD(3KrIQe_Y1MaDS;_NZxDRk-r3 zS!_<=Nom$r;M#S}FZt@E0vmoTdgCi5f;a`8Rvgf~#}>2f+$Y)UFUnrATU%;WA(&Z* zn677#^nGqbg-mRpO%rP3@odh;3#myg+vsNYE6T{r zUOn}s9r6RH2P;59{i~YWX<^3i!Fp?hN8Wn2z9d@`wDB-XrPmurBs;MKCiMr205?_%t`IMXKUk83l66 zqv!*cYW@-UjYEs#35qK4X&`;Wy0L0mu^#~Ap2{qgSyu2`@kJ`4Dw&Fu`8HJ(QQaXN zog?A7_=MA_QW_=1hy9b2$fhVk`&*X@1H;A8vxWW-n~`0i>YPebKMD-=Zc#SIpRnAs z@G)6($_}Kye?U6rU)^4sObaaLwJH#odW|w3GMSrDz_gQ;TsWa-^47kG4L zQ!p1NzeXG32Y_$~N(Fzwbg%(2ZwvH|MPgES3hRH9#EcGc`tpIi3y?b~tAxH15@=h- zhuUz^LDQKF7kxLO6u@Rievlm<-16l7dh6CZyY{#)GWTN^rq&0}m)pixGA1`K^nGJ$ zkHpy6W9X%iho-{sZ1YIX4U_WfX!R^sd2PVb3tw+yMNC^E8fz?}WsYpb7s+>xxt zJeCf!QRC8QnT1CRFnalg)O{*5`KcHsHH?uzJt~5``4%Q(KFOfAsr15*(_*+{xUi_DRu&0-}^tKaD|o7zdt%b@|0s>oVa zm}!dfAt&6BJL72OYcP3FsV->oS`l??UTQy>q2Wifkv@f9dK(sJM|>2*U5WPfPebib z2Hc^Ik`h5O_qCax6FAOtU4JB6ZR8vqoE%+;KfStikd|bI!UQ zeyf-z=U^Xwzgxg;`)(yAJ1~`#VcMl#bXytj`Th$N34U12ndE>yGjN(CY-_+X22Ms<+s_YlGrq(E($0zV zQ;f>G*kfHh1Y6{kt#q-uKYu!fmZ{oFR#n-Vsyf)D<7NR}PWF&iEIG;4bhIBT6B>8v zR)eb}E?7#l?2!@+gDq^v@00s~c<$P$rv_e1C3dm0J!=;j26sJ^C~FAPqgPI#=6e7{ zLIiOC3&`m+b5N%Q{JFetaEC8?2gkA9dUVj^nF0+Ml6}QiEwUCdXNFFAu{o*l^{(@{ zp8kG=&KZUKvb;bEIqZsa;RN@BUG%O3Bgl_Heq6`yh3OVP?kqC@>7aWopwt(qq=Sww z=6$w_C^K3!vqu+6wtn!ue`}&xSu0q$=x zGX;cZqa{S1?_l9F5prKz;!g4zS3tT(@*A8__B9*g1Oue`pmN)_;DyUzJ;`ZTq|&!D z9;gg^w{4tZbXikoZ<}wnN(i*>rH-nY4>$3t%2m$drs5ybf=63guPiO`AsTx@Y zp3OlpNsUAtHBPtMU(Tt9+_fJjoVQiRcZLTjnt2RaCNL-Y8oiU`#9d(4HJkf@xHSFT z{H%)?eKFZZnVz$@^Z6zjr3G(L1OStCNiF8E`BRn*NJBi^B0^rfgO38p)9~A@>3K^_ zThL7{)Zh7kulsCcWdr&HadvNhBu-Gn5kL1eUZ-CXL3-S*;hjZ$endC+Z9p!W5L6#5_jL4z^_jS14VU=X48Cm{HyXS znwn;RzCom3|0=x+qB8s9CM`!llVvVm*2(Q#&ETaZt>5LskqI7Q~G8V6h=~L zI{J(j#WP>>7{Vf#4dEkq8gttF7y01SsD;6`#Bs2(*}c-E!};E`$615@(nY0DdTFG( z%iOw!KGdI<)n_Nu#ZrKi%2L!B1@m5)8}jHFY^L>xRX!*XU4ps0T(6g5G6hv@=B9w@ z`lU`~*FaF=q(RBMWtc)1rMq$sYf*g_jWt}2l$Wpg{?@o&LJ4=_8v7gNc4JdSwRKl9 zv#i==w2rRg5Hh_z2dO#ci!MhR2j1me^Q_W6LaJh>gD2$*{^#cg<+E3o>s_*r_xr$# z$61mJ1qJOD5d9d3cEUluU{xn0bk#vsQwn>KB>psPVP${IOeS-EK_OdqFSCB|{Y&s0 z>*elvRDF_ek7ey8z;RkAxi-(*Bp>u@Vpu77cGc9NnWGA}uI+@n;Su;IR7XwDFvh_S z2>s>TDAefXPHwZg^>p8t7H#tT(8ZG)i}R&rmB6Qbf{(C297M}Hx;o|xoY`r9SR<8l zsV!z}Xq&MXj4!4j&A`Ft5=kY@z3{vbvXBmp;mu{ar{nz z7JyIHqLIOqcFiVAx|begt?!6g`UHloBny!5Nr6?UlV}^ZW-<@UqM7*4+iF!s-5i<= z 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 claves.csv +``` diff --git a/scripts/export-reason-catalog-csv.mjs b/scripts/export-reason-catalog-csv.mjs new file mode 100644 index 0000000..621bd84 --- /dev/null +++ b/scripts/export-reason-catalog-csv.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +/** + * Export reasonCatalog JSON (downtime + scrap) to CSV for printed operator sheets. + * Usage: node scripts/export-reason-catalog-csv.mjs + * cat reasonCatalog.json | node scripts/export-reason-catalog-csv.mjs + * + * CSV columns: kind, reasonCode, categoryLabel, reasonLabel, active + */ +import { readFileSync, existsSync } from "fs"; + +function escCsv(s) { + const t = String(s ?? ""); + if (/[",\n\r]/.test(t)) return `"${t.replace(/"/g, '""')}"`; + return t; +} + +function effectiveReasonCode(categoryId, detail) { + const c = String(detail.reasonCode ?? detail.code ?? "").trim(); + if (c) return c.toUpperCase(); + const cat = String(categoryId ?? "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const det = String(detail.id ?? "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return `${cat}__${det}`.toUpperCase(); +} + +function walk(kind, categories, rows) { + if (!Array.isArray(categories)) return; + for (const cat of categories) { + const cid = String(cat.id ?? "").trim(); + const clab = String(cat.label ?? "").trim(); + const details = Array.isArray(cat.details) + ? cat.details + : Array.isArray(cat.children) + ? cat.children + : []; + for (const d of details) { + const active = d.active === false ? "0" : "1"; + const dlab = String(d.label ?? "").trim(); + rows.push({ + kind, + reasonCode: effectiveReasonCode(cid || clab, d), + categoryLabel: clab, + reasonLabel: dlab, + active, + }); + } + } +} + +let raw = ""; +const arg = process.argv[2]; +if (arg && existsSync(arg)) { + raw = readFileSync(arg, "utf8"); +} else { + raw = readFileSync(0, "utf8"); +} + +const catalog = JSON.parse(raw || "{}"); +const rows = []; +walk("downtime", catalog.downtime, rows); +walk("scrap", catalog.scrap, rows); + +const header = ["kind", "reasonCode", "categoryLabel", "reasonLabel", "active"]; +console.log(header.map(escCsv).join(",")); +for (const r of rows) { + console.log([r.kind, r.reasonCode, r.categoryLabel, r.reasonLabel, r.active].map(escCsv).join(",")); +} diff --git a/scripts/mysql/reason_catalog_mirror.sql b/scripts/mysql/reason_catalog_mirror.sql new file mode 100644 index 0000000..c1547be --- /dev/null +++ b/scripts/mysql/reason_catalog_mirror.sql @@ -0,0 +1,18 @@ +-- Mirror of Control Tower reasonCatalog on the Raspberry Pi (MySQL / MariaDB). +-- Policy: never DELETE rows by reason_code; only INSERT ... ON DUPLICATE KEY UPDATE +-- and set active=0 when CT marks a code inactive. + +CREATE TABLE IF NOT EXISTS reason_catalog_row ( + kind VARCHAR(16) NOT NULL COMMENT 'downtime | scrap', + category_id VARCHAR(128) NOT NULL, + category_label VARCHAR(255) NOT NULL, + reason_code VARCHAR(64) NOT NULL, + reason_label VARCHAR(512) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + active TINYINT(1) NOT NULL DEFAULT 1, + catalog_version INT NOT NULL DEFAULT 1, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (kind, reason_code), + KEY idx_reason_catalog_kind_active (kind, active), + KEY idx_reason_catalog_version (catalog_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/scripts/patch-flows-reason-mirror.mjs b/scripts/patch-flows-reason-mirror.mjs new file mode 100644 index 0000000..47a2800 --- /dev/null +++ b/scripts/patch-flows-reason-mirror.mjs @@ -0,0 +1,213 @@ +#!/usr/bin/env node +/** + * Patches flows_may_4_26.json: + * - Apply settings: pass reasonCode/active in catalog; 3 outputs; trigger MySQL mirror sync + * - New nodes: Build reason catalog mirror SQL → mysql + */ +import { readFileSync, writeFileSync } from "fs"; + +const path = new URL("../flows_may_4_26.json", import.meta.url).pathname; +const j = JSON.parse(readFileSync(path, "utf8")); + +const applyId = "abbec199700a5e29"; +const gateId = "f8e0d1c2b3a40911"; +const mysqlPersistId = "f8e0d1c2b3a40912"; + +const apply = j.find((n) => n.id === applyId); +if (!apply || apply.type !== "function") { + console.error("Apply settings node not found"); + process.exit(1); +} + +const oldDetails = + "const details = detailsRaw.map((d, jdx) => ({\n id: String(d.id || d.detailId || (categoryId + \"_d\" + jdx)),\n label: String(d.label || d.detailLabel || (\"Detalle \" + (jdx + 1)))\n }));"; + +const newDetails = `const details = detailsRaw.map((d, jdx) => { + const row = { + id: String(d.id || d.detailId || (categoryId + "_d" + jdx)), + label: String(d.label || d.detailLabel || ("Detalle " + (jdx + 1))) + }; + if (d.reasonCode != null && String(d.reasonCode).trim()) { + row.reasonCode = String(d.reasonCode).trim(); + } else if (d.code != null && String(d.code).trim()) { + row.reasonCode = String(d.code).trim(); + } + if (d.active === false) { + row.active = false; + } + return row; + });`; + +if (!apply.func.includes(oldDetails)) { + console.error("Expected normalizeCatalog details snippet not found; abort."); + process.exit(1); +} +apply.func = apply.func.replace(oldDetails, newDetails); + +apply.func = apply.func.replaceAll("node.send([uiConfigMsg, null]);", "node.send([uiConfigMsg, null, null]);"); +apply.func = apply.func.replaceAll("node.send([uiMoldMsg, null]);", "node.send([uiMoldMsg, null, null]);"); +apply.func = apply.func.replaceAll("node.send([uiReadOnlyMsg, null]);", "node.send([uiReadOnlyMsg, null, null]);"); +apply.func = apply.func.replaceAll("node.send([uiReasonCatalogMsg, null]);", "node.send([uiReasonCatalogMsg, null, null]);"); + +const oldReturnAck = `const ackMsg = { + topic: ackTopic, + payload: JSON.stringify({ + type: "settings_ack", + orgId, + machineId, + version, + source: "node-red", + ts: new Date().toISOString() + }) +}; + +return [null, ackMsg]; +`; + +const newReturnAck = `const ackMsg = { + topic: ackTopic, + payload: JSON.stringify({ + type: "settings_ack", + orgId, + machineId, + version, + source: "node-red", + ts: new Date().toISOString() + }) +}; + +const mirrorTrigger = { payload: { _syncReasonCatalog: true } }; +return [null, ackMsg, mirrorTrigger]; +`; + +if (!apply.func.includes(oldReturnAck.trim())) { + console.error("Expected ack return block not found"); + process.exit(1); +} +apply.func = apply.func.replace(oldReturnAck.trim(), newReturnAck.trim()); + +apply.func = apply.func.replace( + `if (!orgId || !machineId) { + return [null, null]; +}`, + `if (!orgId || !machineId) { + return [null, null, null]; +}` +); + +apply.outputs = 3; +apply.wires = [ + ["2c8562b2471078ab", "dbfd127c516efa87", "9748899355370bae"], + [], + [gateId], +]; + +const gateFunc = `const p = msg.payload || {}; +if (!p._syncReasonCatalog) { + return null; +} +const settings = global.get("settings") || {}; +const cat = settings.reasonCatalog || {}; +const ver = Number(cat.version || 1); +function esc(v) { + return String(v ?? "").replace(/\\\\/g, "\\\\\\\\").replace(/'/g, "''"); +} +const parts = []; +function walk(kind, list) { + if (!Array.isArray(list)) { + return; + } + let sort = 0; + list.forEach((c) => { + const categoryId = esc(String(c.id || "")); + const categoryLabel = esc(String(c.label || "")); + const ch = c.children || c.details || []; + if (!Array.isArray(ch)) { + return; + } + ch.forEach((d) => { + const id = String(d.id || "").trim(); + const label = String(d.label || "").trim(); + const rc = String(d.reasonCode || d.code || id || "").trim(); + if (!rc) { + return; + } + const active = d.active === false ? 0 : 1; + parts.push( + "('" + + kind + + "','" + + categoryId + + "','" + + categoryLabel + + "','" + + esc(rc) + + "','" + + esc(label) + + "'," + + sort + + "," + + active + + "," + + ver + + ")" + ); + sort += 1; + }); + }); +} +walk("downtime", cat.downtime || []); +walk("scrap", cat.scrap || []); +if (!parts.length) { + node.status({ fill: "yellow", shape: "ring", text: "No reason rows to mirror" }); + return null; +} +const sql = + "INSERT INTO reason_catalog_row (kind,category_id,category_label,reason_code,reason_label,sort_order,active,catalog_version) VALUES " + + parts.join(",") + + " ON DUPLICATE KEY UPDATE category_id=VALUES(category_id),category_label=VALUES(category_label),reason_label=VALUES(reason_label),sort_order=VALUES(sort_order),active=VALUES(active),catalog_version=VALUES(catalog_version),updated_at=CURRENT_TIMESTAMP(3)"; +node.status({ fill: "green", shape: "dot", text: "Reason mirror SQL built" }); +msg.topic = sql; +msg.payload = []; +return msg; +`; + +const gateNode = { + id: gateId, + type: "function", + z: "05d4cb231221b842", + g: "a1b43a9e095c10db", + name: "Build reason catalog mirror SQL", + func: gateFunc, + outputs: 1, + timeout: 0, + noerr: 0, + initialize: "", + finalize: "", + libs: [], + x: 1500, + y: 1020, + wires: [[mysqlPersistId]], +}; + +const mysqlNode = { + id: mysqlPersistId, + type: "mysql", + z: "05d4cb231221b842", + g: "a1b43a9e095c10db", + mydb: "fc9634aabefee16b", + name: "Persist reason catalog mirror", + x: 1820, + y: 1020, + wires: [[]], +}; + +if (j.some((n) => n.id === gateId)) { + console.log("Patch already applied (gate node exists). Skipping insert."); +} else { + const idx = j.findIndex((n) => n.id === applyId); + j.splice(idx + 1, 0, gateNode, mysqlNode); +} + +writeFileSync(path, JSON.stringify(j, null, 4) + "\n"); +console.log("Patched", path); diff --git a/scripts/seed-reason-catalog-from-xlsx.mjs b/scripts/seed-reason-catalog-from-xlsx.mjs new file mode 100644 index 0000000..70db266 --- /dev/null +++ b/scripts/seed-reason-catalog-from-xlsx.mjs @@ -0,0 +1,280 @@ +#!/usr/bin/env node +/** + * Load downtime + scrap catalogs from Excel under ./reasons/ into Postgres. + * + * npx dotenv -e .env -- node scripts/seed-reason-catalog-from-xlsx.mjs --org-id + * npx dotenv -e .env -- node scripts/seed-reason-catalog-from-xlsx.mjs --org-slug my-org --replace + * + * --dry-run parse and print counts only + * --replace delete existing reason_catalog_* rows for the org before insert + */ + +import { readFileSync, existsSync } from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import * as XLSX from "xlsx"; +import { PrismaClient } from "@prisma/client"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, ".."); + +const prisma = new PrismaClient(); + +function composeReasonCode(prefix, suffix) { + 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(); +} + +function parseArgs(argv) { + const out = { + dryRun: false, + replace: false, + orgId: null, + orgSlug: null, + downtimePath: path.join(ROOT, "reasons", "Claves Tiempo Muerto.xlsx"), + scrapPath: path.join(ROOT, "reasons", "Claves de Scrap.xlsx"), + }; + + for (let i = 0; i < argv.length; i += 1) { + const t = argv[i]; + if (t === "--dry-run") out.dryRun = true; + else if (t === "--replace") out.replace = true; + else if (t === "--org-id") { + out.orgId = argv[i + 1] || null; + i += 1; + } else if (t === "--org-slug") { + out.orgSlug = argv[i + 1] || null; + i += 1; + } else if (t === "--downtime") { + out.downtimePath = argv[i + 1] || out.downtimePath; + i += 1; + } else if (t === "--scrap") { + out.scrapPath = argv[i + 1] || out.scrapPath; + i += 1; + } else { + throw new Error(`Unknown arg: ${t}`); + } + } + return out; +} + +function readWorkbook(filePath) { + if (!existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + const buf = readFileSync(filePath); + return XLSX.read(buf, { type: "buffer" }); +} + +/** @returns {{ kind:'downtime', name:string, codePrefix:string, items: { suffix:string, name:string }[] }[]} */ +function parseDowntimeXlsx(filePath) { + const wb = readWorkbook(filePath); + const data = XLSX.utils.sheet_to_json(wb.Sheets["Sheet1"], { header: 1, defval: "" }); + const headerRowIdx = 3; + const header = data[headerRowIdx] || []; + const cols = []; + for (let c = 0; c < header.length; c += 1) { + if (String(header[c] || "").trim()) cols.push(c); + } + + const categoryByCol = {}; + cols.forEach((c) => { + categoryByCol[c] = String(header[c]).trim(); + }); + + const CODE = /^([A-Z0-9][A-Za-z0-9-]*)-(\d+)\s+(.*)$/; + const rawItems = []; + + for (let r = headerRowIdx + 1; r < data.length; r += 1) { + const row = data[r] || []; + for (const c of cols) { + const cell = String(row[c] ?? "").trim(); + if (!cell) continue; + + const m = cell.match(CODE); + if (m) { + rawItems.push({ + col: c, + categoryLabel: categoryByCol[c], + prefix: m[1].toUpperCase(), + suffix: m[2], + name: m[3].trim(), + row: r, + }); + } else if (cell.length > 2 && cell === cell.toUpperCase() && !/\d/.test(cell)) { + categoryByCol[c] = cell; + } + } + } + + /** @type {Map} */ + const catMap = new Map(); + + function catKey(categoryName, prefix) { + return `${categoryName}\0${prefix}`; + } + + for (const it of rawItems) { + const key = catKey(it.categoryLabel, it.prefix); + let bucket = catMap.get(key); + if (!bucket) { + bucket = { kind: "downtime", name: it.categoryLabel, codePrefix: it.prefix, items: [] }; + catMap.set(key, bucket); + } + bucket.items.push({ suffix: it.suffix, name: it.name }); + } + + /** Dedupe suffix per category (keep first description). */ + for (const b of catMap.values()) { + const seen = new Map(); + const next = []; + for (const row of b.items) { + if (seen.has(row.suffix)) continue; + seen.set(row.suffix, true); + next.push(row); + } + b.items = next.sort((a, b) => Number(a.suffix) - Number(b.suffix)); + } + + return [...catMap.values()]; +} + +function parseScrapXlsx(filePath) { + const wb = readWorkbook(filePath); + const data = XLSX.utils.sheet_to_json(wb.Sheets["Sheet1"], { header: 1, defval: "" }); + /** @type { { suffix:string, name:string, full:string }[] } */ + const rows = []; + for (let r = 0; r < data.length; r += 1) { + const clave = String(data[r][0] ?? "").trim(); + const desc = String(data[r][1] ?? "").trim().replace(/\s+/g, " "); + if (!clave || /^clave/i.test(clave)) continue; + if (!desc || /Rev\.?\s*[A-Z]/i.test(desc)) continue; + const m = clave.toUpperCase().match(/^([A-Z]+)(\d+)$/); + if (!m) { + console.warn(`[scrap] skip row ${r}:`, clave); + continue; + } + rows.push({ + full: `${m[1]}${m[2]}`, + suffix: m[2], + name: desc, + }); + } + + /** Single category when all MX… */ + const prefixes = new Set(rows.map((x) => x.full.replace(/\d+$/, ""))); + if (prefixes.size !== 1) { + console.warn("[scrap] multiple prefixes:", [...prefixes]); + } + const codePrefix = [...prefixes][0] || "MX"; + const items = rows.map(({ suffix, name }) => ({ suffix, name })); + return [{ kind: "scrap", name: "Scrap", codePrefix, items }]; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + let orgId = args.orgId; + if (!orgId && args.orgSlug) { + const org = await prisma.org.findUnique({ where: { slug: args.orgSlug }, select: { id: true } }); + if (!org) throw new Error(`Org slug not found: ${args.orgSlug}`); + orgId = org.id; + } + if (!orgId) { + console.error("Provide --org-id or --org-slug "); + process.exit(1); + } + + const downtimeCats = parseDowntimeXlsx(args.downtimePath); + const scrapCats = parseScrapXlsx(args.scrapPath); + + const totalItems = + downtimeCats.reduce((n, c) => n + c.items.length, 0) + scrapCats.reduce((n, c) => n + c.items.length, 0); + + console.log("[seed] downtime categories:", downtimeCats.length, "scrap categories:", scrapCats.length); + console.log("[seed] total items:", totalItems); + + if (args.dryRun) { + console.log(JSON.stringify({ downtimeCats: downtimeCats.slice(0, 2), scrapCats }, null, 2)); + return; + } + + const existing = await prisma.reasonCatalogCategory.count({ where: { orgId } }); + if (existing && !args.replace) { + console.error( + `Org already has ${existing} catalog categor(ies). Re-run with --replace to wipe and reload, or use Control Tower UI.` + ); + process.exit(1); + } + + const bundled = [...downtimeCats, ...scrapCats]; + /** @type {string[]} */ + const dupCheck = []; + + await prisma.$transaction(async (tx) => { + if (args.replace) { + await tx.reasonCatalogItem.deleteMany({ where: { orgId } }); + await tx.reasonCatalogCategory.deleteMany({ where: { orgId } }); + } + + let catOrder = 0; + for (const block of bundled) { + const category = await tx.reasonCatalogCategory.create({ + data: { + orgId, + kind: block.kind, + name: block.name, + codePrefix: block.codePrefix, + sortOrder: catOrder++, + active: true, + }, + }); + + let itOrder = 0; + for (const row of block.items) { + const reasonCode = composeReasonCode(block.codePrefix, row.suffix); + dupCheck.push(reasonCode); + + await tx.reasonCatalogItem.create({ + data: { + orgId, + categoryId: category.id, + name: row.name, + codeSuffix: row.suffix, + reasonCode, + sortOrder: itOrder++, + active: true, + }, + }); + } + } + + await tx.orgSettings.update({ + where: { orgId }, + data: { version: { increment: 1 } }, + }); + }); + + const seen = new Set(); + let dup = 0; + for (const rc of dupCheck) { + if (seen.has(rc)) dup++; + seen.add(rc); + } + if (dup) console.warn("[seed] duplicate reason_code skipped by DB unique?", dup); + + console.log("[seed] done. Bump org_settings.version (+1)."); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + });