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 0ca9e194..dab507ef 100644
--- a/public/expensetracker.css
+++ b/public/expensetracker.css
@@ -9633,361 +9633,284 @@ input:checked + .toggle-slider::before {
.checkbox-container input { width: 16px; height: 16px; }
/* ============================================
- GLOBAL PAYROLL MANAGEMENT
- Issue #589: Payroll & Statutory Engine
+ INVENTORY & SUPPLY CHAIN HUB
+ Issue #588: Multi-Entity Inventory System
============================================ */
-.payroll-header {
+.inventory-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
-.header-actions {
- display: flex;
- gap: 15px;
-}
-
-.stats-grid {
+.inventory-grid {
display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 20px;
- margin-bottom: 30px;
-}
-
-.stat-card {
- display: flex;
- align-items: center;
- gap: 15px;
- padding: 20px;
+ grid-template-columns: 1fr 400px;
+ gap: 25px;
+ margin-bottom: 25px;
}
-.stat-icon {
- width: 50px;
- height: 50px;
- border-radius: 10px;
+.right-panel {
display: flex;
- align-items: center;
- justify-content: center;
- font-size: 1.5rem;
-}
-
-.stat-content label {
- font-size: 0.75rem;
- color: var(--text-secondary);
- display: block;
-}
-
-.stat-content h2 {
- font-size: 1.8rem;
- margin: 5px 0 0 0;
-}
-
-.payroll-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 25px;
- margin-bottom: 25px;
+ flex-direction: column;
+ gap: 20px;
}
-.filter-tabs {
+.filter-controls {
display: flex;
gap: 10px;
}
-.tab-btn {
- padding: 5px 15px;
+.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: var(--text-secondary);
- cursor: pointer;
- transition: all 0.3s;
-}
-
-.tab-btn.active {
- background: var(--accent-primary);
- color: var(--bg-primary);
- border-color: var(--accent-primary);
+ color: white;
+ font-size: 0.8rem;
}
-.payroll-runs-list, .employees-list {
+.stock-items-list, .warehouses-list, .alerts-list, .backorders-list {
display: flex;
flex-direction: column;
- gap: 15px;
+ gap: 12px;
max-height: 600px;
overflow-y: auto;
}
-.payroll-run-card {
- padding: 20px;
+.stock-item-card, .warehouse-card, .backorder-card {
+ padding: 15px;
cursor: pointer;
transition: transform 0.2s;
}
-.payroll-run-card:hover {
+.stock-item-card:hover {
transform: translateY(-2px);
}
-.run-header {
+.item-header, .wh-header, .bo-header {
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: 15px;
+ margin-bottom: 12px;
}
-.run-period strong {
+.item-info, .wh-info, .bo-info {
+ flex: 1;
+}
+
+.item-info strong, .wh-info strong, .bo-info strong {
display: block;
- font-size: 1.1rem;
+ font-size: 0.95rem;
}
-.run-id {
- font-size: 0.75rem;
+.sku-badge {
+ font-size: 0.7rem;
color: var(--text-secondary);
+ font-family: monospace;
+ display: block;
+ margin-top: 2px;
}
-.run-summary {
+.item-details, .bo-details {
display: flex;
- gap: 20px;
- padding: 15px 0;
+ flex-direction: column;
+ gap: 8px;
+ padding-top: 10px;
border-top: 1px solid rgba(255,255,255,0.05);
- border-bottom: 1px solid rgba(255,255,255,0.05);
}
-.summary-item {
- flex: 1;
+.detail-row, .detail-item {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.85rem;
}
-.summary-item label {
- font-size: 0.7rem;
+.detail-row label, .detail-item label {
color: var(--text-secondary);
- display: block;
}
-.summary-item strong {
- font-size: 1rem;
-}
-
-.run-actions {
- margin-top: 15px;
- display: flex;
- gap: 10px;
-}
-
-.employee-card {
- padding: 15px;
+.quantity-badge {
+ padding: 2px 8px;
+ background: rgba(100, 255, 218, 0.15);
+ color: #64ffda;
+ border-radius: 4px;
+ font-size: 0.75rem;
}
-.emp-header {
+.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: 12px;
- margin-bottom: 15px;
+ gap: 8px;
}
-.emp-avatar {
- width: 45px;
- height: 45px;
- border-radius: 50%;
- background: rgba(100, 255, 218, 0.15);
+.wh-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
- color: var(--accent-primary);
-}
-
-.emp-info {
- flex: 1;
+ font-size: 1.2rem;
}
-.emp-info strong {
- display: block;
- font-size: 0.95rem;
-}
+.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; }
-.emp-info span {
- font-size: 0.7rem;
+.wh-location {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.75rem;
color: var(--text-secondary);
- display: block;
+ margin-top: 8px;
}
-.emp-designation {
- margin-top: 2px;
-}
-
-.status-pill {
- font-size: 0.65rem;
- padding: 3px 10px;
- border-radius: 12px;
- text-transform: uppercase;
+.alert-card {
+ padding: 12px;
+ background: rgba(255,255,255,0.02);
+ border-radius: 8px;
+ border-left: 4px solid;
+ display: flex;
+ gap: 12px;
}
-.status-pill.active {
- background: rgba(100, 255, 218, 0.2);
- color: #64ffda;
-}
+.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; }
-.status-pill.inactive {
- background: rgba(255, 107, 107, 0.2);
- color: #ff6b6b;
+.alert-icon {
+ font-size: 1.2rem;
}
-.emp-salary {
- display: flex;
- gap: 15px;
- padding-top: 12px;
- border-top: 1px solid rgba(255,255,255,0.05);
-}
+.alert-card.urgent .alert-icon { color: #ff6b6b; }
+.alert-card.high .alert-icon { color: #ff9f43; }
+.alert-card.medium .alert-icon { color: #48dbfb; }
-.salary-item {
+.alert-content {
flex: 1;
}
-.salary-item label {
- font-size: 0.65rem;
- color: var(--text-secondary);
+.alert-content strong {
display: block;
+ margin-bottom: 4px;
}
-.salary-item strong {
- font-size: 0.9rem;
+.alert-content p {
+ font-size: 0.8rem;
+ margin: 0 0 4px 0;
}
-.status-badge {
+.alert-reason {
font-size: 0.7rem;
- padding: 4px 12px;
- border-radius: 4px;
- text-transform: uppercase;
-}
-
-.status-badge.draft {
- background: rgba(136, 146, 176, 0.2);
- color: #8892b0;
-}
-
-.status-badge.pending_approval {
- background: rgba(255, 159, 67, 0.2);
- color: #ff9f43;
+ color: var(--text-secondary);
}
-.status-badge.approved {
- background: rgba(72, 219, 251, 0.2);
- color: #48dbfb;
+.priority-badge {
+ font-size: 0.65rem;
+ padding: 3px 10px;
+ border-radius: 12px;
+ text-transform: uppercase;
}
-.status-badge.processing {
- background: rgba(255, 159, 67, 0.2);
- color: #ff9f43;
-}
+.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; }
-.status-badge.completed {
- background: rgba(100, 255, 218, 0.2);
- color: #64ffda;
+.pending-qty {
+ color: var(--accent-primary);
+ font-weight: bold;
}
-.status-badge.failed {
+.badge {
+ padding: 2px 8px;
background: rgba(255, 107, 107, 0.2);
color: #ff6b6b;
+ border-radius: 12px;
+ font-size: 0.75rem;
}
-.modal-large {
- max-width: 900px;
-}
-
-.form-section {
- margin-bottom: 25px;
- padding-bottom: 20px;
- border-bottom: 1px solid rgba(255,255,255,0.05);
-}
-
-.form-section h4 {
- margin-bottom: 15px;
- color: var(--accent-primary);
-}
-
-.component-row {
- margin-bottom: 10px;
-}
-
-.search-input {
- padding: 8px 15px;
- background: rgba(255,255,255,0.05);
- border: 1px solid rgba(255,255,255,0.1);
- border-radius: 6px;
- color: white;
- width: 250px;
+.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;
}
-.payroll-details {
+.stock-details {
padding: 20px 0;
}
-.details-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
-}
-
-.details-summary {
+.details-grid {
display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 15px;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 20px;
margin-bottom: 25px;
}
-.summary-card {
+.detail-section {
padding: 15px;
background: rgba(255,255,255,0.02);
border-radius: 8px;
- text-align: center;
}
-.summary-card label {
- font-size: 0.7rem;
- color: var(--text-secondary);
- display: block;
- margin-bottom: 8px;
+.detail-section h4 {
+ margin-bottom: 15px;
+ color: var(--accent-primary);
+ font-size: 0.9rem;
}
-.summary-card h3 {
- font-size: 1.5rem;
- margin: 0;
+.movements-section {
+ padding: 15px;
+ background: rgba(255,255,255,0.02);
+ border-radius: 8px;
}
-.entries-table {
- overflow-x: auto;
+.movements-section h4 {
+ margin-bottom: 15px;
+ color: var(--accent-primary);
}
-.entries-table table {
- width: 100%;
- border-collapse: collapse;
+.movements-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
}
-.entries-table th {
- text-align: left;
- padding: 12px;
+.movement-item {
+ display: flex;
+ justify-content: space-between;
+ padding: 8px;
background: rgba(255,255,255,0.02);
+ border-radius: 4px;
font-size: 0.8rem;
- color: var(--text-secondary);
}
-.entries-table td {
- padding: 12px;
- border-bottom: 1px solid rgba(255,255,255,0.05);
-}
-
-.entries-table td small {
- color: var(--text-secondary);
- font-size: 0.75rem;
+.movement-type {
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.7rem;
+ text-transform: uppercase;
}
-.btn-success {
- background: linear-gradient(135deg, #64ffda, #48dbfb);
- color: var(--bg-primary);
-}
+.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; }
-.text-accent {
- color: var(--accent-primary);
+.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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0
+
+
+
+
+
+
+
+
+
0
+
+
+
+
+
+
+
+
+
₹0
+
+
+
+
+
+
+
+
+
0
+
+
+
+
+
+
+
+
+
+
+
Loading stock items...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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.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.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.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 6a787fe0..0db54b9e 100644
--- a/server.js
+++ b/server.js
@@ -277,9 +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/treasury', require('./routes/treasury'));
app.use('/api/payroll', require('./routes/payroll'));
-app.use('/api/groups', require('./routes/groups'));
+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();