pre-bemis

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

View File

@@ -64,7 +64,7 @@ export async function GET(req: Request) {
count: g._count._all,
};
})
.filter((x) => x.value > 0);
.filter((x) => (kind === "downtime" ? x.value > 0 || x.count > 0 : x.value > 0));
itemsRaw.sort((a, b) => b.value - a.value);

View File

@@ -0,0 +1,45 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import fs from "fs";
import { getLogPath } from "@/lib/logger";
const MAX_LINES = 100;
/**
* GET /api/debug/logs?key=YOUR_DEBUG_LOGS_KEY
*
* Returns the last MAX_LINES from the app log file. Set DEBUG_LOGS_KEY in .env
* and call with ?key=... to view. If DEBUG_LOGS_KEY is unset, returns 401.
*/
export async function GET(req: NextRequest) {
const key = req.nextUrl.searchParams.get("key");
const secret = process.env.DEBUG_LOGS_KEY;
if (!secret || key !== secret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const logPath = getLogPath();
try {
const raw = fs.readFileSync(logPath, "utf8");
const lines = raw.split("\n").filter(Boolean);
const recent = lines.slice(-MAX_LINES);
return NextResponse.json({
logPath,
lines: recent.length,
entries: recent.map((line) => {
try {
return JSON.parse(line) as Record<string, unknown>;
} catch {
return { raw: line };
}
}),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json(
{ error: "Failed to read log file", detail: message, logPath },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,31 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { logLine } from "@/lib/logger";
export const dynamic = "force-dynamic";
type PerfPayload = {
event?: string;
data?: Record<string, unknown>;
};
export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as PerfPayload;
const type = typeof body?.event === "string" ? body.event : "nav";
const data = body?.data && typeof body.data === "object" ? body.data : {};
const userAgent = req.headers.get("user-agent") ?? "";
logLine("perf.client", {
type,
userAgent,
...data,
});
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logLine("perf.client.error", { message });
return NextResponse.json({ ok: false, error: "Bad payload" }, { status: 400 });
}
}

View File

@@ -1,8 +1,17 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createHash } from "crypto";
import { revalidateTag } from "next/cache";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import {
FINANCIAL_CONFIG_SWR_SEC,
FINANCIAL_CONFIG_TTL_SEC,
getFinancialConfig,
type FinancialConfigPayload,
} from "@/lib/financial/cache";
function canManageFinancials(role?: string | null) {
return role === "OWNER";
@@ -101,18 +110,37 @@ async function ensureOrgFinancialProfile(
});
}
async function loadFinancialConfig(orgId: string) {
const [org, locations, machines, products] = await Promise.all([
prisma.orgFinancialProfile.findUnique({ where: { orgId } }),
prisma.locationFinancialOverride.findMany({ where: { orgId }, orderBy: { location: "asc" } }),
prisma.machineFinancialOverride.findMany({ where: { orgId }, orderBy: { createdAt: "desc" } }),
prisma.productCostOverride.findMany({ where: { orgId }, orderBy: { sku: "asc" } }),
]);
return { org, locations, machines, products };
function toMs(value?: Date | string | null) {
if (!value) return 0;
const date = typeof value === "string" ? new Date(value) : value;
const ms = date.getTime();
return Number.isNaN(ms) ? 0 : ms;
}
export async function GET() {
function maxUpdatedMs(rows: Array<{ updatedAt?: Date | string | null }>) {
let max = 0;
for (const row of rows) {
const ms = toMs(row.updatedAt);
if (ms > max) max = ms;
}
return max;
}
function buildConfigEtag(orgId: string, payload: FinancialConfigPayload) {
const parts = [
orgId,
toMs(payload.org?.updatedAt),
maxUpdatedMs(payload.locations ?? []),
maxUpdatedMs(payload.machines ?? []),
maxUpdatedMs(payload.products ?? []),
payload.locations?.length ?? 0,
payload.machines?.length ?? 0,
payload.products?.length ?? 0,
];
return `W/"${createHash("sha1").update(parts.join("|")).digest("hex")}"`;
}
export async function GET(req: NextRequest) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
@@ -124,9 +152,25 @@ export async function GET() {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
const url = new URL(req.url);
const refresh = url.searchParams.get("refresh") === "1";
await prisma.$transaction((tx) => ensureOrgFinancialProfile(tx, session.orgId, session.userId));
const payload = await loadFinancialConfig(session.orgId);
return NextResponse.json({ ok: true, ...payload });
const payload = await getFinancialConfig(session.orgId, { refresh });
const etag = buildConfigEtag(session.orgId, payload);
const responseHeaders = new Headers({
"Cache-Control": `private, max-age=${FINANCIAL_CONFIG_TTL_SEC}, stale-while-revalidate=${FINANCIAL_CONFIG_SWR_SEC}`,
ETag: etag,
Vary: "Cookie",
});
const ifNoneMatch = req.headers.get("if-none-match");
if (!refresh && ifNoneMatch && ifNoneMatch === etag) {
return new NextResponse(null, { status: 304, headers: responseHeaders });
}
return NextResponse.json({ ok: true, ...payload }, { headers: responseHeaders });
}
export async function POST(req: Request) {
@@ -257,6 +301,9 @@ export async function POST(req: Request) {
}
});
const payload = await loadFinancialConfig(session.orgId);
revalidateTag(`financial-config:${session.orgId}`, { expire: 0 });
revalidateTag(`financial-impact:${session.orgId}`, { expire: 0 });
const payload = await getFinancialConfig(session.orgId, { refresh: true });
return NextResponse.json({ ok: true, ...payload });
}

View File

@@ -2,7 +2,11 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { computeFinancialImpact } from "@/lib/financial/impact";
import {
FINANCIAL_IMPACT_SWR_SEC,
FINANCIAL_IMPACT_TTL_SEC,
getFinancialImpactCached,
} from "@/lib/financial/cache";
const RANGE_MS: Record<string, number> = {
"24h": 24 * 60 * 60 * 1000,
@@ -50,22 +54,31 @@ export async function GET(req: NextRequest) {
}
const url = new URL(req.url);
const refresh = url.searchParams.get("refresh") === "1";
const { start, end } = pickRange(req);
const machineId = url.searchParams.get("machineId") ?? undefined;
const location = url.searchParams.get("location") ?? undefined;
const sku = url.searchParams.get("sku") ?? undefined;
const currency = url.searchParams.get("currency") ?? undefined;
const result = await computeFinancialImpact({
orgId: session.orgId,
start,
end,
machineId,
location,
sku,
currency,
includeEvents: false,
const result = await getFinancialImpactCached(
{
orgId: session.orgId,
start,
end,
machineId,
location,
sku,
currency,
includeEvents: false,
},
{ refresh }
);
const responseHeaders = new Headers({
"Cache-Control": `private, max-age=${FINANCIAL_IMPACT_TTL_SEC}, stale-while-revalidate=${FINANCIAL_IMPACT_SWR_SEC}`,
Vary: "Cookie",
});
return NextResponse.json({ ok: true, ...result });
return NextResponse.json({ ok: true, ...result }, { headers: responseHeaders });
}

View File

@@ -33,6 +33,48 @@ function unwrapEnvelope(raw: unknown) {
};
}
function asNumber(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function normalizeCycleInput(raw: unknown): Record<string, unknown> | null {
const row = asRecord(raw);
if (!row) return null;
const data = asRecord(row.data);
const fromRowOrData = (keys: string[]) => {
for (const key of keys) {
if (row[key] !== undefined) return row[key];
if (data && data[key] !== undefined) return data[key];
}
return undefined;
};
return {
...row,
actual_cycle_time: fromRowOrData(["actual_cycle_time", "actualCycleTime", "actual_cycle", "actual"]),
theoretical_cycle_time: fromRowOrData([
"theoretical_cycle_time",
"theoreticalCycleTime",
"cycleTime",
"cycle_time",
"ideal",
]),
cycle_count: fromRowOrData(["cycle_count", "cycleCount"]),
work_order_id: fromRowOrData(["work_order_id", "workOrderId"]),
good_delta: fromRowOrData(["good_delta", "goodDelta"]),
scrap_delta: fromRowOrData(["scrap_delta", "scrapDelta", "scrap_total"]),
timestamp: fromRowOrData(["timestamp", "tsMs"]),
ts: fromRowOrData(["ts", "tsMs"]),
event_timestamp: fromRowOrData(["event_timestamp", "eventTimestamp"]),
};
}
const numberFromAny = z.preprocess((value) => {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim() !== "") return Number(value);
@@ -87,15 +129,22 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const cycleList = Array.isArray(cyclesRaw) ? cyclesRaw : [cyclesRaw];
const cycleList = (Array.isArray(cyclesRaw) ? cyclesRaw : [cyclesRaw])
.map((row) => normalizeCycleInput(row))
.filter((row): row is Record<string, unknown> => !!row);
if (!cycleList.length) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const parsedCycles = z.array(cycleSchema).safeParse(cycleList);
if (!parsedCycles.success) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const fallbackTsMs =
(typeof bodyRecord.tsMs === "number" && bodyRecord.tsMs) ||
(typeof bodyRecord.tsDevice === "number" && bodyRecord.tsDevice) ||
asNumber(bodyRecord.tsMs) ||
asNumber(bodyRecord.tsDevice) ||
undefined;
const rows = parsedCycles.data.map((data) => {

View File

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

View File

@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
import { getMachineAuth } from "@/lib/machineAuthCache";
import { normalizeSnapshotV1 } from "@/lib/contracts/v1";
import { toJsonValue } from "@/lib/prismaJson";
import { logLine } from "@/lib/logger";
function getClientIp(req: Request) {
const xf = req.headers.get("x-forwarded-for");
@@ -21,11 +22,68 @@ function parseSeqToBigInt(seq: unknown): bigint | null {
return null;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function readPath(root: unknown, path: string[]): unknown {
let current = root;
for (const key of path) {
const record = asRecord(current);
if (!record) return undefined;
current = record[key];
}
return current;
}
function collectQualityTrace(params: {
rawBody: unknown;
normalizedKpis: Record<string, unknown> | null;
persistedQuality: number | null;
machineId: string;
rowId: string;
}) {
const { rawBody, normalizedKpis, persistedQuality, machineId, rowId } = params;
const candidates = [
"kpis.quality",
"payload.kpis.quality",
"kpi_snapshot.quality",
"quality",
"payload.quality",
] as const;
const rawQualityCandidates: Record<string, { type: string; value: unknown }> = {};
for (const path of candidates) {
const value = readPath(rawBody, path.split("."));
rawQualityCandidates[path] = {
type: value === null ? "null" : typeof value,
value,
};
}
const normalizedQuality = normalizedKpis?.quality;
return {
machineId,
rowId,
rawQualityCandidates,
normalizedQuality: {
type: normalizedQuality === null ? "null" : typeof normalizedQuality,
value: normalizedQuality ?? null,
},
persistedQuality: {
type: persistedQuality === null ? "null" : typeof persistedQuality,
value: persistedQuality,
},
};
}
export async function POST(req: Request) {
const endpoint = "/api/ingest/kpi";
const startedAt = Date.now();
const ip = getClientIp(req);
const userAgent = req.headers.get("user-agent");
const traceEnabled = process.env.TRACE_KPI_INGEST === "1" || req.headers.get("x-debug-ingest") === "1";
let rawBody: unknown = null;
let orgId: string | null = null;
@@ -182,11 +240,33 @@ export async function POST(req: Request) {
},
});
const trace = collectQualityTrace({
rawBody,
normalizedKpis: asRecord(k),
persistedQuality: row.quality ?? null,
machineId: machine.id,
rowId: row.id,
});
if (traceEnabled) {
logLine("ingest.kpi.trace", {
endpoint,
machineId: machine.id,
orgId,
schemaVersion,
seq: seq != null ? seq.toString() : null,
ip,
userAgent,
trace,
rawBody: toJsonValue(rawBody),
});
}
return NextResponse.json({
ok: true,
id: row.id,
tsDevice: row.ts,
tsServer: row.tsServer,
trace: traceEnabled ? trace : undefined,
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Unknown error";

View File

@@ -1,9 +1,11 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { normalizeEvent } from "@/lib/events/normalizeEvent";
import { invalidateMachineAuth } from "@/lib/machineAuthCache";
const machineIdSchema = z.string().uuid();
@@ -29,10 +31,63 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
}
function parseNumber(value: string | null, fallback: number) {
if (value == null || value === "") return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
type MachineFkReference = {
tableName: string;
columnName: string;
deleteRule: string;
};
function quoteIdent(identifier: string) {
return `"${identifier.replace(/"/g, "\"\"")}"`;
}
async function cleanupMachineReferences(machineId: string) {
const refs = await prisma.$queryRaw<MachineFkReference[]>`
SELECT DISTINCT
tc.table_name AS "tableName",
kcu.column_name AS "columnName",
rc.delete_rule AS "deleteRule"
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.referential_constraints rc
ON tc.constraint_name = rc.constraint_name
AND tc.table_schema = rc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
AND rc.unique_constraint_schema = 'public'
AND rc.unique_constraint_name IN (
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = 'Machine'
AND constraint_type IN ('PRIMARY KEY', 'UNIQUE')
)
`;
for (const ref of refs) {
if (ref.tableName === "Machine") continue;
const table = quoteIdent(ref.tableName);
const column = quoteIdent(ref.columnName);
const rule = String(ref.deleteRule ?? "").toUpperCase();
if (rule === "CASCADE") continue;
if (rule === "SET NULL") {
await prisma.$executeRawUnsafe(`UPDATE ${table} SET ${column} = NULL WHERE ${column} = $1`, machineId);
continue;
}
await prisma.$executeRawUnsafe(`DELETE FROM ${table} WHERE ${column} = $1`, machineId);
}
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ machineId: string }> }) {
const session = await requireSession();
if (!session) {
@@ -158,25 +213,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach
const eventWindowStart = new Date(Date.now() - eventsWindowSec * 1000);
const criticalSeverities = ["critical", "error", "high"];
const eventWhere = {
const eventWhereBase = {
orgId: session.orgId,
machineId,
ts: { gte: eventWindowStart },
eventType: { in: Array.from(ALLOWED_EVENT_TYPES) },
...(eventsMode === "critical"
? {
OR: [
{ eventType: "macrostop" },
{ requiresAck: true },
{ severity: { in: criticalSeverities } },
],
}
: {}),
};
const [rawEvents, eventsCountAll] = await Promise.all([
prisma.machineEvent.findMany({
where: eventWhere,
where: eventWhereBase,
orderBy: { ts: "desc" },
take: eventsOnly ? 300 : 120,
select: {
@@ -192,15 +237,29 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach
workOrderId: true,
},
}),
prisma.machineEvent.count({ where: eventWhere }),
prisma.machineEvent.count({ where: eventWhereBase }),
]);
const normalized = rawEvents.map((row) =>
normalizeEvent(row, { microMultiplier: stoppageMultiplier, macroMultiplier: macroStoppageMultiplier })
);
const allowed = normalized.filter((event) => ALLOWED_EVENT_TYPES.has(event.eventType));
const criticalEventTypes = new Set(["macrostop", "microstop", "slow-cycle", "offline", "error"]);
const filtered =
eventsMode === "critical"
? allowed.filter((event) => {
const severity = String(event.severity ?? "").toLowerCase();
return (
criticalEventTypes.has(event.eventType) ||
event.requiresAck === true ||
criticalSeverities.includes(severity)
);
})
: allowed;
const seen = new Set<string>();
const deduped = normalized.filter((event) => {
const deduped = filtered.filter((event) => {
const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`;
if (seen.has(key)) return false;
seen.add(key);
@@ -249,25 +308,185 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ mach
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
const result = await prisma.$transaction(async (tx) => {
await tx.machineCycle.deleteMany({
where: {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
if (attempt === 0) {
// Revoke credentials first in a committed write so ingest auth fails immediately.
const revoked = await prisma.machine.updateMany({
where: {
id: machineId,
orgId: session.orgId,
},
data: {
apiKey: null,
},
});
if (revoked.count === 0) {
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
}
invalidateMachineAuth(machineId);
}
// Avoid long interactive transactions on very large history tables (P2028 timeout).
// This sequence is idempotent and safe to retry because apiKey is revoked first.
await prisma.machineCycle.deleteMany({
where: {
machineId,
},
});
await prisma.machineHeartbeat.deleteMany({
where: {
machineId,
},
});
await prisma.machineKpiSnapshot.deleteMany({
where: {
machineId,
},
});
await prisma.machineEvent.deleteMany({
where: {
machineId,
},
});
await prisma.machineWorkOrder.deleteMany({
where: {
machineId,
},
});
await prisma.machineSettings.deleteMany({
where: {
machineId,
},
});
await prisma.settingsAudit.deleteMany({
where: {
machineId,
},
});
await prisma.alertNotification.deleteMany({
where: {
machineId,
},
});
await prisma.machineFinancialOverride.deleteMany({
where: {
machineId,
},
});
await prisma.reasonEntry.deleteMany({
where: {
machineId,
},
});
await prisma.downtimeAction.updateMany({
where: {
machineId,
},
data: {
machineId: null,
},
});
const result = await prisma.machine.deleteMany({
where: {
id: machineId,
orgId: session.orgId,
},
});
if (result.count === 0) {
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
}
invalidateMachineAuth(machineId);
return NextResponse.json({ ok: true });
} catch (err: unknown) {
const code = err instanceof Prisma.PrismaClientKnownRequestError ? err.code : undefined;
const message = err instanceof Error ? err.message : String(err);
console.error("DELETE /api/machines/[machineId] failed", {
machineId,
orgId: session.orgId,
},
});
attempt,
code,
message,
});
return tx.machine.deleteMany({
where: {
id: machineId,
orgId: session.orgId,
},
});
});
if (code === "P2003") {
if (attempt < 2) {
try {
await cleanupMachineReferences(machineId);
} catch (cleanupErr: unknown) {
const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
console.error("DELETE /api/machines/[machineId] cleanup failed", {
machineId,
orgId: session.orgId,
attempt,
cleanupMessage,
});
}
await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 150));
continue;
}
if (result.count === 0) {
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
return NextResponse.json(
{
ok: false,
error: "Machine has dependent records and could not be removed",
code,
},
{ status: 409 }
);
}
if (code === "P2022") {
return NextResponse.json(
{
ok: false,
error: "Server schema is out of date for machine delete",
code,
},
{ status: 500 }
);
}
if (code === "P2028") {
return NextResponse.json(
{
ok: false,
error: "Delete timed out while removing machine history",
code,
},
{ status: 503 }
);
}
if (code) {
return NextResponse.json(
{
ok: false,
error: "Delete failed due to database error",
code,
},
{ status: 500 }
);
}
return NextResponse.json({ ok: false, error: "Delete failed" }, { status: 500 });
}
}
return NextResponse.json({ ok: true });
return NextResponse.json({ ok: false, error: "Delete failed", code: "DELETE_RETRY_EXHAUSTED" }, { status: 500 });
}

View File

@@ -1,11 +1,25 @@
import { NextResponse } from "next/server";
import { randomBytes } from "crypto";
import { prisma } from "@/lib/prisma";
import { cookies } from "next/headers";
import { generatePairingCode } from "@/lib/pairingCode";
import { z } from "zod";
import { logLine } from "@/lib/logger";
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
import { requireSession } from "@/lib/auth/requireSession";
import {
fetchLatestHeartbeats,
fetchLatestKpis,
fetchMachineBase,
mergeMachineOverviewRows,
} from "@/lib/machines/withLatest";
const COOKIE_NAME = "mis_session";
let machinesColdStart = true;
function getColdStartInfo() {
const coldStart = machinesColdStart;
machinesColdStart = false;
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
}
const createMachineSchema = z.object({
name: z.string().trim().min(1).max(80),
@@ -13,72 +27,66 @@ const createMachineSchema = z.object({
location: z.string().trim().max(80).optional(),
});
async function requireSession() {
const sessionId = (await cookies()).get(COOKIE_NAME)?.value;
if (!sessionId) return null;
export async function GET(req: Request) {
const perfEnabled = PERF_LOGS_ENABLED;
const totalStart = nowMs();
const timings: Record<string, number> = {};
const { coldStart, uptimeMs } = getColdStartInfo();
const url = new URL(req.url);
const includeKpi = url.searchParams.get("includeKpi") === "1";
const session = await prisma.session.findFirst({
where: { id: sessionId, revokedAt: null, expiresAt: { gt: new Date() } },
include: { org: true, user: true },
});
if (!session || !session.user?.isActive || !session.user?.emailVerifiedAt) {
return null;
}
return session;
}
export async function GET() {
const authStart = nowMs();
const session = await requireSession();
if (perfEnabled) timings.auth = elapsedMs(authStart);
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const machines = await prisma.machine.findMany({
where: { orgId: session.orgId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
code: true,
location: true,
createdAt: true,
updatedAt: true,
heartbeats: {
orderBy: { tsServer: "desc" },
take: 1,
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
},
kpiSnapshots: {
orderBy: { ts: "desc" },
take: 1,
select: {
ts: true,
oee: true,
availability: true,
performance: true,
quality: true,
workOrderId: true,
sku: true,
good: true,
scrap: true,
target: true,
cycleTime: true,
},
},
},
});
const preQueryStart = nowMs();
const machinesStart = nowMs();
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
const machines = await fetchMachineBase(session.orgId);
if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
const heartbeatStart = nowMs();
const machineIds = machines.map((machine) => machine.id);
const heartbeats = await fetchLatestHeartbeats(session.orgId, machineIds);
if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
let kpis: Awaited<ReturnType<typeof fetchLatestKpis>> = [];
if (includeKpi) {
const kpiStart = nowMs();
kpis = await fetchLatestKpis(session.orgId, machineIds);
if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart);
}
const postQueryStart = nowMs();
// flatten latest heartbeat for UI convenience
const out = machines.map((m) => ({
...m,
latestHeartbeat: m.heartbeats[0] ?? null,
latestKpi: m.kpiSnapshots[0] ?? null,
heartbeats: undefined,
kpiSnapshots: undefined,
}));
const out = mergeMachineOverviewRows({
machines,
heartbeats,
kpis,
includeKpi,
});
return NextResponse.json({ ok: true, machines: out });
const payload = { ok: true, machines: out };
const responseHeaders = new Headers();
if (perfEnabled) {
timings.postQuery = elapsedMs(postQueryStart);
timings.total = elapsedMs(totalStart);
responseHeaders.set("Server-Timing", formatServerTiming(timings));
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
logLine("perf.machines.api", {
orgId: session.orgId,
coldStart,
uptimeMs,
timings,
counts: { machines: out.length },
payloadBytes,
});
}
return NextResponse.json(payload, { headers: responseHeaders });
}
export async function POST(req: Request) {

View File

@@ -4,23 +4,72 @@ import { createHash } from "crypto";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { getOverviewData } from "@/lib/overview/getOverviewData";
import { getOverviewSummary } from "@/lib/overview/getOverviewSummary";
import { logLine } from "@/lib/logger";
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
let overviewColdStart = true;
function getColdStartInfo() {
const coldStart = overviewColdStart;
overviewColdStart = false;
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
}
function toMs(value?: Date | null) {
return value ? value.getTime() : 0;
}
export async function GET(req: NextRequest) {
const perfEnabled = PERF_LOGS_ENABLED;
const totalStart = nowMs();
const timings: Record<string, number> = {};
const { coldStart, uptimeMs } = getColdStartInfo();
const authStart = nowMs();
const session = await requireSession();
if (perfEnabled) timings.auth = elapsedMs(authStart);
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const url = new URL(req.url);
const detail = url.searchParams.get("detail") === "1";
if (!detail) {
const summaryStart = nowMs();
const { machines: machineRows } = await getOverviewSummary({ orgId: session.orgId });
if (perfEnabled) timings.summary = elapsedMs(summaryStart);
const payload = { ok: true, machines: machineRows, events: [] };
const responseHeaders = new Headers();
if (perfEnabled) {
timings.total = elapsedMs(totalStart);
responseHeaders.set("Server-Timing", formatServerTiming(timings));
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
logLine("perf.overview.api", {
orgId: session.orgId,
detail: false,
coldStart,
uptimeMs,
timings,
counts: { machines: machineRows.length, events: 0 },
payloadBytes,
});
}
return NextResponse.json(payload, { headers: responseHeaders });
}
const preQueryStart = nowMs();
const eventsMode = url.searchParams.get("events") ?? "critical";
const eventsWindowSecRaw = Number(url.searchParams.get("eventsWindowSec") ?? "21600");
const eventsWindowSec = Number.isFinite(eventsWindowSecRaw) ? eventsWindowSecRaw : 21600;
const eventMachinesRaw = Number(url.searchParams.get("eventMachines") ?? "6");
const eventMachines = Number.isFinite(eventMachinesRaw) ? Math.max(1, eventMachinesRaw) : 6;
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
const aggStart = nowMs();
const [machineAgg, heartbeatAgg, kpiAgg, eventAgg, orgSettings] = await Promise.all([
prisma.machine.aggregate({
where: { orgId: session.orgId },
@@ -43,6 +92,7 @@ export async function GET(req: NextRequest) {
select: { updatedAt: true, stoppageMultiplier: true, macroStoppageMultiplier: true },
}),
]);
if (perfEnabled) timings.agg = elapsedMs(aggStart);
const lastModifiedMs = Math.max(
toMs(machineAgg._max.updatedAt),
@@ -86,6 +136,7 @@ export async function GET(req: NextRequest) {
}
}
const dataStart = nowMs();
const { machines: machineRows, events } = await getOverviewData({
orgId: session.orgId,
eventsMode,
@@ -93,9 +144,29 @@ export async function GET(req: NextRequest) {
eventMachines,
orgSettings,
});
if (perfEnabled) timings.data = elapsedMs(dataStart);
return NextResponse.json(
{ ok: true, machines: machineRows, events },
{ headers: responseHeaders }
);
const postQueryStart = nowMs();
const payload = { ok: true, machines: machineRows, events };
if (perfEnabled) {
timings.postQuery = elapsedMs(postQueryStart);
timings.total = elapsedMs(totalStart);
responseHeaders.set("Server-Timing", formatServerTiming(timings));
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
logLine("perf.overview.api", {
orgId: session.orgId,
detail: true,
coldStart,
uptimeMs,
eventsMode,
eventsWindowSec,
eventMachines,
timings,
counts: { machines: machineRows.length, events: events.length },
payloadBytes,
});
}
return NextResponse.json(payload, { headers: responseHeaders });
}

View File

@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import {
flattenReasonCatalog,
loadFallbackReasonCatalog,
normalizeReasonCatalog,
type ReasonCatalogKind,
} from "@/lib/reasonCatalog";
function asKind(value: string | null): ReasonCatalogKind | null {
const kind = String(value ?? "").toLowerCase();
if (kind === "downtime" || kind === "scrap") return kind;
return null;
}
export async function GET(req: Request) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const url = new URL(req.url);
const kind = asKind(url.searchParams.get("kind"));
if (!kind) {
return NextResponse.json({ ok: false, error: "Invalid kind (downtime|scrap)" }, { status: 400 });
}
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: session.orgId },
select: { defaultsJson: true },
});
const defaultsJson =
orgSettings?.defaultsJson && typeof orgSettings.defaultsJson === "object" && !Array.isArray(orgSettings.defaultsJson)
? (orgSettings.defaultsJson as Record<string, unknown>)
: {};
const settingsCatalog = normalizeReasonCatalog(defaultsJson.reasonCatalog ?? defaultsJson.reasonCatalogData);
const fallbackCatalog = await loadFallbackReasonCatalog();
const catalog = settingsCatalog ?? fallbackCatalog;
const rows = flattenReasonCatalog(catalog, kind);
return NextResponse.json({
ok: true,
source: settingsCatalog ? "settings" : "fallback",
kind,
catalogVersion: catalog.version,
categories: catalog[kind],
rows,
});
}

View File

@@ -2,6 +2,16 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { logLine } from "@/lib/logger";
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
let reportsFiltersColdStart = true;
function getColdStartInfo() {
const coldStart = reportsFiltersColdStart;
reportsFiltersColdStart = false;
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
}
const RANGE_MS: Record<string, number> = {
"24h": 24 * 60 * 60 * 1000,
@@ -33,10 +43,19 @@ function pickRange(req: NextRequest) {
}
export async function GET(req: NextRequest) {
const perfEnabled = PERF_LOGS_ENABLED;
const totalStart = nowMs();
const timings: Record<string, number> = {};
const { coldStart, uptimeMs } = getColdStartInfo();
const authStart = nowMs();
const session = await requireSession();
if (perfEnabled) timings.auth = elapsedMs(authStart);
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const preQueryStart = nowMs();
const url = new URL(req.url);
const range = url.searchParams.get("range") ?? "24h";
const machineId = url.searchParams.get("machineId") ?? undefined;
const { start, end } = pickRange(req);
@@ -46,20 +65,51 @@ export async function GET(req: NextRequest) {
ts: { gte: start, lte: end },
};
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
const workOrdersStart = nowMs();
const workOrderRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, workOrderId: { not: null } },
distinct: ["workOrderId"],
select: { workOrderId: true },
});
if (perfEnabled) timings.workOrders = elapsedMs(workOrdersStart);
const skuStart = nowMs();
const skuRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, sku: { not: null } },
distinct: ["sku"],
select: { sku: true },
});
if (perfEnabled) timings.skus = elapsedMs(skuStart);
const postQueryStart = nowMs();
const workOrders = workOrderRows.map((r) => r.workOrderId).filter(Boolean) as string[];
const skus = skuRows.map((r) => r.sku).filter(Boolean) as string[];
return NextResponse.json({ ok: true, workOrders, skus });
const payload = { ok: true, workOrders, skus };
const responseHeaders = new Headers();
if (perfEnabled) {
timings.postQuery = elapsedMs(postQueryStart);
timings.total = elapsedMs(totalStart);
responseHeaders.set("Server-Timing", formatServerTiming(timings));
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
logLine("perf.reports.filters", {
orgId: session.orgId,
coldStart,
uptimeMs,
range,
machineId,
timings,
rowCounts: {
workOrderRows: workOrderRows.length,
skuRows: skuRows.length,
},
payloadBytes,
});
}
return NextResponse.json(payload, { headers: responseHeaders });
}

View File

@@ -2,6 +2,16 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { logLine } from "@/lib/logger";
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
let reportsColdStart = true;
function getColdStartInfo() {
const coldStart = reportsColdStart;
reportsColdStart = false;
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
}
const RANGE_MS: Record<string, number> = {
"24h": 24 * 60 * 60 * 1000,
@@ -37,10 +47,19 @@ function safeNum(v: unknown) {
}
export async function GET(req: NextRequest) {
const perfEnabled = PERF_LOGS_ENABLED;
const totalStart = nowMs();
const timings: Record<string, number> = {};
const { coldStart, uptimeMs } = getColdStartInfo();
const authStart = nowMs();
const session = await requireSession();
if (perfEnabled) timings.auth = elapsedMs(authStart);
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const preQueryStart = nowMs();
const url = new URL(req.url);
const range = url.searchParams.get("range") ?? "24h";
const machineId = url.searchParams.get("machineId") ?? undefined;
const { start, end } = pickRange(req);
const workOrderId = url.searchParams.get("workOrderId") ?? undefined;
@@ -52,6 +71,9 @@ export async function GET(req: NextRequest) {
...(sku ? { sku } : {}),
};
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
const kpiStart = nowMs();
const kpiRows = await prisma.machineKpiSnapshot.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
orderBy: { ts: "asc" },
@@ -67,6 +89,7 @@ export async function GET(req: NextRequest) {
machineId: true,
},
});
if (perfEnabled) timings.kpiRows = elapsedMs(kpiStart);
let oeeSum = 0;
let oeeCount = 0;
@@ -96,10 +119,12 @@ export async function GET(req: NextRequest) {
}
}
const cyclesStart = nowMs();
const cycles = await prisma.machineCycle.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { goodDelta: true, scrapDelta: true },
});
if (perfEnabled) timings.cycles = elapsedMs(cyclesStart);
let goodTotal = 0;
let scrapTotal = 0;
@@ -109,6 +134,7 @@ export async function GET(req: NextRequest) {
if (safeNum(c.scrapDelta) != null) scrapTotal += Number(c.scrapDelta);
}
const kpiAggStart = nowMs();
const kpiAgg = await prisma.machineKpiSnapshot.groupBy({
by: ["machineId"],
where: { ...baseWhere, ts: { gte: start, lte: end } },
@@ -116,6 +142,7 @@ export async function GET(req: NextRequest) {
_min: { good: true, scrap: true },
_count: { _all: true },
});
if (perfEnabled) timings.kpiAgg = elapsedMs(kpiAggStart);
let targetTotal = 0;
if (goodTotal === 0 && scrapTotal === 0) {
@@ -151,10 +178,12 @@ export async function GET(req: NextRequest) {
if (maxTarget != null) targetTotal += maxTarget;
}
const eventsStart = nowMs();
const events = await prisma.machineEvent.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { eventType: true, data: true },
});
if (perfEnabled) timings.events = elapsedMs(eventsStart);
let macrostopSec = 0;
let microstopSec = 0;
@@ -223,10 +252,12 @@ export async function GET(req: NextRequest) {
trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 });
}
}
const cycleRowsStart = nowMs();
const cycleRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { actualCycleTime: true },
});
if (perfEnabled) timings.cycleRows = elapsedMs(cycleRowsStart);
const values = cycleRows
.map((c) => Number(c.actualCycleTime))
@@ -310,10 +341,14 @@ export async function GET(req: NextRequest) {
const scrapBySku = new Map<string, number>();
const scrapByWo = new Map<string, number>();
const scrapRowsStart = nowMs();
const scrapRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { sku: true, workOrderId: true, scrapDelta: true },
});
if (perfEnabled) timings.scrapRows = elapsedMs(scrapRowsStart);
const postQueryStart = nowMs();
for (const row of scrapRows) {
const scrap = safeNum(row.scrapDelta);
@@ -340,20 +375,20 @@ export async function GET(req: NextRequest) {
return NextResponse.json({
const payload = {
ok: true,
summary: {
oeeAvg,
availabilityAvg,
performanceAvg,
qualityAvg,
goodTotal,
scrapTotal,
targetTotal,
scrapRate,
topScrapSku,
topScrapWorkOrder,
},
oeeAvg,
availabilityAvg,
performanceAvg,
qualityAvg,
goodTotal,
scrapTotal,
targetTotal,
scrapRate,
topScrapSku,
topScrapWorkOrder,
},
downtime: {
macrostopSec,
@@ -365,9 +400,36 @@ export async function GET(req: NextRequest) {
},
trend,
insights,
distribution: {
cycleTime: cycleTimeBins
distribution: {
cycleTime: cycleTimeBins,
},
};
});
const responseHeaders = new Headers();
if (perfEnabled) {
timings.postQuery = elapsedMs(postQueryStart);
timings.total = elapsedMs(totalStart);
responseHeaders.set("Server-Timing", formatServerTiming(timings));
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
logLine("perf.reports.api", {
orgId: session.orgId,
coldStart,
uptimeMs,
range,
machineId,
workOrderId,
sku,
timings,
rowCounts: {
kpiRows: kpiRows.length,
cycles: cycles.length,
events: events.length,
cycleRows: cycleRows.length,
scrapRows: scrapRows.length,
},
payloadBytes,
});
}
return NextResponse.json(payload, { headers: responseHeaders });
}

View File

@@ -14,8 +14,10 @@ import {
validateDefaults,
validateShiftFields,
validateShiftSchedule,
validateShiftOverrides,
validateThresholds,
} from "@/lib/settings";
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
@@ -44,6 +46,24 @@ function pickAllowedOverrides(raw: unknown) {
return out;
}
function withReasonCatalog<T extends Record<string, unknown>>(payload: T, fallbackCatalog: ReasonCatalog) {
const base = (isPlainObject(payload) ? { ...payload } : {}) as T;
const defaults = isPlainObject(base.defaults) ? base.defaults : {};
const parsed =
normalizeReasonCatalog(base.reasonCatalog) ??
normalizeReasonCatalog(base.reasonCatalogData) ??
normalizeReasonCatalog(defaults.reasonCatalog) ??
normalizeReasonCatalog(defaults.reasonCatalogData) ??
fallbackCatalog;
return {
...base,
reasonCatalog: parsed,
reasonCatalogData: parsed,
reasonCatalogVersion: Number(parsed.version || 1),
};
}
async function ensureOrgSettings(
tx: Prisma.TransactionClient,
orgId: string,
@@ -144,6 +164,7 @@ export async function GET(
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
orgId = machine.orgId;
}
const fallbackCatalog = await loadFallbackReasonCatalog();
const { settings, overrides } = await prisma.$transaction(async (tx) => {
const orgSettings = await ensureOrgSettings(tx, orgId as string, userId);
@@ -154,9 +175,15 @@ export async function GET(
select: { overridesJson: true },
});
const orgPayload = buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []);
const orgPayload = withReasonCatalog(
buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []),
fallbackCatalog
);
const rawOverrides = pickAllowedOverrides(machineSettings?.overridesJson ?? {});
const effective = deepMerge(orgPayload, rawOverrides);
const effective = withReasonCatalog(
deepMerge(orgPayload, rawOverrides) as Record<string, unknown>,
fallbackCatalog
);
return { settings: { org: orgPayload, effective }, overrides: rawOverrides };
});
@@ -242,6 +269,14 @@ export async function PUT(
return NextResponse.json({ ok: false, error: shiftValidation.error }, { status: 400 });
}
const overridesResult =
patch?.shiftSchedule?.overrides !== undefined
? validateShiftOverrides(patch.shiftSchedule.overrides)
: ({ ok: true, overrides: undefined } as const);
if (!overridesResult.ok) {
return NextResponse.json({ ok: false, error: overridesResult.error }, { status: 400 });
}
const thresholdsValidation = validateThresholds(patch?.thresholds);
if (!thresholdsValidation.ok) {
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
@@ -275,6 +310,12 @@ export async function PUT(
...patch,
shiftSchedule: {
...patch.shiftSchedule,
overrides:
patch.shiftSchedule.overrides !== undefined
? overridesResult.overrides === null
? null
: overridesResult.overrides
: patch.shiftSchedule.overrides,
shiftChangeCompensationMin:
patch.shiftSchedule.shiftChangeCompensationMin !== undefined
? Number(patch.shiftSchedule.shiftChangeCompensationMin)
@@ -372,9 +413,16 @@ export async function PUT(
},
});
const orgPayload = buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []);
const fallbackCatalog = await loadFallbackReasonCatalog();
const orgPayload = withReasonCatalog(
buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []),
fallbackCatalog
);
const overrides = pickAllowedOverrides(saved.overridesJson ?? {});
const effective = deepMerge(orgPayload, overrides);
const effective = withReasonCatalog(
deepMerge(orgPayload, overrides) as Record<string, unknown>,
fallbackCatalog
);
return {
orgPayload,

View File

@@ -1,4 +1,7 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createHash } from "crypto";
import { revalidateTag, unstable_cache } from "next/cache";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
@@ -13,8 +16,10 @@ import {
validateDefaults,
validateShiftFields,
validateShiftSchedule,
validateShiftOverrides,
validateThresholds,
} from "@/lib/settings";
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
@@ -34,6 +39,24 @@ function canManageSettings(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
function withReasonCatalog<T extends Record<string, unknown>>(payload: T, fallbackCatalog: ReasonCatalog) {
const base = (isPlainObject(payload) ? { ...payload } : {}) as T;
const defaults = isPlainObject(base.defaults) ? base.defaults : {};
const parsed =
normalizeReasonCatalog(base.reasonCatalog) ??
normalizeReasonCatalog(base.reasonCatalogData) ??
normalizeReasonCatalog(defaults.reasonCatalog) ??
normalizeReasonCatalog(defaults.reasonCatalogData) ??
fallbackCatalog;
return {
...base,
reasonCatalog: parsed,
reasonCatalogData: parsed,
reasonCatalogVersion: Number(parsed.version || 1),
};
}
const settingsPayloadSchema = z
.object({
source: z.string().trim().max(40).optional(),
@@ -43,10 +66,14 @@ const settingsPayloadSchema = z
thresholds: z.any().optional(),
alerts: z.any().optional(),
defaults: z.any().optional(),
reasonCatalog: z.any().optional(),
version: z.union([z.number(), z.string()]).optional(),
})
.passthrough();
const SETTINGS_TTL_SEC = 10;
const SETTINGS_SWR_SEC = 30;
async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) {
let settings = await tx.orgSettings.findUnique({
where: { orgId },
@@ -111,24 +138,56 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
return { settings, shifts };
}
export async function GET() {
async function loadSettingsPayload(orgId: string, userId: string) {
const loaded = await prisma.$transaction(async (tx) => {
const found = await ensureOrgSettings(tx, orgId, userId);
if (!found?.settings) throw new Error("SETTINGS_NOT_FOUND");
return found;
});
const fallbackCatalog = await loadFallbackReasonCatalog();
const payload = withReasonCatalog(buildSettingsPayload(loaded.settings, loaded.shifts ?? []), fallbackCatalog);
const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {};
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
const modules = { screenlessMode: modulesRaw.screenlessMode === true };
return { payload, modules };
}
async function loadSettingsCached(orgId: string, userId: string) {
const cached = unstable_cache(
() => loadSettingsPayload(orgId, userId),
["settings", orgId],
{ revalidate: SETTINGS_TTL_SEC, tags: [`settings:${orgId}`] }
);
return cached();
}
export async function GET(req: NextRequest) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
try {
const loaded = await prisma.$transaction(async (tx) => {
const found = await ensureOrgSettings(tx, session.orgId, session.userId);
if (!found?.settings) throw new Error("SETTINGS_NOT_FOUND");
return found;
const url = new URL(req.url);
const refresh = url.searchParams.get("refresh") === "1";
const { payload, modules } = refresh
? await loadSettingsPayload(session.orgId, session.userId)
: await loadSettingsCached(session.orgId, session.userId);
const version = payload.version ?? 0;
const etag = `W/"${createHash("sha1").update(`${session.orgId}:${version}`).digest("hex")}"`;
const responseHeaders = new Headers({
"Cache-Control": `private, max-age=${SETTINGS_TTL_SEC}, stale-while-revalidate=${SETTINGS_SWR_SEC}`,
ETag: etag,
Vary: "Cookie",
});
const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []);
const ifNoneMatch = req.headers.get("if-none-match");
if (!refresh && ifNoneMatch && ifNoneMatch === etag) {
return new NextResponse(null, { status: 304, headers: responseHeaders });
}
const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {};
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
const modules = { screenlessMode: modulesRaw.screenlessMode === true };
return NextResponse.json({ ok: true, settings: { ...payload, modules } });
return NextResponse.json({ ok: true, settings: { ...payload, modules } }, { headers: responseHeaders });
} catch (err) {
console.error("[settings GET] failed", err);
@@ -162,6 +221,7 @@ export async function PUT(req: Request) {
const thresholds = parsed.data.thresholds;
const alerts = parsed.data.alerts;
const defaults = parsed.data.defaults;
const reasonCatalogRaw = parsed.data.reasonCatalog;
const expectedVersion = parsed.data.version;
const modules = parsed.data.modules;
@@ -173,6 +233,7 @@ export async function PUT(req: Request) {
thresholds === undefined &&
alerts === undefined &&
defaults === undefined &&
reasonCatalogRaw === undefined &&
modules === undefined
) {
@@ -191,6 +252,13 @@ export async function PUT(req: Request) {
if (defaults !== undefined && !isPlainObject(defaults)) {
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
}
const nextReasonCatalog =
reasonCatalogRaw === undefined || reasonCatalogRaw === null
? reasonCatalogRaw
: normalizeReasonCatalog(reasonCatalogRaw);
if (reasonCatalogRaw !== undefined && reasonCatalogRaw !== null && !nextReasonCatalog) {
return NextResponse.json({ ok: false, error: "reasonCatalog must be a valid catalog payload" }, { status: 400 });
}
if (modules !== undefined && !isPlainObject(modules)) {
return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 });
}
@@ -210,6 +278,14 @@ export async function PUT(req: Request) {
return NextResponse.json({ ok: false, error: shiftValidation.error }, { status: 400 });
}
const overridesResult =
shiftSchedule?.overrides !== undefined
? validateShiftOverrides(shiftSchedule.overrides)
: ({ ok: true, overrides: undefined } as const);
if (!overridesResult.ok) {
return NextResponse.json({ ok: false, error: overridesResult.error }, { status: 400 });
}
const thresholdsValidation = validateThresholds(thresholds);
if (!thresholdsValidation.ok) {
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
@@ -257,12 +333,22 @@ export async function PUT(req: Request) {
: { ...currentModulesRaw, screenlessMode };
// Write defaultsJson if either defaults changed OR modules changed
const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined;
const shouldWriteDefaultsJson =
!!nextDefaultsCore || screenlessMode !== undefined || reasonCatalogRaw !== undefined;
const nextDefaultsJson = shouldWriteDefaultsJson
? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules }
: undefined;
if (nextDefaultsJson && reasonCatalogRaw !== undefined) {
const defaultsTarget = nextDefaultsJson as Record<string, unknown>;
if (nextReasonCatalog === null) {
delete defaultsTarget.reasonCatalog;
} else if (nextReasonCatalog) {
defaultsTarget.reasonCatalog = nextReasonCatalog;
}
}
const updateData = stripUndefined({
timezone: timezone !== undefined ? String(timezone) : undefined,
@@ -272,6 +358,12 @@ export async function PUT(req: Request) {
: undefined,
lunchBreakMin:
shiftSchedule?.lunchBreakMin !== undefined ? Number(shiftSchedule.lunchBreakMin) : undefined,
shiftScheduleOverridesJson:
shiftSchedule?.overrides !== undefined
? overridesResult.overrides === null
? null
: overridesResult.overrides
: undefined,
stoppageMultiplier:
thresholds?.stoppageMultiplier !== undefined ? Number(thresholds.stoppageMultiplier) : undefined,
macroStoppageMultiplier:
@@ -373,6 +465,8 @@ export async function PUT(req: Request) {
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
const modulesOut = { screenlessMode: modulesRaw.screenlessMode === true };
revalidateTag(`settings:${session.orgId}`, { expire: 0 });
return NextResponse.json({ ok: true, settings: { ...payload, modules: modulesOut } });
} catch (err) {