This commit is contained in:
Marcelo
2026-04-26 16:31:04 +00:00
parent 66c89f9bf4
commit 7e0fe5c2e1
28 changed files with 5310 additions and 2741 deletions

0
426 Normal file
View File

0
476 Normal file
View File

View File

@@ -0,0 +1,30 @@
/**
* Shared markup for loading states (used by `loading.tsx` and explicit `<Suspense>` in pages)
* so the recap UI always shows the same skeleton while server data is pending.
*/
export function RecapGridPageSkeleton() {
return (
<div className="p-4 sm:p-6">
<div className="mb-4 h-24 animate-pulse rounded-2xl border border-white/10 bg-black/40" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="h-[220px] animate-pulse rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
</div>
);
}
export function RecapDetailPageSkeleton() {
return (
<div className="p-4 sm:p-6">
<div className="h-16 animate-pulse rounded-2xl border border-white/10 bg-black/40" />
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-24 animate-pulse rounded-2xl border border-white/10 bg-black/30" />
))}
</div>
<div className="mt-4 h-48 animate-pulse rounded-2xl border border-white/10 bg-black/30" />
</div>
);
}

View File

@@ -1,13 +1,5 @@
import { RecapDetailPageSkeleton } from "../RecapPageSkeletons";
export default function LoadingRecapDetail() { export default function LoadingRecapDetail() {
return ( return <RecapDetailPageSkeleton />;
<div className="p-4 sm:p-6">
<div className="h-16 animate-pulse rounded-2xl border border-white/10 bg-black/40" />
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-24 animate-pulse rounded-2xl border border-white/10 bg-black/30" />
))}
</div>
<div className="mt-4 h-48 animate-pulse rounded-2xl border border-white/10 bg-black/30" />
</div>
);
} }

View File

@@ -1,9 +1,11 @@
import { Suspense } from "react";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { requireSession } from "@/lib/auth/requireSession"; import { requireSession } from "@/lib/auth/requireSession";
import { getRecapMachineDetailCached, parseRecapDetailRangeInput } from "@/lib/recap/redesign"; import { getRecapMachineDetailCached, parseRecapDetailRangeInput } from "@/lib/recap/redesign";
import { RecapDetailPageSkeleton } from "../RecapPageSkeletons";
import RecapDetailClient from "./RecapDetailClient"; import RecapDetailClient from "./RecapDetailClient";
export default async function RecapMachineDetailPage({ async function RecapDetailData({
params, params,
searchParams, searchParams,
}: { }: {
@@ -33,3 +35,17 @@ export default async function RecapMachineDetailPage({
/> />
); );
} }
export default function RecapMachineDetailPage({
params,
searchParams,
}: {
params: Promise<{ machineId: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
return (
<Suspense fallback={<RecapDetailPageSkeleton />}>
<RecapDetailData params={params} searchParams={searchParams} />
</Suspense>
);
}

View File

@@ -1,12 +1,5 @@
import { RecapGridPageSkeleton } from "./RecapPageSkeletons";
export default function LoadingRecapGrid() { export default function LoadingRecapGrid() {
return ( return <RecapGridPageSkeleton />;
<div className="p-4 sm:p-6">
<div className="mb-4 h-24 animate-pulse rounded-2xl border border-white/10 bg-black/40" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="h-[220px] animate-pulse rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
</div>
);
} }

View File

@@ -1,9 +1,11 @@
import { Suspense } from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireSession } from "@/lib/auth/requireSession"; import { requireSession } from "@/lib/auth/requireSession";
import { getRecapSummaryCached } from "@/lib/recap/redesign"; import { getRecapSummaryCached } from "@/lib/recap/redesign";
import RecapGridClient from "./RecapGridClient"; import RecapGridClient from "./RecapGridClient";
import { RecapGridPageSkeleton } from "./RecapPageSkeletons";
export default async function RecapPage() { async function RecapGridData() {
const session = await requireSession(); const session = await requireSession();
if (!session) redirect("/login?next=/recap"); if (!session) redirect("/login?next=/recap");
@@ -14,3 +16,11 @@ export default async function RecapPage() {
return <RecapGridClient initialData={initialData} />; return <RecapGridClient initialData={initialData} />;
} }
export default function RecapPage() {
return (
<Suspense fallback={<RecapGridPageSkeleton />}>
<RecapGridData />
</Suspense>
);
}

View File

@@ -172,11 +172,35 @@ export async function POST(req: Request) {
}; };
}); });
const result = await prisma.machineCycle.createMany({
data: rows,
skipDuplicates: true,
});
if (rows.length === 1) { if (rows.length === 1) {
const row = await prisma.machineCycle.create({ data: rows[0] }); const row = await prisma.machineCycle.findFirst({
return NextResponse.json({ ok: true, id: row.id, ts: row.ts }); where: {
orgId: machine.orgId,
machineId: machine.id,
ts: rows[0].ts,
cycleCount: rows[0].cycleCount ?? null,
},
orderBy: { createdAt: "asc" },
select: { id: true, ts: true },
});
return NextResponse.json({
ok: true,
id: row?.id,
ts: row?.ts,
inserted: result.count,
duplicate: result.count === 0,
});
} }
const result = await prisma.machineCycle.createMany({ data: rows }); return NextResponse.json({
return NextResponse.json({ ok: true, count: result.count }); ok: true,
inserted: result.count,
requested: rows.length,
count: result.count,
});
} }

View File

