diff --git a/src/database/schemas/inventory/filamentstock.schema.js b/src/database/schemas/inventory/filamentstock.schema.js index 21ba3f4..1c42010 100644 --- a/src/database/schemas/inventory/filamentstock.schema.js +++ b/src/database/schemas/inventory/filamentstock.schema.js @@ -2,6 +2,13 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; const { Schema } = mongoose; import { aggregateRollups, aggregateRollupsHistory } from '../../database.js'; +import { updateDraftStockAuditCurrents } from './stockaudit.schema.js'; + +const toId = (value) => { + if (value == null) return null; + if (typeof value === 'object' && value._id) return String(value._id); + return String(value); +}; // Define the main filamentStock schema const filamentStockSchema = new Schema( @@ -96,6 +103,19 @@ filamentStockSchema.statics.history = async function (from, to) { return results; }; +filamentStockSchema.statics.recalculate = async function (filamentStock, user) { + const itemSkuId = toId(filamentStock?.filamentSku); + const stockLocationId = toId(filamentStock?.stockLocation); + if (!itemSkuId || !stockLocationId) return; + + await updateDraftStockAuditCurrents({ + itemType: 'filament', + itemSkuId, + stockLocationId, + user, + }); +}; + // Add virtual id getter filamentStockSchema.virtual('id').get(function () { return this._id; diff --git a/src/database/schemas/inventory/partstock.schema.js b/src/database/schemas/inventory/partstock.schema.js index dab4fef..7e32b10 100644 --- a/src/database/schemas/inventory/partstock.schema.js +++ b/src/database/schemas/inventory/partstock.schema.js @@ -1,6 +1,13 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js'; +import { updateDraftStockAuditCurrents } from './stockaudit.schema.js'; + +const toId = (value) => { + if (value == null) return null; + if (typeof value === 'object' && value._id) return String(value._id); + return String(value); +}; // Define the main partStock schema const partStockSchema = new mongoose.Schema( @@ -89,6 +96,18 @@ partStockSchema.statics.history = async function (from, to) { partStockSchema.statics.recalculate = async function (partStock, user) { if (!partStock?._id) return; + + const itemSkuId = toId(partStock.partSku); + const stockLocationId = toId(partStock.stockLocation); + if (itemSkuId && stockLocationId) { + await updateDraftStockAuditCurrents({ + itemType: 'part', + itemSkuId, + stockLocationId, + user, + }); + } + if (partStock.state?.type === 'draft' || partStock.state?.type === 'consumed') return; if ((Number(partStock.currentQuantity) || 0) > 0) return; diff --git a/src/database/schemas/inventory/productstock.schema.js b/src/database/schemas/inventory/productstock.schema.js index aa21f56..e3fead2 100644 --- a/src/database/schemas/inventory/productstock.schema.js +++ b/src/database/schemas/inventory/productstock.schema.js @@ -2,6 +2,7 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; const { Schema } = mongoose; import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js'; +import { updateDraftStockAuditCurrents } from './stockaudit.schema.js'; const partStockListItemSchema = new Schema({ part: { type: Schema.Types.ObjectId, ref: 'part', required: true }, @@ -122,6 +123,17 @@ productStockSchema.statics.history = async function (from, to) { }; productStockSchema.statics.recalculate = async function (productStock, user) { + const productSkuId = toId(productStock?.productSku); + const stockLocationId = toId(productStock?.stockLocation); + if (productSkuId && stockLocationId) { + await updateDraftStockAuditCurrents({ + itemType: 'product', + itemSkuId: productSkuId, + stockLocationId, + user, + }); + } + if ( productStock?._id && productStock.state?.type !== 'draft' && @@ -139,8 +151,6 @@ productStockSchema.statics.recalculate = async function (productStock, user) { }); } - const productSkuId = toId(productStock?.productSku); - const stockLocationId = toId(productStock?.stockLocation); if (!productSkuId || !stockLocationId) { return; } diff --git a/src/database/schemas/inventory/stockaudit.schema.js b/src/database/schemas/inventory/stockaudit.schema.js index 24cba7b..753833e 100644 --- a/src/database/schemas/inventory/stockaudit.schema.js +++ b/src/database/schemas/inventory/stockaudit.schema.js @@ -1,42 +1,311 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +import { editObject, getObject } from '../../database.js'; +import { stockAuditLevelModel, normalizeAuditLevelLine } from '../management/stockauditlevel.schema.js'; +import { filamentModel } from '../management/filament.schema.js'; +import { filamentSkuModel } from '../management/filamentsku.schema.js'; +import { partModel } from '../management/part.schema.js'; +import { partSkuModel } from '../management/partsku.schema.js'; +import { productModel } from '../management/product.schema.js'; +import { productSkuModel } from '../management/productsku.schema.js'; + const { Schema } = mongoose; -const stockAuditItemSchema = new Schema({ - type: { type: String, enum: ['filament', 'part'], required: true }, - stock: { type: Schema.Types.ObjectId, required: true }, - expectedQuantity: { type: Number, required: true }, - actualQuantity: { type: Number, required: true }, - notes: { type: String }, -}); +const itemModelsByType = { + filament: filamentModel, + part: partModel, + product: productModel, +}; + +const skuModelsByType = { + filament: filamentSkuModel, + part: partSkuModel, + product: productSkuModel, +}; + +const parentFieldByType = { + filament: 'filament', + part: 'part', + product: 'product', +}; + +const toId = (value) => { + if (value == null) return null; + if (typeof value === 'object' && value._id != null) return String(value._id); + return String(value); +}; + +const stockAuditLineSchema = new Schema( + { + itemType: { + type: String, + enum: ['filament', 'part', 'product'], + required: true, + }, + item: { type: Schema.Types.ObjectId, refPath: 'auditLines.itemType', required: true }, + itemSku: { + type: Schema.Types.ObjectId, + ref: function () { + return ['filament', 'part', 'product'].includes(this.itemType) + ? this.itemType + 'Sku' + : null; + }, + required: true, + }, + current: { type: Number, required: true, default: 0 }, + actual: { type: Number, required: true, default: 0 }, + new: { type: Number, required: true, default: 0 }, + }, + { _id: true } +); const stockAuditSchema = new Schema( { _reference: { type: String, default: () => generateId()() }, - type: { type: String, required: true }, - status: { - type: String, - enum: ['pending', 'in_progress', 'completed', 'cancelled'], - default: 'pending', + state: { + type: { type: String, required: true, default: 'draft' }, + progress: { type: Number, required: false }, + }, + auditLevel: { + type: Schema.Types.ObjectId, + ref: 'stockAuditLevel', required: true, }, - notes: { type: String }, - items: [stockAuditItemSchema], - createdBy: { type: Schema.Types.ObjectId, ref: 'user', required: true }, - completedAt: { type: Date }, + stockLocation: { + type: Schema.Types.ObjectId, + ref: 'stockLocation', + required: true, + }, + postedAt: { type: Date, required: false }, + auditLines: { type: [stockAuditLineSchema], default: [] }, }, { timestamps: true } ); -stockAuditSchema.index({ type: 'text', status: 'text', notes: 'text' }); +stockAuditSchema.index({ 'state.type': 'text' }); + +stockAuditSchema.statics.stats = async function () { + const [draft, complete] = await Promise.all([ + this.countDocuments({ 'state.type': 'draft' }), + this.countDocuments({ 'state.type': 'complete' }), + ]); + return { + draft: { count: draft }, + complete: { count: complete }, + }; +}; + +stockAuditSchema.statics.history = async function () { + return []; +}; + +async function fetchItemsForLevelLine(levelLine) { + const itemType = levelLine.itemType; + const itemModel = itemModelsByType[itemType]; + if (!itemModel) return []; + + if (levelLine.allItems) { + return itemModel.find().select('_id').lean(); + } + + const itemId = toId(levelLine.item); + if (!itemId) return []; + return [{ _id: itemId }]; +} + +async function fetchSkusForLevelLine(levelLine, itemId) { + const itemType = levelLine.itemType; + const skuModel = skuModelsByType[itemType]; + const parentField = parentFieldByType[itemType]; + if (!skuModel || !parentField) return []; + + if (levelLine.allSkus) { + return skuModel.find({ [parentField]: itemId }).select('_id').lean(); + } + + const skuId = toId(levelLine.itemSku); + if (!skuId) return []; + return [{ _id: skuId }]; +} + +export async function getCurrentQuantityAtLocation(itemType, itemSkuId, stockLocationId) { + const locationId = toId(stockLocationId); + const skuId = toId(itemSkuId); + if (!locationId || !skuId) return 0; + + if (itemType === 'filament') { + const stocks = await mongoose + .model('filamentStock') + .find({ filamentSku: skuId, stockLocation: locationId }) + .select('currentWeight.net') + .lean(); + return stocks.reduce((sum, stock) => sum + (Number(stock.currentWeight?.net) || 0), 0); + } + + if (itemType === 'part') { + const stocks = await mongoose + .model('partStock') + .find({ partSku: skuId, stockLocation: locationId }) + .select('currentQuantity') + .lean(); + return stocks.reduce((sum, stock) => sum + (Number(stock.currentQuantity) || 0), 0); + } + + if (itemType === 'product') { + const stocks = await mongoose + .model('productStock') + .find({ productSku: skuId, stockLocation: locationId }) + .select('currentQuantity') + .lean(); + return stocks.reduce((sum, stock) => sum + (Number(stock.currentQuantity) || 0), 0); + } + + return 0; +} + +function buildAuditLineQuantities(current, actual) { + const currentVal = Number(current) || 0; + const actualVal = Number(actual) || 0; + return { + current: currentVal, + actual: actualVal, + new: actualVal, + }; +} + +function getExistingActual(line) { + if (line?.actual != null) return Number(line.actual); + if (line?.actualQuantity != null) return Number(line.actualQuantity); + return null; +} + +function buildAuditLineKey(itemType, itemId, itemSkuId) { + return `${itemType}:${toId(itemId)}:${toId(itemSkuId)}`; +} + +async function expandLevelLinesToAuditLines(levelLines, stockLocationId, existingLines = []) { + const existingByKey = new Map(); + for (const line of existingLines) { + existingByKey.set( + buildAuditLineKey(line.itemType, line.item, line.itemSku), + line + ); + } + + const auditLines = []; + + for (const levelLine of levelLines || []) { + const items = await fetchItemsForLevelLine(levelLine); + for (const item of items) { + const itemId = toId(item._id); + const skus = await fetchSkusForLevelLine(levelLine, itemId); + for (const sku of skus) { + const itemSkuId = toId(sku._id); + const current = await getCurrentQuantityAtLocation( + levelLine.itemType, + itemSkuId, + stockLocationId + ); + const key = buildAuditLineKey(levelLine.itemType, itemId, itemSkuId); + const existing = existingByKey.get(key); + const existingActual = getExistingActual(existing); + const actual = existingActual != null ? existingActual : current; + + auditLines.push({ + itemType: levelLine.itemType, + item: itemId, + itemSku: itemSkuId, + ...buildAuditLineQuantities(current, actual), + }); + } + } + } + + return auditLines; +} + +stockAuditSchema.statics.recalculate = async function (stockAudit, user) { + if (stockAudit?.state?.type !== 'draft') return; + + const auditLevelId = toId(stockAudit.auditLevel?._id ?? stockAudit.auditLevel); + const stockLocationId = toId(stockAudit.stockLocation?._id ?? stockAudit.stockLocation); + if (!auditLevelId || !stockLocationId) return; + + const auditLevel = await getObject({ + model: stockAuditLevelModel, + id: auditLevelId, + populate: [ + { path: 'auditLines.item' }, + { path: 'auditLines.itemSku' }, + ], + }); + + if (!auditLevel || auditLevel.error) return; + + const auditLines = await expandLevelLinesToAuditLines( + (auditLevel.auditLines || []).map((line) => normalizeAuditLevelLine(line)), + stockLocationId, + stockAudit.auditLines + ); + + await editObject({ + model: this, + id: stockAudit._id, + updateData: { auditLines }, + user, + recalculate: false, + }); +}; -// Add virtual id getter stockAuditSchema.virtual('id').get(function () { return this._id; }); -// Configure JSON serialization to include virtuals stockAuditSchema.set('toJSON', { virtuals: true }); -// Create and export the model export const stockAuditModel = mongoose.model('stockAudit', stockAuditSchema); + +export async function updateDraftStockAuditCurrents({ + itemType, + itemSkuId, + stockLocationId, + user, +}) { + const skuId = toId(itemSkuId); + const locationId = toId(stockLocationId); + if (!itemType || !skuId || !locationId) return; + + const current = await getCurrentQuantityAtLocation(itemType, skuId, locationId); + + const draftAudits = await stockAuditModel + .find({ + 'state.type': 'draft', + stockLocation: locationId, + }) + .lean(); + + for (const audit of draftAudits) { + let changed = false; + const auditLines = (audit.auditLines || []).map((line) => { + if (line.itemType !== itemType || toId(line.itemSku) !== skuId) { + return line; + } + const lineCurrent = Number(line.current) || 0; + if (lineCurrent === current) { + return line; + } + changed = true; + return { ...line, current }; + }); + + if (!changed) continue; + + await editObject({ + model: stockAuditModel, + id: audit._id, + updateData: { auditLines }, + user, + recalculate: false, + }); + } +} diff --git a/src/database/schemas/management/stockauditlevel.schema.js b/src/database/schemas/management/stockauditlevel.schema.js new file mode 100644 index 0000000..5fe3c06 --- /dev/null +++ b/src/database/schemas/management/stockauditlevel.schema.js @@ -0,0 +1,108 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +import { editObject } from '../../database.js'; + +const { Schema } = mongoose; + +const toId = (value) => { + if (value == null) return null; + if (typeof value === 'object' && value._id != null) return String(value._id); + return String(value); +}; + +export function normalizeAuditLevelLine(line) { + const allItems = Boolean(line?.allItems); + const allSkus = allItems ? true : Boolean(line?.allSkus); + const item = allItems ? null : line?.item?._id ?? line?.item ?? null; + const itemSku = allItems || allSkus ? null : line?.itemSku?._id ?? line?.itemSku ?? null; + + return { + _id: line?._id, + itemType: line?.itemType, + allItems, + allSkus, + item, + itemSku, + }; +} + +function auditLevelLineChanged(before, after) { + return ( + Boolean(before?.allItems) !== Boolean(after.allItems) || + Boolean(before?.allSkus) !== Boolean(after.allSkus) || + toId(before?.item) !== toId(after.item) || + toId(before?.itemSku) !== toId(after.itemSku) + ); +} + +const stockAuditLevelLineSchema = new Schema( + { + itemType: { + type: String, + enum: ['filament', 'part', 'product'], + required: true, + }, + allItems: { type: Boolean, default: false }, + item: { type: Schema.Types.ObjectId, refPath: 'auditLines.itemType', required: false }, + allSkus: { type: Boolean, default: false }, + itemSku: { + type: Schema.Types.ObjectId, + ref: function () { + return ['filament', 'part', 'product'].includes(this.itemType) + ? this.itemType + 'Sku' + : null; + }, + required: false, + }, + }, + { _id: true } +); + +const stockAuditLevelSchema = new Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { type: String, required: true }, + tags: [{ type: String }], + auditLines: { type: [stockAuditLevelLineSchema], default: [] }, + }, + { timestamps: true } +); + +stockAuditLevelSchema.index({ name: 'text', tags: 'text' }); + +stockAuditLevelSchema.statics.stats = async function () { + const count = await this.countDocuments(); + return { total: { count } }; +}; + +stockAuditLevelSchema.statics.history = async function () { + return []; +}; + +stockAuditLevelSchema.statics.recalculate = async function (stockAuditLevel, user) { + if (!stockAuditLevel?._id) return; + + const auditLines = stockAuditLevel.auditLines || []; + const normalizedLines = auditLines.map((line) => normalizeAuditLevelLine(line)); + const changed = auditLines.some((line, index) => + auditLevelLineChanged(line, normalizedLines[index]) + ); + + if (!changed) return; + + await editObject({ + model: this, + id: stockAuditLevel._id, + updateData: { auditLines: normalizedLines }, + user, + recalculate: false, + }); +}; + +stockAuditLevelSchema.virtual('id').get(function () { + return this._id; +}); + +stockAuditLevelSchema.set('toJSON', { virtuals: true }); + +export const stockAuditLevelModel = mongoose.model('stockAuditLevel', stockAuditLevelSchema); diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index 138a752..2cb8d25 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -18,6 +18,7 @@ import { purchaseOrderModel } from './inventory/purchaseorder.schema.js'; import { orderItemModel } from './inventory/orderitem.schema.js'; import { stockEventModel } from './inventory/stockevent.schema.js'; import { stockAuditModel } from './inventory/stockaudit.schema.js'; +import { stockAuditLevelModel } from './management/stockauditlevel.schema.js'; import { partStockModel } from './inventory/partstock.schema.js'; import { productStockModel } from './inventory/productstock.schema.js'; import { stockLocationModel } from './inventory/stocklocation.schema.js'; @@ -88,6 +89,7 @@ export const models = { FLS: modelEntry(() => filamentStockModel, 'filamentStock', 'Filament Stock'), SEV: modelEntry(() => stockEventModel, 'stockEvent', 'Stock Event'), SAU: modelEntry(() => stockAuditModel, 'stockAudit', 'Stock Audit'), + SAL: modelEntry(() => stockAuditLevelModel, 'stockAuditLevel', 'Stock Audit Level'), PTS: modelEntry(() => partStockModel, 'partStock', 'Part Stock'), PDS: modelEntry(() => productStockModel, 'productStock', 'Product Stock'), SLN: modelEntry(() => stockLocationModel, 'stockLocation', 'Stock Location'), diff --git a/src/index.js b/src/index.js index ac94754..9b1ba9d 100644 --- a/src/index.js +++ b/src/index.js @@ -34,6 +34,7 @@ import { orderItemRoutes, shipmentRoutes, stockAuditRoutes, + stockAuditLevelRoutes, stockLocationRoutes, stockTransferRoutes, stockEventRoutes, @@ -204,6 +205,7 @@ app.use('/orderitems', orderItemRoutes); app.use('/shipments', shipmentRoutes); app.use('/stockevents', stockEventRoutes); app.use('/stockaudits', stockAuditRoutes); +app.use('/stockauditlevels', stockAuditLevelRoutes); app.use('/stocklocations', stockLocationRoutes); app.use('/stocktransfers', stockTransferRoutes); app.use('/auditlogs', auditLogRoutes); diff --git a/src/routes/index.js b/src/routes/index.js index 283161c..0dff857 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -29,6 +29,7 @@ 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 stockAuditLevelRoutes from './management/stockauditlevels.js'; import stockLocationRoutes from './inventory/stocklocations.js'; import stockTransferRoutes from './inventory/stocktransfers.js'; import auditLogRoutes from './management/auditlogs.js'; @@ -95,6 +96,7 @@ export { shipmentRoutes, stockEventRoutes, stockAuditRoutes, + stockAuditLevelRoutes, stockLocationRoutes, stockTransferRoutes, auditLogRoutes, diff --git a/src/routes/inventory/stockaudits.js b/src/routes/inventory/stockaudits.js index 44b7052..f15aa06 100644 --- a/src/routes/inventory/stockaudits.js +++ b/src/routes/inventory/stockaudits.js @@ -1,9 +1,23 @@ import express from 'express'; import { isAuthenticated } from '../../keycloak.js'; import { checkPermissions } from '../../database/permissions.js'; -import { parseFilter, getFilter, getSort } from '../../utils.js'; +import { getFilter, convertPropertiesString, getSort } from '../../utils.js'; const router = express.Router(); + +const listAllowedFilters = [ + 'state', + 'state.type', + 'auditLevel', + 'stockLocation', + 'postedAt', + 'createdAt', + 'updatedAt', + '_reference', +]; +const listAllowedSorters = ['createdAt', 'updatedAt', 'postedAt', 'state']; +const propertiesAllowedFilters = ['state.type']; + import { listStockAuditsRouteHandler, getStockAuditRouteHandler, @@ -13,54 +27,66 @@ import { getStockAuditStatsRouteHandler, getStockAuditHistoryRouteHandler, searchStockAuditsRouteHandler, + listStockAuditsByPropertiesRouteHandler, + getStockAuditPropertyValuesRouteHandler, getStockAuditNeighborsRouteHandler, + postStockAuditRouteHandler, } from '../../services/inventory/stockaudits.js'; -const listAllowedFilters = [ - 'status', - 'type', - 'createdBy', - 'state', - 'createdAt', - 'updatedAt', - '_reference', -]; -const listAllowedSorters = ['createdAt', 'updatedAt', 'state']; - -// List stock audits router.get('/', isAuthenticated, checkPermissions('stockAudit', 'list'), async (req, res) => { - const { page, limit, property } = req.query; + const { page, limit, property, search, sortProperty, sortOrder } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + listStockAuditsRouteHandler( + req, + res, + page, + limit, + property, + filter, + search, + getSort(sortProperty, listAllowedSorters), + sortOrder + ); +}); - var filter = {}; - - for (const [key, value] of Object.entries(req.query)) { - for (var i = 0; i < listAllowedFilters.length; i++) { - if (key == listAllowedFilters[i]) { - const parsedFilter = await parseFilter(key, value); - filter = { ...filter, ...parsedFilter }; - } +router.get( + '/properties', + checkPermissions('stockAudit', 'list'), + isAuthenticated, + async (req, res) => { + let properties = convertPropertiesString(req.query.properties); + const filter = await getFilter(req.query, propertiesAllowedFilters, false); + var masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = JSON.parse(req.query.masterFilter); } + listStockAuditsByPropertiesRouteHandler(req, res, properties, filter, masterFilter); } +); - listStockAuditsRouteHandler(req, res, page, limit, property, filter); +router.get('/values', checkPermissions('stockAudit', 'list'), isAuthenticated, async (req, res) => { + const { property } = req.query; + const filter = await getFilter(req.query, listAllowedFilters, true); + var masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = await getFilter(JSON.parse(req.query.masterFilter), listAllowedFilters, true); + } + getStockAuditPropertyValuesRouteHandler(req, res, property, filter, masterFilter); }); -// Create new stock audit -router.post('/', isAuthenticated, checkPermissions('stockAudit', 'new'), async (req, res) => { - newStockAuditRouteHandler(req, res); -}); - -// get stock audit stats router.get('/search', checkPermissions('stockAudit', 'list'), isAuthenticated, async (req, res) => { const { search } = req.query; searchStockAuditsRouteHandler(req, res, search); }); +router.post('/', isAuthenticated, checkPermissions('stockAudit', 'new'), async (req, res) => { + newStockAuditRouteHandler(req, res); +}); + router.get('/stats', isAuthenticated, async (req, res) => { getStockAuditStatsRouteHandler(req, res); }); -// get stock audit history router.get('/history', isAuthenticated, async (req, res) => { getStockAuditHistoryRouteHandler(req, res); }); @@ -68,22 +94,37 @@ router.get('/history', isAuthenticated, async (req, res) => { router.get('/neighbors', isAuthenticated, async (req, res) => { const { property, search, sortProperty, sortOrder, id } = req.query; const filter = await getFilter(req.query, listAllowedFilters); - getStockAuditNeighborsRouteHandler(req, res, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder, id); + getStockAuditNeighborsRouteHandler( + req, + res, + property, + filter, + search, + getSort(sortProperty, listAllowedSorters), + sortOrder, + id + ); }); -// Get specific stock audit router.get('/:id', isAuthenticated, checkPermissions('stockAudit', 'info'), async (req, res) => { getStockAuditRouteHandler(req, res); }); -// Update stock audit router.put('/:id', isAuthenticated, checkPermissions('stockAudit', 'edit'), async (req, res) => { updateStockAuditRouteHandler(req, res); }); -// Delete stock audit router.delete('/:id', isAuthenticated, async (req, res) => { deleteStockAuditRouteHandler(req, res); }); +router.post( + '/:id/post', + isAuthenticated, + checkPermissions('stockAudit', 'post'), + async (req, res) => { + postStockAuditRouteHandler(req, res); + } +); + export default router; diff --git a/src/routes/management/stockauditlevels.js b/src/routes/management/stockauditlevels.js new file mode 100644 index 0000000..241ddd1 --- /dev/null +++ b/src/routes/management/stockauditlevels.js @@ -0,0 +1,131 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { checkPermissions } from '../../database/permissions.js'; +import { getFilter, convertPropertiesString, getSort } from '../../utils.js'; + +const router = express.Router(); + +const listAllowedFilters = ['name', 'createdAt', 'updatedAt', '_reference']; +const listAllowedSorters = ['name', 'createdAt', '_id', 'updatedAt']; +const propertiesAllowedFilters = ['tags']; + +import { + listStockAuditLevelsRouteHandler, + getStockAuditLevelRouteHandler, + editStockAuditLevelRouteHandler, + newStockAuditLevelRouteHandler, + deleteStockAuditLevelRouteHandler, + listStockAuditLevelsByPropertiesRouteHandler, + getStockAuditLevelStatsRouteHandler, + getStockAuditLevelHistoryRouteHandler, + searchStockAuditLevelsRouteHandler, + getStockAuditLevelPropertyValuesRouteHandler, + getStockAuditLevelNeighborsRouteHandler, +} from '../../services/management/stockauditlevels.js'; + +router.get('/', isAuthenticated, checkPermissions('stockAuditLevel', 'list'), async (req, res) => { + const { page, limit, property, search, sortProperty, sortOrder } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + listStockAuditLevelsRouteHandler( + req, + res, + page, + limit, + property, + filter, + search, + getSort(sortProperty, listAllowedSorters), + sortOrder + ); +}); + +router.get( + '/properties', + checkPermissions('stockAuditLevel', 'list'), + isAuthenticated, + async (req, res) => { + let properties = convertPropertiesString(req.query.properties); + const filter = await getFilter(req.query, propertiesAllowedFilters, false); + var masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = JSON.parse(req.query.masterFilter); + } + listStockAuditLevelsByPropertiesRouteHandler(req, res, properties, filter, masterFilter); + } +); + +router.get( + '/values', + checkPermissions('stockAuditLevel', 'list'), + isAuthenticated, + async (req, res) => { + const { property } = req.query; + const filter = await getFilter(req.query, listAllowedFilters, true); + var masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = await getFilter(JSON.parse(req.query.masterFilter), listAllowedFilters, true); + } + getStockAuditLevelPropertyValuesRouteHandler(req, res, property, filter, masterFilter); + } +); + +router.get( + '/search', + checkPermissions('stockAuditLevel', 'list'), + isAuthenticated, + async (req, res) => { + const { search } = req.query; + searchStockAuditLevelsRouteHandler(req, res, search); + } +); + +router.post('/', isAuthenticated, checkPermissions('stockAuditLevel', 'new'), async (req, res) => { + newStockAuditLevelRouteHandler(req, res); +}); + +router.get('/stats', isAuthenticated, async (req, res) => { + getStockAuditLevelStatsRouteHandler(req, res); +}); + +router.get('/history', isAuthenticated, async (req, res) => { + getStockAuditLevelHistoryRouteHandler(req, res); +}); + +router.get('/neighbors', isAuthenticated, async (req, res) => { + const { property, search, sortProperty, sortOrder, id } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + getStockAuditLevelNeighborsRouteHandler( + req, + res, + property, + filter, + search, + getSort(sortProperty, listAllowedSorters), + sortOrder, + id + ); +}); + +router.get( + '/:id', + isAuthenticated, + checkPermissions('stockAuditLevel', 'info'), + async (req, res) => { + getStockAuditLevelRouteHandler(req, res); + } +); + +router.put( + '/:id', + isAuthenticated, + checkPermissions('stockAuditLevel', 'edit'), + async (req, res) => { + editStockAuditLevelRouteHandler(req, res); + } +); + +router.delete('/:id', isAuthenticated, async (req, res) => { + deleteStockAuditLevelRouteHandler(req, res); +}); + +export default router; diff --git a/src/services/inventory/__tests__/stockaudits.test.js b/src/services/inventory/__tests__/stockaudits.test.js index 602c6d3..9981007 100644 --- a/src/services/inventory/__tests__/stockaudits.test.js +++ b/src/services/inventory/__tests__/stockaudits.test.js @@ -1,24 +1,49 @@ import { jest } from '@jest/globals'; -jest.unstable_mockModule('../../../utils.js', () => ({ - getAuditLogs: jest.fn(), -})); - jest.unstable_mockModule('../../../database/database.js', () => ({ searchObjects: jest.fn(), getPropertyValues: jest.fn(), + listObjects: jest.fn(), + getObject: jest.fn(), + editObject: jest.fn(), + newObject: jest.fn(), + deleteObject: jest.fn(), + listObjectsByProperties: jest.fn(), getModelStats: jest.fn(), getModelHistory: jest.fn(), getObjectNeighbors: jest.fn(), + checkStates: jest.fn(), + deleteObjectCache: jest.fn(), })); jest.unstable_mockModule('../../../database/schemas/inventory/stockaudit.schema.js', () => ({ stockAuditModel: { modelName: 'StockAudit', - aggregate: jest.fn(), - findOne: jest.fn(), - create: jest.fn(), + findById: jest.fn(), }, + getCurrentQuantityAtLocation: jest.fn(), +})); + +jest.unstable_mockModule('../../../database/schemas/inventory/filamentstock.schema.js', () => ({ + filamentStockModel: { + find: jest.fn(), + }, +})); + +jest.unstable_mockModule('../../../database/schemas/inventory/partstock.schema.js', () => ({ + partStockModel: { + find: jest.fn(), + }, +})); + +jest.unstable_mockModule('../../../database/schemas/inventory/productstock.schema.js', () => ({ + productStockModel: { + find: jest.fn(), + }, +})); + +jest.unstable_mockModule('../../../database/schemas/inventory/stockevent.schema.js', () => ({ + stockEventModel: { modelName: 'StockEvent' }, })); jest.unstable_mockModule('log4js', () => ({ @@ -37,10 +62,18 @@ const { listStockAuditsRouteHandler, getStockAuditRouteHandler, newStockAuditRouteHandler, + postStockAuditRouteHandler, } = await import('../stockaudits.js'); -const { getAuditLogs } = await import('../../../utils.js'); -const { stockAuditModel } = await import('../../../database/schemas/inventory/stockaudit.schema.js'); +const { listObjects, getObject, newObject, editObject, checkStates } = await import( + '../../../database/database.js' +); +const { stockAuditModel, getCurrentQuantityAtLocation } = await import( + '../../../database/schemas/inventory/stockaudit.schema.js' +); +const { partStockModel } = await import( + '../../../database/schemas/inventory/partstock.schema.js' +); describe('Stock Audit Service Route Handlers', () => { let req, res; @@ -61,34 +94,173 @@ describe('Stock Audit Service Route Handlers', () => { describe('listStockAuditsRouteHandler', () => { it('should list stock audits', async () => { - const mockResult = [{ _id: '1', type: 'full' }]; - stockAuditModel.aggregate.mockResolvedValue(mockResult); + const mockResult = [{ _id: '1', state: { type: 'draft' } }]; + listObjects.mockResolvedValue(mockResult); await listStockAuditsRouteHandler(req, res); - expect(stockAuditModel.aggregate).toHaveBeenCalled(); + expect(listObjects).toHaveBeenCalledWith( + expect.objectContaining({ model: stockAuditModel }) + ); expect(res.send).toHaveBeenCalledWith(mockResult); }); }); describe('getStockAuditRouteHandler', () => { - it('should get a stock audit by ID with audit logs', async () => { + it('should get a stock audit by ID', async () => { req.params.id = '507f1f77bcf86cd799439011'; - const mockAudit = { _id: '507f1f77bcf86cd799439011', type: 'full', _doc: {} }; - stockAuditModel.findOne.mockReturnValue({ - populate: jest.fn().mockReturnValue({ - populate: jest.fn().mockReturnValue({ - populate: jest.fn().mockResolvedValue(mockAudit), - }), - }), - }); - getAuditLogs.mockResolvedValue([]); + const mockAudit = { _id: '507f1f77bcf86cd799439011', state: { type: 'draft' } }; + getObject.mockResolvedValue(mockAudit); await getStockAuditRouteHandler(req, res); - expect(getAuditLogs).toHaveBeenCalled(); - expect(res.send).toHaveBeenCalled(); + expect(getObject).toHaveBeenCalledWith( + expect.objectContaining({ model: stockAuditModel, id: req.params.id }) + ); + expect(res.send).toHaveBeenCalledWith(mockAudit); + }); + }); + + describe('newStockAuditRouteHandler', () => { + it('should create a stock audit with draft state', async () => { + req.body = { + auditLevel: 'level-1', + stockLocation: 'loc-1', + }; + const mockResult = { _id: 'audit-1', state: { type: 'draft' } }; + newObject.mockResolvedValue(mockResult); + + await newStockAuditRouteHandler(req, res); + + expect(newObject).toHaveBeenCalledWith( + expect.objectContaining({ + model: stockAuditModel, + newData: expect.objectContaining({ + state: { type: 'draft' }, + auditLevel: 'level-1', + stockLocation: 'loc-1', + auditLines: [], + }), + }) + ); + expect(res.send).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('postStockAuditRouteHandler', () => { + const auditId = '507f1f77bcf86cd799439011'; + const stockLocationId = '507f1f77bcf86cd799439012'; + + beforeEach(() => { + req.params.id = auditId; + }); + + it('should reject when stock audit is not in draft state', async () => { + checkStates.mockResolvedValue(false); + + await postStockAuditRouteHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith({ + error: 'Stock audit is not in draft state.', + code: 400, + }); + }); + + it('should reject when live current differs from line current', async () => { + checkStates.mockResolvedValue(true); + stockAuditModel.findById.mockResolvedValue({ + _id: auditId, + stockLocation: stockLocationId, + auditLines: [ + { + toObject: () => ({ + itemType: 'part', + item: 'part-1', + itemSku: 'sku-1', + current: 10, + actual: 8, + }), + }, + ], + }); + getCurrentQuantityAtLocation.mockResolvedValue(12); + + await postStockAuditRouteHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.stringContaining('Current quantity for SKU has changed'), + code: 400, + }) + ); + }); + + it('should create stock events and mark audit complete on success', async () => { + checkStates.mockResolvedValue(true); + stockAuditModel.findById.mockResolvedValue({ + _id: auditId, + stockLocation: stockLocationId, + auditLines: [ + { + toObject: () => ({ + itemType: 'part', + item: 'part-1', + itemSku: 'sku-1', + current: 10, + actual: 8, + }), + }, + ], + }); + getCurrentQuantityAtLocation.mockResolvedValue(10); + + const mockStock = { + _id: 'stock-1', + currentQuantity: 10, + }; + partStockModel.find.mockReturnValue({ + sort: jest.fn().mockResolvedValue([mockStock]), + }); + + const completeAudit = { + _id: auditId, + state: { type: 'complete' }, + }; + editObject.mockResolvedValue(completeAudit); + getObject.mockResolvedValue(completeAudit); + newObject.mockResolvedValue({ _id: 'event-1' }); + + await postStockAuditRouteHandler(req, res); + + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + model: stockAuditModel, + id: expect.anything(), + updateData: { + state: { type: 'complete' }, + postedAt: expect.any(Date), + }, + }) + ); + expect(newObject).toHaveBeenCalledWith( + expect.objectContaining({ + model: expect.anything(), + newData: expect.objectContaining({ + value: -2, + unit: 'each', + parent: 'stock-1', + parentType: 'partStock', + owner: auditId, + ownerType: 'stockAudit', + }), + }) + ); + expect(editObject.mock.invocationCallOrder[0]).toBeLessThan( + newObject.mock.invocationCallOrder[0] + ); + expect(res.send).toHaveBeenCalledWith(completeAudit); }); }); }); - diff --git a/src/services/inventory/stockaudits.js b/src/services/inventory/stockaudits.js index 4eb8d30..b43c38c 100644 --- a/src/services/inventory/stockaudits.js +++ b/src/services/inventory/stockaudits.js @@ -1,183 +1,480 @@ import config from '../../config.js'; -import { stockAuditModel } from '../../database/schemas/inventory/stockaudit.schema.js'; +import { + stockAuditModel, + getCurrentQuantityAtLocation, +} from '../../database/schemas/inventory/stockaudit.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 { getAuditLogs } from '../../utils.js'; import { - getModelStats, getModelHistory, + deleteObject, + listObjects, + getObject, + editObject, + newObject, + listObjectsByProperties, + getModelStats, + getModelHistory, + checkStates, searchObjects, + getPropertyValues, getObjectNeighbors, } from '../../database/database.js'; const logger = log4js.getLogger('Stock Audits'); logger.level = config.server.logLevel; +const STOCK_AUDIT_POPULATE = [ + { path: 'auditLevel' }, + { path: 'stockLocation' }, + { path: 'auditLines.item' }, + { path: 'auditLines.itemSku' }, +]; + +const normalizeAuditLineInput = (line) => { + const current = Number(line.current ?? line.currentQuantity) || 0; + const actual = Number(line.actual ?? line.actualQuantity) || 0; + return { + itemType: line.itemType, + item: line.item?._id ?? line.item, + itemSku: line.itemSku?._id ?? line.itemSku, + current, + actual, + new: actual, + }; +}; + +const toId = (value) => { + if (value == null) return null; + if (typeof value === 'object' && value._id != null) return value._id; + return value; +}; + +const stockConfigByItemType = { + filament: { + stockModel: filamentStockModel, + parentType: 'filamentStock', + skuField: 'filamentSku', + itemField: 'filament', + unit: 'g', + getAvailable: (stock) => Number(stock.currentWeight?.net) || 0, + }, + part: { + stockModel: partStockModel, + parentType: 'partStock', + skuField: 'partSku', + itemField: 'part', + unit: 'each', + getAvailable: (stock) => Number(stock.currentQuantity) || 0, + }, + product: { + stockModel: productStockModel, + parentType: 'productStock', + skuField: 'productSku', + itemField: 'product', + unit: 'each', + getAvailable: (stock) => Number(stock.currentQuantity) || 0, + }, +}; + +async function createStockEvent(newData, user) { + const result = await newObject({ + model: stockEventModel, + newData, + user, + }); + + if (result?.error) { + throw new Error(result.error); + } + + return result; +} + +async function createStock(model, newData, user) { + const result = await newObject({ + model, + newData, + user, + }); + + if (result?.error) { + throw new Error(result.error); + } + + return result; +} + +async function applyPositiveVariance(auditId, line, stockLocationId, variance, config, user) { + const itemId = toId(line.item); + const itemSkuId = toId(line.itemSku); + const ts = new Date(); + + if (line.itemType === 'filament') { + const weight = { net: variance, gross: variance }; + const stock = await createStock( + config.stockModel, + { + state: { type: 'unconsumed' }, + startingWeight: weight, + currentWeight: weight, + filament: itemId, + filamentSku: itemSkuId, + stockLocation: stockLocationId, + }, + user + ); + + await createStockEvent( + { + value: variance, + unit: config.unit, + parent: stock._id, + parentType: config.parentType, + owner: auditId, + ownerType: 'stockAudit', + timestamp: ts, + }, + user + ); + return; + } + + if (line.itemType === 'part') { + const stock = await createStock( + config.stockModel, + { + part: itemId, + partSku: itemSkuId, + currentQuantity: variance, + state: { type: 'new' }, + postedAt: ts, + stockLocation: stockLocationId, + }, + user + ); + + await createStockEvent( + { + value: variance, + unit: config.unit, + parent: stock._id, + parentType: config.parentType, + owner: auditId, + ownerType: 'stockAudit', + timestamp: ts, + }, + user + ); + return; + } + + if (line.itemType === 'product') { + const stock = await createStock( + config.stockModel, + { + product: itemId, + productSku: itemSkuId, + currentQuantity: variance, + state: { type: 'new' }, + postedAt: ts, + partStockList: [], + stockLocation: stockLocationId, + }, + user + ); + + await createStockEvent( + { + value: variance, + unit: config.unit, + parent: stock._id, + parentType: config.parentType, + owner: auditId, + ownerType: 'stockAudit', + timestamp: ts, + }, + user + ); + } +} + +async function applyNegativeVariance(auditId, line, stockLocationId, variance, config, user) { + const itemSkuId = toId(line.itemSku); + const amountToRemove = Math.abs(variance); + const ts = new Date(); + + const stocks = await config.stockModel + .find({ + [config.skuField]: itemSkuId, + stockLocation: stockLocationId, + 'state.type': { $ne: 'draft' }, + }) + .sort({ createdAt: 1 }); + + let remaining = amountToRemove; + for (const stock of stocks) { + if (remaining <= 0) break; + const available = config.getAvailable(stock); + if (available <= 0) continue; + + const deduction = Math.min(remaining, available); + await createStockEvent( + { + value: -deduction, + unit: config.unit, + parent: stock._id, + parentType: config.parentType, + owner: auditId, + ownerType: 'stockAudit', + timestamp: ts, + }, + user + ); + remaining -= deduction; + } + + if (remaining > 0) { + throw new Error( + `Insufficient stock to apply audit variance of ${variance} for SKU ${itemSkuId}` + ); + } +} + +async function executePostedAuditLine(auditId, line, stockLocationId, user) { + const current = Number(line.current) || 0; + const actual = Number(line.actual) || 0; + const variance = actual - current; + if (variance === 0) return; + + const itemSkuId = toId(line.itemSku); + const liveCurrent = await getCurrentQuantityAtLocation( + line.itemType, + itemSkuId, + stockLocationId + ); + + if (liveCurrent !== current) { + throw new Error( + `Current quantity for SKU has changed (expected ${current}, found ${liveCurrent}). Recalculate the audit before posting.` + ); + } + + const config = stockConfigByItemType[line.itemType]; + if (!config) { + throw new Error(`Unsupported item type: ${line.itemType}`); + } + + if (variance > 0) { + await applyPositiveVariance(auditId, line, stockLocationId, variance, config, user); + } else { + await applyNegativeVariance(auditId, line, stockLocationId, variance, config, user); + } +} + export const listStockAuditsRouteHandler = async ( req, res, page = 1, limit = 25, property = '', - filter = {} + filter = {}, + search = '', + sort = '', + order = 'ascend' ) => { - try { - const skip = (page - 1) * limit; - let stockAudits; - let aggregateCommand = []; + const result = await listObjects({ + model: stockAuditModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: STOCK_AUDIT_POPULATE, + }); - // Lookup createdBy user - aggregateCommand.push({ - $lookup: { - from: 'users', - localField: 'createdBy', - foreignField: '_id', - as: 'createdBy', - }, - }); - - aggregateCommand.push({ $unwind: '$createdBy' }); - - if (filter != {}) { - aggregateCommand.push({ $match: filter }); - } - - if (property != '') { - aggregateCommand.push({ $group: { _id: `$${property}` } }); - aggregateCommand.push({ $project: { _id: 0, [property]: '$_id' } }); - } - - aggregateCommand.push({ $skip: skip }); - aggregateCommand.push({ $limit: Number(limit) }); - - stockAudits = await stockAuditModel.aggregate(aggregateCommand); - - logger.trace( - `List of stock audits (Page ${page}, Limit ${limit}, Property ${property}):`, - stockAudits - ); - res.send(stockAudits); - } catch (error) { - logger.error('Error listing stock audits:', error); - res.status(500).send({ error: error }); + if (result?.error) { + logger.error('Error listing stock audits.'); + res.status(result.code).send(result); + return; } + + logger.debug(`List of stock audits (Page ${page}, Limit ${limit}). Count: ${result.length}`); + res.send(result); +}; + +export const listStockAuditsByPropertiesRouteHandler = async ( + req, + res, + properties = '', + filter = {}, + masterFilter = {} +) => { + const result = await listObjectsByProperties({ + model: stockAuditModel, + properties, + filter, + masterFilter, + populate: STOCK_AUDIT_POPULATE, + }); + + if (result?.error) { + logger.error('Error listing stock audits.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of stock audits. Count: ${result.length}`); + res.send(result); +}; + +export const getStockAuditPropertyValuesRouteHandler = async ( + req, + res, + property, + filter, + masterFilter +) => { + const result = await getPropertyValues({ + model: stockAuditModel, + property, + filter: { ...filter, ...masterFilter }, + }); + res.send(result); }; export const searchStockAuditsRouteHandler = async (req, res, search) => { const result = await searchObjects({ model: stockAuditModel, search, + populate: STOCK_AUDIT_POPULATE, }); res.send(result); }; export const getStockAuditRouteHandler = async (req, res) => { - try { - const id = new mongoose.Types.ObjectId(req.params.id); - const stockAudit = await stockAuditModel - .findOne({ - _id: id, - }) - .populate('createdBy') - .populate('items.filamentStock') - .populate('items.partStock'); - - if (!stockAudit) { - logger.warn(`Stock audit not found with supplied id.`); - return res.status(404).send({ error: 'Stock audit not found.' }); - } - - logger.trace(`Stock audit with ID: ${id}:`, stockAudit); - - const auditLogs = await getAuditLogs(id); - - res.send({ ...stockAudit._doc, auditLogs: auditLogs }); - } catch (error) { - logger.error('Error fetching stock audit:', error); - res.status(500).send({ error: error.message }); - } -}; - -export const newStockAuditRouteHandler = async (req, res) => { - try { - const newStockAudit = { - type: req.body.type, - status: req.body.status || 'pending', - notes: req.body.notes, - items: req.body.items.map((item) => ({ - type: item.type, - stock: - item.type === 'filament' - ? new mongoose.Types.ObjectId(item.filamentStock) - : new mongoose.Types.ObjectId(item.partStock), - expectedQuantity: item.expectedQuantity, - actualQuantity: item.actualQuantity, - notes: item.notes, - })), - createdBy: new mongoose.Types.ObjectId(req.body.createdBy), - completedAt: req.body.status === 'completed' ? new Date() : null, - }; - - const result = await stockAuditModel.create(newStockAudit); - if (!result) { - logger.error('No stock audit created.'); - return res.status(500).send({ error: 'No stock audit created.' }); - } - return res.send({ status: 'ok', id: result._id }); - } catch (error) { - logger.error('Error adding stock audit:', error); - return res.status(500).send({ error: error.message }); + const id = req.params.id; + const result = await getObject({ + model: stockAuditModel, + id, + populate: STOCK_AUDIT_POPULATE, + }); + if (result?.error) { + logger.warn(`Stock audit not found with supplied id.`); + return res.status(result.code).send(result); } + logger.debug(`Retrieved stock audit with ID: ${id}`); + res.send(result); }; export const updateStockAuditRouteHandler = async (req, res) => { - try { - const id = new mongoose.Types.ObjectId(req.params.id); - const updateData = { - ...req.body, - items: req.body.items?.map((item) => ({ - type: item.type, - stock: - item.type === 'filament' - ? new mongoose.Types.ObjectId(item.filamentStock) - : new mongoose.Types.ObjectId(item.partStock), - expectedQuantity: item.expectedQuantity, - actualQuantity: item.actualQuantity, - notes: item.notes, - })), - completedAt: req.body.status === 'completed' ? new Date() : null, - }; + const id = new mongoose.Types.ObjectId(req.params.id); - const result = await stockAuditModel.findByIdAndUpdate(id, { $set: updateData }, { new: true }); + const checkStatesResult = await checkStates({ model: stockAuditModel, id, states: ['draft'] }); - if (!result) { - logger.warn(`Stock audit not found with supplied id.`); - return res.status(404).send({ error: 'Stock audit not found.' }); - } - - logger.trace(`Updated stock audit with ID: ${id}:`, result); - res.send(result); - } catch (error) { - logger.error('Error updating stock audit:', error); - res.status(500).send({ error: error.message }); + if (checkStatesResult?.error) { + logger.error('Error checking stock audit state:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; } + + if (checkStatesResult === false) { + logger.error('Stock audit is not in draft state.'); + res.status(400).send({ error: 'Stock audit is not in draft state.', code: 400 }); + return; + } + + const updateData = {}; + + if (req.body.state !== undefined) { + updateData.state = req.body.state; + } + if (req.body.auditLevel !== undefined) { + updateData.auditLevel = req.body.auditLevel?._id ?? req.body.auditLevel; + } + if (req.body.stockLocation !== undefined) { + updateData.stockLocation = req.body.stockLocation?._id ?? req.body.stockLocation; + } + if (req.body.auditLines !== undefined) { + updateData.auditLines = (req.body.auditLines || []).map((line) => normalizeAuditLineInput(line)); + } + + const result = await editObject({ + model: stockAuditModel, + id, + updateData, + user: req.user, + populate: STOCK_AUDIT_POPULATE, + }); + + if (result.error) { + logger.error('Error updating stock audit:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Updated stock audit with ID: ${id}`); + res.send(result); +}; + +export const newStockAuditRouteHandler = async (req, res) => { + const newData = { + state: req.body.state ?? { type: 'draft' }, + auditLevel: req.body.auditLevel?._id ?? req.body.auditLevel, + stockLocation: req.body.stockLocation?._id ?? req.body.stockLocation, + auditLines: (req.body.auditLines || []).map((line) => normalizeAuditLineInput(line)), + }; + + const result = await newObject({ + model: stockAuditModel, + newData, + user: req.user, + }); + + if (result.error) { + logger.error('No stock audit created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New stock audit with ID: ${result._id}`); + res.send(result); }; export const deleteStockAuditRouteHandler = async (req, res) => { - try { - const id = new mongoose.Types.ObjectId(req.params.id); - const result = await stockAuditModel.findByIdAndDelete(id); + const id = new mongoose.Types.ObjectId(req.params.id); - if (!result) { - logger.warn(`Stock audit not found with supplied id.`); - return res.status(404).send({ error: 'Stock audit not found.' }); - } + const checkStatesResult = await checkStates({ model: stockAuditModel, id, states: ['draft'] }); - logger.trace(`Deleted stock audit with ID: ${id}`); - res.send({ status: 'ok' }); - } catch (error) { - logger.error('Error deleting stock audit:', error); - res.status(500).send({ error: error.message }); + if (checkStatesResult?.error) { + logger.error('Error checking stock audit state:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; } + + if (checkStatesResult === false) { + logger.error('Stock audit is not in draft state.'); + res.status(400).send({ error: 'Stock audit is not in draft state.', code: 400 }); + return; + } + + const result = await deleteObject({ + model: stockAuditModel, + id, + user: req.user, + }); + + if (result.error) { + logger.error('No stock audit deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted stock audit with ID: ${result._id}`); + res.send(result); }; export const getStockAuditStatsRouteHandler = async (req, res) => { @@ -233,3 +530,68 @@ export const getStockAuditNeighborsRouteHandler = async ( logger.debug(`Retrieved stock audit neighbors for ID: ${id}`); res.send(result); }; + +export const postStockAuditRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + const checkStatesResult = await checkStates({ model: stockAuditModel, id, states: ['draft'] }); + + if (checkStatesResult?.error) { + logger.error('Error checking stock audit state:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Stock audit is not in draft state.'); + res.status(400).send({ error: 'Stock audit is not in draft state.', code: 400 }); + return; + } + + const doc = await stockAuditModel.findById(id); + if (!doc) { + return res.status(404).send({ error: 'Stock audit not found.', code: 404 }); + } + + if (!doc.auditLines?.length) { + return res.status(400).send({ error: 'Stock audit has no audit lines.', code: 400 }); + } + + const stockLocationId = doc.stockLocation; + + try { + const completeResult = await editObject({ + model: stockAuditModel, + id, + updateData: { + state: { type: 'complete' }, + postedAt: new Date(), + }, + user: req.user, + }); + + if (completeResult?.error) { + throw new Error(completeResult.error); + } + + for (const line of doc.auditLines) { + await executePostedAuditLine(doc._id, line.toObject(), stockLocationId, req.user); + } + + const complete = await getObject({ + model: stockAuditModel, + id, + populate: STOCK_AUDIT_POPULATE, + }); + + if (complete?.error) { + throw new Error(complete.error); + } + + logger.debug(`Posted stock audit with ID: ${id}`); + res.send(complete); + } catch (err) { + logger.error('Error posting stock audit:', err); + res.status(400).send({ error: err.message || 'Failed to post stock audit', code: 400 }); + } +}; diff --git a/src/services/management/__tests__/stockauditlevels.test.js b/src/services/management/__tests__/stockauditlevels.test.js new file mode 100644 index 0000000..dedf72e --- /dev/null +++ b/src/services/management/__tests__/stockauditlevels.test.js @@ -0,0 +1,130 @@ +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('../../../database/database.js', () => ({ + searchObjects: jest.fn(), + getPropertyValues: jest.fn(), + listObjects: jest.fn(), + getObject: jest.fn(), + editObject: jest.fn(), + newObject: jest.fn(), + deleteObject: jest.fn(), + listObjectsByProperties: jest.fn(), + getModelStats: jest.fn(), + getModelHistory: jest.fn(), + getObjectNeighbors: jest.fn(), + deleteObjectCache: jest.fn(), +})); + +jest.unstable_mockModule('../../../database/schemas/management/stockauditlevel.schema.js', () => ({ + stockAuditLevelModel: { modelName: 'StockAuditLevel' }, +})); + +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), + }), + }, +})); + +const { + listStockAuditLevelsRouteHandler, + getStockAuditLevelRouteHandler, + newStockAuditLevelRouteHandler, + editStockAuditLevelRouteHandler, +} = await import('../stockauditlevels.js'); + +const { listObjects, getObject, editObject, newObject } = await import( + '../../../database/database.js' +); +const { stockAuditLevelModel } = await import( + '../../../database/schemas/management/stockauditlevel.schema.js' +); + +describe('Stock Audit Level Service Route Handlers', () => { + let req, res; + + beforeEach(() => { + req = { + params: {}, + query: {}, + body: {}, + user: { id: 'test-user-id' }, + }; + res = { + send: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + jest.clearAllMocks(); + }); + + describe('listStockAuditLevelsRouteHandler', () => { + it('should list stock audit levels', async () => { + const mockResult = [{ _id: '1', name: 'Full audit' }]; + listObjects.mockResolvedValue(mockResult); + + await listStockAuditLevelsRouteHandler(req, res); + + expect(listObjects).toHaveBeenCalledWith( + expect.objectContaining({ model: stockAuditLevelModel }) + ); + expect(res.send).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('getStockAuditLevelRouteHandler', () => { + it('should get a stock audit level', async () => { + req.params.id = 'level-1'; + const mockResult = { _id: 'level-1', name: 'Full audit' }; + getObject.mockResolvedValue(mockResult); + + await getStockAuditLevelRouteHandler(req, res); + + expect(getObject).toHaveBeenCalledWith( + expect.objectContaining({ model: stockAuditLevelModel, id: 'level-1' }) + ); + expect(res.send).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('newStockAuditLevelRouteHandler', () => { + it('should create a stock audit level', async () => { + req.body = { name: 'Full audit', auditLines: [] }; + const mockResult = { _id: 'level-1', name: 'Full audit' }; + newObject.mockResolvedValue(mockResult); + + await newStockAuditLevelRouteHandler(req, res); + + expect(newObject).toHaveBeenCalledWith( + expect.objectContaining({ + model: stockAuditLevelModel, + newData: expect.objectContaining({ name: 'Full audit', auditLines: [] }), + }) + ); + expect(res.send).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('editStockAuditLevelRouteHandler', () => { + it('should edit a stock audit level', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + req.body = { name: 'Updated audit', auditLines: [] }; + const mockResult = { _id: req.params.id, name: 'Updated audit' }; + editObject.mockResolvedValue(mockResult); + + await editStockAuditLevelRouteHandler(req, res); + + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + model: stockAuditLevelModel, + updateData: expect.objectContaining({ name: 'Updated audit' }), + }) + ); + expect(res.send).toHaveBeenCalledWith(mockResult); + }); + }); +}); diff --git a/src/services/management/stockauditlevels.js b/src/services/management/stockauditlevels.js new file mode 100644 index 0000000..a4f6b3b --- /dev/null +++ b/src/services/management/stockauditlevels.js @@ -0,0 +1,241 @@ +import config from '../../config.js'; +import { stockAuditLevelModel } from '../../database/schemas/management/stockauditlevel.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { + deleteObject, + listObjects, + getObject, + editObject, + newObject, + listObjectsByProperties, + getModelStats, + getModelHistory, + searchObjects, + getPropertyValues, + getObjectNeighbors, +} from '../../database/database.js'; + +const logger = log4js.getLogger('Stock Audit Levels'); +logger.level = config.server.logLevel; + +const STOCK_AUDIT_LEVEL_POPULATE = [{ path: 'auditLines.item' }, { path: 'auditLines.itemSku' }]; + +export const listStockAuditLevelsRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: stockAuditLevelModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: STOCK_AUDIT_LEVEL_POPULATE, + }); + + if (result?.error) { + logger.error('Error listing stock audit levels.'); + res.status(result.code).send(result); + return; + } + + logger.debug( + `List of stock audit levels (Page ${page}, Limit ${limit}). Count: ${result.length}.` + ); + res.send(result); +}; + +export const listStockAuditLevelsByPropertiesRouteHandler = async ( + req, + res, + properties = '', + filter = {}, + masterFilter = {} +) => { + const result = await listObjectsByProperties({ + model: stockAuditLevelModel, + properties, + filter, + masterFilter, + populate: STOCK_AUDIT_LEVEL_POPULATE, + }); + + if (result?.error) { + logger.error('Error listing stock audit levels.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of stock audit levels. Count: ${result.length}`); + res.send(result); +}; + +export const getStockAuditLevelPropertyValuesRouteHandler = async ( + req, + res, + property, + filter, + masterFilter +) => { + const result = await getPropertyValues({ + model: stockAuditLevelModel, + property, + filter: { ...filter, ...masterFilter }, + }); + res.send(result); +}; + +export const searchStockAuditLevelsRouteHandler = async (req, res, search) => { + const result = await searchObjects({ + model: stockAuditLevelModel, + search, + populate: STOCK_AUDIT_LEVEL_POPULATE, + }); + res.send(result); +}; + +export const getStockAuditLevelRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: stockAuditLevelModel, + id, + populate: STOCK_AUDIT_LEVEL_POPULATE, + }); + if (result?.error) { + logger.warn(`Stock audit level not found with supplied id.`); + return res.status(result.code).send(result); + } + logger.debug(`Retrieved stock audit level with ID: ${id}`); + res.send(result); +}; + +export const editStockAuditLevelRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + const updateData = { + name: req.body.name, + tags: req.body.tags, + auditLines: req.body.auditLines, + }; + + const result = await editObject({ + model: stockAuditLevelModel, + id, + updateData, + user: req.user, + populate: STOCK_AUDIT_LEVEL_POPULATE, + }); + + if (result.error) { + logger.error('Error editing stock audit level:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Edited stock audit level with ID: ${id}`); + res.send(result); +}; + +export const newStockAuditLevelRouteHandler = async (req, res) => { + const newData = { + name: req.body.name, + tags: req.body.tags, + }; + + const result = await newObject({ + model: stockAuditLevelModel, + newData, + user: req.user, + }); + + if (result.error) { + logger.error('No stock audit level created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New stock audit level with ID: ${result._id}`); + res.send(result); +}; + +export const deleteStockAuditLevelRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + const result = await deleteObject({ + model: stockAuditLevelModel, + id, + user: req.user, + }); + + if (result.error) { + logger.error('No stock audit level deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted stock audit level with ID: ${result._id}`); + res.send(result); +}; + +export const getStockAuditLevelStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: stockAuditLevelModel }); + if (result?.error) { + logger.error('Error fetching stock audit level stats:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Stock audit level stats:', result); + res.send(result); +}; + +export const getStockAuditLevelHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: stockAuditLevelModel, from, to }); + if (result?.error) { + logger.error('Error fetching stock audit level history:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Stock audit level history:', result); + res.send(result); +}; + +export const getStockAuditLevelNeighborsRouteHandler = async ( + req, + res, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend', + id +) => { + if (!id) { + return res.status(400).send({ error: 'Missing id parameter', code: 400 }); + } + + const result = await getObjectNeighbors({ + model: stockAuditLevelModel, + id, + filter, + search, + sort, + order, + }); + + if (result?.error) { + logger.error('Error fetching stock audit level neighbors.'); + return res.status(result.code).send(result); + } + + logger.debug(`Retrieved stock audit level neighbors for ID: ${id}`); + res.send(result); +}; diff --git a/src/services/misc/export.js b/src/services/misc/export.js index 7f2c66e..60cb2d5 100644 --- a/src/services/misc/export.js +++ b/src/services/misc/export.js @@ -36,7 +36,8 @@ export const EXPORT_FILTER_BY_TYPE = { stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'], stockLocation: ['name', 'address'], stockTransfer: ['name', 'state.type', 'postedAt'], - stockAudit: ['filamentStock._id', 'partStock._id'], + stockAudit: ['auditLevel._id', 'stockLocation._id', 'state.type', 'postedAt'], + stockAuditLevel: ['name', 'tags'], documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'], documentTemplate: ['parent._id', 'documentSize._id'], salesOrder: ['client'],