151 lines
4.4 KiB
TypeScript
151 lines
4.4 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
const bad = (status: number, error: string) =>
|
|
NextResponse.json({ ok: false, error }, { status });
|
|
|
|
const asTrimmedString = (v: any) => {
|
|
if (v == null) return "";
|
|
return String(v).trim();
|
|
};
|
|
|
|
export async function POST(req: Request) {
|
|
const apiKey = req.headers.get("x-api-key");
|
|
if (!apiKey) return bad(401, "Missing api key");
|
|
|
|
const body = await req.json().catch(() => null);
|
|
if (!body?.machineId || !body?.reason) return bad(400, "Invalid payload");
|
|
|
|
const machine = await prisma.machine.findFirst({
|
|
where: { id: String(body.machineId), apiKey },
|
|
select: { id: true, orgId: true },
|
|
});
|
|
if (!machine) return bad(401, "Unauthorized");
|
|
|
|
const r = body.reason;
|
|
|
|
const reasonId = asTrimmedString(r.reasonId);
|
|
if (!reasonId) return bad(400, "Missing reason.reasonId");
|
|
|
|
const kind = asTrimmedString(r.kind).toLowerCase();
|
|
if (kind !== "downtime" && kind !== "scrap")
|
|
return bad(400, "Invalid reason.kind");
|
|
|
|
const capturedAtMs = r.capturedAtMs;
|
|
if (typeof capturedAtMs !== "number" || !Number.isFinite(capturedAtMs)) {
|
|
return bad(400, "Invalid reason.capturedAtMs");
|
|
}
|
|
const capturedAt = new Date(capturedAtMs);
|
|
|
|
const reasonCodeRaw = asTrimmedString(r.reasonCode);
|
|
if (!reasonCodeRaw) return bad(400, "Missing reason.reasonCode");
|
|
const reasonCode = reasonCodeRaw.toUpperCase(); // normalize for grouping/pareto
|
|
|
|
const reasonLabel = r.reasonLabel != null ? String(r.reasonLabel) : null;
|
|
|
|
let reasonText = r.reasonText != null ? String(r.reasonText).trim() : null;
|
|
if (reasonCode === "OTHER") {
|
|
if (!reasonText || reasonText.length < 2)
|
|
return bad(400, "reason.reasonText required when reasonCode=OTHER");
|
|
} else {
|
|
// Non-OTHER must not store free text
|
|
reasonText = null;
|
|
}
|
|
|
|
// Optional shared fields
|
|
const workOrderId =
|
|
r.workOrderId != null && String(r.workOrderId).trim()
|
|
? String(r.workOrderId).trim()
|
|
: null;
|
|
|
|
const schemaVersion =
|
|
typeof r.schemaVersion === "number" && Number.isFinite(r.schemaVersion)
|
|
? Math.trunc(r.schemaVersion)
|
|
: 1;
|
|
|
|
const meta = r.meta != null ? r.meta : null;
|
|
|
|
// Kind-specific fields
|
|
let episodeId: string | null = null;
|
|
let durationSeconds: number | null = null;
|
|
let episodeEndTs: Date | null = null;
|
|
|
|
let scrapEntryId: string | null = null;
|
|
let scrapQty: number | null = null;
|
|
let scrapUnit: string | null = null;
|
|
|
|
if (kind === "downtime") {
|
|
episodeId = asTrimmedString(r.episodeId) || null;
|
|
if (!episodeId) return bad(400, "Missing reason.episodeId for downtime");
|
|
|
|
if (typeof r.durationSeconds !== "number" || !Number.isFinite(r.durationSeconds)) {
|
|
return bad(400, "Invalid reason.durationSeconds for downtime");
|
|
}
|
|
durationSeconds = Math.max(0, Math.trunc(r.durationSeconds));
|
|
|
|
const episodeEndTsMs = r.episodeEndTsMs;
|
|
if (episodeEndTsMs != null) {
|
|
if (typeof episodeEndTsMs !== "number" || !Number.isFinite(episodeEndTsMs)) {
|
|
return bad(400, "Invalid reason.episodeEndTsMs");
|
|
}
|
|
episodeEndTs = new Date(episodeEndTsMs);
|
|
}
|
|
} else {
|
|
scrapEntryId = asTrimmedString(r.scrapEntryId) || null;
|
|
if (!scrapEntryId) return bad(400, "Missing reason.scrapEntryId for scrap");
|
|
|
|
if (typeof r.scrapQty !== "number" || !Number.isFinite(r.scrapQty)) {
|
|
return bad(400, "Invalid reason.scrapQty for scrap");
|
|
}
|
|
scrapQty = Math.max(0, Math.trunc(r.scrapQty));
|
|
|
|
scrapUnit =
|
|
r.scrapUnit != null && String(r.scrapUnit).trim()
|
|
? String(r.scrapUnit).trim()
|
|
: null;
|
|
}
|
|
|
|
// Idempotent upsert keyed by reasonId
|
|
const row = await prisma.reasonEntry.upsert({
|
|
where: { reasonId },
|
|
create: {
|
|
orgId: machine.orgId,
|
|
machineId: machine.id,
|
|
reasonId,
|
|
kind,
|
|
episodeId,
|
|
durationSeconds,
|
|
episodeEndTs,
|
|
scrapEntryId,
|
|
scrapQty,
|
|
scrapUnit,
|
|
reasonCode,
|
|
reasonLabel,
|
|
reasonText,
|
|
capturedAt,
|
|
workOrderId,
|
|
meta,
|
|
schemaVersion,
|
|
},
|
|
update: {
|
|
kind,
|
|
episodeId,
|
|
durationSeconds,
|
|
episodeEndTs,
|
|
scrapEntryId,
|
|
scrapQty,
|
|
scrapUnit,
|
|
reasonCode,
|
|
reasonLabel,
|
|
reasonText,
|
|
capturedAt,
|
|
workOrderId,
|
|
meta,
|
|
schemaVersion,
|
|
},
|
|
select: { id: true, reasonId: true },
|
|
});
|
|
|
|
return NextResponse.json({ ok: true, id: row.id, reasonId: row.reasonId });
|
|
}
|