382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
// /home/mdares/mis-control-tower/lib/contracts/v1.ts
|
|
import { z } from "zod";
|
|
|
|
/**
|
|
* Phase 0: freeze schema version string now and never change it without bumping.
|
|
* If you later create v2, make a new file or new constant.
|
|
*/
|
|
export const SCHEMA_VERSION = "1.0";
|
|
|
|
// KPI scale is frozen as 0..100 (you confirmed)
|
|
const KPI_0_100 = z.number().min(0).max(100);
|
|
|
|
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 unwrapCanonicalEnvelope(raw: unknown) {
|
|
if (!raw || typeof raw !== "object") return raw;
|
|
const obj = asRecord(raw);
|
|
if (!obj) return raw;
|
|
const payload = asRecord(obj.payload);
|
|
if (!payload) return raw;
|
|
|
|
const hasMeta =
|
|
obj.schemaVersion !== undefined ||
|
|
obj.machineId !== undefined ||
|
|
obj.tsMs !== undefined ||
|
|
obj.tsDevice !== undefined ||
|
|
obj.seq !== undefined ||
|
|
obj.type !== undefined;
|
|
if (!hasMeta) return raw;
|
|
|
|
const tsDevice =
|
|
typeof obj.tsDevice === "number"
|
|
? obj.tsDevice
|
|
: typeof obj.tsMs === "number"
|
|
? obj.tsMs
|
|
: typeof payload.tsDevice === "number"
|
|
? payload.tsDevice
|
|
: typeof payload.tsMs === "number"
|
|
? payload.tsMs
|
|
: undefined;
|
|
|
|
return {
|
|
...payload,
|
|
schemaVersion: obj.schemaVersion ?? payload.schemaVersion,
|
|
machineId: obj.machineId ?? payload.machineId,
|
|
tsDevice: tsDevice ?? payload.tsDevice,
|
|
seq: obj.seq ?? payload.seq,
|
|
};
|
|
}
|
|
|
|
function normalizeTsDevice(raw: unknown) {
|
|
if (!raw || typeof raw !== "object") return raw;
|
|
const obj = asRecord(raw);
|
|
if (!obj) return raw;
|
|
if (typeof obj.tsDevice === "number") return obj;
|
|
if (typeof obj.tsMs === "number") return { ...obj, tsDevice: obj.tsMs };
|
|
return obj;
|
|
}
|
|
|
|
function preprocessPayload(raw: unknown) {
|
|
return normalizeTsDevice(unwrapCanonicalEnvelope(raw));
|
|
}
|
|
|
|
export const SnapshotV1 = z
|
|
.object({
|
|
schemaVersion: z.literal(SCHEMA_VERSION),
|
|
machineId: z.string().uuid(),
|
|
tsDevice: z.number().int().nonnegative(), // epoch ms
|
|
// IMPORTANT: seq should be sent as string if it can ever exceed JS safe int
|
|
seq: z.union([z.number().int().nonnegative(), z.string().regex(/^\d+$/)]),
|
|
|
|
// current shape (keep it flat so Node-RED changes are minimal)
|
|
activeWorkOrder: z
|
|
.object({
|
|
id: z.string(),
|
|
sku: z.string().optional(),
|
|
target: z.number().optional(),
|
|
good: z.number().optional(),
|
|
scrap: z.number().optional(),
|
|
|
|
// add the ones you actually rely on
|
|
cycleTime: z.number().optional(),
|
|
cavities: z.number().optional(),
|
|
progressPercent: z.number().optional(),
|
|
status: z.string().optional(),
|
|
lastUpdateIso: z.string().optional(),
|
|
cycle_count: z.number().optional(),
|
|
good_parts: z.number().optional(),
|
|
scrap_parts: z.number().optional(),
|
|
})
|
|
.partial()
|
|
.passthrough()
|
|
.optional(),
|
|
|
|
cycle_count: z.number().int().nonnegative().optional(),
|
|
good_parts: z.number().int().nonnegative().optional(),
|
|
scrap_parts: z.number().int().nonnegative().optional(),
|
|
cavities: z.number().int().positive().optional(),
|
|
|
|
cycleTime: z.number().nonnegative().optional(), // theoretical/target cycle time
|
|
actualCycleTime: z.number().nonnegative().optional(), // optional
|
|
|
|
trackingEnabled: z.boolean().optional(),
|
|
productionStarted: z.boolean().optional(),
|
|
|
|
kpis: z.object({
|
|
oee: KPI_0_100,
|
|
availability: KPI_0_100,
|
|
performance: KPI_0_100,
|
|
quality: KPI_0_100,
|
|
}),
|
|
})
|
|
.passthrough();
|
|
|
|
/**
|
|
* TEMPORARY: Accept your current legacy payload while Node-RED is not sending
|
|
* schemaVersion/tsDevice/seq yet. Remove this once edge is upgraded.
|
|
*/
|
|
const SnapshotLegacy = z
|
|
.object({
|
|
machineId: z.any(),
|
|
kpis: z.any(),
|
|
})
|
|
.passthrough();
|
|
|
|
export type SnapshotV1Type = z.infer<typeof SnapshotV1>;
|
|
|
|
export function normalizeSnapshotV1(raw: unknown): { ok: true; value: SnapshotV1Type } | { ok: false; error: string } {
|
|
const candidate = preprocessPayload(raw);
|
|
const strict = SnapshotV1.safeParse(candidate);
|
|
if (strict.success) return { ok: true, value: strict.data };
|
|
|
|
// Legacy fallback (temporary)
|
|
const legacy = SnapshotLegacy.safeParse(candidate);
|
|
if (!legacy.success) {
|
|
return { ok: false, error: strict.error.message };
|
|
}
|
|
/*
|
|
const b: any = legacy.data;
|
|
|
|
// Build a "best effort" SnapshotV1 so ingest works during transition.
|
|
// seq is intentionally set to "0" if missing (so you can still store);
|
|
// once Node-RED emits real seq, dedupe and ordering become reliable.
|
|
const migrated: any = {
|
|
schemaVersion: SCHEMA_VERSION,
|
|
machineId: String(b.machineId),
|
|
tsDevice: typeof b.tsDevice === "number" ? b.tsDevice : Date.now(),
|
|
seq: typeof b.seq === "number" || typeof b.seq === "string" ? b.seq : "0",
|
|
...b,
|
|
};
|
|
|
|
const recheck = SnapshotV1.safeParse(migrated);
|
|
if (!recheck.success) return { ok: false, error: recheck.error.message };
|
|
return { ok: true, value: recheck.data };
|
|
*/
|
|
const b = asRecord(legacy.data) ?? {};
|
|
const activeWorkOrder = asRecord(b.activeWorkOrder);
|
|
const kpis = asRecord(b.kpis);
|
|
const kpiSnapshot = asRecord(b.kpi_snapshot);
|
|
|
|
const legacyCycleTime =
|
|
b.cycleTime ??
|
|
b.cycle_time ??
|
|
b.theoretical_cycle_time ??
|
|
b.theoreticalCycleTime ??
|
|
b.standard_cycle_time ??
|
|
kpiSnapshot?.cycleTime ??
|
|
kpiSnapshot?.cycle_time ??
|
|
undefined;
|
|
|
|
const legacyActualCycleTime =
|
|
b.actualCycleTime ??
|
|
b.actual_cycle_time ??
|
|
b.actualCycleSeconds ??
|
|
kpiSnapshot?.actualCycleTime ??
|
|
kpiSnapshot?.actual_cycle_time ??
|
|
undefined;
|
|
|
|
const legacyWorkOrderId =
|
|
activeWorkOrder?.id ??
|
|
b.work_order_id ??
|
|
b.workOrderId ??
|
|
kpis?.workOrderId ??
|
|
kpiSnapshot?.work_order_id ??
|
|
undefined;
|
|
|
|
const legacySku =
|
|
activeWorkOrder?.sku ??
|
|
b.sku ??
|
|
kpis?.sku ??
|
|
kpiSnapshot?.sku ??
|
|
undefined;
|
|
|
|
const legacyTarget =
|
|
activeWorkOrder?.target ??
|
|
b.target ??
|
|
kpis?.target ??
|
|
kpiSnapshot?.target ??
|
|
undefined;
|
|
|
|
const legacyGood =
|
|
activeWorkOrder?.good ??
|
|
b.good_parts ??
|
|
b.good ??
|
|
kpis?.good ??
|
|
kpiSnapshot?.good_parts ??
|
|
undefined;
|
|
|
|
const legacyScrap =
|
|
activeWorkOrder?.scrap ??
|
|
b.scrap_parts ??
|
|
b.scrap ??
|
|
kpis?.scrap ??
|
|
kpiSnapshot?.scrap_parts ??
|
|
undefined;
|
|
|
|
const migrated: Record<string, unknown> = {
|
|
schemaVersion: SCHEMA_VERSION,
|
|
machineId: String(b.machineId),
|
|
tsDevice: typeof b.tsDevice === "number" ? b.tsDevice : Date.now(),
|
|
seq: typeof b.seq === "number" || typeof b.seq === "string" ? b.seq : "0",
|
|
|
|
// canonical fields (force them)
|
|
cycleTime: legacyCycleTime != null ? Number(legacyCycleTime) : undefined,
|
|
actualCycleTime: legacyActualCycleTime != null ? Number(legacyActualCycleTime) : undefined,
|
|
|
|
activeWorkOrder: legacyWorkOrderId
|
|
? {
|
|
id: String(legacyWorkOrderId),
|
|
sku: legacySku != null ? String(legacySku) : undefined,
|
|
target: legacyTarget != null ? Number(legacyTarget) : undefined,
|
|
good: legacyGood != null ? Number(legacyGood) : undefined,
|
|
scrap: legacyScrap != null ? Number(legacyScrap) : undefined,
|
|
}
|
|
: activeWorkOrder,
|
|
|
|
// keep everything else
|
|
...b,
|
|
};
|
|
const recheck = SnapshotV1.safeParse(migrated);
|
|
if (!recheck.success) return { ok: false, error: recheck.error.message };
|
|
return { ok: true, value: recheck.data };
|
|
|
|
}
|
|
|
|
const HeartbeatV1 = z.object({
|
|
schemaVersion: z.literal(SCHEMA_VERSION),
|
|
machineId: z.string().uuid(),
|
|
tsDevice: z.number().int().nonnegative(),
|
|
seq: z.union([z.number().int().nonnegative(), z.string().regex(/^\d+$/)]),
|
|
|
|
// legacy shape you currently send: status/message/ip/fwVersion
|
|
status: z.string().optional(),
|
|
message: z.string().optional(),
|
|
ip: z.string().optional(),
|
|
fwVersion: z.string().optional(),
|
|
|
|
// new canonical boolean
|
|
online: z.boolean().optional(),
|
|
}).passthrough();
|
|
|
|
export function normalizeHeartbeatV1(raw: unknown) {
|
|
const candidate = preprocessPayload(raw);
|
|
const strict = HeartbeatV1.safeParse(candidate);
|
|
if (strict.success) return { ok: true as const, value: strict.data };
|
|
|
|
// legacy fallback: allow missing meta
|
|
const legacy = z.object({ machineId: z.any() }).passthrough().safeParse(candidate);
|
|
if (!legacy.success) return { ok: false as const, error: strict.error.message };
|
|
|
|
const b = asRecord(legacy.data) ?? {};
|
|
const migrated: Record<string, unknown> = {
|
|
schemaVersion: SCHEMA_VERSION,
|
|
machineId: String(b.machineId),
|
|
tsDevice: typeof b.tsDevice === "number" ? b.tsDevice : Date.now(),
|
|
seq: typeof b.seq === "number" || typeof b.seq === "string" ? b.seq : "0",
|
|
...b,
|
|
};
|
|
|
|
const recheck = HeartbeatV1.safeParse(migrated);
|
|
if (!recheck.success) return { ok: false as const, error: recheck.error.message };
|
|
return { ok: true as const, value: recheck.data };
|
|
}
|
|
|
|
const CycleV1 = z.object({
|
|
schemaVersion: z.literal(SCHEMA_VERSION),
|
|
machineId: z.string().uuid(),
|
|
tsDevice: z.number().int().nonnegative(),
|
|
seq: z.union([z.number().int().nonnegative(), z.string().regex(/^\d+$/)]),
|
|
|
|
cycle: z.object({
|
|
timestamp: z.number().int().positive(),
|
|
cycle_count: z.number().int().nonnegative(),
|
|
actual_cycle_time: z.number(),
|
|
theoretical_cycle_time: z.number().optional(),
|
|
work_order_id: z.string(),
|
|
sku: z.string().optional(),
|
|
cavities: z.number().optional(),
|
|
good_delta: z.number().optional(),
|
|
scrap_total: z.number().optional(),
|
|
}).passthrough(),
|
|
}).passthrough();
|
|
|
|
export function normalizeCycleV1(raw: unknown) {
|
|
const candidate = preprocessPayload(raw);
|
|
const strict = CycleV1.safeParse(candidate);
|
|
if (strict.success) return { ok: true as const, value: strict.data };
|
|
|
|
// legacy fallback: { machineId, cycle }
|
|
const legacy = z.object({ machineId: z.any(), cycle: z.any() }).passthrough().safeParse(candidate);
|
|
if (!legacy.success) return { ok: false as const, error: strict.error.message };
|
|
|
|
const b = asRecord(legacy.data) ?? {};
|
|
const cycle = asRecord(b.cycle);
|
|
const tsDevice =
|
|
typeof b.tsDevice === "number" ? b.tsDevice : (cycle?.timestamp as number | undefined) ?? Date.now();
|
|
const seq =
|
|
typeof b.seq === "number" || typeof b.seq === "string"
|
|
? b.seq
|
|
: (cycle?.cycle_count as number | string | undefined) ?? "0";
|
|
|
|
const migrated: Record<string, unknown> = {
|
|
schemaVersion: SCHEMA_VERSION,
|
|
machineId: String(b.machineId),
|
|
tsDevice,
|
|
seq,
|
|
...b,
|
|
};
|
|
const recheck = CycleV1.safeParse(migrated);
|
|
if (!recheck.success) return { ok: false as const, error: recheck.error.message };
|
|
return { ok: true as const, value: recheck.data };
|
|
}
|
|
|
|
const EventV1 = z.object({
|
|
schemaVersion: z.literal(SCHEMA_VERSION),
|
|
machineId: z.string().uuid(),
|
|
tsDevice: z.number().int().nonnegative(),
|
|
seq: z.union([z.number().int().nonnegative(), z.string().regex(/^\d+$/)]),
|
|
|
|
// IMPORTANT: event must be an object, not an array
|
|
event: z.object({
|
|
anomaly_type: z.string(),
|
|
severity: z.string(),
|
|
title: z.string(),
|
|
description: z.string().optional(),
|
|
timestamp: z.number().int().positive(),
|
|
work_order_id: z.string(),
|
|
cycle_count: z.number().optional(),
|
|
data: z.any().optional(),
|
|
kpi_snapshot: z.any().optional(),
|
|
}).passthrough(),
|
|
}).passthrough();
|
|
|
|
export function normalizeEventV1(raw: unknown) {
|
|
const candidate = preprocessPayload(raw);
|
|
const strict = EventV1.safeParse(candidate);
|
|
if (strict.success) return { ok: true as const, value: strict.data };
|
|
|
|
// legacy fallback: allow missing meta, but STILL reject arrays later
|
|
const legacy = z.object({ machineId: z.any(), event: z.any() }).passthrough().safeParse(candidate);
|
|
if (!legacy.success) return { ok: false as const, error: strict.error.message };
|
|
|
|
const b = asRecord(legacy.data) ?? {};
|
|
const event = asRecord(b.event);
|
|
const tsDevice =
|
|
typeof b.tsDevice === "number" ? b.tsDevice : (event?.timestamp as number | undefined) ?? Date.now();
|
|
const migrated: Record<string, unknown> = {
|
|
schemaVersion: SCHEMA_VERSION,
|
|
machineId: String(b.machineId),
|
|
tsDevice,
|
|
seq: typeof b.seq === "number" || typeof b.seq === "string" ? b.seq : "0",
|
|
...b,
|
|
};
|
|
|
|
const recheck = EventV1.safeParse(migrated);
|
|
if (!recheck.success) return { ok: false as const, error: recheck.error.message };
|
|
return { ok: true as const, value: recheck.data };
|
|
}
|