From 087fff89e140468a0bfb15b1b0d03c8f001293d9 Mon Sep 17 00:00:00 2001 From: Marcelo Date: Mon, 24 Nov 2025 12:19:36 -0600 Subject: [PATCH] MVP --- .config.users.json | 3 + .config.users.json.backup | 6 + Explanation_KPI.txt | 25 + Ignore.txt | 74 ++ Respaldo_Funcional_11_23_25.json | 1293 +++++++++++++++++++++++++ Respaldo_MVP_Complete_11_23_25.json | 1377 +++++++++++++++++++++++++++ flows.json | 2 +- optimization_prompt.txt | 198 ++++ phase1_database_migration.sql | 145 +++ phase1_machine_cycles_function.js | 200 ++++ 10 files changed, 3322 insertions(+), 1 deletion(-) create mode 100644 Explanation_KPI.txt create mode 100644 Ignore.txt create mode 100644 Respaldo_Funcional_11_23_25.json create mode 100644 Respaldo_MVP_Complete_11_23_25.json create mode 100644 optimization_prompt.txt create mode 100644 phase1_database_migration.sql create mode 100644 phase1_machine_cycles_function.js diff --git a/.config.users.json b/.config.users.json index add38a3..d063ff4 100644 --- a/.config.users.json +++ b/.config.users.json @@ -24,6 +24,9 @@ "user": { "name": "Mdares", "email": "mdares@maliountech.com" + }, + "workflow": { + "mode": "manual" } } } diff --git a/.config.users.json.backup b/.config.users.json.backup index 91bd15e..add38a3 100644 --- a/.config.users.json.backup +++ b/.config.users.json.backup @@ -19,6 +19,12 @@ }, "debug": { "filter": "filterCurrent" + }, + "git": { + "user": { + "name": "Mdares", + "email": "mdares@maliountech.com" + } } } } \ No newline at end of file diff --git a/Explanation_KPI.txt b/Explanation_KPI.txt new file mode 100644 index 0000000..1183af4 --- /dev/null +++ b/Explanation_KPI.txt @@ -0,0 +1,25 @@ +Excellent! Now I can see the issue. Your Availability calculation is correct - it's using the proper OEE formula with operatingTime / plannedTimeSec. However, I need to check how operatingTime is being accumulated, because that's likely where the problem is. +Let me search for where operatingTime is being updated: +Aha! I found the root cause of your issue. +The Problem +Your Availability calculation is conceptually correct (using the proper OEE formula), BUT the critical variables it depends on are never being updated in your Node-RED flows: +❌ global.get("operatingTime") - Never set anywhere in your flows +❌ global.get("productionStartTime") - Never set anywhere in your flows +This means your Availability calculation is always returning either: +0% (when trackingEnabled is false) +100% (when trackingEnabled is true but operatingTime is 0) +Why Scrap Appears to Affect Availability +When you submit scrap, your UI likely refreshes or recalculates, and depending on the state of these undefined variables, the Availability percentage changes unpredictably. It's not actually measuring availability at all - it's just showing placeholder values that change when the UI updates. +The Fix +You need to add a time tracking mechanism in your "Machine cycles" function. Here's what needs to be added: +// In the "Machine cycles" function, ADD THIS LOGIC:const current = Number(msg.payload) || 0;let zeroStreak = flow.get("zeroStreak") || 0;zeroStreak = current === 0 ? zeroStreak + 1 : 0;flow.set("zeroStreak", zeroStreak);const prev = flow.get("lastMachineState") ?? 0;flow.set("lastMachineState", current);global.set("machineOnline", true);let productionRunning = !!global.get("productionStarted");let stateChanged = false;// ===== ADD THIS SECTION FOR TIME TRACKING =====const now = Date.now();let lastUpdateTime = global.get("lastUpdateTime") || now;const deltaTime = (now - lastUpdateTime) / 1000; // seconds// Initialize productionStartTime when tracking beginsconst trackingEnabled = !!global.get("trackingEnabled");if (trackingEnabled && !global.get("productionStartTime")) { global.set("productionStartTime", now); global.set("operatingTime", 0);}// Accumulate operating time when machine is in state 1 (running)if (trackingEnabled && current === 1) { const currentOperatingTime = global.get("operatingTime") || 0; global.set("operatingTime", currentOperatingTime + deltaTime);}global.set("lastUpdateTime", now);// ===== END NEW SECTION =====if (current === 1 && !productionRunning) { productionRunning = true; stateChanged = true;} else if (current === 0 && zeroStreak >= 2 && productionRunning) { productionRunning = false; stateChanged = true;}// ... rest of your existing code +Also Update "Work Order buttons" Function +When a work order starts, initialize the timers: +case "start-work-order": { // ... existing code ... global.set("activeWorkOrder", order); global.set("cycleCount", 0); flow.set("lastMachineState", 0); global.set("scrapPromptIssuedFor", null); // ADD THESE LINES: global.set("productionStartTime", null); // Will be set when START button is clicked global.set("operatingTime", 0); global.set("lastUpdateTime", null); return [null, null, msg, null];} +And when START button is clicked: +case "start": { // START button clicked from Home dashboard global.set("trackingEnabled", true); // ADD THESE LINES: const now = Date.now(); global.set("productionStartTime", now); global.set("operatingTime", 0); global.set("lastUpdateTime", now); return [null, null, null, null];} +When STOP button is clicked or work order completes, reset timers: +case "stop": { global.set("trackingEnabled", false); // Optionally preserve timing for resume, or reset: // global.set("productionStartTime", null); // global.set("operatingTime", 0); return [null, null, null, null];}case "complete-work-order": { // ... existing code ... global.set("activeWorkOrder", null); global.set("cycleCount", 0); flow.set("lastMachineState", 0); global.set("scrapPromptIssuedFor", null); // ADD THESE LINES: global.set("productionStartTime", null); global.set("operatingTime", 0); global.set("lastUpdateTime", null); return [null, null, null, msg];} +Summary +Your instinct was correct - scrap should NOT affect Availability. The issue is that your Availability isn't being calculated at all because the time-tracking variables (operatingTime, productionStartTime) are never set. Once you implement proper time tracking in the machine cycle monitoring, your Availability will measure actual uptime vs. downtime, and scrap entries will correctly only affect the Quality KPI. +Would you like me to show you the complete updated functions with all these changes integrated? \ No newline at end of file diff --git a/Ignore.txt b/Ignore.txt new file mode 100644 index 0000000..3f90884 --- /dev/null +++ b/Ignore.txt @@ -0,0 +1,74 @@ +CRITICAL NODE-RED DASHBOARD CHART.JS FIX - PRESERVE ALL FUNCTIONALITY +I'm experiencing a Chart.js error in my Node-RED dashboard: Cannot read properties of undefined (reading 'length') at the determineDataLimits function. This occurs when trying to load charts with time-based x-axes. +Current Error Details: + +Error location: app.min.js:556 in a.determineDataLimits +Additional 404 error: ui/loading.html not found +Chart.js version appears to be v4 +Time adapter (chartjs-adapter-date-fns) was supposedly added but charts still not rendering + +REQUIREMENTS (ALL MUST BE MET): + +Fix the Chart.js time scale issue: + +Ensure chartjs-adapter-date-fns is properly loaded and initialized BEFORE any charts are created +Verify the adapter is compatible with the Chart.js version being used +Confirm the time scale is properly registered with Chart.js + + +Verify mock data structure: + +Mock data must have proper timestamp format (ISO 8601 or Unix timestamps) +Data arrays must have matching lengths for x and y values +Include realistic variance for: OEE (~72%), Availability (~85%), Performance (~88%), Quality (~94%) + + +Check loading order: + +Chart.js library must load first +Time adapter must load second +Chart creation code must execute last +Add console logs to verify loading sequence + + +PRESERVE ALL EXISTING FUNCTIONALITY: + +Home tab - unchanged +Alerts tab - unchanged +Work Orders tab - unchanged +Settings tab - unchanged +Help tab - unchanged +Only the Graphs tab should be modified + + +Provide complete debugging steps: + +Show me how to verify the adapter is loaded in browser console +Provide fallback options if adapter doesn't load from CDN +Include error handling for chart initialization failures + + +Output format: + +Provide the complete Node-RED flow JSON that I can import +Include all necessary HTML/JavaScript for the dashboard template +Mark clearly which nodes have been modified vs. unchanged +Include step-by-step deployment instructions + + + +Additional Context: + +Node-RED dashboard is running on http://10.147.20.185:1880 +Using Less CSS (app.min.less) for styling +Need hard refresh instructions for different browsers after deployment + +Success Criteria: + +No console errors after deployment and hard refresh +Charts display with mock data immediately upon loading Graphs tab +All filter buttons are clickable and functional +All other tabs remain completely functional +Solution works without requiring external CDN if possible + +Please provide a complete, tested solution that addresses every point above. Include explanations for each fix so I understand what was wrong and how it's been corrected. \ No newline at end of file diff --git a/Respaldo_Funcional_11_23_25.json b/Respaldo_Funcional_11_23_25.json new file mode 100644 index 0000000..192aa45 --- /dev/null +++ b/Respaldo_Funcional_11_23_25.json @@ -0,0 +1,1293 @@ +[ + { + "id": "cac3a4383120cb57", + "type": "tab", + "label": "Flow 1", + "disabled": false, + "info": "", + "env": [] + }, + { + "id": "16bb591480852f51", + "type": "group", + "z": "cac3a4383120cb57", + "name": "Start ", + "style": { + "stroke": "#92d04f", + "fill": "#addb7b", + "label": true + }, + "nodes": [ + "6ad64dedab2042b9", + "0f5ee343ed17976c", + "4e025693949ec4bd", + "b55c91c096a366db", + "33d1f41119e0e262", + "f98ae23b2430c206", + "0d023d87a13bf56f", + "dbc7a5ee041845ed", + "e15d6c1f78b644a2" + ], + "x": 34, + "y": 339, + "w": 762, + "h": 162 + }, + { + "id": "bdaf9298cd8e306b", + "type": "group", + "z": "cac3a4383120cb57", + "name": "Cavity Settings ", + "style": { + "stroke": "#ff7f7f", + "fill": "#ffbfbf", + "label": true + }, + "nodes": [ + "e1f2a3b4c5d6e7f8", + "75dbe316f19fd44c" + ], + "x": null, + "y": null, + "w": null, + "h": null + }, + { + "id": "ec32d0a62eacfb22", + "type": "group", + "z": "cac3a4383120cb57", + "name": "UI/UX", + "style": { + "fill": "#d1d1d1", + "label": true + }, + "nodes": [ + "1821c4842945ecd8", + "f2a3b4c5d6e7f8a9", + "f3a4b5c6d7e8f9a0", + "f4a5b6c7d8e9f0a1", + "f5a6b7c8d9e0f1a2", + "f1a2b3c4d5e6f7a8", + "a7d58e15929b3d8c", + "cc81a9dbfd443d62", + "06f9769e8b0d5355", + "0a5caf3e23c68e6e", + "010de5af3ced0ae3", + "6f9de736a538d0d1", + "16af50d6fce977a8", + "2f04a72fdeb67f3f", + "8f890f97aa9257c7" + ], + "x": 34, + "y": 19, + "w": 632, + "h": 282 + }, + { + "id": "b7ab5e0cc02b9508", + "type": "group", + "z": "cac3a4383120cb57", + "name": "Work Orders", + "style": { + "stroke": "#9363b7", + "fill": "#dbcbe7", + "label": true + }, + "nodes": [ + "9bbd4fade968036d", + "65ddb4cca6787bde", + "596b390d7aaf69fb", + "f6ad294bc02618c9", + "f2bab26e27e2023d", + "0779932734d8201c", + "3772c25d07b07407", + "c2b272494952cd98", + "87d85c86e4773aa5", + "15a6b7b6d8f39fe4", + "64661fe6aa2cb83d", + "578c92e75bf0f266", + "76ce53cf1ae40e9c", + "0d6ec01f421acdef", + "fd32602c52d896e9" + ], + "x": 804, + "y": 279, + "w": 902, + "h": 202 + }, + { + "id": "75dbe316f19fd44c", + "type": "group", + "z": "cac3a4383120cb57", + "g": "bdaf9298cd8e306b", + "name": "Cavities Settings", + "style": { + "stroke": "#ffff00", + "fill": "#ffffbf", + "label": true + }, + "nodes": [ + "e1f2a3b4c5d6e7f8", + "28c173789034639c" + ], + "x": null, + "y": null, + "w": null, + "h": null + }, + { + "id": "28c173789034639c", + "type": "group", + "z": "cac3a4383120cb57", + "g": "75dbe316f19fd44c", + "name": "Settings", + "style": { + "stroke": "#92d04f", + "fill": "#ffffbf", + "label": true + }, + "nodes": [ + "eaebd8c719c3d135", + "a1b2c3d4e5f6a7b8", + "c9d8e7f6a5b4c3d2", + "b2c3d4e5f6a7b8c9", + "7311641fd09b4d3a", + "0b5740c4a2b298b7" + ], + "x": 714, + "y": 19, + "w": 722, + "h": 142 + }, + { + "id": "c567195d86466cd5", + "type": "ui_tab", + "name": "Home", + "icon": "dashboard", + "order": 1, + "disabled": false, + "hidden": false + }, + { + "id": "f4c299235c1b719d", + "type": "ui_base", + "theme": { + "name": "theme-custom", + "lightTheme": { + "default": "#0094CE", + "baseColor": "#0094CE", + "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif", + "edited": true, + "reset": false + }, + "darkTheme": { + "default": "#097479", + "baseColor": "#000000", + "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif", + "edited": true, + "reset": false + }, + "customTheme": { + "name": "Transparent", + "default": "#4B7930", + "baseColor": "#000000", + "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif", + "reset": false + }, + "themeState": { + "base-color": { + "default": "#4B7930", + "value": "#000000", + "edited": true + }, + "page-titlebar-backgroundColor": { + "value": "#000000", + "edited": false + }, + "page-backgroundColor": { + "value": "#111111", + "edited": false + }, + "page-sidebar-backgroundColor": { + "value": "#333333", + "edited": false + }, + "group-textColor": { + "value": "#262626", + "edited": false + }, + "group-borderColor": { + "value": "#000000", + "edited": true + }, + "group-backgroundColor": { + "value": "#333333", + "edited": false + }, + "widget-textColor": { + "value": "#eeeeee", + "edited": false + }, + "widget-backgroundColor": { + "value": "#000000", + "edited": false + }, + "widget-borderColor": { + "value": "#333333", + "edited": false + }, + "base-font": { + "value": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif" + } + }, + "angularTheme": { + "primary": "indigo", + "accents": "blue", + "warn": "red", + "background": "grey", + "palette": "light" + } + }, + "site": { + "name": "Node-RED Dashboard", + "hideToolbar": "true", + "allowSwipe": "false", + "lockMenu": "false", + "allowTempTheme": "true", + "dateFormat": "DD/MM/YYYY", + "sizes": { + "sx": 48, + "sy": 48, + "gx": 6, + "gy": 6, + "cx": 6, + "cy": 6, + "px": 0, + "py": 0 + } + } + }, + { + "id": "919b5b8d778e2b6c", + "type": "ui_group", + "name": "Default", + "tab": "c567195d86466cd5", + "order": 1, + "disp": false, + "width": "25", + "collapse": false, + "className": "" + }, + { + "id": "d1a1e2f3a4b5c6d7", + "type": "ui_tab", + "name": "Work Orders", + "icon": "list", + "order": 2, + "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": "e1f2a3b4c5d6e7f8", + "type": "ui_group", + "g": "75dbe316f19fd44c", + "name": "Work Orders Group", + "tab": "d1a1e2f3a4b5c6d7", + "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": "00d8ad2b0277f906", + "type": "MySQLdatabase", + "name": "machine_data", + "host": "10.147.20.244", + "port": "3306", + "db": "machine_data", + "tz": "", + "charset": "UTF8" + }, + { + "id": "1821c4842945ecd8", + "type": "ui_template", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "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
Availability
\n
0%
\n
\n
\n
Performance
\n
0%
\n
\n
\n
Quality
\n
0%
\n
\n
\n\n
\n

