Macrostop and timeline segmentation

This commit is contained in:
mdares
2026-01-09 00:01:04 +00:00
parent 7790361a0a
commit d0ab254dd7
33 changed files with 1865 additions and 179 deletions

View File

@@ -130,7 +130,7 @@
"machines.empty": "No machines found for this org.",
"machines.status": "Status",
"machines.status.noHeartbeat": "No heartbeat",
"machines.status.ok": "OK",
"machines.status.ok": "Heartbeat",
"machines.status.offline": "OFFLINE",
"machines.status.unknown": "UNKNOWN",
"machines.lastSeen": "Last seen {time}",
@@ -140,6 +140,14 @@
"machine.detail.error.failed": "Failed to load machine",
"machine.detail.error.network": "Network error",
"machine.detail.back": "Back",
"machine.detail.workOrders.upload": "Upload Work Orders",
"machine.detail.workOrders.uploading": "Uploading...",
"machine.detail.workOrders.uploadParsing": "Parsing file...",
"machine.detail.workOrders.uploadHint": "CSV or XLSX with Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity.",
"machine.detail.workOrders.uploadSuccess": "Uploaded {count} work orders",
"machine.detail.workOrders.uploadError": "Upload failed",
"machine.detail.workOrders.uploadInvalid": "No valid work orders found",
"machine.detail.workOrders.uploadUnauthorized": "Not authorized to upload work orders",
"machine.detail.status.offline": "OFFLINE",
"machine.detail.status.unknown": "UNKNOWN",
"machine.detail.status.run": "RUN",
@@ -286,6 +294,7 @@
"settings.thresholds.performance": "Performance threshold",
"settings.thresholds.qualitySpike": "Quality spike delta",
"settings.thresholds.stoppage": "Stoppage multiplier",
"settings.thresholds.macroStoppage": "Macro stoppage multiplier",
"settings.alerts": "Alerts",
"settings.alertsSubtitle": "Choose which alerts to notify.",
"settings.alerts.oeeDrop": "OEE drop alerts",

View File

@@ -130,7 +130,7 @@
"machines.empty": "No se encontraron máquinas para esta organización.",
"machines.status": "Estado",
"machines.status.noHeartbeat": "Sin heartbeat",
"machines.status.ok": "OK",
"machines.status.ok": "Latido",
"machines.status.offline": "FUERA DE LÍNEA",
"machines.status.unknown": "DESCONOCIDO",
"machines.lastSeen": "Visto hace {time}",
@@ -140,6 +140,14 @@
"machine.detail.error.failed": "No se pudo cargar la máquina",
"machine.detail.error.network": "Error de red",
"machine.detail.back": "Volver",
"machine.detail.workOrders.upload": "Subir ordenes de trabajo",
"machine.detail.workOrders.uploading": "Subiendo...",
"machine.detail.workOrders.uploadParsing": "Leyendo archivo...",
"machine.detail.workOrders.uploadHint": "CSV o XLSX con Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity.",
"machine.detail.workOrders.uploadSuccess": "Se cargaron {count} ordenes de trabajo",
"machine.detail.workOrders.uploadError": "No se pudo cargar",
"machine.detail.workOrders.uploadInvalid": "No se encontraron ordenes de trabajo validas",
"machine.detail.workOrders.uploadUnauthorized": "No autorizado para cargar ordenes de trabajo",
"machine.detail.status.offline": "FUERA DE LÍNEA",
"machine.detail.status.unknown": "DESCONOCIDO",
"machine.detail.status.run": "EN MARCHA",
@@ -286,6 +294,7 @@
"settings.thresholds.performance": "Umbral de Performance",
"settings.thresholds.qualitySpike": "Delta de pico de calidad",
"settings.thresholds.stoppage": "Multiplicador de paro",
"settings.thresholds.macroStoppage": "Multiplicador de macroparo",
"settings.alerts": "Alertas",
"settings.alertsSubtitle": "Elige qué alertas notificar.",
"settings.alerts.oeeDrop": "Alertas por caída de OEE",

124
lib/mqtt.ts Normal file
View File

