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:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user