almost_done
This commit is contained in:
@@ -183,7 +183,7 @@
|
||||
"recap.banner.offline": "No signal for {min} min",
|
||||
"recap.banner.ongoingStop": "Machine stopped for {min} min",
|
||||
"recap.banner.stopped": "Machine stopped for {minutes} min",
|
||||
"recap.timeline.title": "24h timeline",
|
||||
"recap.timeline.title": "Timeline",
|
||||
"recap.timeline.noData": "No timeline data",
|
||||
"recap.timeline.type.production": "Production",
|
||||
"recap.timeline.type.moldChange": "Mold change",
|
||||
@@ -255,6 +255,9 @@
|
||||
"machine.detail.bucket.unknown": "Unknown",
|
||||
"machine.detail.activity.title": "Machine Activity Timeline",
|
||||
"machine.detail.activity.subtitle": "Real-time analysis of production cycles",
|
||||
"machine.detail.activity.windowBadge": "1h",
|
||||
"machine.detail.activity.windowModalTitle": "Timeline window",
|
||||
"machine.detail.activity.windowModalBody": "This timeline always shows the last 1 hour of machine activity.",
|
||||
"machine.detail.activity.noData": "No timeline data yet.",
|
||||
"machine.detail.tooltip.cycle": "Cycle: {label}",
|
||||
"machine.detail.tooltip.duration": "Duration",
|
||||
@@ -621,5 +624,12 @@
|
||||
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
|
||||
"settings.modules.screenless.title": "Screenless mode",
|
||||
"settings.modules.screenless.helper": "Hide the Downtime module from navigation (for plants without Node-RED reason capture).",
|
||||
"settings.modules.note": "This setting is org-wide."
|
||||
"settings.modules.note": "This setting is org-wide.",
|
||||
"overview.attention.offline": "Offline — no heartbeat",
|
||||
"overview.attention.stopped": "Currently stopped",
|
||||
"overview.attention.oeeCritical": "OEE critical: {value}%",
|
||||
"overview.attention.oeeLow": "OEE low: {value}%",
|
||||
"overview.attention.scrapHigh": "Scrap rate high: {value}%",
|
||||
"overview.attention.scrapMod": "Scrap rate elevated: {value}%",
|
||||
"overview.attention.availLow": "Availability low: {value}%"
|
||||
}
|
||||
|
||||
@@ -104,6 +104,13 @@
|
||||
"overview.event.macrostop": "macroparo",
|
||||
"overview.event.microstop": "microparo",
|
||||
"overview.event.slow-cycle": "ciclo lento",
|
||||
"overview.attention.offline": "Sin señal",
|
||||
"overview.attention.stopped": "Detenida ahora",
|
||||
"overview.attention.oeeCritical": "OEE crítica: {value}%",
|
||||
"overview.attention.oeeLow": "OEE baja: {value}%",
|
||||
"overview.attention.scrapHigh": "Scrap alto: {value}%",
|
||||
"overview.attention.scrapMod": "Scrap elevado: {value}%",
|
||||
"overview.attention.availLow": "Disponibilidad baja: {value}%",
|
||||
"overview.status.offline": "FUERA DE LÍNEA",
|
||||
"overview.status.online": "EN LÍNEA",
|
||||
"overview.recap.title": "Resumen diario de turno",
|
||||
@@ -183,7 +190,7 @@
|
||||
"recap.banner.offline": "Sin señal hace {min} min",
|
||||
"recap.banner.ongoingStop": "Máquina detenida hace {min} min",
|
||||
"recap.banner.stopped": "Máquina detenida hace {minutes} min",
|
||||
"recap.timeline.title": "Timeline 24h",
|
||||
"recap.timeline.title": "Timeline",
|
||||
"recap.timeline.noData": "Sin datos de línea de tiempo",
|
||||
"recap.timeline.type.production": "Producción",
|
||||
"recap.timeline.type.moldChange": "Cambio de molde",
|
||||
@@ -255,6 +262,9 @@
|
||||
"machine.detail.bucket.unknown": "Desconocido",
|
||||
"machine.detail.activity.title": "Línea de tiempo de actividad",
|
||||
"machine.detail.activity.subtitle": "Análisis en tiempo real de ciclos de producción",
|
||||
"machine.detail.activity.windowBadge": "1h",
|
||||
"machine.detail.activity.windowModalTitle": "Ventana de timeline",
|
||||
"machine.detail.activity.windowModalBody": "Este timeline siempre muestra la última 1 hora de actividad de la máquina.",
|
||||
"machine.detail.activity.noData": "Sin datos de línea de tiempo.",
|
||||
"machine.detail.tooltip.cycle": "Ciclo: {label}",
|
||||
"machine.detail.tooltip.duration": "Duración",
|
||||
|
||||
@@ -230,45 +230,6 @@ function moldStartMs(data: unknown, fallbackTs: Date) {
|
||||
return Math.trunc(safeNum(inner.start_ms) ?? safeNum(inner.startMs) ?? fallbackTs.getTime());
|
||||
}
|
||||
|
||||
type WorkOrderCounterRow = {
|
||||
machineId: string;
|
||||
workOrderId: string;
|
||||
sku: string | null;
|
||||
targetQty: number | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
goodParts: number;
|
||||
scrapParts: number;
|
||||
cycleCount: number;
|
||||
};
|
||||
|
||||
async function loadWorkOrderCounterRows(params: {
|
||||
orgId: string;
|
||||
machineIds: string[];
|
||||
}) {
|
||||
if (!params.machineIds.length) return [] as WorkOrderCounterRow[];
|
||||
|
||||
return prisma.machineWorkOrder.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: { in: params.machineIds },
|
||||
},
|
||||
select: {
|
||||
machineId: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
targetQty: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
goodParts: true,
|
||||
scrapParts: true,
|
||||
cycleCount: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function parseRecapQuery(input: {
|
||||
machineId?: string | null;
|
||||
start?: string | null;
|
||||
@@ -306,7 +267,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
|
||||
const machineIds = machines.map((m) => m.id);
|
||||
const moldStartLookback = new Date(params.end.getTime() - MOLD_LOOKBACK_MS);
|
||||
const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, workOrderCounterRowsRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] =
|
||||
const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] =
|
||||
await Promise.all([
|
||||
prisma.orgSettings.findUnique({
|
||||
where: { orgId: params.orgId },
|
||||
@@ -401,10 +362,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
loadWorkOrderCounterRows({
|
||||
orgId: params.orgId,
|
||||
machineIds,
|
||||
}),
|
||||
prisma.machineHeartbeat.findMany({
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
@@ -473,7 +430,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
const eventsByMachine = new Map<string, typeof events>();
|
||||
const reasonsByMachine = new Map<string, typeof reasons>();
|
||||
const workOrdersByMachine = new Map<string, typeof workOrdersRaw>();
|
||||
const workOrderCountersByMachine = new Map<string, WorkOrderCounterRow[]>();
|
||||
const hbRangeByMachine = new Map<string, typeof hbRange>();
|
||||
const hbLatestByMachine = new Map(hbLatestRaw.map((row) => [row.machineId, row]));
|
||||
const moldEventsByMachine = new Map<string, typeof moldEventsRaw>();
|
||||
@@ -508,12 +464,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
workOrdersByMachine.set(row.machineId, list);
|
||||
}
|
||||
|
||||
for (const row of workOrderCounterRowsRaw ?? []) {
|
||||
const list = workOrderCountersByMachine.get(row.machineId) ?? [];
|
||||
list.push(row);
|
||||
workOrderCountersByMachine.set(row.machineId, list);
|
||||
}
|
||||
|
||||
for (const row of hbRange) {
|
||||
const list = hbRangeByMachine.get(row.machineId) ?? [];
|
||||
list.push(row);
|
||||
@@ -532,7 +482,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
const machineEvents = eventsByMachine.get(machine.id) ?? [];
|
||||
const machineReasons = reasonsByMachine.get(machine.id) ?? [];
|
||||
const machineWorkOrders = workOrdersByMachine.get(machine.id) ?? [];
|
||||
const machineWorkOrderCounters = workOrderCountersByMachine.get(machine.id) ?? [];
|
||||
const machineHbRange = hbRangeByMachine.get(machine.id) ?? [];
|
||||
const latestHb = hbLatestByMachine.get(machine.id) ?? null;
|
||||
const machineMoldEvents = moldEventsByMachine.get(machine.id) ?? [];
|
||||
@@ -599,7 +548,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
const openWorkOrders = machineWorkOrdersSorted.filter(
|
||||
(wo) => String(wo.status).toUpperCase() !== "COMPLETED"
|
||||
);
|
||||
const authoritativeWorkOrderProgress = new Map<
|
||||
const rangeWorkOrderProgress = new Map<
|
||||
string,
|
||||
{ goodParts: number; scrapParts: number; cycleCount: number; firstTs: Date | null; lastTs: Date | null }
|
||||
>();
|
||||
@@ -639,58 +588,45 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
return created;
|
||||
};
|
||||
|
||||
for (const row of machineWorkOrderCounters) {
|
||||
const safeGood = Math.max(0, Math.trunc(safeNum(row.goodParts) ?? 0));
|
||||
const safeScrap = Math.max(0, Math.trunc(safeNum(row.scrapParts) ?? 0));
|
||||
const safeCycleCount = Math.max(0, Math.trunc(safeNum(row.cycleCount) ?? 0));
|
||||
const target = safeNum(row.targetQty);
|
||||
|
||||
const skuAgg = ensureAuthoritativeSku(row.sku, target, false);
|
||||
skuAgg.good += safeGood;
|
||||
skuAgg.scrap += safeScrap;
|
||||
|
||||
goodParts += safeGood;
|
||||
scrapParts += safeScrap;
|
||||
authoritativeCycleCount += safeCycleCount;
|
||||
|
||||
const woKey = workOrderKey(row.workOrderId);
|
||||
if (!woKey) continue;
|
||||
const progress = authoritativeWorkOrderProgress.get(woKey) ?? {
|
||||
goodParts: 0,
|
||||
scrapParts: 0,
|
||||
cycleCount: 0,
|
||||
firstTs: null,
|
||||
lastTs: null,
|
||||
};
|
||||
progress.goodParts += safeGood;
|
||||
progress.scrapParts += safeScrap;
|
||||
progress.cycleCount += safeCycleCount;
|
||||
if (!progress.firstTs || row.createdAt < progress.firstTs) progress.firstTs = row.createdAt;
|
||||
if (!progress.lastTs || row.updatedAt > progress.lastTs) progress.lastTs = row.updatedAt;
|
||||
authoritativeWorkOrderProgress.set(woKey, progress);
|
||||
}
|
||||
|
||||
for (const wo of openWorkOrders) {
|
||||
ensureAuthoritativeSku(normalizeToken(wo.sku) || null);
|
||||
}
|
||||
if (latestTelemetry?.sku) {
|
||||
ensureAuthoritativeSku(latestTelemetry.sku);
|
||||
}
|
||||
|
||||
const bySku = [...authoritativeSkuMap.values()]
|
||||
.map((row) => {
|
||||
const target = row.target ?? targetBySku.get(skuKey(row.sku))?.target ?? null;
|
||||
const produced = row.good + row.scrap;
|
||||
const progressPct = target && target > 0 ? round2((produced / target) * 100) : null;
|
||||
return {
|
||||
machineName: row.machineName,
|
||||
sku: row.sku,
|
||||
good: row.good,
|
||||
scrap: row.scrap,
|
||||
target,
|
||||
progressPct,
|
||||
for (const cycle of dedupedCycles) {
|
||||
const skuRaw = normalizeToken(cycle.sku);
|
||||
const g = Math.max(0, Math.trunc(safeNum(cycle.goodDelta) ?? 0));
|
||||
const s = Math.max(0, Math.trunc(safeNum(cycle.scrapDelta) ?? 0));
|
||||
const woKey = workOrderKey(cycle.workOrderId);
|
||||
authoritativeCycleCount += 1;
|
||||
if (g === 0 && s === 0) continue;
|
||||
goodParts += g;
|
||||
scrapParts += s;
|
||||
if (woKey) {
|
||||
const progress = rangeWorkOrderProgress.get(woKey) ?? {
|
||||
goodParts: 0,
|
||||
scrapParts: 0,
|
||||
cycleCount: 0,
|
||||
firstTs: null,
|
||||
lastTs: null,
|
||||
};
|
||||
})
|
||||
progress.goodParts += g;
|
||||
progress.scrapParts += s;
|
||||
progress.cycleCount += 1;
|
||||
if (!progress.firstTs || cycle.ts < progress.firstTs) progress.firstTs = cycle.ts;
|
||||
if (!progress.lastTs || cycle.ts > progress.lastTs) progress.lastTs = cycle.ts;
|
||||
rangeWorkOrderProgress.set(woKey, progress);
|
||||
}
|
||||
if (!skuRaw) continue;
|
||||
const skuAgg = ensureAuthoritativeSku(skuRaw, null, true);
|
||||
skuAgg.good += g;
|
||||
skuAgg.scrap += s;
|
||||
}
|
||||
|
||||
const bySku = [...authoritativeSkuMap.values()]
|
||||
.map((row) => ({
|
||||
machineName: row.machineName,
|
||||
sku: row.sku,
|
||||
good: row.good,
|
||||
scrap: row.scrap,
|
||||
target: null as number | null,
|
||||
progressPct: null as number | null,
|
||||
}))
|
||||
.sort((a, b) => b.good - a.good);
|
||||
|
||||
const sortedKpis = [...dedupedKpis].sort((a, b) => a.ts.getTime() - b.ts.getTime());
|
||||
@@ -762,7 +698,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
.filter((wo) => String(wo.status).toUpperCase() === "COMPLETED")
|
||||
.filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end)
|
||||
.map((wo) => {
|
||||
const progress = authoritativeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? {
|
||||
const progress = rangeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? {
|
||||
goodParts: 0,
|
||||
scrapParts: 0,
|
||||
cycleCount: 0,
|
||||
@@ -801,19 +737,15 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
let activeProgressPct: number | null = null;
|
||||
let activeStartedAt: string | null = null;
|
||||
if (activeWorkOrderId) {
|
||||
const authoritativeProgress = activeWorkOrderKey
|
||||
? authoritativeWorkOrderProgress.get(activeWorkOrderKey) ?? null
|
||||
: null;
|
||||
const producedForProgress = authoritativeProgress
|
||||
? authoritativeProgress.goodParts + authoritativeProgress.scrapParts
|
||||
const rangeProgress = activeWorkOrderKey ? rangeWorkOrderProgress.get(activeWorkOrderKey) ?? null : null;
|
||||
const producedForProgress = rangeProgress
|
||||
? rangeProgress.goodParts + rangeProgress.scrapParts
|
||||
: 0;
|
||||
const targetQty = safeNum(activeTargetSource?.targetQty);
|
||||
if (targetQty && targetQty > 0) {
|
||||
activeProgressPct = round2((producedForProgress / targetQty) * 100);
|
||||
}
|
||||
activeStartedAt = toIso(
|
||||
authoritativeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null
|
||||
);
|
||||
activeStartedAt = toIso(rangeProgress?.firstTs ?? latestTelemetry?.ts ?? null);
|
||||
}
|
||||
|
||||
const firstProductionMsAfterMoldStart = (startMs: number) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
* Client-safe recap thresholds. Kept in sync with OFFLINE logic in lib/recap/redesign.ts.
|
||||
*/
|
||||
export const RECAP_HEARTBEAT_STALE_MS = 10 * 60 * 1000;
|
||||
export const RECAP_HEARTBEAT_STALE_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -28,6 +28,7 @@ type DetailRangeInput = {
|
||||
|
||||
const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
|
||||
const RECAP_CACHE_TTL_SEC = 60;
|
||||
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||
const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
|
||||
@@ -213,7 +214,10 @@ async function loadTimelineRowsForMachines(params: {
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: { in: params.machineIds },
|
||||
ts: { gte: params.start, lte: params.end },
|
||||
ts: {
|
||||
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||
lte: params.end,
|
||||
},
|
||||
},
|
||||
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||
select: {
|
||||
@@ -338,7 +342,7 @@ async function computeRecapSummary(params: { orgId: string; hours: number }) {
|
||||
segments,
|
||||
rangeStart: start,
|
||||
rangeEnd: end,
|
||||
maxSegments: 30,
|
||||
maxSegments: 60,
|
||||
});
|
||||
|
||||
return toSummaryMachine({
|
||||
@@ -443,21 +447,25 @@ async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
|
||||
minutes: startMin % 60,
|
||||
timeZone,
|
||||
});
|
||||
const end = zonedToUtcDate({
|
||||
const shiftEndUtc = zonedToUtcDate({
|
||||
...endDate,
|
||||
hours: Math.floor(endMin / 60),
|
||||
minutes: endMin % 60,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
if (end <= start) continue;
|
||||
if (shiftEndUtc <= start) continue;
|
||||
|
||||
// Cap end at "now" so we render shift-so-far, not shift-as-planned.
|
||||
// Without cap:
|
||||
// - timeline fills future minutes with idle (visual lie)
|
||||
// - offline calc = (shift_end_future - last_seen) = looks 5h offline
|
||||
// even on a machine producing right now
|
||||
const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;
|
||||
|
||||
return {
|
||||
hasEnabledShifts: true,
|
||||
range: {
|
||||
start,
|
||||
end,
|
||||
},
|
||||
range: { start, end },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -751,22 +751,24 @@ export function compressTimelineSegments(input: {
|
||||
const bucketEnd = i === maxSegments - 1 ? rangeEndMs : Math.trunc(rangeStartMs + (i + 1) * bucketMs);
|
||||
if (bucketEnd <= bucketStart) continue;
|
||||
|
||||
let winner: RecapTimelineSegment | null = null;
|
||||
let winnerOverlap = -1;
|
||||
let winner: RecapTimelineSegment | null = null;
|
||||
let winnerPriority = -1;
|
||||
let winnerOverlap = -1;
|
||||
|
||||
for (const segment of normalized) {
|
||||
const overlapStart = Math.max(bucketStart, segment.startMs);
|
||||
const overlapEnd = Math.min(bucketEnd, segment.endMs);
|
||||
if (overlapEnd <= overlapStart) continue;
|
||||
for (const segment of normalized) {
|
||||
const overlapStart = Math.max(bucketStart, segment.startMs);
|
||||
const overlapEnd = Math.min(bucketEnd, segment.endMs);
|
||||
if (overlapEnd <= overlapStart) continue;
|
||||
|
||||
const overlap = overlapEnd - overlapStart;
|
||||
const priorityBonus = segmentPriority(segment.type) / 1000;
|
||||
const score = overlap + priorityBonus;
|
||||
if (score > winnerOverlap) {
|
||||
winner = segment;
|
||||
winnerOverlap = score;
|
||||
}
|
||||
}
|
||||
const overlap = overlapEnd - overlapStart;
|
||||
const priority = segmentPriority(segment.type);
|
||||
|
||||
if (priority > winnerPriority || (priority === winnerPriority && overlap > winnerOverlap)) {
|
||||
winner = segment;
|
||||
winnerPriority = priority;
|
||||
winnerOverlap = overlap;
|
||||
}
|
||||
}
|
||||
|
||||
if (!winner) {
|
||||
buckets.push({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { RecapTimelineResponse } from "@/lib/recap/types";
|
||||
|
||||
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
|
||||
const DEFAULT_RANGE_MS = 24 * 60 * 60 * 1000;
|
||||
const MIN_RANGE_MS = 60 * 1000;
|
||||
const MAX_RANGE_MS = 72 * 60 * 60 * 1000;
|
||||
@@ -94,7 +95,10 @@ export async function getRecapTimelineForMachine(params: {
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
ts: { gte: params.start, lte: params.end },
|
||||
ts: {
|
||||
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||
lte: params.end,
|
||||
},
|
||||
},
|
||||
orderBy: { ts: "asc" },
|
||||
select: {
|
||||
@@ -126,7 +130,10 @@ export async function getRecapTimelineForMachine(params: {
|
||||
where: {
|
||||
orgId: params.orgId,
|
||||
machineId: params.machineId,
|
||||
ts: { gte: params.start, lte: params.end },
|
||||
ts: {
|
||||
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||
lte: params.end,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.machineEvent.count({
|
||||
|
||||
Reference in New Issue
Block a user