Mobile friendly, lint correction, typescript error clear
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendEmail } from "@/lib/email";
|
||||
import { sendSms } from "@/lib/sms";
|
||||
import { AlertPolicySchema, DEFAULT_POLICY, normalizeAlertPolicy } from "@/lib/alerts/policy";
|
||||
import { AlertPolicySchema, DEFAULT_POLICY } from "@/lib/alerts/policy";
|
||||
|
||||
type Recipient = {
|
||||
userId?: string;
|
||||
@@ -13,12 +13,43 @@ type Recipient = {
|
||||
};
|
||||
|
||||
function normalizeEventType(value: unknown) {
|
||||
return String(value ?? "").trim().toLowerCase();
|
||||
const raw = String(value ?? "").trim().toLowerCase();
|
||||
if (!raw) return raw;
|
||||
const cleaned = raw.replace(/[_\s]+/g, "-").replace(/-+/g, "-");
|
||||
if (cleaned === "micro-stop") return "microstop";
|
||||
if (cleaned === "macro-stop") return "macrostop";
|
||||
if (cleaned === "slowcycle") return "slow-cycle";
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function extractDurationSec(raw: any): number | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const data = raw.data ?? raw;
|
||||
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 unwrapEventData(raw: unknown) {
|
||||
const payload = asRecord(raw);
|
||||
const inner = asRecord(payload?.data) ?? payload;
|
||||
return { payload, inner };
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown) {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function readBool(value: unknown) {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function extractDurationSec(raw: unknown): number | null {
|
||||
const payload = asRecord(raw);
|
||||
if (!payload) return null;
|
||||
const data = asRecord(payload.data) ?? payload;
|
||||
const candidates = [
|
||||
data?.duration_seconds,
|
||||
data?.duration_sec,
|
||||
@@ -67,6 +98,7 @@ async function ensurePolicy(orgId: string) {
|
||||
|
||||
async function loadRecipients(orgId: string, role: string, eventType: string): Promise<Recipient[]> {
|
||||
const roleUpper = role.toUpperCase();
|
||||
const normalizedEventType = normalizeEventType(eventType);
|
||||
const [members, external] = await Promise.all([
|
||||
prisma.orgUser.findMany({
|
||||
where: { orgId, role: roleUpper },
|
||||
@@ -105,7 +137,7 @@ async function loadRecipients(orgId: string, role: string, eventType: string): P
|
||||
.filter((c) => {
|
||||
const types = Array.isArray(c.eventTypes) ? c.eventTypes : null;
|
||||
if (!types || !types.length) return true;
|
||||
return types.includes(eventType);
|
||||
return types.some((type) => normalizeEventType(type) === normalizedEventType);
|
||||
})
|
||||
.map((c) => ({
|
||||
contactId: c.id,
|
||||
@@ -143,7 +175,7 @@ function buildAlertMessage(params: {
|
||||
}
|
||||
|
||||
async function shouldSendNotification(params: {
|
||||
eventId: string;
|
||||
eventIds: string[];
|
||||
ruleId: string;
|
||||
role: string;
|
||||
channel: string;
|
||||
@@ -153,7 +185,7 @@ async function shouldSendNotification(params: {
|
||||
}) {
|
||||
const existing = await prisma.alertNotification.findFirst({
|
||||
where: {
|
||||
eventId: params.eventId,
|
||||
eventId: { in: params.eventIds },
|
||||
ruleId: params.ruleId,
|
||||
role: params.role,
|
||||
channel: params.channel,
|
||||
@@ -171,6 +203,22 @@ async function shouldSendNotification(params: {
|
||||
return elapsed >= repeatMin * 60 * 1000;
|
||||
}
|
||||
|
||||
async function resolveAlertEventIds(orgId: string, alertId: string, fallbackId: string) {
|
||||
const events = await prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
data: {
|
||||
path: ["alert_id"],
|
||||
equals: alertId,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = events.map((row) => row.id);
|
||||
if (!ids.includes(fallbackId)) ids.push(fallbackId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function recordNotification(params: {
|
||||
orgId: string;
|
||||
machineId: string;
|
||||
@@ -250,6 +298,18 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
const rule = policy.rules.find((r) => normalizeEventType(r.eventType) === eventType);
|
||||
if (!rule) return;
|
||||
|
||||
const { payload, inner } = unwrapEventData(event.data);
|
||||
const alertId = readString(payload?.alert_id ?? inner?.alert_id);
|
||||
const isUpdate = readBool(payload?.is_update ?? inner?.is_update);
|
||||
const isAutoAck = readBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
||||
const lastCycleTs = readNumber(payload?.last_cycle_timestamp ?? inner?.last_cycle_timestamp);
|
||||
const theoreticalSec = readNumber(payload?.theoretical_cycle_time ?? inner?.theoretical_cycle_time);
|
||||
if (isAutoAck) return;
|
||||
if (isUpdate && !(rule.repeatMinutes && rule.repeatMinutes > 0)) return;
|
||||
if ((eventType === "microstop" || eventType === "macrostop") && theoreticalSec && lastCycleTs == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const durationSec = extractDurationSec(event.data);
|
||||
const durationMin = durationSec != null ? durationSec / 60 : 0;
|
||||
const machine = await prisma.machine.findUnique({
|
||||
@@ -257,6 +317,9 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
select: { name: true, code: true },
|
||||
});
|
||||
const delivered = new Set<string>();
|
||||
const notificationEventIds = alertId
|
||||
? await resolveAlertEventIds(event.orgId, alertId, event.id)
|
||||
: [event.id];
|
||||
|
||||
for (const [roleName, roleRule] of Object.entries(rule.roles)) {
|
||||
if (!roleRule?.enabled) continue;
|
||||
@@ -283,7 +346,7 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
if (delivered.has(key)) continue;
|
||||
|
||||
const allowed = await shouldSendNotification({
|
||||
eventId: event.id,
|
||||
eventIds: notificationEventIds,
|
||||
ruleId: rule.id,
|
||||
role: roleName,
|
||||
channel,
|
||||
@@ -321,8 +384,8 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
status: "sent",
|
||||
});
|
||||
delivered.add(key);
|
||||
} catch (err: any) {
|
||||
const msg = err?.message ? String(err.message) : "notification_failed";
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "notification_failed";
|
||||
await recordNotification({
|
||||
orgId: event.orgId,
|
||||
machineId: event.machineId,
|
||||
|
||||
261
lib/alerts/getAlertsInboxData.ts
Normal file
261
lib/alerts/getAlertsInboxData.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const RANGE_MS: Record<string, number> = {
|
||||
"24h": 24 * 60 * 60 * 1000,
|
||||
"7d": 7 * 24 * 60 * 60 * 1000,
|
||||
"30d": 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
type AlertsInboxParams = {
|
||||
orgId: string;
|
||||
range?: string;
|
||||
start?: Date | null;
|
||||
end?: Date | null;
|
||||
machineId?: string;
|
||||
location?: string;
|
||||
eventType?: string;
|
||||
severity?: string;
|
||||
status?: string;
|
||||
shift?: string;
|
||||
includeUpdates?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
function pickRange(range: string, start?: Date | null, end?: Date | null) {
|
||||
const now = new Date();
|
||||
if (range === "custom") {
|
||||
const startFallback = new Date(now.getTime() - RANGE_MS["24h"]);
|
||||
return {
|
||||
range,
|
||||
start: start ?? startFallback,
|
||||
end: end ?? now,
|
||||
};
|
||||
}
|
||||
const ms = RANGE_MS[range] ?? RANGE_MS["24h"];
|
||||
return { range, start: new Date(now.getTime() - ms), end: now };
|
||||
}
|
||||
|
||||
function safeString(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function safeNumber(value: unknown) {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function safeBool(value: unknown) {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function parsePayload(raw: unknown) {
|
||||
let parsed: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = raw;
|
||||
}
|
||||
}
|
||||
const payload =
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const innerCandidate = payload.data;
|
||||
const inner =
|
||||
innerCandidate && typeof innerCandidate === "object" && !Array.isArray(innerCandidate)
|
||||
? (innerCandidate as Record<string, unknown>)
|
||||
: payload;
|
||||
return { payload, inner };
|
||||
}
|
||||
|
||||
function extractDurationSec(raw: unknown) {
|
||||
const { payload, inner } = parsePayload(raw);
|
||||
const candidates = [
|
||||
inner?.duration_seconds,
|
||||
inner?.duration_sec,
|
||||
inner?.stoppage_duration_seconds,
|
||||
inner?.stop_duration_seconds,
|
||||
payload?.duration_seconds,
|
||||
payload?.duration_sec,
|
||||
payload?.stoppage_duration_seconds,
|
||||
payload?.stop_duration_seconds,
|
||||
];
|
||||
for (const val of candidates) {
|
||||
if (typeof val === "number" && Number.isFinite(val) && val >= 0) return val;
|
||||
}
|
||||
const msCandidates = [inner?.duration_ms, inner?.durationMs, payload?.duration_ms, payload?.durationMs];
|
||||
for (const val of msCandidates) {
|
||||
if (typeof val === "number" && Number.isFinite(val) && val >= 0) {
|
||||
return Math.round(val / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
const startMs = inner.start_ts ?? inner.startTs ?? payload.start_ts ?? payload.startTs ?? null;
|
||||
const endMs = inner.end_ts ?? inner.endTs ?? payload.end_ts ?? payload.endTs ?? null;
|
||||
if (typeof startMs === "number" && typeof endMs === "number" && endMs >= startMs) {
|
||||
return Math.round((endMs - startMs) / 1000);
|
||||
}
|
||||
|
||||
const actual = safeNumber(inner.actual_cycle_time ?? payload.actual_cycle_time);
|
||||
const theoretical = safeNumber(inner.theoretical_cycle_time ?? payload.theoretical_cycle_time);
|
||||
if (actual != null && theoretical != null) {
|
||||
return Math.max(0, actual - theoretical);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTimeMinutes(value?: string | null) {
|
||||
if (!value || !/^\d{2}:\d{2}$/.test(value)) return null;
|
||||
const [hh, mm] = value.split(":").map((n) => Number(n));
|
||||
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return null;
|
||||
return hh * 60 + mm;
|
||||
}
|
||||
|
||||
function getLocalMinutes(ts: Date, timeZone: string) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(ts);
|
||||
const hours = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
|
||||
const minutes = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
|
||||
return hours * 60 + minutes;
|
||||
} catch {
|
||||
return ts.getUTCHours() * 60 + ts.getUTCMinutes();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveShift(
|
||||
shifts: Array<{ name: string; startTime: string; endTime: string; enabled?: boolean }>,
|
||||
ts: Date,
|
||||
timeZone: string
|
||||
) {
|
||||
if (!shifts.length) return null;
|
||||
const nowMin = getLocalMinutes(ts, timeZone);
|
||||
for (const shift of shifts) {
|
||||
if (shift.enabled === false) continue;
|
||||
const start = parseTimeMinutes(shift.startTime);
|
||||
const end = parseTimeMinutes(shift.endTime);
|
||||
if (start == null || end == null) continue;
|
||||
if (start <= end) {
|
||||
if (nowMin >= start && nowMin < end) return shift.name;
|
||||
} else {
|
||||
if (nowMin >= start || nowMin < end) return shift.name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
const {
|
||||
orgId,
|
||||
range = "24h",
|
||||
start,
|
||||
end,
|
||||
machineId,
|
||||
location,
|
||||
eventType,
|
||||
severity,
|
||||
status,
|
||||
shift,
|
||||
includeUpdates = false,
|
||||
limit = 200,
|
||||
} = params;
|
||||
|
||||
const picked = pickRange(range, start, end);
|
||||
const normalizedStatus = safeString(status)?.toLowerCase();
|
||||
const normalizedShift = safeString(shift);
|
||||
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 500) : 200;
|
||||
|
||||
const where = {
|
||||
orgId,
|
||||
ts: { gte: picked.start, lte: picked.end },
|
||||
...(machineId ? { machineId } : {}),
|
||||
...(eventType ? { eventType } : {}),
|
||||
...(severity ? { severity } : {}),
|
||||
...(location ? { machine: { location } } : {}),
|
||||
};
|
||||
|
||||
const [events, shifts, settings] = await Promise.all([
|
||||
prisma.machineEvent.findMany({
|
||||
where,
|
||||
orderBy: { ts: "desc" },
|
||||
take: safeLimit,
|
||||
select: {
|
||||
id: true,
|
||||
ts: true,
|
||||
eventType: true,
|
||||
severity: true,
|
||||
title: true,
|
||||
description: true,
|
||||
data: true,
|
||||
machineId: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
machine: {
|
||||
select: {
|
||||
name: true,
|
||||
location: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.orgShift.findMany({
|
||||
where: { orgId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { name: true, startTime: true, endTime: true, enabled: true },
|
||||
}),
|
||||
prisma.orgSettings.findUnique({
|
||||
where: { orgId },
|
||||
select: { timezone: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const timeZone = settings?.timezone || "UTC";
|
||||
const mapped = [];
|
||||
|
||||
for (const ev of events) {
|
||||
const { payload, inner } = parsePayload(ev.data);
|
||||
const rawStatus = safeString(payload?.status ?? inner?.status);
|
||||
const isUpdate = safeBool(payload?.is_update ?? inner?.is_update);
|
||||
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
||||
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
|
||||
|
||||
const shiftName = resolveShift(shifts, ev.ts, timeZone);
|
||||
if (normalizedShift && shiftName !== normalizedShift) continue;
|
||||
|
||||
const statusLabel = rawStatus ? rawStatus.toLowerCase() : "unknown";
|
||||
if (normalizedStatus && statusLabel !== normalizedStatus) continue;
|
||||
|
||||
mapped.push({
|
||||
id: ev.id,
|
||||
ts: ev.ts,
|
||||
eventType: ev.eventType,
|
||||
severity: ev.severity,
|
||||
title: ev.title,
|
||||
description: ev.description,
|
||||
machineId: ev.machineId,
|
||||
machineName: ev.machine?.name ?? null,
|
||||
location: ev.machine?.location ?? null,
|
||||
workOrderId: ev.workOrderId ?? null,
|
||||
sku: ev.sku ?? null,
|
||||
durationSec: extractDurationSec(ev.data),
|
||||
status: statusLabel,
|
||||
shift: shiftName,
|
||||
alertId: safeString(payload?.alert_id ?? inner?.alert_id),
|
||||
isUpdate,
|
||||
isAutoAck,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
range: { range: picked.range, start: picked.start, end: picked.end },
|
||||
events: mapped,
|
||||
};
|
||||
}
|
||||
@@ -10,11 +10,17 @@ 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: any = raw;
|
||||
const payload = obj.payload;
|
||||
if (!payload || typeof payload !== "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 ||
|
||||
@@ -47,7 +53,8 @@ function unwrapCanonicalEnvelope(raw: unknown) {
|
||||
|
||||
function normalizeTsDevice(raw: unknown) {
|
||||
if (!raw || typeof raw !== "object") return raw;
|
||||
const obj: any = 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;
|
||||
@@ -149,87 +156,89 @@ export function normalizeSnapshotV1(raw: unknown): { ok: true; value: SnapshotV1
|
||||
if (!recheck.success) return { ok: false, error: recheck.error.message };
|
||||
return { ok: true, value: recheck.data };
|
||||
*/
|
||||
const b: any = legacy.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 ??
|
||||
b.kpi_snapshot?.cycleTime ??
|
||||
b.kpi_snapshot?.cycle_time ??
|
||||
undefined;
|
||||
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 ??
|
||||
b.kpi_snapshot?.actualCycleTime ??
|
||||
b.kpi_snapshot?.actual_cycle_time ??
|
||||
undefined;
|
||||
const legacyActualCycleTime =
|
||||
b.actualCycleTime ??
|
||||
b.actual_cycle_time ??
|
||||
b.actualCycleSeconds ??
|
||||
kpiSnapshot?.actualCycleTime ??
|
||||
kpiSnapshot?.actual_cycle_time ??
|
||||
undefined;
|
||||
|
||||
const legacyWorkOrderId =
|
||||
b.activeWorkOrder?.id ??
|
||||
b.work_order_id ??
|
||||
b.workOrderId ??
|
||||
b.kpis?.workOrderId ??
|
||||
b.kpi_snapshot?.work_order_id ??
|
||||
undefined;
|
||||
const legacyWorkOrderId =
|
||||
activeWorkOrder?.id ??
|
||||
b.work_order_id ??
|
||||
b.workOrderId ??
|
||||
kpis?.workOrderId ??
|
||||
kpiSnapshot?.work_order_id ??
|
||||
undefined;
|
||||
|
||||
const legacySku =
|
||||
b.activeWorkOrder?.sku ??
|
||||
b.sku ??
|
||||
b.kpis?.sku ??
|
||||
b.kpi_snapshot?.sku ??
|
||||
undefined;
|
||||
const legacySku =
|
||||
activeWorkOrder?.sku ??
|
||||
b.sku ??
|
||||
kpis?.sku ??
|
||||
kpiSnapshot?.sku ??
|
||||
undefined;
|
||||
|
||||
const legacyTarget =
|
||||
b.activeWorkOrder?.target ??
|
||||
b.target ??
|
||||
b.kpis?.target ??
|
||||
b.kpi_snapshot?.target ??
|
||||
undefined;
|
||||
const legacyTarget =
|
||||
activeWorkOrder?.target ??
|
||||
b.target ??
|
||||
kpis?.target ??
|
||||
kpiSnapshot?.target ??
|
||||
undefined;
|
||||
|
||||
const legacyGood =
|
||||
b.activeWorkOrder?.good ??
|
||||
b.good_parts ??
|
||||
b.good ??
|
||||
b.kpis?.good ??
|
||||
b.kpi_snapshot?.good_parts ??
|
||||
undefined;
|
||||
const legacyGood =
|
||||
activeWorkOrder?.good ??
|
||||
b.good_parts ??
|
||||
b.good ??
|
||||
kpis?.good ??
|
||||
kpiSnapshot?.good_parts ??
|
||||
undefined;
|
||||
|
||||
const legacyScrap =
|
||||
b.activeWorkOrder?.scrap ??
|
||||
b.scrap_parts ??
|
||||
b.scrap ??
|
||||
b.kpis?.scrap ??
|
||||
b.kpi_snapshot?.scrap_parts ??
|
||||
undefined;
|
||||
const legacyScrap =
|
||||
activeWorkOrder?.scrap ??
|
||||
b.scrap_parts ??
|
||||
b.scrap ??
|
||||
kpis?.scrap ??
|
||||
kpiSnapshot?.scrap_parts ??
|
||||
undefined;
|
||||
|
||||
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",
|
||||
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,
|
||||
// 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,
|
||||
}
|
||||
: b.activeWorkOrder,
|
||||
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,
|
||||
|
||||
// keep everything else
|
||||
...b,
|
||||
};
|
||||
const recheck = SnapshotV1.safeParse(migrated);
|
||||
if (!recheck.success) return { ok: false, error: recheck.error.message };
|
||||
@@ -262,8 +271,8 @@ export function normalizeHeartbeatV1(raw: unknown) {
|
||||
const legacy = z.object({ machineId: z.any() }).passthrough().safeParse(candidate);
|
||||
if (!legacy.success) return { ok: false as const, error: strict.error.message };
|
||||
|
||||
const b: any = legacy.data;
|
||||
const migrated: any = {
|
||||
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(),
|
||||
@@ -304,11 +313,22 @@ export function normalizeCycleV1(raw: unknown) {
|
||||
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: any = legacy.data;
|
||||
const tsDevice = typeof b.tsDevice === "number" ? b.tsDevice : (b.cycle?.timestamp ?? Date.now());
|
||||
const seq = typeof b.seq === "number" || typeof b.seq === "string" ? b.seq : (b.cycle?.cycle_count ?? "0");
|
||||
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: any = { schemaVersion: SCHEMA_VERSION, machineId: String(b.machineId), tsDevice, seq, ...b };
|
||||
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 };
|
||||
@@ -343,9 +363,11 @@ export function normalizeEventV1(raw: unknown) {
|
||||
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: any = legacy.data;
|
||||
const tsDevice = typeof b.tsDevice === "number" ? b.tsDevice : (b.event?.timestamp ?? Date.now());
|
||||
const migrated: any = {
|
||||
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,
|
||||
|
||||
36
lib/email.ts
36
lib/email.ts
@@ -1,7 +1,5 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { logLine } from "@/lib/logger";
|
||||
|
||||
|
||||
type EmailPayload = {
|
||||
to: string;
|
||||
subject: string;
|
||||
@@ -54,15 +52,14 @@ export async function sendEmail(payload: EmailPayload) {
|
||||
if (!from) {
|
||||
throw new Error("SMTP_FROM not configured");
|
||||
}
|
||||
logLine("email.send.start", {
|
||||
logLine("email.send.start", {
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
from,
|
||||
});
|
||||
|
||||
|
||||
const transporter = getTransporter();
|
||||
try {
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from,
|
||||
to: payload.to,
|
||||
@@ -77,6 +74,7 @@ export async function sendEmail(payload: EmailPayload) {
|
||||
});
|
||||
|
||||
// Nodemailer response details:
|
||||
const pending = "pending" in info ? (info as { pending?: string[] }).pending : undefined;
|
||||
logLine("email.send.ok", {
|
||||
to: payload.to,
|
||||
from,
|
||||
@@ -84,25 +82,33 @@ export async function sendEmail(payload: EmailPayload) {
|
||||
response: info.response,
|
||||
accepted: info.accepted,
|
||||
rejected: info.rejected,
|
||||
pending: (info as any).pending,
|
||||
pending,
|
||||
});
|
||||
|
||||
return info;
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const error = err as {
|
||||
name?: string;
|
||||
message?: string;
|
||||
code?: string;
|
||||
command?: string;
|
||||
response?: unknown;
|
||||
responseCode?: number;
|
||||
stack?: string;
|
||||
};
|
||||
logLine("email.send.err", {
|
||||
to: payload.to,
|
||||
from,
|
||||
name: err?.name,
|
||||
message: err?.message,
|
||||
code: err?.code,
|
||||
command: err?.command,
|
||||
response: err?.response,
|
||||
responseCode: err?.responseCode,
|
||||
stack: err?.stack,
|
||||
name: error?.name,
|
||||
message: error?.message,
|
||||
code: error?.code,
|
||||
command: error?.command,
|
||||
response: error?.response,
|
||||
responseCode: error?.responseCode,
|
||||
stack: error?.stack,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function buildVerifyEmail(params: { appName: string; verifyUrl: string }) {
|
||||
|
||||
214
lib/events/normalizeEvent.ts
Normal file
214
lib/events/normalizeEvent.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
type NormalizeThresholds = {
|
||||
microMultiplier: number;
|
||||
macroMultiplier: number;
|
||||
};
|
||||
|
||||
type RawEventRow = {
|
||||
id: string;
|
||||
ts?: Date | null;
|
||||
topic?: string | null;
|
||||
eventType?: string | null;
|
||||
severity?: string | null;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
requiresAck?: boolean | null;
|
||||
data?: unknown;
|
||||
workOrderId?: string | null;
|
||||
};
|
||||
|
||||
function coerceString(value: unknown) {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number") return String(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeEvent(row: RawEventRow, thresholds: NormalizeThresholds) {
|
||||
// -----------------------------
|
||||
// 1) Parse row.data safely
|
||||
// data may be:
|
||||
// - object
|
||||
// - array of objects
|
||||
// - JSON string of either
|
||||
// -----------------------------
|
||||
const raw = row.data;
|
||||
|
||||
let parsed: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = raw; // keep as string if not JSON
|
||||
}
|
||||
}
|
||||
|
||||
// data can be object OR [object]
|
||||
const blob = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
|
||||
// some payloads nest details under blob.data
|
||||
const inner = (blob as { data?: unknown })?.data ?? blob ?? {};
|
||||
|
||||
const normalizeType = (t: unknown) =>
|
||||
String(t ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/_/g, "-");
|
||||
|
||||
// -----------------------------
|
||||
// 2) Alias mapping (canonical types)
|
||||
// -----------------------------
|
||||
const ALIAS: Record<string, string> = {
|
||||
// Spanish / synonyms
|
||||
macroparo: "macrostop",
|
||||
"macro-stop": "macrostop",
|
||||
macro_stop: "macrostop",
|
||||
|
||||
microparo: "microstop",
|
||||
"micro-paro": "microstop",
|
||||
micro_stop: "microstop",
|
||||
|
||||
// Node-RED types
|
||||
"production-stopped": "stop", // we'll classify to micro/macro below
|
||||
|
||||
// legacy / generic
|
||||
down: "stop",
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// 3) Determine event type from DB or blob
|
||||
// -----------------------------
|
||||
const fromDbType = row.eventType && row.eventType !== "unknown" ? row.eventType : null;
|
||||
|
||||
const fromBlobType =
|
||||
(blob as { anomaly_type?: unknown; eventType?: unknown; topic?: unknown })?.anomaly_type ??
|
||||
(blob as { anomaly_type?: unknown; eventType?: unknown; topic?: unknown })?.eventType ??
|
||||
(blob as { anomaly_type?: unknown; eventType?: unknown; topic?: unknown })?.topic ??
|
||||
(inner as { anomaly_type?: unknown; eventType?: unknown })?.anomaly_type ??
|
||||
(inner as { anomaly_type?: unknown; eventType?: unknown })?.eventType ??
|
||||
null;
|
||||
|
||||
// infer slow-cycle if signature exists
|
||||
const inferredType =
|
||||
fromDbType ??
|
||||
fromBlobType ??
|
||||
(((inner as { actual_cycle_time?: unknown; theoretical_cycle_time?: unknown })?.actual_cycle_time &&
|
||||
(inner as { actual_cycle_time?: unknown; theoretical_cycle_time?: unknown })?.theoretical_cycle_time) ||
|
||||
((blob as { actual_cycle_time?: unknown; theoretical_cycle_time?: unknown })?.actual_cycle_time &&
|
||||
(blob as { actual_cycle_time?: unknown; theoretical_cycle_time?: unknown })?.theoretical_cycle_time)
|
||||
? "slow-cycle"
|
||||
: "unknown");
|
||||
|
||||
const eventTypeRaw = normalizeType(inferredType);
|
||||
let eventType = ALIAS[eventTypeRaw] ?? eventTypeRaw;
|
||||
|
||||
// -----------------------------
|
||||
// 4) Optional: classify "stop" into micro/macro based on duration if present
|
||||
// (keeps old rows usable even if they stored production-stopped)
|
||||
// -----------------------------
|
||||
if (eventType === "stop") {
|
||||
const innerData = inner as {
|
||||
stoppage_duration_seconds?: unknown;
|
||||
stop_duration_seconds?: unknown;
|
||||
theoretical_cycle_time?: unknown;
|
||||
};
|
||||
const blobData = blob as {
|
||||
stoppage_duration_seconds?: unknown;
|
||||
stop_duration_seconds?: unknown;
|
||||
theoretical_cycle_time?: unknown;
|
||||
};
|
||||
|
||||
const stopSec =
|
||||
(typeof innerData?.stoppage_duration_seconds === "number" && innerData.stoppage_duration_seconds) ||
|
||||
(typeof blobData?.stoppage_duration_seconds === "number" && blobData.stoppage_duration_seconds) ||
|
||||
(typeof innerData?.stop_duration_seconds === "number" && innerData.stop_duration_seconds) ||
|
||||
null;
|
||||
|
||||
const microMultiplier = Number(thresholds?.microMultiplier ?? 1.5);
|
||||
const macroMultiplier = Math.max(microMultiplier, Number(thresholds?.macroMultiplier ?? 5));
|
||||
|
||||
const theoreticalCycle =
|
||||
Number(innerData?.theoretical_cycle_time ?? blobData?.theoretical_cycle_time) || 0;
|
||||
|
||||
if (stopSec != null) {
|
||||
if (theoreticalCycle > 0) {
|
||||
const macroThresholdSec = theoreticalCycle * macroMultiplier;
|
||||
eventType = stopSec >= macroThresholdSec ? "macrostop" : "microstop";
|
||||
} else {
|
||||
const fallbackMacroSec = 300;
|
||||
eventType = stopSec >= fallbackMacroSec ? "macrostop" : "microstop";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// 5) Severity, title, description, timestamp
|
||||
// -----------------------------
|
||||
const severity =
|
||||
String(
|
||||
(row.severity && row.severity !== "info" ? row.severity : null) ??
|
||||
(blob as { severity?: unknown })?.severity ??
|
||||
(inner as { severity?: unknown })?.severity ??
|
||||
"info"
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const title =
|
||||
String(
|
||||
(row.title && row.title !== "Event" ? row.title : null) ??
|
||||
(blob as { title?: unknown })?.title ??
|
||||
(inner as { title?: unknown })?.title ??
|
||||
(eventType === "slow-cycle" ? "Slow Cycle Detected" : "Event")
|
||||
).trim();
|
||||
|
||||
const description =
|
||||
row.description ??
|
||||
(blob as { description?: string | null })?.description ??
|
||||
(inner as { description?: string | null })?.description ??
|
||||
(eventType === "slow-cycle" &&
|
||||
((inner as { actual_cycle_time?: unknown })?.actual_cycle_time ??
|
||||
(blob as { actual_cycle_time?: unknown })?.actual_cycle_time) &&
|
||||
((inner as { theoretical_cycle_time?: unknown })?.theoretical_cycle_time ??
|
||||
(blob as { theoretical_cycle_time?: unknown })?.theoretical_cycle_time) &&
|
||||
((inner as { delta_percent?: unknown })?.delta_percent ??
|
||||
(blob as { delta_percent?: unknown })?.delta_percent) != null
|
||||
? `Cycle took ${Number(
|
||||
(inner as { actual_cycle_time?: unknown })?.actual_cycle_time ??
|
||||
(blob as { actual_cycle_time?: unknown })?.actual_cycle_time
|
||||
).toFixed(1)}s (+${Number(
|
||||
(inner as { delta_percent?: unknown })?.delta_percent ??
|
||||
(blob as { delta_percent?: unknown })?.delta_percent
|
||||
)}% vs ${Number(
|
||||
(inner as { theoretical_cycle_time?: unknown })?.theoretical_cycle_time ??
|
||||
(blob as { theoretical_cycle_time?: unknown })?.theoretical_cycle_time
|
||||
).toFixed(1)}s objetivo)`
|
||||
: null);
|
||||
|
||||
const ts =
|
||||
row.ts ??
|
||||
(typeof (blob as { timestamp?: unknown })?.timestamp === "number"
|
||||
? new Date((blob as { timestamp?: number }).timestamp as number)
|
||||
: null) ??
|
||||
(typeof (inner as { timestamp?: unknown })?.timestamp === "number"
|
||||
? new Date((inner as { timestamp?: number }).timestamp as number)
|
||||
: null) ??
|
||||
null;
|
||||
|
||||
const workOrderId =
|
||||
coerceString(row.workOrderId) ??
|
||||
coerceString((blob as { work_order_id?: unknown })?.work_order_id) ??
|
||||
coerceString((inner as { work_order_id?: unknown })?.work_order_id) ??
|
||||
null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
ts,
|
||||
topic: String(row.topic ?? (blob as { topic?: unknown })?.topic ?? eventType),
|
||||
eventType,
|
||||
severity,
|
||||
title,
|
||||
description,
|
||||
requiresAck: !!row.requiresAck,
|
||||
workOrderId,
|
||||
};
|
||||
}
|
||||
418
lib/financial/impact.ts
Normal file
418
lib/financial/impact.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const COST_EVENT_TYPES = ["slow-cycle", "microstop", "macrostop", "quality-spike"] as const;
|
||||
|
||||
type CostProfile = {
|
||||
currency: string;
|
||||
machineCostPerMin: number | null;
|
||||
operatorCostPerMin: number | null;
|
||||
ratedRunningKw: number | null;
|
||||
idleKw: number | null;
|
||||
kwhRate: number | null;
|
||||
energyMultiplier: number | null;
|
||||
energyCostPerMin: number | null;
|
||||
scrapCostPerUnit: number | null;
|
||||
rawMaterialCostPerUnit: number | null;
|
||||
};
|
||||
|
||||
|
||||
|
||||
type CostProfileOverride = Omit<Partial<CostProfile>, "currency">;
|
||||
type Category = "slowCycle" | "microstop" | "macrostop" | "scrap";
|
||||
type Totals = { total: number } & Record<Category, number>;
|
||||
type DayRow = { day: string } & Totals;
|
||||
|
||||
export type FinancialEventDetail = {
|
||||
id: string;
|
||||
ts: Date;
|
||||
eventType: string;
|
||||
status: string;
|
||||
severity: string;
|
||||
category: Category;
|
||||
machineId: string;
|
||||
machineName: string | null;
|
||||
location: string | null;
|
||||
workOrderId: string | null;
|
||||
sku: string | null;
|
||||
durationSec: number | null;
|
||||
costMachine: number;
|
||||
costOperator: number;
|
||||
costEnergy: number;
|
||||
costScrap: number;
|
||||
costRawMaterial: number;
|
||||
costTotal: number;
|
||||
currency: string;
|
||||
};
|
||||
|
||||
export type FinancialImpactSummary = {
|
||||
currency: string;
|
||||
totals: Totals;
|
||||
byDay: DayRow[];
|
||||
};
|
||||
|
||||
export type FinancialImpactResult = {
|
||||
range: { start: Date; end: Date };
|
||||
currencySummaries: FinancialImpactSummary[];
|
||||
eventsEvaluated: number;
|
||||
eventsIncluded: number;
|
||||
events: FinancialEventDetail[];
|
||||
filters: {
|
||||
machineId?: string;
|
||||
location?: string;
|
||||
sku?: string;
|
||||
currency?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type FinancialImpactParams = {
|
||||
orgId: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
machineId?: string;
|
||||
location?: string;
|
||||
sku?: string;
|
||||
currency?: string;
|
||||
includeEvents?: boolean;
|
||||
};
|
||||
|
||||
function safeNumber(value: unknown) {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function parseBlob(raw: unknown) {
|
||||
let parsed: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = raw;
|
||||
}
|
||||
}
|
||||
const blob = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
const blobRecord = typeof blob === "object" && blob !== null ? (blob as Record<string, unknown>) : null;
|
||||
const innerCandidate = blobRecord?.data ?? blobRecord ?? {};
|
||||
const inner =
|
||||
typeof innerCandidate === "object" && innerCandidate !== null
|
||||
? (innerCandidate as Record<string, unknown>)
|
||||
: {};
|
||||
return { blob: blobRecord, inner } as const;
|
||||
}
|
||||
|
||||
function dateKey(ts: Date) {
|
||||
return ts.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function applyOverride(
|
||||
base: CostProfile,
|
||||
override?: CostProfileOverride | null,
|
||||
currency?: string | null
|
||||
) {
|
||||
const out: CostProfile = { ...base };
|
||||
if (currency) out.currency = currency;
|
||||
if (!override) return out;
|
||||
|
||||
if (override.machineCostPerMin != null) out.machineCostPerMin = override.machineCostPerMin;
|
||||
if (override.operatorCostPerMin != null) out.operatorCostPerMin = override.operatorCostPerMin;
|
||||
if (override.ratedRunningKw != null) out.ratedRunningKw = override.ratedRunningKw;
|
||||
if (override.idleKw != null) out.idleKw = override.idleKw;
|
||||
if (override.kwhRate != null) out.kwhRate = override.kwhRate;
|
||||
if (override.energyMultiplier != null) out.energyMultiplier = override.energyMultiplier;
|
||||
if (override.energyCostPerMin != null) out.energyCostPerMin = override.energyCostPerMin;
|
||||
if (override.scrapCostPerUnit != null) out.scrapCostPerUnit = override.scrapCostPerUnit;
|
||||
if (override.rawMaterialCostPerUnit != null) out.rawMaterialCostPerUnit = override.rawMaterialCostPerUnit;
|
||||
return out;
|
||||
}
|
||||
|
||||
function computeEnergyCostPerMin(profile: CostProfile, mode: "running" | "idle") {
|
||||
if (profile.energyCostPerMin != null) return profile.energyCostPerMin;
|
||||
const kw = mode === "running" ? profile.ratedRunningKw : profile.idleKw;
|
||||
const rate = profile.kwhRate;
|
||||
if (kw == null || rate == null) return null;
|
||||
const multiplier = profile.energyMultiplier ?? 1;
|
||||
return (kw / 60) * rate * multiplier;
|
||||
}
|
||||
|
||||
export async function computeFinancialImpact(params: FinancialImpactParams): Promise<FinancialImpactResult> {
|
||||
const { orgId, start, end, machineId, location, sku, currency, includeEvents } = params;
|
||||
|
||||
const machines = await prisma.machine.findMany({
|
||||
where: { orgId },
|
||||
select: { id: true, name: true, location: true },
|
||||
});
|
||||
|
||||
const machineMap = new Map(machines.map((m) => [m.id, m]));
|
||||
|
||||
let machineIds = machines.map((m) => m.id);
|
||||
if (location) {
|
||||
machineIds = machines.filter((m) => m.location === location).map((m) => m.id);
|
||||
}
|
||||
if (machineId) {
|
||||
machineIds = machineIds.includes(machineId) ? [machineId] : [];
|
||||
}
|
||||
|
||||
if (!machineIds.length) {
|
||||
return {
|
||||
range: { start, end },
|
||||
currencySummaries: [],
|
||||
eventsEvaluated: 0,
|
||||
eventsIncluded: 0,
|
||||
events: [],
|
||||
filters: { machineId, location, sku, currency },
|
||||
};
|
||||
}
|
||||
|
||||
const events = await prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
ts: { gte: start, lte: end },
|
||||
machineId: { in: machineIds },
|
||||
eventType: { in: COST_EVENT_TYPES as unknown as string[] },
|
||||
},
|
||||
orderBy: { ts: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
ts: true,
|
||||
eventType: true,
|
||||
data: true,
|
||||
machineId: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
severity: true,
|
||||
},
|
||||
});
|
||||
|
||||
const missingSkuPairs = events
|
||||
.filter((e) => !e.sku && e.workOrderId)
|
||||
.map((e) => ({ machineId: e.machineId, workOrderId: e.workOrderId as string }));
|
||||
const workOrderIds = Array.from(new Set(missingSkuPairs.map((p) => p.workOrderId)));
|
||||
const workOrderMachines = Array.from(new Set(missingSkuPairs.map((p) => p.machineId)));
|
||||
|
||||
const workOrders = workOrderIds.length
|
||||
? await prisma.machineWorkOrder.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
workOrderId: { in: workOrderIds },
|
||||
machineId: { in: workOrderMachines },
|
||||
},
|
||||
select: { machineId: true, workOrderId: true, sku: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const workOrderSku = new Map<string, string>();
|
||||
for (const row of workOrders) {
|
||||
if (row.sku) {
|
||||
workOrderSku.set(`${row.machineId}:${row.workOrderId}`, row.sku);
|
||||
}
|
||||
}
|
||||
|
||||
const [orgProfileRaw, locationOverrides, machineOverrides, productOverrides] = await Promise.all([
|
||||
prisma.orgFinancialProfile.findUnique({ where: { orgId } }),
|
||||
prisma.locationFinancialOverride.findMany({ where: { orgId } }),
|
||||
prisma.machineFinancialOverride.findMany({ where: { orgId } }),
|
||||
prisma.productCostOverride.findMany({ where: { orgId } }),
|
||||
]);
|
||||
|
||||
const orgProfile: CostProfile = {
|
||||
currency: orgProfileRaw?.defaultCurrency ?? "USD",
|
||||
machineCostPerMin: orgProfileRaw?.machineCostPerMin ?? null,
|
||||
operatorCostPerMin: orgProfileRaw?.operatorCostPerMin ?? null,
|
||||
ratedRunningKw: orgProfileRaw?.ratedRunningKw ?? null,
|
||||
idleKw: orgProfileRaw?.idleKw ?? null,
|
||||
kwhRate: orgProfileRaw?.kwhRate ?? null,
|
||||
energyMultiplier: orgProfileRaw?.energyMultiplier ?? 1,
|
||||
energyCostPerMin: orgProfileRaw?.energyCostPerMin ?? null,
|
||||
scrapCostPerUnit: orgProfileRaw?.scrapCostPerUnit ?? null,
|
||||
rawMaterialCostPerUnit: orgProfileRaw?.rawMaterialCostPerUnit ?? null,
|
||||
};
|
||||
|
||||
const locationMap = new Map(locationOverrides.map((o) => [o.location, o]));
|
||||
const machineOverrideMap = new Map(machineOverrides.map((o) => [o.machineId, o]));
|
||||
const productMap = new Map(productOverrides.map((o) => [o.sku, o]));
|
||||
|
||||
const summaries = new Map<
|
||||
string,
|
||||
{
|
||||
currency: string;
|
||||
totals: Totals;
|
||||
byDay: Map<string, DayRow>;
|
||||
}
|
||||
>();
|
||||
|
||||
const detailed: FinancialEventDetail[] = [];
|
||||
let eventsIncluded = 0;
|
||||
|
||||
for (const ev of events) {
|
||||
const eventType = String(ev.eventType ?? "").toLowerCase();
|
||||
const { blob, inner } = parseBlob(ev.data);
|
||||
const status = String(blob?.status ?? inner?.status ?? "").toLowerCase();
|
||||
const severity = String(ev.severity ?? "").toLowerCase();
|
||||
const isAutoAck = Boolean(blob?.is_auto_ack ?? inner?.is_auto_ack);
|
||||
const isUpdate = Boolean(blob?.is_update ?? inner?.is_update);
|
||||
|
||||
const machine = machineMap.get(ev.machineId);
|
||||
const locationName = machine?.location ?? null;
|
||||
const skuResolved =
|
||||
ev.sku ??
|
||||
(ev.workOrderId ? workOrderSku.get(`${ev.machineId}:${ev.workOrderId}`) : null) ??
|
||||
null;
|
||||
|
||||
if (sku && skuResolved !== sku) continue;
|
||||
if (isAutoAck || isUpdate) continue;
|
||||
|
||||
const locationOverride = locationName ? locationMap.get(locationName) : null;
|
||||
const machineOverride = machineOverrideMap.get(ev.machineId) ?? null;
|
||||
|
||||
let profile = applyOverride(orgProfile, locationOverride, locationOverride?.currency ?? null);
|
||||
profile = applyOverride(profile, machineOverride, machineOverride?.currency ?? null);
|
||||
|
||||
const productOverride = skuResolved ? productMap.get(skuResolved) : null;
|
||||
if (productOverride?.rawMaterialCostPerUnit != null) {
|
||||
profile.rawMaterialCostPerUnit = productOverride.rawMaterialCostPerUnit;
|
||||
}
|
||||
if (productOverride?.currency) {
|
||||
profile.currency = productOverride.currency;
|
||||
}
|
||||
|
||||
let category: Category | null = null;
|
||||
let durationSec: number | null = null;
|
||||
let costMachine = 0;
|
||||
let costOperator = 0;
|
||||
let costEnergy = 0;
|
||||
let costScrap = 0;
|
||||
let costRawMaterial = 0;
|
||||
|
||||
if (eventType === "slow-cycle") {
|
||||
const actual =
|
||||
safeNumber(inner?.actual_cycle_time ?? blob?.actual_cycle_time ?? inner?.actualCycleTime ?? blob?.actualCycleTime) ??
|
||||
null;
|
||||
const theoretical =
|
||||
safeNumber(
|
||||
inner?.theoretical_cycle_time ??
|
||||
blob?.theoretical_cycle_time ??
|
||||
inner?.theoreticalCycleTime ??
|
||||
blob?.theoreticalCycleTime
|
||||
) ?? null;
|
||||
if (actual == null || theoretical == null) continue;
|
||||
durationSec = Math.max(0, actual - theoretical);
|
||||
if (!durationSec) continue;
|
||||
const durationMin = durationSec / 60;
|
||||
costMachine = durationMin * (profile.machineCostPerMin ?? 0);
|
||||
costOperator = durationMin * (profile.operatorCostPerMin ?? 0);
|
||||
costEnergy = durationMin * (computeEnergyCostPerMin(profile, "running") ?? 0);
|
||||
category = "slowCycle";
|
||||
} else if (eventType === "microstop" || eventType === "macrostop") {
|
||||
//future activestoppage handling
|
||||
if (status === "active") continue;
|
||||
const rawDurationSec =
|
||||
safeNumber(
|
||||
inner?.stoppage_duration_seconds ??
|
||||
blob?.stoppage_duration_seconds ??
|
||||
inner?.stop_duration_seconds ??
|
||||
blob?.stop_duration_seconds
|
||||
) ?? 0;
|
||||
if (!rawDurationSec || rawDurationSec <= 0) continue;
|
||||
const theoreticalSec =
|
||||
safeNumber(
|
||||
inner?.theoretical_cycle_time ??
|
||||
blob?.theoretical_cycle_time ??
|
||||
inner?.theoreticalCycleTime ??
|
||||
blob?.theoreticalCycleTime
|
||||
) ?? null;
|
||||
const lastCycleTimestamp = safeNumber(inner?.last_cycle_timestamp ?? blob?.last_cycle_timestamp);
|
||||
const isCycleGapStop = theoreticalSec != null && theoreticalSec > 0 && lastCycleTimestamp == null;
|
||||
durationSec = isCycleGapStop ? Math.max(0, rawDurationSec - theoreticalSec) : rawDurationSec;
|
||||
if (!durationSec || durationSec <= 0) continue;
|
||||
const durationMin = durationSec / 60;
|
||||
costMachine = durationMin * (profile.machineCostPerMin ?? 0);
|
||||
costOperator = durationMin * (profile.operatorCostPerMin ?? 0);
|
||||
costEnergy = durationMin * (computeEnergyCostPerMin(profile, "idle") ?? 0);
|
||||
category = eventType === "macrostop" ? "macrostop" : "microstop";
|
||||
} else if (eventType === "quality-spike") {
|
||||
if (severity === "info" || status === "resolved") continue;
|
||||
const scrapParts =
|
||||
safeNumber(
|
||||
inner?.scrap_parts ??
|
||||
blob?.scrap_parts ??
|
||||
inner?.scrapParts ??
|
||||
blob?.scrapParts
|
||||
) ?? 0;
|
||||
if (scrapParts <= 0) continue;
|
||||
costScrap = scrapParts * (profile.scrapCostPerUnit ?? 0);
|
||||
costRawMaterial = scrapParts * (profile.rawMaterialCostPerUnit ?? 0);
|
||||
category = "scrap";
|
||||
}
|
||||
|
||||
if (!category) continue;
|
||||
|
||||
const costTotal = costMachine + costOperator + costEnergy + costScrap + costRawMaterial;
|
||||
if (costTotal <= 0) continue;
|
||||
if (currency && profile.currency !== currency) continue;
|
||||
|
||||
const key = profile.currency || "USD";
|
||||
const bucket = summaries.get(key) ?? {
|
||||
currency: key,
|
||||
totals: { total: 0, slowCycle: 0, microstop: 0, macrostop: 0, scrap: 0 },
|
||||
byDay: new Map<string, DayRow>(),
|
||||
};
|
||||
|
||||
bucket.totals.total += costTotal;
|
||||
bucket.totals[category] += costTotal;
|
||||
|
||||
const day = dateKey(ev.ts);
|
||||
const dayRow: DayRow = bucket.byDay.get(day) ?? {
|
||||
day,
|
||||
total: 0,
|
||||
slowCycle: 0,
|
||||
microstop: 0,
|
||||
macrostop: 0,
|
||||
scrap: 0,
|
||||
};
|
||||
dayRow.total += costTotal;
|
||||
dayRow[category] += costTotal;
|
||||
bucket.byDay.set(day, dayRow);
|
||||
|
||||
summaries.set(key, bucket);
|
||||
eventsIncluded += 1;
|
||||
|
||||
if (includeEvents) {
|
||||
detailed.push({
|
||||
id: ev.id,
|
||||
ts: ev.ts,
|
||||
eventType,
|
||||
status,
|
||||
severity,
|
||||
category,
|
||||
machineId: ev.machineId,
|
||||
machineName: machine?.name ?? null,
|
||||
location: locationName,
|
||||
workOrderId: ev.workOrderId ?? null,
|
||||
sku: skuResolved,
|
||||
durationSec,
|
||||
costMachine,
|
||||
costOperator,
|
||||
costEnergy,
|
||||
costScrap,
|
||||
costRawMaterial,
|
||||
costTotal,
|
||||
currency: key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const currencySummaries = Array.from(summaries.values()).map((summary) => {
|
||||
const byDay = Array.from(summary.byDay.values()).sort((a, b) => {
|
||||
return String(a.day).localeCompare(String(b.day));
|
||||
});
|
||||
return { currency: summary.currency, totals: summary.totals, byDay };
|
||||
});
|
||||
|
||||
return {
|
||||
range: { start, end },
|
||||
currencySummaries,
|
||||
eventsEvaluated: events.length,
|
||||
eventsIncluded,
|
||||
events: detailed,
|
||||
filters: { machineId, location, sku, currency },
|
||||
};
|
||||
}
|
||||
130
lib/i18n/en.json
130
lib/i18n/en.json
@@ -13,6 +13,7 @@
|
||||
"nav.machines": "Machines",
|
||||
"nav.reports": "Reports",
|
||||
"nav.alerts": "Alerts",
|
||||
"nav.financial": "Financial",
|
||||
"nav.settings": "Settings",
|
||||
"sidebar.productTitle": "MIS",
|
||||
"sidebar.productSubtitle": "Control Tower",
|
||||
@@ -220,7 +221,7 @@
|
||||
"reports.qualitySummary": "Quality Summary",
|
||||
"reports.notes": "Notes for Ops",
|
||||
"alerts.title": "Alerts",
|
||||
"alerts.subtitle": "Escalation policies, channels, and contacts.",
|
||||
"alerts.subtitle": "Alert history with filters and drilldowns.",
|
||||
"alerts.comingSoon": "Alert configuration UI is coming soon.",
|
||||
"alerts.loading": "Loading alerts...",
|
||||
"alerts.error.loadPolicy": "Failed to load alert policy.",
|
||||
@@ -271,6 +272,53 @@
|
||||
"alerts.contacts.role.admin": "Admin",
|
||||
"alerts.contacts.role.owner": "Owner",
|
||||
"alerts.contacts.readOnly": "You can view contacts, but only owners can add or edit.",
|
||||
"alerts.inbox.title": "Alerts Inbox",
|
||||
"alerts.inbox.loading": "Loading alerts...",
|
||||
"alerts.inbox.loadingFilters": "Loading filters...",
|
||||
"alerts.inbox.empty": "No alerts found.",
|
||||
"alerts.inbox.error": "Failed to load alerts.",
|
||||
"alerts.inbox.range.24h": "Last 24 hours",
|
||||
"alerts.inbox.range.7d": "Last 7 days",
|
||||
"alerts.inbox.range.30d": "Last 30 days",
|
||||
"alerts.inbox.range.custom": "Custom",
|
||||
"alerts.inbox.filters.title": "Filters",
|
||||
"alerts.inbox.filters.range": "Range",
|
||||
"alerts.inbox.filters.start": "Start",
|
||||
"alerts.inbox.filters.end": "End",
|
||||
"alerts.inbox.filters.machine": "Machine",
|
||||
"alerts.inbox.filters.site": "Site",
|
||||
"alerts.inbox.filters.shift": "Shift",
|
||||
"alerts.inbox.filters.type": "Classification",
|
||||
"alerts.inbox.filters.severity": "Severity",
|
||||
"alerts.inbox.filters.status": "Status",
|
||||
"alerts.inbox.filters.search": "Search",
|
||||
"alerts.inbox.filters.searchPlaceholder": "Title, description, machine...",
|
||||
"alerts.inbox.filters.includeUpdates": "Include updates",
|
||||
"alerts.inbox.filters.allMachines": "All machines",
|
||||
"alerts.inbox.filters.allSites": "All sites",
|
||||
"alerts.inbox.filters.allShifts": "All shifts",
|
||||
"alerts.inbox.filters.allTypes": "All types",
|
||||
"alerts.inbox.filters.allSeverities": "All severities",
|
||||
"alerts.inbox.filters.allStatuses": "All statuses",
|
||||
"alerts.inbox.table.time": "Time",
|
||||
"alerts.inbox.table.machine": "Machine",
|
||||
"alerts.inbox.table.site": "Site",
|
||||
"alerts.inbox.table.shift": "Shift",
|
||||
"alerts.inbox.table.type": "Type",
|
||||
"alerts.inbox.table.severity": "Severity",
|
||||
"alerts.inbox.table.status": "Status",
|
||||
"alerts.inbox.table.duration": "Duration",
|
||||
"alerts.inbox.table.title": "Title",
|
||||
"alerts.inbox.table.unknown": "Unknown",
|
||||
"alerts.inbox.status.active": "Active",
|
||||
"alerts.inbox.status.resolved": "Resolved",
|
||||
"alerts.inbox.status.unknown": "Unknown",
|
||||
"alerts.inbox.duration.na": "n/a",
|
||||
"alerts.inbox.duration.sec": "s",
|
||||
"alerts.inbox.duration.min": " min",
|
||||
"alerts.inbox.duration.hr": " h",
|
||||
"alerts.inbox.meta.workOrder": "WO",
|
||||
"alerts.inbox.meta.sku": "SKU",
|
||||
"reports.notes.suggested": "Suggested actions",
|
||||
"reports.notes.none": "No insights yet. Generate reports after data collection.",
|
||||
"reports.noTrend": "No trend data yet.",
|
||||
@@ -307,8 +355,14 @@
|
||||
"reports.pdf.cycleDistribution": "Cycle Time Distribution",
|
||||
"reports.pdf.notes": "Notes for Ops",
|
||||
"reports.pdf.none": "None",
|
||||
"settings.title": "Settings",
|
||||
"settings.subtitle": "Live configuration for shifts, alerts, and defaults.",
|
||||
"settings.title": "Settings",
|
||||
"settings.subtitle": "Live configuration for shifts, alerts, and defaults.",
|
||||
"settings.tabs.general": "General",
|
||||
"settings.tabs.shifts": "Shifts",
|
||||
"settings.tabs.thresholds": "Thresholds",
|
||||
"settings.tabs.alerts": "Alerts",
|
||||
"settings.tabs.financial": "Financial",
|
||||
"settings.tabs.team": "Team",
|
||||
"settings.loading": "Loading settings...",
|
||||
"settings.loadingTeam": "Loading team...",
|
||||
"settings.refresh": "Refresh",
|
||||
@@ -388,8 +442,68 @@
|
||||
"settings.role.admin": "Admin",
|
||||
"settings.role.member": "Member",
|
||||
"settings.role.inactive": "Inactive",
|
||||
"settings.integrations": "Integrations",
|
||||
"settings.integrations.webhook": "Webhook URL",
|
||||
"settings.integrations.erp": "ERP Sync",
|
||||
"settings.integrations.erpNotConfigured": "Not configured"
|
||||
}
|
||||
"settings.integrations": "Integrations",
|
||||
"settings.integrations.webhook": "Webhook URL",
|
||||
"settings.integrations.erp": "ERP Sync",
|
||||
"settings.integrations.erpNotConfigured": "Not configured",
|
||||
"financial.title": "Financial Impact",
|
||||
"financial.subtitle": "Translate downtime, slow cycles, and scrap into money.",
|
||||
"financial.ownerOnly": "Financial impact is available only to owners.",
|
||||
"financial.costsMoved": "Cost settings are now in",
|
||||
"financial.costsMovedLink": "Settings -> Financial",
|
||||
"financial.export.html": "HTML",
|
||||
"financial.export.csv": "CSV",
|
||||
"financial.totalLoss": "Total Loss",
|
||||
"financial.currencyLabel": "Currency: {currency}",
|
||||
"financial.noImpact": "No impact data yet.",
|
||||
"financial.chart.title": "Lost Money Over Time",
|
||||
"financial.chart.subtitle": "Stacked by event type",
|
||||
"financial.range.day": "Day",
|
||||
"financial.range.week": "Week",
|
||||
"financial.range.month": "Month",
|
||||
"financial.filters.title": "Filters",
|
||||
"financial.filters.machine": "Machine",
|
||||
"financial.filters.location": "Location",
|
||||
"financial.filters.sku": "SKU",
|
||||
"financial.filters.currency": "Currency",
|
||||
"financial.filters.allMachines": "All machines",
|
||||
"financial.filters.allLocations": "All locations",
|
||||
"financial.filters.skuPlaceholder": "Filter by SKU",
|
||||
"financial.filters.currencyPlaceholder": "MXN",
|
||||
"financial.loadingMachines": "Loading machines...",
|
||||
"financial.config.title": "Cost Parameters",
|
||||
"financial.config.subtitle": "Defaults apply to all machines unless overridden.",
|
||||
"financial.config.applyOrg": "Apply org defaults to all machines",
|
||||
"financial.config.save": "Save",
|
||||
"financial.config.saving": "Saving...",
|
||||
"financial.config.saved": "Saved",
|
||||
"financial.config.saveFailed": "Save failed",
|
||||
"financial.config.orgDefaults": "Org Defaults",
|
||||
"financial.config.locationOverrides": "Location Overrides",
|
||||
"financial.config.machineOverrides": "Machine Overrides",
|
||||
"financial.config.productOverrides": "Product Overrides",
|
||||
"financial.config.addLocation": "Add location override",
|
||||
"financial.config.addMachine": "Add machine override",
|
||||
"financial.config.addProduct": "Add product override",
|
||||
"financial.config.noneLocation": "No location overrides yet.",
|
||||
"financial.config.noneMachine": "No machine overrides yet.",
|
||||
"financial.config.noneProduct": "No product overrides yet.",
|
||||
"financial.config.location": "Location",
|
||||
"financial.config.selectLocation": "Select location",
|
||||
"financial.config.machine": "Machine",
|
||||
"financial.config.selectMachine": "Select machine",
|
||||
"financial.config.currency": "Currency",
|
||||
"financial.config.sku": "SKU",
|
||||
"financial.config.rawMaterialUnit": "Raw material / unit",
|
||||
"financial.config.ownerOnly": "Financial cost settings are available only to owners.",
|
||||
"financial.config.loading": "Loading financials...",
|
||||
"financial.field.machineCostPerMin": "Machine cost / min",
|
||||
"financial.field.operatorCostPerMin": "Operator cost / min",
|
||||
"financial.field.ratedRunningKw": "Running kW",
|
||||
"financial.field.idleKw": "Idle kW",
|
||||
"financial.field.kwhRate": "kWh rate",
|
||||
"financial.field.energyMultiplier": "Energy multiplier",
|
||||
"financial.field.energyCostPerMin": "Energy cost / min",
|
||||
"financial.field.scrapCostPerUnit": "Scrap cost / unit",
|
||||
"financial.field.rawMaterialCostPerUnit": "Raw material / unit"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"nav.machines": "Máquinas",
|
||||
"nav.reports": "Reportes",
|
||||
"nav.alerts": "Alertas",
|
||||
"nav.financial": "Finanzas",
|
||||
"nav.settings": "Configuración",
|
||||
"sidebar.productTitle": "MIS",
|
||||
"sidebar.productSubtitle": "Control Tower",
|
||||
@@ -220,7 +221,7 @@
|
||||
"reports.qualitySummary": "Resumen de calidad",
|
||||
"reports.notes": "Notas para operaciones",
|
||||
"alerts.title": "Alertas",
|
||||
"alerts.subtitle": "Politicas de escalamiento, canales y contactos.",
|
||||
"alerts.subtitle": "Historial de alertas con filtros y detalle.",
|
||||
"alerts.comingSoon": "La configuracion de alertas estara disponible pronto.",
|
||||
"alerts.loading": "Cargando alertas...",
|
||||
"alerts.error.loadPolicy": "No se pudo cargar la politica de alertas.",
|
||||
@@ -271,6 +272,53 @@
|
||||
"alerts.contacts.role.admin": "Admin",
|
||||
"alerts.contacts.role.owner": "Propietario",
|
||||
"alerts.contacts.readOnly": "Puedes ver contactos, pero solo propietarios pueden agregar o editar.",
|
||||
"alerts.inbox.title": "Bandeja de alertas",
|
||||
"alerts.inbox.loading": "Cargando alertas...",
|
||||
"alerts.inbox.loadingFilters": "Cargando filtros...",
|
||||
"alerts.inbox.empty": "No se encontraron alertas.",
|
||||
"alerts.inbox.error": "No se pudieron cargar las alertas.",
|
||||
"alerts.inbox.range.24h": "Últimas 24 horas",
|
||||
"alerts.inbox.range.7d": "Últimos 7 días",
|
||||
"alerts.inbox.range.30d": "Últimos 30 días",
|
||||
"alerts.inbox.range.custom": "Personalizado",
|
||||
"alerts.inbox.filters.title": "Filtros",
|
||||
"alerts.inbox.filters.range": "Rango",
|
||||
"alerts.inbox.filters.start": "Inicio",
|
||||
"alerts.inbox.filters.end": "Fin",
|
||||
"alerts.inbox.filters.machine": "Máquina",
|
||||
"alerts.inbox.filters.site": "Sitio",
|
||||
"alerts.inbox.filters.shift": "Turno",
|
||||
"alerts.inbox.filters.type": "Clasificación",
|
||||
"alerts.inbox.filters.severity": "Severidad",
|
||||
"alerts.inbox.filters.status": "Estado",
|
||||
"alerts.inbox.filters.search": "Buscar",
|
||||
"alerts.inbox.filters.searchPlaceholder": "Título, descripción, máquina...",
|
||||
"alerts.inbox.filters.includeUpdates": "Incluir actualizaciones",
|
||||
"alerts.inbox.filters.allMachines": "Todas las máquinas",
|
||||
"alerts.inbox.filters.allSites": "Todos los sitios",
|
||||
"alerts.inbox.filters.allShifts": "Todos los turnos",
|
||||
"alerts.inbox.filters.allTypes": "Todas las clasificaciones",
|
||||
"alerts.inbox.filters.allSeverities": "Todas las severidades",
|
||||
"alerts.inbox.filters.allStatuses": "Todos los estados",
|
||||
"alerts.inbox.table.time": "Hora",
|
||||
"alerts.inbox.table.machine": "Máquina",
|
||||
"alerts.inbox.table.site": "Sitio",
|
||||
"alerts.inbox.table.shift": "Turno",
|
||||
"alerts.inbox.table.type": "Tipo",
|
||||
"alerts.inbox.table.severity": "Severidad",
|
||||
"alerts.inbox.table.status": "Estado",
|
||||
"alerts.inbox.table.duration": "Duración",
|
||||
"alerts.inbox.table.title": "Título",
|
||||
"alerts.inbox.table.unknown": "Sin dato",
|
||||
"alerts.inbox.status.active": "Activa",
|
||||
"alerts.inbox.status.resolved": "Resuelta",
|
||||
"alerts.inbox.status.unknown": "Sin dato",
|
||||
"alerts.inbox.duration.na": "n/d",
|
||||
"alerts.inbox.duration.sec": "s",
|
||||
"alerts.inbox.duration.min": " min",
|
||||
"alerts.inbox.duration.hr": " h",
|
||||
"alerts.inbox.meta.workOrder": "OT",
|
||||
"alerts.inbox.meta.sku": "SKU",
|
||||
"reports.notes.suggested": "Acciones sugeridas",
|
||||
"reports.notes.none": "Sin insights todavía. Genera reportes tras recolectar datos.",
|
||||
"reports.noTrend": "Sin datos de tendencia.",
|
||||
@@ -307,8 +355,14 @@
|
||||
"reports.pdf.cycleDistribution": "Distribución de tiempos de ciclo",
|
||||
"reports.pdf.notes": "Notas para operaciones",
|
||||
"reports.pdf.none": "Ninguna",
|
||||
"settings.title": "Configuración",
|
||||
"settings.subtitle": "Configuración en vivo para turnos, alertas y valores predeterminados.",
|
||||
"settings.title": "Configuración",
|
||||
"settings.subtitle": "Configuración en vivo para turnos, alertas y valores predeterminados.",
|
||||
"settings.tabs.general": "General",
|
||||
"settings.tabs.shifts": "Turnos",
|
||||
"settings.tabs.thresholds": "Umbrales",
|
||||
"settings.tabs.alerts": "Alertas",
|
||||
"settings.tabs.financial": "Finanzas",
|
||||
"settings.tabs.team": "Equipo",
|
||||
"settings.loading": "Cargando configuración...",
|
||||
"settings.loadingTeam": "Cargando equipo...",
|
||||
"settings.refresh": "Actualizar",
|
||||
@@ -388,8 +442,68 @@
|
||||
"settings.role.admin": "Admin",
|
||||
"settings.role.member": "Miembro",
|
||||
"settings.role.inactive": "Inactivo",
|
||||
"settings.integrations": "Integraciones",
|
||||
"settings.integrations.webhook": "Webhook URL",
|
||||
"settings.integrations.erp": "ERP Sync",
|
||||
"settings.integrations.erpNotConfigured": "No configurado"
|
||||
}
|
||||
"settings.integrations": "Integraciones",
|
||||
"settings.integrations.webhook": "Webhook URL",
|
||||
"settings.integrations.erp": "ERP Sync",
|
||||
"settings.integrations.erpNotConfigured": "No configurado",
|
||||
"financial.title": "Impacto financiero",
|
||||
"financial.subtitle": "Convierte paros, ciclos lentos y scrap en dinero.",
|
||||
"financial.ownerOnly": "El impacto financiero solo está disponible para propietarios.",
|
||||
"financial.costsMoved": "Los costos ahora están en",
|
||||
"financial.costsMovedLink": "Configuración -> Finanzas",
|
||||
"financial.export.html": "HTML",
|
||||
"financial.export.csv": "CSV",
|
||||
"financial.totalLoss": "Pérdida total",
|
||||
"financial.currencyLabel": "Moneda: {currency}",
|
||||
"financial.noImpact": "Sin datos de impacto.",
|
||||
"financial.chart.title": "Pérdida de dinero en el tiempo",
|
||||
"financial.chart.subtitle": "Acumulado por tipo de evento",
|
||||
"financial.range.day": "Día",
|
||||
"financial.range.week": "Semana",
|
||||
"financial.range.month": "Mes",
|
||||
"financial.filters.title": "Filtros",
|
||||
"financial.filters.machine": "Máquina",
|
||||
"financial.filters.location": "Ubicación",
|
||||
"financial.filters.sku": "SKU",
|
||||
"financial.filters.currency": "Moneda",
|
||||
"financial.filters.allMachines": "Todas las máquinas",
|
||||
"financial.filters.allLocations": "Todas las ubicaciones",
|
||||
"financial.filters.skuPlaceholder": "Filtrar por SKU",
|
||||
"financial.filters.currencyPlaceholder": "MXN",
|
||||
"financial.loadingMachines": "Cargando máquinas...",
|
||||
"financial.config.title": "Parámetros de costo",
|
||||
"financial.config.subtitle": "Los valores aplican a todas las máquinas salvo override.",
|
||||
"financial.config.applyOrg": "Aplicar valores de organización a todas",
|
||||
"financial.config.save": "Guardar",
|
||||
"financial.config.saving": "Guardando...",
|
||||
"financial.config.saved": "Guardado",
|
||||
"financial.config.saveFailed": "No se pudo guardar",
|
||||
"financial.config.orgDefaults": "Valores de organización",
|
||||
"financial.config.locationOverrides": "Overrides por ubicación",
|
||||
"financial.config.machineOverrides": "Overrides por máquina",
|
||||
"financial.config.productOverrides": "Overrides por producto",
|
||||
"financial.config.addLocation": "Agregar override de ubicación",
|
||||
"financial.config.addMachine": "Agregar override de máquina",
|
||||
"financial.config.addProduct": "Agregar override de producto",
|
||||
"financial.config.noneLocation": "Sin overrides de ubicación.",
|
||||
"financial.config.noneMachine": "Sin overrides de máquina.",
|
||||
"financial.config.noneProduct": "Sin overrides de producto.",
|
||||
"financial.config.location": "Ubicación",
|
||||
"financial.config.selectLocation": "Selecciona ubicación",
|
||||
"financial.config.machine": "Máquina",
|
||||
"financial.config.selectMachine": "Selecciona máquina",
|
||||
"financial.config.currency": "Moneda",
|
||||
"financial.config.sku": "SKU",
|
||||
"financial.config.rawMaterialUnit": "Materia prima / unidad",
|
||||
"financial.config.ownerOnly": "Los costos financieros solo están disponibles para propietarios.",
|
||||
"financial.config.loading": "Cargando finanzas...",
|
||||
"financial.field.machineCostPerMin": "Costo máquina / min",
|
||||
"financial.field.operatorCostPerMin": "Costo operador / min",
|
||||
"financial.field.ratedRunningKw": "kW en operación",
|
||||
"financial.field.idleKw": "kW en espera",
|
||||
"financial.field.kwhRate": "Tarifa kWh",
|
||||
"financial.field.energyMultiplier": "Multiplicador de energía",
|
||||
"financial.field.energyCostPerMin": "Costo energía / min",
|
||||
"financial.field.scrapCostPerUnit": "Costo scrap / unidad",
|
||||
"financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path";
|
||||
|
||||
const LOG_PATH = process.env.LOG_FILE || "/tmp/mis-control-tower.log";
|
||||
|
||||
export function logLine(event: string, data: Record<string, any> = {}) {
|
||||
export function logLine(event: string, data: Record<string, unknown> = {}) {
|
||||
const line = JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
event,
|
||||
|
||||
193
lib/overview/getOverviewData.ts
Normal file
193
lib/overview/getOverviewData.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { normalizeEvent } from "@/lib/events/normalizeEvent";
|
||||
|
||||
const ALLOWED_TYPES = new Set([
|
||||
"slow-cycle",
|
||||
"microstop",
|
||||
"macrostop",
|
||||
"offline",
|
||||
"error",
|
||||
"oee-drop",
|
||||
"quality-spike",
|
||||
"performance-degradation",
|
||||
"predictive-oee-decline",
|
||||
"alert-delivery-failed",
|
||||
]);
|
||||
|
||||
type OrgSettings = {
|
||||
stoppageMultiplier?: number | null;
|
||||
macroStoppageMultiplier?: number | null;
|
||||
};
|
||||
|
||||
type OverviewParams = {
|
||||
orgId: string;
|
||||
eventsMode?: string;
|
||||
eventsWindowSec?: number;
|
||||
eventMachines?: number;
|
||||
orgSettings?: OrgSettings | null;
|
||||
};
|
||||
|
||||
function heartbeatTime(hb?: { ts?: Date | null; tsServer?: Date | null } | null) {
|
||||
return hb?.tsServer ?? hb?.ts ?? null;
|
||||
}
|
||||
|
||||
export async function getOverviewData({
|
||||
orgId,
|
||||
eventsMode = "critical",
|
||||
eventsWindowSec = 21600,
|
||||
eventMachines = 6,
|
||||
orgSettings,
|
||||
}: OverviewParams) {
|
||||
const machines = await prisma.machine.findMany({
|
||||
where: { 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 machineRows = machines.map((m) => ({
|
||||
...m,
|
||||
latestHeartbeat: m.heartbeats[0] ?? null,
|
||||
latestKpi: m.kpiSnapshots[0] ?? null,
|
||||
heartbeats: undefined,
|
||||
kpiSnapshots: undefined,
|
||||
}));
|
||||
|
||||
const safeEventMachines = Number.isFinite(eventMachines) ? Math.max(1, Math.floor(eventMachines)) : 6;
|
||||
const safeWindowSec = Number.isFinite(eventsWindowSec) ? eventsWindowSec : 21600;
|
||||
|
||||
const topMachines = machineRows
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const at = heartbeatTime(a.latestHeartbeat);
|
||||
const bt = heartbeatTime(b.latestHeartbeat);
|
||||
const atMs = at ? at.getTime() : 0;
|
||||
const btMs = bt ? bt.getTime() : 0;
|
||||
return btMs - atMs;
|
||||
})
|
||||
.slice(0, safeEventMachines);
|
||||
|
||||
const targetIds = topMachines.map((m) => m.id);
|
||||
|
||||
let events = [] as Array<{
|
||||
id: string;
|
||||
ts: Date | null;
|
||||
topic: string;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
requiresAck: boolean;
|
||||
workOrderId?: string | null;
|
||||
machineId: string;
|
||||
machineName?: string | null;
|
||||
source: "ingested";
|
||||
}>;
|
||||
|
||||
if (targetIds.length) {
|
||||
let settings = orgSettings ?? null;
|
||||
if (!settings) {
|
||||
settings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId },
|
||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
|
||||
});
|
||||
}
|
||||
|
||||
const microMultiplier = Number(settings?.stoppageMultiplier ?? 1.5);
|
||||
const macroMultiplier = Math.max(microMultiplier, Number(settings?.macroStoppageMultiplier ?? 5));
|
||||
const windowStart = new Date(Date.now() - Math.max(0, safeWindowSec) * 1000);
|
||||
|
||||
const rawEvents = await prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
machineId: { in: targetIds },
|
||||
ts: { gte: windowStart },
|
||||
},
|
||||
orderBy: { ts: "desc" },
|
||||
take: Math.min(300, Math.max(60, targetIds.length * 40)),
|
||||
select: {
|
||||
id: true,
|
||||
ts: true,
|
||||
topic: true,
|
||||
eventType: true,
|
||||
severity: true,
|
||||
title: true,
|
||||
description: true,
|
||||
requiresAck: true,
|
||||
data: true,
|
||||
workOrderId: true,
|
||||
machineId: true,
|
||||
machine: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const normalized = rawEvents
|
||||
.map((row) => ({
|
||||
...normalizeEvent(row, { microMultiplier, macroMultiplier }),
|
||||
machineId: row.machineId,
|
||||
machineName: row.machine?.name ?? null,
|
||||
source: "ingested" as const,
|
||||
}))
|
||||
.filter((event) => event.ts);
|
||||
|
||||
const allowed = normalized.filter((event) => ALLOWED_TYPES.has(event.eventType));
|
||||
const isCritical = (event: (typeof allowed)[number]) => {
|
||||
const severity = String(event.severity ?? "").toLowerCase();
|
||||
return (
|
||||
event.eventType === "macrostop" ||
|
||||
event.requiresAck === true ||
|
||||
severity === "critical" ||
|
||||
severity === "error" ||
|
||||
severity === "high"
|
||||
);
|
||||
};
|
||||
|
||||
const filtered = eventsMode === "critical" ? allowed.filter(isCritical) : allowed;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const deduped = filtered.filter((event) => {
|
||||
const key = `${event.machineId}-${event.eventType}-${event.ts ?? ""}-${event.title}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
deduped.sort((a, b) => {
|
||||
const at = a.ts ? a.ts.getTime() : 0;
|
||||
const bt = b.ts ? b.ts.getTime() : 0;
|
||||
return bt - at;
|
||||
});
|
||||
|
||||
events = deduped.slice(0, 30);
|
||||
}
|
||||
|
||||
return { machines: machineRows, events };
|
||||
}
|
||||
33
lib/prismaJson.ts
Normal file
33
lib/prismaJson.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function toJsonValue(value: unknown): Prisma.InputJsonValue {
|
||||
if (value === null || value === undefined) {
|
||||
return Prisma.JsonNull as unknown as Prisma.InputJsonValue;
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => (item === undefined ? (Prisma.JsonNull as unknown as Prisma.InputJsonValue) : toJsonValue(item)));
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
const out: Record<string, Prisma.InputJsonValue> = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (val === undefined) continue;
|
||||
out[key] = toJsonValue(val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function toNullableJsonValue(
|
||||
value: unknown
|
||||
): Prisma.NullableJsonNullValueInput | Prisma.InputJsonValue {
|
||||
if (value === null || value === undefined) return Prisma.DbNull;
|
||||
return toJsonValue(value);
|
||||
}
|
||||
@@ -18,23 +18,48 @@ export const DEFAULT_SHIFT = {
|
||||
end: "15:00",
|
||||
};
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
function isPlainObject(value: any): value is AnyRecord {
|
||||
function isPlainObject(value: unknown): value is AnyRecord {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function normalizeAlerts(raw: any) {
|
||||
export function normalizeAlerts(raw: unknown) {
|
||||
if (!isPlainObject(raw)) return { ...DEFAULT_ALERTS };
|
||||
return { ...DEFAULT_ALERTS, ...raw };
|
||||
}
|
||||
|
||||
export function normalizeDefaults(raw: any) {
|
||||
export function normalizeDefaults(raw: unknown) {
|
||||
if (!isPlainObject(raw)) return { ...DEFAULT_DEFAULTS };
|
||||
return { ...DEFAULT_DEFAULTS, ...raw };
|
||||
}
|
||||
|
||||
export function buildSettingsPayload(settings: any, shifts: any[]) {
|
||||
type SettingsRow = {
|
||||
orgId: string;
|
||||
version: number;
|
||||
timezone: string;
|
||||
shiftChangeCompMin?: number | null;
|
||||
lunchBreakMin?: number | null;
|
||||
stoppageMultiplier?: number | null;
|
||||
macroStoppageMultiplier?: number | null;
|
||||
oeeAlertThresholdPct?: number | null;
|
||||
performanceThresholdPct?: number | null;
|
||||
qualitySpikeDeltaPct?: number | null;
|
||||
alertsJson?: unknown;
|
||||
defaultsJson?: unknown;
|
||||
updatedAt?: Date | string | null;
|
||||
updatedBy?: string | null;
|
||||
};
|
||||
|
||||
type ShiftRow = {
|
||||
name?: string | null;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
enabled?: boolean | null;
|
||||
sortOrder?: number | null;
|
||||
};
|
||||
|
||||
export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[]) {
|
||||
const ordered = [...(shifts ?? [])].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
const mappedShifts = ordered.map((s, idx) => ({
|
||||
name: s.name || `Shift ${idx + 1}`,
|
||||
@@ -66,7 +91,7 @@ export function buildSettingsPayload(settings: any, shifts: any[]) {
|
||||
};
|
||||
}
|
||||
|
||||
export function deepMerge(base: any, override: any): any {
|
||||
export function deepMerge(base: unknown, override: unknown): unknown {
|
||||
if (!isPlainObject(base) || !isPlainObject(override)) return override;
|
||||
const out: AnyRecord = { ...base };
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
@@ -80,7 +105,7 @@ export function deepMerge(base: any, override: any): any {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function applyOverridePatch(existing: any, patch: any) {
|
||||
export function applyOverridePatch(existing: unknown, patch: unknown) {
|
||||
const base: AnyRecord = isPlainObject(existing) ? { ...existing } : {};
|
||||
if (!isPlainObject(patch)) return base;
|
||||
|
||||
@@ -106,18 +131,29 @@ export function applyOverridePatch(existing: any, patch: any) {
|
||||
return base;
|
||||
}
|
||||
|
||||
export function validateShiftSchedule(shifts: any[]) {
|
||||
type NormalizedShift = {
|
||||
name: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type ShiftValidationResult = NormalizedShift | { error: string };
|
||||
|
||||
export function validateShiftSchedule(shifts: unknown) {
|
||||
if (!Array.isArray(shifts)) return { ok: false, error: "shifts must be an array" };
|
||||
if (shifts.length > 3) return { ok: false, error: "shifts max is 3" };
|
||||
|
||||
const normalized = shifts.map((raw, idx) => {
|
||||
const start = String(raw?.start ?? "").trim();
|
||||
const end = String(raw?.end ?? "").trim();
|
||||
const normalized: ShiftValidationResult[] = shifts.map((raw, idx) => {
|
||||
const record = isPlainObject(raw) ? raw : {};
|
||||
const start = String(record.start ?? "").trim();
|
||||
const end = String(record.end ?? "").trim();
|
||||
if (!TIME_RE.test(start) || !TIME_RE.test(end)) {
|
||||
return { error: `shift ${idx + 1} start/end must be HH:mm` };
|
||||
}
|
||||
const name = String(raw?.name ?? `Shift ${idx + 1}`).trim() || `Shift ${idx + 1}`;
|
||||
const enabled = raw?.enabled !== false;
|
||||
const name = String(record.name ?? `Shift ${idx + 1}`).trim() || `Shift ${idx + 1}`;
|
||||
const enabled = record.enabled !== false;
|
||||
return {
|
||||
name,
|
||||
startTime: start,
|
||||
@@ -127,13 +163,13 @@ export function validateShiftSchedule(shifts: any[]) {
|
||||
};
|
||||
});
|
||||
|
||||
const firstError = normalized.find((s: any) => s?.error);
|
||||
const firstError = normalized.find((s): s is { error: string } => "error" in s);
|
||||
if (firstError) return { ok: false, error: firstError.error };
|
||||
|
||||
return { ok: true, shifts: normalized as any[] };
|
||||
return { ok: true, shifts: normalized as NormalizedShift[] };
|
||||
}
|
||||
|
||||
export function validateShiftFields(shiftChangeCompensationMin?: any, lunchBreakMin?: any) {
|
||||
export function validateShiftFields(shiftChangeCompensationMin?: unknown, lunchBreakMin?: unknown) {
|
||||
if (shiftChangeCompensationMin != null) {
|
||||
const v = Number(shiftChangeCompensationMin);
|
||||
if (!Number.isFinite(v) || v < 0 || v > 480) {
|
||||
@@ -149,7 +185,7 @@ export function validateShiftFields(shiftChangeCompensationMin?: any, lunchBreak
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function validateThresholds(thresholds: any) {
|
||||
export function validateThresholds(thresholds: unknown) {
|
||||
if (!isPlainObject(thresholds)) return { ok: true };
|
||||
|
||||
const stoppage = thresholds.stoppageMultiplier;
|
||||
@@ -195,7 +231,7 @@ export function validateThresholds(thresholds: any) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function validateDefaults(defaults: any) {
|
||||
export function validateDefaults(defaults: unknown) {
|
||||
if (!isPlainObject(defaults)) return { ok: true };
|
||||
|
||||
const moldTotal = defaults.moldTotal != null ? Number(defaults.moldTotal) : null;
|
||||
@@ -216,7 +252,7 @@ export function validateDefaults(defaults: any) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function pickUpdateValue(input: any) {
|
||||
export function pickUpdateValue(input: unknown) {
|
||||
return input === undefined ? undefined : input;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ type SmsPayload = {
|
||||
body: string;
|
||||
};
|
||||
|
||||
export async function sendSms(_payload: SmsPayload) {
|
||||
export async function sendSms(payload: SmsPayload) {
|
||||
void payload;
|
||||
throw new Error("SMS not configured");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user