changes:
This commit is contained in:
@@ -122,7 +122,7 @@ const TOL = 0.10;
|
|||||||
const DEFAULT_MICRO_MULT = 1.5;
|
const DEFAULT_MICRO_MULT = 1.5;
|
||||||
const DEFAULT_MACRO_MULT = 5;
|
const DEFAULT_MACRO_MULT = 5;
|
||||||
const NORMAL_TOL_SEC = 0.1;
|
const NORMAL_TOL_SEC = 0.1;
|
||||||
const LIVE_REFRESH_MS = 5000;
|
const LIVE_REFRESH_MS = 15000;
|
||||||
|
|
||||||
const BUCKET = {
|
const BUCKET = {
|
||||||
normal: {
|
normal: {
|
||||||
@@ -686,17 +686,34 @@ export default function MachineDetailClient() {
|
|||||||
function MachineActivityTimeline({ machineId }: { machineId: string | undefined }) {
|
function MachineActivityTimeline({ machineId }: { machineId: string | undefined }) {
|
||||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||||
const [timelineLoading, setTimelineLoading] = useState(true);
|
const [timelineLoading, setTimelineLoading] = useState(true);
|
||||||
|
const timelineHashRef = useRef("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!machineId) return;
|
if (!machineId) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
timelineHashRef.current = "";
|
||||||
|
setTimeline(null);
|
||||||
|
setTimelineLoading(true);
|
||||||
|
|
||||||
async function loadTimeline() {
|
async function loadTimeline() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/recap/${machineId}/timeline?range=1h`, { cache: "no-store" });
|
const res = await fetch(`/api/recap/${machineId}/timeline?range=1h`, { cache: "no-store" });
|
||||||
const json = await res.json().catch(() => null);
|
const json = await res.json().catch(() => null);
|
||||||
if (!alive || !res.ok || !json) return;
|
if (!alive || !res.ok || !json) return;
|
||||||
setTimeline(json as RecapTimelineResponse);
|
const nextTimeline = json as RecapTimelineResponse;
|
||||||
|
const nextHash = JSON.stringify({
|
||||||
|
start: nextTimeline.range?.start ?? "",
|
||||||
|
end: nextTimeline.range?.end ?? "",
|
||||||
|
hasData: nextTimeline.hasData,
|
||||||
|
segments: nextTimeline.segments.map((segment) => ({
|
||||||
|
type: segment.type,
|
||||||
|
startMs: segment.startMs,
|
||||||
|
endMs: segment.endMs,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (timelineHashRef.current === nextHash) return;
|
||||||
|
timelineHashRef.current = nextHash;
|
||||||
|
setTimeline(nextTimeline);
|
||||||
} finally {
|
} finally {
|
||||||
if (alive) setTimelineLoading(false);
|
if (alive) setTimelineLoading(false);
|
||||||
}
|
}
|
||||||
@@ -1065,7 +1082,7 @@ export default function MachineDetailClient() {
|
|||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
<div className="text-xs text-zinc-400">OEE</div>
|
<div className="text-xs text-zinc-400">{t("machine.detail.kpi.oeeCurrent")}</div>
|
||||||
{kpi?.oee == null || Number.isNaN(kpi.oee) ? (
|
{kpi?.oee == null || Number.isNaN(kpi.oee) ? (
|
||||||
<div className="mt-2 text-3xl font-bold text-zinc-400">—</div>
|
<div className="mt-2 text-3xl font-bold text-zinc-400">—</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -27,6 +27,29 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toFiniteNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFiniteInt(value: unknown): number | null {
|
||||||
|
const parsed = toFiniteNumber(value);
|
||||||
|
if (parsed == null) return null;
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFirstNumber(...values: unknown[]) {
|
||||||
|
for (const value of values) {
|
||||||
|
const parsed = toFiniteNumber(value);
|
||||||
|
if (parsed != null) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function readPath(root: unknown, path: string[]): unknown {
|
function readPath(root: unknown, path: string[]): unknown {
|
||||||
let current = root;
|
let current = root;
|
||||||
for (const key of path) {
|
for (const key of path) {
|
||||||
@@ -160,28 +183,37 @@ export async function POST(req: Request) {
|
|||||||
orgId = machine.orgId;
|
orgId = machine.orgId;
|
||||||
|
|
||||||
const woRecord = (body.activeWorkOrder ?? {}) as Record<string, unknown>;
|
const woRecord = (body.activeWorkOrder ?? {}) as Record<string, unknown>;
|
||||||
const good =
|
const activeWorkOrderId = woRecord.id != null ? String(woRecord.id).trim() : "";
|
||||||
typeof woRecord.good === "number"
|
const activeSku = woRecord.sku != null ? String(woRecord.sku).trim() : "";
|
||||||
? woRecord.good
|
const activeStatus = woRecord.status != null ? String(woRecord.status).trim() : "";
|
||||||
: typeof woRecord.goodParts === "number"
|
const activeTargetQty = toFiniteInt(woRecord.target);
|
||||||
? woRecord.goodParts
|
const activeCycleTime = toFiniteNumber(woRecord.cycleTime);
|
||||||
: typeof woRecord.good_parts === "number"
|
const good = pickFirstNumber(woRecord.good, woRecord.goodParts, woRecord.good_parts);
|
||||||
? woRecord.good_parts
|
const scrap = pickFirstNumber(woRecord.scrap, woRecord.scrapParts, woRecord.scrap_parts);
|
||||||
: null;
|
const activeGoodParts = Math.max(0, Math.trunc(good ?? 0));
|
||||||
const scrap =
|
const activeScrapParts = Math.max(0, Math.trunc(scrap ?? 0));
|
||||||
typeof woRecord.scrap === "number"
|
const activeCycleCount = Math.max(
|
||||||
? woRecord.scrap
|
0,
|
||||||
: typeof woRecord.scrapParts === "number"
|
toFiniteInt(woRecord.cycleCount ?? woRecord.cycle_count ?? body.cycle_count) ?? 0
|
||||||
? woRecord.scrapParts
|
);
|
||||||
: typeof woRecord.scrap_parts === "number"
|
const snapshotCycleCount =
|
||||||
? woRecord.scrap_parts
|
toFiniteInt(body.cycle_count) ??
|
||||||
: null;
|
toFiniteInt(woRecord.cycle_count) ??
|
||||||
|
toFiniteInt(woRecord.cycleCount);
|
||||||
|
const snapshotGoodParts =
|
||||||
|
toFiniteInt(body.good_parts) ??
|
||||||
|
toFiniteInt(woRecord.good_parts) ??
|
||||||
|
toFiniteInt(woRecord.goodParts);
|
||||||
|
const snapshotScrapParts =
|
||||||
|
toFiniteInt(body.scrap_parts) ??
|
||||||
|
toFiniteInt(woRecord.scrap_parts) ??
|
||||||
|
toFiniteInt(woRecord.scrapParts);
|
||||||
const k = body.kpis ?? {};
|
const k = body.kpis ?? {};
|
||||||
const safeCycleTime =
|
const safeCycleTime =
|
||||||
typeof body.cycleTime === "number" && body.cycleTime > 0
|
typeof body.cycleTime === "number" && body.cycleTime > 0
|
||||||
? body.cycleTime
|
? body.cycleTime
|
||||||
: typeof woRecord.cycleTime === "number" && woRecord.cycleTime > 0
|
: activeCycleTime != null && activeCycleTime > 0
|
||||||
? woRecord.cycleTime
|
? activeCycleTime
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const safeCavities =
|
const safeCavities =
|
||||||
@@ -202,16 +234,16 @@ export async function POST(req: Request) {
|
|||||||
ts: tsDeviceDate, // store device-time in ts; server-time goes to ts_server
|
ts: tsDeviceDate, // store device-time in ts; server-time goes to ts_server
|
||||||
|
|
||||||
// Work order fields
|
// Work order fields
|
||||||
workOrderId: woRecord.id != null ? String(woRecord.id) : null,
|
workOrderId: activeWorkOrderId || null,
|
||||||
sku: woRecord.sku != null ? String(woRecord.sku) : null,
|
sku: activeSku || null,
|
||||||
target: typeof woRecord.target === "number" ? Math.trunc(woRecord.target) : null,
|
target: activeTargetQty,
|
||||||
good: good != null ? Math.trunc(good) : null,
|
good: good != null ? Math.trunc(good) : null,
|
||||||
scrap: scrap != null ? Math.trunc(scrap) : null,
|
scrap: scrap != null ? Math.trunc(scrap) : null,
|
||||||
|
|
||||||
// Counters
|
// Counters
|
||||||
cycleCount: typeof body.cycle_count === "number" ? body.cycle_count : null,
|
cycleCount: snapshotCycleCount,
|
||||||
goodParts: typeof body.good_parts === "number" ? body.good_parts : null,
|
goodParts: snapshotGoodParts,
|
||||||
scrapParts: typeof body.scrap_parts === "number" ? body.scrap_parts : null,
|
scrapParts: snapshotScrapParts,
|
||||||
cavities: safeCavities,
|
cavities: safeCavities,
|
||||||
|
|
||||||
// Cycle times
|
// Cycle times
|
||||||
@@ -229,6 +261,38 @@ export async function POST(req: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (activeWorkOrderId) {
|
||||||
|
await prisma.machineWorkOrder.upsert({
|
||||||
|
where: {
|
||||||
|
machineId_workOrderId: {
|
||||||
|
machineId: machine.id,
|
||||||
|
workOrderId: activeWorkOrderId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
orgId: machine.orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
workOrderId: activeWorkOrderId,
|
||||||
|
sku: activeSku || null,
|
||||||
|
targetQty: activeTargetQty,
|
||||||
|
cycleTime: activeCycleTime,
|
||||||
|
status: activeStatus || "RUNNING",
|
||||||
|
goodParts: activeGoodParts,
|
||||||
|
scrapParts: activeScrapParts,
|
||||||
|
cycleCount: activeCycleCount,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
sku: activeSku || undefined,
|
||||||
|
targetQty: activeTargetQty ?? undefined,
|
||||||
|
cycleTime: activeCycleTime ?? undefined,
|
||||||
|
status: activeStatus || undefined,
|
||||||
|
goodParts: activeGoodParts,
|
||||||
|
scrapParts: activeScrapParts,
|
||||||
|
cycleCount: activeCycleCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Optional but useful: update machine "last seen" meta fields
|
// Optional but useful: update machine "last seen" meta fields
|
||||||
await prisma.machine.update({
|
await prisma.machine.update({
|
||||||
where: { id: machine.id },
|
where: { id: machine.id },
|
||||||
|
|||||||
106
fix.md
Normal file
106
fix.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
Root cause found — CT has no authoritative WO counters
|
||||||
|
The actual bug
|
||||||
|
Your Node-RED writes goodParts/scrapParts/cycleCount to the Pi's MariaDB work_orders table. That's local. It never gets written to CT's Postgres machine_work_orders table — which the Prisma model doesn't even declare those columns. Confirmed:
|
||||||
|
|
||||||
|
model MachineWorkOrder {
|
||||||
|
id, orgId, machineId, workOrderId, sku, targetQty, cycleTime, status, createdAt, updatedAt
|
||||||
|
// NO good_parts, scrap_parts, cycle_count
|
||||||
|
}
|
||||||
|
lib/recap/getRecapData.ts line 259 probes information_schema.columns for those columns → always finds zero → workOrderCountersAvailable = false → loadWorkOrderCounterRows returns null → falls back to KPI delta math.
|
||||||
|
|
||||||
|
The KPI delta math (line 700 area):
|
||||||
|
|
||||||
|
const rangeGood = Math.max(0, (agg.maxGood ?? 0) - (agg.minGood ?? agg.maxGood ?? 0));
|
||||||
|
Node-RED sends the cumulative activeWorkOrder.goodParts in every KPI snapshot (not delta). If the WO has 353 parts now and has been stable (mold change, no new parts) across the 24h window, min=maxGood=353 → rangeGood = 0. If there was a brief scrap entry or counter drift, you get rangeGood = 1. That's where your "1 good part" comes from.
|
||||||
|
|
||||||
|
Recap is structurally unable to match Node-RED until CT persists the authoritative counter.
|
||||||
|
|
||||||
|
Fix — 3 changes, in order
|
||||||
|
1. Add counter columns to CT schema
|
||||||
|
File: prisma/schema.prisma
|
||||||
|
|
||||||
|
model MachineWorkOrder {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
orgId String
|
||||||
|
machineId String
|
||||||
|
workOrderId String
|
||||||
|
sku String?
|
||||||
|
targetQty Int?
|
||||||
|
cycleTime Float?
|
||||||
|
status String @default("PENDING")
|
||||||
|
goodParts Int @default(0) @map("good_parts") // NEW
|
||||||
|
scrapParts Int @default(0) @map("scrap_parts") // NEW
|
||||||
|
cycleCount Int @default(0) @map("cycle_count") // NEW
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
// rest unchanged
|
||||||
|
}
|
||||||
|
Generate migration: npx prisma migrate dev --name add_wo_counters. Run on prod DB.
|
||||||
|
|
||||||
|
2. Have KPI ingest upsert the counters
|
||||||
|
File: app/api/ingest/kpi/route.ts
|
||||||
|
|
||||||
|
Each KPI payload from Node-RED contains:
|
||||||
|
|
||||||
|
"activeWorkOrder": { "id": "OTBM-002", "sku": "RAMBOX", "goodParts": 353, "scrapParts": 1, "cycleCount": 353 }
|
||||||
|
Inside the handler, after creating the MachineKpiSnapshot, add:
|
||||||
|
|
||||||
|
const awo = payload?.activeWorkOrder;
|
||||||
|
if (awo?.id) {
|
||||||
|
await prisma.machineWorkOrder.upsert({
|
||||||
|
where: { machineId_workOrderId: { machineId: machine.id, workOrderId: String(awo.id) } },
|
||||||
|
create: {
|
||||||
|
orgId: machine.orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
workOrderId: String(awo.id),
|
||||||
|
sku: awo.sku ?? null,
|
||||||
|
targetQty: Number(awo.target) || null,
|
||||||
|
cycleTime: Number(awo.cycleTime) || null,
|
||||||
|
status: awo.status ?? "RUNNING",
|
||||||
|
goodParts: Number(awo.goodParts) || 0,
|
||||||
|
scrapParts: Number(awo.scrapParts) || 0,
|
||||||
|
cycleCount: Number(awo.cycleCount) || 0,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
sku: awo.sku ?? undefined,
|
||||||
|
targetQty: Number(awo.target) || undefined,
|
||||||
|
cycleTime: Number(awo.cycleTime) || undefined,
|
||||||
|
status: awo.status ?? undefined,
|
||||||
|
goodParts: Number(awo.goodParts) || 0,
|
||||||
|
scrapParts: Number(awo.scrapParts) || 0,
|
||||||
|
cycleCount: Number(awo.cycleCount) || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
This makes CT's machine_work_orders rows track Pi's live state minute-by-minute.
|
||||||
|
|
||||||
|
3. Simplify recap aggregation
|
||||||
|
File: lib/recap/getRecapData.ts
|
||||||
|
|
||||||
|
Now that the columns exist, loadWorkOrderCounterRows will work. But also drop the updatedAt BETWEEN filter — it excludes WOs that haven't ticked recently (e.g. during mold change):
|
||||||
|
|
||||||
|
// REMOVE:
|
||||||
|
AND "updatedAt" >= ${params.start}
|
||||||
|
AND "updatedAt" <= ${params.end}
|
||||||
|
|
||||||
|
// KEEP only:
|
||||||
|
WHERE "orgId" = ${params.orgId}
|
||||||
|
AND "machineId" IN (${machineIdList})
|
||||||
|
Return all WOs for the machine; filter client-side or by another criterion if needed. For the "last 24h production" metric, sum goodParts across all WOs (simple, matches Home UI).
|
||||||
|
|
||||||
|
Also remove the whole KPI-delta fallback block (lines ~600-760) — don't need it anymore. The authoritative counter is always present once changes 1+2 are deployed.
|
||||||
|
|
||||||
|
Other issues you flagged
|
||||||
|
OEE 75% vs 47%: Recap uses time-weighted average across the window (24h including hours of stopped machine → pulls avg down). Machine detail shows a shorter-window or last-snapshot value. Decision: Recap avg is technically correct for "24h avg"; Machine detail's 75% is the "current instantaneous" OEE. Label them clearly: "OEE promedio 24h: 47%" vs "OEE actual: 75%". Don't make them the same number — they measure different things. Show both if you want.
|
||||||
|
|
||||||
|
Machine detail timeline flickering: probably a client useEffect dependency loop or a polling interval too short. Check app/(app)/machines/[machineId]/MachineDetailClient.tsx for a setInterval or SWR revalidation. Likely you're re-fetching every 2-3s and the data comes back with slightly different timestamps, causing re-render. Fix: increase poll to 15-30s and compare by segment hash before updating state.
|
||||||
|
|
||||||
|
"1 good part" bug: will self-fix once #1 and #2 are deployed (recap reads authoritative column instead of computing bad delta).
|
||||||
|
|
||||||
|
Deployment order
|
||||||
|
Merge schema migration to main. Run prisma migrate deploy in prod.
|
||||||
|
Ship KPI ingest change — Node-RED starts populating counters immediately.
|
||||||
|
Ship recap simplification — hits the now-populated columns.
|
||||||
|
Watch for ~5 min for CT to catch up (KPI ticks every minute from Pi).
|
||||||
|
Verify: SELECT work_order_id, good_parts, scrap_parts FROM machine_work_orders WHERE machine_id = '<uuid>' ORDER BY updated_at DESC LIMIT 5; — should match Home UI (353).
|
||||||
|
No Node-RED changes needed. Pi is already sending the right data; CT just wasn't storing it.
|
||||||
@@ -132,12 +132,12 @@
|
|||||||
"recap.shift.1": "Shift 1",
|
"recap.shift.1": "Shift 1",
|
||||||
"recap.shift.2": "Shift 2",
|
"recap.shift.2": "Shift 2",
|
||||||
"recap.shift.3": "Shift 3",
|
"recap.shift.3": "Shift 3",
|
||||||
"recap.kpi.oee": "OEE",
|
"recap.kpi.oee": "OEE Avg 24h",
|
||||||
"recap.kpi.noData": "No KPI data",
|
"recap.kpi.noData": "No KPI data",
|
||||||
"recap.kpi.good": "Good parts",
|
"recap.kpi.good": "Good parts",
|
||||||
"recap.kpi.stops": "Total stops (min)",
|
"recap.kpi.stops": "Total stops (min)",
|
||||||
"recap.kpi.scrap": "Scrap",
|
"recap.kpi.scrap": "Scrap",
|
||||||
"recap.card.oee": "OEE",
|
"recap.card.oee": "OEE Avg 24h",
|
||||||
"recap.card.good": "Good parts",
|
"recap.card.good": "Good parts",
|
||||||
"recap.card.scrap": "Scrap",
|
"recap.card.scrap": "Scrap",
|
||||||
"recap.card.stops": "Stops",
|
"recap.card.stops": "Stops",
|
||||||
@@ -252,6 +252,7 @@
|
|||||||
"machine.detail.tooltip.duration": "Duration",
|
"machine.detail.tooltip.duration": "Duration",
|
||||||
"machine.detail.tooltip.ideal": "Ideal",
|
"machine.detail.tooltip.ideal": "Ideal",
|
||||||
"machine.detail.tooltip.deviation": "Deviation",
|
"machine.detail.tooltip.deviation": "Deviation",
|
||||||
|
"machine.detail.kpi.oeeCurrent": "Current OEE",
|
||||||
"machine.detail.kpi.updated": "Updated {time}",
|
"machine.detail.kpi.updated": "Updated {time}",
|
||||||
"machine.detail.currentWorkOrder": "Current Work Order",
|
"machine.detail.currentWorkOrder": "Current Work Order",
|
||||||
"machine.detail.recentEvents": "Critical Events",
|
"machine.detail.recentEvents": "Critical Events",
|
||||||
|
|||||||
@@ -132,12 +132,12 @@
|
|||||||
"recap.shift.1": "Turno 1",
|
"recap.shift.1": "Turno 1",
|
||||||
"recap.shift.2": "Turno 2",
|
"recap.shift.2": "Turno 2",
|
||||||
"recap.shift.3": "Turno 3",
|
"recap.shift.3": "Turno 3",
|
||||||
"recap.kpi.oee": "OEE",
|
"recap.kpi.oee": "OEE promedio 24h",
|
||||||
"recap.kpi.noData": "Sin datos de KPI",
|
"recap.kpi.noData": "Sin datos de KPI",
|
||||||
"recap.kpi.good": "Buenas",
|
"recap.kpi.good": "Buenas",
|
||||||
"recap.kpi.stops": "Paros totales (min)",
|
"recap.kpi.stops": "Paros totales (min)",
|
||||||
"recap.kpi.scrap": "Scrap",
|
"recap.kpi.scrap": "Scrap",
|
||||||
"recap.card.oee": "OEE",
|
"recap.card.oee": "OEE promedio 24h",
|
||||||
"recap.card.good": "Piezas buenas",
|
"recap.card.good": "Piezas buenas",
|
||||||
"recap.card.scrap": "Scrap",
|
"recap.card.scrap": "Scrap",
|
||||||
"recap.card.stops": "Paros",
|
"recap.card.stops": "Paros",
|
||||||
@@ -252,6 +252,7 @@
|
|||||||
"machine.detail.tooltip.duration": "Duración",
|
"machine.detail.tooltip.duration": "Duración",
|
||||||
"machine.detail.tooltip.ideal": "Ideal",
|
"machine.detail.tooltip.ideal": "Ideal",
|
||||||
"machine.detail.tooltip.deviation": "Desviación",
|
"machine.detail.tooltip.deviation": "Desviación",
|
||||||
|
"machine.detail.kpi.oeeCurrent": "OEE actual",
|
||||||
"machine.detail.kpi.updated": "Actualizado {time}",
|
"machine.detail.kpi.updated": "Actualizado {time}",
|
||||||
"machine.detail.currentWorkOrder": "Orden de trabajo actual",
|
"machine.detail.currentWorkOrder": "Orden de trabajo actual",
|
||||||
"machine.detail.recentEvents": "Eventos críticos",
|
"machine.detail.recentEvents": "Eventos críticos",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { unstable_cache } from "next/cache";
|
import { unstable_cache } from "next/cache";
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
|
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
|
||||||
import type { RecapMachine, RecapQuery, RecapResponse } from "@/lib/recap/types";
|
import type { RecapMachine, RecapQuery, RecapResponse } from "@/lib/recap/types";
|
||||||
@@ -28,7 +27,6 @@ const STOP_TYPES = new Set(["microstop", "macrostop"]);
|
|||||||
const STOP_STATUS = new Set(["STOP", "DOWN", "OFFLINE"]);
|
const STOP_STATUS = new Set(["STOP", "DOWN", "OFFLINE"]);
|
||||||
const CACHE_TTL_SEC = 60;
|
const CACHE_TTL_SEC = 60;
|
||||||
const MOLD_LOOKBACK_MS = 14 * 24 * 60 * 60 * 1000;
|
const MOLD_LOOKBACK_MS = 14 * 24 * 60 * 60 * 1000;
|
||||||
let workOrderCountersAvailable: boolean | null = null;
|
|
||||||
|
|
||||||
function safeNum(value: unknown) {
|
function safeNum(value: unknown) {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
@@ -229,10 +227,6 @@ function moldStartMs(data: unknown, fallbackTs: Date) {
|
|||||||
return Math.trunc(safeNum(inner.start_ms) ?? safeNum(inner.startMs) ?? fallbackTs.getTime());
|
return Math.trunc(safeNum(inner.start_ms) ?? safeNum(inner.startMs) ?? fallbackTs.getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkOrderCounterColumnRow = {
|
|
||||||
column_name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WorkOrderCounterRow = {
|
type WorkOrderCounterRow = {
|
||||||
machineId: string;
|
machineId: string;
|
||||||
workOrderId: string;
|
workOrderId: string;
|
||||||
@@ -249,55 +243,27 @@ type WorkOrderCounterRow = {
|
|||||||
async function loadWorkOrderCounterRows(params: {
|
async function loadWorkOrderCounterRows(params: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
machineIds: string[];
|
machineIds: string[];
|
||||||
start: Date;
|
|
||||||
end: Date;
|
|
||||||
}) {
|
}) {
|
||||||
if (!params.machineIds.length) return [] as WorkOrderCounterRow[];
|
if (!params.machineIds.length) return [] as WorkOrderCounterRow[];
|
||||||
|
|
||||||
try {
|
return prisma.machineWorkOrder.findMany({
|
||||||
if (workOrderCountersAvailable == null) {
|
where: {
|
||||||
const columns = await prisma.$queryRaw<WorkOrderCounterColumnRow[]>`
|
orgId: params.orgId,
|
||||||
SELECT column_name
|
machineId: { in: params.machineIds },
|
||||||
FROM information_schema.columns
|
},
|
||||||
WHERE table_schema = 'public'
|
select: {
|
||||||
AND table_name = 'machine_work_orders'
|
machineId: true,
|
||||||
AND column_name IN ('good_parts', 'scrap_parts', 'cycle_count')
|
workOrderId: true,
|
||||||
`;
|
sku: true,
|
||||||
const availableColumns = new Set(columns.map((row) => row.column_name));
|
targetQty: true,
|
||||||
workOrderCountersAvailable =
|
status: true,
|
||||||
availableColumns.has("good_parts") &&
|
createdAt: true,
|
||||||
availableColumns.has("scrap_parts") &&
|
updatedAt: true,
|
||||||
availableColumns.has("cycle_count");
|
goodParts: true,
|
||||||
}
|
scrapParts: true,
|
||||||
|
cycleCount: true,
|
||||||
if (!workOrderCountersAvailable) {
|
},
|
||||||
return null;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const machineIdList = Prisma.join(params.machineIds.map((id) => Prisma.sql`${id}`));
|
|
||||||
const rows = await prisma.$queryRaw<WorkOrderCounterRow[]>(Prisma.sql`
|
|
||||||
SELECT
|
|
||||||
"machineId",
|
|
||||||
"workOrderId",
|
|
||||||
sku,
|
|
||||||
"targetQty",
|
|
||||||
status,
|
|
||||||
"createdAt",
|
|
||||||
"updatedAt",
|
|
||||||
COALESCE(good_parts, 0)::int AS "goodParts",
|
|
||||||
COALESCE(scrap_parts, 0)::int AS "scrapParts",
|
|
||||||
COALESCE(cycle_count, 0)::int AS "cycleCount"
|
|
||||||
FROM "machine_work_orders"
|
|
||||||
WHERE "orgId" = ${params.orgId}
|
|
||||||
AND "machineId" IN (${machineIdList})
|
|
||||||
AND "updatedAt" >= ${params.start}
|
|
||||||
AND "updatedAt" <= ${params.end}
|
|
||||||
`);
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseRecapQuery(input: {
|
export function parseRecapQuery(input: {
|
||||||
@@ -435,8 +401,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
loadWorkOrderCounterRows({
|
loadWorkOrderCounterRows({
|
||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
machineIds,
|
machineIds,
|
||||||
start: params.start,
|
|
||||||
end: params.end,
|
|
||||||
}),
|
}),
|
||||||
prisma.machineHeartbeat.findMany({
|
prisma.machineHeartbeat.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -605,41 +569,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
scrap: number;
|
scrap: number;
|
||||||
target: number | null;
|
target: number | null;
|
||||||
};
|
};
|
||||||
const skuMap = new Map<string, SkuAggregate>();
|
|
||||||
const rangeByWorkOrder = new Map<string, { goodParts: number; scrapParts: number; firstTs: Date | null; lastTs: Date | null }>();
|
|
||||||
const kpiLatestByWorkOrder = new Map<string, { good: number; scrap: number; ts: Date; sku: string | null }>();
|
|
||||||
let latestTelemetry: { ts: Date; workOrderId: string | null; sku: string | null } | null = null;
|
let latestTelemetry: { ts: Date; workOrderId: string | null; sku: string | null } | null = null;
|
||||||
let goodParts = 0;
|
|
||||||
let scrapParts = 0;
|
|
||||||
|
|
||||||
const ensureSkuRow = (skuInput: string | null) => {
|
|
||||||
const skuToken = normalizeToken(skuInput) || "N/A";
|
|
||||||
const key = skuKey(skuToken);
|
|
||||||
const existing = skuMap.get(key);
|
|
||||||
if (existing) return existing;
|
|
||||||
const target = targetBySku.get(key)?.target ?? null;
|
|
||||||
const created: SkuAggregate = {
|
|
||||||
machineName: machine.name,
|
|
||||||
sku: skuToken,
|
|
||||||
good: 0,
|
|
||||||
scrap: 0,
|
|
||||||
target,
|
|
||||||
};
|
|
||||||
skuMap.set(key, created);
|
|
||||||
return created;
|
|
||||||
};
|
|
||||||
|
|
||||||
type KpiRangeAggregate = {
|
|
||||||
workOrderId: string | null;
|
|
||||||
sku: string | null;
|
|
||||||
minGood: number | null;
|
|
||||||
maxGood: number | null;
|
|
||||||
minScrap: number | null;
|
|
||||||
maxScrap: number | null;
|
|
||||||
firstTs: Date | null;
|
|
||||||
lastTs: Date | null;
|
|
||||||
};
|
|
||||||
const kpiRanges = new Map<string, KpiRangeAggregate>();
|
|
||||||
|
|
||||||
for (const kpi of dedupedKpis) {
|
for (const kpi of dedupedKpis) {
|
||||||
if (!latestTelemetry || kpi.ts > latestTelemetry.ts) {
|
if (!latestTelemetry || kpi.ts > latestTelemetry.ts) {
|
||||||
@@ -649,79 +579,9 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
sku: normalizeToken(kpi.sku) || null,
|
sku: normalizeToken(kpi.sku) || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const workOrderId = normalizeToken(kpi.workOrderId) || null;
|
|
||||||
const sku = normalizeToken(kpi.sku) || null;
|
|
||||||
const goodCounterRaw = safeNum(kpi.goodParts) ?? safeNum(kpi.good);
|
|
||||||
const scrapCounterRaw = safeNum(kpi.scrapParts) ?? safeNum(kpi.scrap);
|
|
||||||
const goodCounter = goodCounterRaw != null ? Math.max(0, Math.trunc(goodCounterRaw)) : null;
|
|
||||||
const scrapCounter = scrapCounterRaw != null ? Math.max(0, Math.trunc(scrapCounterRaw)) : null;
|
|
||||||
|
|
||||||
const woKey = workOrderKey(workOrderId);
|
|
||||||
if (woKey) {
|
|
||||||
const existingLatest = kpiLatestByWorkOrder.get(woKey);
|
|
||||||
if (!existingLatest || kpi.ts > existingLatest.ts) {
|
|
||||||
kpiLatestByWorkOrder.set(woKey, {
|
|
||||||
good: goodCounter ?? 0,
|
|
||||||
scrap: scrapCounter ?? 0,
|
|
||||||
ts: kpi.ts,
|
|
||||||
sku,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((goodCounter == null && scrapCounter == null) || (!workOrderId && !sku)) continue;
|
|
||||||
|
|
||||||
const key = `${woKey || "__none"}::${skuKey(sku) || "__none"}`;
|
|
||||||
const current = kpiRanges.get(key) ?? {
|
|
||||||
workOrderId,
|
|
||||||
sku,
|
|
||||||
minGood: null,
|
|
||||||
maxGood: null,
|
|
||||||
minScrap: null,
|
|
||||||
maxScrap: null,
|
|
||||||
firstTs: null,
|
|
||||||
lastTs: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (goodCounter != null) {
|
|
||||||
current.minGood = current.minGood == null ? goodCounter : Math.min(current.minGood, goodCounter);
|
|
||||||
current.maxGood = current.maxGood == null ? goodCounter : Math.max(current.maxGood, goodCounter);
|
|
||||||
}
|
|
||||||
if (scrapCounter != null) {
|
|
||||||
current.minScrap = current.minScrap == null ? scrapCounter : Math.min(current.minScrap, scrapCounter);
|
|
||||||
current.maxScrap = current.maxScrap == null ? scrapCounter : Math.max(current.maxScrap, scrapCounter);
|
|
||||||
}
|
|
||||||
if (!current.firstTs || kpi.ts < current.firstTs) current.firstTs = kpi.ts;
|
|
||||||
if (!current.lastTs || kpi.ts > current.lastTs) current.lastTs = kpi.ts;
|
|
||||||
kpiRanges.set(key, current);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kpiRanges.size > 0) {
|
if (!latestTelemetry) {
|
||||||
for (const agg of kpiRanges.values()) {
|
|
||||||
const rangeGood = Math.max(0, (agg.maxGood ?? 0) - (agg.minGood ?? agg.maxGood ?? 0));
|
|
||||||
const rangeScrap = Math.max(0, (agg.maxScrap ?? 0) - (agg.minScrap ?? agg.maxScrap ?? 0));
|
|
||||||
const skuRow = ensureSkuRow(agg.sku);
|
|
||||||
skuRow.good += rangeGood;
|
|
||||||
skuRow.scrap += rangeScrap;
|
|
||||||
goodParts += rangeGood;
|
|
||||||
scrapParts += rangeScrap;
|
|
||||||
|
|
||||||
const woKey = workOrderKey(agg.workOrderId);
|
|
||||||
if (!woKey) continue;
|
|
||||||
const existing = rangeByWorkOrder.get(woKey) ?? {
|
|
||||||
goodParts: 0,
|
|
||||||
scrapParts: 0,
|
|
||||||
firstTs: null,
|
|
||||||
lastTs: null,
|
|
||||||
};
|
|
||||||
existing.goodParts += rangeGood;
|
|
||||||
existing.scrapParts += rangeScrap;
|
|
||||||
if (agg.firstTs && (!existing.firstTs || agg.firstTs < existing.firstTs)) existing.firstTs = agg.firstTs;
|
|
||||||
if (agg.lastTs && (!existing.lastTs || agg.lastTs > existing.lastTs)) existing.lastTs = agg.lastTs;
|
|
||||||
rangeByWorkOrder.set(woKey, existing);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const cycle of dedupedCycles) {
|
for (const cycle of dedupedCycles) {
|
||||||
if (!latestTelemetry || cycle.ts > latestTelemetry.ts) {
|
if (!latestTelemetry || cycle.ts > latestTelemetry.ts) {
|
||||||
latestTelemetry = {
|
latestTelemetry = {
|
||||||
@@ -730,95 +590,91 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
sku: normalizeToken(cycle.sku) || null,
|
sku: normalizeToken(cycle.sku) || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const skuRow = ensureSkuRow(normalizeToken(cycle.sku) || null);
|
|
||||||
const good = Math.max(0, Math.trunc(safeNum(cycle.goodDelta) ?? 0));
|
|
||||||
const scrap = Math.max(0, Math.trunc(safeNum(cycle.scrapDelta) ?? 0));
|
|
||||||
skuRow.good += good;
|
|
||||||
skuRow.scrap += scrap;
|
|
||||||
goodParts += good;
|
|
||||||
scrapParts += scrap;
|
|
||||||
|
|
||||||
const woKey = workOrderKey(cycle.workOrderId);
|
|
||||||
if (!woKey) continue;
|
|
||||||
const existing = rangeByWorkOrder.get(woKey) ?? {
|
|
||||||
goodParts: 0,
|
|
||||||
scrapParts: 0,
|
|
||||||
firstTs: null,
|
|
||||||
lastTs: null,
|
|
||||||
};
|
|
||||||
existing.goodParts += good;
|
|
||||||
existing.scrapParts += scrap;
|
|
||||||
if (!existing.firstTs || cycle.ts < existing.firstTs) existing.firstTs = cycle.ts;
|
|
||||||
if (!existing.lastTs || cycle.ts > existing.lastTs) existing.lastTs = cycle.ts;
|
|
||||||
rangeByWorkOrder.set(woKey, existing);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openWorkOrders = machineWorkOrdersSorted.filter(
|
const openWorkOrders = machineWorkOrdersSorted.filter(
|
||||||
(wo) => String(wo.status).toUpperCase() !== "COMPLETED"
|
(wo) => String(wo.status).toUpperCase() !== "COMPLETED"
|
||||||
);
|
);
|
||||||
for (const wo of openWorkOrders) {
|
|
||||||
ensureSkuRow(normalizeToken(wo.sku) || null);
|
|
||||||
}
|
|
||||||
if (latestTelemetry?.sku) ensureSkuRow(latestTelemetry.sku);
|
|
||||||
|
|
||||||
const hasAuthoritativeWorkOrderCounters = machineWorkOrderCounters.length > 0;
|
|
||||||
const authoritativeWorkOrderProgress = new Map<
|
const authoritativeWorkOrderProgress = new Map<
|
||||||
string,
|
string,
|
||||||
{ goodParts: number; scrapParts: number; cycleCount: number; firstTs: Date | null; lastTs: Date | null }
|
{ goodParts: number; scrapParts: number; cycleCount: number; firstTs: Date | null; lastTs: Date | null }
|
||||||
>();
|
>();
|
||||||
const authoritativeSkuMap = new Map<string, SkuAggregate>();
|
const authoritativeSkuMap = new Map<string, SkuAggregate>();
|
||||||
let authoritativeGoodParts = 0;
|
let goodParts = 0;
|
||||||
let authoritativeScrapParts = 0;
|
let scrapParts = 0;
|
||||||
let authoritativeCycleCount = 0;
|
let authoritativeCycleCount = 0;
|
||||||
|
|
||||||
if (hasAuthoritativeWorkOrderCounters) {
|
const ensureAuthoritativeSku = (
|
||||||
for (const row of machineWorkOrderCounters) {
|
skuInput: string | null,
|
||||||
const safeGood = Math.max(0, Math.trunc(safeNum(row.goodParts) ?? 0));
|
targetInput?: number | null,
|
||||||
const safeScrap = Math.max(0, Math.trunc(safeNum(row.scrapParts) ?? 0));
|
useFallbackTarget = true
|
||||||
const safeCycleCount = Math.max(0, Math.trunc(safeNum(row.cycleCount) ?? 0));
|
) => {
|
||||||
const skuToken = normalizeToken(row.sku) || "N/A";
|
const skuToken = normalizeToken(skuInput) || "N/A";
|
||||||
const skuTokenKey = skuKey(skuToken);
|
const skuTokenKey = skuKey(skuToken);
|
||||||
const target = safeNum(row.targetQty);
|
const targetFallback = useFallbackTarget ? targetBySku.get(skuTokenKey)?.target ?? null : null;
|
||||||
|
const explicitTarget =
|
||||||
const skuAgg = authoritativeSkuMap.get(skuTokenKey) ?? {
|
targetInput != null && targetInput > 0 ? Math.max(0, Math.trunc(targetInput)) : null;
|
||||||
machineName: machine.name,
|
const normalizedTarget = explicitTarget ?? targetFallback;
|
||||||
sku: skuToken,
|
const existing = authoritativeSkuMap.get(skuTokenKey);
|
||||||
good: 0,
|
if (existing) {
|
||||||
scrap: 0,
|
if (explicitTarget != null) {
|
||||||
target: target != null && target > 0 ? Math.max(0, Math.trunc(target)) : null,
|
existing.target = (existing.target ?? 0) + explicitTarget;
|
||||||
};
|
} else if (normalizedTarget != null && existing.target == null) {
|
||||||
skuAgg.good += safeGood;
|
existing.target = normalizedTarget;
|
||||||
skuAgg.scrap += safeScrap;
|
|
||||||
if (target != null && target > 0) {
|
|
||||||
skuAgg.target = (skuAgg.target ?? 0) + Math.max(0, Math.trunc(target));
|
|
||||||
}
|
}
|
||||||
authoritativeSkuMap.set(skuTokenKey, skuAgg);
|
return existing;
|
||||||
|
|
||||||
authoritativeGoodParts += safeGood;
|
|
||||||
authoritativeScrapParts += 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);
|
|
||||||
}
|
}
|
||||||
|
const created: SkuAggregate = {
|
||||||
|
machineName: machine.name,
|
||||||
|
sku: skuToken,
|
||||||
|
good: 0,
|
||||||
|
scrap: 0,
|
||||||
|
target: normalizedTarget,
|
||||||
|
};
|
||||||
|
authoritativeSkuMap.set(skuTokenKey, created);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackBySku = [...skuMap.values()]
|
for (const wo of openWorkOrders) {
|
||||||
|
ensureAuthoritativeSku(normalizeToken(wo.sku) || null);
|
||||||
|
}
|
||||||
|
if (latestTelemetry?.sku) {
|
||||||
|
ensureAuthoritativeSku(latestTelemetry.sku);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bySku = [...authoritativeSkuMap.values()]
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const target = row.target ?? targetBySku.get(skuKey(row.sku))?.target ?? null;
|
const target = row.target ?? targetBySku.get(skuKey(row.sku))?.target ?? null;
|
||||||
const produced = row.good + row.scrap;
|
const produced = row.good + row.scrap;
|
||||||
@@ -834,28 +690,6 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
})
|
})
|
||||||
.sort((a, b) => b.good - a.good);
|
.sort((a, b) => b.good - a.good);
|
||||||
|
|
||||||
const authoritativeBySku = [...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,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.good - a.good);
|
|
||||||
|
|
||||||
const bySku = hasAuthoritativeWorkOrderCounters ? authoritativeBySku : fallbackBySku;
|
|
||||||
if (hasAuthoritativeWorkOrderCounters) {
|
|
||||||
goodParts = authoritativeGoodParts;
|
|
||||||
scrapParts = authoritativeScrapParts;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedKpis = [...dedupedKpis].sort((a, b) => a.ts.getTime() - b.ts.getTime());
|
const sortedKpis = [...dedupedKpis].sort((a, b) => a.ts.getTime() - b.ts.getTime());
|
||||||
const weightedAvg = (field: "oee" | "availability" | "performance" | "quality") => {
|
const weightedAvg = (field: "oee" | "availability" | "performance" | "quality") => {
|
||||||
if (!sortedKpis.length) return null;
|
if (!sortedKpis.length) return null;
|
||||||
@@ -925,14 +759,13 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
.filter((wo) => String(wo.status).toUpperCase() === "COMPLETED")
|
.filter((wo) => String(wo.status).toUpperCase() === "COMPLETED")
|
||||||
.filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end)
|
.filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end)
|
||||||
.map((wo) => {
|
.map((wo) => {
|
||||||
const fallbackProgress = rangeByWorkOrder.get(workOrderKey(wo.workOrderId)) ?? {
|
const progress = authoritativeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? {
|
||||||
goodParts: 0,
|
goodParts: 0,
|
||||||
scrapParts: 0,
|
scrapParts: 0,
|
||||||
|
cycleCount: 0,
|
||||||
firstTs: null,
|
firstTs: null,
|
||||||
lastTs: null,
|
lastTs: null,
|
||||||
};
|
};
|
||||||
const authoritativeProgress = authoritativeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? null;
|
|
||||||
const progress = authoritativeProgress ?? fallbackProgress;
|
|
||||||
const durationHrs =
|
const durationHrs =
|
||||||
progress.firstTs && progress.lastTs
|
progress.firstTs && progress.lastTs
|
||||||
? round2((progress.lastTs.getTime() - progress.firstTs.getTime()) / 3600000)
|
? round2((progress.lastTs.getTime() - progress.firstTs.getTime()) / 3600000)
|
||||||
@@ -956,36 +789,27 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
const activeWorkOrderSku =
|
const activeWorkOrderSku =
|
||||||
normalizeToken(latestTelemetry?.sku) || normalizeToken(activeWo?.sku) || null;
|
normalizeToken(latestTelemetry?.sku) || normalizeToken(activeWo?.sku) || null;
|
||||||
const activeWorkOrderKey = workOrderKey(activeWorkOrderId);
|
const activeWorkOrderKey = workOrderKey(activeWorkOrderId);
|
||||||
const authoritativeActiveWo =
|
|
||||||
activeWorkOrderKey && hasAuthoritativeWorkOrderCounters
|
|
||||||
? machineWorkOrderCounters.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ?? null
|
|
||||||
: null;
|
|
||||||
const activeTargetSource =
|
const activeTargetSource =
|
||||||
activeWorkOrderKey
|
activeWorkOrderKey
|
||||||
? machineWorkOrdersSorted.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ??
|
? machineWorkOrdersSorted.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ??
|
||||||
activeWo ??
|
activeWo
|
||||||
authoritativeActiveWo
|
|
||||||
: activeWo;
|
: activeWo;
|
||||||
|
|
||||||
let activeProgressPct: number | null = null;
|
let activeProgressPct: number | null = null;
|
||||||
let activeStartedAt: string | null = null;
|
let activeStartedAt: string | null = null;
|
||||||
if (activeWorkOrderId) {
|
if (activeWorkOrderId) {
|
||||||
const rangeProgress = activeWorkOrderKey ? rangeByWorkOrder.get(activeWorkOrderKey) : null;
|
|
||||||
const authoritativeProgress = activeWorkOrderKey
|
const authoritativeProgress = activeWorkOrderKey
|
||||||
? authoritativeWorkOrderProgress.get(activeWorkOrderKey) ?? null
|
? authoritativeWorkOrderProgress.get(activeWorkOrderKey) ?? null
|
||||||
: null;
|
: null;
|
||||||
const cumulativeProgress = activeWorkOrderKey ? kpiLatestByWorkOrder.get(activeWorkOrderKey) : null;
|
|
||||||
const producedForProgress = authoritativeProgress
|
const producedForProgress = authoritativeProgress
|
||||||
? authoritativeProgress.goodParts + authoritativeProgress.scrapParts
|
? authoritativeProgress.goodParts + authoritativeProgress.scrapParts
|
||||||
: cumulativeProgress
|
: 0;
|
||||||
? cumulativeProgress.good + cumulativeProgress.scrap
|
|
||||||
: (rangeProgress?.goodParts ?? 0) + (rangeProgress?.scrapParts ?? 0);
|
|
||||||
const targetQty = safeNum(activeTargetSource?.targetQty);
|
const targetQty = safeNum(activeTargetSource?.targetQty);
|
||||||
if (targetQty && targetQty > 0) {
|
if (targetQty && targetQty > 0) {
|
||||||
activeProgressPct = round2((producedForProgress / targetQty) * 100);
|
activeProgressPct = round2((producedForProgress / targetQty) * 100);
|
||||||
}
|
}
|
||||||
activeStartedAt = toIso(
|
activeStartedAt = toIso(
|
||||||
authoritativeProgress?.firstTs ?? rangeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null
|
authoritativeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,7 +848,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
|||||||
production: {
|
production: {
|
||||||
goodParts,
|
goodParts,
|
||||||
scrapParts,
|
scrapParts,
|
||||||
totalCycles: hasAuthoritativeWorkOrderCounters ? authoritativeCycleCount : dedupedCycles.length,
|
totalCycles: authoritativeCycleCount,
|
||||||
bySku,
|
bySku,
|
||||||
},
|
},
|
||||||
oee: {
|
oee: {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate:deploy": "prisma migrate deploy"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.19.1",
|
"@prisma/client": "^6.19.1",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE "machine_work_orders"
|
||||||
|
ADD COLUMN "good_parts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "scrap_parts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "cycle_count" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -272,6 +272,9 @@ model MachineWorkOrder {
|
|||||||
targetQty Int?
|
targetQty Int?
|
||||||
cycleTime Float?
|
cycleTime Float?
|
||||||
status String @default("PENDING")
|
status String @default("PENDING")
|
||||||
|
goodParts Int @default(0) @map("good_parts")
|
||||||
|
scrapParts Int @default(0) @map("scrap_parts")
|
||||||
|
cycleCount Int @default(0) @map("cycle_count")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user