const express = require('express'); const Inventory = require('../models/Inventory'); const Product = require('../models/Product'); const ExcelExportService = require('../services/ExcelExportService'); const multer = require('multer'); const path = require('path'); const router = express.Router(); /** * GET /api/inventory * Get inventory summary for all products with optional filtering */ router.get('/', async (req, res) => { try { const filters = {}; // Extract query parameters for filtering if (req.query.category) { filters.category = req.query.category; } if (req.query.lowStock === 'true') { filters.lowStock = true; } const inventorySummary = await Inventory.getInventorySummary(filters); res.json({ success: true, data: inventorySummary, count: inventorySummary.length }); } catch (error) { res.status(500).json({ success: false, error: 'Failed to retrieve inventory summary', message: error.message }); } }); /** * GET /api/inventory/low-stock * Get products with low stock levels */ router.get('/low-stock', async (req, res) => { try { const lowStockItems = await Inventory.getLowStockItems(); res.json({ success: true, data: lowStockItems, count: lowStockItems.length }); } catch (error) { res.status(500).json({ success: false, error: 'Failed to retrieve low stock items', message: error.message }); } }); /** * GET /api/inventory/product/:productId * Get inventory details for a specific product */ router.get('/product/:productId', async (req, res) => { try { const productId = parseInt(req.params.productId); if (isNaN(productId)) { return res.status(400).json({ success: false, error: 'Invalid product ID', message: 'Product ID must be a valid number' }); } const inventory = await Inventory.getByProductId(productId); if (!inventory) { return res.status(404).json({ success: false, error: 'Inventory not found', message: `Inventory record for product ID ${productId} does not exist` }); } res.json({ success: true, data: inventory.toJSON() }); } catch (error) { res.status(500).json({ success: false, error: 'Failed to retrieve inventory', message: error.message }); } }); /** * GET /api/inventory/product/:productId/level * Get current inventory level for a specific product */ router.get('/product/:productId/level', async (req, res) => { try { const productId = parseInt(req.params.productId); if (isNaN(productId)) { return res.status(400).json({ success: false, error: 'Invalid product ID', message: 'Product ID must be a valid number' }); } const currentLevel = await Inventory.getCurrentLevel(productId); res.json({ success: true, data: { product_id: productId, current_level: currentLevel } }); } catch (error) { res.status(500).json({ success: false, error: 'Failed to retrieve inventory level', message: error.message }); } }); /** * GET /api/inventory/product/:productId/history * Get inventory history for a specific product */ router.get('/product/:productId/history', async (req, res) => { try { const productId = parseInt(req.params.productId); if (isNaN(productId)) { return res.status(400).json({ success: false, error: 'Invalid product ID', message: 'Product ID must be a valid number' }); } // Parse query parameters for pagination and filtering const options = { limit: parseInt(req.query.limit) || 50, offset: parseInt(req.query.offset) || 0, startDate: req.query.startDate, endDate: req.query.endDate }; // Validate limit and offset if (options.limit < 1 || options.limit > 1000) { return res.status(400).json({ success: false, error: 'Invalid limit', message: 'Limit must be between 1 and 1000' }); } if (options.offset < 0) { return res.status(400).json({ success: false, error: 'Invalid offset', message: 'Offset must be non-negative' }); } const history = await Inventory.getInventoryHistory(productId, options); res.json({ success: true, data: history, count: history.length, pagination: { limit: options.limit, offset: options.offset, hasMore: history.length === options.limit } }); } catch (error) { res.status(500).json({ success: false, error: 'Failed to retrieve inventory history', message: error.message }); } }); /** * PUT /api/inventory/product/:productId/level * Update inventory level for a specific product */ router.put('/product/:productId/level', async (req, res) => { try { const productId = parseInt(req.params.productId); if (isNaN(productId)) { return res.status(400).json({ success: false, error: 'Invalid product ID', message: 'Product ID must be a valid number' }); } const { newLevel, changeReason, updatedBy } = req.body; // Validate required fields if (typeof newLevel !== 'number') { return res.status(400).json({ success: false, error: 'Invalid new level', message: 'New level must be a number' }); } if (newLevel < 0) { return res.status(400).json({ success: false, error: 'Invalid new level', message: 'New level cannot be negative' }); } // Verify product exists const product = await Product.findById(productId); if (!product) { return res.status(404).json({ success: false, error: 'Product not found', message: `Product with ID ${productId} does not exist` }); } const updatedInventory = await Inventory.updateInventoryLevel( productId, newLevel, changeReason || 'Manual update', updatedBy || 'api-user' ); res.json({ success: true, data: updatedInventory.toJSON(), message: 'Inventory level updated successfully' }); } catch (error) { if (error.message.includes('Concurrent update detected')) { res.status(409).json({ success: false, error: 'Concurrent update conflict', message: error.message }); } else if (error.message.includes('not found')) { res.status(404).json({ success: false, error: 'Inventory not found', message: error.message }); } else { res.status(500).json({ success: false, error: 'Failed to update inventory level', message: error.message }); } } }); /** * PUT /api/inventory/product/:productId * Update inventory settings (minimum/maximum levels) for a specific product */ router.put('/product/:productId', async (req, res) => { try { const productId = parseInt(req.params.productId); if (isNaN(productId)) { return res.status(400).json({ success: false, error: 'Invalid product ID', message: 'Product ID must be a valid number' }); } // Verify product exists const product = await Product.findById(productId); if (!product) { return res.status(404).json({ success: false, error: 'Product not found', message: `Product with ID ${productId} does not exist` }); } // Get existing inventory record let inventory = await Inventory.getByProductId(productId); if (!inventory) { return res.status(404).json({ success: false, error: 'Inventory not found', message: `Inventory record for product ID ${productId} does not exist` }); } // Update inventory settings const { minimum_level, maximum_level, updatedBy } = req.body; if (minimum_level !== undefined) { if (typeof minimum_level !== 'number' || minimum_level < 0) { return res.status(400).json({ success: false, error: 'Invalid minimum level', message: 'Minimum level must be a non-negative number' }); } inventory.minimum_level = minimum_level; } if (maximum_level !== undefined) { if (maximum_level !== null && (typeof maximum_level !== 'number' || maximum_level < 0)) { return res.status(400).json({ success: false, error: 'Invalid maximum level', message: 'Maximum level must be a non-negative number or null' }); } inventory.maximum_level = maximum_level; } if (updatedBy) { inventory.updated_by = updatedBy; } // Validate the updated inventory const validation = inventory.validate(); if (!validation.isValid) { return res.status(400).json({ success: false, error: 'Validation failed', message: 'Updated inventory data is invalid', details: validation.errors }); } await inventory.save(); res.json({ success: true, data: inventory.toJSON(), message: 'Inventory settings updated successfully' }); } catch (error) { if (error.message.includes('Concurrent update detected')) { res.status(409).json({ success: false, error: 'Concurrent update conflict', message: error.message }); } else { res.status(500).json({ success: false, error: 'Failed to update inventory settings', message: error.message }); } } }); /** * POST /api/inventory/product/:productId * Create inventory record for a product */ router.post('/product/:productId', async (req, res) => { try { const productId = parseInt(req.params.productId); if (isNaN(productId)) { return res.status(400).json({ success: false, error: 'Invalid product ID', message: 'Product ID must be a valid number' }); } // Verify product exists const product = await Product.findById(productId); if (!product) { return res.status(404).json({ success: false, error: 'Product not found', message: `Product with ID ${productId} does not exist` }); } // Check if inventory record already exists const existingInventory = await Inventory.getByProductId(productId); if (existingInventory) { return res.status(409).json({ success: false, error: 'Inventory already exists', message: `Inventory record for product ID ${productId} already exists` }); } const { initialLevel = 0, minimumLevel = 0, maximumLevel = null, updatedBy = 'api-user' } = req.body; // Validate input if (typeof initialLevel !== 'number' || initialLevel < 0) { return res.status(400).json({ success: false, error: 'Invalid initial level', message: 'Initial level must be a non-negative number' }); } if (typeof minimumLevel !== 'number' || minimumLevel < 0) { return res.status(400).json({ success: false, error: 'Invalid minimum level', message: 'Minimum level must be a non-negative number' }); } if (maximumLevel !== null && (typeof maximumLevel !== 'number' || maximumLevel < 0)) { return res.status(400).json({ success: false, error: 'Invalid maximum level', message: 'Maximum level must be a non-negative number or null' }); } const inventory = await Inventory.createForProduct( productId, initialLevel, minimumLevel, maximumLevel, updatedBy ); res.status(201).json({ success: true, data: inventory.toJSON(), message: 'Inventory record created successfully' }); } catch (error) { res.status(500).json({ success: false, error: 'Failed to create inventory record', message: error.message }); } }); /** * POST /api/inventory/bulk-update * Bulk update inventory levels for multiple products */ router.post('/bulk-update', async (req, res) => { try { const { updates } = req.body; if (!Array.isArray(updates) || updates.length === 0) { return res.status(400).json({ success: false, error: 'Invalid input', message: 'Updates array is required and cannot be empty' }); } // Validate each update for (let i = 0; i < updates.length; i++) { const update = updates[i]; if (!update.productId || typeof update.productId !== 'number') { return res.status(400).json({ success: false, error: 'Invalid update data', message: `Update at index ${i}: productId is required and must be a number` }); } if (typeof update.newLevel !== 'number' || update.newLevel < 0) { return res.status(400).json({ success: false, error: 'Invalid update data', message: `Update at index ${i}: newLevel must be a non-negative number` }); } } const updatedInventories = await Inventory.bulkUpdateInventory(updates); res.json({ success: true, data: updatedInventories.map(inv => inv.toJSON()), count: updatedInventories.length, message: `Successfully updated ${updatedInventories.length} inventory records` }); } catch (error) { if (error.message.includes('Concurrent update detected')) { res.status(409).json({ success: false, error: 'Concurrent update conflict', message: error.message }); } else if (error.message.includes('not found')) { res.status(404).json({ success: false, error: 'Inventory not found', message: error.message }); } else { res.status(500).json({ success: false, error: 'Failed to bulk update inventory', message: error.message }); } } }); /** * POST /api/inventory/adjust/:productId * Adjust inventory level (add or subtract from current level) */ router.post('/adjust/:productId', async (req, res) => { try { const productId = parseInt(req.params.productId); if (isNaN(productId)) { return res.status(400).json({ success: false, error: 'Invalid product ID', message: 'Product ID must be a valid number' }); } const { adjustment, changeReason, updatedBy } = req.body; // Validate required fields if (typeof adjustment !== 'number') { return res.status(400).json({ success: false, error: 'Invalid adjustment', message: 'Adjustment must be a number (positive to add, negative to subtract)' }); } // Verify product exists const product = await Product.findById(productId); if (!product) { return res.status(404).json({ success: false, error: 'Product not found', message: `Product with ID ${productId} does not exist` }); } // Get current level and calculate new level const currentLevel = await Inventory.getCurrentLevel(productId); const newLevel = currentLevel + adjustment; if (newLevel < 0) { return res.status(400).json({ success: false, error: 'Invalid adjustment', message: `Adjustment would result in negative inventory (current: ${currentLevel}, adjustment: ${adjustment})` }); } const updatedInventory = await Inventory.updateInventoryLevel( productId, newLevel, changeReason || `Inventory adjustment: ${adjustment > 0 ? '+' : ''}${adjustment}`, updatedBy || 'api-user' ); res.json({ success: true, data: { ...updatedInventory.toJSON(), adjustment: adjustment, previous_level: currentLevel }, message: 'Inventory level adjusted successfully' }); } catch (error) { if (error.message.includes('Concurrent update detected')) { res.status(409).json({ success: false, error: 'Concurrent update conflict', message: error.message }); } else if (error.message.includes('not found')) { res.status(404).json({ success: false, error: 'Inventory not found', message: error.message }); } else { res.status(500).json({ success: false, error: 'Failed to adjust inventory level', message: error.message }); } } }); // Configure multer for file uploads const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 // 10MB limit }, fileFilter: (req, file, cb) => { const allowedTypes = [ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx 'application/vnd.ms-excel', // .xls 'application/octet-stream' // fallback ]; if (allowedTypes.includes(file.mimetype) || file.originalname.match(/\.(xlsx|xls)$/i)) { cb(null, true); } else { cb(new Error('Only Excel files (.xlsx, .xls) are allowed'), false); } } }); /** * GET /api/inventory/export * Export inventory data to Excel format */ router.get('/export', async (req, res) => { try { const exportService = new ExcelExportService(); // Parse query parameters for filtering and options const filters = {}; const options = { format: req.query.format || 'xlsx', includeHistory: req.query.includeHistory === 'true', includeAuditInfo: req.query.includeAuditInfo !== 'false', // default true filename: req.query.filename }; // Apply filters if (req.query.category) { filters.category = req.query.category; } if (req.query.stockStatus) { filters.stockStatus = req.query.stockStatus; } if (req.query.updatedSince) { filters.updatedSince = req.query.updatedSince; } if (req.query.productCodes) { filters.productCodes = req.query.productCodes.split(',').map(code => code.trim()); } // Validate format const allowedFormats = ['xlsx', 'xls', 'csv']; if (!allowedFormats.includes(options.format)) { return res.status(400).json({ success: false, error: 'Invalid format', message: `Format must be one of: ${allowedFormats.join(', ')}` }); } // Export inventory data const exportResult = await exportService.exportInventoryToExcel({ ...options, filters: filters }); if (!exportResult.success) { return res.status(500).json({ success: false, error: 'Export failed', message: exportResult.error }); } // Set response headers for file download const contentType = options.format === 'csv' ? 'text/csv' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; res.setHeader('Content-Type', contentType); res.setHeader('Content-Disposition', `attachment; filename="${exportResult.filename}"`); res.setHeader('X-Export-Session-Id', exportResult.sessionId); res.setHeader('X-Record-Count', exportResult.recordCount); // Send file res.sendFile(exportResult.filePath, (err) => { if (err) { console.error('Error sending export file:', err); if (!res.headersSent) { res.status(500).json({ success: false, error: 'Failed to send export file', message: err.message }); } } }); } catch (error) { res.status(500).json({ success: false, error: 'Export failed', message: error.message }); } }); /** * POST /api/inventory/export/with-original * Export inventory data while preserving original Excel file structure */ router.post('/export/with-original', upload.single('originalFile'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ success: false, error: 'Missing original file', message: 'Original Excel file is required for structure preservation' }); } const exportService = new ExcelExportService(); // Parse options from request body const options = { format: req.body.format || 'xlsx', includeHistory: req.body.includeHistory === 'true', includeTimestamp: req.body.includeTimestamp !== 'false', // default true includeNewProducts: req.body.includeNewProducts === 'true', preserveFormatting: req.body.preserveFormatting !== 'false', // default true filename: req.body.filename, originalFileBuffer: req.file.buffer, sheetName: req.body.sheetName }; // Parse filters const filters = {}; if (req.body.category) { filters.category = req.body.category; } if (req.body.stockStatus) { filters.stockStatus = req.body.stockStatus; } if (req.body.updatedSince) { filters.updatedSince = req.body.updatedSince; } if (req.body.productCodes) { const codes = typeof req.body.productCodes === 'string' ? req.body.productCodes.split(',').map(code => code.trim()) : req.body.productCodes; filters.productCodes = codes; } // Export with original file structure const exportResult = await exportService.exportInventoryToExcel({ ...options, filters: filters }); if (!exportResult.success) { return res.status(500).json({ success: false, error: 'Export failed', message: exportResult.error }); } // Set response headers for file download const contentType = options.format === 'csv' ? 'text/csv' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; res.setHeader('Content-Type', contentType); res.setHeader('Content-Disposition', `attachment; filename="${exportResult.filename}"`); res.setHeader('X-Export-Session-Id', exportResult.sessionId); res.setHeader('X-Record-Count', exportResult.recordCount); res.setHeader('X-Preserved-Formatting', exportResult.metadata?.preservedFormatting || false); // Send file res.sendFile(exportResult.filePath, (err) => { if (err) { console.error('Error sending export file:', err); if (!res.headersSent) { res.status(500).json({ success: false, error: 'Failed to send export file', message: err.message }); } } }); } catch (error) { res.status(500).json({ success: false, error: 'Export failed', message: error.message }); } }); /** * GET /api/inventory/export/history * Get export history with pagination */ router.get('/export/history', async (req, res) => { try { const exportService = new ExcelExportService(); const options = { limit: parseInt(req.query.limit) || 50, offset: parseInt(req.query.offset) || 0 }; // Validate pagination parameters if (options.limit < 1 || options.limit > 1000) { return res.status(400).json({ success: false, error: 'Invalid limit', message: 'Limit must be between 1 and 1000' }); } if (options.offset < 0) { return res.status(400).json({ success: false, error: 'Invalid offset', message: 'Offset must be non-negative' }); } const history = await exportService.getExportHistory(options); res.json({ success: true, data: history, count: history.length, pagination: { limit: options.limit, offset: options.offset, hasMore: history.length === options.limit } }); } catch (error) { res.status(500).json({ success: false, error: 'Failed to retrieve export history', message: error.message }); } }); /** * DELETE /api/inventory/export/cleanup * Clean up old export files */ router.delete('/export/cleanup', async (req, res) => { try { const exportService = new ExcelExportService(); const maxAgeHours = parseInt(req.query.maxAgeHours) || 24; // Validate maxAgeHours if (maxAgeHours < 1 || maxAgeHours > 168) { // 1 hour to 1 week return res.status(400).json({ success: false, error: 'Invalid maxAgeHours', message: 'maxAgeHours must be between 1 and 168 (1 week)' }); } const cleanupResult = await exportService.cleanupOldExports(maxAgeHours); if (!cleanupResult.success) { return res.status(500).json({ success: false, error: 'Cleanup failed', message: cleanupResult.error }); } res.json({ success: true, data: { deletedCount: cleanupResult.deletedCount, maxAgeHours: maxAgeHours }, message: cleanupResult.message }); } catch (error) { res.status(500).json({ success: false, error: 'Cleanup failed', message: error.message }); } }); /** * DELETE /api/inventory/export/history * Clear export history */ router.delete('/export/history', async (req, res) => { try { const exportService = new ExcelExportService(); const result = await exportService.clearExportHistory(); if (result.success) { res.json({ success: true, message: 'Export history cleared successfully', deletedCount: result.deletedCount || 0 }); } else { res.status(500).json({ success: false, error: 'Failed to clear export history', message: result.error || 'Unknown error' }); } } catch (error) { console.error('Error clearing export history:', error); res.status(500).json({ success: false, error: 'Failed to clear export history', message: error.message }); } }); module.exports = router;