@@ -0,0 +1,124 @@
import "server-only";
import mqtt, { MqttClient } from "mqtt";
type SettingsUpdate = {
orgId: string;
version: number;
source?: string;
updatedAt?: string;
machineId?: string;
overridesUpdatedAt?: string;
};
type WorkOrdersUpdate = {
orgId: string;
machineId: string;
count?: number;
source?: string;
updatedAt?: string;
};
const MQTT_URL = process.env.MQTT_BROKER_URL || "";
const MQTT_USERNAME = process.env.MQTT_USERNAME;
const MQTT_PASSWORD = process.env.MQTT_PASSWORD;
const MQTT_CLIENT_ID = process.env.MQTT_CLIENT_ID;
const MQTT_TOPIC_PREFIX = (process.env.MQTT_TOPIC_PREFIX || "mis").replace(/\/+$/, "");
const MQTT_QOS_RAW = Number(process.env.MQTT_QOS ?? "2");
const MQTT_QOS = MQTT_QOS_RAW === 0 || MQTT_QOS_RAW === 1 || MQTT_QOS_RAW === 2 ? MQTT_QOS_RAW : 2;
let client: MqttClient | null = null;
let connecting: Promise<MqttClient> | null = null;
function buildSettingsTopic(orgId: string, machineId?: string) {
const base = `${MQTT_TOPIC_PREFIX}/org/${orgId}`;
if (machineId) return `${base}/machines/${machineId}/settings/updated`;
return `${base}/settings/updated`;
}
function buildWorkOrdersTopic(orgId: string, machineId: string) {
const base = `${MQTT_TOPIC_PREFIX}/org/${orgId}`;
return `${base}/machines/${machineId}/work_orders/updated`;
}
async function getClient() {
if (!MQTT_URL) return null;
if (client?.connected) return client;
if (connecting) return connecting;
connecting = new Promise((resolve, reject) => {
const next = mqtt.connect(MQTT_URL, {
clientId: MQTT_CLIENT_ID,
username: MQTT_USERNAME,
password: MQTT_PASSWORD,
clean: true,
reconnectPeriod: 5000,
});
next.once("connect", () => {
client = next;
connecting = null;
resolve(next);
});
next.once("error", (err) => {
next.end(true);
client = null;
connecting = null;
reject(err);
});
next.once("close", () => {
client = null;
});
});
return connecting;
}
export async function publishSettingsUpdate(update: SettingsUpdate) {
if (!MQTT_URL) return { ok: false, reason: "MQTT_NOT_CONFIGURED" as const };
const mqttClient = await getClient();
if (!mqttClient) return { ok: false, reason: "MQTT_NOT_CONFIGURED" as const };
const topic = buildSettingsTopic(update.orgId, update.machineId);
const payload = JSON.stringify({
type: update.machineId ? "machine_settings_updated" : "org_settings_updated",
orgId: update.orgId,
machineId: update.machineId,
version: update.version,
source: update.source || "control_tower",
updatedAt: update.updatedAt,
overridesUpdatedAt: update.overridesUpdatedAt,
});
return new Promise<{ ok: true }>((resolve, reject) => {
mqttClient.publish(topic, payload, { qos: MQTT_QOS }, (err) => {
if (err) return reject(err);
resolve({ ok: true });
});
});
}
export async function publishWorkOrdersUpdate(update: WorkOrdersUpdate) {
if (!MQTT_URL) return { ok: false, reason: "MQTT_NOT_CONFIGURED" as const };
const mqttClient = await getClient();
if (!mqttClient) return { ok: false, reason: "MQTT_NOT_CONFIGURED" as const };
const topic = buildWorkOrdersTopic(update.orgId, update.machineId);
const payload = JSON.stringify({
type: "work_orders_updated",
orgId: update.orgId,
machineId: update.machineId,
count: update.count ?? null,
source: update.source || "control_tower",
updatedAt: update.updatedAt,
});
return new Promise<{ ok: true }>((resolve, reject) => {
mqttClient.publish(topic, payload, { qos: MQTT_QOS }, (err) => {
if (err) return reject(err);
resolve({ ok: true });
});
});
}

View File

@@ -54,6 +54,7 @@ export function buildSettingsPayload(settings: any, shifts: any[]) {
},
thresholds: {
stoppageMultiplier: settings.stoppageMultiplier,
macroStoppageMultiplier: settings.macroStoppageMultiplier,
oeeAlertThresholdPct: settings.oeeAlertThresholdPct,
performanceThresholdPct: settings.performanceThresholdPct,
qualitySpikeDeltaPct: settings.qualitySpikeDeltaPct,
@@ -159,6 +160,14 @@ export function validateThresholds(thresholds: any) {
}
}
const macroStoppage = thresholds.macroStoppageMultiplier;
if (macroStoppage != null) {
const v = Number(macroStoppage);
if (!Number.isFinite(v) || v < 1.1 || v > 20.0) {
return { ok: false, error: "macroStoppageMultiplier must be 1.1-20.0" };
}
}
const oee = thresholds.oeeAlertThresholdPct;
if (oee != null) {
const v = Number(oee);