Downtime catalog

This commit is contained in:
Marcelo
2026-05-06 00:36:48 +00:00
parent 0491237bad
commit bfc1673d89
42 changed files with 8035 additions and 1093 deletions

View File

@@ -34,3 +34,10 @@
## Notes / parked items
- Prisma drift on (orgId,machineId,seq) unique indexes — pre-existing, not related to this work. Address as separate housekeeping task.
- Node-RED incidentKey rotation behavior verified: 10 distinct keys per real stoppage = correct.
## Path A — dead state cleanup (post Round 1)
- [x] Removed `not_started` and `data-loss` branches from classifier
- [x] Removed `RecapStoppedReason` and `RecapDataLossReason` types
- [x] Simplified `RecapStateContext` to empty struct (kept for future use)
- [x] Updated UI rendering: 5 states only (offline/stopped/mold-change/idle/running)
- [x] i18n: removed dead keys

View File

@@ -13,7 +13,6 @@ function statusLabel(status: RecapMachineStatus, t: (key: string) => string) {
if (status === "running") return t("recap.status.running");
if (status === "mold-change") return t("recap.status.moldChange");
if (status === "stopped") return t("recap.status.stopped");
if (status === "data-loss") return t("recap.status.dataLoss");
if (status === "idle") return t("recap.status.idle");
return t("recap.status.offline");
}
@@ -112,7 +111,7 @@ export default function RecapGridClient({ initialData }: Props) {
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="all">{t("recap.filter.allStatuses")}</option>
{(["running", "mold-change", "stopped", "data-loss", "idle", "offline"] as const).map((status) => (
{(["running", "mold-change", "stopped", "idle", "offline"] as const).map((status) => (
<option key={status} value={status}>
{statusLabel(status, t)}
</option>

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertsConfig } from "@/components/settings/AlertsConfig";
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
import { ReasonCatalogConfig } from "@/components/settings/ReasonCatalogConfig";
import { useI18n } from "@/lib/i18n/useI18n";
import { SHIFT_OVERRIDE_DAYS, type ShiftOverrideDay } from "@/lib/settings";
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
@@ -122,6 +123,7 @@ const SETTINGS_TABS = [
{ id: "thresholds", labelKey: "settings.tabs.thresholds" },
{ id: "alerts", labelKey: "settings.tabs.alerts" },
{ id: "financial", labelKey: "settings.tabs.financial" },
{ id: "reasonCatalog", labelKey: "settings.tabs.reasonCatalog" },
{ id: "team", labelKey: "settings.tabs.team" },
] as const;
@@ -239,7 +241,6 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
const thresholds = asRecord(record.thresholds) ?? {};
const alerts = asRecord(record.alerts) ?? {};
const defaults = asRecord(record.defaults) ?? {};
return {
orgId: String(record.orgId ?? ""),
version: Number(record.version ?? 0),
@@ -1276,6 +1277,18 @@ export default function SettingsPage() {
</div>
)}
{activeTab === "reasonCatalog" && (
<div className="space-y-6">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-sm font-semibold text-white">{t("settings.reasonCatalog.title")}</div>
<p className="mt-1 text-xs text-zinc-400">{t("settings.reasonCatalog.subtitle")}</p>
<div className="mt-4">
<ReasonCatalogConfig disabled={saving} />
</div>
</div>
</div>
)}
{activeTab === "team" && (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">

View File

@@ -5,13 +5,14 @@ import { z } from "zod";
import { evaluateAlertsForEvent } from "@/lib/alerts/engine";
import { toJsonValue } from "@/lib/prismaJson";
import {
detailEffectiveReasonCode,
findCatalogReason,
loadFallbackReasonCatalog,
normalizeReasonCatalog,
findCatalogReasonByReasonCode,
toReasonCode,
type ReasonCatalog,
type ReasonCatalogKind,
} from "@/lib/reasonCatalog";
import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb";
const normalizeType = (t: unknown) =>
String(t ?? "")
@@ -169,7 +170,7 @@ function findCatalogReasonFlexible(
categoryLabel: category.label,
detailId: detail.id,
detailLabel: detail.label,
reasonCode: toReasonCode(category.id, detail.id),
reasonCode: detailEffectiveReasonCode(category, detail),
reasonLabel: `${category.label} > ${detail.label}`,
};
}
@@ -177,12 +178,6 @@ function findCatalogReasonFlexible(
return null;
}
function getCatalogFromDefaults(defaultsJson: unknown) {
const defaults = asRecord(defaultsJson);
if (!defaults) return null;
return normalizeReasonCatalog(defaults.reasonCatalog ?? defaults.reasonCatalogData);
}
function resolveReason(
raw: Record<string, unknown>,
kind: ReasonCatalogKind,
@@ -193,7 +188,13 @@ function resolveReason(
const reasonTextPath = parseReasonTextPath(raw.reasonText);
const categoryIdRaw = clampText(raw.categoryId ?? reasonPath.category ?? reasonTextPath.category, 64);
const detailIdRaw = clampText(raw.detailId ?? reasonPath.detail ?? reasonTextPath.detail, 64);
const fromCatalog = findCatalogReasonFlexible(catalog, kind, categoryIdRaw, detailIdRaw);
const fromCatalogFlexible = findCatalogReasonFlexible(catalog, kind, categoryIdRaw, detailIdRaw);
const rawReasonCodeEarly = clampText(raw.reasonCode, 64);
const fromCatalogByCode =
!fromCatalogFlexible && rawReasonCodeEarly
? findCatalogReasonByReasonCode(catalog, kind, rawReasonCodeEarly)
: null;
const fromCatalog = fromCatalogFlexible ?? fromCatalogByCode;
const categoryLabelRaw = clampText(raw.categoryLabel ?? reasonPath.category ?? reasonTextPath.category, 120);
const detailLabelRaw = clampText(raw.detailLabel ?? reasonPath.detail ?? reasonTextPath.detail, 120);
@@ -282,11 +283,13 @@ export async function POST(req: Request) {
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: machine.orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true, version: true },
});
const fallbackCatalog = await loadFallbackReasonCatalog();
const settingsCatalog = getCatalogFromDefaults(orgSettings?.defaultsJson);
const reasonCatalog = settingsCatalog ?? fallbackCatalog;
const reasonCatalog = await effectiveReasonCatalogForOrg(
machine.orgId,
orgSettings?.defaultsJson ?? null,
orgSettings?.version ?? 1
);
const defaultMicroMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
const defaultMacroMultiplier = Math.max(

View File

@@ -258,14 +258,65 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach
})
: allowed;
const seen = new Set<string>();
const deduped = filtered.filter((event) => {
const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
// Build a lookup of raw event metadata (incidentKey, status, is_auto_ack)
// by event id, so we can collapse the normalized events down to one
// "active" + one "resolved" per incident.
const rawMetaById = new Map<string, { incidentKey: string | null; status: string | null; isAutoAck: boolean }>();
for (const row of rawEvents) {
let parsed: unknown = row.data;
if (typeof parsed === "string") {
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
}
const data: Record<string, unknown> =
parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
const isAutoAck =
data.is_auto_ack === true ||
data.isAutoAck === true ||
data.is_auto_ack === "true" ||
data.isAutoAck === "true";
const incidentKey =
typeof data.incidentKey === "string" ? data.incidentKey :
typeof data.incident_key === "string" ? data.incident_key : null;
const status = typeof data.status === "string" ? data.status.toLowerCase() : null;
rawMetaById.set(row.id, { incidentKey, status, isAutoAck });
}
// Drop pure auto-ack refresh pings.
const filteredNoAutoAck = filtered.filter((event) => {
const meta = rawMetaById.get(event.id);
return !meta?.isAutoAck;
});
// Group by incidentKey: keep at most one "active" (oldest = original happen)
// and one "resolved" (newest = actual end) per incident. Events without
// incidentKey pass through unchanged (mold-change, edge-case events).
const byGroup = new Map<string, typeof filteredNoAutoAck[number]>();
const passthrough: typeof filteredNoAutoAck = [];
for (const event of filteredNoAutoAck) {
const meta = rawMetaById.get(event.id);
const groupId = meta?.incidentKey;
if (!groupId) {
passthrough.push(event);
continue;
}
const statusKey = meta.status === "resolved" ? "resolved" : "active";
const key = `${groupId}:${statusKey}`;
const existing = byGroup.get(key);
if (!existing) {
byGroup.set(key, event);
continue;
}
const existingTs = existing.ts ? existing.ts.getTime() : 0;
const eventTs = event.ts ? event.ts.getTime() : 0;
const pickNewest = statusKey === "resolved";
const shouldReplace = pickNewest ? eventTs > existingTs : eventTs < existingTs;
if (shouldReplace) byGroup.set(key, event);
}
const deduped = [...passthrough, ...byGroup.values()];
deduped.sort((a, b) => {
const at = a.ts ? a.ts.getTime() : 0;
const bt = b.ts ? b.ts.getTime() : 0;

View File

@@ -0,0 +1,492 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { normalizeEvent } from "@/lib/events/normalizeEvent";
import { invalidateMachineAuth } from "@/lib/machineAuthCache";
const machineIdSchema = z.string().uuid();
const ALLOWED_EVENT_TYPES = new Set([
"slow-cycle",
"microstop",
"macrostop",
"offline",
"error",
"oee-drop",
"quality-spike",
"performance-degradation",
"predictive-oee-decline",
"alert-delivery-failed",
]);
function canManageMachines(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function parseNumber(value: string | null, fallback: number) {
if (value == null || value === "") return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
type MachineFkReference = {
tableName: string;
columnName: string;
deleteRule: string;
};
function quoteIdent(identifier: string) {
return `"${identifier.replace(/"/g, "\"\"")}"`;
}
async function cleanupMachineReferences(machineId: string) {
const refs = await prisma.$queryRaw<MachineFkReference[]>`
SELECT DISTINCT
tc.table_name AS "tableName",
kcu.column_name AS "columnName",
rc.delete_rule AS "deleteRule"
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.referential_constraints rc
ON tc.constraint_name = rc.constraint_name
AND tc.table_schema = rc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
AND rc.unique_constraint_schema = 'public'
AND rc.unique_constraint_name IN (
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = 'Machine'
AND constraint_type IN ('PRIMARY KEY', 'UNIQUE')
)
`;
for (const ref of refs) {
if (ref.tableName === "Machine") continue;
const table = quoteIdent(ref.tableName);
const column = quoteIdent(ref.columnName);
const rule = String(ref.deleteRule ?? "").toUpperCase();
if (rule === "CASCADE") continue;
if (rule === "SET NULL") {
await prisma.$executeRawUnsafe(`UPDATE ${table} SET ${column} = NULL WHERE ${column} = $1`, machineId);
continue;
}
await prisma.$executeRawUnsafe(`DELETE FROM ${table} WHERE ${column} = $1`, machineId);
}
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ machineId: string }> }) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const { machineId } = await params;
if (!machineIdSchema.safeParse(machineId).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const url = new URL(req.url);
const windowSec = Math.max(0, parseNumber(url.searchParams.get("windowSec"), 3600));
const eventsWindowSec = Math.max(0, parseNumber(url.searchParams.get("eventsWindowSec"), 21600));
const eventsMode = url.searchParams.get("events") ?? "critical";
const eventsOnly = url.searchParams.get("eventsOnly") === "1";
const [machineRow, orgSettings, machineSettings] = await Promise.all([
prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: {
id: true,
name: true,
code: true,
location: true,
createdAt: true,
updatedAt: true,
heartbeats: {
orderBy: { tsServer: "desc" },
take: 1,
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
},
kpiSnapshots: {
orderBy: { ts: "desc" },
take: 1,
select: {
ts: true,
oee: true,
availability: true,
performance: true,
quality: true,
workOrderId: true,
sku: true,
good: true,
scrap: true,
target: true,
cycleTime: true,
},
},
},
}),
prisma.orgSettings.findUnique({
where: { orgId: session.orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
}),
prisma.machineSettings.findUnique({
where: { machineId },
select: { overridesJson: true },
}),
]);
if (!machineRow) {
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
}
const overrides = isPlainObject(machineSettings?.overridesJson) ? machineSettings?.overridesJson : {};
const thresholdsOverride = isPlainObject(overrides.thresholds) ? overrides.thresholds : {};
const stoppageMultiplier =
typeof thresholdsOverride.stoppageMultiplier === "number"
? thresholdsOverride.stoppageMultiplier
: Number(orgSettings?.stoppageMultiplier ?? 1.5);
const macroStoppageMultiplier =
typeof thresholdsOverride.macroStoppageMultiplier === "number"
? thresholdsOverride.macroStoppageMultiplier
: Number(orgSettings?.macroStoppageMultiplier ?? 5);
const thresholds = {
stoppageMultiplier,
macroStoppageMultiplier,
};
const machine = {
...machineRow,
effectiveCycleTime: null,
latestHeartbeat: machineRow.heartbeats[0] ?? null,
latestKpi: machineRow.kpiSnapshots[0] ?? null,
heartbeats: undefined,
kpiSnapshots: undefined,
};
const cycles = eventsOnly
? []
: await prisma.machineCycle.findMany({
where: {
orgId: session.orgId,
machineId,
ts: { gte: new Date(Date.now() - windowSec * 1000) },
},
orderBy: { ts: "asc" },
select: {
ts: true,
tsServer: true,
cycleCount: true,
actualCycleTime: true,
theoreticalCycleTime: true,
workOrderId: true,
sku: true,
},
});
const cyclesOut = cycles.map((row) => {
const ts = row.tsServer ?? row.ts;
return {
ts,
t: ts.getTime(),
cycleCount: row.cycleCount ?? null,
actual: row.actualCycleTime,
ideal: row.theoreticalCycleTime ?? null,
workOrderId: row.workOrderId ?? null,
sku: row.sku ?? null,
};
});
const eventWindowStart = new Date(Date.now() - eventsWindowSec * 1000);
const criticalSeverities = ["critical", "error", "high"];
const eventWhereBase = {
orgId: session.orgId,
machineId,
ts: { gte: eventWindowStart },
};
const [rawEvents, eventsCountAll] = await Promise.all([
prisma.machineEvent.findMany({
where: eventWhereBase,
orderBy: { ts: "desc" },
take: eventsOnly ? 300 : 120,
select: {
id: true,
ts: true,
topic: true,
eventType: true,
severity: true,
title: true,
description: true,
requiresAck: true,
data: true,
workOrderId: true,
},
}),
prisma.machineEvent.count({ where: eventWhereBase }),
]);
const normalized = rawEvents.map((row) =>
normalizeEvent(row, { microMultiplier: stoppageMultiplier, macroMultiplier: macroStoppageMultiplier })
);
const allowed = normalized.filter((event) => ALLOWED_EVENT_TYPES.has(event.eventType));
const criticalEventTypes = new Set(["macrostop", "microstop", "slow-cycle", "offline", "error"]);
const filtered =
eventsMode === "critical"
? allowed.filter((event) => {
const severity = String(event.severity ?? "").toLowerCase();
return (
criticalEventTypes.has(event.eventType) ||
event.requiresAck === true ||
criticalSeverities.includes(severity)
);
})
: allowed;
const seen = new Set<string>();
const deduped = filtered.filter((event) => {
const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
deduped.sort((a, b) => {
const at = a.ts ? a.ts.getTime() : 0;
const bt = b.ts ? b.ts.getTime() : 0;
return bt - at;
});
return NextResponse.json({
ok: true,
machine,
events: deduped,
eventsCountAll,
cycles: cyclesOut,
thresholds,
activeStoppage: null,
});
}
export async function DELETE(_req: Request, { params }: { params: Promise<{ machineId: string }> }) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const { machineId } = await params;
if (!machineIdSchema.safeParse(machineId).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const membership = await prisma.orgUser.findUnique({
where: {
orgId_userId: {
orgId: session.orgId,
userId: session.userId,
},
},
select: { role: true },
});
if (!canManageMachines(membership?.role)) {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
if (attempt === 0) {
// Revoke credentials first in a committed write so ingest auth fails immediately.
const revoked = await prisma.machine.updateMany({
where: {
id: machineId,
orgId: session.orgId,
},
data: {
apiKey: null,
},
});
if (revoked.count === 0) {
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
}
invalidateMachineAuth(machineId);
}
// Avoid long interactive transactions on very large history tables (P2028 timeout).
// This sequence is idempotent and safe to retry because apiKey is revoked first.
await prisma.machineCycle.deleteMany({
where: {
machineId,
},
});
await prisma.machineHeartbeat.deleteMany({
where: {
machineId,
},
});
await prisma.machineKpiSnapshot.deleteMany({
where: {
machineId,
},
});
await prisma.machineEvent.deleteMany({
where: {
machineId,
},
});
await prisma.machineWorkOrder.deleteMany({
where: {
machineId,
},
});
await prisma.machineSettings.deleteMany({
where: {
machineId,
},
});
await prisma.settingsAudit.deleteMany({
where: {
machineId,
},
});
await prisma.alertNotification.deleteMany({
where: {
machineId,
},
});
await prisma.machineFinancialOverride.deleteMany({
where: {
machineId,
},
});
await prisma.reasonEntry.deleteMany({
where: {
machineId,
},
});
await prisma.downtimeAction.updateMany({
where: {
machineId,
},
data: {
machineId: null,
},
});
const result = await prisma.machine.deleteMany({
where: {
id: machineId,
orgId: session.orgId,
},
});
if (result.count === 0) {
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
}
invalidateMachineAuth(machineId);
return NextResponse.json({ ok: true });
} catch (err: unknown) {
const code = err instanceof Prisma.PrismaClientKnownRequestError ? err.code : undefined;
const message = err instanceof Error ? err.message : String(err);
console.error("DELETE /api/machines/[machineId] failed", {
machineId,
orgId: session.orgId,
attempt,
code,
message,
});
if (code === "P2003") {
if (attempt < 2) {
try {
await cleanupMachineReferences(machineId);
} catch (cleanupErr: unknown) {
const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
console.error("DELETE /api/machines/[machineId] cleanup failed", {
machineId,
orgId: session.orgId,
attempt,
cleanupMessage,
});
}
await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 150));
continue;
}
return NextResponse.json(
{
ok: false,
error: "Machine has dependent records and could not be removed",
code,
},
{ status: 409 }
);
}
if (code === "P2022") {
return NextResponse.json(
{
ok: false,
error: "Server schema is out of date for machine delete",
code,
},
{ status: 500 }
);
}
if (code === "P2028") {
return NextResponse.json(
{
ok: false,
error: "Delete timed out while removing machine history",
code,
},
{ status: 503 }
);
}
if (code) {
return NextResponse.json(
{
ok: false,
error: "Delete failed due to database error",
code,
},
{ status: 500 }
);
}
return NextResponse.json({ ok: false, error: "Delete failed" }, { status: 500 });
}
}
return NextResponse.json({ ok: false, error: "Delete failed", code: "DELETE_RETRY_EXHAUSTED" }, { status: 500 });
}

View File

@@ -1,12 +1,8 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import {
flattenReasonCatalog,
loadFallbackReasonCatalog,
normalizeReasonCatalog,
type ReasonCatalogKind,
} from "@/lib/reasonCatalog";
import { flattenReasonCatalog, normalizeReasonCatalog, type ReasonCatalogKind } from "@/lib/reasonCatalog";
import { effectiveReasonCatalogForOrg, loadReasonCatalogFromDb } from "@/lib/reasonCatalogDb";
function asKind(value: string | null): ReasonCatalogKind | null {
const kind = String(value ?? "").toLowerCase();
@@ -26,20 +22,30 @@ export async function GET(req: Request) {
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: session.orgId },
select: { defaultsJson: true },
select: { defaultsJson: true, version: true },
});
const defaultsJson =
orgSettings?.defaultsJson && typeof orgSettings.defaultsJson === "object" && !Array.isArray(orgSettings.defaultsJson)
? (orgSettings.defaultsJson as Record<string, unknown>)
const version = orgSettings?.version ?? 1;
const defaultsJson = orgSettings?.defaultsJson ?? null;
const fromDb = await loadReasonCatalogFromDb(session.orgId, version);
const catalog = await effectiveReasonCatalogForOrg(session.orgId, defaultsJson, version);
const defs =
defaultsJson && typeof defaultsJson === "object" && !Array.isArray(defaultsJson)
? (defaultsJson as Record<string, unknown>)
: {};
const settingsCatalog = normalizeReasonCatalog(defaultsJson.reasonCatalog ?? defaultsJson.reasonCatalogData);
const fallbackCatalog = await loadFallbackReasonCatalog();
const catalog = settingsCatalog ?? fallbackCatalog;
const rows = flattenReasonCatalog(catalog, kind);
const legacyJson = normalizeReasonCatalog(defs.reasonCatalog ?? defs.reasonCatalogData);
let source: "db" | "legacy" | "fallback";
if (fromDb) source = "db";
else if (legacyJson) source = "legacy";
else source = "fallback";
const rows = flattenReasonCatalog(catalog, kind, { activeOnly: true });
return NextResponse.json({
ok: true,
source: settingsCatalog ? "settings" : "fallback",
source,
kind,
catalogVersion: catalog.version,
categories: catalog[kind],

View File

@@ -17,7 +17,7 @@ import {
validateShiftOverrides,
validateThresholds,
} from "@/lib/settings";
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
@@ -46,21 +46,18 @@ function pickAllowedOverrides(raw: unknown) {
return out;
}
function withReasonCatalog<T extends Record<string, unknown>>(payload: T, fallbackCatalog: ReasonCatalog) {
const base = (isPlainObject(payload) ? { ...payload } : {}) as T;
const defaults = isPlainObject(base.defaults) ? base.defaults : {};
const parsed =
normalizeReasonCatalog(base.reasonCatalog) ??
normalizeReasonCatalog(base.reasonCatalogData) ??
normalizeReasonCatalog(defaults.reasonCatalog) ??
normalizeReasonCatalog(defaults.reasonCatalogData) ??
fallbackCatalog;
async function attachReasonCatalog(
orgId: string,
defaultsJson: unknown,
settingsVersion: number,
base: Record<string, unknown>
): Promise<Record<string, unknown>> {
const catalog = await effectiveReasonCatalogForOrg(orgId, defaultsJson, settingsVersion);
return {
...base,
reasonCatalog: parsed,
reasonCatalogData: parsed,
reasonCatalogVersion: Number(parsed.version || 1),
reasonCatalog: catalog,
reasonCatalogData: catalog,
reasonCatalogVersion: Number(catalog.version || 1),
};
}
@@ -164,9 +161,7 @@ export async function GET(
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
orgId = machine.orgId;
}
const fallbackCatalog = await loadFallbackReasonCatalog();
const { settings, overrides } = await prisma.$transaction(async (tx) => {
const { orgRow, shifts, rawOverrides } = await prisma.$transaction(async (tx) => {
const orgSettings = await ensureOrgSettings(tx, orgId as string, userId);
if (!orgSettings?.settings) throw new Error("SETTINGS_NOT_FOUND");
@@ -175,25 +170,24 @@ export async function GET(
select: { overridesJson: true },
});
const orgPayload = withReasonCatalog(
buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []),
fallbackCatalog
);
const rawOverrides = pickAllowedOverrides(machineSettings?.overridesJson ?? {});
const effective = withReasonCatalog(
deepMerge(orgPayload, rawOverrides) as Record<string, unknown>,
fallbackCatalog
);
return { settings: { org: orgPayload, effective }, overrides: rawOverrides };
return {
orgRow: orgSettings.settings,
shifts: orgSettings.shifts ?? [],
rawOverrides,
};
});
const baseOrg = buildSettingsPayload(orgRow, shifts) as Record<string, unknown>;
const orgPayload = await attachReasonCatalog(orgId as string, orgRow.defaultsJson, orgRow.version, baseOrg);
const effective = deepMerge(orgPayload, rawOverrides) as Record<string, unknown>;
return NextResponse.json({
ok: true,
machineId,
orgSettings: settings.org,
effectiveSettings: settings.effective,
overrides,
orgSettings: orgPayload,
effectiveSettings: effective,
overrides: rawOverrides,
});
}
@@ -413,25 +407,23 @@ export async function PUT(
},
});
const fallbackCatalog = await loadFallbackReasonCatalog();
const orgPayload = withReasonCatalog(
buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []),
fallbackCatalog
);
const overrides = pickAllowedOverrides(saved.overridesJson ?? {});
const effective = withReasonCatalog(
deepMerge(orgPayload, overrides) as Record<string, unknown>,
fallbackCatalog
);
return {
orgPayload,
overrides,
effective,
orgSettingsRow: orgSettings.settings,
shifts: orgSettings.shifts ?? [],
overrides: pickAllowedOverrides(saved.overridesJson ?? {}),
overridesUpdatedAt: saved.updatedAt,
};
});
const baseOrg = buildSettingsPayload(result.orgSettingsRow, result.shifts) as Record<string, unknown>;
const orgPayload = await attachReasonCatalog(
session.orgId,
result.orgSettingsRow.defaultsJson,
result.orgSettingsRow.version,
baseOrg
);
const effective = deepMerge(orgPayload, result.overrides) as Record<string, unknown>;
const overridesUpdatedAt =
result.overridesUpdatedAt && result.overridesUpdatedAt instanceof Date
? result.overridesUpdatedAt.toISOString()
@@ -440,7 +432,7 @@ export async function PUT(
await publishSettingsUpdate({
orgId: session.orgId,
machineId,
version: Number(result.orgPayload.version ?? 0),
version: Number(result.orgSettingsRow.version ?? 0),
source,
overridesUpdatedAt,
});
@@ -451,8 +443,8 @@ export async function PUT(
return NextResponse.json({
ok: true,
machineId,
orgSettings: result.orgPayload,
effectiveSettings: result.effective,
orgSettings: orgPayload,
effectiveSettings: effective,
overrides: result.overrides,
});
}

View File

@@ -0,0 +1,106 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
import { bumpOrgSettingsVersion, composeReasonCode } from "@/lib/reasonCatalogDb";
import { z } from "zod";
const PREFIX_RE = /^[A-Za-z][A-Za-z0-9-]*$/;
const patchSchema = z.object({
name: z.string().trim().min(1).max(200).optional(),
codePrefix: z
.string()
.trim()
.min(1)
.max(32)
.transform((s) => s.toUpperCase())
.optional(),
sortOrder: z.number().int().optional(),
active: z.boolean().optional(),
});
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ categoryId: string }> }
) {
const auth = await requireOrgAdminSession();
if (!auth.ok) return auth.response;
const { categoryId } = await params;
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
}
const existing = await prisma.reasonCatalogCategory.findFirst({
where: { id: categoryId, orgId: auth.session.orgId },
include: { items: true },
});
if (!existing) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
const nextPrefix = parsed.data.codePrefix ?? existing.codePrefix;
if (parsed.data.codePrefix !== undefined && !PREFIX_RE.test(nextPrefix)) {
return NextResponse.json(
{ ok: false, error: "codePrefix must start with a letter; letters, digits, hyphen allowed." },
{ status: 400 }
);
}
if (parsed.data.codePrefix !== undefined && parsed.data.codePrefix !== existing.codePrefix) {
const proposed = new Set<string>();
for (const it of existing.items) {
proposed.add(composeReasonCode(nextPrefix, it.codeSuffix));
}
const codes = [...proposed];
const conflicts = await prisma.reasonCatalogItem.findMany({
where: {
orgId: auth.session.orgId,
reasonCode: { in: codes },
NOT: { categoryId: existing.id },
},
select: { reasonCode: true },
});
if (conflicts.length) {
return NextResponse.json(
{ ok: false, error: "Prefix change would duplicate codes", conflicts: conflicts.map((c) => c.reasonCode) },
{ status: 409 }
);
}
}
try {
await prisma.$transaction(async (tx) => {
await tx.reasonCatalogCategory.update({
where: { id: categoryId },
data: {
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
...(parsed.data.codePrefix !== undefined ? { codePrefix: parsed.data.codePrefix } : {}),
...(parsed.data.sortOrder !== undefined ? { sortOrder: parsed.data.sortOrder } : {}),
...(parsed.data.active !== undefined ? { active: parsed.data.active } : {}),
},
});
if (parsed.data.codePrefix !== undefined && parsed.data.codePrefix !== existing.codePrefix) {
for (const it of existing.items) {
const reasonCode = composeReasonCode(nextPrefix, it.codeSuffix);
await tx.reasonCatalogItem.update({
where: { id: it.id },
data: { reasonCode },
});
}
}
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
});
const updated = await prisma.reasonCatalogCategory.findUnique({
where: { id: categoryId },
include: { items: { orderBy: [{ sortOrder: "asc" }, { reasonCode: "asc" }] } },
});
return NextResponse.json({ ok: true, category: updated });
} catch (e) {
console.error("[reason-catalog category PATCH]", e);
return NextResponse.json({ ok: false, error: "Update failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
import { bumpOrgSettingsVersion } from "@/lib/reasonCatalogDb";
import { z } from "zod";
const PREFIX_RE = /^[A-Za-z][A-Za-z0-9-]*$/;
const bodySchema = z.object({
kind: z.enum(["downtime", "scrap"]),
name: z.string().trim().min(1).max(200),
codePrefix: z
.string()
.trim()
.min(1)
.max(32)
.transform((s) => s.toUpperCase()),
});
export async function POST(req: Request) {
const auth = await requireOrgAdminSession();
if (!auth.ok) return auth.response;
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
}
const { kind, name, codePrefix } = parsed.data;
if (!PREFIX_RE.test(codePrefix)) {
return NextResponse.json(
{ ok: false, error: "codePrefix must start with a letter; letters, digits, hyphen allowed." },
{ status: 400 }
);
}
try {
const row = await prisma.$transaction(async (tx) => {
const last = await tx.reasonCatalogCategory.findFirst({
where: { orgId: auth.session.orgId, kind },
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
const sortOrder = (last?.sortOrder ?? -1) + 1;
const created = await tx.reasonCatalogCategory.create({
data: {
orgId: auth.session.orgId,
kind,
name,
codePrefix,
sortOrder,
active: true,
},
});
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
return created;
});
return NextResponse.json({ ok: true, category: row });
} catch (e) {
console.error("[reason-catalog categories POST]", e);
return NextResponse.json({ ok: false, error: "Create failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
import { bumpOrgSettingsVersion, composeReasonCode, isNumericSuffix } from "@/lib/reasonCatalogDb";
import { z } from "zod";
const patchSchema = z.object({
name: z.string().trim().min(1).max(500).optional(),
codeSuffix: z.string().trim().min(1).max(32).optional(),
sortOrder: z.number().int().optional(),
active: z.boolean().optional(),
});
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ itemId: string }> }
) {
const auth = await requireOrgAdminSession();
if (!auth.ok) return auth.response;
const { itemId } = await params;
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
}
const existing = await prisma.reasonCatalogItem.findFirst({
where: { id: itemId, orgId: auth.session.orgId },
include: { category: true },
});
if (!existing) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
const nextSuffix = parsed.data.codeSuffix ?? existing.codeSuffix;
if (parsed.data.codeSuffix !== undefined && !isNumericSuffix(nextSuffix)) {
return NextResponse.json({ ok: false, error: "codeSuffix must be digits only" }, { status: 400 });
}
const reasonCode = composeReasonCode(existing.category.codePrefix, nextSuffix);
if (reasonCode !== existing.reasonCode) {
const conflict = await prisma.reasonCatalogItem.findFirst({
where: { orgId: auth.session.orgId, reasonCode, NOT: { id: itemId } },
select: { id: true },
});
if (conflict) {
return NextResponse.json({ ok: false, error: "Duplicate reasonCode for this organization" }, { status: 409 });
}
}
try {
await prisma.$transaction(async (tx) => {
await tx.reasonCatalogItem.update({
where: { id: itemId },
data: {
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
...(parsed.data.codeSuffix !== undefined ? { codeSuffix: nextSuffix, reasonCode } : {}),
...(parsed.data.sortOrder !== undefined ? { sortOrder: parsed.data.sortOrder } : {}),
...(parsed.data.active !== undefined ? { active: parsed.data.active } : {}),
},
});
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
});
const updated = await prisma.reasonCatalogItem.findUnique({ where: { id: itemId } });
return NextResponse.json({ ok: true, item: updated });
} catch (e) {
console.error("[reason-catalog item PATCH]", e);
return NextResponse.json({ ok: false, error: "Update failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
import { bumpOrgSettingsVersion, composeReasonCode, isNumericSuffix } from "@/lib/reasonCatalogDb";
import { z } from "zod";
const bodySchema = z.object({
categoryId: z.string().uuid(),
codeSuffix: z.string().trim().min(1).max(32),
name: z.string().trim().min(1).max(500),
sortOrder: z.number().int().optional(),
});
export async function POST(req: Request) {
const auth = await requireOrgAdminSession();
if (!auth.ok) return auth.response;
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
}
const { categoryId, codeSuffix, name, sortOrder } = parsed.data;
if (!isNumericSuffix(codeSuffix)) {
return NextResponse.json({ ok: false, error: "codeSuffix must be digits only" }, { status: 400 });
}
const category = await prisma.reasonCatalogCategory.findFirst({
where: { id: categoryId, orgId: auth.session.orgId },
});
if (!category) return NextResponse.json({ ok: false, error: "Category not found" }, { status: 404 });
const reasonCode = composeReasonCode(category.codePrefix, codeSuffix);
try {
const row = await prisma.$transaction(async (tx) => {
let nextOrder = sortOrder;
if (nextOrder === undefined) {
const last = await tx.reasonCatalogItem.findFirst({
where: { categoryId },
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
nextOrder = (last?.sortOrder ?? -1) + 1;
}
const created = await tx.reasonCatalogItem.create({
data: {
orgId: auth.session.orgId,
categoryId,
name,
codeSuffix,
reasonCode,
sortOrder: nextOrder,
active: true,
},
});
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
return created;
});
return NextResponse.json({ ok: true, item: row });
} catch (e: unknown) {
const code = typeof e === "object" && e && "code" in e ? (e as { code: string }).code : "";
if (code === "P2002") {
return NextResponse.json({ ok: false, error: "Duplicate reasonCode for this organization" }, { status: 409 });
}
console.error("[reason-catalog items POST]", e);
return NextResponse.json({ ok: false, error: "Create failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
/** Full tree for Control Tower (includes inactive rows). */
export async function GET() {
const auth = await requireOrgAdminSession();
if (!auth.ok) return auth.response;
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: auth.session.orgId },
select: { version: true },
});
const categories = await prisma.reasonCatalogCategory.findMany({
where: { orgId: auth.session.orgId },
include: {
items: { orderBy: [{ sortOrder: "asc" }, { reasonCode: "asc" }] },
},
orderBy: [{ kind: "asc" }, { sortOrder: "asc" }, { name: "asc" }],
});
return NextResponse.json({
ok: true,
catalogVersion: orgSettings?.version ?? 1,
categories: categories.map((c) => ({
id: c.id,
kind: c.kind,
name: c.name,
codePrefix: c.codePrefix,
sortOrder: c.sortOrder,
active: c.active,
items: c.items.map((it) => ({
id: it.id,
name: it.name,
codeSuffix: it.codeSuffix,
reasonCode: it.reasonCode,
sortOrder: it.sortOrder,
active: it.active,
})),
})),
});
}

View File

@@ -19,7 +19,7 @@ import {
validateShiftOverrides,
validateThresholds,
} from "@/lib/settings";
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
@@ -39,21 +39,18 @@ function canManageSettings(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
function withReasonCatalog<T extends Record<string, unknown>>(payload: T, fallbackCatalog: ReasonCatalog) {
const base = (isPlainObject(payload) ? { ...payload } : {}) as T;
const defaults = isPlainObject(base.defaults) ? base.defaults : {};
const parsed =
normalizeReasonCatalog(base.reasonCatalog) ??
normalizeReasonCatalog(base.reasonCatalogData) ??
normalizeReasonCatalog(defaults.reasonCatalog) ??
normalizeReasonCatalog(defaults.reasonCatalogData) ??
fallbackCatalog;
async function attachReasonCatalog(
orgId: string,
defaultsJson: unknown,
settingsVersion: number,
base: Record<string, unknown>
): Promise<Record<string, unknown>> {
const catalog = await effectiveReasonCatalogForOrg(orgId, defaultsJson, settingsVersion);
return {
...base,
reasonCatalog: parsed,
reasonCatalogData: parsed,
reasonCatalogVersion: Number(parsed.version || 1),
reasonCatalog: catalog,
reasonCatalogData: catalog,
reasonCatalogVersion: Number(catalog.version || 1),
};
}
@@ -66,7 +63,6 @@ const settingsPayloadSchema = z
thresholds: z.any().optional(),
alerts: z.any().optional(),
defaults: z.any().optional(),
reasonCatalog: z.any().optional(),
version: z.union([z.number(), z.string()]).optional(),
})
.passthrough();
@@ -145,8 +141,13 @@ async function loadSettingsPayload(orgId: string, userId: string) {
return found;
});
const fallbackCatalog = await loadFallbackReasonCatalog();
const payload = withReasonCatalog(buildSettingsPayload(loaded.settings, loaded.shifts ?? []), fallbackCatalog);
const base = buildSettingsPayload(loaded.settings, loaded.shifts ?? []) as Record<string, unknown>;
const payload = await attachReasonCatalog(
orgId,
loaded.settings.defaultsJson,
loaded.settings.version,
base
);
const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {};
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
const modules = { screenlessMode: modulesRaw.screenlessMode === true };
@@ -221,7 +222,6 @@ export async function PUT(req: Request) {
const thresholds = parsed.data.thresholds;
const alerts = parsed.data.alerts;
const defaults = parsed.data.defaults;
const reasonCatalogRaw = parsed.data.reasonCatalog;
const expectedVersion = parsed.data.version;
const modules = parsed.data.modules;
@@ -233,7 +233,6 @@ export async function PUT(req: Request) {
thresholds === undefined &&
alerts === undefined &&
defaults === undefined &&
reasonCatalogRaw === undefined &&
modules === undefined
) {
@@ -252,13 +251,6 @@ export async function PUT(req: Request) {
if (defaults !== undefined && !isPlainObject(defaults)) {
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
}
const nextReasonCatalog =
reasonCatalogRaw === undefined || reasonCatalogRaw === null
? reasonCatalogRaw
: normalizeReasonCatalog(reasonCatalogRaw);
if (reasonCatalogRaw !== undefined && reasonCatalogRaw !== null && !nextReasonCatalog) {
return NextResponse.json({ ok: false, error: "reasonCatalog must be a valid catalog payload" }, { status: 400 });
}
if (modules !== undefined && !isPlainObject(modules)) {
return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 });
}
@@ -333,20 +325,16 @@ export async function PUT(req: Request) {
: { ...currentModulesRaw, screenlessMode };
// Write defaultsJson if either defaults changed OR modules changed
const shouldWriteDefaultsJson =
!!nextDefaultsCore || screenlessMode !== undefined || reasonCatalogRaw !== undefined;
const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined;
const nextDefaultsJson = shouldWriteDefaultsJson
? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules }
: undefined;
if (nextDefaultsJson && reasonCatalogRaw !== undefined) {
if (nextDefaultsJson) {
const defaultsTarget = nextDefaultsJson as Record<string, unknown>;
if (nextReasonCatalog === null) {
delete defaultsTarget.reasonCatalog;
} else if (nextReasonCatalog) {
defaultsTarget.reasonCatalog = nextReasonCatalog;
}
delete defaultsTarget.reasonCatalogData;
}
@@ -444,12 +432,18 @@ export async function PUT(req: Request) {
return NextResponse.json({ ok: false, error: updated.error }, { status: 400 });
}
const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []);
const baseOut = buildSettingsPayload(updated.settings, updated.shifts ?? []) as Record<string, unknown>;
const payload = await attachReasonCatalog(
session.orgId,
updated.settings.defaultsJson,
updated.settings.version,
baseOut
);
const updatedAt =
typeof payload.updatedAt === "string"
? payload.updatedAt
: payload.updatedAt
? payload.updatedAt.toISOString()
? (payload.updatedAt as Date).toISOString()
: undefined;
try {
await publishSettingsUpdate({

View File

@@ -16,7 +16,6 @@ const STATUS_DOT: Record<RecapSummaryMachine["status"], string> = {
running: "bg-emerald-400",
"mold-change": "bg-amber-400",
stopped: "bg-red-500",
"data-loss": "bg-red-500",
offline: "bg-zinc-500",
idle: "bg-zinc-400",
};
@@ -25,7 +24,6 @@ function statusLabel(status: RecapSummaryMachine["status"], t: (key: string) =>
if (status === "running") return t("recap.status.running");
if (status === "mold-change") return t("recap.status.moldChange");
if (status === "stopped") return t("recap.status.stopped");
if (status === "data-loss") return t("recap.status.dataLoss");
if (status === "idle") return t("recap.status.idle");
return t("recap.status.offline");
}
@@ -42,7 +40,7 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
const zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`;
const ongoingStopMin = machine.ongoingStopMin ?? 0;
const isUrgent = (machine.status === "stopped" && ongoingStopMin >= 5) || machine.status === "data-loss";
const isUrgent = machine.status === "stopped" && ongoingStopMin >= 5;
const isCalm = machine.status === "idle";
const timelineSegments = timeline?.segments ?? machine.miniTimeline;
const timelineStart = timeline?.range.start ?? rangeStart;
@@ -66,7 +64,7 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
async function loadTimeline() {
try {
const res = await fetch(
`/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=60`,
`/api/recap/${machine.machineId}/timeline?range=24h`,
{ cache: "no-store" }
);
const json = await res.json().catch(() => null);
@@ -150,13 +148,8 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
) : null}
<div className={`mt-3 text-xs ${isUrgent ? "text-red-200 font-semibold" : isCalm ? "text-zinc-500" : "text-zinc-400"}`}>
{machine.status === "data-loss"
? t("recap.card.dataLoss", { count: machine.stateContext.untrackedCycleCount ?? 0 })
+ (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "")
: machine.status === "stopped" && ongoingStopMin >= 5
? (machine.stateContext.stoppedReason === "not_started"
? t("recap.card.notStarted")
: t("recap.card.stoppedFor", { min: ongoingStopMin }))
{isUrgent
? t("recap.card.stoppedFor", { min: ongoingStopMin })
+ (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "")
: machine.status === "idle"
? t("recap.card.idle")

View File

@@ -0,0 +1,445 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
type CatalogKind = "downtime" | "scrap";
type ApiItem = {
id: string;
name: string;
codeSuffix: string;
reasonCode: string;
sortOrder: number;
active: boolean;
};
type ApiCategory = {
id: string;
kind: string;
name: string;
codePrefix: string;
sortOrder: number;
active: boolean;
items: ApiItem[];
};
const PREFIX_RE = /^[A-Za-z][A-Za-z0-9-]*$/;
/** Matches composeReasonCode in reasonCatalogDb (client-safe). */
function formatPrintedPreview(prefix: string, digits: string): string {
const p = String(prefix).trim().toUpperCase();
const d = String(digits).trim();
if (!d) return p.length >= 3 ? `${p}-…` : `${p}`;
if (/^\d+$/.test(d) && p.length >= 3) return `${p}-${d}`;
return `${p}${d}`;
}
async function readJson(res: Response) {
const data = await res.json().catch(() => null);
return data as Record<string, unknown> | null;
}
export function ReasonCatalogConfig({ disabled }: { disabled?: boolean }) {
const { t } = useI18n();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [catalogVersion, setCatalogVersion] = useState(1);
const [categories, setCategories] = useState<ApiCategory[]>([]);
const [kind, setKind] = useState<CatalogKind>("downtime");
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [newCatName, setNewCatName] = useState("");
const [newCatPrefix, setNewCatPrefix] = useState("");
const [newDigits, setNewDigits] = useState("");
const [newItemName, setNewItemName] = useState("");
const [busy, setBusy] = useState(false);
const [editCatName, setEditCatName] = useState("");
const [editCatPrefix, setEditCatPrefix] = useState("");
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/settings/reason-catalog");
const data = await readJson(res);
if (!res.ok || !data || data.ok !== true) {
const msg = typeof data?.error === "string" ? data.error : "Load failed";
throw new Error(msg);
}
setCatalogVersion(Number(data.catalogVersion ?? 1));
setCategories(Array.isArray(data.categories) ? (data.categories as ApiCategory[]) : []);
} catch (e) {
setError(e instanceof Error ? e.message : "Load failed");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const forKind = useMemo(
() => categories.filter((c) => String(c.kind).toLowerCase() === kind),
[categories, kind]
);
const selected = useMemo(
() => forKind.find((c) => c.id === selectedCategoryId) ?? null,
[forKind, selectedCategoryId]
);
useEffect(() => {
if (!selected) {
setEditCatName("");
setEditCatPrefix("");
return;
}
setEditCatName(selected.name);
setEditCatPrefix(selected.codePrefix);
}, [selected?.id, selected?.name, selected?.codePrefix]);
useEffect(() => {
if (!forKind.length) {
setSelectedCategoryId(null);
return;
}
if (!selectedCategoryId || !forKind.some((c) => c.id === selectedCategoryId)) {
setSelectedCategoryId(forKind[0]?.id ?? null);
}
}, [forKind, selectedCategoryId]);
const onDigitsChange = (raw: string) => {
setNewDigits(raw.replace(/\D/g, ""));
};
const createCategory = async () => {
const name = newCatName.trim();
const codePrefix = newCatPrefix.trim().toUpperCase();
if (!name || !codePrefix) return;
if (!PREFIX_RE.test(codePrefix)) {
setError(t("settings.reasonCatalog.prefixInvalid"));
return;
}
setBusy(true);
setError(null);
try {
const res = await fetch("/api/settings/reason-catalog/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind, name, codePrefix }),
});
const data = await readJson(res);
if (!res.ok || !data || data.ok !== true) {
const msg = typeof data?.error === "string" ? data.error : "Create failed";
throw new Error(msg);
}
setNewCatName("");
setNewCatPrefix("");
await load();
const cat = data.category as { id?: string } | undefined;
if (cat?.id) setSelectedCategoryId(cat.id);
} catch (e) {
setError(e instanceof Error ? e.message : "Create failed");
} finally {
setBusy(false);
}
};
const addItem = async () => {
if (!selected) return;
const digits = newDigits.trim();
const name = newItemName.trim();
if (!digits || !name) return;
setBusy(true);
setError(null);
try {
const res = await fetch("/api/settings/reason-catalog/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ categoryId: selected.id, codeSuffix: digits, name }),
});
const data = await readJson(res);
if (!res.ok || !data || data.ok !== true) {
const msg = typeof data?.error === "string" ? data.error : "Create failed";
throw new Error(msg);
}
setNewDigits("");
setNewItemName("");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Create failed");
} finally {
setBusy(false);
}
};
const patchItem = async (itemId: string, patch: Record<string, unknown>) => {
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/settings/reason-catalog/items/${itemId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
const data = await readJson(res);
if (!res.ok || !data || data.ok !== true) {
const msg = typeof data?.error === "string" ? data.error : "Update failed";
throw new Error(msg);
}
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Update failed");
} finally {
setBusy(false);
}
};
const patchCategory = async (categoryId: string, patch: Record<string, unknown>) => {
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/settings/reason-catalog/categories/${categoryId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
const data = await readJson(res);
if (!res.ok || !data || data.ok !== true) {
const msg = typeof data?.error === "string" ? data.error : "Update failed";
throw new Error(msg);
}
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Update failed");
} finally {
setBusy(false);
}
};
const inputCls =
"mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-xs text-white placeholder:text-zinc-600";
const kindBtn = (k: CatalogKind, label: string) => (
<button
type="button"
disabled={disabled || busy}
onClick={() => setKind(k)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
kind === k ? "bg-emerald-500/25 text-emerald-100 ring-1 ring-emerald-400/40" : "bg-black/30 text-zinc-400 hover:bg-white/5"
} disabled:opacity-40`}
>
{label}
</button>
);
return (
<div className="space-y-6">
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] text-zinc-500">
{t("settings.reasonCatalog.dbVersionHint", { version: catalogVersion })}
</div>
<button
type="button"
disabled={disabled || busy || loading}
onClick={() => void load()}
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-[11px] text-white hover:bg-white/10 disabled:opacity-40"
>
{t("settings.reasonCatalog.reload")}
</button>
</div>
{loading ? <p className="mt-2 text-xs text-zinc-500">{t("settings.loading")}</p> : null}
{error ? (
<p className="mt-2 rounded-lg border border-red-500/30 bg-red-500/10 px-2 py-1.5 text-xs text-red-200">{error}</p>
) : null}
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="text-xs font-semibold text-zinc-300">{t("settings.reasonCatalog.stepKind")}</div>
<div className="mt-2 flex flex-wrap gap-2">
{kindBtn("downtime", t("settings.reasonCatalog.downtime"))}
{kindBtn("scrap", t("settings.reasonCatalog.scrap"))}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="text-xs font-semibold text-zinc-300">{t("settings.reasonCatalog.stepCategory")}</div>
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-end">
<label className="min-w-[200px] flex-1 text-[11px] text-zinc-400">
{t("settings.reasonCatalog.pickCategory")}
<select
disabled={disabled || busy || !forKind.length}
value={selectedCategoryId ?? ""}
onChange={(e) => setSelectedCategoryId(e.target.value || null)}
className={`${inputCls} cursor-pointer`}
>
{!forKind.length ? <option value="">{t("settings.reasonCatalog.emptyKind")}</option> : null}
{forKind.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.codePrefix}){c.active ? "" : `${t("settings.reasonCatalog.inactive")}`}
</option>
))}
</select>
</label>
</div>
{selected ? (
<div className="mt-4 grid gap-3 rounded-lg border border-white/5 bg-black/30 p-3 sm:grid-cols-2">
<label className="text-[11px] text-zinc-400">
{t("settings.reasonCatalog.categoryNameEdit")}
<input
disabled={disabled || busy}
value={editCatName}
onChange={(e) => setEditCatName(e.target.value)}
onBlur={() => {
const n = editCatName.trim();
if (n && n !== selected.name) void patchCategory(selected.id, { name: n });
}}
className={inputCls}
/>
</label>
<label className="text-[11px] text-zinc-400">
{t("settings.reasonCatalog.codePrefixEdit")}
<input
disabled={disabled || busy}
value={editCatPrefix}
onChange={(e) => setEditCatPrefix(e.target.value.toUpperCase())}
onBlur={() => {
const v = editCatPrefix.trim().toUpperCase();
if (!v || !PREFIX_RE.test(v)) {
setEditCatPrefix(selected.codePrefix);
return;
}
if (v !== selected.codePrefix) void patchCategory(selected.id, { codePrefix: v });
}}
className={inputCls}
/>
</label>
<label className="flex items-center gap-2 text-[11px] text-zinc-400 sm:col-span-2">
<input
type="checkbox"
disabled={disabled || busy}
checked={selected.active}
onChange={(e) => void patchCategory(selected.id, { active: e.target.checked })}
className="h-3.5 w-3.5 rounded border border-white/20 bg-black/20"
/>
{t("settings.reasonCatalog.categoryActive")}
</label>
</div>
) : null}
<div className="mt-4 border-t border-white/5 pt-4">
<div className="text-[11px] font-semibold text-zinc-400">{t("settings.reasonCatalog.newCategorySection")}</div>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<label className="text-[11px] text-zinc-400">
{t("settings.reasonCatalog.categoryLabel")}
<input
disabled={disabled || busy}
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
className={inputCls}
/>
</label>
<label className="text-[11px] text-zinc-400">
{t("settings.reasonCatalog.codePrefixField")}
<input
disabled={disabled || busy}
value={newCatPrefix}
onChange={(e) => setNewCatPrefix(e.target.value.toUpperCase())}
placeholder="DTPRC"
className={inputCls}
/>
</label>
</div>
<button
type="button"
disabled={disabled || busy || !newCatName.trim() || !newCatPrefix.trim()}
onClick={() => void createCategory()}
className="mt-2 rounded-lg border border-emerald-400/30 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-100 hover:bg-emerald-500/25 disabled:opacity-40"
>
{t("settings.reasonCatalog.addCategory")}
</button>
</div>
</div>
{selected ? (
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="text-xs font-semibold text-zinc-300">{t("settings.reasonCatalog.stepReason")}</div>
<p className="mt-1 text-[11px] text-zinc-500">{t("settings.reasonCatalog.digitsOnlyHint")}</p>
<div className="mt-3 flex flex-wrap items-end gap-3">
<div className="text-[11px] text-zinc-400">
<span className="block text-zinc-500">{t("settings.reasonCatalog.fullCodePreview")}</span>
<span className="mt-1 inline-flex min-h-[2rem] items-center rounded-lg border border-white/10 bg-black/40 px-3 font-mono text-sm text-emerald-200">
{formatPrintedPreview(selected.codePrefix, newDigits)}
</span>
</div>
<label className="w-32 text-[11px] text-zinc-400">
{t("settings.reasonCatalog.numericSuffix")}
<input
disabled={disabled || busy}
inputMode="numeric"
pattern="[0-9]*"
value={newDigits}
onChange={(e) => onDigitsChange(e.target.value)}
placeholder="01"
className={inputCls}
/>
</label>
<label className="min-w-[180px] flex-1 text-[11px] text-zinc-400">
{t("settings.reasonCatalog.detailLabel")}
<input
disabled={disabled || busy}
value={newItemName}
onChange={(e) => setNewItemName(e.target.value)}
className={inputCls}
/>
</label>
<button
type="button"
disabled={disabled || busy || !newDigits.trim() || !newItemName.trim()}
onClick={() => void addItem()}
className="rounded-lg border border-emerald-400/30 bg-emerald-500/15 px-3 py-2 text-xs text-emerald-100 hover:bg-emerald-500/25 disabled:opacity-40"
>
{t("settings.reasonCatalog.addReason")}
</button>
</div>
<div className="mt-4">
<div className="text-[11px] font-semibold text-zinc-500">{t("settings.reasonCatalog.reasonsInCategory")}</div>
<div className="mt-2 space-y-2">
{selected.items.length === 0 ? (
<div className="text-xs text-zinc-500">{t("settings.reasonCatalog.noItemsYet")}</div>
) : (
selected.items.map((it) => (
<div
key={it.id}
className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border border-white/5 px-3 py-2 ${
it.active ? "bg-black/30" : "bg-black/10 opacity-60"
}`}
>
<div className="font-mono text-xs text-emerald-200">{it.reasonCode}</div>
<div className="min-w-0 flex-1 truncate text-xs text-white">{it.name}</div>
<label className="flex items-center gap-1.5 text-[10px] text-zinc-400">
<input
type="checkbox"
disabled={disabled || busy}
checked={it.active}
onChange={(e) => void patchItem(it.id, { active: e.target.checked })}
className="h-3.5 w-3.5 rounded border border-white/20 bg-black/20"
/>
{t("settings.reasonCatalog.active")}
</label>
</div>
))
)}
</div>
</div>
</div>
) : null}
<p className="text-[11px] leading-relaxed text-zinc-500">{t("settings.reasonCatalog.hint")}</p>
</div>
);
}

4288
flows_may_4_26.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -38,6 +38,7 @@ type AlertsInboxEvent = {
status?: string | null;
shift?: string | null;
alertId?: string | null;
incidentKey?: string | null;
isUpdate?: boolean;
isAutoAck?: boolean;
};
@@ -224,29 +225,34 @@ function resolveShift(
}
function collapseAlertEvents(events: AlertsInboxEvent[]) {
const byAlert = new Map<string, AlertsInboxEvent>();
// Group by incidentKey (preferred — stable across the entire incident lifecycle)
// OR alertId (fallback — for older or non-stoppage events).
// Per group, keep AT MOST one "active" (oldest = when it first happened) and
// one "resolved" (newest = when it actually ended). Result: max 2 entries per incident.
const byGroup = new Map<string, AlertsInboxEvent>();
const passthrough: AlertsInboxEvent[] = [];
for (const ev of events) {
if (!ev.alertId) {
const groupId = ev.incidentKey ?? ev.alertId;
if (!groupId) {
passthrough.push(ev);
continue;
}
const statusKey = ev.status === "resolved" ? "resolved" : "active";
const key = `${ev.alertId}:${statusKey}`;
const existing = byAlert.get(key);
const key = `${groupId}:${statusKey}`;
const existing = byGroup.get(key);
if (!existing) {
byAlert.set(key, ev);
byGroup.set(key, ev);
continue;
}
const pickNewest = statusKey === "resolved";
const shouldReplace = pickNewest
? ev.ts.getTime() > existing.ts.getTime()
: ev.ts.getTime() < existing.ts.getTime();
if (shouldReplace) byAlert.set(key, ev);
if (shouldReplace) byGroup.set(key, ev);
}
const combined = [...passthrough, ...byAlert.values()];
const combined = [...passthrough, ...byGroup.values()];
combined.sort((a, b) => b.ts.getTime() - a.ts.getTime());
return combined;
}
@@ -325,7 +331,12 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
const rawStatus = safeString(payload?.status ?? inner?.status);
const isUpdate = safeBool(payload?.is_update ?? inner?.is_update);
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
// Drop only auto-ack pings (every-10s refresh noise).
// Keep is_update events: due to a Node-RED spread inheritance pattern,
// virtually all events carry is_update=true even legitimate first-emission
// and cycle-arrival resolved events. Dedup happens via collapseAlertEvents
// grouping by incidentKey below.
if (!includeUpdates && isAutoAck) continue;
const shiftName = resolveShift(shifts, shiftOverrides, ev.ts, timeZone);
if (normalizedShift && shiftName !== normalizedShift) continue;
@@ -349,6 +360,7 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
status: statusLabel,
shift: shiftName,
alertId: safeString(payload?.alert_id ?? inner?.alert_id),
incidentKey: safeString(payload?.incidentKey ?? payload?.incident_key ?? inner?.incidentKey ?? inner?.incident_key),
isUpdate,
isAutoAck,
});

View File

@@ -0,0 +1,363 @@
import { normalizeShiftOverrides } from "@/lib/settings";
import { prisma } from "@/lib/prisma";
const RANGE_MS: Record<string, number> = {
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
};
type AlertsInboxParams = {
orgId: string;
range?: string;
start?: Date | null;
end?: Date | null;
machineId?: string;
location?: string;
eventType?: string;
severity?: string;
status?: string;
shift?: string;
includeUpdates?: boolean;
limit?: number;
};
type AlertsInboxEvent = {
id: string;
ts: Date;
eventType: string;
severity: string;
title: string;
description?: string | null;
machineId: string;
machineName?: string | null;
location?: string | null;
workOrderId?: string | null;
sku?: string | null;
durationSec?: number | null;
status?: string | null;
shift?: string | null;
alertId?: string | null;
isUpdate?: boolean;
isAutoAck?: boolean;
};
function pickRange(range: string, start?: Date | null, end?: Date | null) {
const now = new Date();
if (range === "custom") {
const startFallback = new Date(now.getTime() - RANGE_MS["24h"]);
return {
range,
start: start ?? startFallback,
end: end ?? now,
};
}
const ms = RANGE_MS[range] ?? RANGE_MS["24h"];
return { range, start: new Date(now.getTime() - ms), end: now };
}
function safeString(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function safeNumber(value: unknown) {
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n : null;
}
function safeBool(value: unknown) {
return value === true;
}
function normalizeStatus(value?: string | null) {
if (!value) return null;
const raw = value.trim().toLowerCase();
if (!raw) return null;
if (raw === "in_progress" || raw === "in-progress" || raw === "open" || raw === "activa" || raw === "activo") {
return "active";
}
if (raw === "resuelta" || raw === "resuelto" || raw === "closed" || raw === "ended" || raw === "done") {
return "resolved";
}
return raw;
}
function parsePayload(raw: unknown) {
let parsed: unknown = raw;
if (typeof raw === "string") {
try {
parsed = JSON.parse(raw);
} catch {
parsed = raw;
}
}
const payload =
parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
const innerCandidate = payload.data;
const inner =
innerCandidate && typeof innerCandidate === "object" && !Array.isArray(innerCandidate)
? (innerCandidate as Record<string, unknown>)
: payload;
return { payload, inner };
}
function extractDurationSec(raw: unknown) {
const { payload, inner } = parsePayload(raw);
const candidates = [
inner?.duration_seconds,
inner?.duration_sec,
inner?.stoppage_duration_seconds,
inner?.stop_duration_seconds,
payload?.duration_seconds,
payload?.duration_sec,
payload?.stoppage_duration_seconds,
payload?.stop_duration_seconds,
];
for (const val of candidates) {
if (typeof val === "number" && Number.isFinite(val) && val >= 0) return val;
}
const msCandidates = [inner?.duration_ms, inner?.durationMs, payload?.duration_ms, payload?.durationMs];
for (const val of msCandidates) {
if (typeof val === "number" && Number.isFinite(val) && val >= 0) {
return Math.round(val / 1000);
}
}
const startMs = inner.start_ts ?? inner.startTs ?? payload.start_ts ?? payload.startTs ?? null;
const endMs = inner.end_ts ?? inner.endTs ?? payload.end_ts ?? payload.endTs ?? null;
if (typeof startMs === "number" && typeof endMs === "number" && endMs >= startMs) {
return Math.round((endMs - startMs) / 1000);
}
const actual = safeNumber(inner.actual_cycle_time ?? payload.actual_cycle_time);
const theoretical = safeNumber(inner.theoretical_cycle_time ?? payload.theoretical_cycle_time);
if (actual != null && theoretical != null) {
return Math.max(0, actual - theoretical);
}
return null;
}
function parseTimeMinutes(value?: string | null) {
if (!value || !/^\d{2}:\d{2}$/.test(value)) return null;
const [hh, mm] = value.split(":").map((n) => Number(n));
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return null;
return hh * 60 + mm;
}
function getLocalMinutes(ts: Date, timeZone: string) {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(ts);
const hours = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
const minutes = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
return hours * 60 + minutes;
} catch {
return ts.getUTCHours() * 60 + ts.getUTCMinutes();
}
}
const WEEKDAY_KEY_MAP: Record<string, string> = {
Sun: "sun",
Mon: "mon",
Tue: "tue",
Wed: "wed",
Thu: "thu",
Fri: "fri",
Sat: "sat",
};
const WEEKDAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
function getLocalDayKey(ts: Date, timeZone: string) {
try {
const weekday = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "short",
}).format(ts);
return WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()];
} catch {
return WEEKDAY_KEYS[ts.getUTCDay()];
}
}
type ShiftLike = {
name: string;
startTime?: string | null;
endTime?: string | null;
start?: string | null;
end?: string | null;
enabled?: boolean;
};
function resolveShift(
shifts: ShiftLike[],
overrides: Record<string, ShiftLike[]> | undefined,
ts: Date,
timeZone: string
) {
const dayKey = getLocalDayKey(ts, timeZone);
const dayOverrides = overrides?.[dayKey];
const activeShifts = dayOverrides ?? shifts;
if (!activeShifts.length) return null;
const nowMin = getLocalMinutes(ts, timeZone);
for (const shift of activeShifts) {
if (shift.enabled === false) continue;
const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null);
const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null);
if (start == null || end == null) continue;
if (start <= end) {
if (nowMin >= start && nowMin < end) return shift.name;
} else {
if (nowMin >= start || nowMin < end) return shift.name;
}
}
return null;
}
function collapseAlertEvents(events: AlertsInboxEvent[]) {
const byAlert = new Map<string, AlertsInboxEvent>();
const passthrough: AlertsInboxEvent[] = [];
for (const ev of events) {
if (!ev.alertId) {
passthrough.push(ev);
continue;
}
const statusKey = ev.status === "resolved" ? "resolved" : "active";
const key = `${ev.alertId}:${statusKey}`;
const existing = byAlert.get(key);
if (!existing) {
byAlert.set(key, ev);
continue;
}
const pickNewest = statusKey === "resolved";
const shouldReplace = pickNewest
? ev.ts.getTime() > existing.ts.getTime()
: ev.ts.getTime() < existing.ts.getTime();
if (shouldReplace) byAlert.set(key, ev);
}
const combined = [...passthrough, ...byAlert.values()];
combined.sort((a, b) => b.ts.getTime() - a.ts.getTime());
return combined;
}
export async function getAlertsInboxData(params: AlertsInboxParams) {
const {
orgId,
range = "24h",
start,
end,
machineId,
location,
eventType,
severity,
status,
shift,
includeUpdates = false,
limit = 200,
} = params;
const picked = pickRange(range, start, end);
const normalizedStatus = safeString(status)?.toLowerCase();
const normalizedShift = safeString(shift);
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 500) : 200;
const where = {
orgId,
ts: { gte: picked.start, lte: picked.end },
...(machineId ? { machineId } : {}),
...(eventType ? { eventType } : {}),
...(severity ? { severity } : {}),
...(location ? { machine: { location } } : {}),
};
const [events, shifts, settings] = await Promise.all([
prisma.machineEvent.findMany({
where,
orderBy: { ts: "desc" },
take: safeLimit,
select: {
id: true,
ts: true,
eventType: true,
severity: true,
title: true,
description: true,
data: true,
machineId: true,
workOrderId: true,
sku: true,
machine: {
select: {
name: true,
location: true,
},
},
},
}),
prisma.orgShift.findMany({
where: { orgId },
orderBy: { sortOrder: "asc" },
select: { name: true, startTime: true, endTime: true, enabled: true },
}),
prisma.orgSettings.findUnique({
where: { orgId },
select: { timezone: true, shiftScheduleOverridesJson: true },
}),
]);
const timeZone = settings?.timezone || "UTC";
const shiftOverrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
const mapped: AlertsInboxEvent[] = [];
for (const ev of events) {
const { payload, inner } = parsePayload(ev.data);
const rawStatus = safeString(payload?.status ?? inner?.status);
const isUpdate = safeBool(payload?.is_update ?? inner?.is_update);
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
const shiftName = resolveShift(shifts, shiftOverrides, ev.ts, timeZone);
if (normalizedShift && shiftName !== normalizedShift) continue;
const statusLabel = normalizeStatus(rawStatus) ?? "unknown";
if (normalizedStatus && statusLabel !== normalizedStatus) continue;
mapped.push({
id: ev.id,
ts: ev.ts,
eventType: ev.eventType,
severity: ev.severity,
title: ev.title,
description: ev.description,
machineId: ev.machineId,
machineName: ev.machine?.name ?? null,
location: ev.machine?.location ?? null,
workOrderId: ev.workOrderId ?? null,
sku: ev.sku ?? null,
durationSec: extractDurationSec(ev.data),
status: statusLabel,
shift: shiftName,
alertId: safeString(payload?.alert_id ?? inner?.alert_id),
isUpdate,
isAutoAck,
});
}
const finalEvents = includeUpdates ? mapped : collapseAlertEvents(mapped);
return {
range: { range: picked.range, start: picked.start, end: picked.end },
events: finalEvents,
};
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
export type OrgAdminSession = { orgId: string; userId: string };
export async function requireOrgAdminSession(): Promise<
{ ok: true; session: OrgAdminSession } | { ok: false; response: NextResponse }
> {
const session = await requireSession();
if (!session) {
return {
ok: false,
response: NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }),
};
}
const membership = await prisma.orgUser.findUnique({
where: { orgId_userId: { orgId: session.orgId, userId: session.userId } },
select: { role: true },
});
if (membership?.role !== "OWNER" && membership?.role !== "ADMIN") {
return { ok: false, response: NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }) };
}
return { ok: true, session: { orgId: session.orgId, userId: session.userId } };
}

View File

@@ -115,10 +115,7 @@
"machines.status.stopped": "STOPPED",
"machines.stoppedFor": "Stopped for {min} min",
"recap.grid.title": "Machine recap",
"recap.status.dataLoss": "Data Loss",
"recap.status.idle": "Idle",
"recap.card.dataLoss": "{count} untracked cycles — press START",
"recap.card.notStarted": "Operator hasn't pressed START",
"recap.card.idle": "No active work order",
"recap.grid.subtitle": "Last 24h · click to open details",
"recap.grid.updatedAgo": "Updated {sec}s ago",
@@ -467,6 +464,7 @@
"settings.tabs.alerts": "Alerts",
"settings.tabs.financial": "Financial",
"settings.tabs.team": "Team",
"settings.tabs.reasonCatalog": "Downtime & scrap",
"settings.loading": "Loading settings...",
"settings.loadingTeam": "Loading team...",
"settings.refresh": "Refresh",
@@ -522,6 +520,46 @@
"settings.thresholds.macroStoppage": "Macro stoppage multiplier",
"settings.alerts": "Alerts",
"settings.alertsSubtitle": "Choose which alerts to notify.",
"settings.reasonCatalog.title": "Downtime and scrap catalogs",
"settings.reasonCatalog.subtitle": "Catalogs are stored in MIS (categories + codes). Changes bump settings version so machines pick them up. Deactivate retired codes instead of deleting them.",
"settings.reasonCatalog.version": "Catalog version",
"settings.reasonCatalog.hint": "Increase version when you change codes so edge devices can detect updates. Use \"Active\" to hide a code from new selections while keeping history labels.",
"settings.reasonCatalog.downtime": "Downtime (stops)",
"settings.reasonCatalog.scrap": "Scrap",
"settings.reasonCatalog.addCategory": "Add category",
"settings.reasonCatalog.emptyKind": "No categories yet.",
"settings.reasonCatalog.categoryId": "Category id",
"settings.reasonCatalog.categoryLabel": "Category name",
"settings.reasonCatalog.reasons": "Reasons",
"settings.reasonCatalog.addReason": "Add reason",
"settings.reasonCatalog.removeCategory": "Remove category",
"settings.reasonCatalog.detailId": "Detail id",
"settings.reasonCatalog.reasonCode": "Printed code",
"settings.reasonCatalog.detailLabel": "Description",
"settings.reasonCatalog.active": "Active",
"settings.reasonCatalog.removeRow": "Remove",
"settings.reasonCatalog.removeDetailHint": "Prefer deactivating codes that were already used in production.",
"settings.reasonCatalog.newCategory": "New category",
"settings.reasonCatalog.newReason": "New reason",
"settings.reasonCatalog.dbVersionHint": "Settings version (includes catalog): {version}",
"settings.reasonCatalog.reload": "Reload",
"settings.reasonCatalog.stepKind": "1. Catalog type",
"settings.reasonCatalog.stepCategory": "2. Category and prefix",
"settings.reasonCatalog.pickCategory": "Category",
"settings.reasonCatalog.inactive": "inactive",
"settings.reasonCatalog.categoryNameEdit": "Category name",
"settings.reasonCatalog.codePrefixEdit": "Code prefix (letters; optional digits/hyphen after first letter)",
"settings.reasonCatalog.categoryActive": "Category active",
"settings.reasonCatalog.newCategorySection": "New category in this catalog type",
"settings.reasonCatalog.codePrefixField": "Prefix (shown before the number)",
"settings.reasonCatalog.stepReason": "3. Add reason (numbers only)",
"settings.reasonCatalog.digitsOnlyHint": "Enter only the numeric part; the full printed code is prefix + number.",
"settings.reasonCatalog.fullCodePreview": "Printed code",
"settings.reasonCatalog.numericSuffix": "Number",
"settings.reasonCatalog.reasonsInCategory": "Reasons in this category",
"settings.reasonCatalog.noItemsYet": "No reasons yet.",
"settings.reasonCatalog.prefixInvalid": "Prefix must start with a letter and use letters, digits, or hyphen.",
"settings.alerts.oeeDrop": "OEE drop alerts",
"settings.alerts.oeeDropHelper": "Notify when OEE falls below threshold",
"settings.alerts.performanceDegradation": "Performance degradation alerts",

View File

@@ -121,10 +121,7 @@
"recap.card.stoppedFor": "Detenida hace {min} min",
"machines.status.stopped": "DETENIDA",
"machines.stoppedFor": "Detenida hace {min} min",
"recap.status.dataLoss": "Sin tracking",
"recap.status.idle": "Inactiva",
"recap.card.dataLoss": "{count} ciclos sin tracking — presione INICIAR",
"recap.card.notStarted": "Operador no ha presionado INICIAR",
"recap.card.idle": "Sin orden de trabajo activa",
"recap.grid.title": "Resumen de máquinas",
"recap.grid.subtitle": "Últimas 24h · click para ver detalle",
@@ -474,6 +471,7 @@
"settings.tabs.alerts": "Alertas",
"settings.tabs.financial": "Finanzas",
"settings.tabs.team": "Equipo",
"settings.tabs.reasonCatalog": "Paros y scrap",
"settings.loading": "Cargando configuración...",
"settings.loadingTeam": "Cargando equipo...",
"settings.refresh": "Actualizar",
@@ -529,6 +527,46 @@
"settings.thresholds.macroStoppage": "Multiplicador de macroparo",
"settings.alerts": "Alertas",
"settings.alertsSubtitle": "Elige qué alertas notificar.",
"settings.reasonCatalog.title": "Catálogos de paros y scrap",
"settings.reasonCatalog.subtitle": "Los catálogos viven en MIS (categorías y códigos). Los cambios suben la versión de ajustes para que las máquinas los reciban. Desactiva códigos retirados en lugar de borrarlos.",
"settings.reasonCatalog.version": "Versión del catálogo",
"settings.reasonCatalog.hint": "Sube la versión cuando cambies códigos para que el borde detecte actualizaciones. Usa \"Activo\" para ocultar un código en nuevas capturas sin perder etiquetas en histórico.",
"settings.reasonCatalog.downtime": "Tiempo muerto (paros)",
"settings.reasonCatalog.scrap": "Scrap",
"settings.reasonCatalog.addCategory": "Agregar categoría",
"settings.reasonCatalog.emptyKind": "Aún no hay categorías.",
"settings.reasonCatalog.categoryId": "Id de categoría",
"settings.reasonCatalog.categoryLabel": "Nombre de categoría",
"settings.reasonCatalog.reasons": "Razones",
"settings.reasonCatalog.addReason": "Agregar razón",
"settings.reasonCatalog.removeCategory": "Quitar categoría",
"settings.reasonCatalog.detailId": "Id del detalle",
"settings.reasonCatalog.reasonCode": "Código impreso",
"settings.reasonCatalog.detailLabel": "Descripción",
"settings.reasonCatalog.active": "Activo",
"settings.reasonCatalog.removeRow": "Quitar",
"settings.reasonCatalog.removeDetailHint": "Para códigos ya usados en producción, preferir desactivar en lugar de quitar la fila.",
"settings.reasonCatalog.newCategory": "Nueva categoría",
"settings.reasonCatalog.newReason": "Nueva razón",
"settings.reasonCatalog.dbVersionHint": "Versión de ajustes (incluye catálogo): {version}",
"settings.reasonCatalog.reload": "Recargar",
"settings.reasonCatalog.stepKind": "1. Tipo de catálogo",
"settings.reasonCatalog.stepCategory": "2. Categoría y prefijo",
"settings.reasonCatalog.pickCategory": "Categoría",
"settings.reasonCatalog.inactive": "inactiva",
"settings.reasonCatalog.categoryNameEdit": "Nombre de categoría",
"settings.reasonCatalog.codePrefixEdit": "Prefijo de código (letras; opcional dígitos o guión después de la primera letra)",
"settings.reasonCatalog.categoryActive": "Categoría activa",
"settings.reasonCatalog.newCategorySection": "Nueva categoría en este tipo de catálogo",
"settings.reasonCatalog.codePrefixField": "Prefijo (se muestra antes del número)",
"settings.reasonCatalog.stepReason": "3. Agregar razón (solo números)",
"settings.reasonCatalog.digitsOnlyHint": "Captura solo la parte numérica; el código impreso completo es prefijo + número.",
"settings.reasonCatalog.fullCodePreview": "Código impreso",
"settings.reasonCatalog.numericSuffix": "Número",
"settings.reasonCatalog.reasonsInCategory": "Razones en esta categoría",
"settings.reasonCatalog.noItemsYet": "Aún no hay razones.",
"settings.reasonCatalog.prefixInvalid": "El prefijo debe empezar con letra y usar letras, dígitos o guión.",
"settings.alerts.oeeDrop": "Alertas por caída de OEE",
"settings.alerts.oeeDropHelper": "Notificar cuando OEE esté por debajo del umbral",
"settings.alerts.performanceDegradation": "Alertas por baja de Performance",

View File

@@ -1,6 +1,3 @@
import { readFile } from "fs/promises";
import path from "path";
type AnyRecord = Record<string, unknown>;
export type ReasonCatalogKind = "downtime" | "scrap";
@@ -8,6 +5,10 @@ export type ReasonCatalogKind = "downtime" | "scrap";
export type ReasonCatalogDetail = {
id: string;
label: string;
/** Official code (e.g. DTPRC-01, MX001). When set, used as reasonCode instead of slug. */
reasonCode?: string;
/** When false, hidden from operator pickers but kept for historical label resolution. Default true. */
active?: boolean;
};
export type ReasonCatalogCategory = {
@@ -22,6 +23,11 @@ export type ReasonCatalog = {
scrap: ReasonCatalogCategory[];
};
export type FlattenReasonCatalogOptions = {
/** If true, omit details with active === false (operator / tactile UI). */
activeOnly?: boolean;
};
function isPlainObject(value: unknown): value is AnyRecord {
return !!value && typeof value === "object" && !Array.isArray(value);
}
@@ -40,6 +46,17 @@ function buildReasonCode(categoryId: string, detailId: string) {
return `${canonicalId(categoryId)}__${canonicalId(detailId)}`.toUpperCase();
}
/** Uppercase official or derived code for this detail row. */
export function detailEffectiveReasonCode(category: ReasonCatalogCategory, detail: ReasonCatalogDetail): string {
const explicit = String(detail.reasonCode ?? "").trim();
if (explicit) return explicit.toUpperCase();
return buildReasonCode(category.id, detail.id);
}
export function isDetailActive(detail: ReasonCatalogDetail): boolean {
return detail.active !== false;
}
function toCategory(raw: unknown): ReasonCatalogCategory | null {
if (!isPlainObject(raw)) return null;
const labelRaw = String(raw.label ?? "").trim();
@@ -57,7 +74,16 @@ function toCategory(raw: unknown): ReasonCatalogCategory | null {
const detailLabel = String(detailRaw.label ?? "").trim();
if (!detailLabel) continue;
const detailId = String(detailRaw.id ?? "").trim() || canonicalId(detailLabel, "detail");
details.push({ id: detailId, label: detailLabel });
const reasonCodeRaw = detailRaw.reasonCode ?? detailRaw.code;
const reasonCode =
reasonCodeRaw != null && String(reasonCodeRaw).trim() ? String(reasonCodeRaw).trim() : undefined;
const active = detailRaw.active === false ? false : true;
details.push({
id: detailId,
label: detailLabel,
...(reasonCode ? { reasonCode } : {}),
...(active ? {} : { active: false }),
});
}
if (!details.length) return null;
@@ -131,7 +157,7 @@ export function parseReasonCatalogMarkdown(markdown: string): ReasonCatalog {
details: [] as ReasonCatalogDetail[],
};
if (!existing.details.some((d) => d.id === detailId)) {
existing.details.push({ id: detailId, label: detailLabel });
existing.details.push({ id: detailId, label: detailLabel, active: true });
}
buckets[activeKind].set(categoryId, existing);
}
@@ -143,31 +169,37 @@ export function parseReasonCatalogMarkdown(markdown: string): ReasonCatalog {
};
}
let catalogPromise: Promise<ReasonCatalog> | null = null;
export async function loadFallbackReasonCatalog() {
if (!catalogPromise) {
catalogPromise = readFile(path.join(process.cwd(), "downtime_menu.md"), "utf8")
.then((raw) => parseReasonCatalogMarkdown(raw))
.catch(() => ({ version: 1, downtime: [], scrap: [] }));
}
return catalogPromise;
}
export function flattenReasonCatalog(catalog: ReasonCatalog, kind: ReasonCatalogKind) {
export function flattenReasonCatalog(
catalog: ReasonCatalog,
kind: ReasonCatalogKind,
options?: FlattenReasonCatalogOptions
) {
const activeOnly = options?.activeOnly === true;
return (catalog[kind] ?? []).flatMap((category) =>
category.details.map((detail) => ({
category.details
.filter((d) => !activeOnly || isDetailActive(d))
.map((detail) => ({
kind,
categoryId: category.id,
categoryLabel: category.label,
detailId: detail.id,
detailLabel: detail.label,
reasonCode: buildReasonCode(category.id, detail.id),
reasonCode: detailEffectiveReasonCode(category, detail),
reasonLabel: `${category.label} > ${detail.label}`,
active: isDetailActive(detail),
}))
);
}
function canonicalText(value: unknown) {
return String(value ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function findCatalogReason(
catalog: ReasonCatalog | null | undefined,
kind: ReasonCatalogKind,
@@ -187,11 +219,38 @@ export function findCatalogReason(
categoryLabel: category.label,
detailId: detail.id,
detailLabel: detail.label,
reasonCode: buildReasonCode(category.id, detail.id),
reasonCode: detailEffectiveReasonCode(category, detail),
reasonLabel: `${category.label} > ${detail.label}`,
};
}
/** Resolve category/detail + labels by official or derived reasonCode (includes inactive details). */
export function findCatalogReasonByReasonCode(
catalog: ReasonCatalog | null | undefined,
kind: ReasonCatalogKind,
reasonCode: string | null | undefined
) {
if (!catalog) return null;
const needle = String(reasonCode ?? "").trim().toUpperCase();
if (!needle) return null;
for (const category of catalog[kind] ?? []) {
for (const detail of category.details) {
const rc = detailEffectiveReasonCode(category, detail);
if (rc === needle) {
return {
categoryId: category.id,
categoryLabel: category.label,
detailId: detail.id,
detailLabel: detail.label,
reasonCode: rc,
reasonLabel: `${category.label} > ${detail.label}`,
};
}
}
}
return null;
}
export function toReasonCode(categoryId: unknown, detailId: unknown) {
const cat = canonicalId(categoryId, "");
const det = canonicalId(detailId, "");

98
lib/reasonCatalogDb.ts Normal file
View File

@@ -0,0 +1,98 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import type { ReasonCatalog, ReasonCatalogCategory, ReasonCatalogDetail } from "@/lib/reasonCatalog";
import { normalizeReasonCatalog } from "@/lib/reasonCatalog";
import { loadFallbackReasonCatalog } from "@/lib/reasonCatalogFallback";
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
/**
* Full printed code from category prefix + operator numeric suffix (or suffix digits from seed).
* Downtime-style keys use a hyphen before the numeric part (e.g. DTPRC-01); short scrap-style
* prefixes (e.g. MX) concatenate without hyphen (MX001).
*/
export function composeReasonCode(prefix: string, suffix: string): string {
const p = String(prefix ?? "").trim().toUpperCase();
const s = String(suffix ?? "").trim();
if (/^\d+$/.test(s) && p.length >= 3) {
return `${p}-${s}`.toUpperCase();
}
return `${p}${s}`.toUpperCase();
}
export function isNumericSuffix(value: string): boolean {
return /^\d+$/.test(String(value ?? "").trim());
}
function mapKind(kind: string): "downtime" | "scrap" | null {
const k = String(kind).toLowerCase();
if (k === "downtime" || k === "scrap") return k;
return null;
}
/**
* Load catalog from Postgres tables. Returns null if org has no catalog rows yet.
* Includes inactive rows for historical label resolution (same as prior JSON behavior).
*/
export async function loadReasonCatalogFromDb(
orgId: string,
catalogVersion: number
): Promise<ReasonCatalog | null> {
const rows = await prisma.reasonCatalogCategory.findMany({
where: { orgId },
include: {
items: { orderBy: { sortOrder: "asc" } },
},
orderBy: [{ kind: "asc" }, { sortOrder: "asc" }],
});
if (!rows.length) return null;
const downtime: ReasonCatalogCategory[] = [];
const scrap: ReasonCatalogCategory[] = [];
for (const cat of rows) {
const k = mapKind(cat.kind);
if (!k) continue;
const details: ReasonCatalogDetail[] = cat.items.map((it) => ({
id: it.id,
label: it.name,
reasonCode: it.reasonCode,
active: it.active,
}));
const bucket: ReasonCatalogCategory = {
id: cat.id,
label: cat.name,
details,
};
if (k === "downtime") downtime.push(bucket);
else scrap.push(bucket);
}
if (!downtime.length && !scrap.length) return null;
return { version: Math.max(1, catalogVersion), downtime, scrap };
}
/** DB first, then legacy JSON in defaults, then file fallback. */
export async function effectiveReasonCatalogForOrg(
orgId: string,
defaultsJson: unknown,
settingsVersion: number
): Promise<ReasonCatalog> {
const fromDb = await loadReasonCatalogFromDb(orgId, settingsVersion);
if (fromDb) return fromDb;
const defs = isPlainObject(defaultsJson) ? defaultsJson : {};
const fromJson = normalizeReasonCatalog(defs.reasonCatalog ?? defs.reasonCatalogData);
if (fromJson) return fromJson;
return loadFallbackReasonCatalog();
}
export async function bumpOrgSettingsVersion(tx: Prisma.TransactionClient, orgId: string, userId: string) {
await tx.orgSettings.update({
where: { orgId },
data: { version: { increment: 1 }, updatedBy: userId },
});
}

View File

@@ -0,0 +1,15 @@
import { readFile } from "fs/promises";
import path from "path";
import { parseReasonCatalogMarkdown, type ReasonCatalog } from "@/lib/reasonCatalog";
let catalogPromise: Promise<ReasonCatalog> | null = null;
/** Server-only: reads downtime_menu.md from the repo root. */
export async function loadFallbackReasonCatalog() {
if (!catalogPromise) {
catalogPromise = readFile(path.join(process.cwd(), "downtime_menu.md"), "utf8")
.then((raw) => parseReasonCatalogMarkdown(raw))
.catch(() => ({ version: 1, downtime: [], scrap: [] }));
}
return catalogPromise;
}

View File

@@ -289,6 +289,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
ts: true,
cycleCount: true,
workOrderId: true,
theoreticalCycleTime: true,
sku: true,
goodDelta: true,
scrapDelta: true,

View File

@@ -23,9 +23,6 @@ export type MachineStateName =
| "idle"
| "running";
export type StoppedReason = "machine_fault" | "not_started";
export type DataLossReason = "untracked";
export type MachineStateResult =
| { state: "offline"; lastSeenMs: number | null; offlineForMin: number }
| {
@@ -35,17 +32,9 @@ export type MachineStateResult =
}
| {
state: "stopped";
reason: StoppedReason;
ongoingStopMin: number;
stopStartedAtMs: number | null;
}
| {
state: "data-loss";
reason: DataLossReason;
untrackedCycleCount: number;
untrackedSinceMs: number | null;
untrackedForMin: number;
}
| { state: "idle" }
| { state: "running" };
@@ -74,8 +63,6 @@ export type MachineStateInputs = {
* Caller computes by counting MachineCycle rows in the last UNTRACKED_WINDOW_MS
* where ts > latestKpi.ts (so they're "after" the tracking-off snapshot).
*/
untrackedCycles: { count: number; oldestTsMs: number | null };
/**
* Most recent cycle timestamp regardless of tracking — used as a sanity check
* for IDLE classification.
@@ -84,8 +71,7 @@ export type MachineStateInputs = {
};
// Trigger thresholds — tunable
const DATA_LOSS_MIN_CYCLES = 5;
const DATA_LOSS_MIN_DURATION_MS = 10 * 60 * 1000; // 10 min
const RECENT_CYCLE_MS = 15 * 60 * 1000; // for IDLE check — "no cycles in 15 min"
export function classifyMachineState(
@@ -116,51 +102,22 @@ export function classifyMachineState(
// 3. DATA_LOSS — tracking off but cycles arriving. Operator forgot START.
// Check this BEFORE STOPPED because cycles ARE arriving (so the "no cycles" branch
// would never fire), but we still want to flag it.
if (!inputs.trackingEnabled && inputs.untrackedCycles.count > 0) {
const oldest = inputs.untrackedCycles.oldestTsMs;
const durationMs = oldest != null ? nowMs - oldest : 0;
const tripped =
inputs.untrackedCycles.count >= DATA_LOSS_MIN_CYCLES ||
durationMs >= DATA_LOSS_MIN_DURATION_MS;
if (tripped) {
return {
state: "data-loss",
reason: "untracked",
untrackedCycleCount: inputs.untrackedCycles.count,
untrackedSinceMs: oldest,
untrackedForMin: Math.max(0, Math.floor(durationMs / 60000)),
};
}
// Not yet tripped — fall through to other checks (likely RUNNING since cycles are coming)
}
// 4. STOPPED — should be producing, isn't. Two reasons:
// a) machine_fault: operator pressed START, macrostop event active → mechanical issue
// b) not_started: operator never pressed START but a WO is loaded
if (inputs.activeMacrostop && inputs.trackingEnabled) {
// 4. STOPPED — machine should be producing, isn't.
// The Pi only emits macrostop events when tracking is on AND a WO is active,
// so the presence of an active macrostop event is sufficient.
if (inputs.activeMacrostop) {
const startedAt = inputs.activeMacrostop.startedAtMs;
return {
state: "stopped",
reason: "machine_fault",
ongoingStopMin: Math.max(0, Math.floor((nowMs - startedAt) / 60000)),
stopStartedAtMs: startedAt,
};
}
if (inputs.hasActiveWorkOrder && !inputs.trackingEnabled) {
// Operator hasn't started production despite a loaded WO.
// We don't have a precise "since when" for this — best estimate is "since latest
// KPI snapshot reported trackingEnabled=false," but that's not in the inputs.
// For now, report ongoingStopMin=0 and let the caller refine if needed.
return {
state: "stopped",
reason: "not_started",
ongoingStopMin: 0,
stopStartedAtMs: null,
};
}
// 5. IDLE — no one expects this machine to be doing anything right now.
// No tracking, no WO, no recent cycles. Calm gray.
const cycledRecently =

View File

@@ -299,7 +299,6 @@ function statusFromMachine(
hasActiveWorkOrder,
activeMoldChange,
activeMacrostop,
untrackedCycles: { count: 0, oldestTsMs: null },
lastCycleTsMs,
},
endMs
@@ -312,25 +311,8 @@ function statusFromMachine(
let ongoingStopMin: number | null = null;
if (result.state === "stopped") ongoingStopMin = result.ongoingStopMin;
let stateContext: RecapStateContext = {
stoppedReason: null,
dataLossReason: null,
untrackedCycleCount: null,
};
const stateContext: RecapStateContext = {};
if (result.state === "stopped") {
stateContext = {
stoppedReason: result.reason,
dataLossReason: null,
untrackedCycleCount: null,
};
} else if (result.state === "data-loss") {
stateContext = {
stoppedReason: null,
dataLossReason: result.reason,
untrackedCycleCount: result.untrackedCycleCount,
};
}
return {
status,
@@ -371,6 +353,7 @@ async function loadTimelineRowsForMachines(params: {
ts: true,
cycleCount: true,
actualCycleTime: true,
theoreticalCycleTime: true,
workOrderId: true,
sku: true,
},
@@ -404,6 +387,7 @@ async function loadTimelineRowsForMachines(params: {
ts: row.ts,
cycleCount: row.cycleCount,
actualCycleTime: row.actualCycleTime,
theoreticalCycleTime: row.theoreticalCycleTime ?? null,
workOrderId: row.workOrderId,
sku: row.sku,
});

View File

@@ -44,6 +44,7 @@ export type TimelineCycleRow = {
ts: Date;
cycleCount: number | null;
actualCycleTime: number;
theoreticalCycleTime: number | null;
workOrderId: string | null;
sku: string | null;
};
@@ -554,19 +555,21 @@ export function buildTimelineSegments(input: {
let currentProduction: RawSegment | null = null;
for (const cycle of dedupedCycles) {
if (!cycle.workOrderId) continue;
const cycleStartMs = cycle.ts.getTime();
// Pi stores cycle.ts at COMPLETION time; the cycle ran in [ts - actual, ts].
const completionMs = cycle.ts.getTime();
const cycleDurationMs = Math.max(
1000,
Math.min(600000, Math.trunc((safeNum(cycle.actualCycleTime) ?? 1) * 1000))
);
const cycleEndMs = cycleStartMs + cycleDurationMs;
const cycleStartMs = completionMs - cycleDurationMs;
const cycleEndMs = completionMs;
if (
currentProduction &&
currentProduction.type === "production" &&
currentProduction.workOrderId === cycle.workOrderId &&
currentProduction.sku === cycle.sku &&
cycleStartMs <= currentProduction.endMs + 5 * 60 * 1000
cycleStartMs <= currentProduction.endMs + MERGE_GAP_MS
) {
currentProduction.endMs = Math.max(currentProduction.endMs, cycleEndMs);
continue;
@@ -652,7 +655,11 @@ export function buildTimelineSegments(input: {
episode.firstTsMs = Math.min(episode.firstTsMs, tsMs);
episode.lastTsMs = Math.max(episode.lastTsMs, tsMs);
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
const startMs =
safeNum(data.start_ms) ??
safeNum(data.startMs) ??
safeNum(data.last_cycle_timestamp) ??
safeNum(data.lastCycleTimestamp);
const endMs = safeNum(data.end_ms) ?? safeNum(data.endMs);
const durationSec =
safeNum(data.duration_sec) ??
@@ -679,7 +686,7 @@ export function buildTimelineSegments(input: {
}
for (const episode of eventEpisodes.values()) {
const startMs = Math.trunc(episode.startMs ?? episode.firstTsMs);
let startMs = Math.trunc(episode.startMs ?? episode.firstTsMs);
let endMs = Math.trunc(episode.endMs ?? episode.lastTsMs);
if (episode.statusActive && !episode.statusResolved) {
@@ -694,8 +701,14 @@ export function buildTimelineSegments(input: {
}
}
} else if (endMs <= startMs && episode.durationSec != null && episode.durationSec > 0) {
// Event ts is end-of-stop; subtract duration to recover start.
// Only adjust if we don't already have an explicit startMs from data.
if (episode.startMs == null) {
startMs = endMs - episode.durationSec * 1000;
} else {
endMs = startMs + episode.durationSec * 1000;
}
}
if (endMs <= startMs) continue;
@@ -732,6 +745,34 @@ export function buildTimelineSegments(input: {
const absorbed = absorbShortSegments(normalized, ABSORB_SHORT_SEGMENT_MS);
const finalSegments = fillGapsWithIdle(absorbed, rangeStartMs, rangeEndMs);
// Live tail: machine cycling now, last cycle not yet completed.
// Extend production through right edge until microstop threshold passes.
const lastCycle = dedupedCycles[dedupedCycles.length - 1];
const idealCT = safeNum(lastCycle?.theoreticalCycleTime) ?? 120;
const MICRO_MS = idealCT * 1.5 * 1000;
// Live-tail: extend whatever the last real state was, until microstop threshold passes.
if (finalSegments.length >= 2) {
const last = finalSegments[finalSegments.length - 1];
const prev = finalSegments[finalSegments.length - 2];
if (last.type === "idle" && last.endMs >= rangeEndMs - 2000) {
const gapMs = last.endMs - prev.endMs;
let shouldExtend = false;
if (prev.type === "production" && gapMs < MICRO_MS) {
// mid-cycle: still running up to microstop threshold
shouldExtend = true;
} else if (prev.type === "microstop" || prev.type === "macrostop") {
// stoppage in progress: extend until resolved/next cycle
shouldExtend = true;
}
if (shouldExtend) {
prev.endMs = last.endMs;
prev.durationSec = Math.max(0, Math.trunc((prev.endMs - prev.startMs) / 1000));
finalSegments.pop();
}
}
}
return finalSegments;
}

View File

@@ -105,6 +105,7 @@ export async function getRecapTimelineForMachine(params: {
ts: true,
cycleCount: true,
actualCycleTime: true,
theoreticalCycleTime: true,
workOrderId: true,
sku: true,
},
@@ -151,10 +152,10 @@ export async function getRecapTimelineForMachine(params: {
ts: row.ts,
cycleCount: row.cycleCount,
actualCycleTime: row.actualCycleTime,
theoreticalCycleTime: row.theoreticalCycleTime,
workOrderId: row.workOrderId,
sku: row.sku,
}));
const events: TimelineEventRow[] = eventsRaw.map((row) => ({
ts: row.ts,
eventType: row.eventType,

View File

@@ -121,23 +121,14 @@ export type RecapQuery = {
shift?: string;
};
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "data-loss" | "offline" | "idle";
export type RecapStoppedReason = "machine_fault" | "not_started";
export type RecapDataLossReason = "untracked";
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "offline" | "idle";
/**
* Reason context for STOPPED and DATA_LOSS states.
* - When status is "stopped": stoppedReason is set, dataLossReason is null.
* - When status is "data-loss": dataLossReason is set, stoppedReason is null.
* - All other states: both are null.
* Reason context — currently empty in practice because the only STOPPED cause
* we can detect (given Node-RED's constraints) is machine_fault. Kept as a
* struct so future expansion doesn't require a type change downstream.
*/
export type RecapStateContext = {
stoppedReason: RecapStoppedReason | null;
dataLossReason: RecapDataLossReason | null;
/** For data-loss: how many untracked cycles have been detected so far. */
untrackedCycleCount: number | null;
};
export type RecapStateContext = Record<string, never>;
export type RecapSummaryMachine = {

View File

@@ -81,10 +81,6 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
const overrides = normalizeShiftOverrides(settings.shiftScheduleOverridesJson);
const defaults = normalizeDefaults(settings.defaultsJson);
const reasonCatalog =
isPlainObject(settings.defaultsJson) && "reasonCatalog" in settings.defaultsJson
? (settings.defaultsJson as AnyRecord).reasonCatalog
: null;
return {
orgId: settings.orgId,
@@ -105,9 +101,6 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
},
alerts: normalizeAlerts(settings.alertsJson),
defaults,
reasonCatalog: reasonCatalog ?? undefined,
reasonCatalogData: reasonCatalog ?? undefined,
reasonCatalogVersion: Number((reasonCatalog as AnyRecord | null)?.version ?? 1),
updatedAt: settings.updatedAt,
updatedBy: settings.updatedBy,
};

View File

@@ -10,7 +10,8 @@
"test:downtime-reason-guard": "node scripts/test-downtime-reason-guard.mjs",
"backfill:downtime-reasons": "node scripts/backfill-downtime-reasons.mjs",
"prisma:generate": "prisma generate",
"prisma:migrate:deploy": "prisma migrate deploy"
"prisma:migrate:deploy": "prisma migrate deploy",
"seed:reason-catalog": "node scripts/seed-reason-catalog-from-xlsx.mjs"
},
"dependencies": {
"@prisma/client": "^6.19.1",

View File

@@ -0,0 +1,42 @@
-- Reason catalog: relational storage (replaces JSON in org_settings for new data).
CREATE TABLE "reason_catalog_category" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code_prefix" TEXT NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "reason_catalog_category_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "reason_catalog_item" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"category_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code_suffix" TEXT NOT NULL,
"reason_code" TEXT NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "reason_catalog_item_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "reason_catalog_category_org_id_kind_active_idx" ON "reason_catalog_category"("org_id", "kind", "active");
CREATE UNIQUE INDEX "reason_catalog_item_org_id_reason_code_key" ON "reason_catalog_item"("org_id", "reason_code");
CREATE INDEX "reason_catalog_item_org_id_category_id_idx" ON "reason_catalog_item"("org_id", "category_id");
ALTER TABLE "reason_catalog_category" ADD CONSTRAINT "reason_catalog_category_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "reason_catalog_item" ADD CONSTRAINT "reason_catalog_item_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "reason_catalog_item" ADD CONSTRAINT "reason_catalog_item_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "reason_catalog_category"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -33,6 +33,8 @@ model Org {
shifts OrgShift[]
productCostOverrides ProductCostOverride[]
settingsAudits SettingsAudit[]
reasonCatalogCategories ReasonCatalogCategory[]
reasonCatalogItems ReasonCatalogItem[]
}
model User {
@@ -290,6 +292,42 @@ model IngestLog {
@@index([machineId, seq])
}
model ReasonCatalogCategory {
id String @id @default(uuid())
orgId String @map("org_id")
kind String
name String
codePrefix String @map("code_prefix")
sortOrder Int @default(0) @map("sort_order")
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
items ReasonCatalogItem[]
@@index([orgId, kind, active])
@@map("reason_catalog_category")
}
model ReasonCatalogItem {
id String @id @default(uuid())
orgId String @map("org_id")
categoryId String @map("category_id")
name String
codeSuffix String @map("code_suffix")
reasonCode String @map("reason_code")
sortOrder Int @default(0) @map("sort_order")
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
category ReasonCatalogCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@unique([orgId, reasonCode])
@@index([orgId, categoryId])
@@map("reason_catalog_item")
}
model OrgSettings {
orgId String @id @map("org_id")
timezone String @default("UTC")

BIN
reasons/Claves Tiempo Muerto.xlsx Executable file

Binary file not shown.

BIN
reasons/Claves de Scrap.xlsx Executable file

Binary file not shown.

View File

@@ -0,0 +1,22 @@
# Reason catalog: Control Tower → settings → MySQL (Pi)
## Authority
- The canonical catalog lives in Control Tower: `org_settings.defaults_json.reasonCatalog` (and `reasonCatalogData` alias), merged in API responses with fallback from `downtime_menu.md`.
- Each **detail** may include:
- `reasonCode`: official printed code (e.g. `DTPRC-01`, `MX001`). If omitted, a slug `CATEGORY__DETAIL` is derived for backward compatibility.
- `active`: `false` to hide from operator pickers while keeping history/report labels. **Never remove** a code from JSON once used; only set `active: false`.
## Raspberry Pi
1. Apply [`scripts/mysql/reason_catalog_mirror.sql`](mysql/reason_catalog_mirror.sql) on the same MySQL database used by Node-RED (`node-red-node-mysql`).
2. Deploy [`flows_may_4_26.json`](../flows_may_4_26.json). After each successful **Apply settings + update UI**, the flow emits a message on output 3 to **Build reason catalog mirror SQL**, which reads `global.settings.reasonCatalog` and runs `INSERT ... ON DUPLICATE KEY UPDATE` into `reason_catalog_row` (no deletes).
## Operator payloads (printed codes)
- On downtime acknowledge and scrap entry, send `reason.reasonCode` (and labels) matching the printed sheet. Ingest already normalizes and stores uppercase codes.
- Generate printable lists from the same JSON as CT: [`scripts/export-reason-catalog-csv.mjs`](export-reason-catalog-csv.mjs).
```bash
node scripts/export-reason-catalog-csv.mjs path/to/reasonCatalog.json > claves.csv
```

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* Export reasonCatalog JSON (downtime + scrap) to CSV for printed operator sheets.
* Usage: node scripts/export-reason-catalog-csv.mjs <path-to-catalog.json>
* cat reasonCatalog.json | node scripts/export-reason-catalog-csv.mjs
*
* CSV columns: kind, reasonCode, categoryLabel, reasonLabel, active
*/
import { readFileSync, existsSync } from "fs";
function escCsv(s) {
const t = String(s ?? "");
if (/[",\n\r]/.test(t)) return `"${t.replace(/"/g, '""')}"`;
return t;
}
function effectiveReasonCode(categoryId, detail) {
const c = String(detail.reasonCode ?? detail.code ?? "").trim();
if (c) return c.toUpperCase();
const cat = String(categoryId ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const det = String(detail.id ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return `${cat}__${det}`.toUpperCase();
}
function walk(kind, categories, rows) {
if (!Array.isArray(categories)) return;
for (const cat of categories) {
const cid = String(cat.id ?? "").trim();
const clab = String(cat.label ?? "").trim();
const details = Array.isArray(cat.details)
? cat.details
: Array.isArray(cat.children)
? cat.children
: [];
for (const d of details) {
const active = d.active === false ? "0" : "1";
const dlab = String(d.label ?? "").trim();
rows.push({
kind,
reasonCode: effectiveReasonCode(cid || clab, d),
categoryLabel: clab,
reasonLabel: dlab,
active,
});
}
}
}
let raw = "";
const arg = process.argv[2];
if (arg && existsSync(arg)) {
raw = readFileSync(arg, "utf8");
} else {
raw = readFileSync(0, "utf8");
}
const catalog = JSON.parse(raw || "{}");
const rows = [];
walk("downtime", catalog.downtime, rows);
walk("scrap", catalog.scrap, rows);
const header = ["kind", "reasonCode", "categoryLabel", "reasonLabel", "active"];
console.log(header.map(escCsv).join(","));
for (const r of rows) {
console.log([r.kind, r.reasonCode, r.categoryLabel, r.reasonLabel, r.active].map(escCsv).join(","));
}

View File

@@ -0,0 +1,18 @@
-- Mirror of Control Tower reasonCatalog on the Raspberry Pi (MySQL / MariaDB).
-- Policy: never DELETE rows by reason_code; only INSERT ... ON DUPLICATE KEY UPDATE
-- and set active=0 when CT marks a code inactive.
CREATE TABLE IF NOT EXISTS reason_catalog_row (
kind VARCHAR(16) NOT NULL COMMENT 'downtime | scrap',
category_id VARCHAR(128) NOT NULL,
category_label VARCHAR(255) NOT NULL,
reason_code VARCHAR(64) NOT NULL,
reason_label VARCHAR(512) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
active TINYINT(1) NOT NULL DEFAULT 1,
catalog_version INT NOT NULL DEFAULT 1,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (kind, reason_code),
KEY idx_reason_catalog_kind_active (kind, active),
KEY idx_reason_catalog_version (catalog_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env node
/**
* Patches flows_may_4_26.json:
* - Apply settings: pass reasonCode/active in catalog; 3 outputs; trigger MySQL mirror sync
* - New nodes: Build reason catalog mirror SQL → mysql
*/
import { readFileSync, writeFileSync } from "fs";
const path = new URL("../flows_may_4_26.json", import.meta.url).pathname;
const j = JSON.parse(readFileSync(path, "utf8"));
const applyId = "abbec199700a5e29";
const gateId = "f8e0d1c2b3a40911";
const mysqlPersistId = "f8e0d1c2b3a40912";
const apply = j.find((n) => n.id === applyId);
if (!apply || apply.type !== "function") {
console.error("Apply settings node not found");
process.exit(1);
}
const oldDetails =
"const details = detailsRaw.map((d, jdx) => ({\n id: String(d.id || d.detailId || (categoryId + \"_d\" + jdx)),\n label: String(d.label || d.detailLabel || (\"Detalle \" + (jdx + 1)))\n }));";
const newDetails = `const details = detailsRaw.map((d, jdx) => {
const row = {
id: String(d.id || d.detailId || (categoryId + "_d" + jdx)),
label: String(d.label || d.detailLabel || ("Detalle " + (jdx + 1)))
};
if (d.reasonCode != null && String(d.reasonCode).trim()) {
row.reasonCode = String(d.reasonCode).trim();
} else if (d.code != null && String(d.code).trim()) {
row.reasonCode = String(d.code).trim();
}
if (d.active === false) {
row.active = false;
}
return row;
});`;
if (!apply.func.includes(oldDetails)) {
console.error("Expected normalizeCatalog details snippet not found; abort.");
process.exit(1);
}
apply.func = apply.func.replace(oldDetails, newDetails);
apply.func = apply.func.replaceAll("node.send([uiConfigMsg, null]);", "node.send([uiConfigMsg, null, null]);");
apply.func = apply.func.replaceAll("node.send([uiMoldMsg, null]);", "node.send([uiMoldMsg, null, null]);");
apply.func = apply.func.replaceAll("node.send([uiReadOnlyMsg, null]);", "node.send([uiReadOnlyMsg, null, null]);");
apply.func = apply.func.replaceAll("node.send([uiReasonCatalogMsg, null]);", "node.send([uiReasonCatalogMsg, null, null]);");
const oldReturnAck = `const ackMsg = {
topic: ackTopic,
payload: JSON.stringify({
type: "settings_ack",
orgId,
machineId,
version,
source: "node-red",
ts: new Date().toISOString()
})
};
return [null, ackMsg];
`;
const newReturnAck = `const ackMsg = {
topic: ackTopic,
payload: JSON.stringify({
type: "settings_ack",
orgId,
machineId,
version,
source: "node-red",
ts: new Date().toISOString()
})
};
const mirrorTrigger = { payload: { _syncReasonCatalog: true } };
return [null, ackMsg, mirrorTrigger];
`;
if (!apply.func.includes(oldReturnAck.trim())) {
console.error("Expected ack return block not found");
process.exit(1);
}
apply.func = apply.func.replace(oldReturnAck.trim(), newReturnAck.trim());
apply.func = apply.func.replace(
`if (!orgId || !machineId) {
return [null, null];
}`,
`if (!orgId || !machineId) {
return [null, null, null];
}`
);
apply.outputs = 3;
apply.wires = [
["2c8562b2471078ab", "dbfd127c516efa87", "9748899355370bae"],
[],
[gateId],
];
const gateFunc = `const p = msg.payload || {};
if (!p._syncReasonCatalog) {
return null;
}
const settings = global.get("settings") || {};
const cat = settings.reasonCatalog || {};
const ver = Number(cat.version || 1);
function esc(v) {
return String(v ?? "").replace(/\\\\/g, "\\\\\\\\").replace(/'/g, "''");
}
const parts = [];
function walk(kind, list) {
if (!Array.isArray(list)) {
return;
}
let sort = 0;
list.forEach((c) => {
const categoryId = esc(String(c.id || ""));
const categoryLabel = esc(String(c.label || ""));
const ch = c.children || c.details || [];
if (!Array.isArray(ch)) {
return;
}
ch.forEach((d) => {
const id = String(d.id || "").trim();
const label = String(d.label || "").trim();
const rc = String(d.reasonCode || d.code || id || "").trim();
if (!rc) {
return;
}
const active = d.active === false ? 0 : 1;
parts.push(
"('" +
kind +
"','" +
categoryId +
"','" +
categoryLabel +
"','" +
esc(rc) +
"','" +
esc(label) +
"'," +
sort +
"," +
active +
"," +
ver +
")"
);
sort += 1;
});
});
}
walk("downtime", cat.downtime || []);
walk("scrap", cat.scrap || []);
if (!parts.length) {
node.status({ fill: "yellow", shape: "ring", text: "No reason rows to mirror" });
return null;
}
const sql =
"INSERT INTO reason_catalog_row (kind,category_id,category_label,reason_code,reason_label,sort_order,active,catalog_version) VALUES " +
parts.join(",") +
" ON DUPLICATE KEY UPDATE category_id=VALUES(category_id),category_label=VALUES(category_label),reason_label=VALUES(reason_label),sort_order=VALUES(sort_order),active=VALUES(active),catalog_version=VALUES(catalog_version),updated_at=CURRENT_TIMESTAMP(3)";
node.status({ fill: "green", shape: "dot", text: "Reason mirror SQL built" });
msg.topic = sql;
msg.payload = [];
return msg;
`;
const gateNode = {
id: gateId,
type: "function",
z: "05d4cb231221b842",
g: "a1b43a9e095c10db",
name: "Build reason catalog mirror SQL",
func: gateFunc,
outputs: 1,
timeout: 0,
noerr: 0,
initialize: "",
finalize: "",
libs: [],
x: 1500,
y: 1020,
wires: [[mysqlPersistId]],
};
const mysqlNode = {
id: mysqlPersistId,
type: "mysql",
z: "05d4cb231221b842",
g: "a1b43a9e095c10db",
mydb: "fc9634aabefee16b",
name: "Persist reason catalog mirror",
x: 1820,
y: 1020,
wires: [[]],
};
if (j.some((n) => n.id === gateId)) {
console.log("Patch already applied (gate node exists). Skipping insert.");
} else {
const idx = j.findIndex((n) => n.id === applyId);
j.splice(idx + 1, 0, gateNode, mysqlNode);
}
writeFileSync(path, JSON.stringify(j, null, 4) + "\n");
console.log("Patched", path);

View File

@@ -0,0 +1,280 @@
#!/usr/bin/env node
/**
* Load downtime + scrap catalogs from Excel under ./reasons/ into Postgres.
*
* npx dotenv -e .env -- node scripts/seed-reason-catalog-from-xlsx.mjs --org-id <uuid>
* npx dotenv -e .env -- node scripts/seed-reason-catalog-from-xlsx.mjs --org-slug my-org --replace
*
* --dry-run parse and print counts only
* --replace delete existing reason_catalog_* rows for the org before insert
*/
import { readFileSync, existsSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import * as XLSX from "xlsx";
import { PrismaClient } from "@prisma/client";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..");
const prisma = new PrismaClient();
function composeReasonCode(prefix, suffix) {
const p = String(prefix ?? "").trim().toUpperCase();
const s = String(suffix ?? "").trim();
if (/^\d+$/.test(s) && p.length >= 3) {
return `${p}-${s}`.toUpperCase();
}
return `${p}${s}`.toUpperCase();
}
function parseArgs(argv) {
const out = {
dryRun: false,
replace: false,
orgId: null,
orgSlug: null,
downtimePath: path.join(ROOT, "reasons", "Claves Tiempo Muerto.xlsx"),
scrapPath: path.join(ROOT, "reasons", "Claves de Scrap.xlsx"),
};
for (let i = 0; i < argv.length; i += 1) {
const t = argv[i];
if (t === "--dry-run") out.dryRun = true;
else if (t === "--replace") out.replace = true;
else if (t === "--org-id") {
out.orgId = argv[i + 1] || null;
i += 1;
} else if (t === "--org-slug") {
out.orgSlug = argv[i + 1] || null;
i += 1;
} else if (t === "--downtime") {
out.downtimePath = argv[i + 1] || out.downtimePath;
i += 1;
} else if (t === "--scrap") {
out.scrapPath = argv[i + 1] || out.scrapPath;
i += 1;
} else {
throw new Error(`Unknown arg: ${t}`);
}
}
return out;
}
function readWorkbook(filePath) {
if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
const buf = readFileSync(filePath);
return XLSX.read(buf, { type: "buffer" });
}
/** @returns {{ kind:'downtime', name:string, codePrefix:string, items: { suffix:string, name:string }[] }[]} */
function parseDowntimeXlsx(filePath) {
const wb = readWorkbook(filePath);
const data = XLSX.utils.sheet_to_json(wb.Sheets["Sheet1"], { header: 1, defval: "" });
const headerRowIdx = 3;
const header = data[headerRowIdx] || [];
const cols = [];
for (let c = 0; c < header.length; c += 1) {
if (String(header[c] || "").trim()) cols.push(c);
}
const categoryByCol = {};
cols.forEach((c) => {
categoryByCol[c] = String(header[c]).trim();
});
const CODE = /^([A-Z0-9][A-Za-z0-9-]*)-(\d+)\s+(.*)$/;
const rawItems = [];
for (let r = headerRowIdx + 1; r < data.length; r += 1) {
const row = data[r] || [];
for (const c of cols) {
const cell = String(row[c] ?? "").trim();
if (!cell) continue;
const m = cell.match(CODE);
if (m) {
rawItems.push({
col: c,
categoryLabel: categoryByCol[c],
prefix: m[1].toUpperCase(),
suffix: m[2],
name: m[3].trim(),
row: r,
});
} else if (cell.length > 2 && cell === cell.toUpperCase() && !/\d/.test(cell)) {
categoryByCol[c] = cell;
}
}
}
/** @type {Map<string, { kind:'downtime', name:string, codePrefix:string, items: { suffix:string, name:string }[]}>} */
const catMap = new Map();
function catKey(categoryName, prefix) {
return `${categoryName}\0${prefix}`;
}
for (const it of rawItems) {
const key = catKey(it.categoryLabel, it.prefix);
let bucket = catMap.get(key);
if (!bucket) {
bucket = { kind: "downtime", name: it.categoryLabel, codePrefix: it.prefix, items: [] };
catMap.set(key, bucket);
}
bucket.items.push({ suffix: it.suffix, name: it.name });
}
/** Dedupe suffix per category (keep first description). */
for (const b of catMap.values()) {
const seen = new Map();
const next = [];
for (const row of b.items) {
if (seen.has(row.suffix)) continue;
seen.set(row.suffix, true);
next.push(row);
}
b.items = next.sort((a, b) => Number(a.suffix) - Number(b.suffix));
}
return [...catMap.values()];
}
function parseScrapXlsx(filePath) {
const wb = readWorkbook(filePath);
const data = XLSX.utils.sheet_to_json(wb.Sheets["Sheet1"], { header: 1, defval: "" });
/** @type { { suffix:string, name:string, full:string }[] } */
const rows = [];
for (let r = 0; r < data.length; r += 1) {
const clave = String(data[r][0] ?? "").trim();
const desc = String(data[r][1] ?? "").trim().replace(/\s+/g, " ");
if (!clave || /^clave/i.test(clave)) continue;
if (!desc || /Rev\.?\s*[A-Z]/i.test(desc)) continue;
const m = clave.toUpperCase().match(/^([A-Z]+)(\d+)$/);
if (!m) {
console.warn(`[scrap] skip row ${r}:`, clave);
continue;
}
rows.push({
full: `${m[1]}${m[2]}`,
suffix: m[2],
name: desc,
});
}
/** Single category when all MX… */
const prefixes = new Set(rows.map((x) => x.full.replace(/\d+$/, "")));
if (prefixes.size !== 1) {
console.warn("[scrap] multiple prefixes:", [...prefixes]);
}
const codePrefix = [...prefixes][0] || "MX";
const items = rows.map(({ suffix, name }) => ({ suffix, name }));
return [{ kind: "scrap", name: "Scrap", codePrefix, items }];
}
async function main() {
const args = parseArgs(process.argv.slice(2));
let orgId = args.orgId;
if (!orgId && args.orgSlug) {
const org = await prisma.org.findUnique({ where: { slug: args.orgSlug }, select: { id: true } });
if (!org) throw new Error(`Org slug not found: ${args.orgSlug}`);
orgId = org.id;
}
if (!orgId) {
console.error("Provide --org-id <uuid> or --org-slug <slug>");
process.exit(1);
}
const downtimeCats = parseDowntimeXlsx(args.downtimePath);
const scrapCats = parseScrapXlsx(args.scrapPath);
const totalItems =
downtimeCats.reduce((n, c) => n + c.items.length, 0) + scrapCats.reduce((n, c) => n + c.items.length, 0);
console.log("[seed] downtime categories:", downtimeCats.length, "scrap categories:", scrapCats.length);
console.log("[seed] total items:", totalItems);
if (args.dryRun) {
console.log(JSON.stringify({ downtimeCats: downtimeCats.slice(0, 2), scrapCats }, null, 2));
return;
}
const existing = await prisma.reasonCatalogCategory.count({ where: { orgId } });
if (existing && !args.replace) {
console.error(
`Org already has ${existing} catalog categor(ies). Re-run with --replace to wipe and reload, or use Control Tower UI.`
);
process.exit(1);
}
const bundled = [...downtimeCats, ...scrapCats];
/** @type {string[]} */
const dupCheck = [];
await prisma.$transaction(async (tx) => {
if (args.replace) {
await tx.reasonCatalogItem.deleteMany({ where: { orgId } });
await tx.reasonCatalogCategory.deleteMany({ where: { orgId } });
}
let catOrder = 0;
for (const block of bundled) {
const category = await tx.reasonCatalogCategory.create({
data: {
orgId,
kind: block.kind,
name: block.name,
codePrefix: block.codePrefix,
sortOrder: catOrder++,
active: true,
},
});
let itOrder = 0;
for (const row of block.items) {
const reasonCode = composeReasonCode(block.codePrefix, row.suffix);
dupCheck.push(reasonCode);
await tx.reasonCatalogItem.create({
data: {
orgId,
categoryId: category.id,
name: row.name,
codeSuffix: row.suffix,
reasonCode,
sortOrder: itOrder++,
active: true,
},
});
}
}
await tx.orgSettings.update({
where: { orgId },
data: { version: { increment: 1 } },
});
});
const seen = new Set();
let dup = 0;
for (const rc of dupCheck) {
if (seen.has(rc)) dup++;
seen.add(rc);
}
if (dup) console.warn("[seed] duplicate reason_code skipped by DB unique?", dup);
console.log("[seed] done. Bump org_settings.version (+1).");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});