From a7bbab1354f4e39d0cb6ced7ddc55fb7579b7a77 Mon Sep 17 00:00:00 2001 From: Satyam Pandey Date: Mon, 9 Feb 2026 00:27:09 +0530 Subject: [PATCH] feat: Implement Enterprise Multi-Entity Inventory & Supply Chain Hub (#588) - Add Warehouse, StockItem, and BackOrder models - Implement comprehensive StockMath utility with FIFO/LIFO/WAC valuation - Support EOQ calculation, ABC classification, and safety stock optimization - Build InventoryService with stock movements, transfers, and reservations - Create ReplenishmentService with automated procurement request generation - Add back order management and fulfillment tracking - Implement multi-warehouse stock reconciliation - Build premium Inventory Hub Dashboard with Chart.js integration - Create interactive stock distribution and ABC classification visualizations - Support batch tracking, expiry management, and serial numbers - Add automated replenishment alerts based on reorder points --- models/BackOrder.js | 103 ++++++ models/StockItem.js | 201 +++++++++++ models/Warehouse.js | 82 +++++ public/expensetracker.css | 283 +++++++++++++++ public/inventory-hub.html | 274 +++++++++++++++ public/js/inventory-controller.js | 550 ++++++++++++++++++++++++++++++ routes/inventory.js | 375 ++++++++++++++++++++ server.js | 23 +- services/inventoryService.js | 418 +++++++++++++++++++++++ services/replenishmentService.js | 310 +++++++++++++++++ utils/stockMath.js | 276 +++++++++++++++ 11 files changed, 2884 insertions(+), 11 deletions(-) create mode 100644 models/BackOrder.js create mode 100644 models/StockItem.js create mode 100644 models/Warehouse.js create mode 100644 public/inventory-hub.html create mode 100644 public/js/inventory-controller.js create mode 100644 routes/inventory.js create mode 100644 services/inventoryService.js create mode 100644 services/replenishmentService.js create mode 100644 utils/stockMath.js diff --git a/models/BackOrder.js b/models/BackOrder.js new file mode 100644 index 00000000..dbff7441 --- /dev/null +++ b/models/BackOrder.js @@ -0,0 +1,103 @@ +const mongoose = require('mongoose'); + +/** + * BackOrder Model + * Manages stock-outs and pending orders when inventory is insufficient + */ +const backOrderSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + backOrderId: { + type: String, + unique: true, + required: true + }, + stockItemId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'StockItem', + required: true, + index: true + }, + sku: { + type: String, + required: true + }, + itemName: String, + requestedQuantity: { + type: Number, + required: true + }, + fulfilledQuantity: { + type: Number, + default: 0 + }, + pendingQuantity: { + type: Number, + required: true + }, + warehouseId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Warehouse', + required: true + }, + requestedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + requestDate: { + type: Date, + default: Date.now + }, + expectedFulfillmentDate: Date, + priority: { + type: String, + enum: ['low', 'medium', 'high', 'urgent'], + default: 'medium' + }, + status: { + type: String, + enum: ['pending', 'partially_fulfilled', 'fulfilled', 'cancelled'], + default: 'pending' + }, + linkedProcurementOrder: { + type: mongoose.Schema.Types.ObjectId, + ref: 'ProcurementOrder' + }, + fulfillmentHistory: [{ + quantity: Number, + fulfilledDate: Date, + batchNumber: String, + notes: String + }], + cancellationReason: String, + notes: String +}, { + timestamps: true +}); + +// Pre-save hook to update pending quantity +backOrderSchema.pre('save', function (next) { + this.pendingQuantity = this.requestedQuantity - this.fulfilledQuantity; + + // Update status based on fulfillment + if (this.fulfilledQuantity === 0) { + this.status = 'pending'; + } else if (this.fulfilledQuantity < this.requestedQuantity) { + this.status = 'partially_fulfilled'; + } else if (this.fulfilledQuantity >= this.requestedQuantity) { + this.status = 'fulfilled'; + } + + next(); +}); + +// Indexes +backOrderSchema.index({ userId: 1, status: 1 }); +backOrderSchema.index({ stockItemId: 1, status: 1 }); +backOrderSchema.index({ priority: 1, requestDate: 1 }); + +module.exports = mongoose.model('BackOrder', backOrderSchema); diff --git a/models/StockItem.js b/models/StockItem.js new file mode 100644 index 00000000..7ada02f1 --- /dev/null +++ b/models/StockItem.js @@ -0,0 +1,201 @@ +const mongoose = require('mongoose'); + +/** + * StockItem Model + * Manages individual stock items with SKU tracking, batch numbers, and expiry dates + */ +const stockMovementSchema = new mongoose.Schema({ + movementType: { + type: String, + enum: ['in', 'out', 'transfer', 'adjustment', 'return'], + required: true + }, + quantity: { + type: Number, + required: true + }, + fromWarehouse: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Warehouse' + }, + toWarehouse: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Warehouse' + }, + reference: { + type: String + }, + referenceType: { + type: String, + enum: ['purchase_order', 'sales_order', 'transfer', 'adjustment', 'return'] + }, + movementDate: { + type: Date, + default: Date.now + }, + performedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + notes: String +}, { _id: false }); + +const stockItemSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + sku: { + type: String, + required: true, + unique: true, + uppercase: true + }, + itemName: { + type: String, + required: true + }, + description: String, + category: { + type: String, + required: true + }, + subcategory: String, + warehouseId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Warehouse', + required: true, + index: true + }, + batchNumber: String, + serialNumber: String, + quantity: { + current: { + type: Number, + required: true, + default: 0 + }, + reserved: { + type: Number, + default: 0 + }, + available: { + type: Number, + default: 0 + }, + unit: { + type: String, + required: true, + default: 'units' + } + }, + reorderPoint: { + type: Number, + default: 10 + }, + safetyStock: { + type: Number, + default: 5 + }, + maxStockLevel: { + type: Number, + default: 1000 + }, + pricing: { + costPrice: { + type: Number, + default: 0 + }, + sellingPrice: { + type: Number, + default: 0 + }, + currency: { + type: String, + default: 'INR' + } + }, + valuation: { + method: { + type: String, + enum: ['FIFO', 'LIFO', 'WAC', 'specific'], + default: 'FIFO' + }, + totalValue: { + type: Number, + default: 0 + } + }, + expiryTracking: { + isPerishable: { + type: Boolean, + default: false + }, + expiryDate: Date, + manufacturingDate: Date, + shelfLife: { + value: Number, + unit: { + type: String, + enum: ['days', 'months', 'years'] + } + } + }, + dimensions: { + length: Number, + width: Number, + height: Number, + weight: Number, + unit: String + }, + supplier: { + supplierId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Vendor' + }, + supplierName: String, + leadTime: Number + }, + stockStatus: { + type: String, + enum: ['in_stock', 'low_stock', 'out_of_stock', 'discontinued'], + default: 'in_stock' + }, + movements: [stockMovementSchema], + lastRestocked: Date, + isActive: { + type: Boolean, + default: true + } +}, { + timestamps: true +}); + +// Pre-save hook to calculate available quantity and stock status +stockItemSchema.pre('save', function (next) { + this.quantity.available = this.quantity.current - this.quantity.reserved; + + // Update stock status + if (this.quantity.current === 0) { + this.stockStatus = 'out_of_stock'; + } else if (this.quantity.current <= this.reorderPoint) { + this.stockStatus = 'low_stock'; + } else { + this.stockStatus = 'in_stock'; + } + + // Update total value + this.valuation.totalValue = this.quantity.current * this.pricing.costPrice; + + next(); +}); + +// Indexes +stockItemSchema.index({ userId: 1, warehouseId: 1 }); +stockItemSchema.index({ sku: 1 }); +stockItemSchema.index({ category: 1, stockStatus: 1 }); +stockItemSchema.index({ 'quantity.current': 1, reorderPoint: 1 }); + +module.exports = mongoose.model('StockItem', stockItemSchema); diff --git a/models/Warehouse.js b/models/Warehouse.js new file mode 100644 index 00000000..b3cc81a4 --- /dev/null +++ b/models/Warehouse.js @@ -0,0 +1,82 @@ +const mongoose = require('mongoose'); + +/** + * Warehouse Model + * Manages multiple warehouse/storage locations for inventory + */ +const warehouseSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + warehouseCode: { + type: String, + required: true, + unique: true, + uppercase: true + }, + warehouseName: { + type: String, + required: true + }, + location: { + address: String, + city: String, + state: String, + country: String, + zipCode: String, + coordinates: { + latitude: Number, + longitude: Number + } + }, + warehouseType: { + type: String, + enum: ['main', 'regional', 'distribution', 'retail', 'virtual'], + default: 'main' + }, + capacity: { + totalSpace: { + type: Number, + default: 0 + }, + usedSpace: { + type: Number, + default: 0 + }, + unit: { + type: String, + enum: ['sqft', 'sqm', 'cubic_ft', 'cubic_m'], + default: 'sqft' + } + }, + manager: { + name: String, + email: String, + phone: String + }, + operatingHours: { + openTime: String, + closeTime: String, + workingDays: [String] + }, + status: { + type: String, + enum: ['active', 'inactive', 'maintenance', 'closed'], + default: 'active' + }, + isActive: { + type: Boolean, + default: true + } +}, { + timestamps: true +}); + +// Indexes +warehouseSchema.index({ userId: 1, status: 1 }); +warehouseSchema.index({ warehouseCode: 1 }); + +module.exports = mongoose.model('Warehouse', warehouseSchema); diff --git a/public/expensetracker.css b/public/expensetracker.css index f7307077..dab507ef 100644 --- a/public/expensetracker.css +++ b/public/expensetracker.css @@ -9631,3 +9631,286 @@ input:checked + .toggle-slider::before { } .checkbox-container input { width: 16px; height: 16px; } + +/* ============================================ + INVENTORY & SUPPLY CHAIN HUB + Issue #588: Multi-Entity Inventory System + ============================================ */ + +.inventory-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; +} + +.inventory-grid { + display: grid; + grid-template-columns: 1fr 400px; + gap: 25px; + margin-bottom: 25px; +} + +.right-panel { + display: flex; + flex-direction: column; + gap: 20px; +} + +.filter-controls { + display: flex; + gap: 10px; +} + +.filter-select { + padding: 6px 12px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + color: white; + font-size: 0.8rem; +} + +.stock-items-list, .warehouses-list, .alerts-list, .backorders-list { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 600px; + overflow-y: auto; +} + +.stock-item-card, .warehouse-card, .backorder-card { + padding: 15px; + cursor: pointer; + transition: transform 0.2s; +} + +.stock-item-card:hover { + transform: translateY(-2px); +} + +.item-header, .wh-header, .bo-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.item-info, .wh-info, .bo-info { + flex: 1; +} + +.item-info strong, .wh-info strong, .bo-info strong { + display: block; + font-size: 0.95rem; +} + +.sku-badge { + font-size: 0.7rem; + color: var(--text-secondary); + font-family: monospace; + display: block; + margin-top: 2px; +} + +.item-details, .bo-details { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 10px; + border-top: 1px solid rgba(255,255,255,0.05); +} + +.detail-row, .detail-item { + display: flex; + justify-content: space-between; + font-size: 0.85rem; +} + +.detail-row label, .detail-item label { + color: var(--text-secondary); +} + +.quantity-badge { + padding: 2px 8px; + background: rgba(100, 255, 218, 0.15); + color: #64ffda; + border-radius: 4px; + font-size: 0.75rem; +} + +.reorder-alert { + margin-top: 10px; + padding: 8px; + background: rgba(255, 159, 67, 0.1); + border-left: 3px solid #ff9f43; + font-size: 0.75rem; + color: #ff9f43; + display: flex; + align-items: center; + gap: 8px; +} + +.wh-icon { + width: 40px; + height: 40px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; +} + +.wh-icon.main { background: rgba(100, 255, 218, 0.15); color: #64ffda; } +.wh-icon.regional { background: rgba(72, 219, 251, 0.15); color: #48dbfb; } +.wh-icon.distribution { background: rgba(255, 159, 67, 0.15); color: #ff9f43; } +.wh-icon.retail { background: rgba(255, 107, 107, 0.15); color: #ff6b6b; } + +.wh-location { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.75rem; + color: var(--text-secondary); + margin-top: 8px; +} + +.alert-card { + padding: 12px; + background: rgba(255,255,255,0.02); + border-radius: 8px; + border-left: 4px solid; + display: flex; + gap: 12px; +} + +.alert-card.urgent { border-left-color: #ff6b6b; } +.alert-card.high { border-left-color: #ff9f43; } +.alert-card.medium { border-left-color: #48dbfb; } +.alert-card.low { border-left-color: #8892b0; } + +.alert-icon { + font-size: 1.2rem; +} + +.alert-card.urgent .alert-icon { color: #ff6b6b; } +.alert-card.high .alert-icon { color: #ff9f43; } +.alert-card.medium .alert-icon { color: #48dbfb; } + +.alert-content { + flex: 1; +} + +.alert-content strong { + display: block; + margin-bottom: 4px; +} + +.alert-content p { + font-size: 0.8rem; + margin: 0 0 4px 0; +} + +.alert-reason { + font-size: 0.7rem; + color: var(--text-secondary); +} + +.priority-badge { + font-size: 0.65rem; + padding: 3px 10px; + border-radius: 12px; + text-transform: uppercase; +} + +.priority-badge.urgent { background: rgba(255, 107, 107, 0.2); color: #ff6b6b; } +.priority-badge.high { background: rgba(255, 159, 67, 0.2); color: #ff9f43; } +.priority-badge.medium { background: rgba(72, 219, 251, 0.2); color: #48dbfb; } +.priority-badge.low { background: rgba(136, 146, 176, 0.2); color: #8892b0; } + +.pending-qty { + color: var(--accent-primary); + font-weight: bold; +} + +.badge { + padding: 2px 8px; + background: rgba(255, 107, 107, 0.2); + color: #ff6b6b; + border-radius: 12px; + font-size: 0.75rem; +} + +.glass-card-sm { + background: rgba(255, 255, 255, 0.02); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 12px; +} + +.stock-details { + padding: 20px 0; +} + +.details-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-bottom: 25px; +} + +.detail-section { + padding: 15px; + background: rgba(255,255,255,0.02); + border-radius: 8px; +} + +.detail-section h4 { + margin-bottom: 15px; + color: var(--accent-primary); + font-size: 0.9rem; +} + +.movements-section { + padding: 15px; + background: rgba(255,255,255,0.02); + border-radius: 8px; +} + +.movements-section h4 { + margin-bottom: 15px; + color: var(--accent-primary); +} + +.movements-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.movement-item { + display: flex; + justify-content: space-between; + padding: 8px; + background: rgba(255,255,255,0.02); + border-radius: 4px; + font-size: 0.8rem; +} + +.movement-type { + padding: 2px 8px; + border-radius: 4px; + font-size: 0.7rem; + text-transform: uppercase; +} + +.movement-type.in { background: rgba(100, 255, 218, 0.2); color: #64ffda; } +.movement-type.out { background: rgba(255, 107, 107, 0.2); color: #ff6b6b; } +.movement-type.transfer { background: rgba(72, 219, 251, 0.2); color: #48dbfb; } +.movement-type.adjustment { background: rgba(255, 159, 67, 0.2); color: #ff9f43; } + +.btn-accent { + background: linear-gradient(135deg, #ff9f43, #ff6b6b); + color: white; +} diff --git a/public/inventory-hub.html b/public/inventory-hub.html new file mode 100644 index 00000000..5ac1c57a --- /dev/null +++ b/public/inventory-hub.html @@ -0,0 +1,274 @@ + + + + + + + Inventory & Supply Chain Hub - ExpenseFlow + + + + + + + + +
+ +
+
+

Multi-Entity Inventory Command Center

+

Decentralized stock management across warehouses with automated replenishment

+
+
+ + + +
+
+ + +
+
+
+ +
+
+ +

0

+
+
+
+
+ +
+
+ +

0

+
+
+
+
+ +
+
+ +

₹0

+
+
+
+
+ +
+
+ +

0

+
+
+
+ + +
+ +
+
+

Stock Items

+
+ + +
+
+
+
Loading stock items...
+
+
+ + +
+
+
+

Warehouses

+
+
+ +
+
+ +
+
+

Replenishment Alerts

+
+
+ +
+
+
+
+ + +
+
+

Back Orders

+ 0 +
+
+ +
+
+ + +
+
+
+

Stock Distribution

+
+
+ +
+
+ +
+
+

ABC Classification

+
+
+ +
+
+
+
+ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/js/inventory-controller.js b/public/js/inventory-controller.js new file mode 100644 index 00000000..2effa275 --- /dev/null +++ b/public/js/inventory-controller.js @@ -0,0 +1,550 @@ +/** + * Inventory Hub Controller + * Handles all inventory management UI logic + */ + +let stockDistChart = null; +let abcChart = null; +let currentStockItems = []; +let currentWarehouses = []; + +document.addEventListener('DOMContentLoaded', () => { + loadInventoryDashboard(); + loadWarehouses(); + loadStockItems(); + loadBackOrders(); + loadReplenishmentAlerts(); + setupForms(); +}); + +async function loadInventoryDashboard() { + try { + const res = await fetch('/api/inventory/dashboard', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + updateDashboardStats(data); + loadABCClassification(); + } catch (err) { + console.error('Failed to load inventory dashboard:', err); + } +} + +function updateDashboardStats(data) { + document.getElementById('total-warehouses').textContent = data.summary.totalWarehouses; + document.getElementById('total-items').textContent = data.summary.totalStockItems; + document.getElementById('inventory-value').textContent = `₹${data.summary.totalInventoryValue.toLocaleString()}`; + document.getElementById('low-stock-count').textContent = data.summary.stockStatusCounts.low_stock + data.summary.stockStatusCounts.out_of_stock; +} + +async function loadWarehouses() { + try { + const res = await fetch('/api/inventory/warehouses', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + currentWarehouses = data; + renderWarehouses(data); + populateWarehouseDropdowns(data); + } catch (err) { + console.error('Failed to load warehouses:', err); + } +} + +function renderWarehouses(warehouses) { + const list = document.getElementById('warehouses-list'); + + if (!warehouses || warehouses.length === 0) { + list.innerHTML = '
No warehouses created yet.
'; + return; + } + + list.innerHTML = warehouses.map(wh => ` +
+
+
+ +
+
+ ${wh.warehouseName} + ${wh.warehouseCode} +
+ ${wh.status} +
+
+ + ${wh.location?.city || 'N/A'} +
+
+ `).join(''); +} + +function populateWarehouseDropdowns(warehouses) { + const selects = ['stock-warehouse', 'warehouse-filter']; + + selects.forEach(selectId => { + const select = document.getElementById(selectId); + if (select) { + const options = warehouses.map(wh => + `` + ).join(''); + + if (selectId === 'warehouse-filter') { + select.innerHTML = '' + options; + } else { + select.innerHTML = options; + } + } + }); +} + +async function loadStockItems() { + try { + const res = await fetch('/api/inventory/stock', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + currentStockItems = data; + renderStockItems(data); + renderStockDistribution(data); + } catch (err) { + console.error('Failed to load stock items:', err); + } +} + +function renderStockItems(items) { + const list = document.getElementById('stock-items-list'); + + if (!items || items.length === 0) { + list.innerHTML = '
No stock items found.
'; + return; + } + + list.innerHTML = items.map(item => ` +
+
+
+ ${item.itemName} + ${item.sku} +
+ ${item.stockStatus.replace('_', ' ')} +
+
+
+ + ${item.warehouseId?.warehouseName || 'N/A'} +
+
+ + ${item.quantity.available} ${item.quantity.unit} +
+
+ + ₹${item.valuation.totalValue.toLocaleString()} +
+
+ ${item.quantity.current <= item.reorderPoint ? ` +
+ + Reorder needed: ${item.reorderPoint - item.quantity.current} units +
+ ` : ''} +
+ `).join(''); +} + +function renderStockDistribution(items) { + const ctx = document.getElementById('stockDistChart').getContext('2d'); + + if (stockDistChart) { + stockDistChart.destroy(); + } + + const statusCounts = { + in_stock: items.filter(i => i.stockStatus === 'in_stock').length, + low_stock: items.filter(i => i.stockStatus === 'low_stock').length, + out_of_stock: items.filter(i => i.stockStatus === 'out_of_stock').length + }; + + stockDistChart = new Chart(ctx, { + type: 'doughnut', + data: { + labels: ['In Stock', 'Low Stock', 'Out of Stock'], + datasets: [{ + data: [statusCounts.in_stock, statusCounts.low_stock, statusCounts.out_of_stock], + backgroundColor: ['#64ffda', '#ff9f43', '#ff6b6b'], + borderWidth: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { color: '#8892b0', font: { size: 10 } } + } + } + } + }); +} + +async function loadABCClassification() { + try { + const res = await fetch('/api/inventory/reports/abc-classification', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + renderABCChart(data); + } catch (err) { + console.error('Failed to load ABC classification:', err); + } +} + +function renderABCChart(data) { + const ctx = document.getElementById('abcChart').getContext('2d'); + + if (abcChart) { + abcChart.destroy(); + } + + abcChart = new Chart(ctx, { + type: 'bar', + data: { + labels: ['Class A', 'Class B', 'Class C'], + datasets: [{ + label: 'Item Count', + data: [ + data.summary.classACount, + data.summary.classBCount, + data.summary.classCCount + ], + backgroundColor: ['#64ffda', '#48dbfb', '#ff9f43'], + borderWidth: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false } + }, + scales: { + x: { + ticks: { color: '#8892b0' }, + grid: { color: 'rgba(255,255,255,0.05)' } + }, + y: { + ticks: { color: '#8892b0' }, + grid: { color: 'rgba(255,255,255,0.05)' } + } + } + } + }); +} + +async function loadBackOrders() { + try { + const res = await fetch('/api/inventory/backorders', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + renderBackOrders(data); + } catch (err) { + console.error('Failed to load back orders:', err); + } +} + +function renderBackOrders(backOrders) { + const list = document.getElementById('backorders-list'); + const count = document.getElementById('backorders-count'); + + count.textContent = backOrders.length; + + if (!backOrders || backOrders.length === 0) { + list.innerHTML = '
No pending back orders.
'; + return; + } + + list.innerHTML = backOrders.map(bo => ` +
+
+
+ ${bo.itemName} + ${bo.sku} +
+ ${bo.priority} +
+
+
+ + ${bo.requestedQuantity} +
+
+ + ${bo.fulfilledQuantity} +
+
+ + ${bo.pendingQuantity} +
+
+
+ `).join(''); +} + +async function loadReplenishmentAlerts() { + try { + const res = await fetch('/api/inventory/replenishment/recommendations', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + renderReplenishmentAlerts(data.recommendations); + } catch (err) { + console.error('Failed to load replenishment alerts:', err); + } +} + +function renderReplenishmentAlerts(alerts) { + const list = document.getElementById('replenishment-alerts'); + + if (!alerts || alerts.length === 0) { + list.innerHTML = '
No replenishment needed.
'; + return; + } + + // Show top 5 alerts + const topAlerts = alerts.slice(0, 5); + + list.innerHTML = topAlerts.map(alert => ` +
+
+ +
+
+ ${alert.itemName} +

Order ${alert.suggestedOrderQty} units

+ ${alert.reason} +
+
+ `).join(''); +} + +async function viewStockDetails(sku) { + try { + const res = await fetch(`/api/inventory/stock/${sku}`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + const modal = document.getElementById('stock-details-modal'); + const content = document.getElementById('stock-details-content'); + + content.innerHTML = ` +
+
+
+

Basic Information

+
+ + ${data.sku} +
+
+ + ${data.itemName} +
+
+ + ${data.category} +
+
+ + ${data.warehouseId.warehouseName} +
+
+
+

Stock Levels

+
+ + ${data.quantity.current} ${data.quantity.unit} +
+
+ + ${data.quantity.reserved} ${data.quantity.unit} +
+
+ + ${data.quantity.available} ${data.quantity.unit} +
+
+ + ${data.reorderPoint} +
+
+
+

Pricing & Valuation

+
+ + ₹${data.pricing.costPrice} +
+
+ + ₹${data.pricing.sellingPrice} +
+
+ + ₹${data.valuation.totalValue.toLocaleString()} +
+
+
+
+

Recent Movements

+
+ ${data.movements.slice(-5).reverse().map(m => ` +
+ ${m.movementType} + ${m.quantity} units + ${new Date(m.movementDate).toLocaleDateString()} +
+ `).join('')} +
+
+
+ `; + + modal.classList.remove('hidden'); + } catch (err) { + console.error('Failed to load stock details:', err); + } +} + +async function generateReplenishment() { + if (!confirm('Generate automated procurement requests for low stock items?')) return; + + try { + const res = await fetch('/api/inventory/replenishment/auto-generate', { + method: 'POST', + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + alert(`Generated ${data.generated} procurement requests. Total value: ₹${data.totalValue.toLocaleString()}`); + loadReplenishmentAlerts(); + } catch (err) { + console.error('Failed to generate replenishment:', err); + } +} + +function filterStockItems() { + const warehouseFilter = document.getElementById('warehouse-filter').value; + const statusFilter = document.getElementById('status-filter').value; + + let filtered = currentStockItems; + + if (warehouseFilter) { + filtered = filtered.filter(item => item.warehouseId._id === warehouseFilter); + } + + if (statusFilter) { + filtered = filtered.filter(item => item.stockStatus === statusFilter); + } + + renderStockItems(filtered); +} + +// Modal Functions +function openAddStockModal() { + document.getElementById('add-stock-modal').classList.remove('hidden'); +} + +function closeAddStockModal() { + document.getElementById('add-stock-modal').classList.add('hidden'); +} + +function openWarehouseModal() { + document.getElementById('warehouse-modal').classList.remove('hidden'); +} + +function closeWarehouseModal() { + document.getElementById('warehouse-modal').classList.add('hidden'); +} + +function closeStockDetailsModal() { + document.getElementById('stock-details-modal').classList.add('hidden'); +} + +function setupForms() { + // Add Stock Form + document.getElementById('add-stock-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const stockData = { + sku: document.getElementById('stock-sku').value.toUpperCase(), + itemName: document.getElementById('stock-name').value, + category: document.getElementById('stock-category').value, + warehouseId: document.getElementById('stock-warehouse').value, + quantity: parseInt(document.getElementById('stock-quantity').value), + costPrice: parseFloat(document.getElementById('stock-cost').value), + reorderPoint: parseInt(document.getElementById('stock-reorder').value), + safetyStock: parseInt(document.getElementById('stock-safety').value) + }; + + try { + const res = await fetch('/api/inventory/stock/add', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify(stockData) + }); + + if (res.ok) { + closeAddStockModal(); + loadStockItems(); + loadInventoryDashboard(); + } + } catch (err) { + console.error('Failed to add stock:', err); + } + }); + + // Warehouse Form + document.getElementById('warehouse-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const warehouseData = { + warehouseCode: document.getElementById('wh-code').value.toUpperCase(), + warehouseName: document.getElementById('wh-name').value, + warehouseType: document.getElementById('wh-type').value, + location: { + city: document.getElementById('wh-city').value + } + }; + + try { + const res = await fetch('/api/inventory/warehouses', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify(warehouseData) + }); + + if (res.ok) { + closeWarehouseModal(); + loadWarehouses(); + loadInventoryDashboard(); + } + } catch (err) { + console.error('Failed to create warehouse:', err); + } + }); +} diff --git a/routes/inventory.js b/routes/inventory.js new file mode 100644 index 00000000..a140fc12 --- /dev/null +++ b/routes/inventory.js @@ -0,0 +1,375 @@ +const express = require('express'); +const router = express.Router(); +const auth = require('../middleware/auth'); +const inventoryService = require('../services/inventoryService'); +const replenishmentService = require('../services/replenishmentService'); +const Warehouse = require('../models/Warehouse'); +const StockItem = require('../models/StockItem'); +const BackOrder = require('../models/BackOrder'); + +/** + * Get Inventory Dashboard + */ +router.get('/dashboard', auth, async (req, res) => { + try { + const dashboard = await inventoryService.getInventoryDashboard(req.user._id); + res.json({ success: true, data: dashboard }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +// ==================== WAREHOUSE ROUTES ==================== + +/** + * Create Warehouse + */ +router.post('/warehouses', auth, async (req, res) => { + try { + const warehouse = new Warehouse({ + ...req.body, + userId: req.user._id + }); + await warehouse.save(); + res.json({ success: true, data: warehouse }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Get All Warehouses + */ +router.get('/warehouses', auth, async (req, res) => { + try { + const warehouses = await Warehouse.find({ userId: req.user._id }); + res.json({ success: true, data: warehouses }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Update Warehouse + */ +router.patch('/warehouses/:id', auth, async (req, res) => { + try { + const warehouse = await Warehouse.findOneAndUpdate( + { _id: req.params.id, userId: req.user._id }, + req.body, + { new: true, runValidators: true } + ); + + if (!warehouse) { + return res.status(404).json({ success: false, error: 'Warehouse not found' }); + } + + res.json({ success: true, data: warehouse }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +// ==================== STOCK ITEM ROUTES ==================== + +/** + * Add Stock + */ +router.post('/stock/add', auth, async (req, res) => { + try { + const stockItem = await inventoryService.addStock(req.user._id, req.body); + res.json({ success: true, data: stockItem }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Remove Stock + */ +router.post('/stock/remove', auth, async (req, res) => { + try { + const { sku, warehouseId, quantity, reference, notes } = req.body; + const stockItem = await inventoryService.removeStock( + req.user._id, + sku, + warehouseId, + quantity, + reference, + notes + ); + res.json({ success: true, data: stockItem }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Transfer Stock Between Warehouses + */ +router.post('/stock/transfer', auth, async (req, res) => { + try { + const { sku, fromWarehouseId, toWarehouseId, quantity, notes } = req.body; + const result = await inventoryService.transferStock( + req.user._id, + sku, + fromWarehouseId, + toWarehouseId, + quantity, + notes + ); + res.json({ success: true, data: result }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Adjust Stock + */ +router.post('/stock/adjust', auth, async (req, res) => { + try { + const { sku, warehouseId, adjustmentQuantity, reason, notes } = req.body; + const stockItem = await inventoryService.adjustStock( + req.user._id, + sku, + warehouseId, + adjustmentQuantity, + reason, + notes + ); + res.json({ success: true, data: stockItem }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Reserve Stock + */ +router.post('/stock/reserve', auth, async (req, res) => { + try { + const { sku, warehouseId, quantity, reference } = req.body; + const stockItem = await inventoryService.reserveStock( + req.user._id, + sku, + warehouseId, + quantity, + reference + ); + res.json({ success: true, data: stockItem }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Release Reserved Stock + */ +router.post('/stock/release', auth, async (req, res) => { + try { + const { sku, warehouseId, quantity } = req.body; + const stockItem = await inventoryService.releaseReservedStock( + req.user._id, + sku, + warehouseId, + quantity + ); + res.json({ success: true, data: stockItem }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Get All Stock Items + */ +router.get('/stock', auth, async (req, res) => { + try { + const { warehouseId, category, status } = req.query; + const query = { userId: req.user._id, isActive: true }; + + if (warehouseId) query.warehouseId = warehouseId; + if (category) query.category = category; + if (status) query.stockStatus = status; + + const stockItems = await StockItem.find(query).populate('warehouseId'); + res.json({ success: true, data: stockItems }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Stock Item by SKU + */ +router.get('/stock/:sku', auth, async (req, res) => { + try { + const stockItem = await StockItem.findOne({ + userId: req.user._id, + sku: req.params.sku + }).populate('warehouseId'); + + if (!stockItem) { + return res.status(404).json({ success: false, error: 'Stock item not found' }); + } + + res.json({ success: true, data: stockItem }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Update Stock Item + */ +router.patch('/stock/:sku', auth, async (req, res) => { + try { + const stockItem = await StockItem.findOneAndUpdate( + { userId: req.user._id, sku: req.params.sku }, + req.body, + { new: true, runValidators: true } + ); + + if (!stockItem) { + return res.status(404).json({ success: false, error: 'Stock item not found' }); + } + + res.json({ success: true, data: stockItem }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +// ==================== BACK ORDER ROUTES ==================== + +/** + * Get All Back Orders + */ +router.get('/backorders', auth, async (req, res) => { + try { + const { status, priority } = req.query; + const query = { userId: req.user._id }; + + if (status) query.status = status; + if (priority) query.priority = priority; + + const backOrders = await BackOrder.find(query) + .populate('stockItemId') + .populate('warehouseId') + .sort({ priority: -1, requestDate: 1 }); + + res.json({ success: true, data: backOrders }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Update Back Order + */ +router.patch('/backorders/:id', auth, async (req, res) => { + try { + const backOrder = await BackOrder.findOneAndUpdate( + { _id: req.params.id, userId: req.user._id }, + req.body, + { new: true, runValidators: true } + ); + + if (!backOrder) { + return res.status(404).json({ success: false, error: 'Back order not found' }); + } + + res.json({ success: true, data: backOrder }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +// ==================== REPLENISHMENT ROUTES ==================== + +/** + * Get Replenishment Recommendations + */ +router.get('/replenishment/recommendations', auth, async (req, res) => { + try { + const recommendations = await replenishmentService.scanAndRecommend(req.user._id); + res.json({ success: true, data: recommendations }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Auto-Generate Procurement Requests + */ +router.post('/replenishment/auto-generate', auth, async (req, res) => { + try { + const result = await replenishmentService.autoGenerateProcurementRequests(req.user._id); + res.json({ success: true, data: result }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Replenishment Analytics + */ +router.get('/replenishment/analytics', auth, async (req, res) => { + try { + const analytics = await replenishmentService.getReplenishmentAnalytics(req.user._id); + res.json({ success: true, data: analytics }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Calculate Optimal Parameters for Item + */ +router.get('/replenishment/optimize/:sku', auth, async (req, res) => { + try { + const stockItem = await StockItem.findOne({ + userId: req.user._id, + sku: req.params.sku + }); + + if (!stockItem) { + return res.status(404).json({ success: false, error: 'Stock item not found' }); + } + + const optimization = await replenishmentService.calculateOptimalParameters(stockItem); + res.json({ success: true, data: optimization }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +// ==================== REPORTING ROUTES ==================== + +/** + * Get Stock Valuation Report + */ +router.get('/reports/valuation', auth, async (req, res) => { + try { + const method = req.query.method || 'FIFO'; + const report = await inventoryService.getStockValuationReport(req.user._id, method); + res.json({ success: true, data: report }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get ABC Classification + */ +router.get('/reports/abc-classification', auth, async (req, res) => { + try { + const classification = await inventoryService.getABCClassification(req.user._id); + res.json({ success: true, data: classification }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index 620fcffb..828b2bfa 100644 --- a/server.js +++ b/server.js @@ -43,8 +43,8 @@ const server = http.createServer(app); const io = socketIo(server, { cors: { origin: [process.env.FRONTEND_URL || - "http://localhost:3000", - 'https://accounts.clerk.dev', + "http://localhost:3000", + 'https://accounts.clerk.dev', 'https://*.clerk.accounts.dev' ], methods: ["GET", "POST"], @@ -63,10 +63,10 @@ app.use(helmet({ directives: { defaultSrc: ["'self'"], styleSrc: [ - "'self'", - "'unsafe-inline'", - "https://cdnjs.cloudflare.com", - "https://fonts.googleapis.com", + "'self'", + "'unsafe-inline'", + "https://cdnjs.cloudflare.com", + "https://fonts.googleapis.com", "https://api.github.com" ], scriptSrc: [ @@ -83,10 +83,10 @@ app.use(helmet({ scriptSrcAttr: ["'unsafe-inline'"], workerSrc: ["'self'", "blob:"], imgSrc: [ - "'self'", - "data:", - "https:", - "https://res.cloudinary.com", + "'self'", + "data:", + "https:", + "https://res.cloudinary.com", "https://api.github.com", "https://img.clerk.com" // For Clerk user avatars ], @@ -277,7 +277,8 @@ console.log('Collaboration handler initialized'); app.use('/api/auth', require('./middleware/rateLimiter').authLimiter, authRoutes); app.use('/api/expenses', expenseRoutes); // Expense management app.use('/api/currency', require('./routes/currency')); -app.use('/api/groups', require('./routes/groups')); +app.use('/api/payroll', require('./routes/payroll')); +app.use('/api/inventory', require('./routes/inventory')); app.use('/api/splits', require('./routes/splits')); app.use('/api/workspaces', require('./routes/workspaces')); app.use('/api/tax', require('./routes/tax')); diff --git a/services/inventoryService.js b/services/inventoryService.js new file mode 100644 index 00000000..ff152099 --- /dev/null +++ b/services/inventoryService.js @@ -0,0 +1,418 @@ +const StockItem = require('../models/StockItem'); +const Warehouse = require('../models/Warehouse'); +const BackOrder = require('../models/BackOrder'); +const stockMath = require('../utils/stockMath'); + +class InventoryService { + /** + * Get comprehensive inventory dashboard + */ + async getInventoryDashboard(userId) { + const warehouses = await Warehouse.find({ userId, isActive: true }); + const stockItems = await StockItem.find({ userId, isActive: true }); + const backOrders = await BackOrder.find({ userId, status: { $in: ['pending', 'partially_fulfilled'] } }); + + // Calculate total inventory value + const totalInventoryValue = stockItems.reduce((sum, item) => sum + item.valuation.totalValue, 0); + + // Count items by status + const stockStatusCounts = { + in_stock: stockItems.filter(i => i.stockStatus === 'in_stock').length, + low_stock: stockItems.filter(i => i.stockStatus === 'low_stock').length, + out_of_stock: stockItems.filter(i => i.stockStatus === 'out_of_stock').length + }; + + // Get items needing reorder + const itemsNeedingReorder = stockItems.filter(i => + i.quantity.current <= i.reorderPoint && i.stockStatus !== 'discontinued' + ); + + // Calculate warehouse utilization + const warehouseUtilization = warehouses.map(wh => ({ + warehouseCode: wh.warehouseCode, + warehouseName: wh.warehouseName, + utilizationPercentage: wh.capacity.totalSpace > 0 + ? (wh.capacity.usedSpace / wh.capacity.totalSpace) * 100 + : 0, + itemCount: stockItems.filter(i => i.warehouseId.equals(wh._id)).length + })); + + return { + summary: { + totalWarehouses: warehouses.length, + totalStockItems: stockItems.length, + totalInventoryValue, + totalBackOrders: backOrders.length, + stockStatusCounts, + itemsNeedingReorder: itemsNeedingReorder.length + }, + warehouses: warehouseUtilization, + reorderAlerts: itemsNeedingReorder.map(item => ({ + sku: item.sku, + itemName: item.itemName, + currentStock: item.quantity.current, + reorderPoint: item.reorderPoint, + suggestedOrderQty: this.calculateSuggestedOrderQuantity(item) + })), + backOrdersSummary: this.summarizeBackOrders(backOrders) + }; + } + + /** + * Add stock to inventory + */ + async addStock(userId, stockData) { + const { sku, warehouseId, quantity, costPrice, batchNumber, expiryDate, reference } = stockData; + + let stockItem = await StockItem.findOne({ userId, sku, warehouseId }); + + if (!stockItem) { + // Create new stock item + stockItem = new StockItem({ + userId, + ...stockData, + quantity: { + current: quantity, + reserved: 0, + available: quantity, + unit: stockData.unit || 'units' + }, + pricing: { + costPrice, + sellingPrice: stockData.sellingPrice || costPrice * 1.2, + currency: stockData.currency || 'INR' + } + }); + } else { + // Update existing stock + stockItem.quantity.current += quantity; + stockItem.pricing.costPrice = costPrice; // Update to latest cost + } + + // Add movement record + stockItem.movements.push({ + movementType: 'in', + quantity, + toWarehouse: warehouseId, + reference, + referenceType: 'purchase_order', + movementDate: new Date(), + performedBy: userId + }); + + stockItem.lastRestocked = new Date(); + await stockItem.save(); + + // Check if this fulfills any back orders + await this.fulfillBackOrders(stockItem); + + return stockItem; + } + + /** + * Remove stock from inventory + */ + async removeStock(userId, sku, warehouseId, quantity, reference, notes) { + const stockItem = await StockItem.findOne({ userId, sku, warehouseId }); + + if (!stockItem) { + throw new Error('Stock item not found'); + } + + if (stockItem.quantity.available < quantity) { + // Create back order for insufficient stock + await this.createBackOrder(userId, stockItem, quantity - stockItem.quantity.available); + throw new Error(`Insufficient stock. Available: ${stockItem.quantity.available}, Requested: ${quantity}`); + } + + stockItem.quantity.current -= quantity; + + // Add movement record + stockItem.movements.push({ + movementType: 'out', + quantity, + fromWarehouse: warehouseId, + reference, + referenceType: 'sales_order', + movementDate: new Date(), + performedBy: userId, + notes + }); + + await stockItem.save(); + return stockItem; + } + + /** + * Transfer stock between warehouses + */ + async transferStock(userId, sku, fromWarehouseId, toWarehouseId, quantity, notes) { + // Remove from source warehouse + const sourceItem = await StockItem.findOne({ userId, sku, warehouseId: fromWarehouseId }); + + if (!sourceItem) { + throw new Error('Source stock item not found'); + } + + if (sourceItem.quantity.available < quantity) { + throw new Error('Insufficient stock for transfer'); + } + + sourceItem.quantity.current -= quantity; + sourceItem.movements.push({ + movementType: 'transfer', + quantity, + fromWarehouse: fromWarehouseId, + toWarehouse: toWarehouseId, + movementDate: new Date(), + performedBy: userId, + notes + }); + await sourceItem.save(); + + // Add to destination warehouse + let destItem = await StockItem.findOne({ userId, sku, warehouseId: toWarehouseId }); + + if (!destItem) { + // Create new stock item at destination + destItem = new StockItem({ + userId, + sku: sourceItem.sku, + itemName: sourceItem.itemName, + description: sourceItem.description, + category: sourceItem.category, + warehouseId: toWarehouseId, + quantity: { + current: quantity, + reserved: 0, + available: quantity, + unit: sourceItem.quantity.unit + }, + pricing: sourceItem.pricing, + reorderPoint: sourceItem.reorderPoint, + safetyStock: sourceItem.safetyStock + }); + } else { + destItem.quantity.current += quantity; + } + + destItem.movements.push({ + movementType: 'transfer', + quantity, + fromWarehouse: fromWarehouseId, + toWarehouse: toWarehouseId, + movementDate: new Date(), + performedBy: userId, + notes + }); + await destItem.save(); + + return { source: sourceItem, destination: destItem }; + } + + /** + * Adjust stock (for corrections, damages, etc.) + */ + async adjustStock(userId, sku, warehouseId, adjustmentQuantity, reason, notes) { + const stockItem = await StockItem.findOne({ userId, sku, warehouseId }); + + if (!stockItem) { + throw new Error('Stock item not found'); + } + + stockItem.quantity.current += adjustmentQuantity; // Can be negative + + stockItem.movements.push({ + movementType: 'adjustment', + quantity: Math.abs(adjustmentQuantity), + toWarehouse: adjustmentQuantity > 0 ? warehouseId : null, + fromWarehouse: adjustmentQuantity < 0 ? warehouseId : null, + reference: reason, + movementDate: new Date(), + performedBy: userId, + notes + }); + + await stockItem.save(); + return stockItem; + } + + /** + * Reserve stock for orders + */ + async reserveStock(userId, sku, warehouseId, quantity, reference) { + const stockItem = await StockItem.findOne({ userId, sku, warehouseId }); + + if (!stockItem) { + throw new Error('Stock item not found'); + } + + if (stockItem.quantity.available < quantity) { + throw new Error('Insufficient available stock for reservation'); + } + + stockItem.quantity.reserved += quantity; + await stockItem.save(); + + return stockItem; + } + + /** + * Release reserved stock + */ + async releaseReservedStock(userId, sku, warehouseId, quantity) { + const stockItem = await StockItem.findOne({ userId, sku, warehouseId }); + + if (!stockItem) { + throw new Error('Stock item not found'); + } + + stockItem.quantity.reserved = Math.max(0, stockItem.quantity.reserved - quantity); + await stockItem.save(); + + return stockItem; + } + + /** + * Create back order + */ + async createBackOrder(userId, stockItem, quantity) { + const backOrderId = `BO-${Date.now()}-${stockItem.sku}`; + + const backOrder = new BackOrder({ + userId, + backOrderId, + stockItemId: stockItem._id, + sku: stockItem.sku, + itemName: stockItem.itemName, + requestedQuantity: quantity, + pendingQuantity: quantity, + warehouseId: stockItem.warehouseId, + requestedBy: userId, + priority: quantity > 100 ? 'high' : 'medium' + }); + + await backOrder.save(); + return backOrder; + } + + /** + * Fulfill back orders when stock is added + */ + async fulfillBackOrders(stockItem) { + const backOrders = await BackOrder.find({ + stockItemId: stockItem._id, + status: { $in: ['pending', 'partially_fulfilled'] } + }).sort({ priority: -1, requestDate: 1 }); + + let availableStock = stockItem.quantity.available; + + for (const backOrder of backOrders) { + if (availableStock <= 0) break; + + const fulfillQty = Math.min(backOrder.pendingQuantity, availableStock); + + backOrder.fulfilledQuantity += fulfillQty; + backOrder.fulfillmentHistory.push({ + quantity: fulfillQty, + fulfilledDate: new Date(), + batchNumber: stockItem.batchNumber + }); + + await backOrder.save(); + availableStock -= fulfillQty; + } + } + + /** + * Calculate suggested order quantity + */ + calculateSuggestedOrderQuantity(stockItem) { + const shortfall = stockItem.reorderPoint - stockItem.quantity.current; + const safetyBuffer = stockItem.safetyStock; + + return Math.max(shortfall + safetyBuffer, stockItem.safetyStock * 2); + } + + /** + * Summarize back orders + */ + summarizeBackOrders(backOrders) { + return { + total: backOrders.length, + byPriority: { + urgent: backOrders.filter(bo => bo.priority === 'urgent').length, + high: backOrders.filter(bo => bo.priority === 'high').length, + medium: backOrders.filter(bo => bo.priority === 'medium').length, + low: backOrders.filter(bo => bo.priority === 'low').length + }, + totalPendingQuantity: backOrders.reduce((sum, bo) => sum + bo.pendingQuantity, 0) + }; + } + + /** + * Get stock valuation report + */ + async getStockValuationReport(userId, method = 'FIFO') { + const stockItems = await StockItem.find({ userId, isActive: true }); + + const valuationReport = stockItems.map(item => { + let valuation; + + // For simplicity, using current cost price + // In real implementation, track stock layers for accurate FIFO/LIFO + valuation = { + totalValue: item.quantity.current * item.pricing.costPrice, + averageCost: item.pricing.costPrice + }; + + return { + sku: item.sku, + itemName: item.itemName, + quantity: item.quantity.current, + costPrice: item.pricing.costPrice, + ...valuation, + warehouse: item.warehouseId + }; + }); + + const totalValue = valuationReport.reduce((sum, item) => sum + item.totalValue, 0); + + return { + method, + totalValue, + itemCount: valuationReport.length, + items: valuationReport + }; + } + + /** + * Get ABC classification of inventory + */ + async getABCClassification(userId) { + const stockItems = await StockItem.find({ userId, isActive: true }); + + const itemsForClassification = stockItems.map(item => ({ + sku: item.sku, + itemName: item.itemName, + quantity: item.quantity.current, + costPrice: item.pricing.costPrice + })); + + const classified = stockMath.calculateABCClassification(itemsForClassification); + + return { + classA: classified.filter(i => i.classification === 'A'), + classB: classified.filter(i => i.classification === 'B'), + classC: classified.filter(i => i.classification === 'C'), + summary: { + totalItems: classified.length, + classACount: classified.filter(i => i.classification === 'A').length, + classBCount: classified.filter(i => i.classification === 'B').length, + classCCount: classified.filter(i => i.classification === 'C').length + } + }; + } +} + +module.exports = new InventoryService(); diff --git a/services/replenishmentService.js b/services/replenishmentService.js new file mode 100644 index 00000000..fd9033f0 --- /dev/null +++ b/services/replenishmentService.js @@ -0,0 +1,310 @@ +const StockItem = require('../models/StockItem'); +const BackOrder = require('../models/BackOrder'); +const stockMath = require('../utils/stockMath'); + +class ReplenishmentService { + /** + * Scan inventory and generate replenishment recommendations + */ + async scanAndRecommend(userId) { + const stockItems = await StockItem.find({ + userId, + isActive: true, + stockStatus: { $in: ['low_stock', 'out_of_stock'] } + }).populate('warehouseId'); + + const recommendations = []; + + for (const item of stockItems) { + const recommendation = await this.generateRecommendation(item); + if (recommendation) { + recommendations.push(recommendation); + } + } + + // Sort by priority + recommendations.sort((a, b) => { + const priorityOrder = { urgent: 4, high: 3, medium: 2, low: 1 }; + return priorityOrder[b.priority] - priorityOrder[a.priority]; + }); + + return { + totalRecommendations: recommendations.length, + urgentCount: recommendations.filter(r => r.priority === 'urgent').length, + recommendations + }; + } + + /** + * Generate replenishment recommendation for a single item + */ + async generateRecommendation(stockItem) { + const currentStock = stockItem.quantity.current; + const reorderPoint = stockItem.reorderPoint; + const safetyStock = stockItem.safetyStock; + + // Check if replenishment is needed + if (currentStock > reorderPoint) { + return null; + } + + // Calculate suggested order quantity + let orderQuantity; + + if (stockItem.supplier && stockItem.supplier.leadTime) { + // Use EOQ if we have enough data + // For simplicity, using a basic calculation + const avgDailyUsage = this.estimateDailyUsage(stockItem); + const leadTime = stockItem.supplier.leadTime; + + orderQuantity = Math.round( + (avgDailyUsage * leadTime) + safetyStock - currentStock + ); + } else { + // Default to safety stock * 2 + orderQuantity = Math.max(safetyStock * 2, reorderPoint - currentStock); + } + + // Ensure minimum order quantity + orderQuantity = Math.max(orderQuantity, 1); + + // Determine priority + let priority; + if (currentStock === 0) { + priority = 'urgent'; + } else if (currentStock <= safetyStock) { + priority = 'high'; + } else if (currentStock <= reorderPoint) { + priority = 'medium'; + } else { + priority = 'low'; + } + + // Check for pending back orders + const backOrders = await BackOrder.find({ + stockItemId: stockItem._id, + status: { $in: ['pending', 'partially_fulfilled'] } + }); + + const totalBackOrderQty = backOrders.reduce((sum, bo) => sum + bo.pendingQuantity, 0); + + return { + sku: stockItem.sku, + itemName: stockItem.itemName, + warehouse: stockItem.warehouseId.warehouseName, + warehouseCode: stockItem.warehouseId.warehouseCode, + currentStock, + reorderPoint, + safetyStock, + suggestedOrderQty: orderQuantity + totalBackOrderQty, + estimatedCost: (orderQuantity + totalBackOrderQty) * stockItem.pricing.costPrice, + priority, + supplier: stockItem.supplier?.supplierName || 'Not specified', + leadTime: stockItem.supplier?.leadTime || 0, + backOrdersCount: backOrders.length, + backOrderQty: totalBackOrderQty, + reason: this.getReplenishmentReason(currentStock, reorderPoint, safetyStock, backOrders.length) + }; + } + + /** + * Auto-generate procurement requests for critical items + */ + async autoGenerateProcurementRequests(userId) { + const recommendations = await this.scanAndRecommend(userId); + + // Filter only urgent and high priority items + const criticalItems = recommendations.recommendations.filter(r => + r.priority === 'urgent' || r.priority === 'high' + ); + + const procurementRequests = []; + + for (const item of criticalItems) { + // In a real implementation, this would create actual ProcurementOrder records + const pr = { + prNumber: `PR-${Date.now()}-${item.sku}`, + sku: item.sku, + itemName: item.itemName, + quantity: item.suggestedOrderQty, + estimatedCost: item.estimatedCost, + supplier: item.supplier, + priority: item.priority, + warehouse: item.warehouse, + requestedDate: new Date(), + reason: item.reason, + autoGenerated: true + }; + + procurementRequests.push(pr); + } + + return { + generated: procurementRequests.length, + totalValue: procurementRequests.reduce((sum, pr) => sum + pr.estimatedCost, 0), + requests: procurementRequests + }; + } + + /** + * Estimate daily usage based on movement history + */ + estimateDailyUsage(stockItem) { + const outMovements = stockItem.movements.filter(m => m.movementType === 'out'); + + if (outMovements.length === 0) { + return 1; // Default to 1 unit per day + } + + // Calculate total quantity moved out + const totalOut = outMovements.reduce((sum, m) => sum + m.quantity, 0); + + // Calculate time span + const firstMovement = outMovements[0].movementDate; + const lastMovement = outMovements[outMovements.length - 1].movementDate; + const daysDiff = Math.max(1, (lastMovement - firstMovement) / (1000 * 60 * 60 * 24)); + + return totalOut / daysDiff; + } + + /** + * Get replenishment reason + */ + getReplenishmentReason(currentStock, reorderPoint, safetyStock, backOrdersCount) { + if (currentStock === 0) { + return 'Stock depleted - Urgent replenishment required'; + } else if (backOrdersCount > 0) { + return `${backOrdersCount} pending back order(s) - High priority`; + } else if (currentStock <= safetyStock) { + return 'Below safety stock level - Risk of stock-out'; + } else if (currentStock <= reorderPoint) { + return 'Reorder point reached - Normal replenishment'; + } else { + return 'Preventive replenishment'; + } + } + + /** + * Calculate optimal reorder parameters for an item + */ + async calculateOptimalParameters(stockItem) { + const movements = stockItem.movements.filter(m => m.movementType === 'out'); + + if (movements.length < 5) { + return { + message: 'Insufficient data for optimization', + currentReorderPoint: stockItem.reorderPoint, + currentSafetyStock: stockItem.safetyStock + }; + } + + // Calculate usage statistics + const dailyUsages = this.calculateDailyUsages(movements); + const avgDailyUsage = dailyUsages.reduce((a, b) => a + b, 0) / dailyUsages.length; + const maxDailyUsage = Math.max(...dailyUsages); + + // Estimate lead time (use supplier lead time or default) + const leadTime = stockItem.supplier?.leadTime || 7; + const maxLeadTime = leadTime * 1.5; // Add 50% buffer + + // Calculate optimal safety stock + const optimalSafetyStock = stockMath.calculateSafetyStock( + maxDailyUsage, + maxLeadTime, + avgDailyUsage, + leadTime + ); + + // Calculate optimal reorder point + const optimalReorderPoint = stockMath.calculateReorderPoint( + avgDailyUsage, + leadTime, + optimalSafetyStock + ); + + return { + current: { + reorderPoint: stockItem.reorderPoint, + safetyStock: stockItem.safetyStock + }, + recommended: { + reorderPoint: optimalReorderPoint, + safetyStock: optimalSafetyStock + }, + statistics: { + avgDailyUsage: Math.round(avgDailyUsage * 100) / 100, + maxDailyUsage: Math.round(maxDailyUsage * 100) / 100, + leadTime, + dataPoints: movements.length + } + }; + } + + /** + * Calculate daily usages from movements + */ + calculateDailyUsages(movements) { + if (movements.length === 0) return [0]; + + // Group movements by day + const dailyMap = new Map(); + + for (const movement of movements) { + const dateKey = new Date(movement.movementDate).toISOString().split('T')[0]; + dailyMap.set(dateKey, (dailyMap.get(dateKey) || 0) + movement.quantity); + } + + return Array.from(dailyMap.values()); + } + + /** + * Get replenishment analytics + */ + async getReplenishmentAnalytics(userId) { + const stockItems = await StockItem.find({ userId, isActive: true }); + + const analytics = { + totalItems: stockItems.length, + itemsByStatus: { + in_stock: 0, + low_stock: 0, + out_of_stock: 0 + }, + replenishmentNeeded: 0, + estimatedReplenishmentCost: 0, + averageStockLevel: 0, + itemsWithBackOrders: 0 + }; + + let totalStockValue = 0; + + for (const item of stockItems) { + analytics.itemsByStatus[item.stockStatus]++; + totalStockValue += item.valuation.totalValue; + + if (item.quantity.current <= item.reorderPoint) { + analytics.replenishmentNeeded++; + const orderQty = Math.max(item.safetyStock * 2, item.reorderPoint - item.quantity.current); + analytics.estimatedReplenishmentCost += orderQty * item.pricing.costPrice; + } + + // Check for back orders + const backOrders = await BackOrder.countDocuments({ + stockItemId: item._id, + status: { $in: ['pending', 'partially_fulfilled'] } + }); + + if (backOrders > 0) { + analytics.itemsWithBackOrders++; + } + } + + analytics.averageStockLevel = stockItems.length > 0 + ? totalStockValue / stockItems.length + : 0; + + return analytics; + } +} + +module.exports = new ReplenishmentService(); diff --git a/utils/stockMath.js b/utils/stockMath.js new file mode 100644 index 00000000..5b4b773b --- /dev/null +++ b/utils/stockMath.js @@ -0,0 +1,276 @@ +/** + * Stock Math Utility + * Provides inventory valuation methods (FIFO, LIFO, WAC) + */ + +class StockMath { + /** + * Calculate FIFO (First In, First Out) valuation + * @param {Array} stockLayers - Array of {quantity, costPrice, date} + * @param {Number} quantityToValue - Quantity to calculate value for + */ + calculateFIFO(stockLayers, quantityToValue) { + let remainingQty = quantityToValue; + let totalValue = 0; + const layersUsed = []; + + // Sort by date (oldest first) + const sortedLayers = [...stockLayers].sort((a, b) => + new Date(a.date) - new Date(b.date) + ); + + for (const layer of sortedLayers) { + if (remainingQty <= 0) break; + + const qtyFromLayer = Math.min(layer.quantity, remainingQty); + totalValue += qtyFromLayer * layer.costPrice; + remainingQty -= qtyFromLayer; + + layersUsed.push({ + quantity: qtyFromLayer, + costPrice: layer.costPrice, + date: layer.date + }); + } + + return { + totalValue, + averageCost: quantityToValue > 0 ? totalValue / quantityToValue : 0, + layersUsed, + remainingQuantity: remainingQty + }; + } + + /** + * Calculate LIFO (Last In, First Out) valuation + * @param {Array} stockLayers - Array of {quantity, costPrice, date} + * @param {Number} quantityToValue - Quantity to calculate value for + */ + calculateLIFO(stockLayers, quantityToValue) { + let remainingQty = quantityToValue; + let totalValue = 0; + const layersUsed = []; + + // Sort by date (newest first) + const sortedLayers = [...stockLayers].sort((a, b) => + new Date(b.date) - new Date(a.date) + ); + + for (const layer of sortedLayers) { + if (remainingQty <= 0) break; + + const qtyFromLayer = Math.min(layer.quantity, remainingQty); + totalValue += qtyFromLayer * layer.costPrice; + remainingQty -= qtyFromLayer; + + layersUsed.push({ + quantity: qtyFromLayer, + costPrice: layer.costPrice, + date: layer.date + }); + } + + return { + totalValue, + averageCost: quantityToValue > 0 ? totalValue / quantityToValue : 0, + layersUsed, + remainingQuantity: remainingQty + }; + } + + /** + * Calculate WAC (Weighted Average Cost) + * @param {Array} stockLayers - Array of {quantity, costPrice} + */ + calculateWAC(stockLayers) { + let totalQuantity = 0; + let totalValue = 0; + + for (const layer of stockLayers) { + totalQuantity += layer.quantity; + totalValue += layer.quantity * layer.costPrice; + } + + const averageCost = totalQuantity > 0 ? totalValue / totalQuantity : 0; + + return { + totalQuantity, + totalValue, + averageCost + }; + } + + /** + * Calculate reorder quantity using Economic Order Quantity (EOQ) formula + * @param {Number} annualDemand - Annual demand in units + * @param {Number} orderingCost - Cost per order + * @param {Number} holdingCost - Annual holding cost per unit + */ + calculateEOQ(annualDemand, orderingCost, holdingCost) { + if (holdingCost === 0) return 0; + + const eoq = Math.sqrt((2 * annualDemand * orderingCost) / holdingCost); + return Math.round(eoq); + } + + /** + * Calculate safety stock level + * @param {Number} maxDailyUsage - Maximum daily usage + * @param {Number} maxLeadTime - Maximum lead time in days + * @param {Number} avgDailyUsage - Average daily usage + * @param {Number} avgLeadTime - Average lead time in days + */ + calculateSafetyStock(maxDailyUsage, maxLeadTime, avgDailyUsage, avgLeadTime) { + const maxUsageDuringLeadTime = maxDailyUsage * maxLeadTime; + const avgUsageDuringLeadTime = avgDailyUsage * avgLeadTime; + + return Math.round(maxUsageDuringLeadTime - avgUsageDuringLeadTime); + } + + /** + * Calculate reorder point + * @param {Number} avgDailyUsage - Average daily usage + * @param {Number} leadTime - Lead time in days + * @param {Number} safetyStock - Safety stock level + */ + calculateReorderPoint(avgDailyUsage, leadTime, safetyStock) { + return Math.round((avgDailyUsage * leadTime) + safetyStock); + } + + /** + * Calculate inventory turnover ratio + * @param {Number} costOfGoodsSold - COGS for the period + * @param {Number} averageInventory - Average inventory value + */ + calculateInventoryTurnover(costOfGoodsSold, averageInventory) { + if (averageInventory === 0) return 0; + return costOfGoodsSold / averageInventory; + } + + /** + * Calculate days inventory outstanding (DIO) + * @param {Number} averageInventory - Average inventory value + * @param {Number} costOfGoodsSold - COGS for the period + * @param {Number} days - Number of days in period (default 365) + */ + calculateDIO(averageInventory, costOfGoodsSold, days = 365) { + if (costOfGoodsSold === 0) return 0; + return (averageInventory / costOfGoodsSold) * days; + } + + /** + * Calculate stock-out probability using normal distribution + * @param {Number} demandMean - Mean demand + * @param {Number} demandStdDev - Standard deviation of demand + * @param {Number} currentStock - Current stock level + */ + calculateStockOutProbability(demandMean, demandStdDev, currentStock) { + if (demandStdDev === 0) return currentStock < demandMean ? 1 : 0; + + // Z-score calculation + const zScore = (currentStock - demandMean) / demandStdDev; + + // Approximate cumulative distribution function + const probability = this.normalCDF(zScore); + + return 1 - probability; // Probability of stock-out + } + + /** + * Normal cumulative distribution function approximation + */ + normalCDF(x) { + const t = 1 / (1 + 0.2316419 * Math.abs(x)); + const d = 0.3989423 * Math.exp(-x * x / 2); + const probability = d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274)))); + + return x > 0 ? 1 - probability : probability; + } + + /** + * Calculate ABC classification based on value + * @param {Array} items - Array of {sku, quantity, costPrice} + */ + calculateABCClassification(items) { + // Calculate total value for each item + const itemsWithValue = items.map(item => ({ + ...item, + totalValue: item.quantity * item.costPrice + })); + + // Sort by value (descending) + itemsWithValue.sort((a, b) => b.totalValue - a.totalValue); + + // Calculate cumulative value + const totalValue = itemsWithValue.reduce((sum, item) => sum + item.totalValue, 0); + let cumulativeValue = 0; + + // Classify items + const classified = itemsWithValue.map(item => { + cumulativeValue += item.totalValue; + const cumulativePercentage = (cumulativeValue / totalValue) * 100; + + let classification; + if (cumulativePercentage <= 70) { + classification = 'A'; // High value items (70% of value) + } else if (cumulativePercentage <= 90) { + classification = 'B'; // Medium value items (20% of value) + } else { + classification = 'C'; // Low value items (10% of value) + } + + return { + ...item, + classification, + cumulativePercentage + }; + }); + + return classified; + } + + /** + * Calculate optimal order quantity considering quantity discounts + * @param {Number} annualDemand + * @param {Number} orderingCost + * @param {Number} holdingCostRate - As percentage of unit cost + * @param {Array} priceBreaks - Array of {quantity, price} + */ + calculateOptimalOrderWithDiscounts(annualDemand, orderingCost, holdingCostRate, priceBreaks) { + const results = []; + + for (const priceBreak of priceBreaks) { + const holdingCost = priceBreak.price * holdingCostRate; + const eoq = this.calculateEOQ(annualDemand, orderingCost, holdingCost); + + // Adjust EOQ to minimum quantity for this price break + const orderQty = Math.max(eoq, priceBreak.quantity); + + // Calculate total annual cost + const purchaseCost = annualDemand * priceBreak.price; + const orderingCostTotal = (annualDemand / orderQty) * orderingCost; + const holdingCostTotal = (orderQty / 2) * holdingCost; + const totalCost = purchaseCost + orderingCostTotal + holdingCostTotal; + + results.push({ + priceBreak: priceBreak.quantity, + unitPrice: priceBreak.price, + orderQuantity: orderQty, + totalAnnualCost: totalCost, + purchaseCost, + orderingCost: orderingCostTotal, + holdingCost: holdingCostTotal + }); + } + + // Find optimal (minimum cost) + results.sort((a, b) => a.totalAnnualCost - b.totalAnnualCost); + + return { + optimal: results[0], + allOptions: results + }; + } +} + +module.exports = new StockMath();