ADD: Warehouse inventory management system

- Add WarehouseItem model with bilingual names (FA/EN) and bilingual counting units
- Add WarehouseItemTransaction model for IN/OUT movements with reason and actor
- Add WarehouseItemStock model for denormalized per-(item×warehouse) running balance
- Add warehouseItemService with full CRUD, atomic stock updates via $inc, negative stock guard, and aggregation queries for per-item/per-warehouse/total stock
- Add warehouseItemController and REST routes at /api/warehouse-items
- Add warehouseService, warehouseController, warehouseRoutes for warehouse location CRUD at /api/warehouses
- Register both route groups in routerEndpoints.js
This commit is contained in:
2026-08-11 20:09:10 +03:30
parent 0fe799e008
commit a9432b96c0
11 changed files with 653 additions and 6 deletions
@@ -0,0 +1,267 @@
const WarehouseItem = require('./warehouseItemModel')
const WarehouseItemTransaction = require('./warehouseItemTransactionModel')
const WarehouseItemStock = require('./warehouseItemStockModel')
const AppError = require('../../globalErrorHandler/AppError')
const mongoose = require('mongoose')
// ─── ITEM CRUD ────────────────────────────────────────────────────────────────
const createItem = async (data) => {
const item = await WarehouseItem.create({
nameFa: data.nameFa,
nameEn: data.nameEn,
code: data.code || undefined,
unitFa: data.unitFa,
unitEn: data.unitEn,
category: data.category,
description: data.description,
})
return item
}
const listItems = async (query = {}) => {
const page = parseInt(query.page) || 1
const limit = parseInt(query.limit) || 50
const skip = (page - 1) * limit
const filter = {}
if (query.search) {
filter.$or = [
{ nameFa: { $regex: query.search, $options: 'i' } },
{ nameEn: { $regex: query.search, $options: 'i' } },
{ code: { $regex: query.search, $options: 'i' } },
]
}
if (query.category) filter.category = query.category
if (query.isActive !== undefined) filter.isActive = query.isActive === 'true'
const [items, total] = await Promise.all([
WarehouseItem.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit),
WarehouseItem.countDocuments(filter),
])
return { items, total, page, limit, pages: Math.ceil(total / limit) }
}
const getItem = async (itemId) => {
const item = await WarehouseItem.findById(itemId)
if (!item) throw new AppError('آیتم انبار یافت نشد', 404, {})
return item
}
const updateItem = async (itemId, data) => {
const allowed = ['nameFa', 'nameEn', 'code', 'unitFa', 'unitEn', 'category', 'description', 'isActive']
const update = {}
allowed.forEach((field) => {
if (data[field] !== undefined) update[field] = data[field]
})
const item = await WarehouseItem.findByIdAndUpdate(itemId, update, { new: true, runValidators: true })
if (!item) throw new AppError('آیتم انبار یافت نشد', 404, {})
return item
}
const deleteItem = async (itemId) => {
const item = await WarehouseItem.findByIdAndUpdate(itemId, { isActive: false }, { new: true })
if (!item) throw new AppError('آیتم انبار یافت نشد', 404, {})
return item
}
// ─── TRANSACTIONS ─────────────────────────────────────────────────────────────
const createTransaction = async (data, user) => {
const { itemId, warehouseId, type, quantity, reason, note, date } = data
if (!itemId || !warehouseId || !type || !quantity || !reason) {
throw new AppError('اطلاعات ناقص است', 400, {})
}
const item = await WarehouseItem.findById(itemId)
if (!item) throw new AppError('آیتم انبار یافت نشد', 404, {})
if (!item.isActive) throw new AppError('این آیتم غیرفعال است', 400, {})
const qty = parseFloat(quantity)
if (isNaN(qty) || qty <= 0) throw new AppError('مقدار باید عدد مثبت باشد', 400, {})
// Prevent negative stock on OUT
if (type === 'OUT') {
const stock = await WarehouseItemStock.findOne({ item: itemId, warehouse: warehouseId })
const currentQty = stock ? stock.quantity : 0
if (currentQty < qty) {
throw new AppError(
`موجودی کافی نیست. موجودی فعلی: ${currentQty} ${item.unitFa}`,
400,
{ currentQuantity: currentQty, requestedQuantity: qty }
)
}
}
// Create the transaction record
const transaction = await WarehouseItemTransaction.create({
item: itemId,
warehouse: warehouseId,
type,
quantity: qty,
unit: item.unitFa,
unitEn: item.unitEn,
reason,
actor: user._id,
actorName: user.name,
note,
date: date || Date.now(),
})
// Update the stock snapshot atomically
const delta = type === 'IN' ? qty : -qty
await WarehouseItemStock.findOneAndUpdate(
{ item: itemId, warehouse: warehouseId },
{
$inc: { quantity: delta },
$set: { unit: item.unitFa, unitEn: item.unitEn },
},
{ upsert: true, new: true }
)
return transaction
}
const listTransactions = async (query = {}) => {
const page = parseInt(query.page) || 1
const limit = parseInt(query.limit) || 50
const skip = (page - 1) * limit
const filter = {}
if (query.itemId) filter.item = query.itemId
if (query.warehouseId) filter.warehouse = query.warehouseId
if (query.type) filter.type = query.type
if (query.actorId) filter.actor = query.actorId
if (query.dateFrom || query.dateTo) {
filter.date = {}
if (query.dateFrom) filter.date.$gte = new Date(query.dateFrom)
if (query.dateTo) filter.date.$lte = new Date(query.dateTo)
}
const [transactions, total] = await Promise.all([
WarehouseItemTransaction.find(filter)
.sort({ date: -1 })
.skip(skip)
.limit(limit)
.populate('item', 'nameFa nameEn code unitFa unitEn')
.populate('warehouse', 'name code location')
.populate('actor', 'name email'),
WarehouseItemTransaction.countDocuments(filter),
])
return { transactions, total, page, limit, pages: Math.ceil(total / limit) }
}
const deleteTransaction = async (transactionId) => {
const transaction = await WarehouseItemTransaction.findById(transactionId)
if (!transaction) throw new AppError('تراکنش یافت نشد', 404, {})
// Reverse the stock effect
const delta = transaction.type === 'IN' ? -transaction.quantity : transaction.quantity
// Check if reversal would cause negative stock (for IN reversals)
if (transaction.type === 'IN') {
const stock = await WarehouseItemStock.findOne({
item: transaction.item,
warehouse: transaction.warehouse,
})
const currentQty = stock ? stock.quantity : 0
if (currentQty < transaction.quantity) {
throw new AppError(
'حذف این تراکنش ممکن نیست: موجودی کافی برای برگشت وجود ندارد',
400,
{ currentQuantity: currentQty }
)
}
}
await WarehouseItemStock.findOneAndUpdate(
{ item: transaction.item, warehouse: transaction.warehouse },
{ $inc: { quantity: delta } }
)
await WarehouseItemTransaction.findByIdAndDelete(transactionId)
return null
}
// ─── STOCK QUERIES ────────────────────────────────────────────────────────────
const getStockByItem = async (itemId) => {
const item = await WarehouseItem.findById(itemId)
if (!item) throw new AppError('آیتم انبار یافت نشد', 404, {})
const stocks = await WarehouseItemStock.find({ item: itemId })
.populate('warehouse', 'name code location description')
.sort({ quantity: -1 })
const totalQuantity = stocks.reduce((sum, s) => sum + s.quantity, 0)
return {
item,
stocks,
totalQuantity,
unit: item.unitFa,
unitEn: item.unitEn,
}
}
const getStockByWarehouse = async (warehouseId) => {
const stocks = await WarehouseItemStock.find({ warehouse: warehouseId })
.populate('item', 'nameFa nameEn code unitFa unitEn category isActive')
.sort({ quantity: -1 })
return stocks
}
const getTotalStock = async () => {
const result = await WarehouseItemStock.aggregate([
{
$group: {
_id: '$item',
totalQuantity: { $sum: '$quantity' },
warehouseCount: { $sum: 1 },
unit: { $first: '$unit' },
unitEn: { $first: '$unitEn' },
},
},
{
$lookup: {
from: 'warehouseitems',
localField: '_id',
foreignField: '_id',
as: 'item',
},
},
{ $unwind: '$item' },
{ $match: { 'item.isActive': true } },
{
$project: {
_id: 0,
item: { _id: '$item._id', nameFa: '$item.nameFa', nameEn: '$item.nameEn', code: '$item.code', category: '$item.category' },
totalQuantity: 1,
warehouseCount: 1,
unit: 1,
unitEn: 1,
},
},
{ $sort: { totalQuantity: -1 } },
])
return result
}
module.exports = {
createItem,
listItems,
getItem,
updateItem,
deleteItem,
createTransaction,
listTransactions,
deleteTransaction,
getStockByItem,
getStockByWarehouse,
getTotalStock,
}