This commit is contained in:
Marcelo
2026-04-29 07:13:42 +00:00
parent 62169b163c
commit 5e7ddaa0db
10 changed files with 679 additions and 104 deletions

View File

@@ -221,6 +221,16 @@ const WORK_ORDER_TEMPLATE_HEADERS = [
"Active Cavities",
] as const;
const WORK_ORDER_TEMPLATE_EXAMPLE_ROW = [
"*borra esta fila al subir excel)",
"SKU-12345",
35,
10000,
"MOLD-01",
8,
8,
] as const;
function normalizeKey(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
}
@@ -654,7 +664,10 @@ export default function MachineDetailClient() {
async function downloadWorkOrderTemplate() {
const xlsx = await import("xlsx");
const wb = xlsx.utils.book_new();
const ws = xlsx.utils.aoa_to_sheet([Array.from(WORK_ORDER_TEMPLATE_HEADERS)]);
const ws = xlsx.utils.aoa_to_sheet([
Array.from(WORK_ORDER_TEMPLATE_HEADERS),
Array.from(WORK_ORDER_TEMPLATE_EXAMPLE_ROW),
]);
xlsx.utils.book_append_sheet(wb, ws, "Work Orders");
const wbout = xlsx.write(wb, { bookType: "xlsx", type: "array" });
const blob = new Blob([wbout], {

View File

@@ -21,7 +21,7 @@ type SimpleTooltipProps<T> = {
label?: string | number;
};
type ChartPoint = { ts: string; label: string; value: number };
type ChartPoint = { ts: string; label: string; value: number | null };
type CycleHistogramRow = {
label: string;
count: number;
@@ -135,7 +135,14 @@ export default function ReportsCharts({
"OEE",
]}
/>
<Line type="monotone" dataKey="value" stroke="#34d399" dot={false} strokeWidth={2} />
<Line
type="linear"
dataKey="value"
stroke="#34d399"
dot={false}
strokeWidth={2}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
) : (

View File

@@ -29,7 +29,7 @@ type ReportDowntime = {
oeeDropCount: number;
};
type ReportTrendPoint = { t: string; v: number };
type ReportTrendPoint = { t: string; v: number | null };
type ReportPayload = {
summary: ReportSummary;
@@ -78,6 +78,31 @@ function downsample<T>(rows: T[], max: number) {
return rows.filter((_, idx) => idx % step === 0);
}
function downsampleTrendPreserveGaps(rows: ReportTrendPoint[], max: number) {
if (rows.length <= max) return rows;
const step = Math.ceil(rows.length / max);
const picked = new Set<number>();
picked.add(0);
picked.add(rows.length - 1);
for (let idx = 0; idx < rows.length; idx += step) picked.add(idx);
// Keep both sides of null/non-null transitions so chart gaps remain visible.
for (let idx = 1; idx < rows.length; idx += 1) {
const prevIsNull = rows[idx - 1]?.v == null;
const currIsNull = rows[idx]?.v == null;
if (prevIsNull !== currIsNull) {
picked.add(idx - 1);
picked.add(idx);
}
}
return [...picked]
.sort((a, b) => a - b)
.map((idx) => rows[idx])
.filter((row): row is ReportTrendPoint => row != null);
}
function formatTickLabel(ts: string, range: RangeKey) {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return ts;
@@ -107,7 +132,7 @@ function ReportsChartsSkeleton() {
}
function buildCsv(report: ReportPayload, t: Translator) {
const rows = new Map<string, Record<string, string | number>>();
const rows = new Map<string, Record<string, string | number | null>>();
const addSeries = (series: ReportTrendPoint[], key: string) => {
for (const p of series) {
const row = rows.get(p.t) ?? { timestamp: p.t };
@@ -414,7 +439,7 @@ export default function ReportsPageClient({
const oeeSeries = useMemo(() => {
const rows = trend?.oee ?? [];
const trimmed = downsample(rows, 600);
const trimmed = downsampleTrendPreserveGaps(rows, 600);
return trimmed.map((p) => ({
ts: p.t,
label: formatTickLabel(p.t, range),

View File

@@ -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,

View File

@@ -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

View File

@@ -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) ??

View File

@@ -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);