const XLSX = require('xlsx'); const database = require('../models/database'); const path = require('path'); const fs = require('fs').promises; /** * Service for exporting inventory data to Excel files */ class ExcelExportService { constructor() { this.exportDirectory = path.join(__dirname, '..', 'data', 'exports'); this.ensureExportDirectory(); } /** * Ensure export directory exists */ async ensureExportDirectory() { try { await fs.mkdir(this.exportDirectory, { recursive: true }); } catch (error) { console.error('Failed to create export directory:', error); } } /** * Export inventory data to Excel format * @param {Object} options - Export options * @returns {Object} Export results with file path and metadata */ async exportInventoryToExcel(options = {}) { try { const { format = 'xlsx', includeHistory = false, filters = {}, filename, originalFileBuffer = null, preserveFormatting = true } = options; // Get inventory data const inventoryData = await this.getInventoryData(filters); let workbook; let exportResult; if (originalFileBuffer && preserveFormatting) { // Preserve original Excel structure and update data exportResult = await this.updateOriginalExcelFile(originalFileBuffer, inventoryData, options); } else { // Create new Excel file with standard format exportResult = await this.createNewExcelFile(inventoryData, options); } // Generate filename if not provided const exportFilename = filename || this.generateExportFilename(format); const exportPath = path.join(this.exportDirectory, exportFilename); // Write file to disk await this.writeExcelFile(exportResult.workbook, exportPath, format); // Create export session record const sessionId = await this.createExportSession({ filename: exportFilename, totalRecords: inventoryData.length, filters: filters, includeHistory: includeHistory }); return { success: true, filePath: exportPath, filename: exportFilename, sessionId: sessionId, recordCount: inventoryData.length, exportDate: new Date().toISOString(), metadata: exportResult.metadata }; } catch (error) { return { success: false, error: error.message, filePath: null }; } } /** * Get inventory data with optional filtering * @param {Object} filters - Filter options * @returns {Array} Array of inventory records with product information */ async getInventoryData(filters = {}) { const db = database.getDatabase(); let query = ` SELECT p.product_code, p.description, p.category, p.unit_of_measure, i.current_level, i.minimum_level, i.maximum_level, i.last_updated, i.updated_by, p.created_at as product_created_at, p.updated_at as product_updated_at, CASE WHEN i.current_level <= i.minimum_level THEN 'Low Stock' WHEN i.current_level <= i.minimum_level * 1.5 THEN 'Warning' ELSE 'Normal' END as stock_status FROM products p LEFT JOIN inventory i ON p.id = i.product_id `; const params = []; const conditions = []; // Apply filters if (filters.category) { conditions.push('p.category = ?'); params.push(filters.category); } if (filters.stockStatus) { switch (filters.stockStatus) { case 'low': conditions.push('i.current_level <= i.minimum_level'); break; case 'warning': conditions.push('i.current_level <= i.minimum_level * 1.5 AND i.current_level > i.minimum_level'); break; case 'normal': conditions.push('i.current_level > i.minimum_level * 1.5'); break; } } if (filters.updatedSince) { conditions.push('i.last_updated >= ?'); params.push(filters.updatedSince); } if (filters.productCodes && filters.productCodes.length > 0) { const placeholders = filters.productCodes.map(() => '?').join(','); conditions.push(`p.product_code IN (${placeholders})`); params.push(...filters.productCodes); } if (conditions.length > 0) { query += ' WHERE ' + conditions.join(' AND '); } query += ' ORDER BY p.product_code'; const stmt = db.prepare(query); return stmt.all(...params); } /** * Update original Excel file with current inventory data * @param {Buffer} originalFileBuffer - Original Excel file buffer * @param {Array} inventoryData - Current inventory data * @param {Object} options - Update options * @returns {Object} Updated workbook and metadata */ async updateOriginalExcelFile(originalFileBuffer, inventoryData, options = {}) { try { // Read original workbook const workbook = XLSX.read(originalFileBuffer, { type: 'buffer', cellStyles: true }); const sheetName = options.sheetName || workbook.SheetNames[0]; const worksheet = workbook.Sheets[sheetName]; if (!worksheet) { throw new Error(`Sheet "${sheetName}" not found in original file`); } // Get original data structure const originalData = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' }); if (originalData.length === 0) { throw new Error('Original Excel file appears to be empty'); } // Detect column mappings from original file const headerRow = originalData[0]; const columnMapping = this.detectColumnMappings(headerRow); // Create lookup map for current inventory data const inventoryMap = new Map(); inventoryData.forEach(item => { inventoryMap.set(item.product_code, item); }); // Update data rows while preserving structure const updatedData = [...originalData]; let updatedCount = 0; let addedCount = 0; // Update existing rows for (let i = 1; i < originalData.length; i++) { const row = [...originalData[i]]; const productCode = this.getCellValue(row, columnMapping.productCode); if (productCode && inventoryMap.has(productCode)) { const inventoryItem = inventoryMap.get(productCode); // Update quantity/current level if (columnMapping.quantity !== null) { row[columnMapping.quantity] = inventoryItem.current_level; } // Update description if it exists and is different if (columnMapping.description !== null && inventoryItem.description) { row[columnMapping.description] = inventoryItem.description; } // Update category if it exists if (columnMapping.category !== null && inventoryItem.category) { row[columnMapping.category] = inventoryItem.category; } // Add timestamp column if requested if (options.includeTimestamp) { if (columnMapping.lastUpdated === null) { // Add new column for timestamp if (i === 1) { // Add header updatedData[0].push('Last Updated'); columnMapping.lastUpdated = updatedData[0].length - 1; } row.push(inventoryItem.last_updated || ''); } else { row[columnMapping.lastUpdated] = inventoryItem.last_updated || ''; } } updatedData[i] = row; updatedCount++; inventoryMap.delete(productCode); // Remove from map to track what's been processed } } // Add new products that weren't in original file if (options.includeNewProducts && inventoryMap.size > 0) { inventoryMap.forEach(inventoryItem => { const newRow = new Array(headerRow.length).fill(''); if (columnMapping.productCode !== null) { newRow[columnMapping.productCode] = inventoryItem.product_code; } if (columnMapping.description !== null) { newRow[columnMapping.description] = inventoryItem.description || ''; } if (columnMapping.category !== null) { newRow[columnMapping.category] = inventoryItem.category || ''; } if (columnMapping.quantity !== null) { newRow[columnMapping.quantity] = inventoryItem.current_level; } if (columnMapping.lastUpdated !== null) { newRow[columnMapping.lastUpdated] = inventoryItem.last_updated || ''; } updatedData.push(newRow); addedCount++; }); } // Convert back to worksheet const newWorksheet = XLSX.utils.aoa_to_sheet(updatedData); // Preserve column widths and formatting where possible if (worksheet['!cols']) { newWorksheet['!cols'] = worksheet['!cols']; } if (worksheet['!rows']) { newWorksheet['!rows'] = worksheet['!rows']; } // Replace the worksheet in workbook workbook.Sheets[sheetName] = newWorksheet; return { workbook: workbook, metadata: { originalRows: originalData.length - 1, updatedRows: updatedCount, addedRows: addedCount, preservedFormatting: true, sheetName: sheetName } }; } catch (error) { throw new Error(`Failed to update original Excel file: ${error.message}`); } } /** * Create new Excel file with inventory data * @param {Array} inventoryData - Inventory data to export * @param {Object} options - Export options * @returns {Object} New workbook and metadata */ async createNewExcelFile(inventoryData, options = {}) { const { includeHistory = false, includeAuditInfo = true } = options; // Create main inventory sheet const inventorySheet = this.createInventorySheet(inventoryData, includeAuditInfo); // Create workbook const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, inventorySheet, 'Inventory'); // Add history sheet if requested if (includeHistory) { const historySheet = await this.createHistorySheet(inventoryData); XLSX.utils.book_append_sheet(workbook, historySheet, 'History'); } // Add summary sheet const summarySheet = this.createSummarySheet(inventoryData); XLSX.utils.book_append_sheet(workbook, summarySheet, 'Summary'); return { workbook: workbook, metadata: { sheets: ['Inventory', ...(includeHistory ? ['History'] : []), 'Summary'], recordCount: inventoryData.length, includeHistory: includeHistory, includeAuditInfo: includeAuditInfo } }; } /** * Create inventory worksheet * @param {Array} inventoryData - Inventory data * @param {boolean} includeAuditInfo - Whether to include audit information * @returns {Object} Worksheet object */ createInventorySheet(inventoryData, includeAuditInfo = true) { const headers = [ 'Product Code', 'Description', 'Category', 'Unit of Measure', 'Current Level', 'Minimum Level', 'Maximum Level', 'Stock Status' ]; if (includeAuditInfo) { headers.push('Last Updated', 'Updated By'); } // Create data rows const rows = [headers]; inventoryData.forEach(item => { const row = [ item.product_code || '', item.description || '', item.category || '', item.unit_of_measure || '', item.current_level || 0, item.minimum_level || 0, item.maximum_level || '', item.stock_status || 'Normal' ]; if (includeAuditInfo) { row.push( item.last_updated ? new Date(item.last_updated).toLocaleString() : '', item.updated_by || '' ); } rows.push(row); }); // Create worksheet const worksheet = XLSX.utils.aoa_to_sheet(rows); // Set column widths const columnWidths = [ { wch: 15 }, // Product Code { wch: 30 }, // Description { wch: 15 }, // Category { wch: 12 }, // Unit of Measure { wch: 12 }, // Current Level { wch: 12 }, // Minimum Level { wch: 12 }, // Maximum Level { wch: 12 } // Stock Status ]; if (includeAuditInfo) { columnWidths.push( { wch: 18 }, // Last Updated { wch: 15 } // Updated By ); } worksheet['!cols'] = columnWidths; return worksheet; } /** * Create history worksheet * @param {Array} inventoryData - Inventory data for getting history * @returns {Object} History worksheet */ async createHistorySheet(inventoryData) { const db = database.getDatabase(); // Get product IDs for history lookup const productCodes = inventoryData.map(item => item.product_code); const placeholders = productCodes.map(() => '?').join(','); const historyQuery = ` SELECT p.product_code, p.description, h.old_level, h.new_level, h.change_reason, h.updated_by, h.updated_at FROM inventory_history h JOIN products p ON h.product_id = p.id WHERE p.product_code IN (${placeholders}) ORDER BY h.updated_at DESC, p.product_code LIMIT 1000 `; const historyData = db.prepare(historyQuery).all(...productCodes); const headers = [ 'Product Code', 'Description', 'Old Level', 'New Level', 'Change Reason', 'Updated By', 'Updated At' ]; const rows = [headers]; historyData.forEach(record => { rows.push([ record.product_code, record.description || '', record.old_level || 0, record.new_level || 0, record.change_reason || '', record.updated_by || '', new Date(record.updated_at).toLocaleString() ]); }); const worksheet = XLSX.utils.aoa_to_sheet(rows); // Set column widths worksheet['!cols'] = [ { wch: 15 }, // Product Code { wch: 30 }, // Description { wch: 10 }, // Old Level { wch: 10 }, // New Level { wch: 20 }, // Change Reason { wch: 15 }, // Updated By { wch: 18 } // Updated At ]; return worksheet; } /** * Create summary worksheet * @param {Array} inventoryData - Inventory data for summary * @returns {Object} Summary worksheet */ createSummarySheet(inventoryData) { const summary = { totalProducts: inventoryData.length, lowStockItems: inventoryData.filter(item => item.stock_status === 'Low Stock').length, warningItems: inventoryData.filter(item => item.stock_status === 'Warning').length, normalItems: inventoryData.filter(item => item.stock_status === 'Normal').length, totalInventoryValue: inventoryData.reduce((sum, item) => sum + (item.current_level || 0), 0), categories: {} }; // Calculate category breakdown inventoryData.forEach(item => { const category = item.category || 'Uncategorized'; if (!summary.categories[category]) { summary.categories[category] = { count: 0, totalStock: 0, lowStock: 0 }; } summary.categories[category].count++; summary.categories[category].totalStock += item.current_level || 0; if (item.stock_status === 'Low Stock') { summary.categories[category].lowStock++; } }); // Create summary data const rows = [ ['Inventory Export Summary'], [''], ['Export Date', new Date().toLocaleString()], ['Total Products', summary.totalProducts], ['Total Inventory Items', summary.totalInventoryValue], [''], ['Stock Status Breakdown'], ['Low Stock Items', summary.lowStockItems], ['Warning Items', summary.warningItems], ['Normal Stock Items', summary.normalItems], [''], ['Category Breakdown'], ['Category', 'Product Count', 'Total Stock', 'Low Stock Items'] ]; // Add category data Object.entries(summary.categories).forEach(([category, data]) => { rows.push([category, data.count, data.totalStock, data.lowStock]); }); const worksheet = XLSX.utils.aoa_to_sheet(rows); // Set column widths worksheet['!cols'] = [ { wch: 25 }, { wch: 15 }, { wch: 15 }, { wch: 15 } ]; return worksheet; } /** * Detect column mappings from header row * @param {Array} headerRow - Header row from Excel * @returns {Object} Column mapping object */ detectColumnMappings(headerRow) { const mapping = { productCode: null, description: null, quantity: null, category: null, lastUpdated: null }; const patterns = { productCode: ['product_code', 'productcode', 'product code', 'code', 'item_code', 'sku'], description: ['description', 'desc', 'product_description', 'name', 'product_name'], quantity: ['quantity', 'qty', 'current_quantity', 'inventory', 'stock', 'current_level'], category: ['category', 'cat', 'product_category', 'type', 'group'], lastUpdated: ['last_updated', 'lastupdated', 'updated', 'timestamp', 'date_updated'] }; const normalizedHeaders = headerRow.map(header => typeof header === 'string' ? header.toLowerCase().trim() : '' ); Object.keys(patterns).forEach(columnType => { const columnPatterns = patterns[columnType]; for (let i = 0; i < normalizedHeaders.length; i++) { const header = normalizedHeaders[i]; if (columnPatterns.includes(header)) { mapping[columnType] = i; break; } const partialMatch = columnPatterns.find(pattern => header.includes(pattern) || pattern.includes(header) ); if (partialMatch && mapping[columnType] === null) { mapping[columnType] = i; } } }); return mapping; } /** * Get cell value safely * @param {Array} row - Data row * @param {number} columnIndex - Column index * @returns {string} Cell value or empty string */ getCellValue(row, columnIndex) { if (columnIndex === null || columnIndex >= row.length) { return ''; } const value = row[columnIndex]; if (value === null || value === undefined) { return ''; } return value.toString().trim(); } /** * Write Excel file to disk * @param {Object} workbook - Excel workbook object * @param {string} filePath - Output file path * @param {string} format - File format (xlsx, xls, csv) */ async writeExcelFile(workbook, filePath, format = 'xlsx') { try { const writeOptions = { bookType: format, type: 'buffer' }; const buffer = XLSX.write(workbook, writeOptions); await fs.writeFile(filePath, buffer); } catch (error) { throw new Error(`Failed to write Excel file: ${error.message}`); } } /** * Generate export filename with timestamp * @param {string} format - File format * @returns {string} Generated filename */ generateExportFilename(format = 'xlsx') { const timestamp = new Date().toISOString() .replace(/[:.]/g, '-') .replace('T', '_') .substring(0, 19); return `inventory_export_${timestamp}.${format}`; } /** * Create export session record for tracking * @param {Object} sessionData - Export session data * @returns {number} Session ID */ async createExportSession(sessionData) { try { const db = database.getDatabase(); // Create export_sessions table if it doesn't exist db.exec(` CREATE TABLE IF NOT EXISTS export_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, filename VARCHAR(255), total_records INTEGER, export_date DATETIME DEFAULT CURRENT_TIMESTAMP, filters TEXT, include_history BOOLEAN DEFAULT 0, status VARCHAR(20) DEFAULT 'completed' ) `); const stmt = db.prepare(` INSERT INTO export_sessions (filename, total_records, filters, include_history) VALUES (?, ?, ?, ?) `); const result = stmt.run( sessionData.filename, sessionData.totalRecords, JSON.stringify(sessionData.filters || {}), sessionData.includeHistory ? 1 : 0 ); return result.lastInsertRowid; } catch (error) { console.error('Failed to create export session:', error); return null; } } /** * Get export history * @param {Object} options - Query options * @returns {Array} Export history records */ async getExportHistory(options = {}) { try { const db = database.getDatabase(); const { limit = 50, offset = 0 } = options; const stmt = db.prepare(` SELECT * FROM export_sessions ORDER BY export_date DESC LIMIT ? OFFSET ? `); return stmt.all(limit, offset); } catch (error) { console.error('Failed to get export history:', error); return []; } } /** * Clean up old export files * @param {number} maxAgeHours - Maximum age in hours * @returns {Object} Cleanup results */ async cleanupOldExports(maxAgeHours = 24) { try { const files = await fs.readdir(this.exportDirectory); const cutoffTime = Date.now() - (maxAgeHours * 60 * 60 * 1000); let deletedCount = 0; for (const file of files) { const filePath = path.join(this.exportDirectory, file); const stats = await fs.stat(filePath); if (stats.mtime.getTime() < cutoffTime) { await fs.unlink(filePath); deletedCount++; } } return { success: true, deletedCount: deletedCount, message: `Cleaned up ${deletedCount} old export files` }; } catch (error) { return { success: false, error: error.message }; } } /** * Clear export history * @returns {Object} Result with success status and deleted count */ async clearExportHistory() { const db = database.getDatabase(); try { // Get count before deletion const countStmt = db.prepare('SELECT COUNT(*) as count FROM export_sessions'); const countResult = countStmt.get(); const deletedCount = countResult.count; // Clear export sessions table const deleteStmt = db.prepare('DELETE FROM export_sessions'); deleteStmt.run(); // Clean up export files directory try { const files = await fs.readdir(this.exportDirectory); for (const file of files) { if (file.endsWith('.xlsx') || file.endsWith('.xls')) { await fs.unlink(path.join(this.exportDirectory, file)); } } } catch (error) { console.warn('Warning: Could not clean up export files:', error.message); } return { success: true, deletedCount }; } catch (error) { console.error('Error clearing export history:', error); return { success: false, error: error.message }; } } } module.exports = ExcelExportService;