@@ -0,0 +1,290 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user