Current Work Order

\n
\n
\n
Work Order ID
\n
 
\n
\n
\n
SKU
\n
 
\n
\n
\n
Cycle Time
\n
0
\n
\n
\n
\n
\n
0%
\n
\n
\n\n
\n
\n
Good Parts
\n
0
\n
out of 0
\n
\n\n
\n
MachineOFFLINE
\n
ProductionSTOPPED
\n
\n\n
\n \n
\n
\n
\n
\n
\n
\n
\n

Work Order Complete

\n

{{ scrapPrompt.orderId }}

\n

Produced {{ scrapPrompt.produced }} of {{ scrapPrompt.target }} pieces

\n\n
Were there any scrap parts?
\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", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 180, + "y": 60, + "wires": [ + [ + "a7d58e15929b3d8c", + "010de5af3ced0ae3" + ] + ] + }, + { + "id": "f2a3b4c5d6e7f8a9", + "type": "ui_template", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "group": "e2f3a4b5c6d7e8f9", + "name": "Alerts Template", + "order": 0, + "width": "25", + "height": "25", + "format": "\n
\n \n\n
\n
\n
\n

Alerts

\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", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 180, + "y": 140, + "wires": [ + [ + "a7d58e15929b3d8c" + ] + ] + }, + { + "id": "f3a4b5c6d7e8f9a0", + "type": "ui_template", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "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 – Last 24h

\n
\n
\n
\n

Availability – Last 7 days

\n
\n
\n
\n

Performance – Last 7 days

\n
\n
\n
\n

Quality – Last 7 days

\n
\n
\n
\n
\n
\n
\n\n\n", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 190, + "y": 180, + "wires": [ + [ + "a7d58e15929b3d8c" + ] + ] + }, + { + "id": "f4a5b6c7d8e9f0a1", + "type": "ui_template", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "group": "e4f5a6b7c8d9e0f1", + "name": "Help Template", + "order": 0, + "width": "25", + "height": "25", + "format": "\n
\n \n\n
\n
\n
\n

Help

\n
\n\n
\n

About this Dashboard

\n

This interface centralizes Overall Equipment Effectiveness metrics, real-time production details, and critical status indicators. Each tab follows a unified layout so operators can scan performance, alerts, and configuration without relearning navigation.

\n
\n\n
\n

How to Start / Stop Production

\n

Navigate to the Work Orders tab, select the required job, and use the primary controls to begin or end production. Always log the reason for stoppages using the Alerts tab, and confirm machine readiness before resuming. Follow your facility’s standard operating procedure for approvals and sign-off.

\n
\n
\n
\n
\n\n\n", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 180, + "y": 220, + "wires": [ + [ + "a7d58e15929b3d8c" + ] + ] + }, + { + "id": "f5a6b7c8d9e0f1a2", + "type": "ui_template", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "group": "e5f6a7b8c9d0e1f2", + "name": "Settings Template", + "order": 0, + "width": "25", + "height": "25", + "format": "\n
\n \n\n
\n
\n
\n

Settings

\n
\n\n
\n

Mold Presets

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

Select a manufacturer and mold from the dropdowns above.

\n

If you can't find the mold you're looking for, add a new one:

\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

Mold Configuration

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

Integrations

\n \n
\n
\n
\n
\n\n\n\n", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 190, + "y": 260, + "wires": [ + [ + "a7d58e15929b3d8c", + "0a5caf3e23c68e6e" + ] + ] + }, + { + "id": "f1a2b3c4d5e6f7a8", + "type": "ui_template", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "group": "e1f2a3b4c5d6e7f8", + "name": "WO Template", + "order": 1, + "width": "25", + "height": "25", + "format": "\n
\n \n\n
\n
\n
\n

Work Orders

