Files
sap-erp/services/downtimeAnalysisStore.js
T
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

291 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };