recent changes
This commit is contained in:
@@ -63,7 +63,12 @@ const MAX_EVENTS = 100;
|
||||
|
||||
//when no cycle time is configed
|
||||
const DEFAULT_MACROSTOP_SEC = 300;
|
||||
const NON_AUTHORITATIVE_REASON_CODES = new Set(["PENDIENTE", "UNCLASSIFIED"]);
|
||||
|
||||
function isNonAuthoritativeReasonCode(code: unknown) {
|
||||
const normalized = clampText(code, 64)?.toUpperCase();
|
||||
return !!normalized && NON_AUTHORITATIVE_REASON_CODES.has(normalized);
|
||||
}
|
||||
|
||||
function clampText(value: unknown, maxLen: number) {
|
||||
if (value === null || value === undefined) return null;
|
||||
@@ -271,8 +276,10 @@ export async function POST(req: Request) {
|
||||
|
||||
const machine = await getMachineAuth(String(machineId), apiKey);
|
||||
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const bodySeq = parseSeqToBigInt(bodyRecord.seq);
|
||||
const bodySchemaVersion = clampText(bodyRecord.schemaVersion, 16);
|
||||
|
||||
const orgSettings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: machine.orgId },
|
||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
|
||||
@@ -602,6 +609,62 @@ export async function POST(req: Request) {
|
||||
numberFrom(evDowntime?.acknowledgedAtMs) ??
|
||||
null;
|
||||
|
||||
let guardedWrite = commonWrite;
|
||||
const incomingIsNonAuthoritative = isNonAuthoritativeReasonCode(resolved.reasonCode);
|
||||
const isManualAckEvent = finalType === "downtime-acknowledged";
|
||||
if (!isManualAckEvent && incomingIsNonAuthoritative) {
|
||||
const existingEpisode = await prisma.reasonEntry.findFirst({
|
||||
where: {
|
||||
orgId: machine.orgId,
|
||||
kind: "downtime",
|
||||
episodeId: incidentKey,
|
||||
},
|
||||
select: {
|
||||
reasonCode: true,
|
||||
reasonLabel: true,
|
||||
reasonText: true,
|
||||
meta: true,
|
||||
},
|
||||
});
|
||||
if (existingEpisode && !isNonAuthoritativeReasonCode(existingEpisode.reasonCode)) {
|
||||
const existingMeta = asRecord(existingEpisode.meta);
|
||||
const existingMetaReason = asRecord(existingMeta?.reason);
|
||||
guardedWrite = {
|
||||
...commonWrite,
|
||||
reasonCode: existingEpisode.reasonCode,
|
||||
reasonLabel: existingEpisode.reasonLabel ?? existingEpisode.reasonCode,
|
||||
reasonText:
|
||||
existingEpisode.reasonText ??
|
||||
existingEpisode.reasonLabel ??
|
||||
existingEpisode.reasonCode,
|
||||
meta: toJsonValue({
|
||||
source: "ingest:event",
|
||||
eventId: row.id,
|
||||
eventType: row.eventType,
|
||||
incidentKey: clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128),
|
||||
anomalyType:
|
||||
clampText(evRecord.anomalyType, 64) ??
|
||||
clampText(evDowntime?.anomalyType, 64) ??
|
||||
clampText(evRecord.anomaly_type, 64),
|
||||
reason: existingMetaReason ?? {
|
||||
type: resolved.type,
|
||||
categoryId: resolved.categoryId,
|
||||
categoryLabel: resolved.categoryLabel,
|
||||
detailId: resolved.detailId,
|
||||
detailLabel: resolved.detailLabel,
|
||||
reasonText:
|
||||
existingEpisode.reasonText ??
|
||||
existingEpisode.reasonLabel ??
|
||||
existingEpisode.reasonCode,
|
||||
catalogVersion: resolved.catalogVersion,
|
||||
},
|
||||
reasonPreservedFromManual: true,
|
||||
incomingReasonCode: resolved.reasonCode,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.reasonEntry.upsert({
|
||||
where: { reasonId },
|
||||
create: {
|
||||
@@ -612,14 +675,14 @@ export async function POST(req: Request) {
|
||||
episodeId: incidentKey,
|
||||
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
|
||||
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
|
||||
...commonWrite,
|
||||
...guardedWrite,
|
||||
},
|
||||
update: {
|
||||
kind: "downtime",
|
||||
episodeId: incidentKey,
|
||||
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
|
||||
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
|
||||
...commonWrite,
|
||||
...guardedWrite,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -43,6 +43,9 @@ export async function GET(
|
||||
sku: row.sku,
|
||||
targetQty: row.targetQty,
|
||||
cycleTime: row.cycleTime,
|
||||
mold: row.mold,
|
||||
cavitiesTotal: row.cavitiesTotal,
|
||||
cavitiesActive: row.cavitiesActive,
|
||||
status: row.status,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -12,8 +12,10 @@ function canManage(role?: string | null) {
|
||||
const MAX_WORK_ORDERS = 2000;
|
||||
const MAX_WORK_ORDER_ID_LENGTH = 64;
|
||||
const MAX_SKU_LENGTH = 64;
|
||||
const MAX_MOLD_LENGTH = 256;
|
||||
const MAX_TARGET_QTY = 2_000_000_000;
|
||||
const MAX_CYCLE_TIME = 86_400;
|
||||
const MAX_CAVITIES = 100_000;
|
||||
const WORK_ORDER_ID_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
const uploadBodySchema = z.object({
|
||||
@@ -51,6 +53,15 @@ type WorkOrderInput = {
|
||||
sku?: string | null;
|
||||
targetQty?: number | null;
|
||||
cycleTime?: number | null;
|
||||
mold?: string | null;
|
||||
cavitiesTotal?: number | null;
|
||||
cavitiesActive?: number | null;
|
||||
};
|
||||
|
||||
type RowIssue = {
|
||||
row: number;
|
||||
workOrderId: string | null;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
function normalizeWorkOrders(raw: unknown[]) {
|
||||
@@ -78,17 +89,98 @@ function normalizeWorkOrders(raw: unknown[]) {
|
||||
const cycleTime =
|
||||
cycleTimeRaw == null ? null : Math.min(Math.max(cycleTimeRaw, 0), MAX_CYCLE_TIME);
|
||||
|
||||
const mold = cleanText(
|
||||
record.mold ?? record.moldId ?? record.mold_id ?? null,
|
||||
MAX_MOLD_LENGTH
|
||||
);
|
||||
const cavitiesTotalRaw = toIntOrNull(
|
||||
record.cavitiesTotal ??
|
||||
record.cavities_total ??
|
||||
record.totalCavities ??
|
||||
record.total_cavities
|
||||
);
|
||||
const cavitiesActiveRaw = toIntOrNull(
|
||||
record.cavitiesActive ??
|
||||
record.cavities_active ??
|
||||
record.activeCavities ??
|
||||
record.active_cavities
|
||||
);
|
||||
const cavitiesTotal =
|
||||
cavitiesTotalRaw == null
|
||||
? null
|
||||
: Math.min(Math.max(cavitiesTotalRaw, 0), MAX_CAVITIES);
|
||||
const cavitiesActive =
|
||||
cavitiesActiveRaw == null
|
||||
? null
|
||||
: Math.min(Math.max(cavitiesActiveRaw, 0), MAX_CAVITIES);
|
||||
|
||||
cleaned.push({
|
||||
workOrderId: idRaw,
|
||||
sku: sku ?? null,
|
||||
targetQty: targetQty ?? null,
|
||||
cycleTime: cycleTime ?? null,
|
||||
mold: mold ?? null,
|
||||
cavitiesTotal: cavitiesTotal ?? null,
|
||||
cavitiesActive: cavitiesActive ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// ✨ NUEVO: validación estricta del Excel
|
||||
// Cada fila debe tener mold (no vacío), cavitiesTotal (>=1), cavitiesActive (>=1, <=cavitiesTotal)
|
||||
// Si UNA SOLA fila falla, se rechaza el archivo completo (Opción A)
|
||||
function validateRows(rows: WorkOrderInput[], rawList: unknown[]): RowIssue[] {
|
||||
const issues: RowIssue[] = [];
|
||||
|
||||
// Validar lista cruda primero (si hay duplicados o IDs inválidos no llegaron a `cleaned`)
|
||||
// Pero aquí enfocamos en la validación de mold/cavidades sobre filas ya normalizadas.
|
||||
rows.forEach((row, idx) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Mold requerido
|
||||
if (!row.mold || row.mold.length === 0) {
|
||||
errors.push("Mold is required");
|
||||
}
|
||||
|
||||
// Cavities Total requerido y >= 1
|
||||
if (row.cavitiesTotal == null) {
|
||||
errors.push("Total Cavities is required");
|
||||
} else if (row.cavitiesTotal < 1) {
|
||||
errors.push("Total Cavities must be at least 1");
|
||||
}
|
||||
|
||||
// Cavities Active requerido y >= 1
|
||||
if (row.cavitiesActive == null) {
|
||||
errors.push("Active Cavities is required");
|
||||
} else if (row.cavitiesActive < 1) {
|
||||
errors.push("Active Cavities must be at least 1");
|
||||
}
|
||||
|
||||
// Active <= Total
|
||||
if (
|
||||
row.cavitiesActive != null &&
|
||||
row.cavitiesTotal != null &&
|
||||
row.cavitiesActive > row.cavitiesTotal
|
||||
) {
|
||||
errors.push(
|
||||
`Active Cavities (${row.cavitiesActive}) cannot exceed Total Cavities (${row.cavitiesTotal})`
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
issues.push({
|
||||
row: idx + 1, // 1-indexed para el operador
|
||||
workOrderId: row.workOrderId,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await requireSession();
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
@@ -138,6 +230,21 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ ok: false, error: "No valid work orders provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
// ✨ NUEVO: validación estricta de mold/cavidades
|
||||
// Si una sola fila falla, rechazamos el archivo completo
|
||||
const issues = validateRows(cleaned, listRaw);
|
||||
if (issues.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Validation failed",
|
||||
summary: `Excel rejected: ${issues.length} of ${cleaned.length} work order(s) have errors. All work orders must include mold name, total cavities, and active cavities. Fix and re-upload.`,
|
||||
issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const created = await prisma.machineWorkOrder.createMany({
|
||||
data: cleaned.map((row) => ({
|
||||
orgId: session.orgId,
|
||||
@@ -146,6 +253,9 @@ export async function POST(req: NextRequest) {
|
||||
sku: row.sku ?? null,
|
||||
targetQty: row.targetQty ?? null,
|
||||
cycleTime: row.cycleTime ?? null,
|
||||
mold: row.mold ?? null,
|
||||
cavitiesTotal: row.cavitiesTotal ?? null,
|
||||
cavitiesActive: row.cavitiesActive ?? null,
|
||||
status: "PENDING",
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
@@ -167,4 +277,4 @@ export async function POST(req: NextRequest) {
|
||||
inserted: created.count,
|
||||
total: cleaned.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user