- 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
268 lines
9.2 KiB
JavaScript
268 lines
9.2 KiB
JavaScript
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,
|
|
}
|