updates
This commit is contained in:
30
app/(app)/recap/RecapPageSkeletons.tsx
Normal file
30
app/(app)/recap/RecapPageSkeletons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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){
|
||||||
|
|||||||
670
app/api/ingest/event/route.ts.bak
Normal file
670
app/api/ingest/event/route.ts.bak
Normal 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 });
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
3868
flows (63).json
3868
flows (63).json
File diff suppressed because one or more lines are too long
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
27
lib/recap/progressDisplay.ts
Normal file
27
lib/recap/progressDisplay.ts
Normal 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);
|
||||||
|
}
|
||||||
4
lib/recap/recapUiConstants.ts
Normal file
4
lib/recap/recapUiConstants.ts
Normal 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;
|
||||||
@@ -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
0
mis-control-tower@0.1.0
Normal file
2394
package-lock.json
generated
2394
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
@@ -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])
|
||||||
|
|||||||
Reference in New Issue
Block a user