'use strict'; // services/downtimeAnalysisStore.js — "Downtime Analysis" report: aggregates // raw OEE shift entries (services/oeeStore.js) across EVERY tab, into one // standardized downtime taxonomy (Admin → System Settings → "Downtime // Analysis" — see appSettingsStore.downtimeAnalysisConfig()). Different OEE // tabs use different field names for conceptually the same cause (e.g. // Sheet's "Head Clean" vs EBB's "Micro Stops") — tabFieldMap is what // reconciles that, so a machine logged under one tab still rolls up next to // a machine logged under another. // // Three views over the same underlying data: // - buildReport() — grouped by the shift's own Machine Name field. // - buildTabReport() — grouped by OEE FORM (tab) instead — "how much // downtime did EBB Production log overall", not // "how much did this one machine log". // - buildMonthlyReport() — grouped by month, every machine/tab combined into // one Down Time vs M/C Run Time total. // // "Base Days" is a company working-day calendar count for the whole report // period (e.g. 330 days for a ~13-month period) — admin-entered, not derived // from OEE data, since it reflects the plant's real working calendar // (weekly-offs/holidays), not anything OEE rows can tell us. A group (machine // or tab) that only started being logged partway through the period (its own // first OEE entry falls after periodFrom) gets this prorated down // automatically — same daily "working-day density" applied to a shorter // span, not the full period's Base Days. const appSettings = require('./appSettingsStore'); const oeeStore = require('./oeeStore'); function daysBetween(fromStr, toStr) { const a = new Date(fromStr + 'T00:00:00Z'); const b = new Date(toStr + 'T00:00:00Z'); return Math.round((b - a) / 86400000); } function resolvePeriod({ periodFrom, periodTo, baseDays }, cfg) { const from = (periodFrom || cfg.periodFrom || '').slice(0, 10); const to = (periodTo || cfg.periodTo || '').slice(0, 10); if (!from || !to) throw new Error('periodFrom and periodTo are required (set a default in Admin → System Settings → Downtime Analysis, or pass them explicitly).'); const baseDaysCommon = Number(baseDays != null ? baseDays : cfg.baseDays) || 0; return { from, to, baseDaysCommon }; } // Shared aggregation core — buckets every in-range shift row by whatever // `keyOf(row, doc)` returns (a machine name, or a tab label), sums its // mapped cause minutes + shift minutes into that bucket, then turns each // bucket into the same derived-metrics shape (Base Days, % by cause, Net/ // Total/Remainder %) buildReport()/buildTabReport() both return. async function aggregate({ company, from, to, baseDaysCommon, causes, tabFieldMap, keyOf }) { const monthFrom = from.slice(0, 7); const monthTo = to.slice(0, 7); const docs = await oeeStore.listAllInRange({ company, monthFrom, monthTo }); const totalPeriodDays = Math.max(1, daysBetween(from, to) + 1); const buckets = {}; // key -> { causeTotals, totalShiftMin, activeDates:Set, firstDate } docs.forEach(doc => { const map = tabFieldMap[doc.tab] || {}; (doc.rows || []).forEach(row => { const d = String(row.date || '').slice(0, 10); if (!d || d < from || d > to) return; const key = keyOf(row, doc); if (!key) return; if (!buckets[key]) buckets[key] = { causeTotals: {}, totalShiftMin: 0, activeDates: new Set(), firstDate: null }; const b = buckets[key]; const shiftHrs = parseFloat(row.shiftLenHrs) || 0; b.totalShiftMin += shiftHrs * 60; b.activeDates.add(d); if (!b.firstDate || d < b.firstDate) b.firstDate = d; Object.keys(map).forEach(field => { const cause = map[field]; if (!cause) return; const v = parseFloat(row[field]) || 0; b.causeTotals[cause] = (b.causeTotals[cause] || 0) + v; }); }); }); const keys = Object.keys(buckets).sort(); const perGroup = keys.map(key => { const b = buckets[key]; let bDays = baseDaysCommon; if (b.firstDate && b.firstDate > from) { const daysSinceStart = Math.max(1, daysBetween(b.firstDate, to) + 1); bDays = Math.round((daysSinceStart / totalPeriodDays) * baseDaysCommon); } const baseMin = bDays * 1440; const totalShiftMin = Math.round(b.totalShiftMin); const plantInoperationMin = Math.max(0, baseMin - totalShiftMin); const causeTotals = {}; causes.forEach(c => { causeTotals[c] = Math.round(b.causeTotals[c] || 0); }); const causePcts = {}; causes.forEach(c => { causePcts[c] = baseMin > 0 ? (causeTotals[c] / baseMin * 100) : 0; }); const netDowntimeMin = causes.reduce((s, c) => s + causeTotals[c], 0); const netDowntimePct = baseMin > 0 ? (netDowntimeMin / baseMin * 100) : 0; const plantInoperationPct = baseMin > 0 ? (plantInoperationMin / baseMin * 100) : 0; const totalPct = netDowntimePct + plantInoperationPct; return { group: key, baseDays: bDays, baseMin, actualActiveDays: b.activeDates.size, totalShiftMin, plantInoperationMin, plantInoperationPct, causeTotals, causePcts, netDowntimeMin, netDowntimePct, totalPct, remainderPct: 100 - totalPct, firstActiveDate: b.firstDate, }; }); const net = { group: 'NET', baseDays: perGroup.reduce((s, g) => s + g.baseDays, 0), baseMin: perGroup.reduce((s, g) => s + g.baseMin, 0), actualActiveDays: perGroup.reduce((s, g) => s + g.actualActiveDays, 0), totalShiftMin: perGroup.reduce((s, g) => s + g.totalShiftMin, 0), plantInoperationMin: perGroup.reduce((s, g) => s + g.plantInoperationMin, 0), }; const netCauseTotals = {}; causes.forEach(c => { netCauseTotals[c] = perGroup.reduce((s, g) => s + (g.causeTotals[c] || 0), 0); }); net.causeTotals = netCauseTotals; const netCausePcts = {}; causes.forEach(c => { netCausePcts[c] = net.baseMin > 0 ? (netCauseTotals[c] / net.baseMin * 100) : 0; }); net.causePcts = netCausePcts; net.netDowntimeMin = causes.reduce((s, c) => s + netCauseTotals[c], 0); net.netDowntimePct = net.baseMin > 0 ? (net.netDowntimeMin / net.baseMin * 100) : 0; net.plantInoperationPct = net.baseMin > 0 ? (net.plantInoperationMin / net.baseMin * 100) : 0; net.totalPct = net.netDowntimePct + net.plantInoperationPct; net.remainderPct = 100 - net.totalPct; return { perGroup, net }; } async function buildReport({ company, periodFrom, periodTo, baseDays } = {}) { const cfg = appSettings.downtimeAnalysisConfig(); const { from, to, baseDaysCommon } = resolvePeriod({ periodFrom, periodTo, baseDays }, cfg); const causes = Array.isArray(cfg.causes) ? cfg.causes.filter(Boolean) : []; const tabFieldMap = cfg.tabFieldMap || {}; const { perGroup, net } = await aggregate({ company, from, to, baseDaysCommon, causes, tabFieldMap, keyOf: row => String(row.machineName || '').trim() || null, }); const machines = perGroup.map(g => ({ ...g, machine: g.group })); return { periodFrom: from, periodTo: to, baseDaysCommon, causes, machines, net: { ...net, machine: 'NET' } }; } const TAB_LABELS = { ebb: 'EBB Production', pd: 'PD Production', sheet: 'Plastic Sheet Plant', moulding: 'Moulding', autoclave: 'Autoclave' }; // Same shape as buildReport(), grouped by OEE FORM (tab) instead of Machine // Name — "how much downtime did EBB Production log overall" rather than // "how much did this one machine log". A tab with no rows in range is simply // absent (not shown as a zero row). async function buildTabReport({ company, periodFrom, periodTo, baseDays } = {}) { const cfg = appSettings.downtimeAnalysisConfig(); const { from, to, baseDaysCommon } = resolvePeriod({ periodFrom, periodTo, baseDays }, cfg); const causes = Array.isArray(cfg.causes) ? cfg.causes.filter(Boolean) : []; const tabFieldMap = cfg.tabFieldMap || {}; const { perGroup, net } = await aggregate({ company, from, to, baseDaysCommon, causes, tabFieldMap, keyOf: (row, doc) => doc.tab || null, }); const tabs = perGroup.map(g => ({ ...g, tab: g.group, tabLabel: TAB_LABELS[g.group] || g.group })); return { periodFrom: from, periodTo: to, baseDaysCommon, causes, tabs, net: { ...net, tab: 'NET', tabLabel: 'NET' } }; } // Shift-level detail, one sheet's worth per OEE tab — every individual shift // row (date/shift/shift length/cause minutes/computed Total Down Time & %), // NOT aggregated by machine or summed into one number, matching the OEE // module's own per-shift sheet layout. A tab absent from the result had no // rows in range at all. // - Total Down Time (per row) = sum of every cause column mapped for that // tab (both "Availability" and "Performance" causes together). // - % Of Down Time (per row) = Total Down Time ÷ that row's own Shift // Length (Equ. Min) × 100. // - The TOTAL row's % column is the SUM of every row's own % (not a // recomputed ratio from the totals) — matches the source Master Sheet's // own convention. async function buildDetailByTab({ company, periodFrom, periodTo } = {}) { const cfg = appSettings.downtimeAnalysisConfig(); const from = (periodFrom || cfg.periodFrom || '').slice(0, 10); const to = (periodTo || cfg.periodTo || '').slice(0, 10); if (!from || !to) throw new Error('periodFrom and periodTo are required.'); const causes = Array.isArray(cfg.causes) ? cfg.causes.filter(Boolean) : []; const tabFieldMap = cfg.tabFieldMap || {}; const monthFrom = from.slice(0, 7); const monthTo = to.slice(0, 7); const docs = await oeeStore.listAllInRange({ company, monthFrom, monthTo }); const byTab = {}; // tab -> { rows:[], causesUsed:Set } docs.forEach(doc => { const map = tabFieldMap[doc.tab] || {}; const mappedFields = Object.keys(map).filter(f => map[f]); if (!mappedFields.length) return; // tab has no cause mapping at all (e.g. Autoclave by default) — nothing meaningful to show (doc.rows || []).forEach(row => { const d = String(row.date || '').slice(0, 10); if (!d || d < from || d > to) return; if (!byTab[doc.tab]) byTab[doc.tab] = []; const equMin = (parseFloat(row.shiftLenHrs) || 0) * 60; const causeMins = {}; let totalDownMin = 0; mappedFields.forEach(field => { const v = parseFloat(row[field]) || 0; const cause = map[field]; causeMins[cause] = (causeMins[cause] || 0) + v; totalDownMin += v; }); byTab[doc.tab].push({ date: d, shift: row.shift || '', shiftLenHrs: parseFloat(row.shiftLenHrs) || 0, equMin: Math.round(equMin), goldStd: parseFloat(row.goldStd) || 0, causeMins, totalDownMin: Math.round(totalDownMin), pctDown: equMin > 0 ? (totalDownMin / equMin * 100) : 0, }); }); }); const tabs = {}; Object.keys(byTab).forEach(tab => { const rows = byTab[tab].sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0); const totals = { equMin: rows.reduce((s, r) => s + r.equMin, 0), totalDownMin: rows.reduce((s, r) => s + r.totalDownMin, 0), pctDownSum: rows.reduce((s, r) => s + r.pctDown, 0), }; tabs[tab] = { tabLabel: TAB_LABELS[tab] || tab, rows, totals }; }); return { periodFrom: from, periodTo: to, causes, tabs }; } function monthLabel(ym) { const [y, m] = ym.split('-').map(Number); return new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' }); } // Month-wise summary — ALL machines/tabs combined into one Down Time total // and one M/C Run Time total per month (not split by machine or cause; see // buildReport() above for the per-machine/per-cause breakdown). % of Down // Time here is Down Time ÷ M/C Run Time (actual logged shift time), NOT ÷ // Base Days×1440 like the per-machine report — this is "of the time the // machines actually ran, how much was downtime", a different question than // "of the full working calendar, how much was downtime". async function buildMonthlyReport({ company, periodFrom, periodTo } = {}) { const cfg = appSettings.downtimeAnalysisConfig(); const from = (periodFrom || cfg.periodFrom || '').slice(0, 10); const to = (periodTo || cfg.periodTo || '').slice(0, 10); if (!from || !to) throw new Error('periodFrom and periodTo are required.'); const tabFieldMap = cfg.tabFieldMap || {}; const monthFrom = from.slice(0, 7); const monthTo = to.slice(0, 7); const docs = await oeeStore.listAllInRange({ company, monthFrom, monthTo }); const months = {}; // 'YYYY-MM' -> { downMin, runMin } docs.forEach(doc => { const map = tabFieldMap[doc.tab] || {}; (doc.rows || []).forEach(row => { const d = String(row.date || '').slice(0, 10); if (!d || d < from || d > to) return; const ym = d.slice(0, 7); if (!months[ym]) months[ym] = { downMin: 0, runMin: 0 }; months[ym].runMin += (parseFloat(row.shiftLenHrs) || 0) * 60; Object.keys(map).forEach(field => { if (!map[field]) return; months[ym].downMin += parseFloat(row[field]) || 0; }); }); }); const rows = Object.keys(months).sort().map((ym, i) => { const d = months[ym]; const downMin = Math.round(d.downMin); const runMin = Math.round(d.runMin); const netRunMin = Math.max(0, runMin - downMin); return { no: i + 1, month: ym, monthLabel: monthLabel(ym), downMin, runMin, netRunMin, pctDown: runMin > 0 ? (downMin / runMin * 100) : 0, }; }); const total = { downMin: rows.reduce((s, r) => s + r.downMin, 0), runMin: rows.reduce((s, r) => s + r.runMin, 0), }; total.netRunMin = Math.max(0, total.runMin - total.downMin); total.pctDown = total.runMin > 0 ? (total.downMin / total.runMin * 100) : 0; return { periodFrom: from, periodTo: to, rows, total }; } module.exports = { buildReport, buildTabReport, buildDetailByTab, buildMonthlyReport };