\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
IDSKUTARGETGOODSCRAPPROGRESSSTATUSLAST UPDATE
\n
\n
0 items
\n
\n
\n
\n
\n\n\n", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 180, + "y": 100, + "wires": [ + [ + "a7d58e15929b3d8c", + "010de5af3ced0ae3" + ] + ] + }, + { + "id": "a7d58e15929b3d8c", + "type": "function", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "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": 420, + "y": 160, + "wires": [ + [ + "cc81a9dbfd443d62" + ] + ] + }, + { + "id": "cc81a9dbfd443d62", + "type": "ui_ui_control", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "name": "", + "events": "all", + "x": 580, + "y": 160, + "wires": [ + [] + ] + }, + { + "id": "06f9769e8b0d5355", + "type": "ui_template", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "group": "", + "name": "General Style", + "order": 0, + "width": 0, + "height": 0, + "format": "", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "global", + "className": "", + "x": 540, + "y": 120, + "wires": [ + [] + ] + }, + { + "id": "6ad64dedab2042b9", + "type": "inject", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "Simula Inyectora", + "props": [ + { + "p": "payload" + } + ], + "repeat": "1", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "1", + "payloadType": "num", + "x": 170, + "y": 380, + "wires": [ + [ + "0f5ee343ed17976c" + ] + ] + }, + { + "id": "0f5ee343ed17976c", + "type": "function", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "1,0", + "func": "// Get current global value (default to 0 if not set)\nlet estado = global.get('Estado_maquina') || 0;\nlet stop = flow.get('stop') || false;\n\nif (stop) {\n // Manual stop active → force 0, don't reschedule\n global.set('Estado_maquina', 0);\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\nglobal.set('Estado_maquina', estado);\n\n// Send it out\nmsg.payload = estado;\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 490, + "y": 400, + "wires": [ + [ + "0d023d87a13bf56f" + ] + ] + }, + { + "id": "4e025693949ec4bd", + "type": "inject", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "Manual Stop", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 150, + "y": 420, + "wires": [ + [ + "b55c91c096a366db" + ] + ] + }, + { + "id": "b55c91c096a366db", + "type": "change", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "", + "rules": [ + { + "t": "set", + "p": "stop", + "pt": "flow", + "to": "true", + "tot": "bool" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 330, + "y": 420, + "wires": [ + [ + "0f5ee343ed17976c" + ] + ] + }, + { + "id": "33d1f41119e0e262", + "type": "inject", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "Manual Start", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 150, + "y": 460, + "wires": [ + [ + "f98ae23b2430c206" + ] + ] + }, + { + "id": "f98ae23b2430c206", + "type": "change", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "set flow.start", + "rules": [ + { + "t": "set", + "p": "stop", + "pt": "flow", + "to": "false", + "tot": "bool" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 320, + "y": 460, + "wires": [ + [ + "0f5ee343ed17976c" + ] + ] + }, + { + "id": "eaebd8c719c3d135", + "type": "function", + "z": "cac3a4383120cb57", + "g": "28c173789034639c", + "name": "Cavities Settings", + "func": "if (msg.topic === \"moldSettings\" && msg.payload) {\n const total = Number(msg.payload.total || 0);\n const active = Number(msg.payload.active || 0);\n\n // Store globally\n global.set(\"moldTotal\", total);\n global.set(\"moldActive\", active);\n\n node.status({ fill: \"green\", shape: \"dot\", text: `Saved: ${active}/${total}` });\n\n msg.payload = { saved: true, total, active };\n return msg;\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 globally\n global.set(\"moldTotal\", total);\n global.set(\"moldActive\", 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 return msg;\n}\n\nnode.status({ fill: \"red\", shape: \"ring\", text: \"Invalid payload\" });\nreturn null;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 910, + "y": 60, + "wires": [ + [] + ] + }, + { + "id": "a1b2c3d4e5f6a7b8", + "type": "function", + "z": "cac3a4383120cb57", + "g": "28c173789034639c", + "name": "Mold Presets Handler", + "func": "const topic = msg.topic || '';\nconst payload = msg.payload || {};\n\n// Log every incoming request\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": 840, + "y": 120, + "wires": [ + [ + "c9d8e7f6a5b4c3d2" + ] + ] + }, + { + "id": "c9d8e7f6a5b4c3d2", + "type": "mysql", + "z": "cac3a4383120cb57", + "g": "28c173789034639c", + "mydb": "00d8ad2b0277f906", + "name": "Mold Presets DB", + "x": 1050, + "y": 120, + "wires": [ + [ + "b2c3d4e5f6a7b8c9" + ] + ] + }, + { + "id": "b2c3d4e5f6a7b8c9", + "type": "function", + "z": "cac3a4383120cb57", + "g": "28c173789034639c", + "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": 1270, + "y": 120, + "wires": [ + [ + "0b5740c4a2b298b7" + ] + ] + }, + { + "id": "0a5caf3e23c68e6e", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "name": "link out 1", + "mode": "link", + "links": [ + "7311641fd09b4d3a" + ], + "x": 305, + "y": 260, + "wires": [] + }, + { + "id": "7311641fd09b4d3a", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "28c173789034639c", + "name": "link in 1", + "links": [ + "0a5caf3e23c68e6e" + ], + "x": 755, + "y": 60, + "wires": [ + [ + "eaebd8c719c3d135", + "a1b2c3d4e5f6a7b8" + ] + ] + }, + { + "id": "9bbd4fade968036d", + "type": "function", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "Work Order buttons", + "func": "switch (msg.action) {\n case \"upload-excel\":\n msg._mode = \"upload\";\n return [msg, null, null, null];\n case \"refresh-work-orders\":\n msg._mode = \"select\";\n msg.topic = \"SELECT * FROM work_orders ORDER BY created_at DESC;\";\n return [null, msg, null, null];\n // start/complete unchanged...\n case \"start-work-order\": {\n msg._mode = \"start\";\n const order = msg.payload || {};\n if (!order.id) {\n node.error(\"No work order id supplied for start\", msg);\n return [null, null, null, null];\n }\n msg.startOrder = order;\n\n msg.topic = `\n UPDATE work_orders\n SET\n status = CASE\n WHEN work_order_id = '${order.id}' THEN 'RUNNING'\n ELSE 'PENDING'\n END,\n updated_at = CASE\n WHEN work_order_id = '${order.id}' THEN NOW()\n ELSE updated_at\n END\n WHERE status <> 'DONE';\n `;\n\n global.set(\"activeWorkOrder\", order);\n global.set(\"cycleCount\", 0);\n flow.set(\"lastMachineState\", 0);\n global.set(\"scrapPromptIssuedFor\", null);\n return [null, null, msg, null];\n }\n case \"complete-work-order\": {\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 [null, null, null, null];\n }\n msg.completeOrder = order;\n msg.topic = `\n UPDATE work_orders\n SET status = 'DONE', updated_at = NOW()\n WHERE work_order_id = '${order.id}';\n `;\n global.set(\"activeWorkOrder\", null);\n global.set(\"cycleCount\", 0);\n flow.set(\"lastMachineState\", 0);\n global.set(\"scrapPromptIssuedFor\", null);\n return [null, null, null, msg];\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 [null, null, null, null];\n }\n\n // Update activeWorkOrder with accumulated scrap\n const activeOrder = global.get(\"activeWorkOrder\");\n if (activeOrder && activeOrder.id === id) {\n activeOrder.scrap = (Number(activeOrder.scrap) || 0) + scrapNum;\n global.set(\"activeWorkOrder\", activeOrder);\n }\n\n // Clear prompt flag so it can show again when target reached next time\n global.set(\"scrapPromptIssuedFor\", null);\n\n msg._mode = \"scrap-update\";\n msg.scrapEntry = { id, scrap: scrapNum };\n msg.topic = `\n UPDATE work_orders\n SET\n scrap_parts = scrap_parts + ${scrapNum},\n updated_at = NOW()\n WHERE work_order_id = '${id}';\n `;\n\n // CRITICAL: Do NOT set status='DONE', do NOT clear activeWorkOrder\n return [null, null, msg, null];\n }\n case \"scrap-skip\": {\n // User clicked \"No, Continue\" - just clear the prompt, keep running\n const { id } = msg.payload || {};\n\n if (!id) {\n node.error(\"No work order id supplied for scrap skip\", msg);\n return [null, null, null, null];\n }\n\n // DON'T clear scrapPromptIssuedFor - keep it set so prompt doesn't appear again\n // Flag will be cleared when work order is completed or a new one is started\n\n msg._mode = \"scrap-skipped\";\n return [null, null, null, null];\n }\n case \"start\": {\n // START button clicked from Home dashboard\n // Enable tracking of cycles for the active work order\n global.set(\"trackingEnabled\", true);\n return [null, null, null, null];\n }\n case \"stop\": {\n // Manual STOP button clicked from Home dashboard\n // Disable tracking but keep work order active\n global.set(\"trackingEnabled\", false);\n return [null, null, null, null];\n }\n}\n", + "outputs": 4, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 970, + "y": 400, + "wires": [ + [ + "15a6b7b6d8f39fe4" + ], + [ + "f6ad294bc02618c9" + ], + [ + "f6ad294bc02618c9" + ], + [ + "f6ad294bc02618c9" + ] + ] + }, + { + "id": "010de5af3ced0ae3", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "name": "link out 2", + "mode": "link", + "links": [ + "65ddb4cca6787bde" + ], + "x": 305, + "y": 100, + "wires": [] + }, + { + "id": "65ddb4cca6787bde", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "link in 2", + "links": [ + "010de5af3ced0ae3" + ], + "x": 845, + "y": 400, + "wires": [ + [ + "9bbd4fade968036d" + ] + ] + }, + { + "id": "596b390d7aaf69fb", + "type": "function", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "Build Insert SQL", + "func": "const rows = Array.isArray(msg.payload) ? msg.payload : [];\nconst vals = rows.map(r => `(\n'${r[\"Work Order ID\"]}',\n'${r[\"SKU\"]}',\n${Number(r[\"Target Quantity\"]) || 0},\n${Number(r[\"Theoretical Cycle Time (Seconds)\"]) || 0},\n'PENDING')`).join(',\\n');\n\nmsg.topic = `\nINSERT INTO work_orders (work_order_id, sku, target_qty, cycle_time, status)\nVALUES\n${vals}\nON DUPLICATE KEY UPDATE\n sku=VALUES(sku),\n target_qty=VALUES(target_qty),\n cycle_time=VALUES(cycle_time);\n`;\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1240, + "y": 360, + "wires": [ + [ + "f6ad294bc02618c9" + ] + ] + }, + { + "id": "f6ad294bc02618c9", + "type": "mysql", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "mydb": "00d8ad2b0277f906", + "name": "mariaDB", + "x": 1220, + "y": 400, + "wires": [ + [ + "578c92e75bf0f266" + ] + ] + }, + { + "id": "f2bab26e27e2023d", + "type": "function", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "Back to UI", + "func": "const mode = msg._mode || '';\nconst started = msg.startOrder || null;\nconst completed = msg.completeOrder || null;\n\ndelete msg._mode;\ndelete msg.startOrder;\ndelete msg.completeOrder;\ndelete msg.action;\ndelete msg.filename;\n\nif (mode === \"upload\") {\n msg.topic = \"uploadStatus\";\n msg.payload = { message: \"✅ Work orders uploaded successfully.\" };\n return [msg, null];\n}\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 good: Number(row.good_parts ?? row.good ?? 0),\n scrap: Number(row.scrap_count ?? row.scrap ?? 0),\n progressPercent: Number(row.progress_percent ?? row.progress ?? 0),\n status: (row.status ?? \"PENDING\").toUpperCase(),\n lastUpdateIso: row.updated_at ?? row.last_update ?? null,\n cycleTime: Number(row.cycle_time ?? row.theoretical_cycle_time ?? 0)\n }));\n return [msg, null];\n}\n\nif (mode === \"start\") {\n const order = started || {};\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: {\n id: order.id || \"\",\n sku: order.sku || \"\",\n target: Number(order.target) || 0,\n good: Number(order.good) || 0,\n scrap: Number(order.scrap) || 0,\n cycleTime: Number(order.cycleTime || order.theoreticalCycleTime || 0),\n progressPercent: Number(order.progressPercent) || 0,\n lastUpdateIso: order.lastUpdateIso || null\n }\n };\n return [null, homeMsg];\n}\n\nif (mode === \"complete\") {\n const homeMsg = { topic: \"activeWorkOrder\", payload: null };\n return [null, homeMsg];\n}\n\nif (mode === \"cycle\") {\n const cycle = msg.cycle || {};\n const workOrderMsg = {\n topic: \"workOrderCycle\",\n payload: {\n id: cycle.id || \"\",\n sku: cycle.sku || \"\",\n target: Number(cycle.target) || 0,\n good: Number(cycle.good) || 0,\n scrap: Number(cycle.scrap) || 0,\n progressPercent: Number(cycle.progressPercent) || 0,\n lastUpdateIso: cycle.lastUpdateIso || new Date().toISOString(),\n status: cycle.progressPercent >= 100 ? \"DONE\" : \"RUNNING\"\n }\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 good: Number(cycle.good) || 0,\n scrap: Number(cycle.scrap) || 0,\n cycleTime: Number(cycle.cycleTime) || 0,\n progressPercent: Number(cycle.progressPercent) || 0,\n lastUpdateIso: cycle.lastUpdateIso || new Date().toISOString()\n }\n };\n\n return [workOrderMsg, homeMsg];\n}\nif (mode === \"production-state\") {\n const homeMsg = {\n topic: \"machineStatus\",\n payload: {\n machineOnline: msg.machineOnline ?? true,\n productionStarted: !!msg.productionStarted\n }\n };\n return [null, homeMsg];\n}\nif (mode === \"scrap-prompt\") {\n const prompt = msg.scrapPrompt || {};\n const homeMsg = { topic: \"scrapPrompt\", payload: prompt };\n const tabMsg = { ui_control: { tab: \"Home\" } };\n\n // output1: nothing, output2: Home template, output3: Tab navigation\n return [null, homeMsg, tabMsg];\n}\n\nif (mode === \"scrap-complete\") {\n const homeMsg = { topic: \"activeWorkOrder\", payload: null };\n return [null, homeMsg];\n}\nreturn [null, null];", + "outputs": 3, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1570, + "y": 400, + "wires": [ + [ + "0779932734d8201c" + ], + [ + "64661fe6aa2cb83d" + ], + [ + "fd32602c52d896e9" + ] + ] + }, + { + "id": "0779932734d8201c", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "link out 3", + "mode": "link", + "links": [ + "6f9de736a538d0d1" + ], + "x": 1665, + "y": 360, + "wires": [] + }, + { + "id": "6f9de736a538d0d1", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "name": "link in 3", + "links": [ + "0779932734d8201c" + ], + "x": 75, + "y": 100, + "wires": [ + [ + "f1a2b3c4d5e6f7a8" + ] + ] + }, + { + "id": "3772c25d07b07407", + "type": "book", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "", + "raw": false, + "x": 1350, + "y": 320, + "wires": [ + [ + "c2b272494952cd98" + ] + ] + }, + { + "id": "c2b272494952cd98", + "type": "sheet", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "", + "sheetName": "Sheet1", + "x": 1470, + "y": 320, + "wires": [ + [ + "87d85c86e4773aa5" + ] + ] + }, + { + "id": "87d85c86e4773aa5", + "type": "sheet-to-json", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "", + "raw": "false", + "range": "", + "header": "default", + "blankrows": false, + "x": 1610, + "y": 320, + "wires": [ + [ + "596b390d7aaf69fb" + ] + ] + }, + { + "id": "15a6b7b6d8f39fe4", + "type": "function", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "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": 1220, + "y": 320, + "wires": [ + [ + "3772c25d07b07407" + ] + ] + }, + { + "id": "64661fe6aa2cb83d", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "link out 4", + "mode": "link", + "links": [ + "16af50d6fce977a8" + ], + "x": 1665, + "y": 400, + "wires": [] + }, + { + "id": "16af50d6fce977a8", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "name": "link in 4", + "links": [ + "64661fe6aa2cb83d" + ], + "x": 75, + "y": 60, + "wires": [ + [ + "1821c4842945ecd8" + ] + ] + }, + { + "id": "578c92e75bf0f266", + "type": "function", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "Refresh Trigger", + "func": "if (msg._mode === \"start\" || msg._mode === \"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}\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 === \"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": 1400, + "y": 400, + "wires": [ + [ + "f6ad294bc02618c9" + ], + [ + "f2bab26e27e2023d" + ] + ] + }, + { + "id": "0d023d87a13bf56f", + "type": "function", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "Machine cycles", + "func": "const current = Number(msg.payload) || 0;\n\nlet zeroStreak = flow.get(\"zeroStreak\") || 0;\nzeroStreak = current === 0 ? zeroStreak + 1 : 0;\nflow.set(\"zeroStreak\", zeroStreak);\n\nconst prev = flow.get(\"lastMachineState\") ?? 0;\nflow.set(\"lastMachineState\", current);\n\nglobal.set(\"machineOnline\", true); // force ONLINE for now\n\nlet productionRunning = !!global.get(\"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\nglobal.set(\"productionStarted\", productionRunning);\n\nconst stateMsg = stateChanged\n ? {\n _mode: \"production-state\",\n machineOnline: true,\n productionStarted: productionRunning\n }\n : null;\n\nconst activeOrder = global.get(\"activeWorkOrder\");\nconst cavities = Number(global.get(\"moldActive\") || 0);\nif (!activeOrder || !activeOrder.id || cavities <= 0) {\n // We still want to pass along any state change even if there's no active WO.\n return [null, stateMsg];\n}\n\n// Check if tracking is enabled (START button clicked)\nconst trackingEnabled = !!global.get(\"trackingEnabled\");\nif (!trackingEnabled) {\n // Cycles are happening but we're not tracking them yet\n return [null, stateMsg];\n}\n\n// only count rising edges (0 -> 1) for production totals\nif (prev === 1 || current !== 1) {\n return [null, stateMsg];\n}\n\nlet cycles = Number(global.get(\"cycleCount\") || 0) + 1;\nglobal.set(\"cycleCount\", cycles);\n\n// Calculate good parts: total produced minus accumulated scrap\nconst scrapTotal = Number(activeOrder.scrap) || 0;\nconst totalProduced = cycles * cavities;\nconst produced = totalProduced - scrapTotal;\nconst target = Number(activeOrder.target) || 0;\nconst progress = target > 0 ? Math.min(100, Math.round((produced / target) * 100)) : 0;\n\nactiveOrder.good = produced;\nactiveOrder.progressPercent = progress;\nactiveOrder.lastUpdateIso = new Date().toISOString();\nglobal.set(\"activeWorkOrder\", activeOrder);\n\nconst promptIssued = global.get(\"scrapPromptIssuedFor\") || null;\nnode.warn(`[DEBUG] promptIssued=${promptIssued}, activeOrderId=${activeOrder.id}, produced=${produced}, target=${target}`);\n\nif (!promptIssued && target > 0 && produced >= target) {\n node.warn(`[DEBUG] TRIGGERING PROMPT!`);\n global.set(\"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 [null, msg]; // bypass the DB update on this cycle\n}\n\nconst dbMsg = {\n _mode: \"cycle\",\n cycle: {\n id: activeOrder.id,\n sku: activeOrder.sku || \"\",\n target,\n good: produced,\n scrap: Number(activeOrder.scrap) || 0,\n cycleTime: Number(activeOrder.cycleTime || activeOrder.theoreticalCycleTime || 0),\n progressPercent: progress,\n lastUpdateIso: activeOrder.lastUpdateIso,\n machineOnline: true,\n productionStarted: productionRunning\n },\n topic: `\n UPDATE work_orders\n SET\n good_parts = ${produced},\n progress_percent = ${progress},\n updated_at = NOW()\n WHERE work_order_id = '${activeOrder.id}';\n `\n};\n\nreturn [dbMsg, stateMsg];", + "outputs": 2, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 640, + "y": 400, + "wires": [ + [ + "dbc7a5ee041845ed" + ], + [ + "e15d6c1f78b644a2" + ] + ] + }, + { + "id": "dbc7a5ee041845ed", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "link out 5", + "mode": "link", + "links": [ + "76ce53cf1ae40e9c" + ], + "x": 755, + "y": 380, + "wires": [] + }, + { + "id": "76ce53cf1ae40e9c", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "link in 5", + "links": [ + "dbc7a5ee041845ed" + ], + "x": 1115, + "y": 400, + "wires": [ + [ + "f6ad294bc02618c9" + ] + ] + }, + { + "id": "e15d6c1f78b644a2", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "link out 6", + "mode": "link", + "links": [ + "0d6ec01f421acdef" + ], + "x": 755, + "y": 420, + "wires": [] + }, + { + "id": "0d6ec01f421acdef", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "link in 6", + "links": [ + "e15d6c1f78b644a2" + ], + "x": 1295, + "y": 440, + "wires": [ + [ + "578c92e75bf0f266" + ] + ] + }, + { + "id": "fd32602c52d896e9", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "link out 7", + "mode": "link", + "links": [ + "2f04a72fdeb67f3f" + ], + "x": 1665, + "y": 440, + "wires": [] + }, + { + "id": "2f04a72fdeb67f3f", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "name": "link in 7", + "links": [ + "fd32602c52d896e9" + ], + "x": 335, + "y": 200, + "wires": [ + [ + "a7d58e15929b3d8c" + ] + ] + }, + { + "id": "0b5740c4a2b298b7", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "28c173789034639c", + "name": "link out 8", + "mode": "link", + "links": [ + "8f890f97aa9257c7" + ], + "x": 1395, + "y": 120, + "wires": [] + }, + { + "id": "8f890f97aa9257c7", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "name": "link in 8", + "links": [ + "0b5740c4a2b298b7" + ], + "x": 75, + "y": 260, + "wires": [ + [ + "f5a6b7c8d9e0f1a2" + ] + ] + } +] \ No newline at end of file diff --git a/Respaldo_MVP_Complete_11_23_25.json b/Respaldo_MVP_Complete_11_23_25.json new file mode 100644 index 0000000..71fe152 --- /dev/null +++ b/Respaldo_MVP_Complete_11_23_25.json @@ -0,0 +1,1377 @@ +[ + { + "id": "cac3a4383120cb57", + "type": "tab", + "label": "Flow 1", + "disabled": false, + "info": "", + "env": [] + }, + { + "id": "16bb591480852f51", + "type": "group", + "z": "cac3a4383120cb57", + "name": "Start ", + "style": { + "stroke": "#92d04f", + "fill": "#addb7b", + "label": true + }, + "nodes": [ + "6ad64dedab2042b9", + "0f5ee343ed17976c", + "4e025693949ec4bd", + "b55c91c096a366db", + "33d1f41119e0e262", + "f98ae23b2430c206", + "0d023d87a13bf56f", + "dbc7a5ee041845ed", + "e15d6c1f78b644a2" + ], + "x": 34, + "y": 339, + "w": 762, + "h": 162 + }, + { + "id": "bdaf9298cd8e306b", + "type": "group", + "z": "cac3a4383120cb57", + "name": "Cavity Settings ", + "style": { + "stroke": "#ff7f7f", + "fill": "#ffbfbf", + "label": true + }, + "nodes": [ + "e1f2a3b4c5d6e7f8", + "75dbe316f19fd44c" + ], + "x": null, + "y": null, + "w": null, + "h": null + }, + { + "id": "ec32d0a62eacfb22", + "type": "group", + "z": "cac3a4383120cb57", + "name": "UI/UX", + "style": { + "fill": "#d1d1d1", + "label": true + }, + "nodes": [ + "1821c4842945ecd8", + "f2a3b4c5d6e7f8a9", + "f3a4b5c6d7e8f9a0", + "f4a5b6c7d8e9f0a1", + "f5a6b7c8d9e0f1a2", + "f1a2b3c4d5e6f7a8", + "a7d58e15929b3d8c", + "cc81a9dbfd443d62", + "06f9769e8b0d5355", + "0a5caf3e23c68e6e", + "010de5af3ced0ae3", + "6f9de736a538d0d1", + "16af50d6fce977a8", + "2f04a72fdeb67f3f", + "8f890f97aa9257c7" + ], + "x": 34, + "y": 19, + "w": 632, + "h": 282 + }, + { + "id": "b7ab5e0cc02b9508", + "type": "group", + "z": "cac3a4383120cb57", + "name": "Work Orders", + "style": { + "stroke": "#9363b7", + "fill": "#dbcbe7", + "label": true + }, + "nodes": [ + "9bbd4fade968036d", + "65ddb4cca6787bde", + "596b390d7aaf69fb", + "f6ad294bc02618c9", + "f2bab26e27e2023d", + "0779932734d8201c", + "3772c25d07b07407", + "c2b272494952cd98", + "87d85c86e4773aa5", + "15a6b7b6d8f39fe4", + "64661fe6aa2cb83d", + "578c92e75bf0f266", + "76ce53cf1ae40e9c", + "0d6ec01f421acdef", + "fd32602c52d896e9" + ], + "x": 804, + "y": 279, + "w": 902, + "h": 202 + }, + { + "id": "75dbe316f19fd44c", + "type": "group", + "z": "cac3a4383120cb57", + "g": "bdaf9298cd8e306b", + "name": "Cavities Settings", + "style": { + "stroke": "#ffff00", + "fill": "#ffffbf", + "label": true + }, + "nodes": [ + "e1f2a3b4c5d6e7f8", + "28c173789034639c" + ], + "x": null, + "y": null, + "w": null, + "h": null + }, + { + "id": "28c173789034639c", + "type": "group", + "z": "cac3a4383120cb57", + "g": "75dbe316f19fd44c", + "name": "Settings", + "style": { + "stroke": "#92d04f", + "fill": "#ffffbf", + "label": true + }, + "nodes": [ + "eaebd8c719c3d135", + "a1b2c3d4e5f6a7b8", + "c9d8e7f6a5b4c3d2", + "b2c3d4e5f6a7b8c9", + "7311641fd09b4d3a", + "0b5740c4a2b298b7" + ], + "x": 714, + "y": 19, + "w": 722, + "h": 142 + }, + { + "id": "c567195d86466cd5", + "type": "ui_tab", + "name": "Home", + "icon": "dashboard", + "order": 1, + "disabled": false, + "hidden": false + }, + { + "id": "f4c299235c1b719d", + "type": "ui_base", + "theme": { + "name": "theme-custom", + "lightTheme": { + "default": "#0094CE", + "baseColor": "#0094CE", + "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif", + "edited": true, + "reset": false + }, + "darkTheme": { + "default": "#097479", + "baseColor": "#000000", + "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif", + "edited": true, + "reset": false + }, + "customTheme": { + "name": "Transparent", + "default": "#4B7930", + "baseColor": "#000000", + "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif", + "reset": false + }, + "themeState": { + "base-color": { + "default": "#4B7930", + "value": "#000000", + "edited": true + }, + "page-titlebar-backgroundColor": { + "value": "#000000", + "edited": false + }, + "page-backgroundColor": { + "value": "#111111", + "edited": false + }, + "page-sidebar-backgroundColor": { + "value": "#333333", + "edited": false + }, + "group-textColor": { + "value": "#262626", + "edited": false + }, + "group-borderColor": { + "value": "#000000", + "edited": true + }, + "group-backgroundColor": { + "value": "#333333", + "edited": false + }, + "widget-textColor": { + "value": "#eeeeee", + "edited": false + }, + "widget-backgroundColor": { + "value": "#000000", + "edited": false + }, + "widget-borderColor": { + "value": "#333333", + "edited": false + }, + "base-font": { + "value": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif" + } + }, + "angularTheme": { + "primary": "indigo", + "accents": "blue", + "warn": "red", + "background": "grey", + "palette": "light" + } + }, + "site": { + "name": "Node-RED Dashboard", + "hideToolbar": "true", + "allowSwipe": "false", + "lockMenu": "false", + "allowTempTheme": "true", + "dateFormat": "DD/MM/YYYY", + "sizes": { + "sx": 48, + "sy": 48, + "gx": 6, + "gy": 6, + "cx": 6, + "cy": 6, + "px": 0, + "py": 0 + } + } + }, + { + "id": "919b5b8d778e2b6c", + "type": "ui_group", + "name": "Default", + "tab": "c567195d86466cd5", + "order": 1, + "disp": false, + "width": "25", + "collapse": false, + "className": "" + }, + { + "id": "d1a1e2f3a4b5c6d7", + "type": "ui_tab", + "name": "Work Orders", + "icon": "list", + "order": 2, + "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": "e1f2a3b4c5d6e7f8", + "type": "ui_group", + "g": "75dbe316f19fd44c", + "name": "Work Orders Group", + "tab": "d1a1e2f3a4b5c6d7", + "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": "00d8ad2b0277f906", + "type": "MySQLdatabase", + "name": "machine_data", + "host": "10.147.20.244", + "port": "3306", + "db": "machine_data", + "tz": "", + "charset": "UTF8" + }, + { + "id": "1821c4842945ecd8", + "type": "ui_template", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "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
Availability
\n
0%
\n
\n
\n
Performance
\n
0%
\n
\n
\n
Quality
\n
0%
\n
\n
\n\n
\n

