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