434 lines
14 KiB
JavaScript
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; |