Current Work Order

\n
\n
\n
Work Order ID
\n
 
\n
\n
\n
SKU
\n
 
\n
\n
\n
Cycle Time
\n
0
\n
\n
\n
\n
\n
0%
\n
\n
\n\n
\n
\n
Good Parts
\n
0
\n
out of 0
\n
\n\n
\n
MachineOFFLINE
\n
ProductionSTOPPED
\n
\n\n
\n \n
\n
\n
\n
\n
\n
\n
\n

Work Order Complete

\n

{{ scrapPrompt.orderId }}

\n

Produced {{ scrapPrompt.produced }} of {{ scrapPrompt.target }} pieces

\n\n
Were there any scrap parts?
\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", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 180, + "y": 60, + "wires": [ + [ + "a7d58e15929b3d8c", + "010de5af3ced0ae3" + ] + ] + }, + { + "id": "f2a3b4c5d6e7f8a9", + "type": "ui_template", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "group": "e2f3a4b5c6d7e8f9", + "name": "Alerts Template", + "order": 0, + "width": "25", + "height": "25", + "format": "\n
\n \n\n
\n
\n
\n

Incidents

\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": 180, + "y": 140, + "wires": [ + [ + "a7d58e15929b3d8c", + "alert_process_function" + ] + ] + }, + { + "id": "f3a4b5c6d7e8f9a0", + "type": "ui_template", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "group": "e3f4a5b6c7d8e9f0", + "name": "Graphs Template", + "order": 0, + "width": "25", + "height": "25", + "format": "\n
\n \n\n
\n
\n
\n

Graphs

\n
\n\n
\n
Time Range:
\n
\n \n \n \n \n \n
\n
\n\n\n
\n
\n

OEE

\n
\n
\n
\n

Availability

\n
\n
\n
\n

Performance

\n
\n
\n
\n

Quality

\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": 190, + "y": 180, + "wires": [ + [ + "a7d58e15929b3d8c" + ] + ] + }, + { + "id": "f4a5b6c7d8e9f0a1", + "type": "ui_template", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "group": "e4f5a6b7c8d9e0f1", + "name": "Help Template", + "order": 0, + "width": "25", + "height": "25", + "format": "\n
\n \n\n
\n
\n
\n

Help

\n
\n\n
\n

About this Dashboard

\n

This interface centralizes Overall Equipment Effectiveness metrics, real-time production details, and critical status indicators. Each tab follows a unified layout so operators can scan performance, alerts, and configuration without relearning navigation.

\n
\n\n
\n

How to Start / Stop Production

\n

Navigate to the Work Orders tab, select the required job, and use the primary controls to begin or end production. Always log the reason for stoppages using the Alerts tab, and confirm machine readiness before resuming. Follow your facility’s standard operating procedure for approvals and sign-off.

\n
\n
\n
\n
\n\n\n", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 180, + "y": 220, + "wires": [ + [ + "a7d58e15929b3d8c" + ] + ] + }, + { + "id": "f5a6b7c8d9e0f1a2", + "type": "ui_template", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "group": "e5f6a7b8c9d0e1f2", + "name": "Settings Template", + "order": 0, + "width": "25", + "height": "25", + "format": "\n
\n \n\n
\n
\n
\n

Settings

\n
\n\n
\n

Mold Presets

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

Select a manufacturer and mold from the dropdowns above.

\n

If you can't find the mold you're looking for, add a new one:

\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

Mold Configuration

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

Integrations

\n \n
\n
\n
\n
\n\n\n\n", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 190, + "y": 260, + "wires": [ + [ + "a7d58e15929b3d8c", + "0a5caf3e23c68e6e" + ] + ] + }, + { + "id": "f1a2b3c4d5e6f7a8", + "type": "ui_template", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "group": "e1f2a3b4c5d6e7f8", + "name": "WO Template", + "order": 1, + "width": "25", + "height": "25", + "format": "\n
\n \n\n
\n
\n
\n

Work Orders

