changes
This commit is contained in:
@@ -3,6 +3,13 @@ import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
applyDowntimeFilters,
|
||||
loadDowntimeShiftContext,
|
||||
normalizeMicrostopLtMin,
|
||||
normalizeShiftFilter,
|
||||
resolvePlannedFilter,
|
||||
} from "@/lib/analytics/downtimeFilters";
|
||||
|
||||
const bad = (status: number, error: string) =>
|
||||
NextResponse.json({ ok: false, error }, { status });
|
||||
@@ -26,6 +33,9 @@ export async function GET(req: Request) {
|
||||
const machineId = url.searchParams.get("machineId"); // optional
|
||||
const reasonCode = url.searchParams.get("reasonCode"); // optional
|
||||
const includeMoldChange = url.searchParams.get("includeMoldChange") === "true";
|
||||
const planned = resolvePlannedFilter(url.searchParams.get("planned"), includeMoldChange);
|
||||
const shift = normalizeShiftFilter(url.searchParams.get("shift"));
|
||||
const microstopLtMin = normalizeMicrostopLtMin(url.searchParams.get("microstopLtMin"));
|
||||
|
||||
const limitRaw = url.searchParams.get("limit");
|
||||
const limit = Math.min(Math.max(Number(limitRaw || 200), 1), 500);
|
||||
@@ -50,7 +60,6 @@ export async function GET(req: Request) {
|
||||
orgId,
|
||||
kind: "downtime",
|
||||
episodeId: { not: null },
|
||||
...(includeMoldChange ? {} : { reasonCode: { not: "MOLD_CHANGE" } }),
|
||||
capturedAt: {
|
||||
gte: start,
|
||||
...(beforeDate ? { lt: beforeDate } : {}),
|
||||
@@ -59,10 +68,11 @@ export async function GET(req: Request) {
|
||||
...(reasonCode ? { reasonCode } : {}),
|
||||
};
|
||||
|
||||
const rows = await prisma.reasonEntry.findMany({
|
||||
const scanTake = Math.min(Math.max(limit * 8, 1000), 5000);
|
||||
const rowsRaw = await prisma.reasonEntry.findMany({
|
||||
where,
|
||||
orderBy: { capturedAt: "desc" },
|
||||
take: limit,
|
||||
take: scanTake,
|
||||
select: {
|
||||
id: true,
|
||||
episodeId: true,
|
||||
@@ -80,6 +90,14 @@ export async function GET(req: Request) {
|
||||
},
|
||||
});
|
||||
|
||||
const shiftContext = shift === "all" ? null : await loadDowntimeShiftContext(orgId);
|
||||
const rows = applyDowntimeFilters(rowsRaw, {
|
||||
planned,
|
||||
shift,
|
||||
microstopLtMin,
|
||||
shiftContext,
|
||||
}).slice(0, limit);
|
||||
|
||||
const events = rows.map((r) => {
|
||||
const startAt = r.capturedAt;
|
||||
const endAt =
|
||||
@@ -116,7 +134,11 @@ export async function GET(req: Request) {
|
||||
});
|
||||
|
||||
const nextBefore =
|
||||
events.length > 0 ? events[events.length - 1]?.capturedAt ?? null : null;
|
||||
events.length > 0
|
||||
? events[events.length - 1]?.capturedAt ?? null
|
||||
: rowsRaw.length > 0
|
||||
? toISO(rowsRaw[rowsRaw.length - 1]?.capturedAt)
|
||||
: null;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
@@ -125,6 +147,9 @@ export async function GET(req: Request) {
|
||||
start,
|
||||
machineId: machineId ?? null,
|
||||
reasonCode: reasonCode ?? null,
|
||||
planned,
|
||||
shift,
|
||||
microstopLtMin,
|
||||
includeMoldChange,
|
||||
limit,
|
||||
before: before ?? null,
|
||||
|
||||
@@ -2,6 +2,13 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
||||
import {
|
||||
applyDowntimeFilters,
|
||||
loadDowntimeShiftContext,
|
||||
normalizeMicrostopLtMin,
|
||||
normalizeShiftFilter,
|
||||
resolvePlannedFilter,
|
||||
} from "@/lib/analytics/downtimeFilters";
|
||||
|
||||
const bad = (status: number, error: string) =>
|
||||
NextResponse.json({ ok: false, error }, { status });
|
||||
@@ -21,6 +28,9 @@ export async function GET(req: Request) {
|
||||
const machineId = url.searchParams.get("machineId"); // optional
|
||||
const kind = (url.searchParams.get("kind") || "downtime").toLowerCase();
|
||||
const includeMoldChange = url.searchParams.get("includeMoldChange") === "true";
|
||||
const planned = resolvePlannedFilter(url.searchParams.get("planned"), includeMoldChange);
|
||||
const shift = normalizeShiftFilter(url.searchParams.get("shift"));
|
||||
const microstopLtMin = normalizeMicrostopLtMin(url.searchParams.get("microstopLtMin"));
|
||||
|
||||
if (kind !== "downtime" && kind !== "scrap" && kind !== "planned-downtime") {
|
||||
return bad(400, "Invalid kind (downtime|scrap|planned-downtime)");
|
||||
@@ -35,41 +45,82 @@ export async function GET(req: Request) {
|
||||
if (!m) return bad(404, "Machine not found");
|
||||
}
|
||||
|
||||
// ✅ Scope by orgId (+ machineId if provided)
|
||||
const grouped = await prisma.reasonEntry.groupBy({
|
||||
by: ["reasonCode", "reasonLabel"],
|
||||
where: {
|
||||
orgId,
|
||||
...(machineId ? { machineId } : {}),
|
||||
kind: kind === "planned-downtime" ? "downtime" : kind,
|
||||
...(kind === "downtime" && !includeMoldChange ? { reasonCode: { not: "MOLD_CHANGE" } } : {}),
|
||||
...(kind === "planned-downtime" ? { reasonCode: "MOLD_CHANGE" } : {}),
|
||||
capturedAt: { gte: start },
|
||||
},
|
||||
_sum: {
|
||||
durationSeconds: true,
|
||||
scrapQty: true,
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
let itemsRaw: { reasonCode: string; reasonLabel: string; value: number; count: number }[] = [];
|
||||
|
||||
const itemsRaw = grouped
|
||||
.map((g) => {
|
||||
const value =
|
||||
kind === "downtime" || kind === "planned-downtime"
|
||||
? Math.round(((g._sum.durationSeconds ?? 0) / 60) * 10) / 10 // minutes, 1 decimal
|
||||
: g._sum.scrapQty ?? 0;
|
||||
if (kind === "downtime" || kind === "planned-downtime") {
|
||||
const baseRows = await prisma.reasonEntry.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
...(machineId ? { machineId } : {}),
|
||||
kind: "downtime",
|
||||
capturedAt: { gte: start },
|
||||
},
|
||||
select: {
|
||||
reasonCode: true,
|
||||
reasonLabel: true,
|
||||
durationSeconds: true,
|
||||
capturedAt: true,
|
||||
meta: true,
|
||||
episodeId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
const effectivePlanned = kind === "planned-downtime" ? "planned" : planned;
|
||||
const shiftContext = shift === "all" ? null : await loadDowntimeShiftContext(orgId);
|
||||
const filteredRows = applyDowntimeFilters(baseRows, {
|
||||
planned: effectivePlanned,
|
||||
shift,
|
||||
microstopLtMin,
|
||||
shiftContext,
|
||||
});
|
||||
|
||||
const grouped = new Map<string, { reasonCode: string; reasonLabel: string; durationSeconds: number; count: number }>();
|
||||
for (const row of filteredRows) {
|
||||
const key = `${row.reasonCode}:::${row.reasonLabel ?? row.reasonCode}`;
|
||||
const slot =
|
||||
grouped.get(key) ??
|
||||
{
|
||||
reasonCode: row.reasonCode,
|
||||
reasonLabel: row.reasonLabel ?? row.reasonCode,
|
||||
durationSeconds: 0,
|
||||
count: 0,
|
||||
};
|
||||
slot.durationSeconds += Math.max(0, row.durationSeconds ?? 0);
|
||||
slot.count += 1;
|
||||
grouped.set(key, slot);
|
||||
}
|
||||
|
||||
itemsRaw = [...grouped.values()]
|
||||
.map((g) => ({
|
||||
reasonCode: g.reasonCode,
|
||||
reasonLabel: g.reasonLabel,
|
||||
value: Math.round((g.durationSeconds / 60) * 10) / 10,
|
||||
count: g.count,
|
||||
}))
|
||||
.filter((x) => x.value > 0 || x.count > 0);
|
||||
} else {
|
||||
// Scrap path unchanged.
|
||||
const grouped = await prisma.reasonEntry.groupBy({
|
||||
by: ["reasonCode", "reasonLabel"],
|
||||
where: {
|
||||
orgId,
|
||||
...(machineId ? { machineId } : {}),
|
||||
kind,
|
||||
capturedAt: { gte: start },
|
||||
},
|
||||
_sum: { scrapQty: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
itemsRaw = grouped
|
||||
.map((g) => ({
|
||||
reasonCode: g.reasonCode,
|
||||
reasonLabel: g.reasonLabel ?? g.reasonCode,
|
||||
value,
|
||||
value: g._sum.scrapQty ?? 0,
|
||||
count: g._count._all,
|
||||
};
|
||||
})
|
||||
.filter((x) =>
|
||||
kind === "downtime" || kind === "planned-downtime" ? x.value > 0 || x.count > 0 : x.value > 0
|
||||
);
|
||||
}))
|
||||
.filter((x) => x.value > 0);
|
||||
}
|
||||
|
||||
itemsRaw.sort((a, b) => b.value - a.value);
|
||||
|
||||
@@ -111,6 +162,9 @@ export async function GET(req: Request) {
|
||||
orgId,
|
||||
machineId: machineId ?? null,
|
||||
kind,
|
||||
planned: kind === "downtime" ? planned : kind === "planned-downtime" ? "planned" : "all",
|
||||
shift,
|
||||
microstopLtMin,
|
||||
includeMoldChange,
|
||||
range, // ✅ now defined correctly
|
||||
start, // ✅ now defined correctly
|
||||
|
||||
@@ -517,6 +517,14 @@ export async function POST(req: Request) {
|
||||
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 fallbackIncidentKey =
|
||||
clampText(
|
||||
evData.incidentKey ??
|
||||
dataObj.incidentKey ??
|
||||
evDowntime?.incidentKey ??
|
||||
evReason?.incidentKey,
|
||||
128
|
||||
) ?? null;
|
||||
const moldIncidentKey =
|
||||
clampText(evData.incidentKey ?? dataObj.incidentKey, 128) ??
|
||||
(numberFrom(evData.start_ms ?? dataObj.start_ms) != null
|
||||
@@ -533,7 +541,7 @@ export async function POST(req: Request) {
|
||||
detailLabel: "Cambio molde",
|
||||
reasonCode: "MOLD_CHANGE",
|
||||
reasonText: "Cambio molde",
|
||||
incidentKey: moldIncidentKey ?? row.id,
|
||||
incidentKey: moldIncidentKey ?? fallbackIncidentKey ?? row.id,
|
||||
} as Record<string, unknown>)
|
||||
:
|
||||
({
|
||||
@@ -544,7 +552,7 @@ export async function POST(req: Request) {
|
||||
detailLabel: "Unclassified",
|
||||
reasonCode: "UNCLASSIFIED",
|
||||
reasonText: "Unclassified",
|
||||
incidentKey: row.id,
|
||||
incidentKey: fallbackIncidentKey ?? row.id,
|
||||
} as Record<string, unknown>));
|
||||
|
||||
const inferredKind: ReasonCatalogKind =
|
||||
@@ -554,10 +562,18 @@ export async function POST(req: Request) {
|
||||
const resolved = resolveReason(reasonRaw, inferredKind, reasonCatalog, reasonCatalog.version);
|
||||
|
||||
if (resolved.reasonCode) {
|
||||
const continuityIncidentKey =
|
||||
inferredKind === "downtime"
|
||||
? clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey ?? fallbackIncidentKey, 128) ?? row.id
|
||||
: null;
|
||||
const reasonMetaIncidentKey =
|
||||
inferredKind === "downtime"
|
||||
? continuityIncidentKey
|
||||
: clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128);
|
||||
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}:downtime:${continuityIncidentKey ?? row.id}`
|
||||
: `evt:${machine.id}:scrap:${clampText(reasonRaw.scrapEntryId, 128) ?? row.id}`);
|
||||
|
||||
const workOrderId =
|
||||
@@ -577,7 +593,7 @@ export async function POST(req: Request) {
|
||||
source: "ingest:event",
|
||||
eventId: row.id,
|
||||
eventType: row.eventType,
|
||||
incidentKey: clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128),
|
||||
incidentKey: reasonMetaIncidentKey,
|
||||
anomalyType:
|
||||
clampText(evRecord.anomalyType, 64) ??
|
||||
clampText(evDowntime?.anomalyType, 64) ??
|
||||
@@ -595,7 +611,7 @@ export async function POST(req: Request) {
|
||||
};
|
||||
|
||||
if (inferredKind === "downtime") {
|
||||
const incidentKey = clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id;
|
||||
const incidentKey = continuityIncidentKey ?? row.id;
|
||||
const durationSeconds =
|
||||
numberFrom(evDowntime?.durationSeconds) ??
|
||||
numberFrom(evData.duration_sec) ??
|
||||
@@ -641,7 +657,7 @@ export async function POST(req: Request) {
|
||||
source: "ingest:event",
|
||||
eventId: row.id,
|
||||
eventType: row.eventType,
|
||||
incidentKey: clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128),
|
||||
incidentKey: reasonMetaIncidentKey,
|
||||
anomalyType:
|
||||
clampText(evRecord.anomalyType, 64) ??
|
||||
clampText(evDowntime?.anomalyType, 64) ??
|
||||
|
||||
@@ -47,6 +47,10 @@ function safeNum(v: unknown) {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function isProductionSnapshot(trackingEnabled: unknown, productionStarted: unknown) {
|
||||
return trackingEnabled === true && productionStarted === true;
|
||||
}
|
||||
|
||||
function toMs(value?: Date | null) {
|
||||
return value ? value.getTime() : 0;
|
||||
}
|
||||
@@ -137,6 +141,8 @@ export async function GET(req: NextRequest) {
|
||||
good: true,
|
||||
scrap: true,
|
||||
target: true,
|
||||
trackingEnabled: true,
|
||||
productionStarted: true,
|
||||
machineId: true,
|
||||
},
|
||||
});
|
||||
@@ -151,7 +157,9 @@ export async function GET(req: NextRequest) {
|
||||
let qualSum = 0;
|
||||
let qualCount = 0;
|
||||
|
||||
// OEE-family summaries are production-only to avoid mixing downtime/off windows.
|
||||
for (const k of kpiRows) {
|
||||
if (!isProductionSnapshot(k.trackingEnabled, k.productionStarted)) continue;
|
||||
if (safeNum(k.oee) != null) {
|
||||
oeeSum += Number(k.oee);
|
||||
oeeCount += 1;
|
||||
@@ -274,7 +282,7 @@ export async function GET(req: NextRequest) {
|
||||
else if (type === "oee-drop") oeeDropCount += 1;
|
||||
}
|
||||
|
||||
type TrendPoint = { t: string; v: number };
|
||||
type TrendPoint = { t: string; v: number | null };
|
||||
|
||||
const trend: {
|
||||
oee: TrendPoint[];
|
||||
@@ -292,10 +300,18 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
for (const k of kpiRows) {
|
||||
const t = k.ts.toISOString();
|
||||
if (safeNum(k.oee) != null) trend.oee.push({ t, v: Number(k.oee) });
|
||||
if (safeNum(k.availability) != null) trend.availability.push({ t, v: Number(k.availability) });
|
||||
if (safeNum(k.performance) != null) trend.performance.push({ t, v: Number(k.performance) });
|
||||
if (safeNum(k.quality) != null) trend.quality.push({ t, v: Number(k.quality) });
|
||||
if (!isProductionSnapshot(k.trackingEnabled, k.productionStarted)) {
|
||||
// Preserve timeline gaps across non-production windows for OEE-family charting.
|
||||
trend.oee.push({ t, v: null });
|
||||
trend.availability.push({ t, v: null });
|
||||
trend.performance.push({ t, v: null });
|
||||
trend.quality.push({ t, v: null });
|
||||
} else {
|
||||
trend.oee.push({ t, v: safeNum(k.oee) != null ? Number(k.oee) : null });
|
||||
trend.availability.push({ t, v: safeNum(k.availability) != null ? Number(k.availability) : null });
|
||||
trend.performance.push({ t, v: safeNum(k.performance) != null ? Number(k.performance) : null });
|
||||
trend.quality.push({ t, v: safeNum(k.quality) != null ? Number(k.quality) : null });
|
||||
}
|
||||
|
||||
const good = safeNum(k.good);
|
||||
const scrap = safeNum(k.scrap);
|
||||
|
||||
Reference in New Issue
Block a user