pre-bemis

This commit is contained in:
Marcelo
2026-04-22 05:04:19 +00:00
parent ac1a7900c8
commit 80d27f83b6
91 changed files with 11769 additions and 820 deletions

View File

@@ -4,6 +4,14 @@ import { getMachineAuth } from "@/lib/machineAuthCache";
import { z } from "zod";
import { evaluateAlertsForEvent } from "@/lib/alerts/engine";
import { toJsonValue } from "@/lib/prismaJson";
import {
findCatalogReason,
loadFallbackReasonCatalog,
normalizeReasonCatalog,
toReasonCode,
type ReasonCatalog,
type ReasonCatalogKind,
} from "@/lib/reasonCatalog";
const normalizeType = (t: unknown) =>
String(t ?? "")
@@ -30,6 +38,8 @@ const CANON_TYPE: Record<string, string> = {
"microparo": "microstop",
"micro-paro": "microstop",
"down": "stop",
"downtime-acknowledged": "downtime-acknowledged",
"scrap-manual-entry": "scrap-manual-entry",
};
const ALLOWED_TYPES = new Set([
@@ -42,6 +52,8 @@ const ALLOWED_TYPES = new Set([
"quality-spike",
"performance-degradation",
"predictive-oee-decline",
"downtime-acknowledged",
"scrap-manual-entry",
]);
const machineIdSchema = z.string().uuid();
@@ -58,6 +70,153 @@ function clampText(value: unknown, maxLen: number) {
return text.length > maxLen ? text.slice(0, maxLen) : text;
}
function numberFrom(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function canonicalText(value: unknown) {
return String(value ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function parseReasonPath(rawPath: unknown) {
let category: string | null = null;
let detail: string | null = null;
if (Array.isArray(rawPath)) {
const first = rawPath[0];
const second = rawPath[1];
if (typeof first === "string") category = first;
if (typeof second === "string") detail = second;
if (asRecord(first)) category = clampText(first.id ?? first.label ?? first.value, 120);
if (asRecord(second)) detail = clampText(second.id ?? second.label ?? second.value, 120);
} else if (typeof rawPath === "string") {
const pieces = rawPath
.split(/>|\/|\\|\|/g)
.map((p) => p.trim())
.filter(Boolean);
category = pieces[0] ?? null;
detail = pieces[1] ?? null;
}
return {
category: clampText(category, 120),
detail: clampText(detail, 120),
};
}
function parseReasonTextPath(reasonText: unknown) {
const text = clampText(reasonText, 240);
if (!text) return { category: null as string | null, detail: null as string | null };
const pieces = text
.split(/>|\/|\\|\|/g)
.map((p) => p.trim())
.filter(Boolean);
return {
category: clampText(pieces[0] ?? null, 120),
detail: clampText(pieces[1] ?? null, 120),
};
}
function findCatalogReasonFlexible(
catalog: ReasonCatalog | null,
kind: ReasonCatalogKind,
categoryIdOrLabel: unknown,
detailIdOrLabel: unknown
) {
const direct = findCatalogReason(catalog, kind, categoryIdOrLabel, detailIdOrLabel);
if (direct) return direct;
if (!catalog) return null;
const catNeedle = canonicalText(categoryIdOrLabel);
const detNeedle = canonicalText(detailIdOrLabel);
if (!catNeedle || !detNeedle) return null;
for (const category of catalog[kind] ?? []) {
const catMatch =
canonicalText(category.id) === catNeedle || canonicalText(category.label) === catNeedle;
if (!catMatch) continue;
for (const detail of category.details) {
const detMatch = canonicalText(detail.id) === detNeedle || canonicalText(detail.label) === detNeedle;
if (!detMatch) continue;
return {
categoryId: category.id,
categoryLabel: category.label,
detailId: detail.id,
detailLabel: detail.label,
reasonCode: toReasonCode(category.id, detail.id),
reasonLabel: `${category.label} > ${detail.label}`,
};
}
}
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,
catalog: ReasonCatalog | null,
fallbackVersion: number
) {
const reasonPath = parseReasonPath(raw.reasonPath);
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 categoryLabelRaw = clampText(raw.categoryLabel ?? reasonPath.category ?? reasonTextPath.category, 120);
const detailLabelRaw = clampText(raw.detailLabel ?? reasonPath.detail ?? reasonTextPath.detail, 120);
const reasonCode =
clampText(raw.reasonCode, 64)?.toUpperCase() ??
fromCatalog?.reasonCode ??
toReasonCode(categoryIdRaw ?? categoryLabelRaw, detailIdRaw ?? detailLabelRaw) ??
null;
const categoryId = fromCatalog?.categoryId ?? categoryIdRaw;
const detailId = fromCatalog?.detailId ?? detailIdRaw;
const categoryLabel = fromCatalog?.categoryLabel ?? categoryLabelRaw;
const detailLabel = fromCatalog?.detailLabel ?? detailLabelRaw;
const pathLabel =
clampText(raw.reasonText, 240) ??
fromCatalog?.reasonLabel ??
(categoryLabel && detailLabel ? `${categoryLabel} > ${detailLabel}` : null) ??
detailLabel ??
categoryLabel ??
reasonCode;
const catalogVersionRaw = numberFrom(raw.catalogVersion);
const catalogVersion = catalogVersionRaw != null ? Math.trunc(catalogVersionRaw) : fallbackVersion;
return {
type: kind,
categoryId,
categoryLabel,
detailId,
detailLabel,
reasonCode,
reasonLabel: pathLabel,
reasonText: pathLabel,
catalogVersion,
};
}
export async function POST(req: Request) {
const apiKey = req.headers.get("x-api-key");
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
@@ -103,8 +262,11 @@ export async function POST(req: Request) {
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: machine.orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
});
const fallbackCatalog = await loadFallbackReasonCatalog();
const settingsCatalog = getCatalogFromDefaults(orgSettings?.defaultsJson);
const reasonCatalog = settingsCatalog ?? fallbackCatalog;
const defaultMicroMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
const defaultMacroMultiplier = Math.max(
@@ -129,6 +291,8 @@ export async function POST(req: Request) {
continue;
}
const evData = asRecord(evRecord.data) ?? {};
const evReason = asRecord(evRecord.reason) ?? asRecord(evData.reason);
const evDowntime = asRecord(evRecord.downtime) ?? asRecord(evData.downtime);
const rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? "";
const typ0 = normalizeType(rawType);
@@ -211,6 +375,8 @@ export async function POST(req: Request) {
if (evRecord.alert_id != null && dataObj.alert_id == null) dataObj.alert_id = evRecord.alert_id;
if (evRecord.is_update != null && dataObj.is_update == null) dataObj.is_update = evRecord.is_update;
if (evRecord.is_auto_ack != null && dataObj.is_auto_ack == null) dataObj.is_auto_ack = evRecord.is_auto_ack;
if (evReason && dataObj.reason == null) dataObj.reason = evReason;
if (evDowntime && dataObj.downtime == null) dataObj.downtime = evDowntime;
const activeWorkOrder = asRecord(evRecord.activeWorkOrder);
const dataActiveWorkOrder = asRecord(evData.activeWorkOrder);
@@ -244,8 +410,127 @@ export async function POST(req: Request) {
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
if (evReason) {
const inferredKind: ReasonCatalogKind =
String(evReason.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
? "scrap"
: "downtime";
const resolved = resolveReason(evReason, inferredKind, reasonCatalog, reasonCatalog.version);
if (resolved.reasonCode) {
const reasonId =
clampText(evReason.reasonId, 128) ??
(inferredKind === "downtime"
? `evt:${machine.id}:downtime:${clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id}`
: `evt:${machine.id}:scrap:${clampText(evReason.scrapEntryId, 128) ?? row.id}`);
const workOrderId =
clampText(evRecord.work_order_id, 64) ??
clampText(evData.work_order_id, 64) ??
clampText(evRecord.workOrderId, 64) ??
null;
const commonWrite = {
reasonCode: resolved.reasonCode,
reasonLabel: resolved.reasonLabel ?? resolved.reasonCode,
reasonText: resolved.reasonText ?? null,
capturedAt: row.ts,
workOrderId,
schemaVersion: Math.max(1, Math.trunc(resolved.catalogVersion)),
meta: toJsonValue({
source: "ingest:event",
eventId: row.id,
eventType: row.eventType,
incidentKey: clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128),
anomalyType:
clampText(evRecord.anomalyType, 64) ??
clampText(evDowntime?.anomalyType, 64) ??
clampText(evRecord.anomaly_type, 64),
reason: {
type: resolved.type,
categoryId: resolved.categoryId,
categoryLabel: resolved.categoryLabel,
detailId: resolved.detailId,
detailLabel: resolved.detailLabel,
reasonText: resolved.reasonText,
catalogVersion: resolved.catalogVersion,
},
}),
};
if (inferredKind === "downtime") {
const incidentKey = clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id;
const durationSeconds =
numberFrom(evDowntime?.durationSeconds) ??
numberFrom(evData.stoppage_duration_seconds) ??
numberFrom(evData.stop_duration_seconds) ??
null;
const episodeEndTsMs =
numberFrom(evDowntime?.episodeEndTsMs) ??
numberFrom(evDowntime?.acknowledgedAtMs) ??
null;
await prisma.reasonEntry.upsert({
where: { reasonId },
create: {
orgId: machine.orgId,
machineId: machine.id,
reasonId,
kind: "downtime",
episodeId: incidentKey,
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
...commonWrite,
},
update: {
kind: "downtime",
episodeId: incidentKey,
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
...commonWrite,
},
});
} else {
const scrapEntryId =
clampText(evReason.scrapEntryId, 128) ??
clampText(evRecord.id, 128) ??
clampText(evRecord.eventId, 128) ??
row.id;
const scrapQtyRaw =
numberFrom(evRecord.scrapDelta) ??
numberFrom(evData.scrapDelta) ??
numberFrom(evData.scrap_delta) ??
0;
const scrapQty = Math.max(0, Math.trunc(scrapQtyRaw));
await prisma.reasonEntry.upsert({
where: { reasonId },
create: {
orgId: machine.orgId,
machineId: machine.id,
reasonId,
kind: "scrap",
scrapEntryId,
scrapQty,
scrapUnit: clampText(evReason.scrapUnit, 16) ?? null,
...commonWrite,
},
update: {
kind: "scrap",
scrapEntryId,
scrapQty,
scrapUnit: clampText(evReason.scrapUnit, 16) ?? null,
...commonWrite,
},
});
}
}
}
try {
await evaluateAlertsForEvent(row.id);
if (row.eventType !== "downtime-acknowledged" && row.eventType !== "scrap-manual-entry") {
await evaluateAlertsForEvent(row.id);
}
} catch (err) {
console.error("[alerts] evaluation failed", err);
}