diff --git a/src/database/schemas/inventory/filamentstock.schema.js b/src/database/schemas/inventory/filamentstock.schema.js index 8e48672..67938a6 100644 --- a/src/database/schemas/inventory/filamentstock.schema.js +++ b/src/database/schemas/inventory/filamentstock.schema.js @@ -20,6 +20,11 @@ const filamentStockSchema = new Schema( gross: { type: Number, required: true }, }, filamentSku: { type: mongoose.Schema.Types.ObjectId, ref: 'filamentSku', required: true }, + stockLocation: { + type: mongoose.Schema.Types.ObjectId, + ref: 'stockLocation', + required: false, + }, }, { timestamps: true } ); diff --git a/src/database/schemas/inventory/partstock.schema.js b/src/database/schemas/inventory/partstock.schema.js index 95d6baf..f8d9347 100644 --- a/src/database/schemas/inventory/partstock.schema.js +++ b/src/database/schemas/inventory/partstock.schema.js @@ -12,6 +12,11 @@ const partStockSchema = new Schema( progress: { type: Number, required: false }, }, partSku: { type: mongoose.Schema.Types.ObjectId, ref: 'partSku', required: true }, + stockLocation: { + type: mongoose.Schema.Types.ObjectId, + ref: 'stockLocation', + required: false, + }, currentQuantity: { type: Number, required: true }, sourceType: { type: String, required: true }, source: { type: Schema.Types.ObjectId, refPath: 'sourceType', required: true }, diff --git a/src/database/schemas/inventory/productstock.schema.js b/src/database/schemas/inventory/productstock.schema.js index 86afaa4..b6038c0 100644 --- a/src/database/schemas/inventory/productstock.schema.js +++ b/src/database/schemas/inventory/productstock.schema.js @@ -19,6 +19,11 @@ const productStockSchema = new Schema( }, postedAt: { type: Date, required: false }, productSku: { type: mongoose.Schema.Types.ObjectId, ref: 'productSku', required: true }, + stockLocation: { + type: mongoose.Schema.Types.ObjectId, + ref: 'stockLocation', + required: false, + }, currentQuantity: { type: Number, required: true }, partStocks: [partStockUsageSchema], }, diff --git a/src/database/schemas/inventory/stockevent.schema.js b/src/database/schemas/inventory/stockevent.schema.js index ca11179..cc0bd31 100644 --- a/src/database/schemas/inventory/stockevent.schema.js +++ b/src/database/schemas/inventory/stockevent.schema.js @@ -25,7 +25,7 @@ const stockEventSchema = new Schema( ownerType: { type: String, required: true, - enum: ['user', 'subJob', 'stockAudit'], + enum: ['user', 'subJob', 'stockAudit', 'stockTransfer'], }, timestamp: { type: Date, default: Date.now }, }, diff --git a/src/database/schemas/inventory/stocklocation.schema.js b/src/database/schemas/inventory/stocklocation.schema.js new file mode 100644 index 0000000..db99cc6 --- /dev/null +++ b/src/database/schemas/inventory/stocklocation.schema.js @@ -0,0 +1,29 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +const { Schema } = mongoose; + +const stockLocationSchema = new Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { type: String, required: true }, + notes: { type: String, required: false }, + }, + { timestamps: true } +); + +stockLocationSchema.statics.stats = async function () { + const total = await this.countDocuments({}); + return { total: { count: total } }; +}; + +stockLocationSchema.statics.history = async function () { + return []; +}; + +stockLocationSchema.virtual('id').get(function () { + return this._id; +}); + +stockLocationSchema.set('toJSON', { virtuals: true }); + +export const stockLocationModel = mongoose.model('stockLocation', stockLocationSchema); diff --git a/src/database/schemas/inventory/stocktransfer.schema.js b/src/database/schemas/inventory/stocktransfer.schema.js new file mode 100644 index 0000000..284bcd4 --- /dev/null +++ b/src/database/schemas/inventory/stocktransfer.schema.js @@ -0,0 +1,71 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +const { Schema } = mongoose; + +const stockTransferLineSchema = new Schema( + { + fromStockType: { + type: String, + required: true, + enum: ['filamentStock', 'partStock', 'productStock'], + }, + fromStock: { + type: Schema.Types.ObjectId, + refPath: 'fromStockType', + required: true, + }, + quantity: { type: Number, required: true }, + toStockLocation: { + type: Schema.Types.ObjectId, + ref: 'stockLocation', + required: true, + }, + toStockType: { + type: String, + required: false, + enum: ['filamentStock', 'partStock', 'productStock'], + }, + toStock: { + type: Schema.Types.ObjectId, + refPath: 'toStockType', + required: false, + }, + }, + { _id: true } +); + +const stockTransferSchema = new Schema( + { + _reference: { type: String, default: () => generateId()() }, + state: { + type: { type: String, required: true, default: 'draft' }, + progress: { type: Number, required: false }, + }, + postedAt: { type: Date, required: false }, + lines: { type: [stockTransferLineSchema], default: [] }, + }, + { timestamps: true } +); + +stockTransferSchema.statics.stats = async function () { + const [draft, posted] = await Promise.all([ + this.countDocuments({ 'state.type': 'draft' }), + this.countDocuments({ 'state.type': 'posted' }), + ]); + return { + draft: { count: draft }, + posted: { count: posted }, + }; +}; + +stockTransferSchema.statics.history = async function () { + return []; +}; + +stockTransferSchema.virtual('id').get(function () { + return this._id; +}); + +stockTransferSchema.set('toJSON', { virtuals: true }); + +export const stockTransferModel = mongoose.model('stockTransfer', stockTransferSchema); diff --git a/src/database/schemas/management/part.schema.js b/src/database/schemas/management/part.schema.js index de89983..71008dc 100644 --- a/src/database/schemas/management/part.schema.js +++ b/src/database/schemas/management/part.schema.js @@ -31,19 +31,10 @@ partSchema.virtual('id').get(function () { partSchema.set('toJSON', { virtuals: true }); partSchema.statics.recalculate = async function (part, user) { - const orderItemModel = mongoose.model('orderItem'); - const itemId = part._id; - const draftOrderItems = await orderItemModel - .find({ - 'state.type': 'draft', - itemType: 'part', - item: itemId, - }) - .populate('order') - .lean(); - - for (const orderItem of draftOrderItems) { - await orderItemModel.recalculate(orderItem, user); + const partSkuModel = mongoose.model('partSku'); + const skus = await partSkuModel.find({ part: part._id }).select('_id').lean(); + for (const sku of skus) { + await partSkuModel.recalculate(sku, user); } }; diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index 86c5a26..f771452 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -17,6 +17,8 @@ import { stockEventModel } from './inventory/stockevent.schema.js'; import { stockAuditModel } from './inventory/stockaudit.schema.js'; import { partStockModel } from './inventory/partstock.schema.js'; import { productStockModel } from './inventory/productstock.schema.js'; +import { stockLocationModel } from './inventory/stocklocation.schema.js'; +import { stockTransferModel } from './inventory/stocktransfer.schema.js'; import { auditLogModel } from './management/auditlog.schema.js'; import { userModel } from './management/user.schema.js'; import { appPasswordModel } from './management/apppassword.schema.js'; @@ -157,6 +159,20 @@ export const models = { referenceField: '_reference', label: 'Product Stock', }, + SLN: { + model: stockLocationModel, + idField: '_id', + type: 'stockLocation', + referenceField: '_reference', + label: 'Stock Location', + }, + STT: { + model: stockTransferModel, + idField: '_id', + type: 'stockTransfer', + referenceField: '_reference', + label: 'Stock Transfer', + }, ADL: { model: auditLogModel, idField: '_id', diff --git a/src/index.js b/src/index.js index b4d2b04..b13c3fa 100644 --- a/src/index.js +++ b/src/index.js @@ -29,6 +29,8 @@ import { orderItemRoutes, shipmentRoutes, stockAuditRoutes, + stockLocationRoutes, + stockTransferRoutes, stockEventRoutes, auditLogRoutes, noteTypeRoutes, @@ -152,6 +154,8 @@ app.use('/orderitems', orderItemRoutes); app.use('/shipments', shipmentRoutes); app.use('/stockevents', stockEventRoutes); app.use('/stockaudits', stockAuditRoutes); +app.use('/stocklocations', stockLocationRoutes); +app.use('/stocktransfers', stockTransferRoutes); app.use('/auditlogs', auditLogRoutes); app.use('/notetypes', noteTypeRoutes); app.use('/documentsizes', documentSizesRoutes); diff --git a/src/routes/index.js b/src/routes/index.js index e95b69c..65e2378 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -24,6 +24,8 @@ import orderItemRoutes from './inventory/orderitems.js'; import shipmentRoutes from './inventory/shipments.js'; import stockEventRoutes from './inventory/stockevents.js'; import stockAuditRoutes from './inventory/stockaudits.js'; +import stockLocationRoutes from './inventory/stocklocations.js'; +import stockTransferRoutes from './inventory/stocktransfers.js'; import auditLogRoutes from './management/auditlogs.js'; import noteTypeRoutes from './management/notetypes.js'; import documentSizesRoutes from './management/documentsizes.js'; @@ -75,6 +77,8 @@ export { shipmentRoutes, stockEventRoutes, stockAuditRoutes, + stockLocationRoutes, + stockTransferRoutes, auditLogRoutes, noteTypeRoutes, noteRoutes, diff --git a/src/routes/inventory/filamentstocks.js b/src/routes/inventory/filamentstocks.js index 7dc22ed..f506241 100644 --- a/src/routes/inventory/filamentstocks.js +++ b/src/routes/inventory/filamentstocks.js @@ -18,7 +18,15 @@ import { // list of filament stocks router.get('/', isAuthenticated, (req, res) => { const { page, limit, property, search, sort, order } = req.query; - const allowedFilters = ['filamentSku', 'state', 'startingWeight', 'currentWeight', 'filamentSku._id']; + const allowedFilters = [ + 'filamentSku', + 'state', + 'startingWeight', + 'currentWeight', + 'filamentSku._id', + 'stockLocation', + 'stockLocation._id', + ]; const filter = getFilter(req.query, allowedFilters); listFilamentStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order); }); diff --git a/src/routes/inventory/partstocks.js b/src/routes/inventory/partstocks.js index 050f0ff..774a090 100644 --- a/src/routes/inventory/partstocks.js +++ b/src/routes/inventory/partstocks.js @@ -18,7 +18,15 @@ import { // list of part stocks router.get('/', isAuthenticated, (req, res) => { const { page, limit, property, search, sort, order } = req.query; - const allowedFilters = ['partSku', 'state', 'startingQuantity', 'currentQuantity', 'partSku._id']; + const allowedFilters = [ + 'partSku', + 'state', + 'startingQuantity', + 'currentQuantity', + 'partSku._id', + 'stockLocation', + 'stockLocation._id', + ]; const filter = getFilter(req.query, allowedFilters); listPartStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order); }); diff --git a/src/routes/inventory/productstocks.js b/src/routes/inventory/productstocks.js index b5fc3bb..bfff9e4 100644 --- a/src/routes/inventory/productstocks.js +++ b/src/routes/inventory/productstocks.js @@ -18,7 +18,14 @@ import { router.get('/', isAuthenticated, (req, res) => { const { page, limit, property, search, sort, order } = req.query; - const allowedFilters = ['productSku', 'state', 'currentQuantity', 'productSku._id']; + const allowedFilters = [ + 'productSku', + 'state', + 'currentQuantity', + 'productSku._id', + 'stockLocation', + 'stockLocation._id', + ]; const filter = getFilter(req.query, allowedFilters); listProductStocksRouteHandler(req, res, page, limit, property, filter, search, sort, order); }); diff --git a/src/routes/inventory/stocklocations.js b/src/routes/inventory/stocklocations.js new file mode 100644 index 0000000..6db748b --- /dev/null +++ b/src/routes/inventory/stocklocations.js @@ -0,0 +1,64 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { getFilter, convertPropertiesString } from '../../utils.js'; + +const router = express.Router(); +import { + listStockLocationsRouteHandler, + getStockLocationRouteHandler, + editStockLocationRouteHandler, + editMultipleStockLocationsRouteHandler, + newStockLocationRouteHandler, + deleteStockLocationRouteHandler, + listStockLocationsByPropertiesRouteHandler, + getStockLocationStatsRouteHandler, + getStockLocationHistoryRouteHandler, +} from '../../services/inventory/stocklocations.js'; + +router.get('/', isAuthenticated, (req, res) => { + const { page, limit, property, search, sort, order } = req.query; + const allowedFilters = ['name', 'notes']; + const filter = getFilter(req.query, allowedFilters); + listStockLocationsRouteHandler(req, res, page, limit, property, filter, search, sort, order); +}); + +router.get('/properties', isAuthenticated, (req, res) => { + let properties = convertPropertiesString(req.query.properties); + const allowedFilters = ['name']; + const filter = getFilter(req.query, allowedFilters, false); + var masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = JSON.parse(req.query.masterFilter); + } + listStockLocationsByPropertiesRouteHandler(req, res, properties, filter, masterFilter); +}); + +router.post('/', isAuthenticated, (req, res) => { + newStockLocationRouteHandler(req, res); +}); + +router.get('/stats', isAuthenticated, (req, res) => { + getStockLocationStatsRouteHandler(req, res); +}); + +router.get('/history', isAuthenticated, (req, res) => { + getStockLocationHistoryRouteHandler(req, res); +}); + +router.get('/:id', isAuthenticated, (req, res) => { + getStockLocationRouteHandler(req, res); +}); + +router.put('/', isAuthenticated, async (req, res) => { + editMultipleStockLocationsRouteHandler(req, res); +}); + +router.put('/:id', isAuthenticated, async (req, res) => { + editStockLocationRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, async (req, res) => { + deleteStockLocationRouteHandler(req, res); +}); + +export default router; diff --git a/src/routes/inventory/stocktransfers.js b/src/routes/inventory/stocktransfers.js new file mode 100644 index 0000000..101f311 --- /dev/null +++ b/src/routes/inventory/stocktransfers.js @@ -0,0 +1,69 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { getFilter, convertPropertiesString } from '../../utils.js'; + +const router = express.Router(); +import { + listStockTransfersRouteHandler, + getStockTransferRouteHandler, + editStockTransferRouteHandler, + editMultipleStockTransfersRouteHandler, + newStockTransferRouteHandler, + deleteStockTransferRouteHandler, + postStockTransferRouteHandler, + listStockTransfersByPropertiesRouteHandler, + getStockTransferStatsRouteHandler, + getStockTransferHistoryRouteHandler, +} from '../../services/inventory/stocktransfers.js'; + +router.get('/', isAuthenticated, (req, res) => { + const { page, limit, property, search, sort, order } = req.query; + const allowedFilters = ['state', 'state.type', 'postedAt']; + const filter = getFilter(req.query, allowedFilters); + listStockTransfersRouteHandler(req, res, page, limit, property, filter, search, sort, order); +}); + +router.get('/properties', isAuthenticated, (req, res) => { + let properties = convertPropertiesString(req.query.properties); + const allowedFilters = ['state.type']; + const filter = getFilter(req.query, allowedFilters, false); + var masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = JSON.parse(req.query.masterFilter); + } + listStockTransfersByPropertiesRouteHandler(req, res, properties, filter, masterFilter); +}); + +router.post('/', isAuthenticated, (req, res) => { + newStockTransferRouteHandler(req, res); +}); + +router.get('/stats', isAuthenticated, (req, res) => { + getStockTransferStatsRouteHandler(req, res); +}); + +router.get('/history', isAuthenticated, (req, res) => { + getStockTransferHistoryRouteHandler(req, res); +}); + +router.get('/:id', isAuthenticated, (req, res) => { + getStockTransferRouteHandler(req, res); +}); + +router.put('/', isAuthenticated, async (req, res) => { + editMultipleStockTransfersRouteHandler(req, res); +}); + +router.put('/:id', isAuthenticated, async (req, res) => { + editStockTransferRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, async (req, res) => { + deleteStockTransferRouteHandler(req, res); +}); + +router.post('/:id/post', isAuthenticated, async (req, res) => { + postStockTransferRouteHandler(req, res); +}); + +export default router; diff --git a/src/services/inventory/filamentstocks.js b/src/services/inventory/filamentstocks.js index 5bd73f0..f96ba10 100644 --- a/src/services/inventory/filamentstocks.js +++ b/src/services/inventory/filamentstocks.js @@ -36,7 +36,7 @@ export const listFilamentStocksRouteHandler = async ( search, sort, order, - populate: [{ path: 'filamentSku' }], + populate: [{ path: 'filamentSku' }, { path: 'stockLocation' }], }); if (result?.error) { @@ -60,7 +60,7 @@ export const listFilamentStocksByPropertiesRouteHandler = async ( model: filamentStockModel, properties, filter, - populate: ['filamentSku'], + populate: ['filamentSku', 'stockLocation'], masterFilter, }); @@ -79,7 +79,7 @@ export const getFilamentStockRouteHandler = async (req, res) => { const result = await getObject({ model: filamentStockModel, id, - populate: [{ path: 'filamentSku' }], + populate: [{ path: 'filamentSku' }, { path: 'stockLocation' }], }); if (result?.error) { logger.warn(`Filament Stock not found with supplied id.`); @@ -95,8 +95,9 @@ export const editFilamentStockRouteHandler = async (req, res) => { logger.trace(`Filament Stock with ID: ${id}`); - const updateData = {}; - // Create audit log before updating + const updateData = { + stockLocation: req.body.stockLocation, + }; const result = await editObject({ model: filamentStockModel, id, @@ -148,6 +149,7 @@ export const newFilamentStockRouteHandler = async (req, res) => { currentWeight: req.body.currentWeight, filamentSku: req.body.filamentSku, state: req.body.state, + stockLocation: req.body.stockLocation, }; const result = await newObject({ model: filamentStockModel, diff --git a/src/services/inventory/partstocks.js b/src/services/inventory/partstocks.js index 61d1fb1..1062153 100644 --- a/src/services/inventory/partstocks.js +++ b/src/services/inventory/partstocks.js @@ -36,7 +36,7 @@ export const listPartStocksRouteHandler = async ( search, sort, order, - populate: [{ path: 'partSku' }], + populate: [{ path: 'partSku' }, { path: 'stockLocation' }, { path: 'source' }], }); if (result?.error) { @@ -60,7 +60,7 @@ export const listPartStocksByPropertiesRouteHandler = async ( model: partStockModel, properties, filter, - populate: ['partSku'], + populate: ['partSku', 'stockLocation', 'source'], masterFilter, }); @@ -79,7 +79,7 @@ export const getPartStockRouteHandler = async (req, res) => { const result = await getObject({ model: partStockModel, id, - populate: [{ path: 'partSku' }], + populate: [{ path: 'partSku' }, { path: 'stockLocation' }, { path: 'source' }], }); if (result?.error) { logger.warn(`Part Stock not found with supplied id.`); @@ -96,7 +96,6 @@ export const editPartStockRouteHandler = async (req, res) => { logger.trace(`Part Stock with ID: ${id}`); const updateData = {}; - // Create audit log before updating const result = await editObject({ model: partStockModel, id, @@ -148,6 +147,9 @@ export const newPartStockRouteHandler = async (req, res) => { currentQuantity: req.body.currentQuantity, partSku: req.body.partSku, state: req.body.state, + sourceType: req.body.sourceType, + source: req.body.source, + stockLocation: req.body.stockLocation, }; const result = await newObject({ model: partStockModel, diff --git a/src/services/inventory/productstocks.js b/src/services/inventory/productstocks.js index 9a0d0d5..6a2f8cd 100644 --- a/src/services/inventory/productstocks.js +++ b/src/services/inventory/productstocks.js @@ -38,7 +38,11 @@ export const listProductStocksRouteHandler = async ( search, sort, order, - populate: [{ path: 'productSku' }, { path: 'partStocks.partStock' }], + populate: [ + { path: 'productSku' }, + { path: 'partStocks.partStock' }, + { path: 'stockLocation' }, + ], }); if (result?.error) { @@ -62,7 +66,7 @@ export const listProductStocksByPropertiesRouteHandler = async ( model: productStockModel, properties, filter, - populate: ['productSku', 'partStocks.partStock'], + populate: ['productSku', 'partStocks.partStock', 'stockLocation'], masterFilter, }); @@ -81,7 +85,12 @@ export const getProductStockRouteHandler = async (req, res) => { const result = await getObject({ model: productStockModel, id, - populate: [{ path: 'partStocks.partSku' }, { path: 'partStocks.partStock' }, { path: 'productSku' }], + populate: [ + { path: 'partStocks.partSku' }, + { path: 'partStocks.partStock' }, + { path: 'productSku' }, + { path: 'stockLocation' }, + ], }); if (result?.error) { logger.warn(`Product Stock not found with supplied id.`); @@ -111,6 +120,7 @@ export const editProductStockRouteHandler = async (req, res) => { } const updateData = { + stockLocation: req.body?.stockLocation, partStocks: req.body?.partStocks?.map((partStock) => ({ quantity: partStock.quantity, partStock: partStock.partStock, @@ -173,6 +183,7 @@ export const newProductStockRouteHandler = async (req, res) => { currentQuantity: req.body.currentQuantity, productSku: req.body.productSku, state: req.body.state ?? { type: 'draft' }, + stockLocation: req.body.stockLocation, partStocks: (productSku.parts || []).map((part) => ({ partSku: part.partSku, quantity: part.quantity, diff --git a/src/services/inventory/stocklocations.js b/src/services/inventory/stocklocations.js new file mode 100644 index 0000000..e46f8aa --- /dev/null +++ b/src/services/inventory/stocklocations.js @@ -0,0 +1,196 @@ +import config from '../../config.js'; +import { stockLocationModel } from '../../database/schemas/inventory/stocklocation.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { + deleteObject, + listObjects, + getObject, + editObject, + editObjects, + newObject, + listObjectsByProperties, + getModelStats, + getModelHistory, +} from '../../database/database.js'; + +const logger = log4js.getLogger('Stock Locations'); +logger.level = config.server.logLevel; + +export const listStockLocationsRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: stockLocationModel, + page, + limit, + property, + filter, + search, + sort, + order, + }); + + if (result?.error) { + logger.error('Error listing stock locations.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of stock locations (Page ${page}, Limit ${limit}). Count: ${result.length}`); + res.send(result); +}; + +export const listStockLocationsByPropertiesRouteHandler = async ( + req, + res, + properties = '', + filter = {}, + masterFilter = {} +) => { + const result = await listObjectsByProperties({ + model: stockLocationModel, + properties, + filter, + masterFilter, + }); + + if (result?.error) { + logger.error('Error listing stock locations.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of stock locations. Count: ${result.length}`); + res.send(result); +}; + +export const getStockLocationRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: stockLocationModel, + id, + }); + if (result?.error) { + logger.warn(`Stock location not found with supplied id.`); + return res.status(result.code).send(result); + } + logger.debug(`Retrieved stock location with ID: ${id}`); + res.send(result); +}; + +export const editStockLocationRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + const updateData = { + name: req.body.name, + notes: req.body.notes, + }; + + const result = await editObject({ + model: stockLocationModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error editing stock location:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Edited stock location with ID: ${id}`); + res.send(result); +}; + +export const editMultipleStockLocationsRouteHandler = async (req, res) => { + const updates = req.body.map((update) => ({ + _id: update._id, + })); + + if (!Array.isArray(updates)) { + return res.status(400).send({ error: 'Body must be an array of updates.', code: 400 }); + } + + const result = await editObjects({ + model: stockLocationModel, + updates, + user: req.user, + }); + + if (result.error) { + logger.error('Error editing stock locations:', result.error); + res.status(result.code || 500).send(result); + return; + } + + logger.debug(`Edited ${updates.length} stock locations`); + res.send(result); +}; + +export const newStockLocationRouteHandler = async (req, res) => { + const newData = { + name: req.body.name, + notes: req.body.notes, + }; + const result = await newObject({ + model: stockLocationModel, + newData, + user: req.user, + }); + if (result.error) { + logger.error('No stock location created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New stock location with ID: ${result._id}`); + res.send(result); +}; + +export const deleteStockLocationRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + const result = await deleteObject({ + model: stockLocationModel, + id, + user: req.user, + }); + if (result.error) { + logger.error('No stock location deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted stock location with ID: ${result._id}`); + res.send(result); +}; + +export const getStockLocationStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: stockLocationModel }); + if (result?.error) { + logger.error('Error fetching stock location stats:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Stock location stats:', result); + res.send(result); +}; + +export const getStockLocationHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: stockLocationModel, from, to }); + if (result?.error) { + logger.error('Error fetching stock location history:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Stock location history:', result); + res.send(result); +}; diff --git a/src/services/inventory/stocktransfers.js b/src/services/inventory/stocktransfers.js new file mode 100644 index 0000000..5704197 --- /dev/null +++ b/src/services/inventory/stocktransfers.js @@ -0,0 +1,461 @@ +import config from '../../config.js'; +import { stockTransferModel } from '../../database/schemas/inventory/stocktransfer.schema.js'; +import { stockLocationModel } from '../../database/schemas/inventory/stocklocation.schema.js'; +import { filamentStockModel } from '../../database/schemas/inventory/filamentstock.schema.js'; +import { partStockModel } from '../../database/schemas/inventory/partstock.schema.js'; +import { productStockModel } from '../../database/schemas/inventory/productstock.schema.js'; +import { stockEventModel } from '../../database/schemas/inventory/stockevent.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { + deleteObject, + listObjects, + getObject, + editObject, + editObjects, + newObject, + listObjectsByProperties, + getModelStats, + getModelHistory, + checkStates, +} from '../../database/database.js'; + +const logger = log4js.getLogger('Stock Transfers'); +logger.level = config.server.logLevel; + +const normalizeLineInput = (l) => ({ + fromStockType: l.fromStockType, + fromStock: l.fromStock?._id ?? l.fromStock, + quantity: Number(l.quantity), + toStockLocation: l.toStockLocation?._id ?? l.toStockLocation, +}); + +async function createStockEventsForLine({ transferId, fromId, fromType, toId, toType, qty, unit }) { + const ts = new Date(); + await stockEventModel.insertMany([ + { + value: -Math.abs(qty), + unit, + parent: fromId, + parentType: fromType, + owner: transferId, + ownerType: 'stockTransfer', + timestamp: ts, + }, + { + value: Math.abs(qty), + unit, + parent: toId, + parentType: toType, + owner: transferId, + ownerType: 'stockTransfer', + timestamp: ts, + }, + ]); +} + +async function executePostedLine(transferId, line) { + const toLocId = line.toStockLocation; + const loc = await stockLocationModel.findById(toLocId).lean(); + if (!loc) { + throw new Error(`Unknown stock location: ${toLocId}`); + } + + if (!(line.quantity > 0)) { + throw new Error('Line quantity must be positive'); + } + + if (line.fromStockType === 'filamentStock') { + const src = await filamentStockModel.findById(line.fromStock); + if (!src) throw new Error('From filament stock not found'); + const netAvail = src.currentWeight?.net ?? 0; + if (line.quantity > netAvail) { + throw new Error('Filament transfer quantity exceeds available net weight'); + } + const tareBefore = Math.max(0, (src.currentWeight?.gross ?? 0) - (src.currentWeight?.net ?? 0)); + const newNet = netAvail - line.quantity; + const ratio = netAvail > 0 ? newNet / netAvail : 0; + const newGross = (src.currentWeight?.gross ?? 0) * ratio; + await filamentStockModel.findByIdAndUpdate(src._id, { + $set: { 'currentWeight.net': newNet, 'currentWeight.gross': newGross }, + }); + + const destWeight = { + net: line.quantity, + gross: line.quantity + tareBefore, + }; + const dest = await filamentStockModel.create({ + state: src.state, + startingWeight: destWeight, + currentWeight: destWeight, + filamentSku: src.filamentSku, + stockLocation: toLocId, + }); + + await createStockEventsForLine({ + transferId, + fromId: src._id, + fromType: 'filamentStock', + toId: dest._id, + toType: 'filamentStock', + qty: line.quantity, + unit: 'g', + }); + + return { toStockType: 'filamentStock', toStock: dest._id }; + } + + if (line.fromStockType === 'partStock') { + const src = await partStockModel.findById(line.fromStock); + console.log(src); + if (!src) throw new Error('From part stock not found'); + const currentQuantity = src.state.type === 'new' ? src.startingQuantity : src.currentQuantity; + if (line.quantity > currentQuantity) { + throw new Error('Part transfer quantity exceeds current quantity'); + } + await partStockModel.findByIdAndUpdate(src._id, { + $inc: { currentQuantity: -line.quantity }, + }); + + const dest = await partStockModel.create({ + partSku: src.partSku, + currentQuantity: line.quantity, + state: { type: 'new' }, + sourceType: 'stockTransfer', + source: transferId, + stockLocation: toLocId, + }); + + await createStockEventsForLine({ + transferId, + fromId: src._id, + fromType: 'partStock', + toId: dest._id, + toType: 'partStock', + qty: line.quantity, + unit: 'each', + }); + + return { toStockType: 'partStock', toStock: dest._id }; + } + + if (line.fromStockType === 'productStock') { + const src = await productStockModel.findById(line.fromStock); + if (!src) throw new Error('From product stock not found'); + if (line.quantity > src.currentQuantity) { + throw new Error('Product transfer quantity exceeds current quantity'); + } + await productStockModel.findByIdAndUpdate(src._id, { + $inc: { currentQuantity: -line.quantity }, + }); + + const dest = await productStockModel.create({ + productSku: src.productSku, + currentQuantity: line.quantity, + state: { type: 'posted' }, + postedAt: new Date(), + partStocks: [], + stockLocation: toLocId, + }); + + await createStockEventsForLine({ + transferId, + fromId: src._id, + fromType: 'productStock', + toId: dest._id, + toType: 'productStock', + qty: line.quantity, + unit: 'each', + }); + + return { toStockType: 'productStock', toStock: dest._id }; + } + + throw new Error(`Unsupported from stock type: ${line.fromStockType}`); +} + +export const listStockTransfersRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: stockTransferModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: [ + { path: 'lines.fromStock' }, + { path: 'lines.toStockLocation' }, + { path: 'lines.toStock' }, + ], + }); + + if (result?.error) { + logger.error('Error listing stock transfers.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of stock transfers (Page ${page}, Limit ${limit}). Count: ${result.length}`); + res.send(result); +}; + +export const listStockTransfersByPropertiesRouteHandler = async ( + req, + res, + properties = '', + filter = {}, + masterFilter = {} +) => { + const result = await listObjectsByProperties({ + model: stockTransferModel, + properties, + filter, + populate: [ + { path: 'lines.fromStock' }, + { path: 'lines.toStockLocation' }, + { path: 'lines.toStock' }, + ], + masterFilter, + }); + + if (result?.error) { + logger.error('Error listing stock transfers.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of stock transfers. Count: ${result.length}`); + res.send(result); +}; + +export const getStockTransferRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: stockTransferModel, + id, + populate: [ + { path: 'lines.fromStock' }, + { path: 'lines.toStockLocation' }, + { path: 'lines.toStock' }, + ], + }); + if (result?.error) { + logger.warn(`Stock transfer not found with supplied id.`); + return res.status(result.code).send(result); + } + logger.debug(`Retrieved stock transfer with ID: ${id}`); + res.send(result); +}; + +export const editStockTransferRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + const checkStatesResult = await checkStates({ model: stockTransferModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking stock transfer state:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Stock transfer is not in draft state.'); + res.status(400).send({ error: 'Stock transfer is not in draft state.', code: 400 }); + return; + } + + const updateData = { + lines: (req.body.lines || []).map((l) => normalizeLineInput(l)), + }; + + const result = await editObject({ + model: stockTransferModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error editing stock transfer:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Edited stock transfer with ID: ${id}`); + res.send(result); +}; + +export const editMultipleStockTransfersRouteHandler = async (req, res) => { + const updates = req.body.map((update) => ({ + _id: update._id, + })); + + if (!Array.isArray(updates)) { + return res.status(400).send({ error: 'Body must be an array of updates.', code: 400 }); + } + + const result = await editObjects({ + model: stockTransferModel, + updates, + user: req.user, + }); + + if (result.error) { + logger.error('Error editing stock transfers:', result.error); + res.status(result.code || 500).send(result); + return; + } + + logger.debug(`Edited ${updates.length} stock transfers`); + res.send(result); +}; + +export const newStockTransferRouteHandler = async (req, res) => { + const newData = { + state: req.body.state ?? { type: 'draft' }, + lines: (req.body.lines || []).map((l) => normalizeLineInput(l)), + }; + const result = await newObject({ + model: stockTransferModel, + newData, + user: req.user, + }); + if (result.error) { + logger.error('No stock transfer created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New stock transfer with ID: ${result._id}`); + res.send(result); +}; + +export const deleteStockTransferRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + const checkStatesResult = await checkStates({ model: stockTransferModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking stock transfer state:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Stock transfer is not in draft state.'); + res.status(400).send({ error: 'Stock transfer is not in draft state.', code: 400 }); + return; + } + + const result = await deleteObject({ + model: stockTransferModel, + id, + user: req.user, + }); + if (result.error) { + logger.error('No stock transfer deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted stock transfer with ID: ${result._id}`); + res.send(result); +}; + +export const postStockTransferRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + const checkStatesResult = await checkStates({ model: stockTransferModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking stock transfer state:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Stock transfer is not in draft state.'); + res.status(400).send({ error: 'Stock transfer is not in draft state.', code: 400 }); + return; + } + + const doc = await stockTransferModel.findById(id); + if (!doc) { + return res.status(404).send({ error: 'Stock transfer not found.', code: 404 }); + } + + if (!doc.lines?.length) { + return res.status(400).send({ error: 'Stock transfer has no lines.', code: 400 }); + } + + const updatedLines = []; + + try { + for (const line of doc.lines) { + const plain = line.toObject(); + const { toStockType, toStock } = await executePostedLine(doc._id, plain); + updatedLines.push({ + ...plain, + toStockType, + toStock, + }); + } + + const posted = await stockTransferModel + .findByIdAndUpdate( + id, + { + $set: { + state: { type: 'posted' }, + postedAt: new Date(), + lines: updatedLines, + }, + }, + { new: true } + ) + .populate([ + { path: 'lines.fromStock' }, + { path: 'lines.toStockLocation' }, + { path: 'lines.toStock' }, + ]) + .lean(); + + logger.debug(`Posted stock transfer with ID: ${id}`); + res.send(posted); + } catch (err) { + logger.error('Error posting stock transfer:', err); + res.status(400).send({ error: err.message || 'Failed to post stock transfer', code: 400 }); + } +}; + +export const getStockTransferStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: stockTransferModel }); + if (result?.error) { + logger.error('Error fetching stock transfer stats:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Stock transfer stats:', result); + res.send(result); +}; + +export const getStockTransferHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: stockTransferModel, from, to }); + if (result?.error) { + logger.error('Error fetching stock transfer history:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Stock transfer history:', result); + res.send(result); +}; diff --git a/src/services/misc/export.js b/src/services/misc/export.js index 0440ae5..5c147e9 100644 --- a/src/services/misc/export.js +++ b/src/services/misc/export.js @@ -23,6 +23,8 @@ export const EXPORT_FILTER_BY_TYPE = { orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'sku._id', 'shipment._id'], shipment: ['order._id', 'orderType', 'courierService._id'], stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'], + stockLocation: ['name'], + stockTransfer: ['state.type', 'postedAt'], stockAudit: ['filamentStock._id', 'partStock._id'], documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'], documentTemplate: ['parent._id', 'documentSize._id'],