From b66f800f260c3eb0f3d5a5cb23c8c6020cfc3f5a Mon Sep 17 00:00:00 2001 From: Linh Huynh Date: Mon, 26 Jan 2026 23:21:55 -0800 Subject: [PATCH 1/4] feat: add Material Usage Insights controller, routes, and API endpoints --- .../bmdashboard/bmMaterialInsightsRouter.js | 71 +++++++++++++++++++ src/startup/routes.js | 4 ++ 2 files changed, 75 insertions(+) create mode 100644 src/routes/bmdashboard/bmMaterialInsightsRouter.js diff --git a/src/routes/bmdashboard/bmMaterialInsightsRouter.js b/src/routes/bmdashboard/bmMaterialInsightsRouter.js new file mode 100644 index 000000000..e744b1c9c --- /dev/null +++ b/src/routes/bmdashboard/bmMaterialInsightsRouter.js @@ -0,0 +1,71 @@ +/** + * Material Insights Router + * Provides API endpoints for Material Usage Insights & Visual Indicators feature + * All endpoints return structured JSON with success flag, data, and timestamp + */ + +const express = require('express'); + +const routes = function (BuildingMaterial) { + const insightsRouter = express.Router(); + const controller = require('../../controllers/bmdashboard/bmMaterialInsightsController')( + BuildingMaterial, + ); + + /** + * GET /materials/insights/all + * Get insights for all materials with summary metrics + * Response: { success, data: { materials[], summary{}, timestamp } } + */ + insightsRouter.route('/insights/all').get(controller.getMaterialInsightsAll); + + /** + * GET /materials/insights/summary + * Get summary metrics for all materials (lightweight) + * Response: { success, data: { totalMaterials, lowStockCount, lowStockPercentage, ... } } + */ + insightsRouter.route('/insights/summary').get(controller.getSummaryMetrics); + + /** + * GET /materials/insights/by-project/:projectId + * Get insights for materials in a specific project + * Response: { success, data: { projectId, materials[], summary{}, timestamp } } + */ + insightsRouter + .route('/insights/by-project/:projectId') + .get(controller.getMaterialInsightsByProject); + + /** + * GET /materials/insights/summary/by-project/:projectId + * Get summary metrics for a specific project + * Response: { success, data: { projectId, totalMaterials, lowStockCount, ... } } + */ + insightsRouter + .route('/insights/summary/by-project/:projectId') + .get(controller.getSummaryMetricsByProject); + + /** + * GET /materials/insights/critical-items + * Get all materials with critical/low stock levels + * Response: { success, data: { criticalItemCount, items[], timestamp } } + */ + insightsRouter.route('/insights/critical-items').get(controller.getCriticalStockItems); + + /** + * GET /materials/insights/high-usage-items + * Get all materials with high usage (>= 80%) + * Response: { success, data: { highUsageItemCount, items[], timestamp } } + */ + insightsRouter.route('/insights/high-usage-items').get(controller.getHighUsageItems); + + /** + * GET /materials/insights/:materialId + * Get detailed insights for a specific material + * Response: { success, data: { materialId, materialName, unit, projectId, ... } } + */ + insightsRouter.route('/insights/:materialId').get(controller.getMaterialInsightsDetail); + + return insightsRouter; +}; + +module.exports = routes; diff --git a/src/startup/routes.js b/src/startup/routes.js index 56a3ae939..69138ca47 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -198,6 +198,9 @@ const materialCostRouter = require('../routes/materialCostRouter')(); // bm dashboard const bmLoginRouter = require('../routes/bmdashboard/bmLoginRouter')(); const bmMaterialsRouter = require('../routes/bmdashboard/bmMaterialsRouter')(buildingMaterial); +const bmMaterialInsightsRouter = require('../routes/bmdashboard/bmMaterialInsightsRouter')( + buildingMaterial, +); const bmReusableRouter = require('../routes/bmdashboard/bmReusableRouter')(buildingReusable); const bmProjectRouter = require('../routes/bmdashboard/bmProjectRouter')(buildingProject); @@ -450,6 +453,7 @@ module.exports = function (app) { // bm dashboard app.use('/api/bm', bmLoginRouter); app.use('/api/bm', bmMaterialsRouter); + app.use('/api/bm', bmMaterialInsightsRouter); app.use('/api/bm', bmReusableRouter); app.use('/api/bm', bmProjectRouter); app.use('/api/bm', bmNewLessonRouter); From 7183a814b3a869479c619a41928beac64642f85e Mon Sep 17 00:00:00 2001 From: Linh Huynh Date: Tue, 27 Jan 2026 19:21:31 -0800 Subject: [PATCH 2/4] feat: add bmMaterialInsightsController --- .../bmMaterialInsightsController.js | 495 ++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 src/controllers/bmdashboard/bmMaterialInsightsController.js diff --git a/src/controllers/bmdashboard/bmMaterialInsightsController.js b/src/controllers/bmdashboard/bmMaterialInsightsController.js new file mode 100644 index 000000000..3e9b466d4 --- /dev/null +++ b/src/controllers/bmdashboard/bmMaterialInsightsController.js @@ -0,0 +1,495 @@ +/** + * Material Insights Controller + * Provides backend calculations and endpoints for Material Usage Insights feature + * Mirrors frontend calculations from materialInsights.js utility + */ + +const mongoose = require('mongoose'); + +const bmMaterialInsightsController = function (BuildingMaterial) { + /** + * Format a number to specified decimal places + * Handles floating point precision issues + * @param {number} value - The value to format + * @param {number} decimals - Number of decimal places (default: 2) + * @returns {number|null} Formatted number or null + */ + const formatNumber = (value, decimals = 2) => { + if (typeof value !== 'number' || Number.isNaN(value)) { + return null; + } + return Math.round(value * 10 ** decimals) / 10 ** decimals; + }; + + /** + * Calculate usage percentage (Used / Bought) + * @param {number} used - Amount used + * @param {number} bought - Amount bought + * @returns {number|null} Usage percentage or null if bought is 0 + */ + const calculateUsagePercentage = (used, bought) => { + if (!bought || bought <= 0) { + return null; + } + const percentage = (used / bought) * 100; + return formatNumber(percentage, 2); + }; + + /** + * Calculate stock ratio (Available / Bought) + * @param {number} available - Amount available + * @param {number} bought - Amount bought + * @returns {number|null} Stock ratio (0-1) or null if bought is 0 + */ + const calculateStockRatio = (available, bought) => { + if (!bought || bought <= 0) { + return null; + } + const ratio = available / bought; + return formatNumber(ratio, 2); + }; + + /** + * Get stock health status based on stock ratio + * Thresholds: + * - critical: <= 20% stock remaining + * - low: 20-40% stock remaining + * - healthy: > 40% stock remaining + * @param {number} stockRatio - The stock ratio (0-1) + * @returns {string} Health status: 'healthy', 'low', 'critical', or 'no-data' + */ + const getStockHealthStatus = (stockRatio) => { + if (stockRatio === null || stockRatio === undefined) { + return 'no-data'; + } + if (stockRatio <= 0.2) { + return 'critical'; + } + if (stockRatio <= 0.4) { + return 'low'; + } + return 'healthy'; + }; + + /** + * Get stock health color for UI display + * @param {string} status - Stock health status + * @returns {string} Color code: 'green', 'yellow', 'red', or 'gray' + */ + const getStockHealthColor = (status) => { + const colorMap = { + healthy: 'green', + low: 'yellow', + critical: 'red', + }; + return colorMap[status] || 'gray'; + }; + + /** + * Get stock health label for UI display + * @param {string} status - Stock health status + * @returns {string} Display label + */ + const getStockHealthLabel = (status) => { + const labelMap = { + healthy: 'Healthy', + low: 'Low', + critical: 'Critical', + }; + return labelMap[status] || 'No Data'; + }; + + /** + * Calculate all insights for a single material + * @param {object} material - Material document + * @returns {object} Insights object with all calculated values + */ + const calculateMaterialInsights = (material) => { + const bought = material?.stockBought || 0; + const used = material?.stockUsed || 0; + const available = material?.stockAvailable || 0; + const wasted = material?.stockWasted || 0; + const hold = material?.stockHold || 0; + + const usagePct = calculateUsagePercentage(used, bought); + const stockRatio = calculateStockRatio(available, bought); + const stockHealth = getStockHealthStatus(stockRatio); + const stockHealthColor = getStockHealthColor(stockHealth); + const stockHealthLabel = getStockHealthLabel(stockHealth); + + return { + materialId: material._id?.toString(), + materialName: material.itemType?.name || 'Unknown', + unit: material.itemType?.unit || '', + projectId: material.project?._id?.toString(), + projectName: material.project?.name || 'Unknown', + bought, + used, + available, + wasted, + hold, + usagePct, + stockRatio, + stockHealth, + stockHealthColor, + stockHealthLabel, + hasBoughtData: bought > 0, + }; + }; + + /** + * Calculate summary metrics from a list of materials + * @param {array} materials - Array of material documents + * @returns {object} Summary metrics for dashboard + */ + const calculateSummaryMetrics = (materials) => { + if (!materials || materials.length === 0) { + return { + totalMaterials: 0, + lowStockCount: 0, + lowStockPercentage: 0, + overUsageCount: 0, + overUsagePercentage: 0, + onHoldCount: 0, + usageThreshold: 80, + }; + } + + const total = materials.length; + let lowStockCount = 0; + let overUsageCount = 0; + let onHoldCount = 0; + + materials.forEach((material) => { + const insights = calculateMaterialInsights(material); + + // Count low/critical stock + if (insights.stockHealth === 'low' || insights.stockHealth === 'critical') { + lowStockCount += 1; + } + + // Count over usage threshold (default 80%) + if (insights.usagePct !== null && insights.usagePct >= 80) { + overUsageCount += 1; + } + + // Count items on hold + if ((material?.stockHold || 0) > 0) { + onHoldCount += 1; + } + }); + + const lowStockPercentage = formatNumber((lowStockCount / total) * 100, 1); + const overUsagePercentage = formatNumber((overUsageCount / total) * 100, 1); + + return { + totalMaterials: total, + lowStockCount, + lowStockPercentage, + overUsageCount, + overUsagePercentage, + onHoldCount, + usageThreshold: 80, + }; + }; + + /** + * GET /materials/insights/all + * Get insights for all materials with summary metrics + */ + const getMaterialInsightsAll = async (req, res) => { + try { + const materials = await BuildingMaterial.find() + .populate([ + { path: 'project', select: '_id name' }, + { path: 'itemType', select: '_id name unit' }, + ]) + .lean() + .exec(); + + const materialInsights = materials.map((material) => calculateMaterialInsights(material)); + + const summaryMetrics = calculateSummaryMetrics(materials); + + return res.status(200).json({ + success: true, + data: { + materials: materialInsights, + summary: summaryMetrics, + timestamp: new Date(), + }, + }); + } catch (error) { + console.error('Error fetching material insights:', error); + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: error.message, + }); + } + }; + + /** + * GET /materials/insights/by-project/:projectId + * Get insights for materials in a specific project + */ + const getMaterialInsightsByProject = async (req, res) => { + try { + const { projectId } = req.params; + + // Validate project ID + if (!mongoose.Types.ObjectId.isValid(projectId)) { + return res.status(400).json({ + success: false, + message: 'Invalid project ID', + }); + } + + const materials = await BuildingMaterial.find({ project: projectId }) + .populate([ + { path: 'project', select: '_id name' }, + { path: 'itemType', select: '_id name unit' }, + ]) + .lean() + .exec(); + + const materialInsights = materials.map((material) => calculateMaterialInsights(material)); + + const summaryMetrics = calculateSummaryMetrics(materials); + + return res.status(200).json({ + success: true, + data: { + projectId, + materials: materialInsights, + summary: summaryMetrics, + timestamp: new Date(), + }, + }); + } catch (error) { + console.error('Error fetching material insights by project:', error); + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: error.message, + }); + } + }; + + /** + * GET /materials/insights/summary + * Get summary metrics for all materials (lightweight endpoint) + */ + const getSummaryMetrics = async (req, res) => { + try { + const materials = await BuildingMaterial.find().lean().exec(); + + const summaryMetrics = calculateSummaryMetrics(materials); + + return res.status(200).json({ + success: true, + data: { + ...summaryMetrics, + timestamp: new Date(), + }, + }); + } catch (error) { + console.error('Error fetching summary metrics:', error); + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: error.message, + }); + } + }; + + /** + * GET /materials/insights/summary/by-project/:projectId + * Get summary metrics for a specific project + */ + const getSummaryMetricsByProject = async (req, res) => { + try { + const { projectId } = req.params; + + // Validate project ID + if (!mongoose.Types.ObjectId.isValid(projectId)) { + return res.status(400).json({ + success: false, + message: 'Invalid project ID', + }); + } + + const materials = await BuildingMaterial.find({ project: projectId }).lean().exec(); + + const summaryMetrics = calculateSummaryMetrics(materials); + + return res.status(200).json({ + success: true, + data: { + projectId, + ...summaryMetrics, + timestamp: new Date(), + }, + }); + } catch (error) { + console.error('Error fetching summary metrics by project:', error); + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: error.message, + }); + } + }; + + /** + * GET /materials/insights/critical-items + * Get all materials with critical stock levels across all projects + */ + const getCriticalStockItems = async (req, res) => { + try { + const materials = await BuildingMaterial.find() + .populate([ + { path: 'project', select: '_id name' }, + { path: 'itemType', select: '_id name unit' }, + ]) + .lean() + .exec(); + + const criticalItems = materials + .map((material) => calculateMaterialInsights(material)) + .filter((insight) => insight.stockHealth === 'critical' || insight.stockHealth === 'low') + .sort((a, b) => { + // Sort by stock ratio (lowest first) + if (a.stockRatio === null) return 1; + if (b.stockRatio === null) return -1; + return a.stockRatio - b.stockRatio; + }); + + return res.status(200).json({ + success: true, + data: { + criticalItemCount: criticalItems.length, + items: criticalItems, + timestamp: new Date(), + }, + }); + } catch (error) { + console.error('Error fetching critical stock items:', error); + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: error.message, + }); + } + }; + + /** + * GET /materials/insights/high-usage-items + * Get all materials with high usage (>= 80%) across all projects + */ + const getHighUsageItems = async (req, res) => { + try { + const materials = await BuildingMaterial.find() + .populate([ + { path: 'project', select: '_id name' }, + { path: 'itemType', select: '_id name unit' }, + ]) + .lean() + .exec(); + + const highUsageItems = materials + .map((material) => calculateMaterialInsights(material)) + .filter((insight) => insight.usagePct !== null && insight.usagePct >= 80) + .sort( + (a, b) => + // Sort by usage percentage (highest first) + (b.usagePct || 0) - (a.usagePct || 0), + ); + + return res.status(200).json({ + success: true, + data: { + highUsageItemCount: highUsageItems.length, + items: highUsageItems, + timestamp: new Date(), + }, + }); + } catch (error) { + console.error('Error fetching high usage items:', error); + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: error.message, + }); + } + }; + + /** + * GET /materials/insights/:materialId + * Get detailed insights for a specific material + */ + const getMaterialInsightsDetail = async (req, res) => { + try { + const { materialId } = req.params; + + // Validate material ID + if (!mongoose.Types.ObjectId.isValid(materialId)) { + return res.status(400).json({ + success: false, + message: 'Invalid material ID', + }); + } + + const material = await BuildingMaterial.findById(materialId) + .populate([ + { path: 'project', select: '_id name' }, + { path: 'itemType', select: '_id name unit' }, + ]) + .lean() + .exec(); + + if (!material) { + return res.status(404).json({ + success: false, + message: 'Material not found', + }); + } + + const insights = calculateMaterialInsights(material); + + return res.status(200).json({ + success: true, + data: { + ...insights, + timestamp: new Date(), + }, + }); + } catch (error) { + console.error('Error fetching material insights detail:', error); + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: error.message, + }); + } + }; + + // Export all functions + return { + getMaterialInsightsAll, + getMaterialInsightsByProject, + getSummaryMetrics, + getSummaryMetricsByProject, + getCriticalStockItems, + getHighUsageItems, + getMaterialInsightsDetail, + // Export calculation functions for testing + calculateMaterialInsights, + calculateSummaryMetrics, + calculateUsagePercentage, + calculateStockRatio, + getStockHealthStatus, + }; +}; + +module.exports = bmMaterialInsightsController; From a47df5f7ed49cdd0a2b34ff399466820413301a1 Mon Sep 17 00:00:00 2001 From: Linh Huynh Date: Tue, 27 Jan 2026 19:37:25 -0800 Subject: [PATCH 3/4] refactor(bmdashboard): modularize material insights controller Split controller into calculations, handlers, and composition layers to resolve ESLint violations and improve maintainability. --- .../bmMaterialInsightsController.js | 491 +----------------- .../materialInsightsCalculations.js | 150 ++++++ .../bmdashboard/materialInsightsHandlers.js | 193 +++++++ 3 files changed, 355 insertions(+), 479 deletions(-) create mode 100644 src/controllers/bmdashboard/materialInsightsCalculations.js create mode 100644 src/controllers/bmdashboard/materialInsightsHandlers.js diff --git a/src/controllers/bmdashboard/bmMaterialInsightsController.js b/src/controllers/bmdashboard/bmMaterialInsightsController.js index 3e9b466d4..bb54f195a 100644 --- a/src/controllers/bmdashboard/bmMaterialInsightsController.js +++ b/src/controllers/bmdashboard/bmMaterialInsightsController.js @@ -1,489 +1,22 @@ /** * Material Insights Controller - * Provides backend calculations and endpoints for Material Usage Insights feature - * Mirrors frontend calculations from materialInsights.js utility + * Factory function that assembles handlers and utilities for material insights endpoints */ -const mongoose = require('mongoose'); +const createHandlers = require('./materialInsightsHandlers'); +const { + calculateMaterialInsights, + calculateSummaryMetrics, + calculateUsagePercentage, + calculateStockRatio, + getStockHealthStatus, +} = require('./materialInsightsCalculations'); -const bmMaterialInsightsController = function (BuildingMaterial) { - /** - * Format a number to specified decimal places - * Handles floating point precision issues - * @param {number} value - The value to format - * @param {number} decimals - Number of decimal places (default: 2) - * @returns {number|null} Formatted number or null - */ - const formatNumber = (value, decimals = 2) => { - if (typeof value !== 'number' || Number.isNaN(value)) { - return null; - } - return Math.round(value * 10 ** decimals) / 10 ** decimals; - }; - - /** - * Calculate usage percentage (Used / Bought) - * @param {number} used - Amount used - * @param {number} bought - Amount bought - * @returns {number|null} Usage percentage or null if bought is 0 - */ - const calculateUsagePercentage = (used, bought) => { - if (!bought || bought <= 0) { - return null; - } - const percentage = (used / bought) * 100; - return formatNumber(percentage, 2); - }; - - /** - * Calculate stock ratio (Available / Bought) - * @param {number} available - Amount available - * @param {number} bought - Amount bought - * @returns {number|null} Stock ratio (0-1) or null if bought is 0 - */ - const calculateStockRatio = (available, bought) => { - if (!bought || bought <= 0) { - return null; - } - const ratio = available / bought; - return formatNumber(ratio, 2); - }; - - /** - * Get stock health status based on stock ratio - * Thresholds: - * - critical: <= 20% stock remaining - * - low: 20-40% stock remaining - * - healthy: > 40% stock remaining - * @param {number} stockRatio - The stock ratio (0-1) - * @returns {string} Health status: 'healthy', 'low', 'critical', or 'no-data' - */ - const getStockHealthStatus = (stockRatio) => { - if (stockRatio === null || stockRatio === undefined) { - return 'no-data'; - } - if (stockRatio <= 0.2) { - return 'critical'; - } - if (stockRatio <= 0.4) { - return 'low'; - } - return 'healthy'; - }; - - /** - * Get stock health color for UI display - * @param {string} status - Stock health status - * @returns {string} Color code: 'green', 'yellow', 'red', or 'gray' - */ - const getStockHealthColor = (status) => { - const colorMap = { - healthy: 'green', - low: 'yellow', - critical: 'red', - }; - return colorMap[status] || 'gray'; - }; - - /** - * Get stock health label for UI display - * @param {string} status - Stock health status - * @returns {string} Display label - */ - const getStockHealthLabel = (status) => { - const labelMap = { - healthy: 'Healthy', - low: 'Low', - critical: 'Critical', - }; - return labelMap[status] || 'No Data'; - }; - - /** - * Calculate all insights for a single material - * @param {object} material - Material document - * @returns {object} Insights object with all calculated values - */ - const calculateMaterialInsights = (material) => { - const bought = material?.stockBought || 0; - const used = material?.stockUsed || 0; - const available = material?.stockAvailable || 0; - const wasted = material?.stockWasted || 0; - const hold = material?.stockHold || 0; - - const usagePct = calculateUsagePercentage(used, bought); - const stockRatio = calculateStockRatio(available, bought); - const stockHealth = getStockHealthStatus(stockRatio); - const stockHealthColor = getStockHealthColor(stockHealth); - const stockHealthLabel = getStockHealthLabel(stockHealth); - - return { - materialId: material._id?.toString(), - materialName: material.itemType?.name || 'Unknown', - unit: material.itemType?.unit || '', - projectId: material.project?._id?.toString(), - projectName: material.project?.name || 'Unknown', - bought, - used, - available, - wasted, - hold, - usagePct, - stockRatio, - stockHealth, - stockHealthColor, - stockHealthLabel, - hasBoughtData: bought > 0, - }; - }; - - /** - * Calculate summary metrics from a list of materials - * @param {array} materials - Array of material documents - * @returns {object} Summary metrics for dashboard - */ - const calculateSummaryMetrics = (materials) => { - if (!materials || materials.length === 0) { - return { - totalMaterials: 0, - lowStockCount: 0, - lowStockPercentage: 0, - overUsageCount: 0, - overUsagePercentage: 0, - onHoldCount: 0, - usageThreshold: 80, - }; - } - - const total = materials.length; - let lowStockCount = 0; - let overUsageCount = 0; - let onHoldCount = 0; - - materials.forEach((material) => { - const insights = calculateMaterialInsights(material); - - // Count low/critical stock - if (insights.stockHealth === 'low' || insights.stockHealth === 'critical') { - lowStockCount += 1; - } - - // Count over usage threshold (default 80%) - if (insights.usagePct !== null && insights.usagePct >= 80) { - overUsageCount += 1; - } - - // Count items on hold - if ((material?.stockHold || 0) > 0) { - onHoldCount += 1; - } - }); - - const lowStockPercentage = formatNumber((lowStockCount / total) * 100, 1); - const overUsagePercentage = formatNumber((overUsageCount / total) * 100, 1); - - return { - totalMaterials: total, - lowStockCount, - lowStockPercentage, - overUsageCount, - overUsagePercentage, - onHoldCount, - usageThreshold: 80, - }; - }; - - /** - * GET /materials/insights/all - * Get insights for all materials with summary metrics - */ - const getMaterialInsightsAll = async (req, res) => { - try { - const materials = await BuildingMaterial.find() - .populate([ - { path: 'project', select: '_id name' }, - { path: 'itemType', select: '_id name unit' }, - ]) - .lean() - .exec(); - - const materialInsights = materials.map((material) => calculateMaterialInsights(material)); - - const summaryMetrics = calculateSummaryMetrics(materials); - - return res.status(200).json({ - success: true, - data: { - materials: materialInsights, - summary: summaryMetrics, - timestamp: new Date(), - }, - }); - } catch (error) { - console.error('Error fetching material insights:', error); - return res.status(500).json({ - success: false, - message: 'Internal server error', - error: error.message, - }); - } - }; - - /** - * GET /materials/insights/by-project/:projectId - * Get insights for materials in a specific project - */ - const getMaterialInsightsByProject = async (req, res) => { - try { - const { projectId } = req.params; - - // Validate project ID - if (!mongoose.Types.ObjectId.isValid(projectId)) { - return res.status(400).json({ - success: false, - message: 'Invalid project ID', - }); - } - - const materials = await BuildingMaterial.find({ project: projectId }) - .populate([ - { path: 'project', select: '_id name' }, - { path: 'itemType', select: '_id name unit' }, - ]) - .lean() - .exec(); - - const materialInsights = materials.map((material) => calculateMaterialInsights(material)); - - const summaryMetrics = calculateSummaryMetrics(materials); - - return res.status(200).json({ - success: true, - data: { - projectId, - materials: materialInsights, - summary: summaryMetrics, - timestamp: new Date(), - }, - }); - } catch (error) { - console.error('Error fetching material insights by project:', error); - return res.status(500).json({ - success: false, - message: 'Internal server error', - error: error.message, - }); - } - }; - - /** - * GET /materials/insights/summary - * Get summary metrics for all materials (lightweight endpoint) - */ - const getSummaryMetrics = async (req, res) => { - try { - const materials = await BuildingMaterial.find().lean().exec(); - - const summaryMetrics = calculateSummaryMetrics(materials); - - return res.status(200).json({ - success: true, - data: { - ...summaryMetrics, - timestamp: new Date(), - }, - }); - } catch (error) { - console.error('Error fetching summary metrics:', error); - return res.status(500).json({ - success: false, - message: 'Internal server error', - error: error.message, - }); - } - }; - - /** - * GET /materials/insights/summary/by-project/:projectId - * Get summary metrics for a specific project - */ - const getSummaryMetricsByProject = async (req, res) => { - try { - const { projectId } = req.params; - - // Validate project ID - if (!mongoose.Types.ObjectId.isValid(projectId)) { - return res.status(400).json({ - success: false, - message: 'Invalid project ID', - }); - } - - const materials = await BuildingMaterial.find({ project: projectId }).lean().exec(); - - const summaryMetrics = calculateSummaryMetrics(materials); - - return res.status(200).json({ - success: true, - data: { - projectId, - ...summaryMetrics, - timestamp: new Date(), - }, - }); - } catch (error) { - console.error('Error fetching summary metrics by project:', error); - return res.status(500).json({ - success: false, - message: 'Internal server error', - error: error.message, - }); - } - }; - - /** - * GET /materials/insights/critical-items - * Get all materials with critical stock levels across all projects - */ - const getCriticalStockItems = async (req, res) => { - try { - const materials = await BuildingMaterial.find() - .populate([ - { path: 'project', select: '_id name' }, - { path: 'itemType', select: '_id name unit' }, - ]) - .lean() - .exec(); - - const criticalItems = materials - .map((material) => calculateMaterialInsights(material)) - .filter((insight) => insight.stockHealth === 'critical' || insight.stockHealth === 'low') - .sort((a, b) => { - // Sort by stock ratio (lowest first) - if (a.stockRatio === null) return 1; - if (b.stockRatio === null) return -1; - return a.stockRatio - b.stockRatio; - }); - - return res.status(200).json({ - success: true, - data: { - criticalItemCount: criticalItems.length, - items: criticalItems, - timestamp: new Date(), - }, - }); - } catch (error) { - console.error('Error fetching critical stock items:', error); - return res.status(500).json({ - success: false, - message: 'Internal server error', - error: error.message, - }); - } - }; - - /** - * GET /materials/insights/high-usage-items - * Get all materials with high usage (>= 80%) across all projects - */ - const getHighUsageItems = async (req, res) => { - try { - const materials = await BuildingMaterial.find() - .populate([ - { path: 'project', select: '_id name' }, - { path: 'itemType', select: '_id name unit' }, - ]) - .lean() - .exec(); - - const highUsageItems = materials - .map((material) => calculateMaterialInsights(material)) - .filter((insight) => insight.usagePct !== null && insight.usagePct >= 80) - .sort( - (a, b) => - // Sort by usage percentage (highest first) - (b.usagePct || 0) - (a.usagePct || 0), - ); - - return res.status(200).json({ - success: true, - data: { - highUsageItemCount: highUsageItems.length, - items: highUsageItems, - timestamp: new Date(), - }, - }); - } catch (error) { - console.error('Error fetching high usage items:', error); - return res.status(500).json({ - success: false, - message: 'Internal server error', - error: error.message, - }); - } - }; - - /** - * GET /materials/insights/:materialId - * Get detailed insights for a specific material - */ - const getMaterialInsightsDetail = async (req, res) => { - try { - const { materialId } = req.params; - - // Validate material ID - if (!mongoose.Types.ObjectId.isValid(materialId)) { - return res.status(400).json({ - success: false, - message: 'Invalid material ID', - }); - } - - const material = await BuildingMaterial.findById(materialId) - .populate([ - { path: 'project', select: '_id name' }, - { path: 'itemType', select: '_id name unit' }, - ]) - .lean() - .exec(); - - if (!material) { - return res.status(404).json({ - success: false, - message: 'Material not found', - }); - } - - const insights = calculateMaterialInsights(material); - - return res.status(200).json({ - success: true, - data: { - ...insights, - timestamp: new Date(), - }, - }); - } catch (error) { - console.error('Error fetching material insights detail:', error); - return res.status(500).json({ - success: false, - message: 'Internal server error', - error: error.message, - }); - } - }; +const bmMaterialInsightsController = (BuildingMaterial) => { + const handlers = createHandlers(BuildingMaterial); - // Export all functions return { - getMaterialInsightsAll, - getMaterialInsightsByProject, - getSummaryMetrics, - getSummaryMetricsByProject, - getCriticalStockItems, - getHighUsageItems, - getMaterialInsightsDetail, - // Export calculation functions for testing + ...handlers, calculateMaterialInsights, calculateSummaryMetrics, calculateUsagePercentage, diff --git a/src/controllers/bmdashboard/materialInsightsCalculations.js b/src/controllers/bmdashboard/materialInsightsCalculations.js new file mode 100644 index 000000000..314ee36b9 --- /dev/null +++ b/src/controllers/bmdashboard/materialInsightsCalculations.js @@ -0,0 +1,150 @@ +/** + * Material Insights Calculations + * Pure calculation functions for material insights + */ + +const formatNumber = (value, decimals = 2) => { + if (typeof value !== 'number' || Number.isNaN(value)) { + return null; + } + return Math.round(value * 10 ** decimals) / 10 ** decimals; +}; + +const calculateUsagePercentage = (used, bought) => { + if (!bought || bought <= 0) { + return null; + } + const percentage = (used / bought) * 100; + return formatNumber(percentage, 2); +}; + +const calculateStockRatio = (available, bought) => { + if (!bought || bought <= 0) { + return null; + } + const ratio = available / bought; + return formatNumber(ratio, 2); +}; + +const getStockHealthStatus = (stockRatio) => { + if (stockRatio === null || stockRatio === undefined) { + return 'no-data'; + } + if (stockRatio <= 0.2) { + return 'critical'; + } + if (stockRatio <= 0.4) { + return 'low'; + } + return 'healthy'; +}; + +const getStockHealthColor = (status) => { + const colorMap = { + healthy: 'green', + low: 'yellow', + critical: 'red', + }; + return colorMap[status] || 'gray'; +}; + +const getStockHealthLabel = (status) => { + const labelMap = { + healthy: 'Healthy', + low: 'Low', + critical: 'Critical', + }; + return labelMap[status] || 'No Data'; +}; + +const calculateMaterialInsights = (material) => { + const bought = material?.stockBought || 0; + const used = material?.stockUsed || 0; + const available = material?.stockAvailable || 0; + const wasted = material?.stockWasted || 0; + const hold = material?.stockHold || 0; + + const usagePct = calculateUsagePercentage(used, bought); + const stockRatio = calculateStockRatio(available, bought); + const stockHealth = getStockHealthStatus(stockRatio); + const stockHealthColor = getStockHealthColor(stockHealth); + const stockHealthLabel = getStockHealthLabel(stockHealth); + + return { + materialId: material._id?.toString(), + materialName: material.itemType?.name || 'Unknown', + unit: material.itemType?.unit || '', + projectId: material.project?._id?.toString(), + projectName: material.project?.name || 'Unknown', + bought, + used, + available, + wasted, + hold, + usagePct, + stockRatio, + stockHealth, + stockHealthColor, + stockHealthLabel, + hasBoughtData: bought > 0, + }; +}; + +const calculateSummaryMetrics = (materials) => { + if (!materials || materials.length === 0) { + return { + totalMaterials: 0, + lowStockCount: 0, + lowStockPercentage: 0, + overUsageCount: 0, + overUsagePercentage: 0, + onHoldCount: 0, + usageThreshold: 80, + }; + } + + const total = materials.length; + let lowStockCount = 0; + let overUsageCount = 0; + let onHoldCount = 0; + + materials.forEach((material) => { + const insights = calculateMaterialInsights(material); + + if (insights.stockHealth === 'low' || insights.stockHealth === 'critical') { + lowStockCount += 1; + } + + if (insights.usagePct !== null && insights.usagePct >= 80) { + overUsageCount += 1; + } + + if ((material?.stockHold || 0) > 0) { + onHoldCount += 1; + } + }); + + const lowStockPercentage = formatNumber((lowStockCount / total) * 100, 1); + const overUsagePercentage = formatNumber((overUsageCount / total) * 100, 1); + + return { + totalMaterials: total, + lowStockCount, + lowStockPercentage, + overUsageCount, + overUsagePercentage, + onHoldCount, + usageThreshold: 80, + }; +}; + +module.exports = { + formatNumber, + calculateUsagePercentage, + calculateStockRatio, + getStockHealthStatus, + getStockHealthColor, + getStockHealthLabel, + calculateMaterialInsights, + calculateSummaryMetrics, +}; diff --git a/src/controllers/bmdashboard/materialInsightsHandlers.js b/src/controllers/bmdashboard/materialInsightsHandlers.js new file mode 100644 index 000000000..54f2d3811 --- /dev/null +++ b/src/controllers/bmdashboard/materialInsightsHandlers.js @@ -0,0 +1,193 @@ +/** + * Material Insights Handlers + * API endpoint handlers for material insights + */ + +const mongoose = require('mongoose'); +const { + calculateMaterialInsights, + calculateSummaryMetrics, +} = require('./materialInsightsCalculations'); + +const createUtilities = (BuildingMaterial) => { + const fetchMaterials = (query = {}) => + BuildingMaterial.find(query) + .populate([ + { path: 'project', select: '_id name' }, + { path: 'itemType', select: '_id name unit' }, + ]) + .lean() + .exec(); + + const handleError = (res, error, endpoint) => { + console.error(`Error ${endpoint}:`, error); + return res.status(500).json({ + success: false, + message: 'Internal server error', + error: error.message, + }); + }; + + const sendSuccessResponse = (res, data, status = 200) => + res.status(status).json({ success: true, data }); + + const sendErrorResponse = (res, message, status = 400) => + res.status(status).json({ success: false, message }); + + const validateObjectId = (id) => mongoose.Types.ObjectId.isValid(id); + + return { fetchMaterials, handleError, sendSuccessResponse, sendErrorResponse, validateObjectId }; +}; + +const createAllMaterialsHandler = (BuildingMaterial, utils) => async (req, res) => { + try { + const materials = await utils.fetchMaterials(); + const materialInsights = materials.map((m) => calculateMaterialInsights(m)); + const summaryMetrics = calculateSummaryMetrics(materials); + return utils.sendSuccessResponse(res, { + materials: materialInsights, + summary: summaryMetrics, + timestamp: new Date(), + }); + } catch (error) { + return utils.handleError(res, error, 'fetching material insights'); + } +}; + +const createProjectMaterialsHandler = (BuildingMaterial, utils) => async (req, res) => { + try { + const { projectId } = req.params; + if (!utils.validateObjectId(projectId)) { + return utils.sendErrorResponse(res, 'Invalid project ID'); + } + + const materials = await utils.fetchMaterials({ project: projectId }); + const materialInsights = materials.map((m) => calculateMaterialInsights(m)); + const summaryMetrics = calculateSummaryMetrics(materials); + return utils.sendSuccessResponse(res, { + projectId, + materials: materialInsights, + summary: summaryMetrics, + timestamp: new Date(), + }); + } catch (error) { + return utils.handleError(res, error, 'fetching material insights by project'); + } +}; + +const createSummaryHandler = (BuildingMaterial, utils) => async (req, res) => { + try { + const materials = await BuildingMaterial.find().lean().exec(); + const summaryMetrics = calculateSummaryMetrics(materials); + return utils.sendSuccessResponse(res, { + ...summaryMetrics, + timestamp: new Date(), + }); + } catch (error) { + return utils.handleError(res, error, 'fetching summary metrics'); + } +}; + +const createProjectSummaryHandler = (BuildingMaterial, utils) => async (req, res) => { + try { + const { projectId } = req.params; + if (!utils.validateObjectId(projectId)) { + return utils.sendErrorResponse(res, 'Invalid project ID'); + } + + const materials = await BuildingMaterial.find({ project: projectId }).lean().exec(); + const summaryMetrics = calculateSummaryMetrics(materials); + return utils.sendSuccessResponse(res, { + projectId, + ...summaryMetrics, + timestamp: new Date(), + }); + } catch (error) { + return utils.handleError(res, error, 'fetching summary metrics by project'); + } +}; + +const createCriticalItemsHandler = (BuildingMaterial, utils) => async (req, res) => { + try { + const materials = await utils.fetchMaterials(); + const criticalItems = materials + .map((m) => calculateMaterialInsights(m)) + .filter((i) => i.stockHealth === 'critical' || i.stockHealth === 'low') + .sort((a, b) => { + if (a.stockRatio === null) return 1; + if (b.stockRatio === null) return -1; + return a.stockRatio - b.stockRatio; + }); + + return utils.sendSuccessResponse(res, { + criticalItemCount: criticalItems.length, + items: criticalItems, + timestamp: new Date(), + }); + } catch (error) { + return utils.handleError(res, error, 'fetching critical stock items'); + } +}; + +const createHighUsageHandler = (BuildingMaterial, utils) => async (req, res) => { + try { + const materials = await utils.fetchMaterials(); + const highUsageItems = materials + .map((m) => calculateMaterialInsights(m)) + .filter((i) => i.usagePct !== null && i.usagePct >= 80) + .sort((a, b) => (b.usagePct || 0) - (a.usagePct || 0)); + + return utils.sendSuccessResponse(res, { + highUsageItemCount: highUsageItems.length, + items: highUsageItems, + timestamp: new Date(), + }); + } catch (error) { + return utils.handleError(res, error, 'fetching high usage items'); + } +}; + +const createDetailHandler = (BuildingMaterial, utils) => async (req, res) => { + try { + const { materialId } = req.params; + if (!utils.validateObjectId(materialId)) { + return utils.sendErrorResponse(res, 'Invalid material ID'); + } + + const material = await BuildingMaterial.findById(materialId) + .populate([ + { path: 'project', select: '_id name' }, + { path: 'itemType', select: '_id name unit' }, + ]) + .lean() + .exec(); + + if (!material) { + return utils.sendErrorResponse(res, 'Material not found', 404); + } + + const insights = calculateMaterialInsights(material); + return utils.sendSuccessResponse(res, { + ...insights, + timestamp: new Date(), + }); + } catch (error) { + return utils.handleError(res, error, 'fetching material insights detail'); + } +}; + +const createHandlers = (BuildingMaterial) => { + const utils = createUtilities(BuildingMaterial); + + return { + getMaterialInsightsAll: createAllMaterialsHandler(BuildingMaterial, utils), + getMaterialInsightsByProject: createProjectMaterialsHandler(BuildingMaterial, utils), + getSummaryMetrics: createSummaryHandler(BuildingMaterial, utils), + getSummaryMetricsByProject: createProjectSummaryHandler(BuildingMaterial, utils), + getCriticalStockItems: createCriticalItemsHandler(BuildingMaterial, utils), + getHighUsageItems: createHighUsageHandler(BuildingMaterial, utils), + getMaterialInsightsDetail: createDetailHandler(BuildingMaterial, utils), + }; +}; + +module.exports = createHandlers; From 5f955d4cefa69857469995707f20ca96649e7f7b Mon Sep 17 00:00:00 2001 From: Linh Huynh Date: Thu, 12 Feb 2026 01:43:52 -0800 Subject: [PATCH 4/4] test(material-insights): add unit tests for controller and calculations --- .../bmMaterialInsightsController.test.js | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 src/controllers/bmdashboard/__tests__/bmMaterialInsightsController.test.js diff --git a/src/controllers/bmdashboard/__tests__/bmMaterialInsightsController.test.js b/src/controllers/bmdashboard/__tests__/bmMaterialInsightsController.test.js new file mode 100644 index 000000000..250aac75f --- /dev/null +++ b/src/controllers/bmdashboard/__tests__/bmMaterialInsightsController.test.js @@ -0,0 +1,384 @@ +/** + * Material Insights Controller Tests + * Tests for all calculation functions and API endpoints + */ + +const bmMaterialInsightsController = require('../bmMaterialInsightsController'); + +describe('Material Insights Controller', () => { + let mockBuildingMaterial; + let controller; + + beforeEach(() => { + // Mock BuildingMaterial model + mockBuildingMaterial = { + find: jest.fn(), + findById: jest.fn(), + }; + + controller = bmMaterialInsightsController(mockBuildingMaterial); + }); + + describe('Calculation Functions', () => { + describe('calculateMaterialInsights', () => { + it('should calculate insights for material with complete data', () => { + const material = { + _id: '123', + stockBought: 100, + stockUsed: 75, + stockAvailable: 30, + stockWasted: 5, + stockHold: 0, + itemType: { name: 'Cement', unit: 'kg' }, + project: { _id: 'proj1', name: 'Project A' }, + }; + + const insights = controller.calculateMaterialInsights(material); + + expect(insights.usagePct).toBe(75); + expect(insights.stockRatio).toBe(0.3); + expect(insights.stockHealth).toBe('low'); + expect(insights.materialName).toBe('Cement'); + expect(insights.unit).toBe('kg'); + expect(insights.hasBoughtData).toBe(true); + }); + + it('should handle material with no purchases', () => { + const material = { + _id: '123', + stockBought: 0, + stockUsed: 0, + stockAvailable: 0, + stockWasted: 0, + stockHold: 0, + itemType: { name: 'Steel', unit: 'tons' }, + project: { _id: 'proj1', name: 'Project B' }, + }; + + const insights = controller.calculateMaterialInsights(material); + + expect(insights.usagePct).toBeNull(); + expect(insights.stockRatio).toBeNull(); + expect(insights.stockHealth).toBe('no-data'); + expect(insights.hasBoughtData).toBe(false); + }); + + it('should classify as critical when stock <= 20%', () => { + const material = { + _id: '123', + stockBought: 100, + stockUsed: 90, + stockAvailable: 10, + stockWasted: 0, + stockHold: 0, + itemType: { name: 'Wood', unit: 'pcs' }, + project: { _id: 'proj1', name: 'Project C' }, + }; + + const insights = controller.calculateMaterialInsights(material); + + expect(insights.stockHealth).toBe('critical'); + expect(insights.stockHealthColor).toBe('red'); + expect(insights.stockHealthLabel).toBe('Critical'); + }); + + it('should classify as healthy when stock > 40%', () => { + const material = { + _id: '123', + stockBought: 100, + stockUsed: 50, + stockAvailable: 50, + stockWasted: 0, + stockHold: 0, + itemType: { name: 'Brick', unit: 'units' }, + project: { _id: 'proj1', name: 'Project D' }, + }; + + const insights = controller.calculateMaterialInsights(material); + + expect(insights.stockHealth).toBe('healthy'); + expect(insights.stockHealthColor).toBe('green'); + expect(insights.stockHealthLabel).toBe('Healthy'); + }); + }); + + describe('calculateUsagePercentage', () => { + it('should calculate usage percentage correctly', () => { + const percentage = controller.calculateUsagePercentage(50, 100); + expect(percentage).toBe(50); + }); + + it('should return null when bought is 0', () => { + const percentage = controller.calculateUsagePercentage(50, 0); + expect(percentage).toBeNull(); + }); + + it('should handle decimal values', () => { + const percentage = controller.calculateUsagePercentage(33.33, 100); + expect(percentage).toBe(33.33); + }); + }); + + describe('calculateStockRatio', () => { + it('should calculate stock ratio correctly', () => { + const ratio = controller.calculateStockRatio(50, 100); + expect(ratio).toBe(0.5); + }); + + it('should return null when bought is 0', () => { + const ratio = controller.calculateStockRatio(50, 0); + expect(ratio).toBeNull(); + }); + + it('should clamp ratio at 1', () => { + const ratio = controller.calculateStockRatio(150, 100); + expect(ratio).toBe(1.5); // Can exceed 1 if available > bought + }); + }); + + describe('getStockHealthStatus', () => { + it('should return critical for ratio <= 0.2', () => { + expect(controller.getStockHealthStatus(0.2)).toBe('critical'); + expect(controller.getStockHealthStatus(0.1)).toBe('critical'); + }); + + it('should return low for ratio between 0.2 and 0.4', () => { + expect(controller.getStockHealthStatus(0.3)).toBe('low'); + expect(controller.getStockHealthStatus(0.4)).toBe('low'); + }); + + it('should return healthy for ratio > 0.4', () => { + expect(controller.getStockHealthStatus(0.5)).toBe('healthy'); + expect(controller.getStockHealthStatus(1.0)).toBe('healthy'); + }); + + it('should return no-data for null/undefined', () => { + expect(controller.getStockHealthStatus(null)).toBe('no-data'); + expect(controller.getStockHealthStatus(undefined)).toBe('no-data'); + }); + }); + }); + + describe('Summary Metrics', () => { + describe('calculateSummaryMetrics', () => { + it('should calculate summary for empty array', () => { + const summary = controller.calculateSummaryMetrics([]); + + expect(summary.totalMaterials).toBe(0); + expect(summary.lowStockCount).toBe(0); + expect(summary.lowStockPercentage).toBe(0); + expect(summary.overUsageCount).toBe(0); + expect(summary.overUsagePercentage).toBe(0); + expect(summary.onHoldCount).toBe(0); + }); + + it('should count low stock items', () => { + const materials = [ + { + _id: '1', + stockBought: 100, + stockUsed: 80, + stockAvailable: 20, + stockWasted: 0, + stockHold: 0, + itemType: { name: 'A', unit: 'kg' }, + project: { _id: 'p1', name: 'P1' }, + }, + { + _id: '2', + stockBought: 100, + stockUsed: 85, + stockAvailable: 15, + stockWasted: 0, + stockHold: 0, + itemType: { name: 'B', unit: 'kg' }, + project: { _id: 'p1', name: 'P1' }, + }, + { + _id: '3', + stockBought: 100, + stockUsed: 50, + stockAvailable: 50, + stockWasted: 0, + stockHold: 0, + itemType: { name: 'C', unit: 'kg' }, + project: { _id: 'p1', name: 'P1' }, + }, + ]; + + const summary = controller.calculateSummaryMetrics(materials); + + expect(summary.totalMaterials).toBe(3); + expect(summary.lowStockCount).toBe(2); + expect(summary.lowStockPercentage).toBe(66.7); + }); + + it('should count high usage items', () => { + const materials = [ + { + _id: '1', + stockBought: 100, + stockUsed: 85, + stockAvailable: 15, + stockWasted: 0, + stockHold: 0, + itemType: { name: 'A', unit: 'kg' }, + project: { _id: 'p1', name: 'P1' }, + }, + { + _id: '2', + stockBought: 100, + stockUsed: 75, + stockAvailable: 25, + stockWasted: 0, + stockHold: 0, + itemType: { name: 'B', unit: 'kg' }, + project: { _id: 'p1', name: 'P1' }, + }, + ]; + + const summary = controller.calculateSummaryMetrics(materials); + + expect(summary.totalMaterials).toBe(2); + expect(summary.overUsageCount).toBe(1); // Only item 1 >= 80% + expect(summary.overUsagePercentage).toBe(50); + }); + + it('should count items on hold', () => { + const materials = [ + { + _id: '1', + stockBought: 100, + stockUsed: 50, + stockAvailable: 40, + stockWasted: 0, + stockHold: 10, + itemType: { name: 'A', unit: 'kg' }, + project: { _id: 'p1', name: 'P1' }, + }, + { + _id: '2', + stockBought: 100, + stockUsed: 50, + stockAvailable: 50, + stockWasted: 0, + stockHold: 0, + itemType: { name: 'B', unit: 'kg' }, + project: { _id: 'p1', name: 'P1' }, + }, + ]; + + const summary = controller.calculateSummaryMetrics(materials); + + expect(summary.onHoldCount).toBe(1); + }); + }); + }); + + describe('API Endpoints', () => { + let mockRes; + let mockReq; + + beforeEach(() => { + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + mockReq = { + params: {}, + query: {}, + }; + }); + + describe('getMaterialInsightsAll', () => { + it('should return all materials with insights and summary', async () => { + const mockMaterials = [ + { + _id: '1', + stockBought: 100, + stockUsed: 75, + stockAvailable: 25, + stockWasted: 0, + stockHold: 0, + itemType: { name: 'Material 1', unit: 'kg' }, + project: { _id: 'p1', name: 'Project 1' }, + }, + ]; + + mockBuildingMaterial.find = jest.fn().mockReturnThis(); + mockBuildingMaterial.populate = jest.fn().mockReturnThis(); + mockBuildingMaterial.lean = jest.fn().mockReturnThis(); + mockBuildingMaterial.exec = jest.fn().mockResolvedValue(mockMaterials); + + await controller.getMaterialInsightsAll(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + materials: expect.any(Array), + summary: expect.any(Object), + timestamp: expect.any(Date), + }), + }), + ); + }); + + it('should handle errors gracefully', async () => { + mockBuildingMaterial.find = jest.fn().mockImplementation(() => { + throw new Error('Database error'); + }); + + await controller.getMaterialInsightsAll(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: 'Internal server error', + }), + ); + }); + }); + + describe('getMaterialInsightsByProject', () => { + it('should reject invalid project ID', async () => { + mockReq.params.projectId = 'invalid-id'; + + await controller.getMaterialInsightsByProject(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: 'Invalid project ID', + }), + ); + }); + }); + + describe('getSummaryMetrics', () => { + it('should return only summary metrics', async () => { + const mockMaterials = []; + + mockBuildingMaterial.find = jest.fn().mockReturnThis(); + mockBuildingMaterial.lean = jest.fn().mockReturnThis(); + mockBuildingMaterial.exec = jest.fn().mockResolvedValue(mockMaterials); + + await controller.getSummaryMetrics(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + totalMaterials: 0, + }), + }), + ); + }); + }); + }); +});