Initial commit: Inventory Barcode System
This commit is contained in:
2
models/.gitkeep
Normal file
2
models/.gitkeep
Normal file
@ -0,0 +1,2 @@
|
||||
# Models directory
|
||||
This directory contains data models and database schemas
|
||||
434
models/Inventory.js
Normal file
434
models/Inventory.js
Normal file
@ -0,0 +1,434 @@
|
||||
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;
|
||||
235
models/Product.js
Normal file
235
models/Product.js
Normal file
@ -0,0 +1,235 @@
|
||||
const database = require('./database');
|
||||
|
||||
class Product {
|
||||
constructor(data = {}) {
|
||||
this.id = data.id || null;
|
||||
this.name = data.name || '';
|
||||
this.description = data.description || '';
|
||||
this.category = data.category || '';
|
||||
this.quantity = data.quantity || 0;
|
||||
this.unit = data.unit || '';
|
||||
this.barcode = data.barcode || null;
|
||||
this.qr_code = data.qr_code || null;
|
||||
this.location = data.location || '';
|
||||
this.min_stock_level = data.min_stock_level || 0;
|
||||
this.created_at = data.created_at || null;
|
||||
this.updated_at = data.updated_at || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate product data
|
||||
* @returns {Object} validation result with isValid boolean and errors array
|
||||
*/
|
||||
validate() {
|
||||
const errors = [];
|
||||
|
||||
// Required field validation
|
||||
if (!this.name || this.name.trim().length === 0) {
|
||||
errors.push('Product name is required');
|
||||
}
|
||||
|
||||
if (this.name && this.name.length > 255) {
|
||||
errors.push('Product name must be less than 255 characters');
|
||||
}
|
||||
|
||||
// Quantity validation
|
||||
if (typeof this.quantity !== 'number' || this.quantity < 0) {
|
||||
errors.push('Quantity must be a non-negative number');
|
||||
}
|
||||
|
||||
// Min stock level validation
|
||||
if (typeof this.min_stock_level !== 'number' || this.min_stock_level < 0) {
|
||||
errors.push('Minimum stock level must be a non-negative number');
|
||||
}
|
||||
|
||||
// Barcode validation (if provided)
|
||||
if (this.barcode && (typeof this.barcode !== 'string' || this.barcode.trim().length === 0)) {
|
||||
errors.push('Barcode must be a non-empty string if provided');
|
||||
}
|
||||
|
||||
// Category validation
|
||||
if (this.category && this.category.length > 100) {
|
||||
errors.push('Category must be less than 100 characters');
|
||||
}
|
||||
|
||||
// Unit validation
|
||||
if (this.unit && this.unit.length > 20) {
|
||||
errors.push('Unit must be less than 20 characters');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors: errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save product to database
|
||||
* @returns {Promise<Product>} saved product instance
|
||||
*/
|
||||
async save() {
|
||||
const validation = this.validate();
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
const db = database.getDatabase();
|
||||
|
||||
try {
|
||||
if (this.id) {
|
||||
// Update existing product
|
||||
const stmt = db.prepare(`
|
||||
UPDATE items
|
||||
SET name = ?, description = ?, category = ?, quantity = ?,
|
||||
unit = ?, barcode = ?, qr_code = ?, location = ?,
|
||||
min_stock_level = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
this.name, this.description, this.category, this.quantity,
|
||||
this.unit, this.barcode, this.qr_code, this.location,
|
||||
this.min_stock_level, this.id
|
||||
);
|
||||
} else {
|
||||
// Insert new product
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO items (name, description, category, quantity, unit, barcode, qr_code, location, min_stock_level)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
this.name, this.description, this.category, this.quantity,
|
||||
this.unit, this.barcode, this.qr_code, this.location,
|
||||
this.min_stock_level
|
||||
);
|
||||
|
||||
this.id = result.lastInsertRowid;
|
||||
}
|
||||
|
||||
return this;
|
||||
} catch (error) {
|
||||
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
throw new Error('A product with this barcode already exists');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find product by ID
|
||||
* @param {number} id - Product ID
|
||||
* @returns {Promise<Product|null>} Product instance or null if not found
|
||||
*/
|
||||
static async findById(id) {
|
||||
const db = database.getDatabase();
|
||||
const stmt = db.prepare('SELECT * FROM items WHERE id = ?');
|
||||
const row = stmt.get(id);
|
||||
|
||||
return row ? new Product(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find product by barcode
|
||||
* @param {string} barcode - Product barcode
|
||||
* @returns {Promise<Product|null>} Product instance or null if not found
|
||||
*/
|
||||
static async findByBarcode(barcode) {
|
||||
const db = database.getDatabase();
|
||||
const stmt = db.prepare('SELECT * FROM items WHERE barcode = ?');
|
||||
const row = stmt.get(barcode);
|
||||
|
||||
return row ? new Product(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all products with optional filtering
|
||||
* @param {Object} filters - Optional filters
|
||||
* @returns {Promise<Product[]>} Array of Product instances
|
||||
*/
|
||||
static async findAll(filters = {}) {
|
||||
const db = database.getDatabase();
|
||||
let query = 'SELECT * FROM items';
|
||||
const params = [];
|
||||
const conditions = [];
|
||||
|
||||
if (filters.category) {
|
||||
conditions.push('category = ?');
|
||||
params.push(filters.category);
|
||||
}
|
||||
|
||||
if (filters.name) {
|
||||
conditions.push('name LIKE ?');
|
||||
params.push(`%${filters.name}%`);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
query += ' ORDER BY name';
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
const rows = stmt.all(...params);
|
||||
|
||||
return rows.map(row => new Product(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete product by ID
|
||||
* @param {number} id - Product ID
|
||||
* @returns {Promise<boolean>} true if deleted, false if not found
|
||||
*/
|
||||
static async deleteById(id) {
|
||||
const db = database.getDatabase();
|
||||
const stmt = db.prepare('DELETE FROM items WHERE id = ?');
|
||||
const result = stmt.run(id);
|
||||
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert product to plain object
|
||||
* @returns {Object} Plain object representation
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
category: this.category,
|
||||
quantity: this.quantity,
|
||||
unit: this.unit,
|
||||
barcode: this.barcode,
|
||||
qr_code: this.qr_code,
|
||||
location: this.location,
|
||||
min_stock_level: this.min_stock_level,
|
||||
created_at: this.created_at,
|
||||
updated_at: this.updated_at
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Get all unique categories
|
||||
* @returns {Promise<Array>} Array of unique category names
|
||||
*/
|
||||
static async getCategories() {
|
||||
const db = database.getDatabase();
|
||||
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT DISTINCT category
|
||||
FROM products
|
||||
WHERE category IS NOT NULL AND category != ''
|
||||
ORDER BY category
|
||||
`);
|
||||
|
||||
const rows = stmt.all();
|
||||
return rows.map(row => row.category);
|
||||
} catch (error) {
|
||||
console.error('Error getting categories:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Product;
|
||||
696
models/database.js
Normal file
696
models/database.js
Normal file
@ -0,0 +1,696 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const logger = require('../utils/logger');
|
||||
const { withDatabaseRetry, CircuitBreaker } = require('../utils/retry');
|
||||
const { DatabaseError } = require('../middleware/errorHandler');
|
||||
|
||||
class DatabaseManager {
|
||||
constructor() {
|
||||
this.db = null;
|
||||
this.dbPath = path.join(__dirname, '..', 'inventory.db');
|
||||
this.isInitialized = false;
|
||||
this.circuitBreaker = new CircuitBreaker({
|
||||
failureThreshold: 5,
|
||||
resetTimeout: 30000
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database connection and create tables
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.isInitialized) {
|
||||
logger.debug('Database already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await withDatabaseRetry(async () => {
|
||||
logger.info('Initializing database connection', { dbPath: this.dbPath });
|
||||
|
||||
this.db = new Database(this.dbPath);
|
||||
|
||||
// Configure database settings
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.pragma('synchronous = NORMAL');
|
||||
this.db.pragma('cache_size = 1000');
|
||||
this.db.pragma('temp_store = memory');
|
||||
this.db.pragma('mmap_size = 268435456'); // 256MB
|
||||
|
||||
// Set busy timeout
|
||||
this.db.pragma('busy_timeout = 5000');
|
||||
|
||||
await this.createTables();
|
||||
|
||||
this.isInitialized = true;
|
||||
|
||||
logger.info('Database initialized successfully', {
|
||||
dbPath: this.dbPath,
|
||||
journalMode: this.db.pragma('journal_mode', { simple: true }),
|
||||
cacheSize: this.db.pragma('cache_size', { simple: true })
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Database initialization failed', {
|
||||
error: error.message,
|
||||
dbPath: this.dbPath,
|
||||
stack: error.stack
|
||||
});
|
||||
throw new DatabaseError('Failed to initialize database', {
|
||||
originalError: error.message,
|
||||
dbPath: this.dbPath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create all required tables
|
||||
*/
|
||||
async createTables() {
|
||||
// Products table as per design document
|
||||
const createProductsTable = `
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_code VARCHAR(50) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(100),
|
||||
unit_of_measure VARCHAR(20),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
// Inventory table as per design document
|
||||
const createInventoryTable = `
|
||||
CREATE TABLE IF NOT EXISTS inventory (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_id INTEGER NOT NULL,
|
||||
current_level INTEGER NOT NULL DEFAULT 0,
|
||||
minimum_level INTEGER DEFAULT 0,
|
||||
maximum_level INTEGER,
|
||||
last_updated DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(100),
|
||||
version INTEGER DEFAULT 1,
|
||||
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
|
||||
)
|
||||
`;
|
||||
|
||||
// Inventory history table as per design document
|
||||
const createInventoryHistoryTable = `
|
||||
CREATE TABLE IF NOT EXISTS inventory_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_id INTEGER NOT NULL,
|
||||
old_level INTEGER,
|
||||
new_level INTEGER NOT NULL,
|
||||
change_reason VARCHAR(200),
|
||||
updated_by VARCHAR(100),
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
|
||||
)
|
||||
`;
|
||||
|
||||
// Import sessions table as per design document
|
||||
const createImportSessionsTable = `
|
||||
CREATE TABLE IF NOT EXISTS import_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename VARCHAR(255),
|
||||
total_records INTEGER,
|
||||
successful_imports INTEGER,
|
||||
failed_imports INTEGER,
|
||||
import_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
status VARCHAR(20) DEFAULT 'completed'
|
||||
)
|
||||
`;
|
||||
|
||||
// Legacy tables for backward compatibility (will be migrated)
|
||||
const createItemsTable = `
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
quantity INTEGER NOT NULL DEFAULT 0,
|
||||
unit TEXT,
|
||||
barcode TEXT UNIQUE,
|
||||
qr_code TEXT,
|
||||
location TEXT,
|
||||
min_stock_level INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
const createTransactionsTable = `
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('in', 'out', 'adjustment')),
|
||||
quantity INTEGER NOT NULL,
|
||||
reason TEXT,
|
||||
user_name TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (item_id) REFERENCES items (id) ON DELETE CASCADE
|
||||
)
|
||||
`;
|
||||
|
||||
const createIndexes = [
|
||||
// New schema indexes - optimized for performance
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_products_product_code ON products(product_code)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_products_category ON products(category)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_products_description ON products(description)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_products_created_at ON products(created_at)',
|
||||
|
||||
// Inventory indexes - optimized for common queries
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_product_id ON inventory(product_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inventory_current_level ON inventory(current_level)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inventory_low_stock ON inventory(current_level, minimum_level) WHERE current_level <= minimum_level',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inventory_last_updated ON inventory(last_updated)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inventory_updated_by ON inventory(updated_by)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inventory_version ON inventory(version)', // For optimistic locking
|
||||
|
||||
// Inventory history indexes - optimized for audit queries
|
||||
'CREATE INDEX IF NOT EXISTS idx_inventory_history_product_id ON inventory_history(product_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inventory_history_updated_at ON inventory_history(updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inventory_history_updated_by ON inventory_history(updated_by)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inventory_history_product_date ON inventory_history(product_id, updated_at)',
|
||||
|
||||
// Import sessions indexes
|
||||
'CREATE INDEX IF NOT EXISTS idx_import_sessions_date ON import_sessions(import_date)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_import_sessions_status ON import_sessions(status)',
|
||||
|
||||
// Composite indexes for complex queries
|
||||
'CREATE INDEX IF NOT EXISTS idx_products_category_code ON products(category, product_code)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inventory_product_level ON inventory(product_id, current_level)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inventory_levels_range ON inventory(current_level, minimum_level, maximum_level)',
|
||||
|
||||
// Legacy schema indexes
|
||||
'CREATE INDEX IF NOT EXISTS idx_items_barcode ON items(barcode)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_items_name ON items(name)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_items_category ON items(category)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_items_quantity ON items(quantity)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_transactions_item_id ON transactions(item_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_transactions_created_at ON transactions(created_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_transactions_type ON transactions(type)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_transactions_item_date ON transactions(item_id, created_at)'
|
||||
];
|
||||
|
||||
try {
|
||||
logger.info('Creating database tables and indexes');
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create new schema tables
|
||||
this.db.exec(createProductsTable);
|
||||
this.db.exec(createInventoryTable);
|
||||
this.db.exec(createInventoryHistoryTable);
|
||||
this.db.exec(createImportSessionsTable);
|
||||
|
||||
// Create legacy tables for backward compatibility
|
||||
this.db.exec(createItemsTable);
|
||||
this.db.exec(createTransactionsTable);
|
||||
|
||||
// Create indexes
|
||||
createIndexes.forEach((indexQuery, index) => {
|
||||
try {
|
||||
this.db.exec(indexQuery);
|
||||
logger.debug('Index created', { index: index + 1, total: createIndexes.length });
|
||||
} catch (indexError) {
|
||||
logger.warn('Index creation failed', {
|
||||
query: indexQuery,
|
||||
error: indexError.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info('Database tables created successfully', { duration: `${duration}ms` });
|
||||
} catch (error) {
|
||||
logger.error('Error creating database tables', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw new DatabaseError('Failed to create database tables', {
|
||||
originalError: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database instance with circuit breaker protection
|
||||
*/
|
||||
getDatabase() {
|
||||
if (!this.db || !this.isInitialized) {
|
||||
throw new DatabaseError('Database not initialized. Call initialize() first.');
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute database operation with circuit breaker and retry logic
|
||||
*/
|
||||
async executeWithProtection(operation, operationName = 'database_operation') {
|
||||
return await this.circuitBreaker.execute(async () => {
|
||||
return await withDatabaseRetry(async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logger.logDbOperation(operationName, 'unknown', {}, duration);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logger.error('Database operation failed', {
|
||||
operation: operationName,
|
||||
duration: `${duration}ms`,
|
||||
error: error.message,
|
||||
code: error.code
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
close() {
|
||||
if (this.db) {
|
||||
try {
|
||||
logger.info('Closing database connection');
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
this.isInitialized = false;
|
||||
logger.info('Database connection closed successfully');
|
||||
} catch (error) {
|
||||
logger.error('Error closing database connection', {
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a transaction with rollback support and error handling
|
||||
*/
|
||||
async executeTransaction(callback, transactionName = 'transaction') {
|
||||
return await this.executeWithProtection(async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
logger.debug('Starting database transaction', { name: transactionName });
|
||||
|
||||
const transaction = this.db.transaction(callback);
|
||||
const result = transaction();
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.debug('Database transaction completed', {
|
||||
name: transactionName,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error('Database transaction failed', {
|
||||
name: transactionName,
|
||||
duration: `${duration}ms`,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
throw new DatabaseError(`Transaction failed: ${transactionName}`, {
|
||||
originalError: error.message,
|
||||
transactionName
|
||||
});
|
||||
}
|
||||
}, `transaction_${transactionName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for database connection
|
||||
*/
|
||||
async healthCheck() {
|
||||
try {
|
||||
const result = this.db.prepare('SELECT 1 as health').get();
|
||||
return {
|
||||
status: 'healthy',
|
||||
connected: true,
|
||||
result: result.health === 1
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Database health check failed', {
|
||||
error: error.message
|
||||
});
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
connected: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database statistics
|
||||
*/
|
||||
getStats() {
|
||||
try {
|
||||
const stats = {
|
||||
isInitialized: this.isInitialized,
|
||||
dbPath: this.dbPath,
|
||||
circuitBreakerState: this.circuitBreaker.getState()
|
||||
};
|
||||
|
||||
if (this.db) {
|
||||
stats.pragmas = {
|
||||
journalMode: this.db.pragma('journal_mode', { simple: true }),
|
||||
synchronous: this.db.pragma('synchronous', { simple: true }),
|
||||
cacheSize: this.db.pragma('cache_size', { simple: true }),
|
||||
busyTimeout: this.db.pragma('busy_timeout', { simple: true })
|
||||
};
|
||||
|
||||
// Get table statistics
|
||||
stats.tables = this.getTableStats();
|
||||
|
||||
// Get index usage statistics
|
||||
stats.indexes = this.getIndexStats();
|
||||
}
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
logger.error('Error getting database stats', {
|
||||
error: error.message
|
||||
});
|
||||
return {
|
||||
isInitialized: this.isInitialized,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table statistics for performance monitoring
|
||||
*/
|
||||
getTableStats() {
|
||||
try {
|
||||
const tables = ['products', 'inventory', 'inventory_history', 'import_sessions'];
|
||||
const stats = {};
|
||||
|
||||
tables.forEach(table => {
|
||||
try {
|
||||
const countResult = this.db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get();
|
||||
stats[table] = {
|
||||
rowCount: countResult.count
|
||||
};
|
||||
} catch (error) {
|
||||
stats[table] = { error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
logger.error('Error getting table stats', { error: error.message });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get index statistics for query optimization
|
||||
*/
|
||||
getIndexStats() {
|
||||
try {
|
||||
const indexes = this.db.prepare(`
|
||||
SELECT name, tbl_name, sql
|
||||
FROM sqlite_master
|
||||
WHERE type = 'index' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY tbl_name, name
|
||||
`).all();
|
||||
|
||||
return indexes.map(index => ({
|
||||
name: index.name,
|
||||
table: index.tbl_name,
|
||||
definition: index.sql
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Error getting index stats', { error: error.message });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze database performance and suggest optimizations
|
||||
*/
|
||||
analyzePerformance() {
|
||||
try {
|
||||
const analysis = {
|
||||
timestamp: new Date().toISOString(),
|
||||
recommendations: []
|
||||
};
|
||||
|
||||
// Check for missing indexes on frequently queried columns
|
||||
const tableStats = this.getTableStats();
|
||||
|
||||
// Analyze query patterns (this would be enhanced with actual query logging)
|
||||
if (tableStats.products && tableStats.products.rowCount > 1000) {
|
||||
analysis.recommendations.push({
|
||||
type: 'INDEX_OPTIMIZATION',
|
||||
message: 'Consider adding composite indexes for frequently filtered product queries',
|
||||
priority: 'MEDIUM'
|
||||
});
|
||||
}
|
||||
|
||||
if (tableStats.inventory_history && tableStats.inventory_history.rowCount > 10000) {
|
||||
analysis.recommendations.push({
|
||||
type: 'ARCHIVAL',
|
||||
message: 'Consider archiving old inventory history records to improve query performance',
|
||||
priority: 'LOW'
|
||||
});
|
||||
}
|
||||
|
||||
// Check cache hit ratio (simplified)
|
||||
const cacheSize = this.db.pragma('cache_size', { simple: true });
|
||||
if (cacheSize < 2000) {
|
||||
analysis.recommendations.push({
|
||||
type: 'CACHE_OPTIMIZATION',
|
||||
message: 'Consider increasing cache size for better performance with large datasets',
|
||||
priority: 'HIGH'
|
||||
});
|
||||
}
|
||||
|
||||
return analysis;
|
||||
} catch (error) {
|
||||
logger.error('Error analyzing database performance', { error: error.message });
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error.message,
|
||||
recommendations: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize database for better performance
|
||||
*/
|
||||
async optimize() {
|
||||
return await this.executeWithProtection(async () => {
|
||||
logger.info('Starting database optimization');
|
||||
|
||||
const startTime = Date.now();
|
||||
const results = {
|
||||
vacuum: false,
|
||||
analyze: false,
|
||||
reindex: false,
|
||||
pragmaUpdates: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Run VACUUM to reclaim space and defragment
|
||||
this.db.exec('VACUUM');
|
||||
results.vacuum = true;
|
||||
logger.debug('Database VACUUM completed');
|
||||
|
||||
// Run ANALYZE to update query planner statistics
|
||||
this.db.exec('ANALYZE');
|
||||
results.analyze = true;
|
||||
logger.debug('Database ANALYZE completed');
|
||||
|
||||
// Reindex all indexes
|
||||
this.db.exec('REINDEX');
|
||||
results.reindex = true;
|
||||
logger.debug('Database REINDEX completed');
|
||||
|
||||
// Optimize pragma settings for performance
|
||||
const optimizations = [
|
||||
{ pragma: 'optimize', value: null }, // SQLite auto-optimization
|
||||
{ pragma: 'cache_size', value: 2000 },
|
||||
{ pragma: 'temp_store', value: 'memory' },
|
||||
{ pragma: 'mmap_size', value: 268435456 } // 256MB
|
||||
];
|
||||
|
||||
optimizations.forEach(opt => {
|
||||
try {
|
||||
if (opt.value !== null) {
|
||||
this.db.pragma(`${opt.pragma} = ${opt.value}`);
|
||||
} else {
|
||||
this.db.pragma(opt.pragma);
|
||||
}
|
||||
results.pragmaUpdates.push(opt.pragma);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to update pragma ${opt.pragma}`, { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info('Database optimization completed', {
|
||||
duration: `${duration}ms`,
|
||||
results
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
duration,
|
||||
results
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error('Database optimization failed', {
|
||||
duration: `${duration}ms`,
|
||||
error: error.message,
|
||||
partialResults: results
|
||||
});
|
||||
|
||||
throw new DatabaseError('Database optimization failed', {
|
||||
originalError: error.message,
|
||||
partialResults: results
|
||||
});
|
||||
}
|
||||
}, 'database_optimization');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare optimized statements for common queries
|
||||
*/
|
||||
prepareOptimizedStatements() {
|
||||
if (!this.db) {
|
||||
throw new DatabaseError('Database not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Cache frequently used prepared statements
|
||||
this.preparedStatements = {
|
||||
// Product queries
|
||||
findProductByCode: this.db.prepare(`
|
||||
SELECT p.*, i.current_level, i.minimum_level, i.maximum_level
|
||||
FROM products p
|
||||
LEFT JOIN inventory i ON p.id = i.product_id
|
||||
WHERE p.product_code = ?
|
||||
`),
|
||||
|
||||
findProductsByCategory: this.db.prepare(`
|
||||
SELECT p.*, i.current_level, i.minimum_level, i.maximum_level
|
||||
FROM products p
|
||||
LEFT JOIN inventory i ON p.id = i.product_id
|
||||
WHERE p.category = ?
|
||||
ORDER BY p.product_code
|
||||
LIMIT ? OFFSET ?
|
||||
`),
|
||||
|
||||
// Inventory queries
|
||||
getInventorySummary: this.db.prepare(`
|
||||
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.maximum_level THEN 'high'
|
||||
ELSE 'normal'
|
||||
END as stock_status
|
||||
FROM products p
|
||||
INNER JOIN inventory i ON p.id = i.product_id
|
||||
ORDER BY p.product_code
|
||||
LIMIT ? OFFSET ?
|
||||
`),
|
||||
|
||||
getLowStockItems: this.db.prepare(`
|
||||
SELECT
|
||||
p.id,
|
||||
p.product_code,
|
||||
p.description,
|
||||
p.category,
|
||||
i.current_level,
|
||||
i.minimum_level,
|
||||
i.last_updated
|
||||
FROM products p
|
||||
INNER JOIN inventory i ON p.id = i.product_id
|
||||
WHERE i.current_level <= i.minimum_level
|
||||
ORDER BY (i.current_level - i.minimum_level), p.product_code
|
||||
`),
|
||||
|
||||
// History queries
|
||||
getInventoryHistory: this.db.prepare(`
|
||||
SELECT
|
||||
ih.*,
|
||||
p.product_code,
|
||||
p.description
|
||||
FROM inventory_history ih
|
||||
INNER JOIN products p ON ih.product_id = p.id
|
||||
WHERE ih.product_id = ?
|
||||
ORDER BY ih.updated_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`),
|
||||
|
||||
// Update queries with optimistic locking
|
||||
updateInventoryLevel: this.db.prepare(`
|
||||
UPDATE inventory
|
||||
SET current_level = ?,
|
||||
last_updated = datetime('now'),
|
||||
updated_by = ?,
|
||||
version = version + 1
|
||||
WHERE product_id = ? AND version = ?
|
||||
`),
|
||||
|
||||
insertInventoryHistory: this.db.prepare(`
|
||||
INSERT INTO inventory_history (product_id, old_level, new_level, change_reason, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`)
|
||||
};
|
||||
|
||||
logger.info('Optimized prepared statements created');
|
||||
return this.preparedStatements;
|
||||
} catch (error) {
|
||||
logger.error('Failed to prepare optimized statements', { error: error.message });
|
||||
throw new DatabaseError('Failed to prepare optimized statements', {
|
||||
originalError: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prepared statement by name
|
||||
*/
|
||||
getPreparedStatement(name) {
|
||||
if (!this.preparedStatements) {
|
||||
this.prepareOptimizedStatements();
|
||||
}
|
||||
|
||||
const statement = this.preparedStatements[name];
|
||||
if (!statement) {
|
||||
throw new DatabaseError(`Prepared statement '${name}' not found`);
|
||||
}
|
||||
|
||||
return statement;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new DatabaseManager();
|
||||
Reference in New Issue
Block a user