From bce40ce30d5798723f613b8cf98aadb6d818c7bb Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Tue, 1 Sep 2026 15:41:32 +0100 Subject: [PATCH] Add stock audit level management functionality and integrate with existing inventory system This commit introduces a new `stockAuditLevel` schema and corresponding routes for managing stock audit levels, enhancing the inventory management capabilities. It includes CRUD operations for stock audit levels, allowing for the creation, retrieval, updating, and deletion of audit levels. The integration with existing stock audit functionalities is established, enabling the association of audit levels with stock audits. Additionally, utility functions for handling stock audit levels are implemented, improving the overall structure and maintainability of the inventory system. --- .../schemas/inventory/filamentstock.schema.js | 20 + .../schemas/inventory/partstock.schema.js | 19 + .../schemas/inventory/productstock.schema.js | 14 +- .../schemas/inventory/stockaudit.schema.js | 309 ++++++++- .../management/stockauditlevel.schema.js | 108 +++ src/database/schemas/models.js | 2 + src/index.js | 2 + src/routes/index.js | 2 + src/routes/inventory/stockaudits.js | 109 ++- src/routes/management/stockauditlevels.js | 131 ++++ .../inventory/__tests__/stockaudits.test.js | 222 +++++- src/services/inventory/stockaudits.js | 634 ++++++++++++++---- .../__tests__/stockauditlevels.test.js | 130 ++++ src/services/management/stockauditlevels.js | 241 +++++++ src/services/misc/export.js | 3 +- 15 files changed, 1728 insertions(+), 218 deletions(-) create mode 100644 src/database/schemas/management/stockauditlevel.schema.js create mode 100644 src/routes/management/stockauditlevels.js create mode 100644 src/services/management/__tests__/stockauditlevels.test.js create mode 100644 src/services/management/stockauditlevels.js 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'],