\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
IDSKUTARGETGOODSCRAPPROGRESSSTATUSLAST UPDATE
\n
\n
0 items
\n
\n
\n
\n
\n\n\n", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 180, + "y": 100, + "wires": [ + [ + "a7d58e15929b3d8c", + "010de5af3ced0ae3" + ] + ] + }, + { + "id": "a7d58e15929b3d8c", + "type": "function", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "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": 420, + "y": 160, + "wires": [ + [ + "cc81a9dbfd443d62" + ] + ] + }, + { + "id": "cc81a9dbfd443d62", + "type": "ui_ui_control", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "name": "", + "events": "all", + "x": 580, + "y": 160, + "wires": [ + [] + ] + }, + { + "id": "06f9769e8b0d5355", + "type": "ui_template", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "group": "", + "name": "General Style", + "order": 0, + "width": 0, + "height": 0, + "format": "", + "storeOutMessages": true, + "fwdInMessages": true, + "resendOnRefresh": true, + "templateScope": "global", + "className": "", + "x": 540, + "y": 120, + "wires": [ + [] + ] + }, + { + "id": "6ad64dedab2042b9", + "type": "inject", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "Simula Inyectora", + "props": [ + { + "p": "payload" + } + ], + "repeat": "1", + "crontab": "", + "once": true, + "onceDelay": 0.1, + "topic": "", + "payload": "1", + "payloadType": "num", + "x": 170, + "y": 380, + "wires": [ + [ + "0f5ee343ed17976c" + ] + ] + }, + { + "id": "0f5ee343ed17976c", + "type": "function", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "1,0", + "func": "// Get current global value (default to 0 if not set)\nlet estado = global.get('Estado_maquina') || 0;\nlet stop = flow.get('stop') || false;\n\nif (stop) {\n // Manual stop active → force 0, don't reschedule\n global.set('Estado_maquina', 0);\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\nglobal.set('Estado_maquina', estado);\n\n// Send it out\nmsg.payload = estado;\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 490, + "y": 400, + "wires": [ + [ + "0d023d87a13bf56f" + ] + ] + }, + { + "id": "4e025693949ec4bd", + "type": "inject", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "Manual Stop", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 150, + "y": 420, + "wires": [ + [ + "b55c91c096a366db" + ] + ] + }, + { + "id": "b55c91c096a366db", + "type": "change", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "", + "rules": [ + { + "t": "set", + "p": "stop", + "pt": "flow", + "to": "true", + "tot": "bool" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 330, + "y": 420, + "wires": [ + [ + "0f5ee343ed17976c" + ] + ] + }, + { + "id": "33d1f41119e0e262", + "type": "inject", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "Manual Start", + "props": [ + { + "p": "payload" + }, + { + "p": "topic", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "payload": "", + "payloadType": "date", + "x": 150, + "y": 460, + "wires": [ + [ + "f98ae23b2430c206" + ] + ] + }, + { + "id": "f98ae23b2430c206", + "type": "change", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "set flow.start", + "rules": [ + { + "t": "set", + "p": "stop", + "pt": "flow", + "to": "false", + "tot": "bool" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 320, + "y": 460, + "wires": [ + [ + "0f5ee343ed17976c" + ] + ] + }, + { + "id": "eaebd8c719c3d135", + "type": "function", + "z": "cac3a4383120cb57", + "g": "28c173789034639c", + "name": "Cavities Settings", + "func": "if (msg.topic === \"moldSettings\" && msg.payload) {\n const total = Number(msg.payload.total || 0);\n const active = Number(msg.payload.active || 0);\n\n // Store globally\n global.set(\"moldTotal\", total);\n global.set(\"moldActive\", active);\n\n node.status({ fill: \"green\", shape: \"dot\", text: `Saved: ${active}/${total}` });\n\n msg.payload = { saved: true, total, active };\n return msg;\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 globally\n global.set(\"moldTotal\", total);\n global.set(\"moldActive\", 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 return msg;\n}\n\nnode.status({ fill: \"red\", shape: \"ring\", text: \"Invalid payload\" });\nreturn null;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 910, + "y": 60, + "wires": [ + [] + ] + }, + { + "id": "a1b2c3d4e5f6a7b8", + "type": "function", + "z": "cac3a4383120cb57", + "g": "28c173789034639c", + "name": "Mold Presets Handler", + "func": "const topic = msg.topic || '';\nconst payload = msg.payload || {};\n\n// Log every incoming request\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": 840, + "y": 120, + "wires": [ + [ + "c9d8e7f6a5b4c3d2" + ] + ] + }, + { + "id": "c9d8e7f6a5b4c3d2", + "type": "mysql", + "z": "cac3a4383120cb57", + "g": "28c173789034639c", + "mydb": "00d8ad2b0277f906", + "name": "Mold Presets DB", + "x": 1050, + "y": 120, + "wires": [ + [ + "b2c3d4e5f6a7b8c9" + ] + ] + }, + { + "id": "b2c3d4e5f6a7b8c9", + "type": "function", + "z": "cac3a4383120cb57", + "g": "28c173789034639c", + "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": 1270, + "y": 120, + "wires": [ + [ + "0b5740c4a2b298b7" + ] + ] + }, + { + "id": "0a5caf3e23c68e6e", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "name": "link out 1", + "mode": "link", + "links": [ + "7311641fd09b4d3a" + ], + "x": 305, + "y": 260, + "wires": [] + }, + { + "id": "7311641fd09b4d3a", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "28c173789034639c", + "name": "link in 1", + "links": [ + "0a5caf3e23c68e6e" + ], + "x": 755, + "y": 60, + "wires": [ + [ + "eaebd8c719c3d135", + "a1b2c3d4e5f6a7b8" + ] + ] + }, + { + "id": "9bbd4fade968036d", + "type": "function", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "Work Order buttons", + "func": "switch (msg.action) {\n case \"upload-excel\":\n msg._mode = \"upload\";\n return [msg, null, null, null];\n case \"refresh-work-orders\":\n msg._mode = \"select\";\n msg.topic = \"SELECT * FROM work_orders ORDER BY created_at DESC;\";\n return [null, msg, null, null];\n // start/complete unchanged...\n case \"start-work-order\": {\n msg._mode = \"start\";\n const order = msg.payload || {};\n if (!order.id) {\n node.error(\"No work order id supplied for start\", msg);\n return [null, null, null, null];\n }\n msg.startOrder = order;\n\n msg.topic = `\n UPDATE work_orders\n SET\n status = CASE\n WHEN work_order_id = '${order.id}' THEN 'RUNNING'\n ELSE 'PENDING'\n END,\n updated_at = CASE\n WHEN work_order_id = '${order.id}' THEN NOW()\n ELSE updated_at\n END\n WHERE status <> 'DONE';\n `;\n\n global.set(\"activeWorkOrder\", order);\n global.set(\"cycleCount\", 0);\n flow.set(\"lastMachineState\", 0);\n global.set(\"scrapPromptIssuedFor\", null);\n return [null, null, msg, null];\n }\n case \"complete-work-order\": {\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 [null, null, null, null];\n }\n msg.completeOrder = order;\n msg.topic = `\n UPDATE work_orders\n SET status = 'DONE', updated_at = NOW()\n WHERE work_order_id = '${order.id}';\n `;\n global.set(\"activeWorkOrder\", null);\n\n // Phase 2: Clean up time tracking variables\n global.set(\"operatingTime\", 0);\n global.set(\"lastCycleTime\", null);\n global.set(\"cycleCount\", 0);\n flow.set(\"lastMachineState\", 0);\n global.set(\"scrapPromptIssuedFor\", null);\n return [null, null, null, msg];\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 [null, null, null, null];\n }\n\n // Update activeWorkOrder with accumulated scrap\n const activeOrder = global.get(\"activeWorkOrder\");\n if (activeOrder && activeOrder.id === id) {\n activeOrder.scrap = (Number(activeOrder.scrap) || 0) + scrapNum;\n global.set(\"activeWorkOrder\", activeOrder);\n }\n\n // Clear prompt flag so it can show again when target reached next time\n global.set(\"scrapPromptIssuedFor\", null);\n\n msg._mode = \"scrap-update\";\n msg.scrapEntry = { id, scrap: scrapNum };\n msg.topic = `\n UPDATE work_orders\n SET\n scrap_parts = scrap_parts + ${scrapNum},\n updated_at = NOW()\n WHERE work_order_id = '${id}';\n `;\n\n // CRITICAL: Do NOT set status='DONE', do NOT clear activeWorkOrder\n return [null, null, msg, null];\n }\n case \"scrap-skip\": {\n // User clicked \"No, Continue\" - respect their \"remind again\" preference\n const { id, remindAgain } = msg.payload || {};\n\n if (!id) {\n node.error(\"No work order id supplied for scrap skip\", msg);\n return [null, null, null, null];\n }\n\n // Only clear prompt flag if user wants to be reminded again\n // By default (unchecked), keep the flag set to prevent loop\n if (remindAgain) {\n global.set(\"scrapPromptIssuedFor\", null);\n }\n // Otherwise, leave scrapPromptIssuedFor as-is (won't prompt again)\n\n msg._mode = \"scrap-skipped\";\n return [null, null, null, null];\n }\n case \"start\": {\n // START button clicked from Home dashboard\n // Enable tracking of cycles for the active work order\n global.set(\"trackingEnabled\", true);\n\n // Initialize production start time for KPI calculations\n global.set(\"productionStartTime\", Date.now());\n\n // Phase 2: Initialize operating time tracking\n global.set(\"operatingTime\", 0); // Reset operating time counter\n global.set(\"lastCycleTime\", Date.now()); // Initialize last cycle timestamp\n \n // Trigger initial KPI calculation\n const activeOrder = global.get(\"activeWorkOrder\") || {};\n msg._mode = \"production-state\";\n msg.productionStarted = true;\n msg.machineOnline = true;\n \n // Send through output 2 to trigger KPI calculation\n return [null, msg, null, null];\n }\n case \"stop\": {\n // Manual STOP button clicked from Home dashboard\n // Disable tracking but keep work order active\n global.set(\"trackingEnabled\", false);\n return [null, null, null, null];\n }\n}\n", + "outputs": 4, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 970, + "y": 400, + "wires": [ + [ + "15a6b7b6d8f39fe4" + ], + [ + "f6ad294bc02618c9", + "00b6132848964bd9" + ], + [ + "f6ad294bc02618c9", + "00b6132848964bd9" + ], + [ + "f6ad294bc02618c9" + ] + ] + }, + { + "id": "010de5af3ced0ae3", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "name": "link out 2", + "mode": "link", + "links": [ + "65ddb4cca6787bde" + ], + "x": 305, + "y": 100, + "wires": [] + }, + { + "id": "65ddb4cca6787bde", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "link in 2", + "links": [ + "010de5af3ced0ae3" + ], + "x": 845, + "y": 400, + "wires": [ + [ + "9bbd4fade968036d" + ] + ] + }, + { + "id": "596b390d7aaf69fb", + "type": "function", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "Build Insert SQL", + "func": "const rows = Array.isArray(msg.payload) ? msg.payload : [];\nconst vals = rows.map(r => `(\n'${r[\"Work Order ID\"]}',\n'${r[\"SKU\"]}',\n${Number(r[\"Target Quantity\"]) || 0},\n${Number(r[\"Theoretical Cycle Time (Seconds)\"]) || 0},\n'PENDING')`).join(',\\n');\n\nmsg.topic = `\nINSERT INTO work_orders (work_order_id, sku, target_qty, cycle_time, status)\nVALUES\n${vals}\nON DUPLICATE KEY UPDATE\n sku=VALUES(sku),\n target_qty=VALUES(target_qty),\n cycle_time=VALUES(cycle_time);\n`;\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1240, + "y": 360, + "wires": [ + [ + "f6ad294bc02618c9" + ] + ] + }, + { + "id": "f6ad294bc02618c9", + "type": "mysql", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "mydb": "00d8ad2b0277f906", + "name": "mariaDB", + "x": 1220, + "y": 400, + "wires": [ + [ + "578c92e75bf0f266" + ] + ] + }, + { + "id": "f2bab26e27e2023d", + "type": "function", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "Back to UI", + "func": "const mode = msg._mode || '';\nconst started = msg.startOrder || null;\nconst completed = msg.completeOrder || null;\n\ndelete msg._mode;\ndelete msg.startOrder;\ndelete msg.completeOrder;\ndelete msg.action;\ndelete msg.filename;\n\nif (mode === \"upload\") {\n msg.topic = \"uploadStatus\";\n msg.payload = { message: \"✅ Work orders uploaded successfully.\" };\n return [msg, null];\n}\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 good: Number(row.good_parts ?? row.good ?? 0),\n scrap: Number(row.scrap_count ?? row.scrap ?? 0),\n progressPercent: Number(row.progress_percent ?? row.progress ?? 0),\n status: (row.status ?? \"PENDING\").toUpperCase(),\n lastUpdateIso: row.updated_at ?? row.last_update ?? null,\n cycleTime: Number(row.cycle_time ?? row.theoretical_cycle_time ?? 0)\n }));\n return [msg, null];\n}\n\nif (mode === \"start\") {\n const order = started || {};\n // Get KPIs from global or from msg\n const kpis = msg.kpis || global.get(\"currentKPIs\") || { oee: 0, availability: 0, performance: 0, quality: 0 };\n\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: {\n id: order.id || \"\",\n sku: order.sku || \"\",\n target: Number(order.target) || 0,\n good: Number(order.good) || 0,\n scrap: Number(order.scrap) || 0,\n cycleTime: Number(order.cycleTime || order.theoreticalCycleTime || 0),\n progressPercent: Number(order.progressPercent) || 0,\n lastUpdateIso: order.lastUpdateIso || null,\n kpis: kpis\n }\n };\n return [null, homeMsg];\n}\n\nif (mode === \"complete\") {\n // Get KPIs from global or from msg\n const kpis = msg.kpis || global.get(\"currentKPIs\") || { oee: 0, availability: 0, performance: 0, quality: 0 };\n\n const homeMsg = { topic: \"activeWorkOrder\", payload: null };\n return [null, homeMsg];\n}\n\nif (mode === \"cycle\") {\n const cycle = msg.cycle || {};\n const workOrderMsg = {\n topic: \"workOrderCycle\",\n payload: {\n id: cycle.id || \"\",\n sku: cycle.sku || \"\",\n target: Number(cycle.target) || 0,\n good: Number(cycle.good) || 0,\n scrap: Number(cycle.scrap) || 0,\n progressPercent: Number(cycle.progressPercent) || 0,\n lastUpdateIso: cycle.lastUpdateIso || new Date().toISOString(),\n status: cycle.progressPercent >= 100 ? \"DONE\" : \"RUNNING\"\n }\n };\n\n // Get KPIs from global or from msg\n const kpis = msg.kpis || global.get(\"currentKPIs\") || { oee: 0, availability: 0, performance: 0, quality: 0 };\n\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: {\n id: cycle.id || \"\",\n sku: cycle.sku || \"\",\n target: Number(cycle.target) || 0,\n good: Number(cycle.good) || 0,\n scrap: Number(cycle.scrap) || 0,\n cycleTime: Number(cycle.cycleTime) || 0,\n progressPercent: Number(cycle.progressPercent) || 0,\n lastUpdateIso: cycle.lastUpdateIso || new Date().toISOString(),\n kpis: kpis\n }\n };\n\n return [workOrderMsg, homeMsg];\n}\nif (mode === \"production-state\") {\n // Get KPIs from global or from msg\n const kpis = msg.kpis || global.get(\"currentKPIs\") || { oee: 0, availability: 0, performance: 0, quality: 0 };\n\n const homeMsg = {\n topic: \"machineStatus\",\n payload: {\n machineOnline: msg.machineOnline ?? true,\n productionStarted: !!msg.productionStarted\n }\n };\n return [null, homeMsg];\n}\nif (mode === \"scrap-prompt\") {\n const prompt = msg.scrapPrompt || {};\n // Get KPIs from global or from msg\n const kpis = msg.kpis || global.get(\"currentKPIs\") || { oee: 0, availability: 0, performance: 0, quality: 0 };\n\n const homeMsg = { topic: \"scrapPrompt\", payload: prompt };\n const tabMsg = { ui_control: { tab: \"Home\" } };\n\n // output1: nothing, output2: Home template, output3: Tab navigation\n return [null, homeMsg, tabMsg];\n}\n\nif (mode === \"scrap-update\") {\n // Scrap was just submitted - send updated KPIs to UI\n const activeOrder = global.get(\"activeWorkOrder\") || {};\n const kpis = msg.kpis || global.get(\"currentKPIs\") || { oee: 0, availability: 0, performance: 0, quality: 0 };\n\n const homeMsg = {\n topic: \"activeWorkOrder\",\n payload: {\n id: activeOrder.id || \"\",\n sku: activeOrder.sku || \"\",\n target: Number(activeOrder.target) || 0,\n good: Number(activeOrder.good) || 0,\n scrap: Number(activeOrder.scrap) || 0,\n cycleTime: Number(activeOrder.cycleTime) || 0,\n progressPercent: Number(activeOrder.progressPercent) || 0,\n lastUpdateIso: activeOrder.lastUpdateIso || new Date().toISOString(),\n kpis: kpis\n }\n };\n return [null, homeMsg];\n}\n\nif (mode === \"scrap-complete\") {\n // Get KPIs from global or from msg\n const kpis = msg.kpis || global.get(\"currentKPIs\") || { oee: 0, availability: 0, performance: 0, quality: 0 };\n\n const homeMsg = { topic: \"activeWorkOrder\", payload: null };\n return [null, homeMsg];\n}\nreturn [null, null];", + "outputs": 3, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1570, + "y": 400, + "wires": [ + [ + "0779932734d8201c" + ], + [ + "64661fe6aa2cb83d" + ], + [ + "fd32602c52d896e9" + ] + ] + }, + { + "id": "0779932734d8201c", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "link out 3", + "mode": "link", + "links": [ + "6f9de736a538d0d1" + ], + "x": 1665, + "y": 360, + "wires": [] + }, + { + "id": "6f9de736a538d0d1", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "name": "link in 3", + "links": [ + "0779932734d8201c" + ], + "x": 75, + "y": 100, + "wires": [ + [ + "f1a2b3c4d5e6f7a8" + ] + ] + }, + { + "id": "3772c25d07b07407", + "type": "book", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "", + "raw": false, + "x": 1350, + "y": 320, + "wires": [ + [ + "c2b272494952cd98" + ] + ] + }, + { + "id": "c2b272494952cd98", + "type": "sheet", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "", + "sheetName": "Sheet1", + "x": 1470, + "y": 320, + "wires": [ + [ + "87d85c86e4773aa5" + ] + ] + }, + { + "id": "87d85c86e4773aa5", + "type": "sheet-to-json", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "", + "raw": "false", + "range": "", + "header": "default", + "blankrows": false, + "x": 1610, + "y": 320, + "wires": [ + [ + "596b390d7aaf69fb" + ] + ] + }, + { + "id": "15a6b7b6d8f39fe4", + "type": "function", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "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": 1220, + "y": 320, + "wires": [ + [ + "3772c25d07b07407" + ] + ] + }, + { + "id": "64661fe6aa2cb83d", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "link out 4", + "mode": "link", + "links": [ + "16af50d6fce977a8" + ], + "x": 1665, + "y": 400, + "wires": [] + }, + { + "id": "16af50d6fce977a8", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "name": "link in 4", + "links": [ + "64661fe6aa2cb83d" + ], + "x": 75, + "y": 60, + "wires": [ + [ + "1821c4842945ecd8" + ] + ] + }, + { + "id": "578c92e75bf0f266", + "type": "function", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "Refresh Trigger", + "func": "if (msg._mode === \"start\" || msg._mode === \"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}\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 === \"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": 1400, + "y": 400, + "wires": [ + [ + "f6ad294bc02618c9" + ], + [ + "f2bab26e27e2023d" + ] + ] + }, + { + "id": "0d023d87a13bf56f", + "type": "function", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "Machine cycles", + "func": "const current = Number(msg.payload) || 0;\n\nlet zeroStreak = flow.get(\"zeroStreak\") || 0;\nzeroStreak = current === 0 ? zeroStreak + 1 : 0;\nflow.set(\"zeroStreak\", zeroStreak);\n\nconst prev = flow.get(\"lastMachineState\") ?? 0;\nflow.set(\"lastMachineState\", current);\n\nglobal.set(\"machineOnline\", true); // force ONLINE for now\n\nlet productionRunning = !!global.get(\"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\nglobal.set(\"productionStarted\", productionRunning);\n\nconst stateMsg = stateChanged\n ? {\n _mode: \"production-state\",\n machineOnline: true,\n productionStarted: productionRunning\n }\n : null;\n\nconst activeOrder = global.get(\"activeWorkOrder\");\nconst cavities = Number(global.get(\"moldActive\") || 0);\nif (!activeOrder || !activeOrder.id || cavities <= 0) {\n // We still want to pass along any state change even if there's no active WO.\n return [null, stateMsg];\n}\n\n// Check if tracking is enabled (START button clicked)\nconst trackingEnabled = !!global.get(\"trackingEnabled\");\nif (!trackingEnabled) {\n // Cycles are happening but we're not tracking them yet\n return [null, stateMsg];\n}\n\n// only count rising edges (0 -> 1) for production totals\nif (prev === 1 || current !== 1) {\n return [null, stateMsg];\n}\n\nlet cycles = Number(global.get(\"cycleCount\") || 0) + 1;\nglobal.set(\"cycleCount\", cycles);\n\n// ===== PHASE 2: OPERATING TIME TRACKING =====\n// Track actual operating time between cycles\nconst now = Date.now();\nconst lastCycleTime = global.get(\"lastCycleTime\") || now;\n\n// Calculate time since last cycle (in milliseconds)\nconst timeSinceLastCycle = now - lastCycleTime;\n\n// Accumulate operating time (in seconds)\nlet operatingTime = global.get(\"operatingTime\") || 0;\noperatingTime += (timeSinceLastCycle / 1000);\n\nglobal.set(\"operatingTime\", operatingTime);\nglobal.set(\"lastCycleTime\", now);\n// ===== END OPERATING TIME TRACKING =====\n\n// Calculate good parts: total produced minus accumulated scrap\nconst scrapTotal = Number(activeOrder.scrap) || 0;\nconst totalProduced = cycles * cavities;\nconst produced = totalProduced - scrapTotal;\nconst target = Number(activeOrder.target) || 0;\nconst progress = target > 0 ? Math.min(100, Math.round((produced / target) * 100)) : 0;\n\nactiveOrder.good = produced;\nactiveOrder.progressPercent = progress;\nactiveOrder.lastUpdateIso = new Date().toISOString();\nglobal.set(\"activeWorkOrder\", activeOrder);\n\nconst promptIssued = global.get(\"scrapPromptIssuedFor\") || null;\nif (!promptIssued && target > 0 && produced >= target) {\n global.set(\"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 [null, msg]; // bypass the DB update on this cycle\n}\n\nconst dbMsg = {\n _mode: \"cycle\",\n cycle: {\n id: activeOrder.id,\n sku: activeOrder.sku || \"\",\n target,\n good: produced,\n scrap: Number(activeOrder.scrap) || 0,\n cycleTime: Number(activeOrder.cycleTime || activeOrder.theoreticalCycleTime || 0),\n progressPercent: progress,\n lastUpdateIso: activeOrder.lastUpdateIso,\n machineOnline: true,\n productionStarted: productionRunning\n },\n topic: `\n UPDATE work_orders\n SET\n good_parts = ${produced},\n progress_percent = ${progress},\n updated_at = NOW()\n WHERE work_order_id = '${activeOrder.id}';\n `\n};\n\nreturn [dbMsg, stateMsg];", + "outputs": 2, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 640, + "y": 400, + "wires": [ + [ + "dbc7a5ee041845ed" + ], + [ + "00b6132848964bd9" + ] + ] + }, + { + "id": "dbc7a5ee041845ed", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "link out 5", + "mode": "link", + "links": [ + "76ce53cf1ae40e9c" + ], + "x": 755, + "y": 380, + "wires": [] + }, + { + "id": "76ce53cf1ae40e9c", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "link in 5", + "links": [ + "dbc7a5ee041845ed" + ], + "x": 1115, + "y": 400, + "wires": [ + [ + "f6ad294bc02618c9" + ] + ] + }, + { + "id": "e15d6c1f78b644a2", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "link out 6", + "mode": "link", + "links": [ + "0d6ec01f421acdef" + ], + "x": 755, + "y": 420, + "wires": [] + }, + { + "id": "0d6ec01f421acdef", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "link in 6", + "links": [ + "e15d6c1f78b644a2" + ], + "x": 1295, + "y": 440, + "wires": [ + [ + "578c92e75bf0f266" + ] + ] + }, + { + "id": "fd32602c52d896e9", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "b7ab5e0cc02b9508", + "name": "link out 7", + "mode": "link", + "links": [ + "2f04a72fdeb67f3f" + ], + "x": 1665, + "y": 440, + "wires": [] + }, + { + "id": "2f04a72fdeb67f3f", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "name": "link in 7", + "links": [ + "fd32602c52d896e9" + ], + "x": 335, + "y": 200, + "wires": [ + [ + "a7d58e15929b3d8c" + ] + ] + }, + { + "id": "0b5740c4a2b298b7", + "type": "link out", + "z": "cac3a4383120cb57", + "g": "28c173789034639c", + "name": "link out 8", + "mode": "link", + "links": [ + "8f890f97aa9257c7" + ], + "x": 1395, + "y": 120, + "wires": [] + }, + { + "id": "8f890f97aa9257c7", + "type": "link in", + "z": "cac3a4383120cb57", + "g": "ec32d0a62eacfb22", + "name": "link in 8", + "links": [ + "0b5740c4a2b298b7" + ], + "x": 75, + "y": 260, + "wires": [ + [ + "f5a6b7c8d9e0f1a2" + ] + ] + }, + { + "id": "00b6132848964bd9", + "type": "function", + "z": "cac3a4383120cb57", + "g": "16bb591480852f51", + "name": "Calculate KPIs", + "func": "// ========================================\n// OEE KPI CALCULATOR - PHASE 1\n// Industry Standard: OEE = Availability × Performance × Quality\n// ========================================\n\nconst activeOrder = global.get(\"activeWorkOrder\") || {};\nconst cycleCount = global.get(\"cycleCount\") || 0;\nconst cavities = Number(global.get(\"moldActive\")?.cavities) || 1;\nconst trackingEnabled = global.get(\"trackingEnabled\") || false;\n\n// Initialize KPI object\nmsg.kpis = {\n quality: 0,\n performance: 0,\n availability: 0,\n oee: 0\n};\n\n// ========================================\n// 1. QUALITY CALCULATION\n// Formula: (Good Parts / Total Parts) × 100%\n// ========================================\nconst goodParts = Number(activeOrder.good) || 0;\nconst scrapParts = Number(activeOrder.scrap) || 0;\nconst totalParts = goodParts + scrapParts;\n\nif (totalParts > 0) {\n msg.kpis.quality = (goodParts / totalParts) * 100;\n} else {\n msg.kpis.quality = 100; // No production yet = perfect quality\n}\n\n// Cap at 100% per OEE standard\nmsg.kpis.quality = Math.min(100, msg.kpis.quality);\n\n// ========================================\n// 2. PERFORMANCE CALCULATION - PHASE 2\n// Formula: (Ideal Cycle Time × Total Count / Operating Time) × 100%\n// Uses actual operating time for accurate measurement\n// ========================================\nconst idealCycleTime = Number(activeOrder.cycleTime) || 0; // seconds per cycle\nconst operatingTime = global.get(\"operatingTime\") || 0; // seconds actually running\nlet productionStartTime = global.get(\"productionStartTime\");\n\nif (cycleCount > 0 && idealCycleTime > 0 && operatingTime > 0) {\n // Calculate how many cycles SHOULD have been completed in operating time\n const targetCount = operatingTime / idealCycleTime;\n\n // Performance = (Actual Count / Target Count) × 100%\n msg.kpis.performance = (cycleCount / targetCount) * 100;\n\n // Cap at 100% per OEE standard\n msg.kpis.performance = Math.min(100, msg.kpis.performance);\n} else if (trackingEnabled && productionStartTime) {\n // Production started but no cycles yet - show 100% placeholder\n msg.kpis.performance = 100;\n} else {\n msg.kpis.performance = 0; // No production yet\n}\n\n// ========================================\n// 3. AVAILABILITY CALCULATION - PHASE 2\n// Formula: (Operating Time / Planned Production Time) × 100%\n// Planned Production Time = total elapsed time since start\n// ========================================\nif (trackingEnabled && productionStartTime) {\n const operatingTime = global.get(\"operatingTime\") || 0;\n const elapsedTimeMs = Date.now() - productionStartTime;\n const plannedTimeSec = elapsedTimeMs / 1000;\n\n if (plannedTimeSec > 0 && operatingTime > 0) {\n // Availability = Operating Time / Planned Time\n msg.kpis.availability = (operatingTime / plannedTimeSec) * 100;\n\n // Cap at 100%\n msg.kpis.availability = Math.min(100, msg.kpis.availability);\n } else if (plannedTimeSec > 0) {\n // Just started - no operating time yet but production is \"on\"\n msg.kpis.availability = 100.0;\n } else {\n msg.kpis.availability = 100.0; // Just started\n }\n} else {\n msg.kpis.availability = 0; // Not running\n}\n\n// ========================================\n// 4. OEE CALCULATION\n// Formula: (Availability × Performance × Quality) / 10,000\n// ========================================\nmsg.kpis.oee = (msg.kpis.availability * msg.kpis.performance * msg.kpis.quality) / 10000;\n\n// Round all values to 1 decimal place\nmsg.kpis.quality = Math.round(msg.kpis.quality * 10) / 10;\nmsg.kpis.performance = Math.round(msg.kpis.performance * 10) / 10;\nmsg.kpis.availability = Math.round(msg.kpis.availability * 10) / 10;\nmsg.kpis.oee = Math.round(msg.kpis.oee * 10) / 10;\n\n// Store KPIs globally for access by other nodes\nglobal.set(\"currentKPIs\", msg.kpis);\n\n// Debug logging (comment out in production)\n// node.warn(`KPIs: OEE=${msg.kpis.oee}% A=${msg.kpis.availability}% P=${msg.kpis.performance}% Q=${msg.kpis.quality}%`);\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 840, + "y": 500, + "wires": [ + [ + "578c92e75bf0f266" + ] + ] + }, + { + "id": "alert_process_function", + "type": "function", + "z": "cac3a4383120cb57", + "name": "Process Alert for DB", + "func": "// Process incoming alert\nif (msg.payload && msg.payload.action === 'alert') {\n const alert = msg.payload;\n\n // Format timestamp for MySQL DATETIME\n const timestamp = alert.timestamp ?\n new Date(alert.timestamp).toISOString().slice(0, 19).replace('T', ' ') :\n new Date().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": 400, + "y": 620, + "wires": [ + [ + "alert_insert_mysql" + ] + ] + }, + { + "id": "alert_insert_mysql", + "type": "mysql", + "z": "cac3a4383120cb57", + "mydb": "00d8ad2b0277f906", + "name": "Log Alert to DB", + "x": 650, + "y": 620, + "wires": [ + [ + "alert_insert_debug" + ] + ] + }, + { + "id": "alert_insert_debug", + "type": "debug", + "z": "cac3a4383120cb57", + "name": "Alert Logged", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "true", + "targetType": "full", + "statusVal": "", + "statusType": "auto", + "x": 850, + "y": 620, + "wires": [] + }, + { + "id": "alert_flow_comment", + "type": "comment", + "z": "cac3a4383120cb57", + "name": "Alert Logging: Template → Process → MySQL Insert → Debug", + "info": "Alerts from the UI are logged to the alerts_log table.\nTable structure:\n- id (auto-increment)\n- timestamp (when alert occurred)\n- alert_type (category)\n- description (optional notes)\n- created_at (when logged)", + "x": 400, + "y": 580, + "wires": [] + } +] \ No newline at end of file diff --git a/flows.json b/flows.json index 192aa45..c248bb1 100644 --- a/flows.json +++ b/flows.json @@ -448,7 +448,7 @@ "order": 0, "width": "25", "height": "25", - "format": "\n
\n \n\n
\n
\n
\n

