From edb8282c417897851522de4c69ea0fd4a38c7d3e Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Tue, 1 Sep 2026 17:26:10 +0100 Subject: [PATCH] Enhance filament stock management by adding default state and posting functionality This commit updates the filament stock schema to set a default state of 'draft' and introduces a new route handler for posting filament stocks. The posting process includes validation to ensure stocks are in the draft state before transitioning to 'unconsumed', along with the creation of an initial stock event. Additionally, the service layer is updated to handle these changes, and tests are added to verify the new functionality. --- .../schemas/inventory/filamentstock.schema.js | 10 +- src/routes/inventory/filamentstocks.js | 5 + .../__tests__/filamentstocks.test.js | 75 ++++++++- src/services/inventory/filamentstocks.js | 149 ++++++++++++++---- src/services/misc/spotlight.js | 9 ++ 5 files changed, 210 insertions(+), 38 deletions(-) diff --git a/src/database/schemas/inventory/filamentstock.schema.js b/src/database/schemas/inventory/filamentstock.schema.js index 1c42010..4033d94 100644 --- a/src/database/schemas/inventory/filamentstock.schema.js +++ b/src/database/schemas/inventory/filamentstock.schema.js @@ -15,9 +15,10 @@ const filamentStockSchema = new Schema( { _reference: { type: String, default: () => generateId()() }, state: { - type: { type: String, required: true }, + type: { type: String, required: true, default: 'draft' }, progress: { type: Number, required: false }, }, + postedAt: { type: Date, required: false }, startingWeight: { net: { type: Number, required: true }, gross: { type: Number, required: true }, @@ -65,6 +66,11 @@ const rollupConfigs = [ filter: {}, rollups: [{ name: 'totalCurrentWeight', property: 'currentWeight.net', operation: 'sum' }], }, + { + name: 'draft', + filter: { 'state.type': 'draft' }, + rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }], + }, { name: 'unconsumed', filter: { 'state.type': 'unconsumed' }, @@ -114,6 +120,8 @@ filamentStockSchema.statics.recalculate = async function (filamentStock, user) { stockLocationId, user, }); + + if (filamentStock.state?.type === 'draft' || filamentStock.state?.type === 'consumed') return; }; // Add virtual id getter diff --git a/src/routes/inventory/filamentstocks.js b/src/routes/inventory/filamentstocks.js index f369cc0..21aca5f 100644 --- a/src/routes/inventory/filamentstocks.js +++ b/src/routes/inventory/filamentstocks.js @@ -34,6 +34,7 @@ import { editMultipleFilamentStocksRouteHandler, newFilamentStockRouteHandler, deleteFilamentStockRouteHandler, + postFilamentStockRouteHandler, listFilamentStocksByPropertiesRouteHandler, getFilamentStockStatsRouteHandler, getFilamentStockHistoryRouteHandler, @@ -121,4 +122,8 @@ router.delete('/:id', isAuthenticated, async (req, res) => { deleteFilamentStockRouteHandler(req, res); }); +router.post('/:id/post', isAuthenticated, checkPermissions('filamentStock', 'post'), async (req, res) => { + postFilamentStockRouteHandler(req, res); +}); + export default router; diff --git a/src/services/inventory/__tests__/filamentstocks.test.js b/src/services/inventory/__tests__/filamentstocks.test.js index ea8012b..3fdedf3 100644 --- a/src/services/inventory/__tests__/filamentstocks.test.js +++ b/src/services/inventory/__tests__/filamentstocks.test.js @@ -6,6 +6,7 @@ jest.unstable_mockModule('../../../database/database.js', () => ({ listObjects: jest.fn(), getObject: jest.fn(), editObject: jest.fn(), + editObject: jest.fn(), editObjects: jest.fn(), newObject: jest.fn(), deleteObject: jest.fn(), @@ -13,6 +14,7 @@ jest.unstable_mockModule('../../../database/database.js', () => ({ getModelStats: jest.fn(), getModelHistory: jest.fn(), getObjectNeighbors: jest.fn(), + checkStates: jest.fn(), aggregateRollups: jest.fn(), aggregateRollupsHistory: jest.fn(), })); @@ -41,9 +43,12 @@ const { listFilamentStocksRouteHandler, getFilamentStockRouteHandler, newFilamentStockRouteHandler, + postFilamentStockRouteHandler, } = await import('../filamentstocks.js'); -const { listObjects, getObject, newObject } = await import('../../../database/database.js'); +const { listObjects, getObject, newObject, checkStates, editObject } = await import( + '../../../database/database.js' +); const { filamentStockModel } = await import( '../../../database/schemas/inventory/filamentstock.schema.js' ); @@ -80,21 +85,79 @@ describe('Filament Stock Service Route Handlers', () => { }); describe('newFilamentStockRouteHandler', () => { - it('should create a new filament stock', async () => { + it('should create a new draft filament stock without a stock event', async () => { req.body = { filament: 'filament123', startingWeight: { net: 1000, gross: 1100 }, currentWeight: { net: 1000, gross: 1100 }, }; - const mockStock = { _id: '456', ...req.body }; - const mockStockEvent = { _id: '789' }; - newObject.mockResolvedValueOnce(mockStock).mockResolvedValueOnce(mockStockEvent); + const mockStock = { _id: '456', ...req.body, state: { type: 'draft' } }; + newObject.mockResolvedValueOnce(mockStock); await newFilamentStockRouteHandler(req, res); - expect(newObject).toHaveBeenCalledTimes(2); + expect(newObject).toHaveBeenCalledTimes(1); + expect(newObject).toHaveBeenCalledWith( + expect.objectContaining({ + newData: expect.objectContaining({ state: { type: 'draft' } }), + }) + ); expect(res.send).toHaveBeenCalledWith(mockStock); }); }); + + describe('postFilamentStockRouteHandler', () => { + it('should post a draft filament stock and create an initial stock event', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + req.user = { _id: 'test-user-id' }; + checkStates.mockResolvedValue(true); + getObject.mockResolvedValue({ + _id: '507f1f77bcf86cd799439011', + startingWeight: { net: 1000, gross: 1100 }, + }); + newObject.mockResolvedValue({ _id: '789' }); + editObject.mockResolvedValue({ + _id: '507f1f77bcf86cd799439011', + state: { type: 'unconsumed' }, + postedAt: expect.any(Date), + }); + + await postFilamentStockRouteHandler(req, res); + + expect(checkStates).toHaveBeenCalledWith( + expect.objectContaining({ states: ['draft'] }) + ); + expect(newObject).toHaveBeenCalledWith( + expect.objectContaining({ + newData: expect.objectContaining({ + value: 1000, + unit: 'g', + parentType: 'filamentStock', + }), + recalculate: true, + }) + ); + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ + state: { type: 'unconsumed' }, + }), + }) + ); + expect(res.send).toHaveBeenCalled(); + }); + + it('should fail if filament stock is not in draft state', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + checkStates.mockResolvedValue(false); + + await postFilamentStockRouteHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Filament stock is not in draft state.' }) + ); + }); + }); }); diff --git a/src/services/inventory/filamentstocks.js b/src/services/inventory/filamentstocks.js index 162eacd..06593f4 100644 --- a/src/services/inventory/filamentstocks.js +++ b/src/services/inventory/filamentstocks.js @@ -12,6 +12,7 @@ import { listObjectsByProperties, getModelStats, getModelHistory, + checkStates, searchObjects, getPropertyValues, getObjectNeighbors, @@ -20,6 +21,12 @@ import { stockEventModel } from '../../database/schemas/inventory/stockevent.sch const logger = log4js.getLogger('Filament Stocks'); logger.level = config.server.logLevel; +const FILAMENT_STOCK_POPULATE = [ + { path: 'filament' }, + { path: 'filamentSku', populate: 'filament' }, + { path: 'stockLocation' }, +]; + export const listFilamentStocksRouteHandler = async ( req, res, @@ -130,19 +137,33 @@ export const editFilamentStockRouteHandler = async (req, res) => { logger.trace(`Filament Stock with ID: ${id}`); + const checkStatesResult = await checkStates({ model: filamentStockModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking filament stock states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Filament stock is not in draft state.'); + res.status(400).send({ error: 'Filament stock is not in draft state.', code: 400 }); + return; + } + const updateData = { - stockLocation: req.body.stockLocation, + filament: req.body?.filament, + filamentSku: req.body?.filamentSku, + stockLocation: req.body?.stockLocation, + startingWeight: req.body?.startingWeight, + currentWeight: req.body?.currentWeight ?? req.body?.startingWeight, }; const result = await editObject({ model: filamentStockModel, id, updateData, user: req.user, - populate: [ - { path: 'filament' }, - { path: 'filamentSku', populate: 'filament' }, - { path: 'stockLocation' }, - ], + populate: FILAMENT_STOCK_POPULATE, }); if (result.error) { @@ -183,13 +204,14 @@ export const editMultipleFilamentStocksRouteHandler = async (req, res) => { }; export const newFilamentStockRouteHandler = async (req, res) => { + const startingWeight = req.body.startingWeight; const newData = { updatedAt: new Date(), - startingWeight: req.body.startingWeight, - currentWeight: req.body.currentWeight, + startingWeight, + currentWeight: req.body.currentWeight ?? startingWeight, filament: req.body.filament, filamentSku: req.body.filamentSku, - state: req.body.state, + state: req.body.state ?? { type: 'draft' }, stockLocation: req.body.stockLocation, }; const result = await newObject({ @@ -204,28 +226,6 @@ export const newFilamentStockRouteHandler = async (req, res) => { logger.debug(`New filament stock with ID: ${result._id}`); - const netStockEventData = { - updatedAt: new Date(), - value: req.body.startingWeight.net, - owner: req.user, - ownerType: 'user', - parent: result._id, - parentType: 'filamentStock', - unit: 'g', - }; - - const stockEventResult = await newObject({ - model: stockEventModel, - newData: netStockEventData, - user: req.user, - }); - if (stockEventResult.error) { - logger.error('No stock event created:', stockEventResult.error); - return res.status(stockEventResult.code).send(stockEventResult); - } - - logger.debug(`New stock event with ID: ${stockEventResult._id}`); - res.send(result); }; @@ -235,6 +235,20 @@ export const deleteFilamentStockRouteHandler = async (req, res) => { logger.trace(`Filament Stock with ID: ${id}`); + const checkStatesResult = await checkStates({ model: filamentStockModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking filament stock states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Filament stock is not in draft state.'); + res.status(400).send({ error: 'Filament stock is not in draft state.', code: 400 }); + return; + } + const result = await deleteObject({ model: filamentStockModel, id, @@ -250,6 +264,79 @@ export const deleteFilamentStockRouteHandler = async (req, res) => { res.send(result); }; +export const postFilamentStockRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Filament Stock with ID: ${id}`); + + const checkStatesResult = await checkStates({ model: filamentStockModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking filament stock states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Filament stock is not in draft state.'); + res.status(400).send({ error: 'Filament stock is not in draft state.', code: 400 }); + return; + } + + const filamentStock = await getObject({ + model: filamentStockModel, + id, + populate: FILAMENT_STOCK_POPULATE, + }); + if (filamentStock?.error) { + logger.error('Error loading filament stock to post:', filamentStock.error); + res.status(filamentStock.code || 500).send(filamentStock); + return; + } + + const initialStockEventResult = await newObject({ + model: stockEventModel, + newData: { + value: filamentStock.startingWeight.net, + unit: 'g', + parent: { _id: id }, + parentType: 'filamentStock', + owner: { _id: req.user._id }, + ownerType: 'user', + }, + recalculate: true, + user: req.user, + }); + if (initialStockEventResult?.error) { + logger.error('Error creating initial stock event:', initialStockEventResult.error); + res.status(initialStockEventResult.code || 500).send(initialStockEventResult); + return; + } + + const updateData = { + updatedAt: new Date(), + state: { type: 'unconsumed' }, + postedAt: new Date(), + currentWeight: filamentStock.startingWeight, + }; + const result = await editObject({ + model: filamentStockModel, + id, + updateData, + user: req.user, + populate: FILAMENT_STOCK_POPULATE, + }); + + if (result.error) { + logger.error('Error posting filament stock:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Posted filament stock with ID: ${id}`); + res.send(result); +}; + export const getFilamentStockStatsRouteHandler = async (req, res) => { const result = await getModelStats({ model: filamentStockModel }); if (result?.error) { diff --git a/src/services/misc/spotlight.js b/src/services/misc/spotlight.js index 36679fb..bba26a1 100644 --- a/src/services/misc/spotlight.js +++ b/src/services/misc/spotlight.js @@ -51,6 +51,15 @@ const trimSpotlightObject = (object, objectType) => { online: object.online || undefined, amount: object.amount || undefined, unit: object.unit || undefined, + currentWeight: object.currentWeight || undefined, + currentQuantity: object.currentQuantity || undefined, + grandTotalAmount: object.grandTotalAmount || undefined, + totalAmount: object.totalAmount || undefined, + totalAmountWithTax: object.totalAmountWithTax || undefined, + cost: object.cost || undefined, + costWithTax: object.costWithTax || undefined, + price: object.price || undefined, + priceWithTax: object.priceWithTax || undefined, }; };