From 078f677facdbcad122039035f58fa64b553e19b3 Mon Sep 17 00:00:00 2001 From: Aditya Gambhir <67105262+Aditya-gam@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:45:29 -0700 Subject: [PATCH 1/5] feat(bm): add utilization insight helpers Add thresholds, forecast modes, and PDF styling constants. Extract check-in/out aggregation, classification, regression and EMA-based forecasting against project risk profiles, caching, recommendations, maintenance alerts, balancing suggestions, and PDF/CSV report generation for procurement-style exports. --- src/constants/toolUtilization.js | 88 +++ src/helpers/toolUtilizationHelpers.js | 562 ++++++++++++++++++++ src/helpers/toolUtilizationReportHelpers.js | 143 +++++ 3 files changed, 793 insertions(+) create mode 100644 src/constants/toolUtilization.js create mode 100644 src/helpers/toolUtilizationHelpers.js create mode 100644 src/helpers/toolUtilizationReportHelpers.js diff --git a/src/constants/toolUtilization.js b/src/constants/toolUtilization.js new file mode 100644 index 000000000..f56298be2 --- /dev/null +++ b/src/constants/toolUtilization.js @@ -0,0 +1,88 @@ +const UTILIZATION_THRESHOLDS = { + UNDER_UTILIZED_MAX: 55, + NORMAL_MAX: 85, +}; + +const UTILIZATION_LABELS = { + UNDER: 'Under-utilized', + NORMAL: 'Normal', + OVER: 'Over-utilized', +}; + +const TRAFFIC_LIGHT = { + GREEN: 'green', + YELLOW: 'yellow', + RED: 'red', +}; + +const FORECAST_MODES = { + HISTORICAL: 'historical', + FORECAST_30: 'forecast30', + FORECAST_FULL: 'forecastFull', +}; + +const VALID_FORECAST_MODES = Object.values(FORECAST_MODES); + +const REPORT_FORMATS = ['pdf', 'csv']; + +const MAINTENANCE_TRIGGER_THRESHOLD = 85; + +const MINIMUM_WEEKS_FOR_REGRESSION = 3; + +const FORECAST_DEFAULT_DAYS = 30; + +const HOURS_PER_DAY = 24; +const DAYS_PER_WEEK = 7; +const MS_PER_HOUR = 3600000; + +const DEGRADED_CONDITIONS = ['Worn', 'Needs Repair', 'Needs Replacing']; +const NON_OPERATIONAL_STATUSES = ['Under Maintenance', 'Out of Service']; + +const CONFIDENCE_THRESHOLDS = { + HIGH: 0.7, + MEDIUM: 0.4, +}; + +const ENSEMBLE_WEIGHTS = { + HIGH_R2: { regression: 0.6, ema: 0.4 }, + MEDIUM_R2: { regression: 0.4, ema: 0.6 }, + LOW_R2: { regression: 0.2, ema: 0.8 }, +}; + +const EMA_SMOOTHING_BASE = 2; + +const ROUNDING_PRECISION = 10; +const PDF_MOVE_DOWN_HALF = 0.5; + +const PDF_STYLES = { + FONT_SIZE_TITLE: 24, + FONT_SIZE_METADATA: 10, + FONT_SIZE_SECTION_HEADER: 16, + FONT_SIZE_BODY: 11, + FONT_SIZE_DETAIL: 10, + PAGE_BREAK_THRESHOLD: 700, + PAGE_MARGIN: 50, +}; + +module.exports = { + UTILIZATION_THRESHOLDS, + UTILIZATION_LABELS, + TRAFFIC_LIGHT, + FORECAST_MODES, + VALID_FORECAST_MODES, + REPORT_FORMATS, + MAINTENANCE_TRIGGER_THRESHOLD, + MINIMUM_WEEKS_FOR_REGRESSION, + FORECAST_DEFAULT_DAYS, + HOURS_PER_DAY, + DAYS_PER_WEEK, + MS_PER_HOUR, + DEGRADED_CONDITIONS, + NON_OPERATIONAL_STATUSES, + CONFIDENCE_THRESHOLDS, + ENSEMBLE_WEIGHTS, + EMA_SMOOTHING_BASE, + ROUNDING_PRECISION, + PDF_MOVE_DOWN_HALF, + PDF_STYLES, +}; diff --git a/src/helpers/toolUtilizationHelpers.js b/src/helpers/toolUtilizationHelpers.js new file mode 100644 index 000000000..98bbdb6bd --- /dev/null +++ b/src/helpers/toolUtilizationHelpers.js @@ -0,0 +1,562 @@ +const mongoose = require('mongoose'); +const regression = require('regression'); +const ProjectRiskProfile = require('../models/bmdashboard/projectRiskProfile'); +const cache = require('../utilities/nodeCache'); +const { + UTILIZATION_THRESHOLDS, + UTILIZATION_LABELS, + TRAFFIC_LIGHT, + FORECAST_MODES, + MAINTENANCE_TRIGGER_THRESHOLD, + MINIMUM_WEEKS_FOR_REGRESSION, + FORECAST_DEFAULT_DAYS, + HOURS_PER_DAY, + DAYS_PER_WEEK, + MS_PER_HOUR, + DEGRADED_CONDITIONS, + NON_OPERATIONAL_STATUSES, + CONFIDENCE_THRESHOLDS, + ENSEMBLE_WEIGHTS, + EMA_SMOOTHING_BASE, + ROUNDING_PRECISION, +} = require('../constants/toolUtilization'); + +// ─── classifyUtilization ─── +const classifyUtilization = (rate) => { + if (rate < UTILIZATION_THRESHOLDS.UNDER_UTILIZED_MAX) { + return { label: UTILIZATION_LABELS.UNDER, trafficLight: TRAFFIC_LIGHT.YELLOW }; + } + if (rate <= UTILIZATION_THRESHOLDS.NORMAL_MAX) { + return { label: UTILIZATION_LABELS.NORMAL, trafficLight: TRAFFIC_LIGHT.GREEN }; + } + return { label: UTILIZATION_LABELS.OVER, trafficLight: TRAFFIC_LIGHT.RED }; +}; + +// ─── calculateCheckedOutHours ─── +const calculateCheckedOutHours = (toolItem, periodStart, periodEnd) => { + const periodHours = (periodEnd - periodStart) / MS_PER_HOUR; + const relevantLogs = (toolItem.logRecord || []).filter((log) => { + const logDate = new Date(log.date); + return logDate >= periodStart && logDate <= periodEnd; + }); + + relevantLogs.sort((a, b) => new Date(a.date) - new Date(b.date)); + + let checkedOutTime = 0; + let lastCheckOut = null; + + relevantLogs.forEach((log) => { + if (log.type === 'Check Out') { + lastCheckOut = new Date(log.date); + } else if (log.type === 'Check In' && lastCheckOut) { + const hoursOut = (new Date(log.date) - lastCheckOut) / MS_PER_HOUR; + checkedOutTime += Math.max(0, hoursOut); + lastCheckOut = null; + } + }); + + if (lastCheckOut) { + checkedOutTime += Math.max(0, (periodEnd - lastCheckOut) / MS_PER_HOUR); + } + + if (relevantLogs.length === 0 && toolItem.logRecord && toolItem.logRecord.length > 0) { + const sortedAllLogs = [...toolItem.logRecord].sort( + (a, b) => new Date(b.date) - new Date(a.date), + ); + const lastLog = sortedAllLogs[0]; + if (lastLog?.type === 'Check Out' && new Date(lastLog.date) < periodStart) { + checkedOutTime = periodHours; + } + } + + return checkedOutTime; +}; + +// ─── buildToolFilter ─── +const buildToolFilter = (query) => { + const filter = { __t: 'tool_item' }; + if (query.tool && query.tool !== 'ALL' && mongoose.Types.ObjectId.isValid(query.tool)) { + filter.itemType = mongoose.Types.ObjectId(query.tool); + } + if (query.project && query.project !== 'ALL' && mongoose.Types.ObjectId.isValid(query.project)) { + filter.project = mongoose.Types.ObjectId(query.project); + } + return filter; +}; + +// ─── parseDateRange ─── +const parseDateRange = (startDate, endDate) => { + const rangeEnd = endDate ? new Date(endDate) : new Date(); + const defaultStart = new Date(); + defaultStart.setDate(defaultStart.getDate() - FORECAST_DEFAULT_DAYS); + const rangeStart = startDate ? new Date(startDate) : defaultStart; + const totalHours = (rangeEnd - rangeStart) / MS_PER_HOUR; + return { rangeStart, rangeEnd, totalHours }; +}; + +// ─── groupToolsByType ─── +const groupToolsByType = (tools) => { + const toolGroups = {}; + tools.forEach((toolItem) => { + if (!toolItem.itemType) return; + const toolTypeId = toolItem.itemType._id.toString(); + if (!toolGroups[toolTypeId]) { + toolGroups[toolTypeId] = { + name: toolItem.itemType.name || 'Unknown Tool', + tools: [], + }; + } + toolGroups[toolTypeId].tools.push(toolItem); + }); + return toolGroups; +}; + +// ─── calculateGroupUtilization ─── +const calculateGroupUtilization = (group, rangeStart, rangeEnd, totalHours) => { + let totalCheckedOut = 0; + const purchaseStatuses = []; + const conditions = []; + const currentUsages = []; + + group.tools.forEach((toolItem) => { + totalCheckedOut += calculateCheckedOutHours(toolItem, rangeStart, rangeEnd); + if (toolItem.purchaseStatus) purchaseStatuses.push(toolItem.purchaseStatus); + if (toolItem.condition) conditions.push(toolItem.condition); + if (toolItem.currentUsage) currentUsages.push(toolItem.currentUsage); + }); + + const toolCount = group.tools.length; + const totalPossibleHours = totalHours * toolCount; + const utilizationRate = + totalPossibleHours > 0 ? Math.round((totalCheckedOut / totalPossibleHours) * 100) : 0; + const downtime = + Math.round((totalPossibleHours - totalCheckedOut) * ROUNDING_PRECISION) / ROUNDING_PRECISION; + + return { + name: group.name, + utilizationRate, + downtime, + classification: classifyUtilization(utilizationRate), + toolCount, + totalCheckedOutHours: Math.round(totalCheckedOut * ROUNDING_PRECISION) / ROUNDING_PRECISION, + totalPossibleHours: Math.round(totalPossibleHours * ROUNDING_PRECISION) / ROUNDING_PRECISION, + toolGroupDetails: { + tools: group.tools, + purchaseStatuses, + conditions, + currentUsages, + }, + }; +}; + +// ─── buildCacheKey ─── +const buildCacheKey = (query) => + `toolUtil:${query.tool || 'ALL'}:${query.project || 'ALL'}:${query.startDate || 'default'}:${query.endDate || 'default'}`; + +// ─── computeUtilizationData ─── +const computeUtilizationData = async (BuildingTool, query) => { + const cacheKey = buildCacheKey(query); + if (cache.hasCache(cacheKey)) { + return cache.getCache(cacheKey); + } + + const filter = buildToolFilter(query); + const tools = await BuildingTool.find(filter) + .populate('itemType', 'name') + .populate('project', 'name') + .lean(); + + const { rangeStart, rangeEnd, totalHours } = parseDateRange(query.startDate, query.endDate); + const toolGroups = groupToolsByType(tools); + const utilizationData = Object.values(toolGroups) + .map((group) => calculateGroupUtilization(group, rangeStart, rangeEnd, totalHours)) + .sort((a, b) => b.utilizationRate - a.utilizationRate); + + const result = { toolGroups, utilizationData, rangeStart, rangeEnd, totalHours }; + cache.setCache(cacheKey, result); + return result; +}; + +// ─── bucketUtilizationByWeek ─── +const bucketUtilizationByWeek = (toolGroup, rangeStart, rangeEnd) => { + const rangeDuration = rangeEnd.getTime() - rangeStart.getTime(); + const weekMs = DAYS_PER_WEEK * HOURS_PER_DAY * MS_PER_HOUR; + const numWeeks = Math.max(1, Math.ceil(rangeDuration / weekMs)); + const buckets = []; + + for (let i = 0; i < numWeeks; i += 1) { + const weekStart = new Date(rangeStart.getTime() + i * weekMs); + const weekEnd = new Date(Math.min(weekStart.getTime() + weekMs, rangeEnd.getTime())); + const weekHours = (weekEnd - weekStart) / MS_PER_HOUR; + + let weekCheckedOut = 0; + toolGroup.tools.forEach((toolItem) => { + weekCheckedOut += calculateCheckedOutHours(toolItem, weekStart, weekEnd); + }); + + const weekTotalPossible = weekHours * toolGroup.tools.length; + const weekRate = + weekTotalPossible > 0 ? Math.round((weekCheckedOut / weekTotalPossible) * 100) : 0; + + buckets.push({ weekIndex: i, weekStart, weekEnd, utilizationRate: weekRate }); + } + + return buckets; +}; + +// ─── computeEnsemblePredictions (internal helper for forecastUtilization) ─── +const computeEnsemblePredictions = (weeklyBuckets, forecastWeeks, weekMs, existingWeeks) => { + const data = weeklyBuckets.map((b, i) => [i, b.utilizationRate]); + const result = regression.linear(data); + const r2 = result.r2 || 0; + + let emaValue = weeklyBuckets[0].utilizationRate; + const alpha = EMA_SMOOTHING_BASE / (existingWeeks + 1); + for (let i = 1; i < existingWeeks; i += 1) { + emaValue = alpha * weeklyBuckets[i].utilizationRate + (1 - alpha) * emaValue; + } + + let weights; + let confidence = 'low'; + if (r2 >= CONFIDENCE_THRESHOLDS.HIGH) { + weights = ENSEMBLE_WEIGHTS.HIGH_R2; + confidence = 'high'; + } else if (r2 >= CONFIDENCE_THRESHOLDS.MEDIUM) { + weights = ENSEMBLE_WEIGHTS.MEDIUM_R2; + confidence = 'medium'; + } else { + weights = ENSEMBLE_WEIGHTS.LOW_R2; + } + + const weeklyPredictions = []; + for (let j = 0; j < forecastWeeks; j += 1) { + const futureIndex = existingWeeks + j; + const regressionPred = result.predict(futureIndex)[1]; + const blended = weights.regression * regressionPred + weights.ema * emaValue; + const clamped = Math.min(100, Math.max(0, Math.round(blended))); + + const weekStart = new Date(Date.now() + j * weekMs); + const weekEnd = new Date(weekStart.getTime() + weekMs); + weeklyPredictions.push({ + weekStart: weekStart.toISOString(), + weekEnd: weekEnd.toISOString(), + predictedRate: clamped, + }); + } + + return { weeklyPredictions, confidence }; +}; + +// ─── forecastUtilization (ensemble: regression + EMA) ─── +const forecastUtilization = (weeklyBuckets, forecastDays) => { + const weekMs = DAYS_PER_WEEK * HOURS_PER_DAY * MS_PER_HOUR; + const forecastWeeks = Math.max(1, Math.ceil(forecastDays / DAYS_PER_WEEK)); + const existingWeeks = weeklyBuckets.length; + let weeklyPredictions; + let confidence = 'low'; + let method = 'average'; + + if (existingWeeks < MINIMUM_WEEKS_FOR_REGRESSION) { + const avgRate = + existingWeeks > 0 + ? Math.round(weeklyBuckets.reduce((sum, b) => sum + b.utilizationRate, 0) / existingWeeks) + : 0; + + weeklyPredictions = []; + for (let j = 0; j < forecastWeeks; j += 1) { + const weekStart = new Date(Date.now() + j * weekMs); + const weekEnd = new Date(weekStart.getTime() + weekMs); + weeklyPredictions.push({ + weekStart: weekStart.toISOString(), + weekEnd: weekEnd.toISOString(), + predictedRate: avgRate, + }); + } + } else { + const ensemble = computeEnsemblePredictions( + weeklyBuckets, + forecastWeeks, + weekMs, + existingWeeks, + ); + weeklyPredictions = ensemble.weeklyPredictions; + confidence = ensemble.confidence; + method = 'ensemble'; + } + + const predictedRate = + weeklyPredictions.length > 0 + ? Math.round( + weeklyPredictions.reduce((sum, w) => sum + w.predictedRate, 0) / weeklyPredictions.length, + ) + : 0; + + const forecastEndDate = new Date( + Date.now() + forecastDays * HOURS_PER_DAY * MS_PER_HOUR, + ).toISOString(); + + return { + predictedRate, + confidence, + forecastEndDate, + weeklyPredictions, + predictedClassification: classifyUtilization(predictedRate), + method, + }; +}; + +// ─── generateMaintenanceAlerts ─── +const generateMaintenanceAlerts = (utilizationData) => { + const alerts = []; + + utilizationData.forEach((item) => { + const { toolGroupDetails } = item; + + if (item.utilizationRate > MAINTENANCE_TRIGGER_THRESHOLD) { + alerts.push({ + toolName: item.name, + alertType: 'overuse', + message: `High utilization at ${item.utilizationRate}%. Schedule preventive maintenance.`, + urgency: 'high', + }); + } + + const degradedSet = new Set(); + toolGroupDetails.conditions.forEach((condition) => { + if (DEGRADED_CONDITIONS.includes(condition) && !degradedSet.has(condition)) { + degradedSet.add(condition); + alerts.push({ + toolName: item.name, + alertType: 'condition', + message: `Tool condition is ${condition}. Immediate maintenance recommended.`, + urgency: 'high', + }); + } + }); + + const nonOpCounts = {}; + toolGroupDetails.currentUsages.forEach((usage) => { + if (NON_OPERATIONAL_STATUSES.includes(usage)) { + nonOpCounts[usage] = (nonOpCounts[usage] || 0) + 1; + } + }); + Object.entries(nonOpCounts).forEach(([status, count]) => { + alerts.push({ + toolName: item.name, + alertType: 'non_operational', + message: `${count} unit(s) currently ${status}. Effective fleet capacity reduced.`, + urgency: 'medium', + }); + }); + }); + + return alerts; +}; + +// ─── generateResourceBalancingSuggestions ─── +const generateResourceBalancingSuggestions = (utilizationData) => { + const suggestions = []; + const overUtilized = utilizationData.filter( + (d) => d.utilizationRate > UTILIZATION_THRESHOLDS.NORMAL_MAX, + ); + const underUtilized = utilizationData.filter( + (d) => d.utilizationRate < UTILIZATION_THRESHOLDS.UNDER_UTILIZED_MAX, + ); + + const sortedUnder = [...underUtilized].sort((a, b) => a.utilizationRate - b.utilizationRate); + + overUtilized.forEach((overTool) => { + if (sortedUnder.length > 0) { + const leastUsed = sortedUnder[0]; + suggestions.push({ + suggestion: 'Consider redistributing workload.', + fromTool: overTool.name, + toTool: leastUsed.name, + rationale: `${overTool.name} is at ${overTool.utilizationRate}% while ${leastUsed.name} is at ${leastUsed.utilizationRate}%.`, + }); + } + + const rentalCount = overTool.toolGroupDetails.purchaseStatuses.filter( + (s) => s === 'Rental', + ).length; + if (rentalCount > overTool.toolCount / 2) { + suggestions.push({ + suggestion: 'Consider purchasing additional units to reduce rental dependency.', + fromTool: overTool.name, + toTool: null, + rationale: `${overTool.name} is over-utilized at ${overTool.utilizationRate}% and predominantly rented.`, + }); + } + }); + + underUtilized.forEach((underTool) => { + const purchasedCount = underTool.toolGroupDetails.purchaseStatuses.filter( + (s) => s === 'Purchased', + ).length; + if (purchasedCount > underTool.toolCount / 2) { + suggestions.push({ + suggestion: 'Consider renting instead of owning, or reassign to other projects.', + fromTool: underTool.name, + toTool: null, + rationale: `${underTool.name} is under-utilized at ${underTool.utilizationRate}% and predominantly owned.`, + }); + } + }); + + return suggestions; +}; + +// ─── generateRecommendations ─── +const generateRecommendations = (utilizationData) => + utilizationData.map((item) => { + let action; + if (item.classification.label === UTILIZATION_LABELS.UNDER) { + action = 'Potentially removable or rentable instead of owned. Review necessity.'; + } else if (item.classification.label === UTILIZATION_LABELS.OVER) { + action = 'Requires maintenance scheduling, backup inventory, or purchase planning.'; + } else { + action = 'Normal operation. No action required.'; + } + + return { + toolName: item.name, + utilizationRate: item.utilizationRate, + label: item.classification.label, + trafficLight: item.classification.trafficLight, + action, + }; + }); + +// ─── stripInternalDetails ─── +const stripInternalDetails = (item) => { + const { toolGroupDetails: _toolGroupDetails, ...publicFields } = item; + return publicFields; +}; + +// ─── buildInsightsSummary ─── +const buildInsightsSummary = (utilizationData) => { + const totalToolTypes = utilizationData.length; + const underCount = utilizationData.filter( + (d) => d.classification.label === UTILIZATION_LABELS.UNDER, + ).length; + const overCount = utilizationData.filter( + (d) => d.classification.label === UTILIZATION_LABELS.OVER, + ).length; + const normalCount = totalToolTypes - underCount - overCount; + const averageUtilization = + totalToolTypes > 0 + ? Math.round(utilizationData.reduce((s, d) => s + d.utilizationRate, 0) / totalToolTypes) + : 0; + + return { + totalToolTypes, + underUtilized: underCount, + normal: normalCount, + overUtilized: overCount, + averageUtilization, + }; +}; + +// ─── determineForecastDays ─── +const determineForecastDays = async (mode, projectId) => { + if (mode === FORECAST_MODES.FORECAST_30) { + return { forecastDays: FORECAST_DEFAULT_DAYS, warning: null }; + } + + if (!projectId || projectId === 'ALL') { + return { + forecastDays: FORECAST_DEFAULT_DAYS, + warning: 'No specific project selected. Defaulting to 30-day forecast.', + }; + } + + const riskProfile = await ProjectRiskProfile.findOne({ + projectId: mongoose.Types.ObjectId(projectId), + }).lean(); + + if (!riskProfile?.endDate) { + return { + forecastDays: FORECAST_DEFAULT_DAYS, + warning: 'No project schedule found. Defaulting to 30-day forecast.', + }; + } + + const daysToEnd = Math.ceil( + (new Date(riskProfile.endDate) - Date.now()) / (HOURS_PER_DAY * MS_PER_HOUR), + ); + return { + forecastDays: Math.max(DAYS_PER_WEEK, daysToEnd), + warning: null, + }; +}; + +// ─── buildUtilizationResponse ─── +const buildUtilizationResponse = async ({ + utilizationData, + rangeStart, + rangeEnd, + selectedMode, + project, +}) => { + let warning = null; + + const responseData = await Promise.all( + utilizationData.map(async (item) => { + const publicItem = stripInternalDetails(item); + publicItem.forecast = null; + + if (selectedMode !== FORECAST_MODES.HISTORICAL) { + const { forecastDays, warning: fw } = await determineForecastDays(selectedMode, project); + if (fw) warning = fw; + + const weeklyBuckets = bucketUtilizationByWeek(item.toolGroupDetails, rangeStart, rangeEnd); + publicItem.forecast = forecastUtilization(weeklyBuckets, forecastDays); + } + + return publicItem; + }), + ); + + if (warning) { + responseData.forEach((item) => { + item.warning = warning; + }); + } + + return responseData; +}; + +// ─── buildReportPayload ─── +const buildReportPayload = (utilizationData, rangeStart, rangeEnd, project) => ({ + utilizationData, + alerts: generateMaintenanceAlerts(utilizationData), + balancing: generateResourceBalancingSuggestions(utilizationData), + recommendations: generateRecommendations(utilizationData), + summary: buildInsightsSummary(utilizationData), + metadata: { + generatedDate: new Date().toLocaleString(), + dateRange: `${rangeStart.toISOString()} to ${rangeEnd.toISOString()}`, + projectFilter: project || 'ALL', + }, +}); + +module.exports = { + classifyUtilization, + calculateCheckedOutHours, + buildToolFilter, + parseDateRange, + groupToolsByType, + calculateGroupUtilization, + buildCacheKey, + computeUtilizationData, + bucketUtilizationByWeek, + forecastUtilization, + generateMaintenanceAlerts, + generateResourceBalancingSuggestions, + generateRecommendations, + stripInternalDetails, + buildInsightsSummary, + determineForecastDays, + buildUtilizationResponse, + buildReportPayload, +}; diff --git a/src/helpers/toolUtilizationReportHelpers.js b/src/helpers/toolUtilizationReportHelpers.js new file mode 100644 index 000000000..d4c85a0f0 --- /dev/null +++ b/src/helpers/toolUtilizationReportHelpers.js @@ -0,0 +1,143 @@ +const PDFDocument = require('pdfkit'); +const { Parser } = require('json2csv'); +const { PDF_STYLES, PDF_MOVE_DOWN_HALF } = require('../constants/toolUtilization'); + +// ─── writePDFSection (internal helper) ─── +const writePDFSection = (doc, title, items, formatLine) => { + if (items.length === 0) return; + doc.moveDown(); + doc.fontSize(PDF_STYLES.FONT_SIZE_SECTION_HEADER).font('Helvetica-Bold').text(title); + doc.moveDown(PDF_MOVE_DOWN_HALF); + items.forEach((item, idx) => { + if (doc.y > PDF_STYLES.PAGE_BREAK_THRESHOLD) doc.addPage(); + doc.fontSize(PDF_STYLES.FONT_SIZE_DETAIL).font('Helvetica'); + doc.text(formatLine(item, idx)); + }); +}; + +// ─── generatePDFReport ─── +const generatePDFReport = (res, reportData) => { + const { utilizationData, alerts, balancing, recommendations, summary, metadata } = reportData; + const doc = new PDFDocument({ + margin: PDF_STYLES.PAGE_MARGIN, + bufferPages: true, + compress: true, + }); + const filename = `tool-utilization-report-${Date.now()}.pdf`; + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + doc.pipe(res); + + doc + .fontSize(PDF_STYLES.FONT_SIZE_TITLE) + .font('Helvetica-Bold') + .text('Tool Utilization & Procurement/Operations Report', { align: 'center' }); + doc.moveDown(); + + doc.fontSize(PDF_STYLES.FONT_SIZE_METADATA).font('Helvetica').fillColor('#666666'); + doc.text(`Generated: ${metadata.generatedDate}`); + doc.text(`Date Range: ${metadata.dateRange}`); + doc.text(`Project Filter: ${metadata.projectFilter}`); + doc.moveDown(); + doc.fillColor('#000000'); + + doc.fontSize(PDF_STYLES.FONT_SIZE_SECTION_HEADER).font('Helvetica-Bold').text('Summary'); + doc.fontSize(PDF_STYLES.FONT_SIZE_BODY).font('Helvetica'); + doc.text(`Total Tool Types: ${summary.totalToolTypes}`); + doc.text(`Average Utilization: ${summary.averageUtilization}%`); + doc.text( + `Under-utilized: ${summary.underUtilized} | Normal: ${summary.normal} | Over-utilized: ${summary.overUtilized}`, + ); + doc.moveDown(); + + doc + .fontSize(PDF_STYLES.FONT_SIZE_SECTION_HEADER) + .font('Helvetica-Bold') + .text('Utilization Details'); + doc.moveDown(PDF_MOVE_DOWN_HALF); + utilizationData.forEach((item) => { + if (doc.y > PDF_STYLES.PAGE_BREAK_THRESHOLD) doc.addPage(); + doc.fontSize(PDF_STYLES.FONT_SIZE_BODY).font('Helvetica-Bold'); + doc.text(`${item.name} — ${item.utilizationRate}% (${item.classification.label})`); + doc.fontSize(PDF_STYLES.FONT_SIZE_DETAIL).font('Helvetica'); + doc.text(` Downtime: ${item.downtime} hrs | Tool Count: ${item.toolCount}`); + doc.moveDown(PDF_MOVE_DOWN_HALF); + }); + + writePDFSection( + doc, + 'Maintenance Alerts', + alerts, + (alert) => `\u2022 [${alert.urgency.toUpperCase()}] ${alert.toolName}: ${alert.message}`, + ); + + writePDFSection( + doc, + 'Resource Balancing Suggestions', + balancing, + (item, idx) => `${idx + 1}. ${item.suggestion} ${item.rationale}`, + ); + + writePDFSection( + doc, + 'Recommendations', + recommendations, + (rec) => `\u2022 ${rec.toolName}: ${rec.action}`, + ); + + doc.end(); +}; + +// ─── generateCSVReport ─── +const generateCSVReport = (res, reportData) => { + const { utilizationData, alerts, recommendations } = reportData; + const filename = `tool-utilization-report-${Date.now()}.csv`; + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + + const alertsByTool = {}; + alerts.forEach((alert) => { + if (!alertsByTool[alert.toolName]) alertsByTool[alert.toolName] = []; + alertsByTool[alert.toolName].push(alert.message); + }); + + const recByTool = {}; + recommendations.forEach((rec) => { + recByTool[rec.toolName] = rec.action; + }); + + const rows = utilizationData.map((item) => ({ + name: item.name, + utilizationRate: item.utilizationRate, + downtime: item.downtime, + classificationLabel: item.classification.label, + trafficLight: item.classification.trafficLight, + toolCount: item.toolCount, + maintenanceAlert: (alertsByTool[item.name] || []).join('; ') || 'None', + recommendation: recByTool[item.name] || 'None', + })); + + const fields = [ + { label: 'Tool Name', value: 'name' }, + { label: 'Utilization Rate (%)', value: 'utilizationRate' }, + { label: 'Downtime (hours)', value: 'downtime' }, + { label: 'Classification', value: 'classificationLabel' }, + { label: 'Traffic Light', value: 'trafficLight' }, + { label: 'Tool Count', value: 'toolCount' }, + { label: 'Maintenance Alert', value: 'maintenanceAlert' }, + { label: 'Recommendation', value: 'recommendation' }, + ]; + + const parser = new Parser({ fields }); + const csv = parser.parse(rows); + res.send(`\ufeff${csv}`); +}; + +module.exports = { + generatePDFReport, + generateCSVReport, +}; From 28c8750eabc4581667153007dae4892d833b32d1 Mon Sep 17 00:00:00 2001 From: Aditya Gambhir <67105262+Aditya-gam@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:49:05 -0700 Subject: [PATCH 2/5] feat(bm): extend utilization API with modes Validate forecast mode on GET /tools/utilization and return enriched payloads via helpers. Add GET /tools/utilization/insights and GET /tools/utilization/export (pdf or csv). Replace console errors with startup logger. --- .../bmdashboard/toolUtilizationController.js | 223 +++++++----------- .../bmdashboard/toolUtilizationRouter.js | 2 + 2 files changed, 91 insertions(+), 134 deletions(-) diff --git a/src/controllers/bmdashboard/toolUtilizationController.js b/src/controllers/bmdashboard/toolUtilizationController.js index dff96ce80..c085b4528 100644 --- a/src/controllers/bmdashboard/toolUtilizationController.js +++ b/src/controllers/bmdashboard/toolUtilizationController.js @@ -1,157 +1,112 @@ -const mongoose = require('mongoose'); - -const toolUtilizationController = (BuildingTool) => { +const logger = require('../../startup/logger'); +const { + FORECAST_MODES, + VALID_FORECAST_MODES, + REPORT_FORMATS, +} = require('../../constants/toolUtilization'); +const { + computeUtilizationData, + buildUtilizationResponse, + generateRecommendations, + generateMaintenanceAlerts, + generateResourceBalancingSuggestions, + buildInsightsSummary, + buildReportPayload, +} = require('../../helpers/toolUtilizationHelpers'); +const { + generatePDFReport, + generateCSVReport, +} = require('../../helpers/toolUtilizationReportHelpers'); + +const toolUtilizationController = function (BuildingTool) { const getUtilization = async (req, res) => { try { - const { tool, project, startDate, endDate } = req.query; - - // Build filter for tools - const toolFilter = {}; + const { tool, project, startDate, endDate, mode } = req.query; + const selectedMode = mode || FORECAST_MODES.HISTORICAL; - // Filter by tool type if specified - if (tool && tool !== 'ALL') { - if (mongoose.Types.ObjectId.isValid(tool)) { - toolFilter.itemType = mongoose.Types.ObjectId(tool); - } - } - - // Filter by project if specified - if (project && project !== 'ALL') { - if (mongoose.Types.ObjectId.isValid(project)) { - toolFilter.project = mongoose.Types.ObjectId(project); - } + if (mode && !VALID_FORECAST_MODES.includes(selectedMode)) { + return res.status(400).json({ + error: `Invalid mode. Must be one of: ${VALID_FORECAST_MODES.join(', ')}`, + }); } - // Fetch tools with their log records - // For discriminator models, we need to filter by __t field - const tools = await BuildingTool.find({ - ...toolFilter, - __t: 'tool_item', // Filter for tool items only (discriminator field) - }) - .populate('itemType', 'name') - .populate('project', 'name') - .lean(); - - // Calculate date range - const start = startDate ? new Date(startDate) : null; - const end = endDate ? new Date(endDate) : null; - - // If no date range specified, use last 30 days as default - const defaultEnd = new Date(); - const defaultStart = new Date(); - defaultStart.setDate(defaultStart.getDate() - 30); - - const rangeStart = start || defaultStart; - const rangeEnd = end || defaultEnd; - const totalHours = (rangeEnd - rangeStart) / (1000 * 60 * 60); // Convert to hours - - // Group tools by itemType (tool name) - const toolGroups = {}; - - tools.forEach((toolItem) => { - if (!toolItem.itemType) return; // Skip if no itemType - - const toolName = toolItem.itemType.name || 'Unknown Tool'; - const toolTypeId = toolItem.itemType._id.toString(); - - if (!toolGroups[toolTypeId]) { - toolGroups[toolTypeId] = { - name: toolName, - tools: [], - }; - } - - toolGroups[toolTypeId].tools.push(toolItem); + const { utilizationData, rangeStart, rangeEnd } = await computeUtilizationData(BuildingTool, { + tool, + project, + startDate, + endDate, }); - // Calculate utilization for each tool type - const utilizationData = Object.values(toolGroups).map((group) => { - let totalCheckedOutHours = 0; - let totalDowntimeHours = 0; - let toolCount = 0; - - group.tools.forEach((toolItem) => { - toolCount += 1; - - // Filter log records by date range - const relevantLogs = (toolItem.logRecord || []).filter((log) => { - const logDate = new Date(log.date); - return logDate >= rangeStart && logDate <= rangeEnd; - }); - - // Sort logs by date - relevantLogs.sort((a, b) => new Date(a.date) - new Date(b.date)); + const responseData = await buildUtilizationResponse({ + utilizationData, + rangeStart, + rangeEnd, + selectedMode, + project, + }); - // Calculate checked out time - let checkedOutTime = 0; - let lastCheckOut = null; + return res.status(200).json(responseData); + } catch (err) { + logger.logException(err, 'toolUtilizationController.getUtilization'); + return res.status(500).json({ error: `Server error: ${err.message}` }); + } + }; - relevantLogs.forEach((log) => { - if (log.type === 'Check Out') { - lastCheckOut = new Date(log.date); - } else if (log.type === 'Check In' && lastCheckOut) { - const checkInTime = new Date(log.date); - const hoursCheckedOut = (checkInTime - lastCheckOut) / (1000 * 60 * 60); - checkedOutTime += Math.max(0, hoursCheckedOut); - lastCheckOut = null; - } - }); + const getInsights = async (req, res) => { + try { + const { utilizationData } = await computeUtilizationData(BuildingTool, req.query); - // If still checked out at the end of the range - if (lastCheckOut) { - const hoursCheckedOut = (rangeEnd - lastCheckOut) / (1000 * 60 * 60); - checkedOutTime += Math.max(0, hoursCheckedOut); - } + return res.status(200).json({ + recommendations: generateRecommendations(utilizationData), + maintenanceAlerts: generateMaintenanceAlerts(utilizationData), + resourceBalancing: generateResourceBalancingSuggestions(utilizationData), + summary: buildInsightsSummary(utilizationData), + }); + } catch (err) { + logger.logException(err, 'toolUtilizationController.getInsights'); + return res.status(500).json({ error: `Server error: ${err.message}` }); + } + }; - // If no logs in range, check if tool was checked out before range start - if (relevantLogs.length === 0 && toolItem.logRecord && toolItem.logRecord.length > 0) { - const sortedAllLogs = [...toolItem.logRecord].sort( - (a, b) => new Date(b.date) - new Date(a.date), - ); - const lastLog = sortedAllLogs[0]; - if (lastLog && lastLog.type === 'Check Out') { - const lastCheckOutDate = new Date(lastLog.date); - if (lastCheckOutDate < rangeStart) { - // Tool was checked out before range start, count entire range as checked out - checkedOutTime = totalHours; - } - } - } + const exportReport = async (req, res) => { + try { + const { format } = req.query; - totalCheckedOutHours += checkedOutTime; + if (!format || !REPORT_FORMATS.includes(format)) { + return res.status(400).json({ + error: `Invalid format. Must be one of: ${REPORT_FORMATS.join(', ')}`, }); + } - // Calculate downtime (time not checked out) - totalDowntimeHours = totalHours * toolCount - totalCheckedOutHours; - - // Calculate utilization rate - const totalPossibleHours = totalHours * toolCount; - const utilizationRate = - totalPossibleHours > 0 - ? Math.round((totalCheckedOutHours / totalPossibleHours) * 100) - : 0; - - return { - name: group.name, - utilizationRate, - downtime: Math.round(totalDowntimeHours * 10) / 10, // Round to 1 decimal place - }; - }); - - // Sort by utilization rate (descending) - utilizationData.sort((a, b) => b.utilizationRate - a.utilizationRate); - - res.status(200).json(utilizationData); + const { utilizationData, rangeStart, rangeEnd } = await computeUtilizationData( + BuildingTool, + req.query, + ); + const reportPayload = buildReportPayload( + utilizationData, + rangeStart, + rangeEnd, + req.query.project, + ); + + if (format === 'pdf') { + generatePDFReport(res, reportPayload); + } else { + generateCSVReport(res, reportPayload); + } } catch (err) { - console.error('Error calculating tool utilization:', err); - res.status(500).json({ - error: `Server error: ${err.message}`, - }); + logger.logException(err, 'toolUtilizationController.exportReport'); + if (!res.headersSent) { + return res.status(500).json({ error: `Server error: ${err.message}` }); + } } + return undefined; }; return { getUtilization, + getInsights, + exportReport, }; }; diff --git a/src/routes/bmdashboard/toolUtilizationRouter.js b/src/routes/bmdashboard/toolUtilizationRouter.js index b981c3e57..9a4026c82 100644 --- a/src/routes/bmdashboard/toolUtilizationRouter.js +++ b/src/routes/bmdashboard/toolUtilizationRouter.js @@ -7,6 +7,8 @@ const routes = function (BuildingTool) { ); toolUtilizationRouter.route('/tools/utilization').get(controller.getUtilization); + toolUtilizationRouter.route('/tools/utilization/insights').get(controller.getInsights); + toolUtilizationRouter.route('/tools/utilization/export').get(controller.exportReport); return toolUtilizationRouter; }; From 41870d98ce5413a805b3c2dba079fd6da3c0274f Mon Sep 17 00:00:00 2001 From: Aditya Gambhir <67105262+Aditya-gam@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:03:07 -0700 Subject: [PATCH 3/5] test(bm): cover utilization insights flows Add focused tests for utilization controller endpoints, helper computations, forecasting/classification behavior, and CSV/PDF report generation. This validates the new insights and export paths without changing production code. --- .../toolUtilizationController.test.js | 268 ++++++ .../__tests__/toolUtilizationHelpers.test.js | 875 ++++++++++++++++++ .../toolUtilizationReportHelpers.test.js | 181 ++++ 3 files changed, 1324 insertions(+) create mode 100644 src/controllers/bmdashboard/__tests__/toolUtilizationController.test.js create mode 100644 src/helpers/__tests__/toolUtilizationHelpers.test.js create mode 100644 src/helpers/__tests__/toolUtilizationReportHelpers.test.js diff --git a/src/controllers/bmdashboard/__tests__/toolUtilizationController.test.js b/src/controllers/bmdashboard/__tests__/toolUtilizationController.test.js new file mode 100644 index 000000000..c8acf176a --- /dev/null +++ b/src/controllers/bmdashboard/__tests__/toolUtilizationController.test.js @@ -0,0 +1,268 @@ +jest.mock('../../../helpers/toolUtilizationHelpers'); +jest.mock('../../../helpers/toolUtilizationReportHelpers'); +jest.mock('../../../startup/logger', () => ({ logException: jest.fn() })); + +const { + computeUtilizationData, + buildUtilizationResponse, + generateRecommendations, + generateMaintenanceAlerts, + generateResourceBalancingSuggestions, + buildInsightsSummary, + buildReportPayload, +} = require('../../../helpers/toolUtilizationHelpers'); +const { + generatePDFReport, + generateCSVReport, +} = require('../../../helpers/toolUtilizationReportHelpers'); +const toolUtilizationController = require('../toolUtilizationController'); + +// ─── Shared setup ─── +const mockBuildingTool = {}; +const controller = toolUtilizationController(mockBuildingTool); + +const makeReq = (query = {}) => ({ query }); +const makeRes = () => ({ + status: jest.fn().mockReturnThis(), + json: jest.fn(), + setHeader: jest.fn(), + send: jest.fn(), + headersSent: false, +}); + +const mockRangeStart = new Date('2026-01-01'); +const mockRangeEnd = new Date('2026-01-31'); +const mockUtilizationData = [ + { + name: 'Drill', + utilizationRate: 75, + downtime: 180, + classification: { label: 'Normal', trafficLight: 'green' }, + toolCount: 1, + toolGroupDetails: { tools: [], purchaseStatuses: [], conditions: [], currentUsages: [] }, + }, +]; +const mockReportPayload = { + utilizationData: mockUtilizationData, + alerts: [], + balancing: [], + recommendations: [], + summary: {}, + metadata: {}, +}; + +beforeEach(() => { + jest.clearAllMocks(); + computeUtilizationData.mockResolvedValue({ + utilizationData: mockUtilizationData, + rangeStart: mockRangeStart, + rangeEnd: mockRangeEnd, + }); + buildUtilizationResponse.mockResolvedValue(mockUtilizationData); + generateRecommendations.mockReturnValue([]); + generateMaintenanceAlerts.mockReturnValue([]); + generateResourceBalancingSuggestions.mockReturnValue([]); + buildInsightsSummary.mockReturnValue({ + totalToolTypes: 1, + underUtilized: 0, + normal: 1, + overUtilized: 0, + averageUtilization: 75, + }); + buildReportPayload.mockReturnValue(mockReportPayload); +}); + +// ─── getUtilization ─── +describe('getUtilization', () => { + it('returns 200 with utilization data when no mode is specified', async () => { + const req = makeReq({}); + const res = makeRes(); + + await controller.getUtilization(req, res); + + expect(computeUtilizationData).toHaveBeenCalledWith(mockBuildingTool, { + tool: undefined, + project: undefined, + startDate: undefined, + endDate: undefined, + }); + expect(buildUtilizationResponse).toHaveBeenCalledWith( + expect.objectContaining({ selectedMode: 'historical' }), + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(mockUtilizationData); + }); + + it('passes forecast30 mode to buildUtilizationResponse', async () => { + const req = makeReq({ mode: 'forecast30' }); + const res = makeRes(); + + await controller.getUtilization(req, res); + + expect(buildUtilizationResponse).toHaveBeenCalledWith( + expect.objectContaining({ selectedMode: 'forecast30' }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('returns 400 when mode is invalid', async () => { + const req = makeReq({ mode: 'badMode' }); + const res = makeRes(); + + await controller.getUtilization(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('Invalid mode') }), + ); + }); + + it('returns 500 when computeUtilizationData throws', async () => { + computeUtilizationData.mockRejectedValue(new Error('DB failure')); + const req = makeReq({}); + const res = makeRes(); + + await controller.getUtilization(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('DB failure') }), + ); + }); + + it('returns 500 when buildUtilizationResponse throws', async () => { + buildUtilizationResponse.mockRejectedValue(new Error('Response build failed')); + const req = makeReq({}); + const res = makeRes(); + + await controller.getUtilization(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + }); +}); + +// ─── getInsights ─── +describe('getInsights', () => { + it('returns 200 with all insight sections', async () => { + const recommendations = [{ toolName: 'Drill', action: 'Plan maintenance.' }]; + const alerts = [{ toolName: 'Drill', alertType: 'overuse', message: 'High.', urgency: 'high' }]; + const balancing = [{ suggestion: 'Redistribute.', fromTool: 'Drill', toTool: 'Shovel' }]; + const summary = { + totalToolTypes: 1, + underUtilized: 0, + normal: 0, + overUtilized: 1, + averageUtilization: 90, + }; + + generateRecommendations.mockReturnValue(recommendations); + generateMaintenanceAlerts.mockReturnValue(alerts); + generateResourceBalancingSuggestions.mockReturnValue(balancing); + buildInsightsSummary.mockReturnValue(summary); + + const req = makeReq({}); + const res = makeRes(); + + await controller.getInsights(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + recommendations, + maintenanceAlerts: alerts, + resourceBalancing: balancing, + summary, + }); + }); + + it('calls all insight helpers with utilizationData from computeUtilizationData', async () => { + const req = makeReq({}); + const res = makeRes(); + + await controller.getInsights(req, res); + + expect(generateRecommendations).toHaveBeenCalledWith(mockUtilizationData); + expect(generateMaintenanceAlerts).toHaveBeenCalledWith(mockUtilizationData); + expect(generateResourceBalancingSuggestions).toHaveBeenCalledWith(mockUtilizationData); + expect(buildInsightsSummary).toHaveBeenCalledWith(mockUtilizationData); + }); + + it('returns 500 when computeUtilizationData throws', async () => { + computeUtilizationData.mockRejectedValue(new Error('DB error')); + const req = makeReq({}); + const res = makeRes(); + + await controller.getInsights(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('DB error') }), + ); + }); +}); + +// ─── exportReport ─── +describe('exportReport', () => { + it('returns 400 when format param is missing', async () => { + const req = makeReq({}); + const res = makeRes(); + + await controller.exportReport(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('Invalid format') }), + ); + }); + + it('returns 400 when format is not pdf or csv', async () => { + const req = makeReq({ format: 'xlsx' }); + const res = makeRes(); + + await controller.exportReport(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('calls generateCSVReport for csv format', async () => { + const req = makeReq({ format: 'csv' }); + const res = makeRes(); + + await controller.exportReport(req, res); + + expect(generateCSVReport).toHaveBeenCalledWith(res, mockReportPayload); + expect(generatePDFReport).not.toHaveBeenCalled(); + }); + + it('calls generatePDFReport for pdf format', async () => { + const req = makeReq({ format: 'pdf' }); + const res = makeRes(); + + await controller.exportReport(req, res); + + expect(generatePDFReport).toHaveBeenCalledWith(res, mockReportPayload); + expect(generateCSVReport).not.toHaveBeenCalled(); + }); + + it('returns 500 when error occurs and headers are not yet sent', async () => { + computeUtilizationData.mockRejectedValue(new Error('Export failed')); + const req = makeReq({ format: 'csv' }); + const res = { ...makeRes(), headersSent: false }; + + await controller.exportReport(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('Export failed') }), + ); + }); + + it('does not send error response when headers are already sent', async () => { + computeUtilizationData.mockRejectedValue(new Error('Stream error')); + const req = makeReq({ format: 'csv' }); + const res = { ...makeRes(), headersSent: true }; + + await controller.exportReport(req, res); + + expect(res.status).not.toHaveBeenCalled(); + }); +}); diff --git a/src/helpers/__tests__/toolUtilizationHelpers.test.js b/src/helpers/__tests__/toolUtilizationHelpers.test.js new file mode 100644 index 000000000..e7e7dfd7c --- /dev/null +++ b/src/helpers/__tests__/toolUtilizationHelpers.test.js @@ -0,0 +1,875 @@ +jest.mock('mongoose', () => ({ + Types: { + ObjectId: Object.assign( + jest.fn((id) => id), + { + isValid: jest.fn(), + }, + ), + }, +})); + +jest.mock('regression', () => ({ linear: jest.fn() })); + +jest.mock('../../models/bmdashboard/projectRiskProfile', () => ({ findOne: jest.fn() })); + +jest.mock('../../utilities/nodeCache', () => ({ + hasCache: jest.fn(), + getCache: jest.fn(), + setCache: jest.fn(), +})); + +const mongoose = require('mongoose'); +const regression = require('regression'); +const ProjectRiskProfile = require('../../models/bmdashboard/projectRiskProfile'); +const cache = require('../../utilities/nodeCache'); +const { + classifyUtilization, + calculateCheckedOutHours, + buildToolFilter, + parseDateRange, + groupToolsByType, + calculateGroupUtilization, + buildCacheKey, + computeUtilizationData, + bucketUtilizationByWeek, + forecastUtilization, + generateMaintenanceAlerts, + generateResourceBalancingSuggestions, + generateRecommendations, + stripInternalDetails, + buildInsightsSummary, + determineForecastDays, + buildUtilizationResponse, + buildReportPayload, +} = require('../toolUtilizationHelpers'); + +// ─── Shared fixtures ─── +const makeLog = (type, dateStr) => ({ type, date: new Date(dateStr) }); + +const JAN1 = new Date('2026-01-01T00:00:00Z'); +const JAN31 = new Date('2026-01-31T23:59:59Z'); +const JAN_HOURS = (JAN31 - JAN1) / 3600000; + +const makeToolItem = (logRecord = [], extra = {}) => ({ + logRecord, + itemType: { _id: { toString: () => 'type1' }, name: 'Power Drill' }, + purchaseStatus: 'Purchased', + condition: 'Good', + currentUsage: 'Available', + ...extra, +}); + +const makeUtilizationItem = ( + name, + rate, + purchaseStatuses = [], + conditions = [], + currentUsages = [], +) => ({ + name, + utilizationRate: rate, + downtime: 100, + classification: classifyUtilization(rate), + toolCount: purchaseStatuses.length || 2, + totalCheckedOutHours: rate * 0.72, + totalPossibleHours: 720, + toolGroupDetails: { + tools: purchaseStatuses.map(() => ({})), + purchaseStatuses, + conditions, + currentUsages, + }, +}); + +const makeMockBuildingTool = (tools = []) => { + const lean = jest.fn().mockResolvedValue(tools); + const chain = { populate: jest.fn(), lean }; + chain.populate.mockReturnValue(chain); + return { BuildingTool: { find: jest.fn().mockReturnValue(chain) }, lean }; +}; + +beforeEach(() => { + jest.clearAllMocks(); + mongoose.Types.ObjectId.isValid.mockReturnValue(true); + cache.hasCache.mockReturnValue(false); + regression.linear.mockReturnValue({ + r2: 0.8, + predict: jest.fn().mockImplementation((i) => [i, 60]), + }); +}); + +// ─── classifyUtilization ─── +describe('classifyUtilization', () => { + it('returns Under-utilized and yellow for rate 0', () => { + expect(classifyUtilization(0)).toEqual({ label: 'Under-utilized', trafficLight: 'yellow' }); + }); + + it('returns Under-utilized and yellow for rate 54', () => { + expect(classifyUtilization(54)).toEqual({ label: 'Under-utilized', trafficLight: 'yellow' }); + }); + + it('returns Normal and green for rate 55', () => { + expect(classifyUtilization(55)).toEqual({ label: 'Normal', trafficLight: 'green' }); + }); + + it('returns Normal and green for rate 85', () => { + expect(classifyUtilization(85)).toEqual({ label: 'Normal', trafficLight: 'green' }); + }); + + it('returns Over-utilized and red for rate 86', () => { + expect(classifyUtilization(86)).toEqual({ label: 'Over-utilized', trafficLight: 'red' }); + }); + + it('returns Over-utilized and red for rate 100', () => { + expect(classifyUtilization(100)).toEqual({ label: 'Over-utilized', trafficLight: 'red' }); + }); +}); + +// ─── calculateCheckedOutHours ─── +describe('calculateCheckedOutHours', () => { + it('returns 0 when logRecord is undefined', () => { + expect(calculateCheckedOutHours({ logRecord: undefined }, JAN1, JAN31)).toBe(0); + }); + + it('returns 0 when no logs fall within period and only a prior checkin exists', () => { + const toolWithCheckin = makeToolItem([makeLog('Check In', '2025-12-15T00:00:00Z')]); + expect(calculateCheckedOutHours(toolWithCheckin, JAN1, JAN31)).toBe(0); + }); + + it('returns full period hours when no logs in period and last prior log is Check Out', () => { + const tool = makeToolItem([makeLog('Check Out', '2025-12-15T00:00:00Z')]); + const result = calculateCheckedOutHours(tool, JAN1, JAN31); + expect(result).toBeCloseTo(JAN_HOURS, 0); + }); + + it('returns 0 when no logs in period and last prior log is Check In', () => { + const tool = makeToolItem([ + makeLog('Check Out', '2025-12-01T00:00:00Z'), + makeLog('Check In', '2025-12-15T00:00:00Z'), + ]); + expect(calculateCheckedOutHours(tool, JAN1, JAN31)).toBe(0); + }); + + it('returns hours between Check Out and Check In within period', () => { + const checkOut = new Date('2026-01-05T08:00:00Z'); + const checkIn = new Date('2026-01-05T12:00:00Z'); + const tool = makeToolItem([makeLog('Check Out', checkOut), makeLog('Check In', checkIn)]); + expect(calculateCheckedOutHours(tool, JAN1, JAN31)).toBeCloseTo(4, 5); + }); + + it('counts time to period end when Check Out has no matching Check In', () => { + const checkOut = new Date('2026-01-31T20:00:00Z'); + const tool = makeToolItem([makeLog('Check Out', checkOut)]); + const expected = (JAN31 - checkOut) / 3600000; + expect(calculateCheckedOutHours(tool, JAN1, JAN31)).toBeCloseTo(expected, 5); + }); + + it('ignores orphan Check In with no preceding Check Out', () => { + const tool = makeToolItem([makeLog('Check In', '2026-01-10T10:00:00Z')]); + expect(calculateCheckedOutHours(tool, JAN1, JAN31)).toBe(0); + }); + + it('accumulates multiple checkout/checkin pairs', () => { + const logs = [ + makeLog('Check Out', '2026-01-05T08:00:00Z'), + makeLog('Check In', '2026-01-05T10:00:00Z'), // 2 hours + makeLog('Check Out', '2026-01-10T06:00:00Z'), + makeLog('Check In', '2026-01-10T10:00:00Z'), // 4 hours + ]; + const tool = makeToolItem(logs); + expect(calculateCheckedOutHours(tool, JAN1, JAN31)).toBeCloseTo(6, 5); + }); +}); + +// ─── buildToolFilter ─── +describe('buildToolFilter', () => { + it('returns only __t filter when no params', () => { + expect(buildToolFilter({})).toEqual({ __t: 'tool_item' }); + }); + + it('ignores tool when tool is ALL', () => { + expect(buildToolFilter({ tool: 'ALL' })).toEqual({ __t: 'tool_item' }); + }); + + it('ignores tool when ObjectId is invalid', () => { + mongoose.Types.ObjectId.isValid.mockReturnValue(false); + expect(buildToolFilter({ tool: 'invalid-id' })).toEqual({ __t: 'tool_item' }); + }); + + it('adds itemType when tool is a valid ObjectId', () => { + const result = buildToolFilter({ tool: 'abc123' }); + expect(result.__t).toBe('tool_item'); + expect(result.itemType).toBeDefined(); + }); + + it('ignores project when project is ALL', () => { + expect(buildToolFilter({ project: 'ALL' })).toEqual({ __t: 'tool_item' }); + }); + + it('adds both itemType and project when both are valid ObjectIds', () => { + const result = buildToolFilter({ tool: 'tool1', project: 'proj1' }); + expect(result.itemType).toBeDefined(); + expect(result.project).toBeDefined(); + }); +}); + +// ─── parseDateRange ─── +describe('parseDateRange', () => { + it('defaults to last 30 days when no dates provided', () => { + const before = Date.now(); + const { rangeEnd, totalHours } = parseDateRange(undefined, undefined); + const after = Date.now(); + expect(rangeEnd.getTime()).toBeGreaterThanOrEqual(before); + expect(rangeEnd.getTime()).toBeLessThanOrEqual(after); + // 30 calendar days ≈ 719–721 hours depending on DST transitions + expect(totalHours).toBeGreaterThanOrEqual(29 * 24); + expect(totalHours).toBeLessThanOrEqual(31 * 24); + }); + + it('uses provided startDate', () => { + const { rangeStart } = parseDateRange('2026-01-01', undefined); + expect(rangeStart.toISOString().startsWith('2026-01-01')).toBe(true); + }); + + it('uses provided endDate', () => { + const { rangeEnd } = parseDateRange(undefined, '2026-02-01'); + expect(rangeEnd.toISOString().startsWith('2026-02-01')).toBe(true); + }); + + it('computes totalHours correctly when both dates provided', () => { + const { totalHours } = parseDateRange('2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z'); + expect(totalHours).toBeCloseTo(24, 5); + }); +}); + +// ─── groupToolsByType ─── +describe('groupToolsByType', () => { + it('returns empty object for empty tools array', () => { + expect(groupToolsByType([])).toEqual({}); + }); + + it('skips tools without itemType', () => { + const tool = { logRecord: [] }; // no itemType + expect(groupToolsByType([tool])).toEqual({}); + }); + + it('groups multiple tools of the same type under one key', () => { + const tool1 = makeToolItem(); + const tool2 = makeToolItem(); + const groups = groupToolsByType([tool1, tool2]); + expect(Object.keys(groups)).toHaveLength(1); + expect(groups.type1.tools).toHaveLength(2); + }); + + it('creates separate groups for different types', () => { + const tool1 = makeToolItem([], { + itemType: { _id: { toString: () => 'typeA' }, name: 'Drill' }, + }); + const tool2 = makeToolItem([], { + itemType: { _id: { toString: () => 'typeB' }, name: 'Hammer' }, + }); + const groups = groupToolsByType([tool1, tool2]); + expect(Object.keys(groups)).toHaveLength(2); + }); +}); + +// ─── calculateGroupUtilization ─── +describe('calculateGroupUtilization', () => { + it('returns utilizationRate of 0 when totalHours is 0', () => { + const group = { name: 'Drill', tools: [makeToolItem()] }; + const result = calculateGroupUtilization(group, JAN1, JAN1, 0); + expect(result.utilizationRate).toBe(0); + }); + + it('computes correct utilization rate and downtime for normal case', () => { + const checkOut = new Date('2026-01-15T00:00:00Z'); + const checkIn = new Date('2026-01-16T00:00:00Z'); // 24 hours checked out + const tool = makeToolItem([makeLog('Check Out', checkOut), makeLog('Check In', checkIn)]); + const result = calculateGroupUtilization( + { name: 'Drill', tools: [tool] }, + JAN1, + JAN31, + JAN_HOURS, + ); + expect(result.utilizationRate).toBeGreaterThan(0); + expect(result.downtime).toBeGreaterThanOrEqual(0); + expect(result.classification).toBeDefined(); + }); + + it('collects purchaseStatuses, conditions, and currentUsages from tools', () => { + const tool1 = makeToolItem([], { + purchaseStatus: 'Purchased', + condition: 'Worn', + currentUsage: 'Under Maintenance', + }); + const tool2 = makeToolItem([], { + purchaseStatus: 'Rental', + condition: 'Good', + currentUsage: 'Available', + }); + const result = calculateGroupUtilization( + { name: 'Drill', tools: [tool1, tool2] }, + JAN1, + JAN31, + JAN_HOURS, + ); + expect(result.toolGroupDetails.purchaseStatuses).toEqual(['Purchased', 'Rental']); + expect(result.toolGroupDetails.conditions).toEqual(['Worn', 'Good']); + expect(result.toolGroupDetails.currentUsages).toEqual(['Under Maintenance', 'Available']); + }); + + it('includes toolCount and totalCheckedOutHours in result', () => { + const group = { name: 'Drill', tools: [makeToolItem(), makeToolItem()] }; + const result = calculateGroupUtilization(group, JAN1, JAN31, JAN_HOURS); + expect(result.toolCount).toBe(2); + expect(result.totalCheckedOutHours).toBeGreaterThanOrEqual(0); + expect(result.totalPossibleHours).toBeCloseTo(JAN_HOURS * 2, 0); + }); +}); + +// ─── buildCacheKey ─── +describe('buildCacheKey', () => { + it('builds full key from all params', () => { + expect( + buildCacheKey({ tool: 't1', project: 'p1', startDate: '2026-01-01', endDate: '2026-01-31' }), + ).toBe('toolUtil:t1:p1:2026-01-01:2026-01-31'); + }); + + it('uses ALL and default when params are absent', () => { + expect(buildCacheKey({})).toBe('toolUtil:ALL:ALL:default:default'); + }); + + it('fills in defaults for missing params only', () => { + expect(buildCacheKey({ tool: 'myTool' })).toBe('toolUtil:myTool:ALL:default:default'); + }); +}); + +// ─── computeUtilizationData ─── +describe('computeUtilizationData', () => { + it('returns cached result without querying DB when cache hits', async () => { + const cached = { + utilizationData: [], + rangeStart: JAN1, + rangeEnd: JAN31, + totalHours: JAN_HOURS, + }; + cache.hasCache.mockReturnValue(true); + cache.getCache.mockReturnValue(cached); + + const { BuildingTool } = makeMockBuildingTool([]); + const result = await computeUtilizationData(BuildingTool, {}); + + expect(result).toBe(cached); + expect(BuildingTool.find).not.toHaveBeenCalled(); + }); + + it('queries DB and caches result on cache miss', async () => { + const tool = makeToolItem([], { itemType: { _id: { toString: () => 'id1' }, name: 'Drill' } }); + const { BuildingTool } = makeMockBuildingTool([tool]); + + const result = await computeUtilizationData(BuildingTool, {}); + + expect(BuildingTool.find).toHaveBeenCalled(); + expect(cache.setCache).toHaveBeenCalled(); + expect(result.utilizationData).toHaveLength(1); + }); + + it('returns empty utilizationData when no tools found', async () => { + const { BuildingTool } = makeMockBuildingTool([]); + const result = await computeUtilizationData(BuildingTool, {}); + expect(result.utilizationData).toHaveLength(0); + }); + + it('sorts utilizationData by utilizationRate descending', async () => { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 29 * 24 * 3600000); + const highUseTool = makeToolItem( + [makeLog('Check Out', thirtyDaysAgo), makeLog('Check In', now)], + { itemType: { _id: { toString: () => 'highId' }, name: 'Hammer' } }, + ); + const lowUseTool = makeToolItem([], { + itemType: { _id: { toString: () => 'lowId' }, name: 'Shovel' }, + }); + const { BuildingTool } = makeMockBuildingTool([lowUseTool, highUseTool]); + + const result = await computeUtilizationData(BuildingTool, {}); + + expect(result.utilizationData[0].utilizationRate).toBeGreaterThanOrEqual( + result.utilizationData[1].utilizationRate, + ); + }); +}); + +// ─── bucketUtilizationByWeek ─── +describe('bucketUtilizationByWeek', () => { + const toolGroup = { tools: [makeToolItem()] }; + + it('returns 1 bucket when range is less than one week', () => { + const start = new Date('2026-01-01T00:00:00Z'); + const end = new Date('2026-01-03T00:00:00Z'); // 2 days + const buckets = bucketUtilizationByWeek(toolGroup, start, end); + expect(buckets).toHaveLength(1); + }); + + it('returns 2 buckets for a 14-day range', () => { + const start = new Date('2026-01-01T00:00:00Z'); + const end = new Date('2026-01-15T00:00:00Z'); + const buckets = bucketUtilizationByWeek(toolGroup, start, end); + expect(buckets).toHaveLength(2); + }); + + it('returns 5 buckets for a 30-day range', () => { + const start = new Date('2026-01-01T00:00:00Z'); + const end = new Date('2026-01-31T00:00:00Z'); + const buckets = bucketUtilizationByWeek(toolGroup, start, end); + expect(buckets).toHaveLength(5); + }); + + it('each bucket has weekIndex, weekStart, weekEnd, and utilizationRate', () => { + const start = new Date('2026-01-01T00:00:00Z'); + const end = new Date('2026-01-15T00:00:00Z'); + const buckets = bucketUtilizationByWeek(toolGroup, start, end); + buckets.forEach((bucket, i) => { + expect(bucket.weekIndex).toBe(i); + expect(bucket.weekStart).toBeInstanceOf(Date); + expect(bucket.weekEnd).toBeInstanceOf(Date); + expect(typeof bucket.utilizationRate).toBe('number'); + }); + }); +}); + +// ─── forecastUtilization ─── +describe('forecastUtilization', () => { + const makeWeeklyBuckets = (count, rate = 50) => + Array.from({ length: count }, (_, i) => ({ + weekIndex: i, + weekStart: new Date(Date.now() - (count - i) * 7 * 24 * 3600000), + weekEnd: new Date(Date.now() - (count - i - 1) * 7 * 24 * 3600000), + utilizationRate: rate, + })); + + it('returns predictedRate 0 and method average for empty buckets', () => { + const result = forecastUtilization([], 30); + expect(result.predictedRate).toBe(0); + expect(result.method).toBe('average'); + expect(result.confidence).toBe('low'); + }); + + it('uses average method when fewer than 3 buckets', () => { + const result = forecastUtilization(makeWeeklyBuckets(2, 60), 30); + expect(result.method).toBe('average'); + expect(result.predictedRate).toBe(60); + expect(result.confidence).toBe('low'); + }); + + it('uses average when exactly 1 bucket', () => { + const result = forecastUtilization(makeWeeklyBuckets(1, 40), 30); + expect(result.method).toBe('average'); + expect(result.predictedRate).toBe(40); + }); + + it('uses ensemble method with high confidence for r2 >= 0.7', () => { + regression.linear.mockReturnValue({ r2: 0.8, predict: jest.fn().mockReturnValue([0, 65]) }); + const result = forecastUtilization(makeWeeklyBuckets(4, 60), 30); + expect(result.method).toBe('ensemble'); + expect(result.confidence).toBe('high'); + }); + + it('uses ensemble with medium confidence for r2 between 0.4 and 0.7', () => { + regression.linear.mockReturnValue({ r2: 0.5, predict: jest.fn().mockReturnValue([0, 55]) }); + const result = forecastUtilization(makeWeeklyBuckets(4, 50), 30); + expect(result.method).toBe('ensemble'); + expect(result.confidence).toBe('medium'); + }); + + it('uses ensemble with low confidence for r2 below 0.4', () => { + regression.linear.mockReturnValue({ r2: 0.2, predict: jest.fn().mockReturnValue([0, 50]) }); + const result = forecastUtilization(makeWeeklyBuckets(4, 50), 30); + expect(result.confidence).toBe('low'); + }); + + it('clamps blended prediction to 0 when regression predicts very low', () => { + regression.linear.mockReturnValue({ r2: 0.9, predict: jest.fn().mockReturnValue([0, -100]) }); + const result = forecastUtilization(makeWeeklyBuckets(4, 0), 30); + result.weeklyPredictions.forEach((w) => { + expect(w.predictedRate).toBeGreaterThanOrEqual(0); + }); + }); + + it('clamps blended prediction to 100 when regression predicts very high', () => { + regression.linear.mockReturnValue({ r2: 0.9, predict: jest.fn().mockReturnValue([0, 200]) }); + const result = forecastUtilization(makeWeeklyBuckets(4, 100), 30); + result.weeklyPredictions.forEach((w) => { + expect(w.predictedRate).toBeLessThanOrEqual(100); + }); + }); + + it('returns ISO forecastEndDate and predictedClassification', () => { + const result = forecastUtilization(makeWeeklyBuckets(2, 50), 30); + expect(() => new Date(result.forecastEndDate)).not.toThrow(); + expect(result.predictedClassification).toHaveProperty('label'); + expect(result.predictedClassification).toHaveProperty('trafficLight'); + }); +}); + +// ─── generateMaintenanceAlerts ─── +describe('generateMaintenanceAlerts', () => { + it('returns no alerts for a normal tool', () => { + const data = [makeUtilizationItem('Drill', 70, ['Purchased'], ['Good'], ['Available'])]; + expect(generateMaintenanceAlerts(data)).toHaveLength(0); + }); + + it('generates overuse alert when rate exceeds 85', () => { + const data = [makeUtilizationItem('Drill', 90, ['Purchased'], ['Good'], ['Available'])]; + const alerts = generateMaintenanceAlerts(data); + const overuseAlert = alerts.find((a) => a.alertType === 'overuse'); + expect(overuseAlert).toBeDefined(); + expect(overuseAlert.urgency).toBe('high'); + expect(overuseAlert.message).toContain('90%'); + }); + + it('generates condition alert for degraded tool condition', () => { + const data = [makeUtilizationItem('Drill', 70, [], ['Worn'], [])]; + const alerts = generateMaintenanceAlerts(data); + expect(alerts.find((a) => a.alertType === 'condition')).toBeDefined(); + }); + + it('deduplicates condition alerts for same condition appearing twice', () => { + const data = [makeUtilizationItem('Drill', 70, [], ['Worn', 'Worn'], [])]; + const conditionAlerts = generateMaintenanceAlerts(data).filter( + (a) => a.alertType === 'condition', + ); + expect(conditionAlerts).toHaveLength(1); + }); + + it('does not generate condition alert for Good condition', () => { + const data = [makeUtilizationItem('Drill', 70, [], ['Good'], [])]; + expect(generateMaintenanceAlerts(data).filter((a) => a.alertType === 'condition')).toHaveLength( + 0, + ); + }); + + it('generates non_operational alert when tool is Under Maintenance', () => { + const data = [makeUtilizationItem('Drill', 70, [], [], ['Under Maintenance'])]; + const alerts = generateMaintenanceAlerts(data); + const nonOpAlert = alerts.find((a) => a.alertType === 'non_operational'); + expect(nonOpAlert).toBeDefined(); + expect(nonOpAlert.urgency).toBe('medium'); + expect(nonOpAlert.message).toContain('1 unit(s)'); + }); + + it('counts multiple non-operational units in single alert per status', () => { + const data = [ + makeUtilizationItem('Drill', 70, [], [], ['Under Maintenance', 'Under Maintenance']), + ]; + const nonOpAlerts = generateMaintenanceAlerts(data).filter( + (a) => a.alertType === 'non_operational', + ); + expect(nonOpAlerts).toHaveLength(1); + expect(nonOpAlerts[0].message).toContain('2 unit(s)'); + }); +}); + +// ─── generateResourceBalancingSuggestions ─── +describe('generateResourceBalancingSuggestions', () => { + it('returns no suggestions for all-normal tools', () => { + const data = [makeUtilizationItem('Drill', 70), makeUtilizationItem('Hammer', 65)]; + expect(generateResourceBalancingSuggestions(data)).toHaveLength(0); + }); + + it('suggests redistribution when over and under-utilized tools coexist', () => { + const data = [makeUtilizationItem('Drill', 90), makeUtilizationItem('Wheelbarrow', 30)]; + const suggestions = generateResourceBalancingSuggestions(data); + expect(suggestions.find((s) => s.suggestion.includes('redistributing'))).toBeDefined(); + }); + + it('pairs over-utilized with the least-used under-utilized tool', () => { + const data = [ + makeUtilizationItem('Drill', 90), + makeUtilizationItem('Shovel', 40), + makeUtilizationItem('Wheelbarrow', 20), + ]; + const suggestions = generateResourceBalancingSuggestions(data); + const redist = suggestions.find((s) => s.suggestion.includes('redistributing')); + expect(redist.toTool).toBe('Wheelbarrow'); + }); + + it('suggests purchasing when over-utilized tool is mostly rented', () => { + const data = [makeUtilizationItem('Drill', 90, ['Rental', 'Rental', 'Purchased'])]; + const suggestions = generateResourceBalancingSuggestions(data); + expect(suggestions.find((s) => s.suggestion.includes('purchasing additional'))).toBeDefined(); + }); + + it('suggests renting when under-utilized tool is mostly owned', () => { + const data = [makeUtilizationItem('Shovel', 30, ['Purchased', 'Purchased', 'Rental'])]; + const suggestions = generateResourceBalancingSuggestions(data); + expect(suggestions.find((s) => s.suggestion.includes('renting instead'))).toBeDefined(); + }); + + it('no redistribution suggestion when over-utilized has no under-utilized counterpart', () => { + const data = [makeUtilizationItem('Drill', 90, ['Purchased', 'Purchased'])]; + const suggestions = generateResourceBalancingSuggestions(data); + expect(suggestions.find((s) => s.suggestion.includes('redistributing'))).toBeUndefined(); + }); +}); + +// ─── generateRecommendations ─── +describe('generateRecommendations', () => { + it('recommends review for under-utilized tool', () => { + const result = generateRecommendations([makeUtilizationItem('Shovel', 30)]); + expect(result[0].action).toContain('Potentially removable'); + expect(result[0].toolName).toBe('Shovel'); + expect(result[0].trafficLight).toBe('yellow'); + }); + + it('recommends no action for normal tool', () => { + const result = generateRecommendations([makeUtilizationItem('Drill', 70)]); + expect(result[0].action).toContain('Normal operation'); + expect(result[0].trafficLight).toBe('green'); + }); + + it('recommends maintenance planning for over-utilized tool', () => { + const result = generateRecommendations([makeUtilizationItem('Crane', 90)]); + expect(result[0].action).toContain('Requires maintenance scheduling'); + expect(result[0].trafficLight).toBe('red'); + }); +}); + +// ─── stripInternalDetails ─── +describe('stripInternalDetails', () => { + it('removes toolGroupDetails from item', () => { + const item = { name: 'Drill', utilizationRate: 75, toolGroupDetails: { tools: [] } }; + const result = stripInternalDetails(item); + expect(result.toolGroupDetails).toBeUndefined(); + }); + + it('preserves all other public fields', () => { + const item = { + name: 'Drill', + utilizationRate: 75, + downtime: 100, + classification: { label: 'Normal', trafficLight: 'green' }, + toolCount: 2, + toolGroupDetails: { tools: [] }, + }; + const result = stripInternalDetails(item); + expect(result.name).toBe('Drill'); + expect(result.utilizationRate).toBe(75); + expect(result.downtime).toBe(100); + expect(result.classification).toBeDefined(); + expect(result.toolCount).toBe(2); + }); +}); + +// ─── buildInsightsSummary ─── +describe('buildInsightsSummary', () => { + it('returns all zeros for empty array', () => { + const result = buildInsightsSummary([]); + expect(result).toEqual({ + totalToolTypes: 0, + underUtilized: 0, + normal: 0, + overUtilized: 0, + averageUtilization: 0, + }); + }); + + it('counts all as normal when all tools are normal', () => { + const data = [makeUtilizationItem('A', 60), makeUtilizationItem('B', 75)]; + const result = buildInsightsSummary(data); + expect(result.normal).toBe(2); + expect(result.underUtilized).toBe(0); + expect(result.overUtilized).toBe(0); + }); + + it('correctly counts mixed utilization categories', () => { + const data = [ + makeUtilizationItem('A', 30), // under + makeUtilizationItem('B', 70), // normal + makeUtilizationItem('C', 90), // over + ]; + const result = buildInsightsSummary(data); + expect(result.underUtilized).toBe(1); + expect(result.normal).toBe(1); + expect(result.overUtilized).toBe(1); + expect(result.totalToolTypes).toBe(3); + }); + + it('computes averageUtilization as rounded mean', () => { + const data = [makeUtilizationItem('A', 60), makeUtilizationItem('B', 80)]; + const result = buildInsightsSummary(data); + expect(result.averageUtilization).toBe(70); + }); +}); + +// ─── determineForecastDays ─── +describe('determineForecastDays', () => { + it('returns 30 days with no warning for forecast30 mode', async () => { + const result = await determineForecastDays('forecast30', 'anyId'); + expect(result.forecastDays).toBe(30); + expect(result.warning).toBeNull(); + }); + + it('returns 30 days with warning when no projectId for forecastFull', async () => { + const result = await determineForecastDays('forecastFull', undefined); + expect(result.forecastDays).toBe(30); + expect(result.warning).toContain('No specific project'); + }); + + it('returns 30 days with warning when projectId is ALL', async () => { + const result = await determineForecastDays('forecastFull', 'ALL'); + expect(result.forecastDays).toBe(30); + expect(result.warning).toBeTruthy(); + }); + + it('returns 30 days with warning when no risk profile found', async () => { + ProjectRiskProfile.findOne.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }); + const result = await determineForecastDays('forecastFull', 'validProjectId'); + expect(result.forecastDays).toBe(30); + expect(result.warning).toContain('No project schedule'); + }); + + it('returns 30 days with warning when risk profile has no endDate', async () => { + ProjectRiskProfile.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ projectId: 'p1' }), + }); + const result = await determineForecastDays('forecastFull', 'validProjectId'); + expect(result.forecastDays).toBe(30); + expect(result.warning).toBeTruthy(); + }); + + it('returns calculated days when risk profile has a future endDate', async () => { + const futureDate = new Date(Date.now() + 60 * 24 * 3600000); + ProjectRiskProfile.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ endDate: futureDate }), + }); + const result = await determineForecastDays('forecastFull', 'validProjectId'); + expect(result.forecastDays).toBeGreaterThan(50); + expect(result.warning).toBeNull(); + }); + + it('enforces minimum of 7 days when endDate is very soon', async () => { + const nearDate = new Date(Date.now() + 2 * 24 * 3600000); + ProjectRiskProfile.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ endDate: nearDate }), + }); + const result = await determineForecastDays('forecastFull', 'validProjectId'); + expect(result.forecastDays).toBe(7); + }); +}); + +// ─── buildUtilizationResponse ─── +describe('buildUtilizationResponse', () => { + const rangeStart = new Date('2026-01-01T00:00:00Z'); + const rangeEnd = new Date('2026-01-22T00:00:00Z'); // 21 days → 3 weekly buckets + + const makeItem = (name, rate) => ({ + ...makeUtilizationItem(name, rate), + toolGroupDetails: { + tools: [makeToolItem()], + purchaseStatuses: [], + conditions: [], + currentUsages: [], + }, + }); + + it('sets forecast to null in historical mode', async () => { + const data = [makeItem('Drill', 70)]; + const result = await buildUtilizationResponse({ + utilizationData: data, + rangeStart, + rangeEnd, + selectedMode: 'historical', + project: null, + }); + expect(result[0].forecast).toBeNull(); + expect(result[0].toolGroupDetails).toBeUndefined(); + }); + + it('attaches forecast object in forecast30 mode', async () => { + const data = [makeItem('Drill', 70)]; + const result = await buildUtilizationResponse({ + utilizationData: data, + rangeStart, + rangeEnd, + selectedMode: 'forecast30', + project: null, + }); + expect(result[0].forecast).not.toBeNull(); + expect(result[0].forecast).toHaveProperty('predictedRate'); + expect(result[0].forecast).toHaveProperty('weeklyPredictions'); + }); + + it('adds warning to all items when no project selected in forecastFull', async () => { + const data = [makeItem('Drill', 70), makeItem('Hammer', 50)]; + const result = await buildUtilizationResponse({ + utilizationData: data, + rangeStart, + rangeEnd, + selectedMode: 'forecastFull', + project: null, + }); + expect(result[0].warning).toBeTruthy(); + expect(result[1].warning).toBeTruthy(); + }); + + it('does not add warning when valid project has a risk profile', async () => { + const futureDate = new Date(Date.now() + 60 * 24 * 3600000); + ProjectRiskProfile.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ endDate: futureDate }), + }); + const data = [makeItem('Drill', 70)]; + const result = await buildUtilizationResponse({ + utilizationData: data, + rangeStart, + rangeEnd, + selectedMode: 'forecastFull', + project: 'validProjectId', + }); + expect(result[0].warning).toBeUndefined(); + }); + + it('strips toolGroupDetails from all response items', async () => { + const data = [makeItem('Drill', 70)]; + const result = await buildUtilizationResponse({ + utilizationData: data, + rangeStart, + rangeEnd, + selectedMode: 'historical', + project: null, + }); + expect(result[0].toolGroupDetails).toBeUndefined(); + }); +}); + +// ─── buildReportPayload ─── +describe('buildReportPayload', () => { + const rangeStart = new Date('2026-01-01T00:00:00Z'); + const rangeEnd = new Date('2026-01-31T00:00:00Z'); + + it('returns object with all required keys', () => { + const data = [makeUtilizationItem('Drill', 70)]; + const result = buildReportPayload(data, rangeStart, rangeEnd, 'proj1'); + expect(result).toHaveProperty('utilizationData'); + expect(result).toHaveProperty('alerts'); + expect(result).toHaveProperty('balancing'); + expect(result).toHaveProperty('recommendations'); + expect(result).toHaveProperty('summary'); + expect(result).toHaveProperty('metadata'); + }); + + it('builds metadata with correct dateRange and projectFilter', () => { + const data = [makeUtilizationItem('Drill', 70)]; + const result = buildReportPayload(data, rangeStart, rangeEnd, 'myProject'); + expect(result.metadata.dateRange).toContain(rangeStart.toISOString()); + expect(result.metadata.dateRange).toContain(rangeEnd.toISOString()); + expect(result.metadata.projectFilter).toBe('myProject'); + }); + + it('defaults projectFilter to ALL when project is not provided', () => { + const data = []; + const result = buildReportPayload(data, rangeStart, rangeEnd, undefined); + expect(result.metadata.projectFilter).toBe('ALL'); + }); +}); diff --git a/src/helpers/__tests__/toolUtilizationReportHelpers.test.js b/src/helpers/__tests__/toolUtilizationReportHelpers.test.js new file mode 100644 index 000000000..644473d2c --- /dev/null +++ b/src/helpers/__tests__/toolUtilizationReportHelpers.test.js @@ -0,0 +1,181 @@ +jest.mock('pdfkit', () => jest.fn()); +jest.mock('json2csv', () => ({ Parser: jest.fn() })); + +const PDFDocument = require('pdfkit'); +const { Parser } = require('json2csv'); +const { generatePDFReport, generateCSVReport } = require('../toolUtilizationReportHelpers'); + +// ─── Shared fixtures ─── +const makeReportData = (overrides = {}) => ({ + utilizationData: [ + { + name: 'Drill', + utilizationRate: 90, + downtime: 72, + classification: { label: 'Over-utilized', trafficLight: 'red' }, + toolCount: 2, + }, + { + name: 'Wheelbarrow', + utilizationRate: 30, + downtime: 504, + classification: { label: 'Under-utilized', trafficLight: 'yellow' }, + toolCount: 1, + }, + ], + alerts: [{ toolName: 'Drill', urgency: 'high', message: 'High utilization at 90%.' }], + balancing: [ + { + suggestion: 'Redistribute.', + rationale: 'Drill at 90%.', + fromTool: 'Drill', + toTool: 'Wheelbarrow', + }, + ], + recommendations: [ + { toolName: 'Drill', action: 'Schedule maintenance.' }, + { toolName: 'Wheelbarrow', action: 'Review necessity.' }, + ], + summary: { + totalToolTypes: 2, + underUtilized: 1, + normal: 0, + overUtilized: 1, + averageUtilization: 60, + }, + metadata: { + generatedDate: '3/20/2026', + dateRange: '2026-01-01 to 2026-01-31', + projectFilter: 'ALL', + }, + ...overrides, +}); + +// ─── generatePDFReport ─── +describe('generatePDFReport', () => { + let mockDoc; + let mockRes; + + beforeEach(() => { + jest.clearAllMocks(); + mockDoc = { + fontSize: jest.fn().mockReturnThis(), + font: jest.fn().mockReturnThis(), + text: jest.fn().mockReturnThis(), + moveDown: jest.fn().mockReturnThis(), + addPage: jest.fn().mockReturnThis(), + fillColor: jest.fn().mockReturnThis(), + pipe: jest.fn(), + end: jest.fn(), + y: 100, + }; + PDFDocument.mockImplementation(() => mockDoc); + + mockRes = { + setHeader: jest.fn(), + pipe: jest.fn(), + }; + }); + + it('sets Content-Type header to application/pdf', () => { + generatePDFReport(mockRes, makeReportData()); + expect(mockRes.setHeader).toHaveBeenCalledWith('Content-Type', 'application/pdf'); + }); + + it('sets Content-Disposition header with pdf filename', () => { + generatePDFReport(mockRes, makeReportData()); + const [, disposition] = mockRes.setHeader.mock.calls.find(([h]) => h === 'Content-Disposition'); + expect(disposition).toMatch(/attachment; filename="tool-utilization-report-\d+\.pdf"/); + }); + + it('pipes document to response', () => { + generatePDFReport(mockRes, makeReportData()); + expect(mockDoc.pipe).toHaveBeenCalledWith(mockRes); + }); + + it('calls doc.end() to finalize PDF', () => { + generatePDFReport(mockRes, makeReportData()); + expect(mockDoc.end).toHaveBeenCalled(); + }); + + it('skips empty sections when alerts, balancing, and recommendations are empty', () => { + const emptyData = makeReportData({ alerts: [], balancing: [], recommendations: [] }); + const moveDownCallsBefore = mockDoc.moveDown.mock.calls.length; + generatePDFReport(mockRes, emptyData); + // writePDFSection returns early for empty arrays, so fewer moveDown calls + // At minimum doc.end() is called meaning the function completed + expect(mockDoc.end).toHaveBeenCalled(); + expect(mockDoc.moveDown.mock.calls.length).toBeLessThanOrEqual(moveDownCallsBefore + 10); + }); + + it('calls addPage when doc.y exceeds page break threshold', () => { + mockDoc.y = 750; // above PAGE_BREAK_THRESHOLD (700) + generatePDFReport(mockRes, makeReportData()); + expect(mockDoc.addPage).toHaveBeenCalled(); + }); +}); + +// ─── generateCSVReport ─── +describe('generateCSVReport', () => { + let mockRes; + let mockParseInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockParseInstance = { parse: jest.fn().mockReturnValue('Tool Name,Rate\nDrill,90') }; + Parser.mockImplementation(() => mockParseInstance); + + mockRes = { + setHeader: jest.fn(), + send: jest.fn(), + }; + }); + + it('sets Content-Type header to text/csv', () => { + generateCSVReport(mockRes, makeReportData()); + expect(mockRes.setHeader).toHaveBeenCalledWith('Content-Type', 'text/csv; charset=utf-8'); + }); + + it('sets Content-Disposition header with csv filename', () => { + generateCSVReport(mockRes, makeReportData()); + const [, disposition] = mockRes.setHeader.mock.calls.find(([h]) => h === 'Content-Disposition'); + expect(disposition).toMatch(/attachment; filename="tool-utilization-report-\d+\.csv"/); + }); + + it('sends response with UTF-8 BOM prefix', () => { + generateCSVReport(mockRes, makeReportData()); + const sent = mockRes.send.mock.calls[0][0]; + expect(sent.startsWith('\ufeff')).toBe(true); + }); + + it('sets maintenanceAlert to None when no alerts for a tool', () => { + const data = makeReportData({ alerts: [] }); + generateCSVReport(mockRes, data); + const rowsPassed = mockParseInstance.parse.mock.calls[0][0]; + rowsPassed.forEach((row) => { + expect(row.maintenanceAlert).toBe('None'); + }); + }); + + it('concatenates multiple alerts for the same tool with semicolons', () => { + const data = makeReportData({ + alerts: [ + { toolName: 'Drill', message: 'High usage.' }, + { toolName: 'Drill', message: 'Condition worn.' }, + ], + }); + generateCSVReport(mockRes, data); + const rowsPassed = mockParseInstance.parse.mock.calls[0][0]; + const drillRow = rowsPassed.find((r) => r.name === 'Drill'); + expect(drillRow.maintenanceAlert).toBe('High usage.; Condition worn.'); + }); + + it('sets recommendation to None when no recommendation exists for a tool', () => { + const data = makeReportData({ recommendations: [] }); + generateCSVReport(mockRes, data); + const rowsPassed = mockParseInstance.parse.mock.calls[0][0]; + rowsPassed.forEach((row) => { + expect(row.recommendation).toBe('None'); + }); + }); +}); From 786106169c63fbad67c84141e04e2c537702f4f8 Mon Sep 17 00:00:00 2001 From: Aditya Gambhir <67105262+Aditya-gam@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:43:43 -0700 Subject: [PATCH 4/5] fix(utilization): initialize node cache instance Call the node cache factory to get the cache object before using hasCache/getCache/setCache, and align helper tests with the factory mock shape so cache-hit and cache-miss behavior is exercised correctly. Made-with: Cursor --- src/helpers/__tests__/toolUtilizationHelpers.test.js | 9 +++------ src/helpers/toolUtilizationHelpers.js | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/helpers/__tests__/toolUtilizationHelpers.test.js b/src/helpers/__tests__/toolUtilizationHelpers.test.js index e7e7dfd7c..dc7baa86d 100644 --- a/src/helpers/__tests__/toolUtilizationHelpers.test.js +++ b/src/helpers/__tests__/toolUtilizationHelpers.test.js @@ -13,16 +13,13 @@ jest.mock('regression', () => ({ linear: jest.fn() })); jest.mock('../../models/bmdashboard/projectRiskProfile', () => ({ findOne: jest.fn() })); -jest.mock('../../utilities/nodeCache', () => ({ - hasCache: jest.fn(), - getCache: jest.fn(), - setCache: jest.fn(), -})); +const mockCache = { hasCache: jest.fn(), getCache: jest.fn(), setCache: jest.fn() }; +jest.mock('../../utilities/nodeCache', () => jest.fn(() => mockCache)); const mongoose = require('mongoose'); const regression = require('regression'); const ProjectRiskProfile = require('../../models/bmdashboard/projectRiskProfile'); -const cache = require('../../utilities/nodeCache'); +const cache = require('../../utilities/nodeCache')(); const { classifyUtilization, calculateCheckedOutHours, diff --git a/src/helpers/toolUtilizationHelpers.js b/src/helpers/toolUtilizationHelpers.js index 98bbdb6bd..13cf1dd99 100644 --- a/src/helpers/toolUtilizationHelpers.js +++ b/src/helpers/toolUtilizationHelpers.js @@ -1,7 +1,7 @@ const mongoose = require('mongoose'); const regression = require('regression'); const ProjectRiskProfile = require('../models/bmdashboard/projectRiskProfile'); -const cache = require('../utilities/nodeCache'); +const cache = require('../utilities/nodeCache')(); const { UTILIZATION_THRESHOLDS, UTILIZATION_LABELS, From 81f64b82c374df9ac01af65b56126099aacfdfa7 Mon Sep 17 00:00:00 2001 From: Aditya Gambhir <67105262+Aditya-gam@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:44:06 -0700 Subject: [PATCH 5/5] fix(utilization): validate date ranges in endpoints Reject requests where startDate is after endDate in utilization, insights, and export handlers to prevent invalid queries and return consistent 400 responses before any data processing. Made-with: Cursor --- .../toolUtilizationController.test.js | 39 +++++++++++++++++++ .../bmdashboard/toolUtilizationController.js | 15 ++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/controllers/bmdashboard/__tests__/toolUtilizationController.test.js b/src/controllers/bmdashboard/__tests__/toolUtilizationController.test.js index c8acf176a..a7cd2a44f 100644 --- a/src/controllers/bmdashboard/__tests__/toolUtilizationController.test.js +++ b/src/controllers/bmdashboard/__tests__/toolUtilizationController.test.js @@ -117,6 +117,19 @@ describe('getUtilization', () => { ); }); + it('returns 400 when startDate is after endDate', async () => { + const req = makeReq({ startDate: '2026-01-31', endDate: '2026-01-01' }); + const res = makeRes(); + + await controller.getUtilization(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('startDate') }), + ); + expect(computeUtilizationData).not.toHaveBeenCalled(); + }); + it('returns 500 when computeUtilizationData throws', async () => { computeUtilizationData.mockRejectedValue(new Error('DB failure')); const req = makeReq({}); @@ -186,6 +199,19 @@ describe('getInsights', () => { expect(buildInsightsSummary).toHaveBeenCalledWith(mockUtilizationData); }); + it('returns 400 when startDate is after endDate', async () => { + const req = makeReq({ startDate: '2026-01-31', endDate: '2026-01-01' }); + const res = makeRes(); + + await controller.getInsights(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('startDate') }), + ); + expect(computeUtilizationData).not.toHaveBeenCalled(); + }); + it('returns 500 when computeUtilizationData throws', async () => { computeUtilizationData.mockRejectedValue(new Error('DB error')); const req = makeReq({}); @@ -223,6 +249,19 @@ describe('exportReport', () => { expect(res.status).toHaveBeenCalledWith(400); }); + it('returns 400 when startDate is after endDate', async () => { + const req = makeReq({ format: 'csv', startDate: '2026-01-31', endDate: '2026-01-01' }); + const res = makeRes(); + + await controller.exportReport(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('startDate') }), + ); + expect(computeUtilizationData).not.toHaveBeenCalled(); + }); + it('calls generateCSVReport for csv format', async () => { const req = makeReq({ format: 'csv' }); const res = makeRes(); diff --git a/src/controllers/bmdashboard/toolUtilizationController.js b/src/controllers/bmdashboard/toolUtilizationController.js index c085b4528..6378632d5 100644 --- a/src/controllers/bmdashboard/toolUtilizationController.js +++ b/src/controllers/bmdashboard/toolUtilizationController.js @@ -30,6 +30,10 @@ const toolUtilizationController = function (BuildingTool) { }); } + if (startDate && endDate && new Date(startDate) > new Date(endDate)) { + return res.status(400).json({ error: 'startDate cannot be after endDate.' }); + } + const { utilizationData, rangeStart, rangeEnd } = await computeUtilizationData(BuildingTool, { tool, project, @@ -54,6 +58,11 @@ const toolUtilizationController = function (BuildingTool) { const getInsights = async (req, res) => { try { + const { startDate, endDate } = req.query; + if (startDate && endDate && new Date(startDate) > new Date(endDate)) { + return res.status(400).json({ error: 'startDate cannot be after endDate.' }); + } + const { utilizationData } = await computeUtilizationData(BuildingTool, req.query); return res.status(200).json({ @@ -70,7 +79,11 @@ const toolUtilizationController = function (BuildingTool) { const exportReport = async (req, res) => { try { - const { format } = req.query; + const { format, startDate, endDate } = req.query; + + if (startDate && endDate && new Date(startDate) > new Date(endDate)) { + return res.status(400).json({ error: 'startDate cannot be after endDate.' }); + } if (!format || !REPORT_FORMATS.includes(format)) { return res.status(400).json({