Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified .husky/commit-msg
100755 → 100644
Empty file.
Empty file modified .husky/pre-commit
100755 → 100644
Empty file.
79 changes: 79 additions & 0 deletions src/controllers/__tests__/materialUtilizationController.test.js
Original file line number Diff line number Diff line change
@@ -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',
}),
);
});
});
});
217 changes: 217 additions & 0 deletions src/controllers/materialUtilizationController.js
Original file line number Diff line number Diff line change
@@ -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,
};
23 changes: 11 additions & 12 deletions src/controllers/userProfileController.js
Original file line number Diff line number Diff line change
Expand Up @@ -2626,31 +2626,30 @@
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' });
Expand All @@ -2663,7 +2662,7 @@
* Payload:
* { isSet: "RemoveFinalDay" }
*/
if (isSet === 'RemoveFinalDay') {

Check failure on line 2665 in src/controllers/userProfileController.js

View workflow job for this annotation

GitHub Actions / Lint Check

'isSet' is not defined
user.endDate = null;
user.isSet = false;

Expand All @@ -2690,14 +2689,14 @@
* isSet: "FinalDay"
* }
*/
if (!endDate || isSet !== 'FinalDay') {

Check failure on line 2692 in src/controllers/userProfileController.js

View workflow job for this annotation

GitHub Actions / Lint Check

'isSet' is not defined

Check failure on line 2692 in src/controllers/userProfileController.js

View workflow job for this annotation

GitHub Actions / Lint Check

'endDate' is not defined
return res.status(400).json({
success: false,
message: 'Invalid payload for setting final day',
});
}

const parsedEndDate = new Date(endDate);

Check failure on line 2699 in src/controllers/userProfileController.js

View workflow job for this annotation

GitHub Actions / Lint Check

'endDate' is not defined
if (Number.isNaN(parsedEndDate.getTime())) {
return res.status(400).json({ message: 'Invalid endDate format' });
}
Expand Down
Loading
Loading