Files
inventory-barcode-system/models/Inventory.js

434 lines
14 KiB
JavaScript

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<Inventory>} 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<Inventory>} 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<number>} 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|null>} 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<Object[]>} 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<Object[]>} 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<Inventory[]>} 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<Object[]>} 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<Inventory>} 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;