diff --git a/.husky/commit-msg b/.husky/commit-msg old mode 100755 new mode 100644 diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100755 new mode 100644 diff --git a/src/controllers/__tests__/materialUtilizationController.test.js b/src/controllers/__tests__/materialUtilizationController.test.js new file mode 100644 index 000000000..ccf5a8144 --- /dev/null +++ b/src/controllers/__tests__/materialUtilizationController.test.js @@ -0,0 +1,79 @@ +// 1. Define the mock function +const mockAggregate = jest.fn(); + +// 2. Mock the module using an implicit return (no braces, wrapped in parens) +jest.mock('../../models/materialUsage', () => ({ + aggregate: (...args) => mockAggregate(...args), +})); + +// 3. Import the controller and model +const { getMaterialUtilization } = require('../materialUtilizationController'); + +describe('Material Utilization Controller', () => { + let req; + let res; + + beforeEach(() => { + jest.clearAllMocks(); + + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + req = { + query: { + start: '2025-11-01', + end: '2026-01-07', + projects: ['6541c4001111111111111111'], + materials: [], + }, + }; + }); + + describe('getMaterialUtilization', () => { + it('should return 200 and data when valid parameters are provided', async () => { + const mockData = [{ project: 'Project Alpha', used: 80, unused: 20, totalHandled: 100 }]; + + // Use the defined mockAggregate function + mockAggregate.mockResolvedValue(mockData); + + await getMaterialUtilization(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(mockData); + }); + + it('should return 400 if start or end date is missing', async () => { + req.query.start = ''; + await getMaterialUtilization(req, res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('should return 404 if no records match the criteria', async () => { + mockAggregate.mockResolvedValue([]); + + await getMaterialUtilization(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + }), + ); + }); + + it('should return 500 if the database aggregation fails', async () => { + mockAggregate.mockRejectedValue(new Error('Aggregation Failed')); + + await getMaterialUtilization(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Server Error', + }), + ); + }); + }); +}); diff --git a/src/controllers/materialUtilizationController.js b/src/controllers/materialUtilizationController.js new file mode 100644 index 000000000..fa9c1cc74 --- /dev/null +++ b/src/controllers/materialUtilizationController.js @@ -0,0 +1,217 @@ +const mongoose = require('mongoose'); +const MaterialUsage = require('../models/materialUsage'); + +/** + * @desc Get Material Utilization data aggregated by project + * @route GET /api/materials/utilization + * @access Private + */ +const getMaterialUtilization = async (req, res) => { + const { start, end, projects, materials } = req.query; + + // --- 1. Validation --- + if (!start || !end) { + return res + .status(400) + .json({ success: false, message: 'Both start and end dates are required' }); + } + + let startDate; + let endDate; + + try { + startDate = new Date(start); + endDate = new Date(end); + + if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { + throw new Error('Invalid date format'); + } + } catch (error) { + return res + .status(400) + .json({ success: false, message: 'Invalid date format. Please use ISO date strings.' }); + } + + if (startDate > endDate) { + return res.status(400).json({ success: false, message: 'Start date cannot be after end date' }); + } + + // --- 2. Build Initial Match Stage --- + const matchStage = { + date: { + $gte: startDate, + $lte: endDate, + }, + }; + + // If project filters are provided, add them to the match stage + if (projects && Array.isArray(projects) && projects.length > 0) { + try { + // Convert string IDs to valid MongoDB ObjectIds + const projectObjectIds = projects.map((id) => new mongoose.Types.ObjectId(id)); + matchStage.projectId = { $in: projectObjectIds }; + } catch (error) { + return res + .status(400) + .json({ success: false, message: 'Invalid project ID format provided.' }); + } + } + + if (materials && Array.isArray(materials) && materials.length > 0) { + try { + // Convert material string IDs to ObjectIds + const materialObjectIds = materials.map((id) => new mongoose.Types.ObjectId(id)); + matchStage.materialId = { $in: materialObjectIds }; + } catch (error) { + return res.status(400).json({ success: false, message: 'Invalid material ID format.' }); + } + } + + // --- 3. Aggregation Pipeline --- + try { + const aggregationPipeline = [ + // Stage 1: Filter documents by date and optional projects + { + $match: matchStage, + }, + // Stage 2: Group by projectId to sum up quantities + { + $group: { + _id: '$projectId', + projectName: { $first: '$projectName' }, // Get the first project name found + totalHandled: { $sum: '$receivedQty' }, + used: { $sum: '$usedQty' }, + }, + }, + // Stage 3: Calculate unused, and handle division by zero + { + $project: { + _id: 0, // Exclude the default _id + project: '$projectName', + used: '$used', + unused: { $max: [0, { $subtract: ['$totalHandled', '$used'] }] }, + totalHandled: '$totalHandled', // Pass totalHandled to the next stage + }, + }, + // Stage 4: Calculate percentages + { + $project: { + project: 1, + used: 1, + unused: 1, + totalHandled: 1, + // Use $cond to prevent division by zero if totalHandled is 0 + usedPct: { + $cond: { + if: { $eq: ['$totalHandled', 0] }, + then: 0, + else: { + $round: [ + { $multiply: [{ $divide: ['$used', '$totalHandled'] }, 100] }, + 1, // Round to 1 decimal place + ], + }, + }, + }, + }, + }, + // Stage 5: Calculate unusedPct and final formatting + { + $project: { + project: 1, + totalHandled: 1, + used: 1, + unused: 1, + usedPct: 1, + unusedPct: { $subtract: [100, '$usedPct'] }, + }, + }, + // Stage 6: Sort by project name as requested + { + $sort: { project: 1 }, + }, + ]; + + const utilizationData = await MaterialUsage.aggregate(aggregationPipeline); + + if (utilizationData.length === 0) { + // Per spec, return 404 if no records found + return res + .status(404) + .json({ success: false, message: 'No material records for selected range' }); + } + + res.status(200).json(utilizationData); // Send the successful response + } catch (error) { + console.error('Error in Material Utilization Aggregation:', error.message); + res.status(500).json({ success: false, message: 'Server Error' }); + } +}; + +/** + * @desc Get all distinct projects for the filter dropdown + * @route GET /api/materials/distinct-projects + * @access Private + */ +const getDistinctProjects = async (req, res) => { + try { + // This finds all unique projectId/projectName pairs + const projectData = await MaterialUsage.aggregate([ + { + $group: { + _id: '$projectId', + projectName: { $first: '$projectName' }, + }, + }, + { $sort: { projectName: 1 } }, + // Send it in the format { _id: "...", projectName: "..." } + { + $project: { + _id: 1, + projectName: 1, + }, + }, + ]); + + res.status(200).json(projectData); + } catch (error) { + console.error('Error fetching distinct projects:', error.message); + res.status(500).json({ success: false, message: 'Server Error' }); + } +}; + +/** + * @desc Get all distinct materials for the filter dropdown + * @route GET /api/materials/distinct-materials + * @access Private + */ +const getDistinctMaterials = async (req, res) => { + try { + const materialData = await MaterialUsage.aggregate([ + { + $group: { + _id: '$materialId', + materialName: { $first: '$materialName' }, + }, + }, + { $sort: { materialName: 1 } }, + { + $project: { + _id: 1, + materialName: 1, + }, + }, + ]); + + res.status(200).json(materialData); + } catch (error) { + console.error('Error fetching distinct materials:', error.message); + res.status(500).json({ success: false, message: 'Server Error' }); + } +}; + +module.exports = { + getMaterialUtilization, + getDistinctProjects, + getDistinctMaterials, +}; diff --git a/src/controllers/userProfileController.js b/src/controllers/userProfileController.js index 3c731473d..97be9724a 100644 --- a/src/controllers/userProfileController.js +++ b/src/controllers/userProfileController.js @@ -2626,31 +2626,30 @@ const createControllerMethods = function (UserProfile, Project, cache) { const updateFinalDay = async (req, res) => { try { const { userId } = req.params; - const { endDate, isSet } = req.body; - const { requestor } = req.body; + const { date } = req.body; - // 1️⃣ Auth check - if (!requestor) { + console.log('=== DEBUG setFinalDay ==='); + console.log('req.body.requestor:', req.body.requestor); + console.log('req.body.requestor.role:', req.body.requestor?.role); + console.log('req.body.requestor.permissions:', req.body.requestor?.permissions); + + // Check if user has permission to set final day + if (!req.body.requestor) { + console.log('No requestor found'); return res.status(401).json({ success: false, message: 'Authentication required', }); } - // 2️⃣ Permission check (ONLY setFinalDay) - const allowed = await hasPermission(requestor, 'setFinalDay'); + const allowed = await hasPermission(req.body.requestor, 'setFinalDay'); if (!allowed) { return res.status(403).json({ success: false, - message: 'Access denied. Missing setFinalDay permission.', + message: 'Access denied. Insufficient permissions.', }); } - // 3️⃣ Validate target user - if (!mongoose.Types.ObjectId.isValid(userId)) { - return res.status(400).json({ message: 'Invalid userId' }); - } - const user = await UserProfile.findById(userId); if (!user) { return res.status(404).json({ message: 'User not found' }); diff --git a/src/helpers/userHelper.js b/src/helpers/userHelper.js index ffb54daef..6ff5a8760 100644 --- a/src/helpers/userHelper.js +++ b/src/helpers/userHelper.js @@ -10,13 +10,13 @@ /* eslint-disable no-unsafe-optional-chaining */ /* eslint-disable no-restricted-syntax */ +const mongoose = require('mongoose'); +const moment = require('moment-timezone'); +const _ = require('lodash'); const fs = require('fs'); const cheerio = require('cheerio'); const axios = require('axios'); const sharp = require('sharp'); -const mongoose = require('mongoose'); -const moment = require('moment-timezone'); -const _ = require('lodash'); const userProfile = require('../models/userProfile'); const timeEntries = require('../models/timeentry'); const badge = require('../models/badge'); @@ -45,9 +45,8 @@ const WEEKS_BEFORE_FINAL_EMAIL = 3; const delay = (ms) => new Promise((resolve) => { - setTimeout(() => resolve(), ms); + setTimeout(resolve, ms); }); - const userHelper = function () { // Update format to "MMM-DD-YY" from "YYYY-MMM-DD" (Confirmed with Jae) const earnedDateBadge = () => { @@ -66,50 +65,6 @@ const userHelper = function () { }); }; - async function getCurrentTeamCode(teamId) { - // Ensure teamId is a valid MongoDB ObjectId - if (!mongoose.Types.ObjectId.isValid(teamId)) return null; - - // Fetch the current team code of the given teamId from active users - const result = await userProfile.aggregate([ - { - $match: { - teams: mongoose.Types.ObjectId(teamId), - isActive: true, - }, - }, - { $limit: 1 }, - { $project: { teamCode: 1 } }, - ]); - - // Return the teamCode if found - return result.length > 0 ? result[0].teamCode : null; - } - - async function checkTeamCodeMismatch(user) { - try { - // no user or no teams → nothing to compare - if (!user || !user.teams.length) { - return false; - } - - // looks like they always checked the first (latest) team - const latestTeamId = user.teams[0]; - - // this was in your diff: getCurrentTeamCode(latestTeamId) - const teamCodeFromFirstActive = await getCurrentTeamCode(latestTeamId); - if (!teamCodeFromFirstActive) { - return false; - } - - // mismatch if user's stored teamCode != that team's current code - return teamCodeFromFirstActive !== user.teamCode; - } catch (error) { - logger.logException(error); - return false; - } - } - const getTeamMembersForBadge = async function (user) { try { const results = await Team.aggregate([ @@ -143,6 +98,7 @@ const userHelper = function () { ]); return results; } catch (error) { + console.log(error); return error; } }; @@ -538,6 +494,7 @@ const userHelper = function () { const t0 = Date.now(); console.log('[BlueSquare] start'); try { + console.log('run'); const currentFormattedDate = moment().tz('America/Los_Angeles').format(); moment.tz('America/Los_Angeles').startOf('day').toISOString(); @@ -653,6 +610,7 @@ const userHelper = function () { timeSpent === 0 && userStartDate.isAfter(pdtStartOfLastWeek) ) { + console.log('1'); isNewUser = true; } @@ -662,6 +620,7 @@ const userHelper = function () { userStartDate.isBefore(pdtEndOfLastWeek) && timeUtils.getDayOfWeekStringFromUTC(person.startDate) > 1) ) { + console.log('2'); isNewUser = true; } @@ -919,6 +878,31 @@ const userHelper = function () { administrativeContent, ); } + + let emailsBCCs; + /* eslint-disable array-callback-return */ + const blueSquareBCCs = await BlueSquareEmailAssignment.find() + .populate('assignedTo') + .exec(); + if (blueSquareBCCs.length > 0) { + emailsBCCs = blueSquareBCCs.map((assignment) => { + if (assignment.assignedTo.isActive === true) { + return assignment.email; + } + }); + } else { + emailsBCCs = null; + } + + emailSender( + status.email, + 'New Infringement Assigned', + emailBody, + null, + emailsBCCs, + 'onecommunityglobal@gmail.com', + ); + emailQueue.push({ to: status.email, subject: 'New Infringement Assigned', @@ -1237,7 +1221,7 @@ const userHelper = function () { } }; - const notifyInfringements = async ( + const notifyInfringements = function ( original, current, firstName, @@ -1246,9 +1230,7 @@ const userHelper = function () { role, startDate, jobTitle, - weeklycommittedHours, - infringementCCList, - ) => { + ) { if (!current) return; const newOriginal = original.toObject(); const newCurrent = current.toObject(); @@ -1328,13 +1310,6 @@ const userHelper = function () { newInfringements = _.differenceWith(newCurrent, newOriginal, (arrVal, othVal) => arrVal._id.equals(othVal._id), ); - - const assignments = await BlueSquareEmailAssignment.find().populate('assignedTo').exec(); - const bccEmails = assignments.map((a) => a.email); - - const combinedCCList = [...new Set([...(infringementCCList || []), ...DEFAULT_CC_EMAILS])]; - const combinedBCCList = [...new Set([...(bccEmails || []), ...DEFAULT_BCC_EMAILS])]; - newInfringements.forEach((element) => { emailSender( emailAddress, @@ -1387,7 +1362,7 @@ const userHelper = function () { }, (err) => { if (err) { - // Error handled silently + console.log(err); } }, ); @@ -1645,15 +1620,21 @@ const userHelper = function () { // }; const checkNoInfringementStreak = async function (personId, user, badgeCollection) { + console.log('==> Starting checkNoInfringementStreak'); + console.log('Input personId:', personId); + let badgeOfType; for (let i = 0; i < badgeCollection.length; i += 1) { const badgeItem = badgeCollection[i].badge; if (badgeItem?.type === 'No Infringement Streak') { + console.log(`Found badge: ${badgeItem.months} months`); if (badgeOfType && badgeOfType.months <= badgeItem.months) { + console.log(`Removing duplicate badge:`, badgeOfType._id); removeDupBadge(personId, badgeOfType._id); badgeOfType = badgeItem; } else if (badgeOfType && badgeOfType.months > badgeItem.months) { + console.log(`Removing duplicate badge:`, badgeItem._id); removeDupBadge(personId, badgeItem._id); } else if (!badgeOfType) { badgeOfType = badgeItem; @@ -1661,15 +1642,22 @@ const userHelper = function () { } } + console.log('Final badgeOfType selected:', badgeOfType); + await badge .find({ type: 'No Infringement Streak' }) .sort({ months: -1 }) .then((results) => { + console.log('Available No Infringement Streak badges:', results); + if (!Array.isArray(results) || !results.length) { + console.log('No badges found, exiting.'); return; } results.every((elem) => { + console.log('Evaluating badge:', elem.months); + if (elem.months <= 12) { const monthsSinceJoined = moment().diff(moment(user.createdDate), 'months', true); const monthsSinceLastInfringement = user.infringements.length @@ -1682,12 +1670,18 @@ const userHelper = function () { ) : null; + console.log( + `monthsSinceJoined: ${monthsSinceJoined}, monthsSinceLastInfringement: ${monthsSinceLastInfringement}`, + ); + if ( monthsSinceJoined >= elem.months && (user.infringements.length === 0 || monthsSinceLastInfringement >= elem.months) ) { + console.log('User qualifies for badge:', elem._id); if (badgeOfType) { if (badgeOfType._id.toString() !== elem._id.toString()) { + console.log('Replacing badge:', badgeOfType._id, 'with', elem._id); replaceBadge( personId, mongoose.Types.ObjectId(badgeOfType._id), @@ -1696,6 +1690,7 @@ const userHelper = function () { } return false; } + console.log('Adding new badge:', elem._id); addBadge(personId, mongoose.Types.ObjectId(elem._id)); return false; } @@ -1711,13 +1706,19 @@ const userHelper = function () { ) : null; + console.log( + `(Long-term) monthsSinceJoined: ${monthsSinceJoined}, monthsSinceLastOldInfringement: ${monthsSinceLastOldInfringement}`, + ); + if ( monthsSinceJoined >= elem.months && (user.oldInfringements.length === 0 || monthsSinceLastOldInfringement >= elem.months - 12) ) { + console.log('User qualifies for long-term badge:', elem._id); if (badgeOfType) { if (badgeOfType._id.toString() !== elem._id.toString()) { + console.log('Replacing badge:', badgeOfType._id, 'with', elem._id); replaceBadge( personId, mongoose.Types.ObjectId(badgeOfType._id), @@ -1726,6 +1727,7 @@ const userHelper = function () { } return false; } + console.log('Adding new long-term badge:', elem._id); addBadge(personId, mongoose.Types.ObjectId(elem._id)); return false; } @@ -1734,11 +1736,16 @@ const userHelper = function () { return true; }); }); + + console.log('==> Finished checkNoInfringementStreak'); }; // 'Minimum Hours Multiple', const checkMinHoursMultiple = async function (personId, user, badgeCollection) { + console.log('--- checkMinHoursMultiple START ---'); + const ratio = user.lastWeekTangibleHrs / user.weeklycommittedHours; + console.log(`User tangible/committed hours ratio: ${ratio}`); const badgesOfType = badgeCollection .map((obj) => obj.badge) @@ -1749,11 +1756,15 @@ const userHelper = function () { .sort({ multiple: -1 }); if (!availableBadges.length) { + console.log('No matching badges found in database.'); return; } for (const candidateBadge of availableBadges) { if (ratio < candidateBadge.multiple) { + console.log( + `Not eligible for badge ${candidateBadge._id} (requires ${candidateBadge.multiple})`, + ); continue; } @@ -1767,25 +1778,35 @@ const userHelper = function () { highestExisting && candidateBadge._id.toString() === highestExisting._id.toString(); if (isSameAsHighest) { + console.log(`Already has the highest badge. Increasing count.`); return increaseBadgeCount(personId, mongoose.Types.ObjectId(candidateBadge._id)); } if (highestExisting) { + console.log( + `Replacing lower badge ${highestExisting._id} with higher badge ${candidateBadge._id}`, + ); + const existingBadgeEntry = badgeCollection.find( (entry) => entry.badge._id.toString() === highestExisting._id.toString(), ); if (existingBadgeEntry?.count > 1) { + console.log(`Count > 1: Decreasing existing badge count.`); await decreaseBadgeCount(personId, mongoose.Types.ObjectId(highestExisting._id)); } else { + console.log(`Removing duplicate badge.`); await removeDupBadge(personId, mongoose.Types.ObjectId(highestExisting._id)); } return addBadge(personId, mongoose.Types.ObjectId(candidateBadge._id)); } + console.log(`Adding new badge: ${candidateBadge._id}`); return addBadge(personId, mongoose.Types.ObjectId(candidateBadge._id)); } + + console.log('--- checkMinHoursMultiple END ---'); }; const getAllWeeksData = async (personId, user) => { @@ -1956,6 +1977,7 @@ const userHelper = function () { const checkXHrsForXWeeks = async (personId, user, badgeCollection) => { try { if (user.savedTangibleHrs.length === 0) { + console.log('No tangible hours available.'); return; } @@ -1971,7 +1993,10 @@ const userHelper = function () { } } + console.log('Calculated streak:', streak); + if (streak === 0) { + console.log('No valid streak found.'); return; } @@ -2013,6 +2038,7 @@ const userHelper = function () { } if (badgeInCollection) { + console.log(`Badge already exists: ${newBadge.badgeName}, increasing count.`); await increaseBadgeCount(personId, newBadge._id); return; } @@ -2025,10 +2051,14 @@ const userHelper = function () { continue; } + console.log('lastBadge.badge.totalHrs ::', lastBadge.badge.totalHrs === currentMaxHours); + if (lastBadge.badge.totalHrs === currentMaxHours) { // Check if the badge is eligible for downgrade or replacement if (lastBadge.badge.weeks < streak && lastBadge.count > 1) { await decreaseBadgeCount(personId, lastBadge.badge._id); + + console.log(`Adding new badge: ${newBadge.badgeName}`); await addBadge(personId, newBadge._id); return; } @@ -2179,11 +2209,14 @@ const userHelper = function () { const currBadge = current.badge; if (current.count > 1) { + console.log(`Decreasing count for badge ${currBadge._id}`); decreaseBadgeCount(personId, currBadge._id); + console.log(`Adding badge ${currBadge._id}`); addBadge(personId, currBadge._id); badgeOfType = currBadge; break; } else if (badgeOfType && badgeOfType.totalHrs > currBadge.totalHrs) { + console.log(`Removing lower badge ${currBadge._id}`); removeDupBadge(personId, currBadge._id); } else if (!badgeOfType) { badgeOfType = currBadge; @@ -2195,6 +2228,7 @@ const userHelper = function () { .sort({ totalHrs: -1 }); if (!Array.isArray(results) || !results.length || !categoryHrs) { + console.log(`No valid badge results or category hours missing for ${newCatg}`); continue; } @@ -2207,16 +2241,19 @@ const userHelper = function () { ); if (alreadyHas) { + console.log(`Increasing badge count for ${elem._id}`); increaseBadgeCount(personId, elem._id); break; } if (badgeOfType && badgeOfType.totalHrs < elem.totalHrs) { + console.log(`Replacing badge ${badgeOfType._id} with ${elem._id}`); replaceBadge(personId, badgeOfType._id, elem._id); break; } if (!badgeOfType) { + console.log(`Adding new badge ${elem._id}`); addBadge(personId, elem._id); break; } @@ -2308,6 +2345,8 @@ const userHelper = function () { try { const users = await userProfile.find({ isActive: true }).populate('badgeCollection.badge'); + console.log('awardNewBadge working'); + for (let i = 0; i < users.length; i += 1) { const user = users[i]; const { _id, badgeCollection } = user; @@ -2328,6 +2367,7 @@ const userHelper = function () { } } } catch (err) { + console.log(err); logger.logException(err); } }; @@ -2646,6 +2686,8 @@ const userHelper = function () { const resendBlueSquareEmailsOnlyForLastWeek = async () => { try { + console.log('[Manual Resend] Starting email-only blue square resend...'); + const startOfLastWeek = moment() .tz('America/Los_Angeles') .startOf('week') @@ -2732,6 +2774,8 @@ const userHelper = function () { [...new Set(emailsBCCs)], ); } + + console.log('[Manual Resend] Emails successfully resent for existing blue squares.'); } catch (err) { console.error('[Manual Resend] Error while resending:', err); logger.logException(err); @@ -2900,7 +2944,6 @@ const userHelper = function () { changeBadgeCount, getUserName, getTeamMembers, - checkTeamCodeMismatch, getTeamManagementEmail, validateProfilePic, assignBlueSquareForTimeNotMet, diff --git a/src/models/materialUsage.js b/src/models/materialUsage.js new file mode 100644 index 000000000..364696d7b --- /dev/null +++ b/src/models/materialUsage.js @@ -0,0 +1,58 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +/** + * This schema represents the raw data for material transactions. + * The API will aggregate data from this collection. + */ +const materialUsageSchema = new Schema( + { + projectId: { + type: Schema.Types.ObjectId, + ref: 'Project', // Assuming you have a 'Project' collection + required: true, + }, + projectName: { + type: String, + required: true, + }, + materialId: { + type: Schema.Types.ObjectId, + required: true, + }, + materialName: { + type: String, + required: true, + }, + date: { + type: Date, + required: true, + index: true, // Index for faster date-range queries + }, + receivedQty: { + type: Number, + required: true, + default: 0, + }, + usedQty: { + type: Number, + required: true, + default: 0, + }, + // Optional: wastedQty, if you split 'unused' vs 'wasted' later + // wastedQty: { + // type: Number, + // required: true, + // default: 0, + // } + }, + { + timestamps: true, // Adds createdAt and updatedAt + }, +); + +// Create a compound index for common queries +materialUsageSchema.index({ projectId: 1, date: 1 }); + +module.exports = mongoose.model('MaterialUsage', materialUsageSchema, 'materialusages'); diff --git a/src/routes/materialUtilizationRouter.js b/src/routes/materialUtilizationRouter.js new file mode 100644 index 000000000..1310acf58 --- /dev/null +++ b/src/routes/materialUtilizationRouter.js @@ -0,0 +1,18 @@ +const express = require('express'); +const controller = require('../controllers/materialUtilizationController'); +// const authMiddleware = require('../middleware/auth'); // Optional: Add auth middleware if needed + +const router = express.Router(); + +// GET /api/materials/utilization?start=...&end=...&projects[]=... +router.get( + '/materials/utilization', + // authMiddleware, // Uncomment if this route requires authentication + controller.getMaterialUtilization, +); + +router.get('/materials/distinct-projects', controller.getDistinctProjects); + +router.get('/materials/distinct-materials', controller.getDistinctMaterials); + +module.exports = router; diff --git a/src/startup/routes.js b/src/startup/routes.js index 71526e7a1..4554eefa6 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -159,6 +159,8 @@ const isEmailExistsRouter = require('../routes/isEmailExistsRouter')(); const jobNotificationListRouter = require('../routes/jobNotificationListRouter'); const helpCategoryRouter = require('../routes/helpCategoryRouter'); +const materialUtilizationRouter = require('../routes/materialUtilizationRouter'); + const userSkillsProfileRouter = require('../routes/userSkillsProfileRouter')(userProfile); const faqRouter = require('../routes/faqRouter'); @@ -401,6 +403,7 @@ module.exports = function (app) { app.use('/api', followUpRouter); app.use('/api', blueSquareEmailAssignmentRouter); app.use('/api', weeklySummaryEmailAssignmentRouter); + app.use('/api', materialUtilizationRouter); app.use('/api', formRouter); app.use('/api', collaborationRouter);