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/controllers/bmdashboard/__tests__/toolUtilizationController.test.js b/src/controllers/bmdashboard/__tests__/toolUtilizationController.test.js new file mode 100644 index 000000000..a7cd2a44f --- /dev/null +++ b/src/controllers/bmdashboard/__tests__/toolUtilizationController.test.js @@ -0,0 +1,307 @@ +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 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({}); + 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 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({}); + 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('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(); + + 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/controllers/bmdashboard/toolUtilizationController.js b/src/controllers/bmdashboard/toolUtilizationController.js index dff96ce80..6378632d5 100644 --- a/src/controllers/bmdashboard/toolUtilizationController.js +++ b/src/controllers/bmdashboard/toolUtilizationController.js @@ -1,157 +1,125 @@ -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); - } + if (mode && !VALID_FORECAST_MODES.includes(selectedMode)) { + return res.status(400).json({ + error: `Invalid mode. Must be one of: ${VALID_FORECAST_MODES.join(', ')}`, + }); } - // Filter by project if specified - if (project && project !== 'ALL') { - if (mongoose.Types.ObjectId.isValid(project)) { - toolFilter.project = mongoose.Types.ObjectId(project); - } + if (startDate && endDate && new Date(startDate) > new Date(endDate)) { + return res.status(400).json({ error: 'startDate cannot be after endDate.' }); } - // 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)); - - // Calculate checked out time - 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 checkInTime = new Date(log.date); - const hoursCheckedOut = (checkInTime - lastCheckOut) / (1000 * 60 * 60); - checkedOutTime += Math.max(0, hoursCheckedOut); - lastCheckOut = null; - } - }); - - // If still checked out at the end of the range - if (lastCheckOut) { - const hoursCheckedOut = (rangeEnd - lastCheckOut) / (1000 * 60 * 60); - checkedOutTime += Math.max(0, hoursCheckedOut); - } - - // 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; - } - } - } - - totalCheckedOutHours += checkedOutTime; - }); + const responseData = await buildUtilizationResponse({ + utilizationData, + rangeStart, + rangeEnd, + selectedMode, + project, + }); + + return res.status(200).json(responseData); + } catch (err) { + logger.logException(err, 'toolUtilizationController.getUtilization'); + return res.status(500).json({ error: `Server error: ${err.message}` }); + } + }; + + 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); - // 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 - }; + 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}` }); + } + }; + + const exportReport = async (req, res) => { + try { + 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.' }); + } - // Sort by utilization rate (descending) - utilizationData.sort((a, b) => b.utilizationRate - a.utilizationRate); + if (!format || !REPORT_FORMATS.includes(format)) { + return res.status(400).json({ + error: `Invalid format. Must be one of: ${REPORT_FORMATS.join(', ')}`, + }); + } - 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/helpers/__tests__/toolUtilizationHelpers.test.js b/src/helpers/__tests__/toolUtilizationHelpers.test.js new file mode 100644 index 000000000..dc7baa86d --- /dev/null +++ b/src/helpers/__tests__/toolUtilizationHelpers.test.js @@ -0,0 +1,872 @@ +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() })); + +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 { + 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'); + }); + }); +}); diff --git a/src/helpers/toolUtilizationHelpers.js b/src/helpers/toolUtilizationHelpers.js new file mode 100644 index 000000000..13cf1dd99 --- /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, +}; 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; };