const database = require('./database'); class Inventory { constructor(data = {}) { this.id = data.id || null; this.product_id = data.product_id || null; this.current_level = data.current_level || 0; this.minimum_level = data.minimum_level || 0; this.maximum_level = data.maximum_level || null; this.last_updated = data.last_updated || null; this.updated_by = data.updated_by || 'system'; this.version = data.version || 1; // For optimistic locking } /** * Validate inventory data * @returns {Object} validation result with isValid boolean and errors array */ validate() { const errors = []; // Required field validation if (!this.product_id || typeof this.product_id !== 'number') { errors.push('Product ID is required and must be a number'); } // Current level validation if (typeof this.current_level !== 'number' || this.current_level < 0) { errors.push('Current level must be a non-negative number'); } // Minimum level validation if (this.minimum_level !== null && (typeof this.minimum_level !== 'number' || this.minimum_level < 0)) { errors.push('Minimum level must be a non-negative number'); } // Maximum level validation if (this.maximum_level !== null && (typeof this.maximum_level !== 'number' || this.maximum_level < 0)) { errors.push('Maximum level must be a non-negative number'); } // Maximum should be greater than minimum if (this.maximum_level !== null && this.minimum_level !== null && this.maximum_level < this.minimum_level) { errors.push('Maximum level must be greater than minimum level'); } // Updated by validation if (!this.updated_by || this.updated_by.trim().length === 0) { errors.push('Updated by is required'); } return { isValid: errors.length === 0, errors: errors }; } /** * Save inventory record to database * @returns {Promise} Saved inventory record */ async save() { const validation = this.validate(); if (!validation.isValid) { throw new Error(`Validation failed: ${validation.errors.join(', ')}`); } const db = database.getDatabase(); if (this.id) { // Update existing record with optimistic locking const updateStmt = db.prepare(` UPDATE inventory SET current_level = ?, minimum_level = ?, maximum_level = ?, last_updated = CURRENT_TIMESTAMP, updated_by = ?, version = version + 1 WHERE id = ? AND version = ? `); const result = updateStmt.run( this.current_level, this.minimum_level, this.maximum_level, this.updated_by, this.id, this.version ); if (result.changes === 0) { throw new Error('Concurrent update detected. Please refresh and try again.'); } this.version += 1; this.last_updated = new Date().toISOString(); } else { // Insert new record const insertStmt = db.prepare(` INSERT INTO inventory (product_id, current_level, minimum_level, maximum_level, updated_by) VALUES (?, ?, ?, ?, ?) `); const result = insertStmt.run( this.product_id, this.current_level, this.minimum_level, this.maximum_level, this.updated_by ); this.id = result.lastInsertRowid; this.last_updated = new Date().toISOString(); } return this; } /** * Update inventory level with audit trail and concurrent update handling * @param {number} productId - Product ID to update * @param {number} newLevel - New inventory level * @param {string} changeReason - Reason for the change * @param {string} updatedBy - User making the change * @returns {Promise} Updated inventory record */ static async updateInventoryLevel(productId, newLevel, changeReason = '', updatedBy = 'system') { const db = database.getDatabase(); return database.executeTransaction(() => { // Get current inventory record with locking const currentInventory = db.prepare(` SELECT * FROM inventory WHERE product_id = ? `).get(productId); if (!currentInventory) { throw new Error(`Inventory record for product ID ${productId} not found`); } const oldLevel = currentInventory.current_level; // Validate the new level if (newLevel < 0) { throw new Error('Inventory level cannot be negative'); } // Update inventory with optimistic locking const updateStmt = db.prepare(` UPDATE inventory SET current_level = ?, last_updated = CURRENT_TIMESTAMP, updated_by = ?, version = version + 1 WHERE product_id = ? AND version = ? `); const result = updateStmt.run(newLevel, updatedBy, productId, currentInventory.version); if (result.changes === 0) { throw new Error('Concurrent update detected. Please refresh and try again.'); } // Create audit trail record const historyStmt = db.prepare(` INSERT INTO inventory_history (product_id, old_level, new_level, change_reason, updated_by) VALUES (?, ?, ?, ?, ?) `); historyStmt.run(productId, oldLevel, newLevel, changeReason, updatedBy); // Return updated inventory record const updatedInventory = db.prepare(` SELECT * FROM inventory WHERE product_id = ? `).get(productId); return new Inventory({ id: updatedInventory.id, product_id: updatedInventory.product_id, current_level: updatedInventory.current_level, minimum_level: updatedInventory.minimum_level, maximum_level: updatedInventory.maximum_level, last_updated: updatedInventory.last_updated, updated_by: updatedInventory.updated_by, version: updatedInventory.version }); }); } /** * Get current inventory level for a product * @param {number} productId - Product ID * @returns {Promise} Current inventory level */ static async getCurrentLevel(productId) { const db = database.getDatabase(); const stmt = db.prepare('SELECT current_level FROM inventory WHERE product_id = ?'); const result = stmt.get(productId); return result ? result.current_level : 0; } /** * Get inventory record by product ID * @param {number} productId - Product ID * @returns {Promise} Inventory record or null if not found */ static async getByProductId(productId) { const db = database.getDatabase(); const stmt = db.prepare('SELECT * FROM inventory WHERE product_id = ?'); const result = stmt.get(productId); return result ? new Inventory(result) : null; } /** * Get inventory history for a product * @param {number} productId - Product ID * @param {Object} options - Query options (limit, offset, startDate, endDate) * @returns {Promise} Array of inventory history records */ static async getInventoryHistory(productId, options = {}) { const db = database.getDatabase(); const { limit = 50, offset = 0, startDate, endDate } = options; let query = ` SELECT h.*, p.product_code, p.description FROM inventory_history h JOIN products p ON h.product_id = p.id WHERE h.product_id = ? `; const params = [productId]; if (startDate) { query += ' AND h.updated_at >= ?'; params.push(startDate); } if (endDate) { query += ' AND h.updated_at <= ?'; params.push(endDate); } query += ' ORDER BY h.updated_at DESC, h.id DESC LIMIT ? OFFSET ?'; params.push(limit, offset); const stmt = db.prepare(query); return stmt.all(...params); } /** * Get inventory summary for all products * @param {Object} filters - Optional filters (category, lowStock) * @returns {Promise} Array of inventory summary objects */ static async getInventorySummary(filters = {}) { const db = database.getDatabase(); let query = ` SELECT p.id, p.product_code, p.description, p.category, i.current_level, i.minimum_level, i.maximum_level, i.last_updated, i.updated_by, CASE WHEN i.current_level <= i.minimum_level THEN 'low' 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 = []; if (filters.category) { conditions.push('p.category = ?'); params.push(filters.category); } if (filters.lowStock) { conditions.push('i.current_level <= i.minimum_level'); } if (conditions.length > 0) { query += ' WHERE ' + conditions.join(' AND '); } query += ' ORDER BY p.product_code'; const stmt = db.prepare(query); return stmt.all(...params); } /** * Bulk update inventory levels with concurrent handling * @param {Array} updates - Array of {productId, newLevel, changeReason, updatedBy} objects * @returns {Promise} Array of updated inventory records */ static async bulkUpdateInventory(updates) { const db = database.getDatabase(); const results = []; return database.executeTransaction(() => { for (const update of updates) { const { productId, newLevel, changeReason = '', updatedBy = 'system' } = update; // Get current inventory record const currentInventory = db.prepare(` SELECT * FROM inventory WHERE product_id = ? `).get(productId); if (!currentInventory) { throw new Error(`Inventory record for product ID ${productId} not found`); } const oldLevel = currentInventory.current_level; if (newLevel < 0) { throw new Error(`Inventory level cannot be negative for product ${productId}`); } // Update inventory with optimistic locking const updateStmt = db.prepare(` UPDATE inventory SET current_level = ?, last_updated = CURRENT_TIMESTAMP, updated_by = ?, version = version + 1 WHERE product_id = ? AND version = ? `); const result = updateStmt.run(newLevel, updatedBy, productId, currentInventory.version); if (result.changes === 0) { throw new Error(`Concurrent update detected for product ${productId}. Please refresh and try again.`); } // Create audit trail record const historyStmt = db.prepare(` INSERT INTO inventory_history (product_id, old_level, new_level, change_reason, updated_by) VALUES (?, ?, ?, ?, ?) `); historyStmt.run(productId, oldLevel, newLevel, changeReason, updatedBy); // Get updated record const updatedInventory = db.prepare(` SELECT * FROM inventory WHERE product_id = ? `).get(productId); results.push(new Inventory({ id: updatedInventory.id, product_id: updatedInventory.product_id, current_level: updatedInventory.current_level, minimum_level: updatedInventory.minimum_level, maximum_level: updatedInventory.maximum_level, last_updated: updatedInventory.last_updated, updated_by: updatedInventory.updated_by, version: updatedInventory.version })); } return results; }); } /** * Get low stock items * @returns {Promise} Array of products with low stock */ static async getLowStockItems() { const db = database.getDatabase(); const stmt = db.prepare(` SELECT p.id, p.product_code, p.description, p.category, i.current_level, i.minimum_level, i.last_updated FROM products p JOIN inventory i ON p.id = i.product_id WHERE i.current_level <= i.minimum_level ORDER BY (i.current_level - i.minimum_level) ASC `); return stmt.all(); } /** * Create inventory record for a new product * @param {number} productId - Product ID * @param {number} initialLevel - Initial inventory level * @param {number} minimumLevel - Minimum stock level * @param {number} maximumLevel - Maximum stock level (optional) * @param {string} updatedBy - User creating the record * @returns {Promise} Created inventory record */ static async createForProduct(productId, initialLevel = 0, minimumLevel = 0, maximumLevel = null, updatedBy = 'system') { const inventory = new Inventory({ product_id: productId, current_level: initialLevel, minimum_level: minimumLevel, maximum_level: maximumLevel, updated_by: updatedBy }); await inventory.save(); // Create initial history record if there's an initial level if (initialLevel > 0) { const db = database.getDatabase(); const historyStmt = db.prepare(` INSERT INTO inventory_history (product_id, old_level, new_level, change_reason, updated_by) VALUES (?, ?, ?, ?, ?) `); historyStmt.run(productId, 0, initialLevel, 'Initial inventory setup', updatedBy); } return inventory; } /** * Convert inventory record to plain object * @returns {Object} Plain object representation */ toJSON() { return { id: this.id, product_id: this.product_id, current_level: this.current_level, minimum_level: this.minimum_level, maximum_level: this.maximum_level, last_updated: this.last_updated, updated_by: this.updated_by, version: this.version }; } } module.exports = Inventory;