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,27 @@
const catchAsync = require('../../globalErrorHandler/catchAsync')
const warehouseService = require('./warehouseService')
exports.createWarehouse = catchAsync(async (req, res, next) => {
const warehouse = await warehouseService.createWarehouse(req.body)
res.status(201).json({ status: 'success', data: warehouse })
})
exports.listWarehouses = catchAsync(async (req, res, next) => {
const warehouses = await warehouseService.listWarehouses(req.query)
res.status(200).json({ status: 'success', results: warehouses.length, data: warehouses })
})
exports.getWarehouse = catchAsync(async (req, res, next) => {
const warehouse = await warehouseService.getWarehouse(req.params.warehouseId)
res.status(200).json({ status: 'success', data: warehouse })
})
exports.updateWarehouse = catchAsync(async (req, res, next) => {
const warehouse = await warehouseService.updateWarehouse(req.params.warehouseId, req.body)
res.status(200).json({ status: 'success', data: warehouse })
})
exports.deleteWarehouse = catchAsync(async (req, res, next) => {
await warehouseService.deleteWarehouse(req.params.warehouseId)
res.status(200).json({ status: 'success', data: null })
})
+34
View File
@@ -0,0 +1,34 @@
const mongoose = require('mongoose')
const ObjectID = mongoose.Schema.Types.ObjectId
const warehouseSchema = new mongoose.Schema(
{
name: {
type: String,
required: true,
},
code: {
type: String,
unique: true,
},
location: {
address: String,
coordinates: [Number], // [long, lat]
},
manager: {
type: ObjectID,
ref: 'User',
},
description: String,
isActive: {
type: Boolean,
default: true,
},
},
{
timestamps: true,
}
)
const Warehouse = mongoose.model('Warehouse', warehouseSchema)
module.exports = Warehouse
+15
View File
@@ -0,0 +1,15 @@
const express = require('express')
const warehouseController = require('./warehouseController')
const loginCheck = require('../../middlewares/loginCheck')
const router = new express.Router()
router.use(loginCheck.loginProtect)
router.get('/admin/get-all', warehouseController.listWarehouses)
router.post('/admin/create', warehouseController.createWarehouse)
router.get('/admin/get-one/:warehouseId', warehouseController.getWarehouse)
router.patch('/admin/update/:warehouseId', warehouseController.updateWarehouse)
router.delete('/admin/delete/:warehouseId', warehouseController.deleteWarehouse)
module.exports = router
+62
View File
@@ -0,0 +1,62 @@
const Warehouse = require('./warehouseModel')
const AppError = require('../../globalErrorHandler/AppError')
const createWarehouse = async (data) => {
const warehouse = await Warehouse.create({
name: data.name,
code: data.code || undefined,
location: data.location,
manager: data.manager,
description: data.description,
})
return warehouse
}
const listWarehouses = async (query = {}) => {
const filter = {}
if (query.isActive !== undefined) filter.isActive = query.isActive === 'true'
if (query.search) {
filter.$or = [
{ name: { $regex: query.search, $options: 'i' } },
{ code: { $regex: query.search, $options: 'i' } },
]
}
const warehouses = await Warehouse.find(filter)
.populate('manager', 'name email')
.sort({ createdAt: -1 })
return warehouses
}
const getWarehouse = async (warehouseId) => {
const warehouse = await Warehouse.findById(warehouseId).populate('manager', 'name email')
if (!warehouse) throw new AppError('انبار یافت نشد', 404, {})
return warehouse
}
const updateWarehouse = async (warehouseId, data) => {
const allowed = ['name', 'code', 'location', 'manager', 'description', 'isActive']
const update = {}
allowed.forEach((field) => {
if (data[field] !== undefined) update[field] = data[field]
})
const warehouse = await Warehouse.findByIdAndUpdate(warehouseId, update, { new: true, runValidators: true })
if (!warehouse) throw new AppError('انبار یافت نشد', 404, {})
return warehouse
}
const deleteWarehouse = async (warehouseId) => {
const warehouse = await Warehouse.findByIdAndUpdate(warehouseId, { isActive: false }, { new: true })
if (!warehouse) throw new AppError('انبار یافت نشد', 404, {})
return warehouse
}
module.exports = {
createWarehouse,
listWarehouses,
getWarehouse,
updateWarehouse,
deleteWarehouse,
}
@@ -0,0 +1,63 @@
const catchAsync = require('../../globalErrorHandler/catchAsync')
const warehouseItemService = require('./warehouseItemService')
// ─── ITEMS ────────────────────────────────────────────────────────────────────
exports.createItem = catchAsync(async (req, res, next) => {
const item = await warehouseItemService.createItem(req.body)
res.status(201).json({ status: 'success', data: item })
})
exports.listItems = catchAsync(async (req, res, next) => {
const result = await warehouseItemService.listItems(req.query)
res.status(200).json({ status: 'success', ...result })
})
exports.getItem = catchAsync(async (req, res, next) => {
const item = await warehouseItemService.getItem(req.params.itemId)
res.status(200).json({ status: 'success', data: item })
})
exports.updateItem = catchAsync(async (req, res, next) => {
const item = await warehouseItemService.updateItem(req.params.itemId, req.body)
res.status(200).json({ status: 'success', data: item })
})
exports.deleteItem = catchAsync(async (req, res, next) => {
await warehouseItemService.deleteItem(req.params.itemId)
res.status(200).json({ status: 'success', data: null })
})
// ─── TRANSACTIONS ─────────────────────────────────────────────────────────────
exports.createTransaction = catchAsync(async (req, res, next) => {
const transaction = await warehouseItemService.createTransaction(req.body, req.user)
res.status(201).json({ status: 'success', data: transaction })
})
exports.listTransactions = catchAsync(async (req, res, next) => {
const result = await warehouseItemService.listTransactions(req.query)
res.status(200).json({ status: 'success', ...result })
})
exports.deleteTransaction = catchAsync(async (req, res, next) => {
await warehouseItemService.deleteTransaction(req.params.transactionId)
res.status(200).json({ status: 'success', data: null })
})
// ─── STOCK ────────────────────────────────────────────────────────────────────
exports.getStockByItem = catchAsync(async (req, res, next) => {
const result = await warehouseItemService.getStockByItem(req.params.itemId)
res.status(200).json({ status: 'success', data: result })
})
exports.getStockByWarehouse = catchAsync(async (req, res, next) => {
const stocks = await warehouseItemService.getStockByWarehouse(req.params.warehouseId)
res.status(200).json({ status: 'success', results: stocks.length, data: stocks })
})
exports.getTotalStock = catchAsync(async (req, res, next) => {
const result = await warehouseItemService.getTotalStock()
res.status(200).json({ status: 'success', results: result.length, data: result })
})
@@ -0,0 +1,43 @@
const mongoose = require('mongoose')
const warehouseItemSchema = new mongoose.Schema(
{
nameFa: {
type: String,
required: true, // نام فارسی — مثال: سیمان، آجر
},
nameEn: {
type: String,
required: true, // English name — e.g. Cement, Brick
},
code: {
type: String,
unique: true,
sparse: true, // optional unique code, e.g. CEM, BRK
},
unitFa: {
type: String,
required: true, // واحد شمارش فارسی — مثال: تن، عدد، متر
},
unitEn: {
type: String,
required: true, // English counting unit — e.g. Ton, Piece, Meter
},
category: {
type: String, // e.g. "مصالح ساختمانی", "تأسیسات"
},
description: String,
isActive: {
type: Boolean,
default: true,
},
},
{
timestamps: true,
}
)
warehouseItemSchema.index({ nameFa: 'text', nameEn: 'text', code: 'text' })
const WarehouseItem = mongoose.model('WarehouseItem', warehouseItemSchema)
module.exports = WarehouseItem
@@ -0,0 +1,26 @@
const express = require('express')
const warehouseItemController = require('./warehouseItemController')
const loginCheck = require('../../middlewares/loginCheck')
const router = new express.Router()
router.use(loginCheck.loginProtect)
// ─── Items ────────────────────────────────────────────────────────────────────
router.get('/admin/items', warehouseItemController.listItems)
router.post('/admin/items', warehouseItemController.createItem)
router.get('/admin/items/:itemId', warehouseItemController.getItem)
router.patch('/admin/items/:itemId', warehouseItemController.updateItem)
router.delete('/admin/items/:itemId', warehouseItemController.deleteItem)
// ─── Transactions ─────────────────────────────────────────────────────────────
router.get('/admin/transactions', warehouseItemController.listTransactions)
router.post('/admin/transactions', warehouseItemController.createTransaction)
router.delete('/admin/transactions/:transactionId', warehouseItemController.deleteTransaction)
// ─── Stock ────────────────────────────────────────────────────────────────────
router.get('/admin/stock/total', warehouseItemController.getTotalStock)
router.get('/admin/stock/by-item/:itemId', warehouseItemController.getStockByItem)
router.get('/admin/stock/by-warehouse/:warehouseId', warehouseItemController.getStockByWarehouse)
module.exports = router
@@ -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,
}
@@ -0,0 +1,39 @@
const mongoose = require('mongoose')
const ObjectID = mongoose.Schema.Types.ObjectId
// Running balance snapshot per (item × warehouse)
// Updated atomically on each transaction using $inc
// Acts as a fast-read cache — transactions are the source of truth
const warehouseItemStockSchema = new mongoose.Schema(
{
item: {
type: ObjectID,
ref: 'WarehouseItem',
required: true,
},
warehouse: {
type: ObjectID,
ref: 'Warehouse',
required: true,
},
quantity: {
type: Number,
default: 0, // current balance
},
unit: {
type: String,
},
unitEn: {
type: String,
},
},
{
timestamps: true,
}
)
// Guarantee one document per (item, warehouse) combination
warehouseItemStockSchema.index({ item: 1, warehouse: 1 }, { unique: true })
const WarehouseItemStock = mongoose.model('WarehouseItemStock', warehouseItemStockSchema)
module.exports = WarehouseItemStock
@@ -0,0 +1,65 @@
const mongoose = require('mongoose')
const ObjectID = mongoose.Schema.Types.ObjectId
const warehouseItemTransactionSchema = new mongoose.Schema(
{
item: {
type: ObjectID,
ref: 'WarehouseItem',
required: true,
},
warehouse: {
type: ObjectID,
ref: 'Warehouse',
required: true,
},
type: {
type: String,
enum: ['IN', 'OUT'], // ورود / خروج
required: true,
},
quantity: {
type: Number,
required: true,
min: [0.001, 'مقدار باید بیشتر از صفر باشد'],
},
unit: {
type: String,
required: true, // واحد — کپی از آیتم در زمان تراکنش
},
unitEn: {
type: String,
},
reason: {
type: String,
required: true, // دلیل تراکنش — مثال: خرید، مصرف در پروژه، برگشت کالا
},
actor: {
type: ObjectID,
ref: 'User',
required: true,
},
actorName: {
type: String, // ذخیره برای نمایش سریع
},
note: {
type: String, // توضیحات اضافی
},
date: {
type: Date,
default: Date.now,
},
},
{
timestamps: true,
}
)
warehouseItemTransactionSchema.index({ item: 1, warehouse: 1 })
warehouseItemTransactionSchema.index({ item: 1, type: 1 })
warehouseItemTransactionSchema.index({ warehouse: 1, date: -1 })
warehouseItemTransactionSchema.index({ actor: 1 })
warehouseItemTransactionSchema.index({ date: -1 })
const WarehouseItemTransaction = mongoose.model('WarehouseItemTransaction', warehouseItemTransactionSchema)
module.exports = WarehouseItemTransaction