pre-bemis
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
45
app/api/debug/logs/route.ts
Normal file
45
app/api/debug/logs/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
31
app/api/debug/perf/route.ts
Normal file
31
app/api/debug/perf/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
48
app/api/reasons/catalog/route.ts
Normal file
48
app/api/reasons/catalog/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user