Graphs

\n
\n\n
\n
\n

OEE – Last 24h

\n
\n
\n
\n

Availability – Last 7 days

\n
\n
\n
\n

Performance – Last 7 days

\n
\n
\n
\n

Quality – Last 7 days

\n
\n
\n
\n
\n
\n
\n\n\n", + "format": "\n
\n \n\n
\n
\n
\n

Graphs

\n
\n\n
\n
Time Range:
\n
\n \n \n \n \n \n
\n
\n\n\n
\n
\n

OEE – Last 24h

\n
\n
\n
\n

Availability – Last 7 days

\n
\n
\n
\n

Performance – Last 7 days

\n
\n
\n
\n

Quality – Last 7 days

\n
\n
\n
\n
\n
\n
\n\n\n\n\n\n", "storeOutMessages": true, "fwdInMessages": true, "resendOnRefresh": true, diff --git a/optimization_prompt.txt b/optimization_prompt.txt new file mode 100644 index 0000000..0476a22 --- /dev/null +++ b/optimization_prompt.txt @@ -0,0 +1,198 @@ +# Backend Optimization Requirements for KPI Tracking System + +## Context +This Node-RED application tracks manufacturing KPIs (OEE, Availability, Performance, Quality) for a production line. The system monitors machine cycles, work orders, and production metrics. These optimizations address data persistence, session management, and accurate downtime tracking. + +## Critical Issues to Address + +### Issue 1: Data Persistence (Option A - Database Storage) +**Problem:** Timing data and context variables are lost on crash or reboot, making it impossible to restore sessions. + +**Solution:** Implement database persistence for timing and context data +- Create a new table in the database called `session_state` or `global_context` +- Store the following timing data: + - `productionStartTime` + - `operatingTime` + - `lastUpdateTime` + - `trackingEnabled` status + - `cycleCount` + - `activeWorkOrder` details + - Any other critical global/flow context variables + +**Implementation Requirements:** +1. On system startup, check if there's existing session data in the database +2. Restore session state from database if found +3. Continuously sync critical timing variables to database (implement throttled writes to avoid database overload - e.g., update every 5-10 seconds or on state changes) +4. On clean shutdown or work order completion, ensure final state is written +5. On crash recovery, prompt user if they want to restore the previous session or start fresh + +**Database Schema Example:** +```sql +CREATE TABLE session_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT UNIQUE, + production_start_time INTEGER, + operating_time REAL, + last_update_time INTEGER, + tracking_enabled INTEGER, + cycle_count INTEGER, + active_work_order_id TEXT, + created_at INTEGER, + updated_at INTEGER, + is_active INTEGER DEFAULT 1 +); +``` + +### Issue 2: Cycle Count Capping +**Problem:** Need to prevent cycle counts from exceeding reasonable limits. + +**Solution:** Implement a cap at 100 cycles +- When cycle count reaches 100, either: + - Stop accepting new cycles and alert the operator + - OR automatically prompt for work order completion +- Add validation in the cycle counting logic +- Display warning in UI when approaching the cap (e.g., at 90+ cycles) + +### Issue 3: Hardware Irregularity Handling +**Problem:** Real hardware will be used and may have irregular behavior/timing. + +**Solution:** Design system to handle and track irregularities +- Log irregular cycle times (cycles that deviate significantly from average) +- Track unexpected state changes +- Store metadata about irregular events for later analysis +- This data will help diagnose: + - Unplanned production stops + - Slower than normal cycles + - Patterns in machine behavior + +**Implementation Requirements:** +1. Calculate rolling average cycle time +2. Flag cycles that are >20% slower/faster than average +3. Store flagged cycles with timestamps in database +4. Create a separate table for anomaly tracking: +```sql +CREATE TABLE cycle_anomalies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + work_order_id TEXT, + cycle_number INTEGER, + expected_time REAL, + actual_time REAL, + deviation_percent REAL, + timestamp INTEGER, + notes TEXT +); +``` + +### Issue 4: Intelligent Downtime Categorization +**Problem:** Not all stops should count as downtime (e.g., lunch breaks vs machine issues). + +**Solution:** Implement stop reason prompt and categorization +- When STOP button is clicked, immediately show a prompt asking "Why are you stopping?" +- Provide categorized options: + - **Planned Stops** (don't affect downtime): + - Lunch break + - Scheduled break + - Shift change + - Planned maintenance + - **Unplanned Stops** (count as downtime, affect Availability): + - Machine malfunction + - Material shortage + - Quality issue + - Operator error + - Other (with text input) + +**Implementation Requirements:** +1. Modify "Work Order buttons" function to intercept "stop" command +2. Display modal/prompt with categorized options +3. Store stop reason in database with timestamp +4. Only accumulate downtime for unplanned stops +5. Create stop_events table: +```sql +CREATE TABLE stop_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + work_order_id TEXT, + stop_time INTEGER, + resume_time INTEGER, + duration INTEGER, + reason_category TEXT, -- 'planned' or 'unplanned' + reason_detail TEXT, + affects_availability INTEGER, + notes TEXT +); +``` + +### Issue 5: Session Management and Pattern Tracking +**Problem:** Need to track production patterns and identify when sessions start/stop. + +**Solution:** Create new session each time RESUME is clicked +- Each START/RESUME creates a new production session +- Track session metadata for pattern analysis +- Add time tracking columns to identify: + - When production occurs + - Unexpected breaks + - Patterns (e.g., breaks at certain hours) + - Session duration trends + +**Implementation Requirements:** +1. Generate unique session_id on each START/RESUME +2. Store session metadata in new table: +```sql +CREATE TABLE production_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT UNIQUE, + work_order_id TEXT, + start_time INTEGER, + end_time INTEGER, + duration INTEGER, + cycles_completed INTEGER, + reason_for_start TEXT, -- 'initial_start', 'resume_after_planned', 'resume_after_unplanned' + reason_for_end TEXT, -- 'planned_stop', 'unplanned_stop', 'work_order_complete' + operating_time REAL, + downtime REAL, + created_at INTEGER +); +``` + +3. Add time tracking columns to work_orders table: +```sql +ALTER TABLE work_orders ADD COLUMN total_sessions INTEGER DEFAULT 0; +ALTER TABLE work_orders ADD COLUMN total_operating_time REAL DEFAULT 0; +ALTER TABLE work_orders ADD COLUMN total_downtime REAL DEFAULT 0; +ALTER TABLE work_orders ADD COLUMN avg_session_duration REAL DEFAULT 0; +``` + +4. On RESUME, log the previous session and start a new one +5. Track cumulative metrics across all sessions for the work order +6. Build analytics to identify: + - Peak production times + - Frequent break times + - Session duration patterns + - Correlation between session length and performance + +## Implementation Priority +Since these are backend fixes affecting ~1% of Availability KPI accuracy, and UI presentation is the immediate priority: + +**Phase 1 (Later):** Implement Issues 1, 4, 5 (data persistence, stop categorization, session tracking) +**Phase 2 (Later):** Implement Issues 2, 3 (cycle capping, irregularity tracking) +**Phase 3 (Later):** Build analytics dashboard for pattern analysis + +## Key Functions to Modify +1. **Machine cycles function** - Add database sync for timing data +2. **Work Order buttons function** - Add session management, stop reason prompt +3. **Database initialization** - Add new tables +4. **Startup routine** - Add session recovery logic +5. **Availability calculation** - Exclude planned stops from downtime calculation + +## Expected Outcome +- Data persists through crashes/reboots +- Accurate downtime vs planned stop tracking +- Session-based production history for pattern analysis +- Foundation for predictive maintenance and efficiency optimization +- Ability to identify and mitigate unexpected production issues + +## Notes +- All timing should use Unix timestamps (milliseconds) for consistency +- Implement database connection pooling for performance +- Add error handling for database operations +- Consider adding a "notes" field to most tables for operator comments +- Build admin interface to review historical sessions and patterns diff --git a/phase1_database_migration.sql b/phase1_database_migration.sql new file mode 100644 index 0000000..c2afed6 --- /dev/null +++ b/phase1_database_migration.sql @@ -0,0 +1,145 @@ +-- ============================================================================ +-- PHASE 1: Database Migration for Time Tracking & Alerts +-- Database: machine_data +-- Version: 1.0 +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- Table 1: KPI Snapshots (Time-series data for graphs) +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS kpi_snapshots ( + id INT AUTO_INCREMENT PRIMARY KEY, + timestamp BIGINT NOT NULL COMMENT 'Unix timestamp in milliseconds', + work_order_id VARCHAR(255), + oee_percent DECIMAL(5,2) DEFAULT 0, + availability_percent DECIMAL(5,2) DEFAULT 0, + performance_percent DECIMAL(5,2) DEFAULT 0, + quality_percent DECIMAL(5,2) DEFAULT 0, + cycle_count INT DEFAULT 0, + good_parts INT DEFAULT 0, + scrap_count INT DEFAULT 0, + operating_time DECIMAL(10,2) DEFAULT 0 COMMENT 'Accumulated seconds in state 1', + downtime DECIMAL(10,2) DEFAULT 0 COMMENT 'Accumulated seconds in state 0 while tracking', + machine_state INT DEFAULT 0 COMMENT 'Current machine state: 0 or 1', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_timestamp (timestamp), + INDEX idx_work_order (work_order_id), + INDEX idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KPI data snapshots for trending and graphs'; + +-- ---------------------------------------------------------------------------- +-- Table 2: Alert History (Manual + Automatic alerts) +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS alert_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + timestamp BIGINT NOT NULL COMMENT 'Unix timestamp in milliseconds', + alert_type VARCHAR(100) NOT NULL, + description TEXT, + severity VARCHAR(20) NOT NULL COMMENT 'info, warning, critical', + source VARCHAR(50) NOT NULL COMMENT 'manual or automatic', + work_order_id VARCHAR(255), + acknowledged BOOLEAN DEFAULT 0, + acknowledged_at BIGINT, + acknowledged_by VARCHAR(100), + auto_resolved BOOLEAN DEFAULT 0, + resolved_at BIGINT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_timestamp (timestamp), + INDEX idx_severity (severity), + INDEX idx_acknowledged (acknowledged), + INDEX idx_work_order (work_order_id), + INDEX idx_source (source) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Alert history for both manual and automatic alerts'; + +-- ---------------------------------------------------------------------------- +-- Table 3: Shift Definitions (Reference data) +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS shift_definitions ( + id INT AUTO_INCREMENT PRIMARY KEY, + shift_name VARCHAR(50) NOT NULL, + start_hour INT NOT NULL COMMENT '0-23', + start_minute INT NOT NULL DEFAULT 0, + end_hour INT NOT NULL COMMENT '0-23', + end_minute INT NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY unique_shift_name (shift_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Shift definitions for time range filtering'; + +-- Seed shift data +INSERT IGNORE INTO shift_definitions (id, shift_name, start_hour, start_minute, end_hour, end_minute) VALUES +(1, 'Day Shift', 6, 0, 15, 0), +(2, 'Evening Shift', 15, 0, 23, 0), +(3, 'Night Shift', 23, 0, 6, 0); + +-- ---------------------------------------------------------------------------- +-- Table 4: Session State (For crash recovery - Option 2) +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS session_state ( + id INT AUTO_INCREMENT PRIMARY KEY, + session_key VARCHAR(50) NOT NULL COMMENT 'Always "current_session" - single row table', + work_order_id VARCHAR(255), + cycle_count INT DEFAULT 0, + production_start_time BIGINT COMMENT 'Unix timestamp when tracking started', + operating_time DECIMAL(10,2) DEFAULT 0, + downtime DECIMAL(10,2) DEFAULT 0, + last_update_time BIGINT, + tracking_enabled BOOLEAN DEFAULT 0, + machine_state INT DEFAULT 0, + scrap_prompt_issued_for VARCHAR(255), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY unique_session (session_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Current session state for crash recovery'; + +-- Initialize the single session row +INSERT IGNORE INTO session_state (session_key, work_order_id, cycle_count, tracking_enabled) +VALUES ('current_session', NULL, 0, 0); + +-- ---------------------------------------------------------------------------- +-- Table 5: Add columns to existing work_orders table (if not present) +-- ---------------------------------------------------------------------------- +-- Check if scrap_count column exists, add if missing +SET @col_exists = 0; +SELECT COUNT(*) INTO @col_exists +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'machine_data' + AND TABLE_NAME = 'work_orders' + AND COLUMN_NAME = 'scrap_count'; + +SET @query = IF(@col_exists = 0, + 'ALTER TABLE work_orders ADD COLUMN scrap_count INT DEFAULT 0 AFTER good_parts', + 'SELECT "Column scrap_count already exists" AS Info'); +PREPARE stmt FROM @query; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Check if scrap_parts column exists (legacy), rename to scrap_count if needed +SET @col_exists_legacy = 0; +SELECT COUNT(*) INTO @col_exists_legacy +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'machine_data' + AND TABLE_NAME = 'work_orders' + AND COLUMN_NAME = 'scrap_parts'; + +SET @query = IF(@col_exists_legacy > 0, + 'ALTER TABLE work_orders CHANGE scrap_parts scrap_count INT DEFAULT 0', + 'SELECT "No legacy scrap_parts column to rename" AS Info'); +PREPARE stmt FROM @query; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------------------- +-- Verification Queries (Run these to confirm migration success) +-- ---------------------------------------------------------------------------- +-- SELECT COUNT(*) AS kpi_snapshots_count FROM kpi_snapshots; +-- SELECT COUNT(*) AS alert_history_count FROM alert_history; +-- SELECT * FROM shift_definitions; +-- SELECT * FROM session_state WHERE session_key = 'current_session'; +-- SHOW COLUMNS FROM work_orders; + +-- ============================================================================ +-- END OF MIGRATION +-- ============================================================================ diff --git a/phase1_machine_cycles_function.js b/phase1_machine_cycles_function.js new file mode 100644 index 0000000..7d444cc --- /dev/null +++ b/phase1_machine_cycles_function.js @@ -0,0 +1,200 @@ +// ============================================================================ +// UPDATED "Machine cycles" Function +// Location: flows.json, line 1150, node ID: 0d023d87a13bf56f +// Outputs: 3 (added third output for database state backup) +// ============================================================================ + +const current = Number(msg.payload) || 0; + +// ============================================================================ +// SECTION 1: TIME TRACKING (NEW - Must run BEFORE any early returns) +// ============================================================================ +const now = Date.now(); +const trackingEnabled = !!global.get("trackingEnabled"); +const lastUpdate = global.get("lastUpdateTime") || now; +const deltaMs = now - lastUpdate; +const deltaSeconds = deltaMs / 1000; + +// Sanity check: Protect against clock skew (negative delta or >5 min gap) +if (deltaSeconds < 0 || deltaSeconds > 300) { + node.warn(`[TIME] Abnormal delta: ${deltaSeconds.toFixed(2)}s - clock skew or restart detected, resetting timer`); + global.set("lastUpdateTime", now); + // Don't accumulate time, but continue processing +} else { + // Normal delta, accumulate time if tracking is enabled + if (trackingEnabled) { + // Initialize timing vars if they don't exist (handles restart scenario) + if (!global.get("productionStartTime")) { + node.warn("[TIME] Production start time missing, initializing now"); + global.set("productionStartTime", now); + global.set("operatingTime", 0); + global.set("downtime", 0); + } + + // Accumulate operating time when machine is running (state 1) + if (current === 1) { + const opTime = global.get("operatingTime") || 0; + global.set("operatingTime", opTime + deltaSeconds); + } + // Accumulate downtime when machine is stopped (state 0) while tracking + else if (current === 0) { + const downTime = global.get("downtime") || 0; + global.set("downtime", downTime + deltaSeconds); + } + } + global.set("lastUpdateTime", now); +} + +// ============================================================================ +// SECTION 2: EXISTING CYCLE COUNTING LOGIC (UNCHANGED) +// ============================================================================ +let zeroStreak = flow.get("zeroStreak") || 0; +zeroStreak = current === 0 ? zeroStreak + 1 : 0; +flow.set("zeroStreak", zeroStreak); + +const prev = flow.get("lastMachineState") ?? 0; +flow.set("lastMachineState", current); + +global.set("machineOnline", true); // force ONLINE for now + +let productionRunning = !!global.get("productionStarted"); +let stateChanged = false; + +if (current === 1 && !productionRunning) { + productionRunning = true; + stateChanged = true; +} else if (current === 0 && zeroStreak >= 2 && productionRunning) { + productionRunning = false; + stateChanged = true; +} + +global.set("productionStarted", productionRunning); + +const stateMsg = stateChanged + ? { + _mode: "production-state", + machineOnline: true, + productionStarted: productionRunning + } + : null; + +const activeOrder = global.get("activeWorkOrder"); +const cavities = Number(global.get("moldActive") || 0); +if (!activeOrder || !activeOrder.id || cavities <= 0) { + // We still want to pass along any state change even if there's no active WO. + return [null, stateMsg, null]; +} + +// Check if tracking is enabled (START button clicked) +if (!trackingEnabled) { + // Cycles are happening but we're not tracking them yet + return [null, stateMsg, null]; +} + +// only count rising edges (0 -> 1) for production totals +if (prev === 1 || current !== 1) { + return [null, stateMsg, null]; +} + +let cycles = Number(global.get("cycleCount") || 0) + 1; +global.set("cycleCount", cycles); + +// Calculate good parts: total produced minus accumulated scrap +const scrapTotal = Number(activeOrder.scrap) || 0; +const totalProduced = cycles * cavities; +const produced = totalProduced - scrapTotal; +const target = Number(activeOrder.target) || 0; +const progress = target > 0 ? Math.min(100, Math.round((produced / target) * 100)) : 0; + +activeOrder.good = produced; +activeOrder.progressPercent = progress; +activeOrder.lastUpdateIso = new Date().toISOString(); +global.set("activeWorkOrder", activeOrder); + +const promptIssued = global.get("scrapPromptIssuedFor") || null; + +if (!promptIssued && target > 0 && produced >= target) { + node.warn(`[DEBUG] TRIGGERING PROMPT!`); + global.set("scrapPromptIssuedFor", activeOrder.id); + msg._mode = "scrap-prompt"; + msg.scrapPrompt = { + id: activeOrder.id, + sku: activeOrder.sku || "", + target, + produced + }; + return [null, msg, null]; // bypass the DB update on this cycle +} + +// ============================================================================ +// SECTION 3: DATABASE UPDATE MESSAGE (EXISTING) +// ============================================================================ +const dbMsg = { + _mode: "cycle", + cycle: { + id: activeOrder.id, + sku: activeOrder.sku || "", + target, + good: produced, + scrap: Number(activeOrder.scrap) || 0, + cycleTime: Number(activeOrder.cycleTime || activeOrder.theoreticalCycleTime || 0), + progressPercent: progress, + lastUpdateIso: activeOrder.lastUpdateIso, + machineOnline: true, + productionStarted: productionRunning + }, + topic: ` + UPDATE work_orders + SET + good_parts = ${produced}, + progress_percent = ${progress}, + updated_at = NOW() + WHERE work_order_id = '${activeOrder.id}'; + ` +}; + +// ============================================================================ +// SECTION 4: STATE BACKUP TO DATABASE (NEW - Every 10th cycle to reduce load) +// ============================================================================ +let cyclesSinceBackup = flow.get("cyclesSinceBackup") || 0; +cyclesSinceBackup++; +flow.set("cyclesSinceBackup", cyclesSinceBackup); + +let stateBackupMsg = null; +if (cyclesSinceBackup >= 10) { + // Reset counter + flow.set("cyclesSinceBackup", 0); + + // Backup current state to database + const productionStartTime = global.get("productionStartTime") || null; + const operatingTime = global.get("operatingTime") || 0; + const downtime = global.get("downtime") || 0; + const lastUpdateTime = global.get("lastUpdateTime") || null; + const scrapPromptIssuedFor = global.get("scrapPromptIssuedFor") || null; + + stateBackupMsg = { + _mode: "state-backup", + topic: ` + UPDATE session_state + SET + work_order_id = '${activeOrder.id}', + cycle_count = ${cycles}, + production_start_time = ${productionStartTime}, + operating_time = ${operatingTime.toFixed(2)}, + downtime = ${downtime.toFixed(2)}, + last_update_time = ${lastUpdateTime}, + tracking_enabled = ${trackingEnabled ? 1 : 0}, + machine_state = ${current}, + scrap_prompt_issued_for = ${scrapPromptIssuedFor ? `'${scrapPromptIssuedFor}'` : 'NULL'} + WHERE session_key = 'current_session'; + ` + }; +} + +// ============================================================================ +// OUTPUTS: +// Output 1: Database update for work_orders table (existing) +// Output 2: State message to UI (existing) +// Output 3: State backup to session_state table (NEW) +// ============================================================================ +return [dbMsg, stateMsg, stateBackupMsg];