@@ -80,6 +80,15 @@ function numberFrom(value: unknown) {
} }
return null; return null;
} }
function parseSeqToBigInt(value: unknown): bigint | null {
if (value === null || value === undefined) return null;
if (typeof value === "number") {
if (!Number.isInteger(value) || value < 0) return null;
return BigInt(value);
}
if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value);
return null;
}
function canonicalText(value: unknown) { function canonicalText(value: unknown) {
return String(value ?? "") return String(value ?? "")
@@ -262,6 +271,8 @@ export async function POST(req: Request) {
const machine = await getMachineAuth(String(machineId), apiKey); const machine = await getMachineAuth(String(machineId), apiKey);
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const bodySeq = parseSeqToBigInt(bodyRecord.seq);
const bodySchemaVersion = clampText(bodyRecord.schemaVersion, 16);
const orgSettings = await prisma.orgSettings.findUnique({ const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: machine.orgId }, where: { orgId: machine.orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true }, select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
@@ -410,35 +421,90 @@ export async function POST(req: Request) {
const activeWorkOrder = asRecord(evRecord.activeWorkOrder); const activeWorkOrder = asRecord(evRecord.activeWorkOrder);
const dataActiveWorkOrder = asRecord(evData.activeWorkOrder); const dataActiveWorkOrder = asRecord(evData.activeWorkOrder);
const row = await prisma.machineEvent.create({ // ✨ Cada evento puede traer su propio seq, o usar el del payload raíz
data: { const evSeq =
orgId: machine.orgId, parseSeqToBigInt(evRecord.seq) ??
machineId: machine.id, parseSeqToBigInt(evData.seq) ??
ts, bodySeq;
topic: clampText(evRecord.topic ?? finalType, 64) ?? finalType,
eventType: finalType, const evSchemaVersion =
severity: sev, clampText(evRecord.schemaVersion, 16) ??
requiresAck: !!evRecord.requires_ack, bodySchemaVersion;
title,
description, const eventData = {
data: toJsonValue(dataObj), orgId: machine.orgId,
workOrderId: machineId: machine.id,
clampText(evRecord.work_order_id, 64) ?? schemaVersion: evSchemaVersion,
clampText(evData.work_order_id, 64) ?? seq: evSeq,
clampText(activeWorkOrder?.id, 64) ?? ts,
clampText(dataActiveWorkOrder?.id, 64) ?? topic: clampText(evRecord.topic ?? finalType, 64) ?? finalType,
null, eventType: finalType,
sku: severity: sev,
clampText(evRecord.sku, 64) ?? requiresAck: !!evRecord.requires_ack,
clampText(evData.sku, 64) ?? title,
clampText(activeWorkOrder?.sku, 64) ?? description,
clampText(dataActiveWorkOrder?.sku, 64) ?? data: toJsonValue(dataObj),
null, workOrderId:
}, clampText(evRecord.work_order_id, 64) ??
clampText(evData.work_order_id, 64) ??
clampText(activeWorkOrder?.id, 64) ??
clampText(dataActiveWorkOrder?.id, 64) ??
null,
sku:
clampText(evRecord.sku, 64) ??
clampText(evData.sku, 64) ??
clampText(activeWorkOrder?.sku, 64) ??
clampText(dataActiveWorkOrder?.sku, 64) ??
null,
};
// ✨ Idempotente: si ya existe (mismo orgId+machineId+seq), no inserta
const insertResult = await prisma.machineEvent.createMany({
data: [eventData],
skipDuplicates: true,
}); });
// ✨ Buscar la fila (la recién creada o la duplicada existente)
let row;
if (evSeq != null) {
row = await prisma.machineEvent.findFirst({
where: {
orgId: machine.orgId,
machineId: machine.id,
seq: evSeq,
},
orderBy: { ts: "asc" },
});
} else {
// Sin seq, buscar por ts (fallback compatibilidad con eventos viejos)
row = await prisma.machineEvent.findFirst({
where: {
orgId: machine.orgId,
machineId: machine.id,
ts,
eventType: finalType,
},
orderBy: { ts: "desc" },
});
}
if (!row) {
skipped.push({ reason: "row_not_found_after_insert", seq: evSeq?.toString() });
continue;
}
const wasDuplicate = insertResult.count === 0;
// Si fue duplicado, no procesar reasonEntry ni alertas (ya se hicieron antes)
if (wasDuplicate) {
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
continue; // ✨ saltar el resto del procesamiento
}
created.push({ id: row.id, ts: row.ts, eventType: row.eventType }); created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
// If the payload carries a `reason`, create the corresponding ReasonEntry. // If the payload carries a `reason`, create the corresponding ReasonEntry.
// If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage. // If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage.
if (evRecord.is_update || evRecord.is_auto_ack || dataObj.is_update || dataObj.is_auto_ack){ if (evRecord.is_update || evRecord.is_auto_ack || dataObj.is_update || dataObj.is_auto_ack){

View File

@@ -0,0 +1,670 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
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 ?? "")
.trim()
.toLowerCase()
.replace(/_/g, "-");
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
const CANON_TYPE: Record<string, string> = {
// Node-RED
"production-stopped": "stop",
"oee-drop": "oee-drop",
"quality-spike": "quality-spike",
"predictive-oee-decline": "predictive-oee-decline",
"performance-degradation": "performance-degradation",
// legacy / synonyms
"macroparo": "macrostop",
"macro-stop": "macrostop",
"microparo": "microstop",
"micro-paro": "microstop",
"down": "stop",
"downtime-acknowledged": "downtime-acknowledged",
"scrap-manual-entry": "scrap-manual-entry",
"mold-change": "mold-change",
};
const ALLOWED_TYPES = new Set([
"slow-cycle",
"microstop",
"macrostop",
"offline",
"error",
"oee-drop",
"quality-spike",
"performance-degradation",
"predictive-oee-decline",
"downtime-acknowledged",
"scrap-manual-entry",
"mold-change",
]);
const machineIdSchema = z.string().uuid();
const MAX_EVENTS = 100;
//when no cycle time is configed
const DEFAULT_MACROSTOP_SEC = 300;
function clampText(value: unknown, maxLen: number) {
if (value === null || value === undefined) return null;
const text = String(value).trim().replace(/[\u0000-\u001f\u007f]/g, "");
if (!text) return null;
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 parseSeqToBigInt(value: unknown): bigint | null {
if (value === null || value === undefined) return null;
if (typeof value === "number") {
if (!Number.isInteger(value) || value < 0) return null;
return BigInt(value);
}
if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value);
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 });
let body: unknown = await req.json().catch(() => null);
// ✅ if Node-RED sent an array as the whole body, unwrap it
if (Array.isArray(body)) body = body[0];
const bodyRecord = asRecord(body) ?? {};
const payloadRecord = asRecord(bodyRecord.payload) ?? {};
// ✅ accept multiple common keys
const machineId =
bodyRecord.machineId ??
bodyRecord.machine_id ??
(asRecord(bodyRecord.machine)?.id ?? null);
let rawEvent =
bodyRecord.event ??
bodyRecord.events ??
bodyRecord.anomalies ??
payloadRecord.event ??
payloadRecord.events ??
payloadRecord.anomalies ??
payloadRecord ??
bodyRecord.data; // sometimes "data"
const rawEventRecord = asRecord(rawEvent);
if (rawEventRecord?.event && typeof rawEventRecord.event === "object") rawEvent = rawEventRecord.event;
if (Array.isArray(rawEventRecord?.events)) rawEvent = rawEventRecord.events;
if (!machineId || !rawEvent) {
return NextResponse.json(
{ ok: false, error: "Invalid payload", got: { hasMachineId: !!machineId, keys: Object.keys(bodyRecord) } },
{ status: 400 }
);
}
if (!machineIdSchema.safeParse(String(machineId)).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const machine = await getMachineAuth(String(machineId), apiKey);
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, 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(
defaultMicroMultiplier,
Number(orgSettings?.macroStoppageMultiplier ?? 5)
);
// ✅ normalize to array no matter what
const events = Array.isArray(rawEvent) ? rawEvent : [rawEvent];
if (events.length > MAX_EVENTS) {
return NextResponse.json({ ok: false, error: "Too many events" }, { status: 400 });
}
const created: { id: string; ts: Date; eventType: string }[] = [];
const skipped: Array<Record<string, unknown>> = [];
for (const ev of events) {
const evRecord = asRecord(ev);
if (!evRecord) {
skipped.push({ reason: "invalid_event_object" });
continue;
}
const evData = asRecord(evRecord.data) ?? {};
// We'll re-check reason again after parsing `data` (it may be a JSON string)
let evReason = asRecord(evRecord.reason) ?? asRecord(evData.reason);
const evDowntime = asRecord(evRecord.downtime) ?? asRecord(evData.downtime);
// Some producers nest the reason under `downtime.reason`
if (!evReason) evReason = asRecord(evDowntime?.reason);
const rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? "";
const typ0 = normalizeType(rawType);
const typ = CANON_TYPE[typ0] ?? typ0;
// Determine timestamp
const tsMs =
(typeof evRecord.timestamp === "number" && evRecord.timestamp) ||
(typeof evData.timestamp === "number" && evData.timestamp) ||
(typeof evData.event_timestamp === "number" && evData.event_timestamp) ||
null;
const ts = tsMs ? new Date(tsMs) : new Date();
// Severity defaulting (do not skip on severity — store for audit)
let sev = String(evRecord.severity ?? "").trim().toLowerCase();
if (!sev) sev = "warning";
// Stop classification -> microstop/macrostop
let finalType = typ;
let stopSecForReason: number | null = null;
if (typ === "stop") {
const stopSec =
(typeof evData.stoppage_duration_seconds === "number" && evData.stoppage_duration_seconds) ||
(typeof evData.stop_duration_seconds === "number" && evData.stop_duration_seconds) ||
null;
stopSecForReason = stopSec != null ? Number(stopSec) : null;
if (stopSec != null) {
const theoretical = Number(evData.theoretical_cycle_time ?? evData.theoreticalCycleTime ?? 0) || 0;
const microMultiplier = Number(
evData.micro_threshold_multiplier ?? evData.threshold_multiplier ?? defaultMicroMultiplier
);
const macroMultiplier = Math.max(
microMultiplier,
Number(evData.macro_threshold_multiplier ?? defaultMacroMultiplier)
);
if (theoretical > 0) {
const macroThresholdSec = theoretical * macroMultiplier;
finalType = stopSec >= macroThresholdSec ? "macrostop" : "microstop";
} else {
finalType = stopSec >= DEFAULT_MACROSTOP_SEC ? "macrostop" : "microstop";
}
} else {
// missing duration -> conservative
finalType = "microstop";
}
}
if (!ALLOWED_TYPES.has(finalType)) {
skipped.push({ reason: "type_not_allowed", typ: finalType, sev });
continue;
}
const title =
clampText(evRecord.title, 160) ||
(finalType === "slow-cycle" ? "Slow Cycle Detected" :
finalType === "macrostop" ? "Macrostop Detected" :
finalType === "microstop" ? "Microstop Detected" :
"Event");
const description = clampText(evRecord.description, 1000);
// store full blob, ensure object
const rawData = evRecord.data ?? evRecord;
const parsedData = typeof rawData === "string"
? (() => {
try {
return JSON.parse(rawData);
} catch {
return { raw: rawData };
}
})()
: rawData;
const dataObj: Record<string, unknown> =
parsedData && typeof parsedData === "object" && !Array.isArray(parsedData)
? { ...(parsedData as Record<string, unknown>) }
: { raw: parsedData };
if (evRecord.status != null && dataObj.status == null) dataObj.status = evRecord.status;
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;
// If `data` was a JSON string, the earlier evReason lookup would miss it.
// Re-check here using the normalized object we will persist.
if (!evReason) evReason = asRecord(dataObj.reason);
if (!evReason) evReason = asRecord(asRecord(dataObj.downtime)?.reason);
// If we have a reasonText but missing ids, derive ids from the path-like string.
if (evReason) {
const hasCat = clampText((evReason as any).categoryId, 64) ?? clampText((evReason as any).categoryLabel, 120);
const hasDet = clampText((evReason as any).detailId, 64) ?? clampText((evReason as any).detailLabel, 120);
const rt = clampText((evReason as any).reasonText, 240);
if ((!hasCat || !hasDet) && rt) {
const parsed = parseReasonTextPath(rt);
const next = { ...evReason } as Record<string, unknown>;
// Preserve any explicit ids; only fill gaps.
if ((next as any).categoryId == null && parsed.category) next.categoryId = canonicalText(parsed.category);
if ((next as any).categoryLabel == null && parsed.category) next.categoryLabel = parsed.category;
if ((next as any).detailId == null && parsed.detail) next.detailId = canonicalText(parsed.detail);
if ((next as any).detailLabel == null && parsed.detail) next.detailLabel = parsed.detail;
evReason = next;
}
}
const activeWorkOrder = asRecord(evRecord.activeWorkOrder);
const dataActiveWorkOrder = asRecord(evData.activeWorkOrder);
// ✨ Cada evento puede traer su propio seq, o usar el del payload raíz
const evSeq =
parseSeqToBigInt(evRecord.seq) ??
parseSeqToBigInt(evData.seq) ??
bodySeq;
const evSchemaVersion =
clampText(evRecord.schemaVersion, 16) ??
bodySchemaVersion;
const eventData = {
orgId: machine.orgId,
machineId: machine.id,
schemaVersion: evSchemaVersion,
seq: evSeq,
ts,
topic: clampText(evRecord.topic ?? finalType, 64) ?? finalType,
eventType: finalType,
severity: sev,
requiresAck: !!evRecord.requires_ack,
title,
description,
data: toJsonValue(dataObj),
workOrderId:
clampText(evRecord.work_order_id, 64) ??
clampText(evData.work_order_id, 64) ??
clampText(activeWorkOrder?.id, 64) ??
clampText(dataActiveWorkOrder?.id, 64) ??
null,
sku:
clampText(evRecord.sku, 64) ??
clampText(evData.sku, 64) ??
clampText(activeWorkOrder?.sku, 64) ??
clampText(dataActiveWorkOrder?.sku, 64) ??
null,
};
// ✨ Idempotente: si ya existe (mismo orgId+machineId+seq), no inserta
const insertResult = await prisma.machineEvent.createMany({
data: [eventData],
skipDuplicates: true,
});
// ✨ Buscar la fila (la recién creada o la duplicada existente)
let row;
if (evSeq != null) {
row = await prisma.machineEvent.findFirst({
where: {
orgId: machine.orgId,
machineId: machine.id,
seq: evSeq,
},
orderBy: { ts_server: "asc" },
});
} else {
// Sin seq, buscar por ts (fallback compatibilidad con eventos viejos)
row = await prisma.machineEvent.findFirst({
where: {
orgId: machine.orgId,
machineId: machine.id,
ts,
eventType: finalType,
},
orderBy: { ts_server: "desc" },
});
}
if (!row) {
skipped.push({ reason: "row_not_found_after_insert", seq: evSeq?.toString() });
continue;
}
const wasDuplicate = insertResult.count === 0;
// Si fue duplicado, no procesar reasonEntry ni alertas (ya se hicieron antes)
if (wasDuplicate) {
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
continue; // ✨ saltar el resto del procesamiento
}
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
// If the payload carries a `reason`, create the corresponding ReasonEntry.
// If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage.
if (evRecord.is_update || evRecord.is_auto_ack || dataObj.is_update || dataObj.is_auto_ack){
// skip duplicate reasonEntry for refresh/ack
} else if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged" || finalType === "mold-change"){
const moldIncidentKey =
clampText(evData.incidentKey ?? dataObj.incidentKey, 128) ??
(numberFrom(evData.start_ms ?? dataObj.start_ms) != null
? `mold-change:${Math.trunc(numberFrom(evData.start_ms ?? dataObj.start_ms) as number)}`
: null);
const reasonRaw: Record<string, unknown> =
evReason ??
(finalType === "mold-change"
? ({
type: "downtime",
categoryId: "cambio-molde",
detailId: "cambio-molde",
categoryLabel: "Cambio molde",
detailLabel: "Cambio molde",
reasonCode: "MOLD_CHANGE",
reasonText: "Cambio molde",
incidentKey: moldIncidentKey ?? row.id,
} as Record<string, unknown>)
:
({
type: "downtime",
categoryId: "unclassified",
detailId: "unclassified",
categoryLabel: "Unclassified",
detailLabel: "Unclassified",
reasonCode: "UNCLASSIFIED",
reasonText: "Unclassified",
incidentKey: row.id,
} as Record<string, unknown>));
const inferredKind: ReasonCatalogKind =
String(reasonRaw.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
? "scrap"
: "downtime";
const resolved = resolveReason(reasonRaw, inferredKind, reasonCatalog, reasonCatalog.version);
if (resolved.reasonCode) {
const reasonId =
clampText(reasonRaw.reasonId, 128) ??
(inferredKind === "downtime"
? `evt:${machine.id}:downtime:${clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id}`
: `evt:${machine.id}:scrap:${clampText(reasonRaw.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((reasonRaw as any).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((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id;
const durationSeconds =
numberFrom(evDowntime?.durationSeconds) ??
numberFrom(evData.duration_sec) ??
numberFrom(evData.stoppage_duration_seconds) ??
numberFrom(evData.stop_duration_seconds) ??
(stopSecForReason != null ? stopSecForReason : null) ??
null;
const episodeEndTsMs =
numberFrom(evData.end_ms) ??
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((reasonRaw as any).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((reasonRaw as any).scrapUnit, 16) ?? null,
...commonWrite,
},
update: {
kind: "scrap",
scrapEntryId,
scrapQty,
scrapUnit: clampText((reasonRaw as any).scrapUnit, 16) ?? null,
...commonWrite,
},
});
}
}
}
try {
if (row.eventType !== "downtime-acknowledged" && row.eventType !== "scrap-manual-entry") {
await evaluateAlertsForEvent(row.id);
}
} catch (err) {
console.error("[alerts] evaluation failed", err);
}
}
return NextResponse.json({ ok: true, createdCount: created.length, created, skippedCount: skipped.length, skipped });
}

View File

@@ -97,26 +97,38 @@ export async function POST(req: Request) {
// 5) Store heartbeat // 5) Store heartbeat
// Keep your legacy fields, but store meta fields too. // Keep your legacy fields, but store meta fields too.
const tsServerNow = new Date(); const tsServerNow = new Date();
const hb = await prisma.machineHeartbeat.create({ const hbRow = {
data: { orgId,
orgId, machineId: machine.id,
machineId: machine.id, schemaVersion,
seq,
ts: tsDeviceDate,
tsServer: tsServerNow,
status: body.status ? String(body.status) : (body.online ? "RUN" : "STOP"),
message: body.message ? String(body.message) : null,
ip: body.ip ? String(body.ip) : null,
fwVersion: body.fwVersion ? String(body.fwVersion) : null,
};
// Phase 0 meta const insertHb = await prisma.machineHeartbeat.createMany({
schemaVersion, data: [hbRow],
seq, skipDuplicates: true,
ts: tsDeviceDate,
tsServer: tsServerNow,
// Legacy payload compatibility
status: body.status ? String(body.status) : (body.online ? "RUN" : "STOP"),
message: body.message ? String(body.message) : null,
ip: body.ip ? String(body.ip) : null,
fwVersion: body.fwVersion ? String(body.fwVersion) : null,
},
}); });
// Optional: update machine last seen (same as KPI) const hb = await prisma.machineHeartbeat.findFirst({
where: {
orgId,
machineId: machine.id,
ts: tsDeviceDate,
},
orderBy: { tsServer: "asc" },
});
if (!hb) {
return NextResponse.json({ ok: false, error: "Server error", detail: "Heartbeat row missing" }, { status: 500 });
}
// Optional: update machine last seen (same as KPI) — also on duplicate HB so lastSeen is fresh
await prisma.machine.update({ await prisma.machine.update({
where: { id: machine.id }, where: { id: machine.id },
data: { data: {
@@ -132,6 +144,7 @@ export async function POST(req: Request) {
id: hb.id, id: hb.id,
tsDevice: hb.ts, tsDevice: hb.ts,
tsServer: hb.tsServer, tsServer: hb.tsServer,
duplicate: insertHb.count === 0,
}); });
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Unknown error"; const msg = err instanceof Error ? err.message : "Unknown error";

View File

@@ -222,44 +222,42 @@ export async function POST(req: Request) {
: typeof woRecord.cavities === "number" && woRecord.cavities > 0 : typeof woRecord.cavities === "number" && woRecord.cavities > 0
? woRecord.cavities ? woRecord.cavities
: null; : null;
// Write snapshot (ts = tsDevice; tsServer auto) // Write snapshot (ts = tsDevice; tsServer auto). Idempotent on (org, machine, ts) to absorb retries.
const row = await prisma.machineKpiSnapshot.create({ const kpiData = {
data: { orgId,
orgId, machineId: machine.id,
machineId: machine.id, schemaVersion,
seq,
// Phase 0 meta ts: tsDeviceDate,
schemaVersion, workOrderId: activeWorkOrderId || null,
seq, sku: activeSku || null,
ts: tsDeviceDate, // store device-time in ts; server-time goes to ts_server target: activeTargetQty,
good: good != null ? Math.trunc(good) : null,
// Work order fields scrap: scrap != null ? Math.trunc(scrap) : null,
workOrderId: activeWorkOrderId || null, cycleCount: snapshotCycleCount,
sku: activeSku || null, goodParts: snapshotGoodParts,
target: activeTargetQty, scrapParts: snapshotScrapParts,
good: good != null ? Math.trunc(good) : null, cavities: safeCavities,
scrap: scrap != null ? Math.trunc(scrap) : null, cycleTime: safeCycleTime,
actualCycle: typeof body.actualCycleTime === "number" ? body.actualCycleTime : null,
// Counters availability: typeof k.availability === "number" ? k.availability : null,
cycleCount: snapshotCycleCount, performance: typeof k.performance === "number" ? k.performance : null,
goodParts: snapshotGoodParts, quality: typeof k.quality === "number" ? k.quality : null,
scrapParts: snapshotScrapParts, oee: typeof k.oee === "number" ? k.oee : null,
cavities: safeCavities, trackingEnabled: typeof body.trackingEnabled === "boolean" ? body.trackingEnabled : null,
productionStarted: typeof body.productionStarted === "boolean" ? body.productionStarted : null,
// Cycle times };
cycleTime: safeCycleTime, const insertKpi = await prisma.machineKpiSnapshot.createMany({
actualCycle: typeof body.actualCycleTime === "number" ? body.actualCycleTime : null, data: [kpiData],
skipDuplicates: true,
// KPIs (0..100)
availability: typeof k.availability === "number" ? k.availability : null,
performance: typeof k.performance === "number" ? k.performance : null,
quality: typeof k.quality === "number" ? k.quality : null,
oee: typeof k.oee === "number" ? k.oee : null,
trackingEnabled: typeof body.trackingEnabled === "boolean" ? body.trackingEnabled : null,
productionStarted: typeof body.productionStarted === "boolean" ? body.productionStarted : null,
},
}); });
const row = await prisma.machineKpiSnapshot.findFirst({
where: { orgId, machineId: machine.id, ts: tsDeviceDate },
orderBy: { tsServer: "asc" },
});
if (!row) {
return NextResponse.json({ ok: false, error: "Server error", detail: "KPI snapshot row missing" }, { status: 500 });
}
if (activeWorkOrderId) { if (activeWorkOrderId) {
await prisma.machineWorkOrder.upsert({ await prisma.machineWorkOrder.upsert({
@@ -330,6 +328,7 @@ export async function POST(req: Request) {
id: row.id, id: row.id,
tsDevice: row.ts, tsDevice: row.ts,
tsServer: row.tsServer, tsServer: row.tsServer,
duplicate: insertKpi.count === 0,
trace: traceEnabled ? trace : undefined, trace: traceEnabled ? trace : undefined,
}); });
} catch (err: unknown) { } catch (err: unknown) {

View File

@@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types"; import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types";
import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline"; import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline";
@@ -42,7 +43,8 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
const timelineStart = timeline?.range.start ?? rangeStart; const timelineStart = timeline?.range.start ?? rangeStart;
const timelineEnd = timeline?.range.end ?? rangeEnd; const timelineEnd = timeline?.range.end ?? rangeEnd;
const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0; const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0;
const staleHeartbeat = machine.lastSeenMs == null ? true : nowMs - machine.lastSeenMs > 5 * 60 * 1000; const staleHeartbeat =
machine.lastSeenMs == null ? true : nowMs - machine.lastSeenMs > RECAP_HEARTBEAT_STALE_MS;
const lastSeenLabel = const lastSeenLabel =
machine.lastActivityMin == null machine.lastActivityMin == null

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { formatRecapProgressPercent } from "@/lib/recap/progressDisplay";
import type { RecapSkuRow } from "@/lib/recap/types"; import type { RecapSkuRow } from "@/lib/recap/types";
type Props = { type Props = {
@@ -8,7 +9,7 @@ type Props = {
}; };
export default function RecapProductionBySku({ rows }: Props) { export default function RecapProductionBySku({ rows }: Props) {
const { t } = useI18n(); const { t, locale } = useI18n();
return ( return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4"> <div className="rounded-2xl border border-white/10 bg-black/40 p-4">
@@ -30,7 +31,8 @@ export default function RecapProductionBySku({ rows }: Props) {
</thead> </thead>
<tbody> <tbody>
{rows.slice(0, 10).map((row) => { {rows.slice(0, 10).map((row) => {
const progress = row.progressPct == null ? "--" : `${Math.round(row.progressPct)}%`; const progress =
row.progressPct == null ? "—" : formatRecapProgressPercent(row.progressPct, locale);
return ( return (
<tr key={`${row.sku}:${row.machineName}`} className="border-b border-white/5"> <tr key={`${row.sku}:${row.machineName}`} className="border-b border-white/5">
<td className="py-2 pr-3">{row.sku}</td> <td className="py-2 pr-3">{row.sku}</td>

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { formatRecapProgressPercent, progressBarWidthPercent } from "@/lib/recap/progressDisplay";
import type { RecapMachine } from "@/lib/recap/types"; import type { RecapMachine } from "@/lib/recap/types";
type Props = { type Props = {
@@ -22,10 +23,13 @@ export default function RecapWorkOrderStatus({ workOrders }: Props) {
<div className="mt-2 rounded-xl border border-white/10 bg-black/30 p-3 text-sm text-zinc-200"> <div className="mt-2 rounded-xl border border-white/10 bg-black/30 p-3 text-sm text-zinc-200">
<div className="font-medium text-white">{workOrders.active.id}</div> <div className="font-medium text-white">{workOrders.active.id}</div>
<div className="text-zinc-400">SKU: {workOrders.active.sku || "--"}</div> <div className="text-zinc-400">SKU: {workOrders.active.sku || "--"}</div>
<div className="mt-1 text-xs text-zinc-300">
{t("recap.production.progress")}: {formatRecapProgressPercent(workOrders.active.progressPct, locale)}
</div>
<div className="mt-2 h-2 rounded-full bg-white/10"> <div className="mt-2 h-2 rounded-full bg-white/10">
<div <div
className="h-2 rounded-full bg-emerald-400" className="h-2 rounded-full bg-emerald-400 transition-[width]"
style={{ width: `${Math.max(0, Math.min(100, workOrders.active.progressPct ?? 0))}%` }} style={{ width: `${progressBarWidthPercent(workOrders.active.progressPct)}%` }}
/> />
</div> </div>
<div className="mt-2 text-xs text-zinc-400"> <div className="mt-2 text-xs text-zinc-400">

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { formatRecapProgressPercent, progressBarWidthPercent } from "@/lib/recap/progressDisplay";
import type { RecapWorkOrders as RecapWorkOrdersType } from "@/lib/recap/types"; import type { RecapWorkOrders as RecapWorkOrdersType } from "@/lib/recap/types";
type Props = { type Props = {
@@ -41,10 +42,14 @@ export default function RecapWorkOrders({ workOrders }: Props) {
<div className="mt-2 rounded-lg border border-white/10 bg-black/20 p-3 text-sm text-zinc-200"> <div className="mt-2 rounded-lg border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
<div className="font-medium text-white">{workOrders.active.id}</div> <div className="font-medium text-white">{workOrders.active.id}</div>
<div className="text-zinc-400">{t("recap.workOrders.sku")}: {workOrders.active.sku || "--"}</div> <div className="text-zinc-400">{t("recap.workOrders.sku")}: {workOrders.active.sku || "--"}</div>
<div className="mt-1 text-xs text-zinc-300">
{t("recap.production.progress")}:{" "}
{formatRecapProgressPercent(workOrders.active.progressPct, locale)}
</div>
<div className="mt-2 h-2 rounded-full bg-white/10"> <div className="mt-2 h-2 rounded-full bg-white/10">
<div <div
className="h-2 rounded-full bg-emerald-400" className="h-2 rounded-full bg-emerald-400 transition-[width]"
style={{ width: `${Math.max(0, Math.min(100, workOrders.active.progressPct ?? 0))}%` }} style={{ width: `${progressBarWidthPercent(workOrders.active.progressPct)}%` }}
/> />
</div> </div>
<div className="mt-2 text-xs text-zinc-400"> <div className="mt-2 text-xs text-zinc-400">

File diff suppressed because one or more lines are too long

View File

@@ -218,6 +218,8 @@ function eventIncidentKey(data: unknown, eventType: string, ts: Date) {
const inner = extractEventData(data); const inner = extractEventData(data);
const direct = String(inner.incidentKey ?? inner.incident_key ?? "").trim(); const direct = String(inner.incidentKey ?? inner.incident_key ?? "").trim();
if (direct) return direct; if (direct) return direct;
const alertId = String(inner.alert_id ?? inner.alertId ?? "").trim();
if (alertId) return `${eventType}:${alertId}`;
const startMs = safeNum(inner.start_ms) ?? safeNum(inner.startMs); const startMs = safeNum(inner.start_ms) ?? safeNum(inner.startMs);
if (startMs != null) return `${eventType}:${Math.trunc(startMs)}`; if (startMs != null) return `${eventType}:${Math.trunc(startMs)}`;
return `${eventType}:${ts.getTime()}`; return `${eventType}:${ts.getTime()}`;
@@ -291,7 +293,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
const machines = await prisma.machine.findMany({ const machines = await prisma.machine.findMany({
where: { orgId: params.orgId, ...machineFilter }, where: { orgId: params.orgId, ...machineFilter },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
select: { id: true, name: true, location: true }, select: { id: true, name: true, location: true, tsServer: true },
}); });
if (!machines.length) { if (!machines.length) {
@@ -421,9 +423,9 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
where: { where: {
orgId: params.orgId, orgId: params.orgId,
machineId: { in: machineIds }, machineId: { in: machineIds },
ts: { lte: params.end }, tsServer: { lte: params.end },
}, },
orderBy: [{ machineId: "asc" }, { ts: "desc" }], orderBy: [{ machineId: "asc" }, { tsServer: "desc" }],
distinct: ["machineId"], distinct: ["machineId"],
select: { select: {
machineId: true, machineId: true,
@@ -814,8 +816,36 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
); );
} }
const firstProductionMsAfterMoldStart = (startMs: number) => {
let best: number | null = null;
for (const cycle of dedupedCycles) {
const t = cycle.ts.getTime();
if (t <= startMs) continue;
const g = safeNum(cycle.goodDelta) ?? 0;
const s = safeNum(cycle.scrapDelta) ?? 0;
if (g > 0 || s > 0) {
if (best == null || t < best) best = t;
}
}
for (const kpi of dedupedKpis) {
const t = kpi.ts.getTime();
if (t <= startMs) continue;
const g = safeNum(kpi.good) ?? safeNum(kpi.goodParts) ?? 0;
const s = safeNum(kpi.scrap) ?? safeNum(kpi.scrapParts) ?? 0;
if (g > 0 || s > 0) {
if (best == null || t < best) best = t;
}
}
return best;
};
const moldActiveByIncident = new Map<string, number>(); const moldActiveByIncident = new Map<string, number>();
for (const event of machineMoldEvents) { for (const event of machineMoldEvents) {
const inner = extractEventData(event.data);
const isUpdate = safeBool(inner.is_update ?? inner.isUpdate);
const isAutoAck = safeBool(inner.is_auto_ack ?? inner.isAutoAck);
if (isUpdate || isAutoAck) continue;
const key = eventIncidentKey(event.data, "mold-change", event.ts); const key = eventIncidentKey(event.data, "mold-change", event.ts);
const status = eventStatus(event.data); const status = eventStatus(event.data);
if (status === "resolved") { if (status === "resolved") {
@@ -827,6 +857,12 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
moldActiveByIncident.set(key, moldStartMs(event.data, event.ts)); moldActiveByIncident.set(key, moldStartMs(event.data, event.ts));
} }
} }
for (const [k, startMs] of [...moldActiveByIncident.entries()]) {
const resumeMs = firstProductionMsAfterMoldStart(startMs);
if (resumeMs != null && resumeMs <= params.end.getTime()) {
moldActiveByIncident.delete(k);
}
}
let moldChangeStartMs: number | null = null; let moldChangeStartMs: number | null = null;
for (const startMs of moldActiveByIncident.values()) { for (const startMs of moldActiveByIncident.values()) {
if (moldChangeStartMs == null || startMs > moldChangeStartMs) moldChangeStartMs = startMs; if (moldChangeStartMs == null || startMs > moldChangeStartMs) moldChangeStartMs = startMs;
@@ -879,7 +915,14 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
moldChangeStartMs, moldChangeStartMs,
}, },
heartbeat: { heartbeat: {
lastSeenAt: toIso(latestTs), lastSeenAt: toIso(
(() => {
const hbMs = latestHb ? (latestHb.tsServer ?? latestHb.ts).getTime() : null;
const machineMs = machine.tsServer.getTime();
if (hbMs != null) return new Date(Math.max(hbMs, machineMs));
return machine.tsServer;
})()
),
uptimePct, uptimePct,
}, },
}; };

View File

@@ -0,0 +1,27 @@
/**
* Recap & work-order progress: large targets (e.g. 301k) make raw % < 1.
* Rounding to integer shows 0%; bar width 0.17% is invisible. Use decimals + a visual floor for the bar.
*/
/** "0.17%" with enough precision when needed; "—" for null. */
export function formatRecapProgressPercent(
pct: number | null | undefined,
locale: string
): string {
if (pct == null || Number.isNaN(pct)) return "—";
if (pct <= 0) return "0%";
if (pct < 10) {
return `${pct.toLocaleString(locale, { maximumFractionDigits: 2, minimumFractionDigits: 0 })}%`;
}
return `${Math.round(pct).toLocaleString(locale)}%`;
}
/**
* For CSS width %: keep proportional when ≥2%; below that, any positive progress
* needs a minimum or the bar looks like a single pixel.
*/
export function progressBarWidthPercent(pct: number | null | undefined): number {
if (pct == null || Number.isNaN(pct) || pct <= 0) return 0;
if (pct < 2) return Math.max(2, Math.min(100, pct));
return Math.min(100, pct);
}

View File

@@ -0,0 +1,4 @@
/**
* Client-safe recap thresholds. Kept in sync with OFFLINE logic in lib/recap/redesign.ts.
*/
export const RECAP_HEARTBEAT_STALE_MS = 10 * 60 * 1000;

View File

@@ -9,6 +9,7 @@ import {
type TimelineCycleRow, type TimelineCycleRow,
type TimelineEventRow, type TimelineEventRow,
} from "@/lib/recap/timeline"; } from "@/lib/recap/timeline";
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
import type { import type {
RecapDetailResponse, RecapDetailResponse,
RecapMachine, RecapMachine,
@@ -25,7 +26,7 @@ type DetailRangeInput = {
end?: string | null; end?: string | null;
}; };
const OFFLINE_THRESHOLD_MS = 10 * 60 * 1000; const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS;
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000; const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
const RECAP_CACHE_TTL_SEC = 60; const RECAP_CACHE_TTL_SEC = 60;
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];

0
mis-control-tower@0.1.0 Normal file
View File

0
next Normal file
View File

2394
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,7 @@
"@types/nodemailer": "^7.0.4", "@types/nodemailer": "^7.0.4",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"baseline-browser-mapping": "^2.10.22",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.10", "eslint-config-next": "16.0.10",

View File

@@ -0,0 +1,18 @@
-- Dedupe existing rows (keep oldest by createdAt, then id) before unique constraint.
WITH ranked AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "orgId", "machineId", "ts", "cycleCount"
ORDER BY "createdAt" ASC, "id" ASC
) AS rn
FROM "MachineCycle"
)
DELETE FROM "MachineCycle" mc
USING ranked r
WHERE mc."id" = r."id"
AND r.rn > 1;
-- One row per (org, machine, device ts, cycle counter) — blocks retry / fan-out duplicates.
CREATE UNIQUE INDEX "MachineCycle_orgId_machineId_ts_cycleCount_key"
ON "MachineCycle" ("orgId", "machineId", "ts", "cycleCount");

View File

@@ -0,0 +1,35 @@
-- Heartbeat: same device ts + machine = one row (retries / double POST).
WITH ranked_hb AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "orgId", "machineId", "ts"
ORDER BY "ts_server" ASC, "id" ASC
) AS rn
FROM "MachineHeartbeat"
)
DELETE FROM "MachineHeartbeat" h
USING ranked_hb r
WHERE h."id" = r."id"
AND r.rn > 1;
CREATE UNIQUE INDEX "MachineHeartbeat_orgId_machineId_ts_key"
ON "MachineHeartbeat" ("orgId", "machineId", "ts");
-- KPI snapshot: same minute bucket (device ts) per machine — Node-RED aligns ts to minute.
WITH ranked_kpi AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "orgId", "machineId", "ts"
ORDER BY "ts_server" ASC, "id" ASC
) AS rn
FROM "MachineKpiSnapshot"
)
DELETE FROM "MachineKpiSnapshot" k
USING ranked_kpi r
WHERE k."id" = r."id"
AND r.rn > 1;
CREATE UNIQUE INDEX "MachineKpiSnapshot_orgId_machineId_ts_key"
ON "MachineKpiSnapshot" ("orgId", "machineId", "ts");

View File

@@ -8,65 +8,61 @@ datasource db {
} }
model Org { model Org {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
slug String @unique slug String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
machines Machine[]
members OrgUser[] events MachineEvent[]
sessions Session[] heartbeats MachineHeartbeat[]
machines Machine[] kpiSnapshots MachineKpiSnapshot[]
heartbeats MachineHeartbeat[] members OrgUser[]
kpiSnapshots MachineKpiSnapshot[] reasonEntries ReasonEntry[]
events MachineEvent[] sessions Session[]
workOrders MachineWorkOrder[] alertContacts AlertContact[]
settings OrgSettings? alertNotifications AlertNotification[]
shifts OrgShift[] alertPolicies AlertPolicy?
machineSettings MachineSettings[] downtimeActions DowntimeAction[]
settingsAudits SettingsAudit[]
invites OrgInvite[]
alertPolicies AlertPolicy[]
alertContacts AlertContact[]
alertNotifications AlertNotification[]
financialProfile OrgFinancialProfile?
locationFinancialOverrides LocationFinancialOverride[] locationFinancialOverrides LocationFinancialOverride[]
machineFinancialOverrides MachineFinancialOverride[] machineFinancialOverrides MachineFinancialOverride[]
productCostOverrides ProductCostOverride[] machineSettings MachineSettings[]
reasonEntries ReasonEntry[] workOrders MachineWorkOrder[]
downtimeActions DowntimeAction[] financialProfile OrgFinancialProfile?
invites OrgInvite[]
settings OrgSettings?
shifts OrgShift[]
productCostOverrides ProductCostOverride[]
settingsAudits SettingsAudit[]
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
name String? name String?
phone String? @map("phone") passwordHash String
passwordHash String isActive Boolean @default(true)
isActive Boolean @default(true) createdAt DateTime @default(now())
createdAt DateTime @default(now()) emailVerificationExpiresAt DateTime? @map("email_verification_expires_at")
emailVerifiedAt DateTime? @map("email_verified_at") emailVerificationToken String? @unique @map("email_verification_token")
emailVerificationToken String? @unique @map("email_verification_token") emailVerifiedAt DateTime? @map("email_verified_at")
emailVerificationExpiresAt DateTime? @map("email_verification_expires_at") phone String? @map("phone")
orgs OrgUser[]
orgs OrgUser[] sessions Session[]
sessions Session[] alertContacts AlertContact[]
sentInvites OrgInvite[] @relation("OrgInviteInviter") alertNotifications AlertNotification[]
alertContacts AlertContact[] downtimeActionsCreated DowntimeAction[] @relation("DowntimeActionCreator")
alertNotifications AlertNotification[] downtimeActionsOwned DowntimeAction[] @relation("DowntimeActionOwner")
downtimeActionsOwned DowntimeAction[] @relation("DowntimeActionOwner") sentInvites OrgInvite[] @relation("OrgInviteInviter")
downtimeActionsCreated DowntimeAction[] @relation("DowntimeActionCreator")
} }
model OrgUser { model OrgUser {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String orgId String
userId String userId String
role String @default("MEMBER") // OWNER | ADMIN | MEMBER role String @default("MEMBER")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([orgId, userId]) @@unique([orgId, userId])
@@index([userId]) @@index([userId])
@@ -74,19 +70,18 @@ model OrgUser {
} }
model OrgInvite { model OrgInvite {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String @map("org_id") orgId String @map("org_id")
email String email String
role String @default("MEMBER") // OWNER | ADMIN | MEMBER role String @default("MEMBER")
token String @unique token String @unique
invitedBy String? @map("invited_by") invitedBy String? @map("invited_by")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at") expiresAt DateTime @map("expires_at")
acceptedAt DateTime? @map("accepted_at") acceptedAt DateTime? @map("accepted_at")
revokedAt DateTime? @map("revoked_at") revokedAt DateTime? @map("revoked_at")
inviter User? @relation("OrgInviteInviter", fields: [invitedBy], references: [id])
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
inviter User? @relation("OrgInviteInviter", fields: [invitedBy], references: [id], onDelete: SetNull)
@@index([orgId]) @@index([orgId])
@@index([orgId, email]) @@index([orgId, email])
@@ -95,7 +90,7 @@ model OrgInvite {
} }
model Session { model Session {
id String @id @default(uuid()) // cookie value id String @id @default(uuid())
orgId String orgId String
userId String userId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -104,9 +99,8 @@ model Session {
revokedAt DateTime? revokedAt DateTime?
ip String? ip String?
userAgent String? userAgent String?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId]) @@index([userId])
@@index([orgId]) @@index([orgId])
@@ -114,39 +108,36 @@ model Session {
} }
model Machine { model Machine {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String orgId String
name String name String
apiKey String? @unique code String?
code String? createdAt DateTime @default(now())
location String? location String?
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
updatedAt DateTime @updatedAt apiKey String? @unique
tsDevice DateTime @default(now()) @map("ts") schemaVersion String? @map("schema_version")
tsServer DateTime @default(now()) @map("ts_server") seq BigInt? @map("seq")
schemaVersion String? @map("schema_version") tsDevice DateTime @default(now()) @map("ts")
seq BigInt? @map("seq") tsServer DateTime @default(now()) @map("ts_server")
pairingCode String? @unique @map("pairing_code") pairingCode String? @unique @map("pairing_code")
pairingCodeExpiresAt DateTime? @map("pairing_code_expires_at") pairingCodeExpiresAt DateTime? @map("pairing_code_expires_at")
pairingCodeUsedAt DateTime? @map("pairing_code_used_at") pairingCodeUsedAt DateTime? @map("pairing_code_used_at")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) cycles MachineCycle[]
heartbeats MachineHeartbeat[] events MachineEvent[]
kpiSnapshots MachineKpiSnapshot[] heartbeats MachineHeartbeat[]
events MachineEvent[] kpiSnapshots MachineKpiSnapshot[]
cycles MachineCycle[] reasonEntries ReasonEntry[]
workOrders MachineWorkOrder[] alertNotifications AlertNotification[]
settings MachineSettings? downtimeActions DowntimeAction[]
settingsAudits SettingsAudit[] financialOverrides MachineFinancialOverride[]
alertNotifications AlertNotification[] settings MachineSettings?
financialOverrides MachineFinancialOverride[] workOrders MachineWorkOrder[]
reasonEntries ReasonEntry[] settingsAudits SettingsAudit[]
downtimeActions DowntimeAction[]
@@unique([orgId, name]) @@unique([orgId, name])
@@index([orgId]) @@index([orgId])
@@index([orgId, createdAt])
} }
model MachineHeartbeat { model MachineHeartbeat {
@@ -154,111 +145,97 @@ model MachineHeartbeat {
orgId String orgId String
machineId String machineId String
ts DateTime @default(now()) ts DateTime @default(now())
tsServer DateTime @default(now()) @map("ts_server") status String
message String?
ip String?
fwVersion String?
schemaVersion String? @map("schema_version") schemaVersion String? @map("schema_version")
seq BigInt? @map("seq") seq BigInt? @map("seq")
tsServer DateTime @default(now()) @map("ts_server")
status String machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
message String? org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
ip String?
fwVersion String?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@index([orgId, machineId, ts]) @@index([orgId, machineId, ts])
@@index([orgId, machineId, tsServer])
} }
model MachineKpiSnapshot { model MachineKpiSnapshot {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String orgId String
machineId String machineId String
ts DateTime @default(now()) ts DateTime @default(now())
workOrderId String?
workOrderId String? sku String?
sku String? target Int?
good Int?
target Int? scrap Int?
good Int? cycleCount Int?
scrap Int? goodParts Int?
cycleCount Int? scrapParts Int?
goodParts Int? cavities Int?
scrapParts Int? cycleTime Float?
cavities Int? actualCycle Float?
cycleTime Float? // theoretical/target availability Float?
actualCycle Float? // if you want (optional) performance Float?
quality Float?
availability Float? oee Float?
performance Float?
quality Float?
oee Float?
trackingEnabled Boolean? trackingEnabled Boolean?
productionStarted Boolean? productionStarted Boolean?
tsServer DateTime @default(now()) @map("ts_server")
schemaVersion String? @map("schema_version") schemaVersion String? @map("schema_version")
seq BigInt? @map("seq") seq BigInt? @map("seq")
tsServer DateTime @default(now()) @map("ts_server")
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) @@unique([orgId, machineId, seq], map: "uq_kpi_org_machine_seq")
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@index([orgId, machineId, ts]) @@index([orgId, machineId, ts])
} }
model MachineEvent { model MachineEvent {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String orgId String
machineId String machineId String
ts DateTime @default(now()) ts DateTime @default(now())
topic String
topic String // "anomaly-detected" eventType String
eventType String // "slow-cycle" severity String
severity String // "critical"
requiresAck Boolean @default(false) requiresAck Boolean @default(false)
title String title String
description String? description String?
tsServer DateTime @default(now()) @map("ts_server") data Json?
workOrderId String?
sku String?
schemaVersion String? @map("schema_version") schemaVersion String? @map("schema_version")
seq BigInt? @map("seq") seq BigInt? @map("seq")
tsServer DateTime @default(now()) @map("ts_server")
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
// store the raw data blob so we don't lose fields @@unique([orgId, machineId, seq], map: "uq_event_org_machine_seq")
data Json?
workOrderId String?
sku String?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@index([orgId, machineId, ts]) @@index([orgId, machineId, ts])
@@index([orgId, machineId, eventType, ts]) @@index([orgId, machineId, eventType, ts])
} }
model MachineCycle { model MachineCycle {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String orgId String
machineId String machineId String
ts DateTime @default(now()) ts DateTime @default(now())
cycleCount Int? cycleCount Int?
actualCycleTime Float actualCycleTime Float
theoreticalCycleTime Float? theoreticalCycleTime Float?
workOrderId String?
sku String?
cavities Int?
goodDelta Int?
scrapDelta Int?
createdAt DateTime @default(now())
schemaVersion String? @map("schema_version")
seq BigInt? @map("seq")
tsServer DateTime @default(now()) @map("ts_server")
machine Machine @relation(fields: [machineId], references: [id])
workOrderId String? @@unique([orgId, machineId, ts, cycleCount])
sku String? @@unique([orgId, machineId, seq], map: "uq_cycle_org_machine_seq")
cavities Int?
goodDelta Int?
scrapDelta Int?
tsServer DateTime @default(now()) @map("ts_server")
schemaVersion String? @map("schema_version")
seq BigInt? @map("seq")
createdAt DateTime @default(now())
machine Machine @relation(fields: [machineId], references: [id])
@@index([orgId, machineId, ts]) @@index([orgId, machineId, ts])
@@index([orgId, machineId, cycleCount]) @@index([orgId, machineId, cycleCount])
} }
@@ -272,14 +249,13 @@ model MachineWorkOrder {
targetQty Int? targetQty Int?
cycleTime Float? cycleTime Float?
status String @default("PENDING") status String @default("PENDING")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
goodParts Int @default(0) @map("good_parts") goodParts Int @default(0) @map("good_parts")
scrapParts Int @default(0) @map("scrap_parts") scrapParts Int @default(0) @map("scrap_parts")
cycleCount Int @default(0) @map("cycle_count") cycleCount Int @default(0) @map("cycle_count")
createdAt DateTime @default(now()) machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
updatedAt DateTime @updatedAt org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@unique([machineId, workOrderId]) @@unique([machineId, workOrderId])
@@index([orgId, machineId]) @@index([orgId, machineId])
@@ -296,14 +272,13 @@ model IngestLog {
seq BigInt? seq BigInt?
tsDevice DateTime? tsDevice DateTime?
tsServer DateTime @default(now()) tsServer DateTime @default(now())
ok Boolean
ok Boolean status Int
status Int errorCode String?
errorCode String? errorMsg String?
errorMsg String? body Json?
body Json? ip String?
ip String? userAgent String?
userAgent String?
@@index([endpoint, tsServer]) @@index([endpoint, tsServer])
@@index([machineId, tsServer]) @@index([machineId, tsServer])
@@ -311,44 +286,42 @@ model IngestLog {
} }
model OrgSettings { model OrgSettings {
orgId String @id @map("org_id") orgId String @id @map("org_id")
timezone String @default("UTC") timezone String @default("UTC")
shiftChangeCompMin Int @default(10) @map("shift_change_comp_min") shiftChangeCompMin Int @default(10) @map("shift_change_comp_min")
lunchBreakMin Int @default(30) @map("lunch_break_min") lunchBreakMin Int @default(30) @map("lunch_break_min")
shiftScheduleOverridesJson Json? @map("shift_schedule_overrides_json") stoppageMultiplier Float @default(1.5) @map("stoppage_multiplier")
stoppageMultiplier Float @default(1.5) @map("stoppage_multiplier") oeeAlertThresholdPct Float @default(90) @map("oee_alert_threshold_pct")
oeeAlertThresholdPct Float @default(90) @map("oee_alert_threshold_pct") macroStoppageMultiplier Float @default(5) @map("macro_stoppage_multiplier")
macroStoppageMultiplier Float @default(5) @map("macro_stoppage_multiplier") performanceThresholdPct Float @default(85) @map("performance_threshold_pct")
performanceThresholdPct Float @default(85) @map("performance_threshold_pct") qualitySpikeDeltaPct Float @default(5) @map("quality_spike_delta_pct")
qualitySpikeDeltaPct Float @default(5) @map("quality_spike_delta_pct") alertsJson Json? @map("alerts_json")
alertsJson Json? @map("alerts_json") defaultsJson Json? @map("defaults_json")
defaultsJson Json? @map("defaults_json") version Int @default(1)
version Int @default(1) updatedAt DateTime @updatedAt @map("updated_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedBy String? @map("updated_by")
updatedBy String? @map("updated_by") shiftScheduleOverridesJson Json? @map("shift_schedule_overrides_json")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@map("org_settings") @@map("org_settings")
} }
model OrgFinancialProfile { model OrgFinancialProfile {
orgId String @id @map("org_id") orgId String @id @map("org_id")
defaultCurrency String @default("USD") @map("default_currency") defaultCurrency String @default("USD") @map("default_currency")
machineCostPerMin Float? @map("machine_cost_per_min") machineCostPerMin Float? @map("machine_cost_per_min")
operatorCostPerMin Float? @map("operator_cost_per_min") operatorCostPerMin Float? @map("operator_cost_per_min")
ratedRunningKw Float? @map("rated_running_kw") ratedRunningKw Float? @map("rated_running_kw")
idleKw Float? @map("idle_kw") idleKw Float? @map("idle_kw")
kwhRate Float? @map("kwh_rate") kwhRate Float? @map("kwh_rate")
energyMultiplier Float @default(1.0) @map("energy_multiplier") energyMultiplier Float @default(1.0) @map("energy_multiplier")
energyCostPerMin Float? @map("energy_cost_per_min") energyCostPerMin Float? @map("energy_cost_per_min")
scrapCostPerUnit Float? @map("scrap_cost_per_unit") scrapCostPerUnit Float? @map("scrap_cost_per_unit")
rawMaterialCostPerUnit Float? @map("raw_material_cost_per_unit") rawMaterialCostPerUnit Float? @map("raw_material_cost_per_unit")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by") updatedBy String? @map("updated_by")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@map("org_financial_profiles") @@map("org_financial_profiles")
} }
@@ -370,8 +343,7 @@ model LocationFinancialOverride {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by") updatedBy String? @map("updated_by")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@unique([orgId, location]) @@unique([orgId, location])
@@index([orgId]) @@index([orgId])
@@ -395,9 +367,8 @@ model MachineFinancialOverride {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by") updatedBy String? @map("updated_by")
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@unique([orgId, machineId]) @@unique([orgId, machineId])
@@index([orgId]) @@index([orgId])
@@ -405,16 +376,15 @@ model MachineFinancialOverride {
} }
model ProductCostOverride { model ProductCostOverride {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String @map("org_id") orgId String @map("org_id")
sku String sku String
currency String? currency String?
rawMaterialCostPerUnit Float? @map("raw_material_cost_per_unit") rawMaterialCostPerUnit Float? @map("raw_material_cost_per_unit")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by") updatedBy String? @map("updated_by")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@unique([orgId, sku]) @@unique([orgId, sku])
@@index([orgId]) @@index([orgId])
@@ -423,33 +393,30 @@ model ProductCostOverride {
model AlertPolicy { model AlertPolicy {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String @map("org_id") orgId String @unique @map("org_id")
policyJson Json @map("policy_json") policyJson Json @map("policy_json")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by") updatedBy String? @map("updated_by")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@unique([orgId])
@@index([orgId]) @@index([orgId])
@@map("alert_policies") @@map("alert_policies")
} }
model AlertContact { model AlertContact {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String @map("org_id") orgId String @map("org_id")
userId String? @map("user_id") userId String? @map("user_id")
name String name String
roleScope String @map("role_scope") // MEMBER | ADMIN | OWNER | CUSTOM roleScope String @map("role_scope")
email String? email String?
phone String? phone String?
eventTypes Json? @map("event_types") // optional allowlist (array of strings) eventTypes Json? @map("event_types")
isActive Boolean @default(true) @map("is_active") isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id])
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
notifications AlertNotification[] notifications AlertNotification[]
@@unique([orgId, userId]) @@unique([orgId, userId])
@@ -459,24 +426,23 @@ model AlertContact {
} }
model AlertNotification { model AlertNotification {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String @map("org_id") orgId String @map("org_id")
machineId String @map("machine_id") machineId String @map("machine_id")
eventId String @map("event_id") eventId String @map("event_id")
eventType String @map("event_type") eventType String @map("event_type")
ruleId String @map("rule_id") ruleId String @map("rule_id")
role String role String
channel String channel String
contactId String? @map("contact_id") contactId String? @map("contact_id")
userId String? @map("user_id") userId String? @map("user_id")
sentAt DateTime @default(now()) @map("sent_at") sentAt DateTime @default(now()) @map("sent_at")
status String status String
error String? error String?
contact AlertContact? @relation(fields: [contactId], references: [id])
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
contact AlertContact? @relation(fields: [contactId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id])
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([orgId, machineId, sentAt]) @@index([orgId, machineId, sentAt])
@@index([orgId, eventId, role, channel]) @@index([orgId, eventId, role, channel])
@@ -493,8 +459,7 @@ model OrgShift {
endTime String @map("end_time") endTime String @map("end_time")
sortOrder Int @map("sort_order") sortOrder Int @map("sort_order")
enabled Boolean @default(true) enabled Boolean @default(true)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@index([orgId]) @@index([orgId])
@@index([orgId, sortOrder]) @@index([orgId, sortOrder])
@@ -507,9 +472,8 @@ model MachineSettings {
overridesJson Json? @map("overrides_json") overridesJson Json? @map("overrides_json")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by") updatedBy String? @map("updated_by")
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@index([orgId]) @@index([orgId])
@@map("machine_settings") @@map("machine_settings")
@@ -523,9 +487,8 @@ model SettingsAudit {
source String source String
payloadJson Json @map("payload_json") payloadJson Json @map("payload_json")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
machine Machine? @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine? @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@index([orgId, createdAt]) @@index([orgId, createdAt])
@@index([machineId, createdAt]) @@index([machineId, createdAt])
@@ -533,75 +496,58 @@ model SettingsAudit {
} }
model ReasonEntry { model ReasonEntry {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String orgId String
machineId String machineId String
reasonId String @unique
// idempotency key from Edge (rsn_<ulid>) kind String
reasonId String @unique
// "downtime" | "scrap"
kind String
// For downtime reasons
episodeId String? episodeId String?
durationSeconds Int? durationSeconds Int?
episodeEndTs DateTime? episodeEndTs DateTime?
scrapEntryId String?
scrapQty Int?
scrapUnit String?
reasonCode String
reasonLabel String?
reasonText String?
capturedAt DateTime
workOrderId String?
meta Json?
schemaVersion Int @default(1)
createdAt DateTime @default(now())
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
// For scrap reasons
scrapEntryId String?
scrapQty Int?
scrapUnit String?
// Required reason
reasonCode String
reasonLabel String?
reasonText String?
capturedAt DateTime
workOrderId String?
meta Json?
schemaVersion Int @default(1)
createdAt DateTime @default(now())
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@index([orgId, machineId, capturedAt])
@@index([orgId, kind, capturedAt])
@@unique([orgId, kind, episodeId]) @@unique([orgId, kind, episodeId])
@@unique([orgId, kind, scrapEntryId]) @@unique([orgId, kind, scrapEntryId])
@@index([orgId, machineId, capturedAt])
@@index([orgId, kind, capturedAt])
} }
model DowntimeAction { model DowntimeAction {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String @map("org_id") orgId String @map("org_id")
machineId String? @map("machine_id") machineId String? @map("machine_id")
reasonCode String? @map("reason_code") reasonCode String? @map("reason_code")
hmDay Int? @map("hm_day") hmDay Int? @map("hm_day")
hmHour Int? @map("hm_hour") hmHour Int? @map("hm_hour")
title String
title String notes String?
notes String? status String @default("open")
status String @default("open") priority String @default("medium")
priority String @default("medium") dueDate DateTime? @map("due_date")
dueDate DateTime? @map("due_date") reminderAt DateTime? @map("reminder_at")
reminderAt DateTime? @map("reminder_at")
lastReminderAt DateTime? @map("last_reminder_at") lastReminderAt DateTime? @map("last_reminder_at")
reminderStage String? @map("reminder_stage") completedAt DateTime? @map("completed_at")
completedAt DateTime? @map("completed_at") ownerUserId String? @map("owner_user_id")
createdBy String? @map("created_by")
ownerUserId String? @map("owner_user_id") createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") updatedAt DateTime @updatedAt @map("updated_at")
reminderStage String? @map("reminder_stage")
createdAt DateTime @default(now()) @map("created_at") creator User? @relation("DowntimeActionCreator", fields: [createdBy], references: [id])
updatedAt DateTime @updatedAt @map("updated_at") machine Machine? @relation(fields: [machineId], references: [id])
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) ownerUser User? @relation("DowntimeActionOwner", fields: [ownerUserId], references: [id])
machine Machine? @relation(fields: [machineId], references: [id], onDelete: SetNull)
ownerUser User? @relation("DowntimeActionOwner", fields: [ownerUserId], references: [id], onDelete: SetNull)
creator User? @relation("DowntimeActionCreator", fields: [createdBy], references: [id], onDelete: SetNull)
@@index([orgId]) @@index([orgId])
@@index([orgId, machineId]) @@index([orgId, machineId])