From fa74c464f9dcf7d61bf7542e14e17ed09d223efb Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 26 Jul 2026 18:37:55 +0100 Subject: [PATCH] Added history tracking to inventory schemas (filament, part, product stocks) and stock events, enabling better state management and historical data retention. Updated related service functions to incorporate new history logic for stock events and recalculations. --- .../schemas/inventory/filamentstock.schema.js | 9 + .../schemas/inventory/partstock.schema.js | 6 + .../schemas/inventory/productstock.schema.js | 6 + .../schemas/inventory/stockevent.schema.js | 158 ++++++++++++++++-- src/services/inventory/filamentstocks.js | 23 +++ src/services/inventory/stocktransfers.js | 28 ---- 6 files changed, 192 insertions(+), 38 deletions(-) diff --git a/src/database/schemas/inventory/filamentstock.schema.js b/src/database/schemas/inventory/filamentstock.schema.js index 55ac461..bfbc16c 100644 --- a/src/database/schemas/inventory/filamentstock.schema.js +++ b/src/database/schemas/inventory/filamentstock.schema.js @@ -19,6 +19,15 @@ const filamentStockSchema = new Schema( net: { type: Number, required: true }, gross: { type: Number, required: true }, }, + history: [ + { + currentWeight: { + net: { type: Number, required: true }, + gross: { type: Number, required: true }, + }, + timestamp: { type: Date, default: Date.now }, + }, + ], filament: { type: mongoose.Schema.Types.ObjectId, ref: 'filament', required: true }, filamentSku: { type: mongoose.Schema.Types.ObjectId, ref: 'filamentSku', required: true }, stockLocation: { diff --git a/src/database/schemas/inventory/partstock.schema.js b/src/database/schemas/inventory/partstock.schema.js index 0d8235d..b640f0e 100644 --- a/src/database/schemas/inventory/partstock.schema.js +++ b/src/database/schemas/inventory/partstock.schema.js @@ -18,6 +18,12 @@ const partStockSchema = new Schema( required: false, }, currentQuantity: { type: Number, required: true }, + history: [ + { + currentQuantity: { type: Number, required: true }, + timestamp: { type: Date, default: Date.now }, + }, + ], sourceType: { type: String, required: true }, source: { type: Schema.Types.ObjectId, refPath: 'sourceType', required: true }, }, diff --git a/src/database/schemas/inventory/productstock.schema.js b/src/database/schemas/inventory/productstock.schema.js index 25983c7..7476496 100644 --- a/src/database/schemas/inventory/productstock.schema.js +++ b/src/database/schemas/inventory/productstock.schema.js @@ -25,6 +25,12 @@ const productStockSchema = new Schema( required: false, }, currentQuantity: { type: Number, required: true }, + history: [ + { + currentQuantity: { type: Number, required: true }, + timestamp: { type: Date, default: Date.now }, + }, + ], partStocks: [partStockUsageSchema], }, { timestamps: true } diff --git a/src/database/schemas/inventory/stockevent.schema.js b/src/database/schemas/inventory/stockevent.schema.js index 680fe12..8809575 100644 --- a/src/database/schemas/inventory/stockevent.schema.js +++ b/src/database/schemas/inventory/stockevent.schema.js @@ -12,6 +12,48 @@ const parentStockModelNames = { const initialStockStates = { filamentStock: 'unconsumed', partStock: 'new', + productStock: 'posted', +}; + +const getStartingAmount = (parentType, parentStock) => { + if (parentType === 'filamentStock') { + return parentStock.startingWeight?.net ?? 0; + } + + return parentStock.startingQuantity ?? 0; +}; + +const buildParentState = (parentType, parentStock, currentAmount, startingAmount) => { + if (parentStock.state?.type === 'draft') { + return undefined; + } + + const fullState = initialStockStates[parentType]; + if (!fullState) { + return undefined; + } + + if (currentAmount <= 0) { + return { ...parentStock.state, type: 'consumed', progress: 0 }; + } + + if (startingAmount <= 0) { + return undefined; + } + + const progress = currentAmount / startingAmount; + + console.log('progress', progress); + + if (currentAmount === startingAmount) { + return { ...parentStock.state, type: fullState, progress: 1 }; + } + + if (currentAmount < startingAmount) { + return { ...parentStock.state, type: 'used', progress }; + } + + return { ...parentStock.state, type: fullState, progress: 1 }; }; const getStockEventTotal = async (parentId, parentType) => { @@ -20,10 +62,12 @@ const getStockEventTotal = async (parentId, parentType) => { const objectId = parentId instanceof mongoose.Types.ObjectId ? parentId : new mongoose.Types.ObjectId(parentId); - const [result] = await mongoose.model('stockEvent').aggregate([ - { $match: { parent: objectId, parentType } }, - { $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } }, - ]); + const [result] = await mongoose + .model('stockEvent') + .aggregate([ + { $match: { parent: objectId, parentType } }, + { $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } }, + ]); return { total: result?.total ?? 0, @@ -33,6 +77,7 @@ const getStockEventTotal = async (parentId, parentType) => { const buildParentUpdateData = (parentType, parentStock, events) => { const updateData = {}; + let currentAmount; if (parentType === 'filamentStock') { const net = events.total; @@ -40,20 +85,85 @@ const buildParentUpdateData = (parentType, parentStock, events) => { const startingGross = parentStock.startingWeight?.gross ?? 0; const gross = startingNet > 0 ? (startingGross * net) / startingNet : net; updateData.currentWeight = { net, gross }; + currentAmount = net; } else { updateData.currentQuantity = events.total; + currentAmount = events.total; } - if (parentStock.state?.type !== 'draft') { - const initialState = initialStockStates[parentType]; - if (initialState && parentStock.state?.type === initialState) { - updateData.state = { ...parentStock.state, type: 'used' }; - } + const state = buildParentState( + parentType, + parentStock, + currentAmount, + getStartingAmount(parentType, parentStock) + ); + if (state) { + updateData.state = state; } return updateData; }; +const HISTORY_RATE_LIMIT_MS = 3000; + +const isWithinHistoryRateLimit = (lastEntry, timestamp = new Date()) => { + if (!lastEntry?.timestamp) return false; + const elapsed = new Date(timestamp).getTime() - new Date(lastEntry.timestamp).getTime(); + return elapsed < HISTORY_RATE_LIMIT_MS; +}; + +const getLastParentHistoryValue = (parentType, history = []) => { + const lastEntry = history.at(-1); + if (!lastEntry) return undefined; + + return parentType === 'filamentStock' ? lastEntry.currentWeight : lastEntry.currentQuantity; +}; + +const parentValuesEqual = (parentType, a, b) => { + if (a === b) return true; + if (a == null || b == null) return false; + + if (parentType === 'filamentStock') { + return a.net === b.net && a.gross === b.gross; + } + + return a === b; +}; + +const buildParentHistoryEntry = (parentType, currentValue, timestamp) => { + if (parentType === 'filamentStock') { + return { currentWeight: currentValue, timestamp }; + } + + return { currentQuantity: currentValue, timestamp }; +}; + +const appendParentHistoryIfChanged = ( + parentType, + parentStock, + updateData, + timestamp = new Date() +) => { + const history = parentStock.history || []; + const lastEntry = history.at(-1); + const currentValue = + parentType === 'filamentStock' ? updateData.currentWeight : updateData.currentQuantity; + const lastHistoryValue = getLastParentHistoryValue(parentType, history); + + if (parentValuesEqual(parentType, currentValue, lastHistoryValue)) { + return updateData; + } + + if (isWithinHistoryRateLimit(lastEntry, timestamp)) { + return updateData; + } + + return { + ...updateData, + history: [...history, buildParentHistoryEntry(parentType, currentValue, timestamp)], + }; +}; + const recalculateParentStock = async (parentType, parentId, user) => { if (!parentType || !parentId) return; @@ -74,7 +184,11 @@ const recalculateParentStock = async (parentType, parentId, user) => { await editObject({ model: parentModel, id: parentStock._id, - updateData: buildParentUpdateData(parentType, parentStock, events), + updateData: appendParentHistoryIfChanged( + parentType, + parentStock, + buildParentUpdateData(parentType, parentStock, events) + ), user, recalculate: false, }); @@ -105,6 +219,12 @@ const stockEventSchema = new Schema( required: true, enum: ['user', 'subJob', 'stockAudit', 'stockTransfer'], }, + history: [ + { + value: { type: Number, required: true }, + timestamp: { type: Date, default: Date.now }, + }, + ], timestamp: { type: Date, default: Date.now }, }, { timestamps: true } @@ -113,6 +233,24 @@ const stockEventSchema = new Schema( stockEventSchema.index({ parentType: 'text', ownerType: 'text', unit: 'text' }); stockEventSchema.statics.recalculate = async function (stockEvent, user) { + const history = stockEvent.history || []; + const lastEntry = history.at(-1); + const lastHistoryValue = lastEntry?.value; + const currentValue = stockEvent.value; + const timestamp = stockEvent.timestamp || new Date(); + + if (currentValue !== lastHistoryValue && !isWithinHistoryRateLimit(lastEntry, timestamp)) { + await editObject({ + model: this, + id: stockEvent._id, + updateData: { + history: [...history, { value: currentValue, timestamp }], + }, + user, + recalculate: false, + }); + } + const parentType = stockEvent.parentType; const parentId = stockEvent.parent?._id || stockEvent.parent; await recalculateParentStock(parentType, parentId, user); diff --git a/src/services/inventory/filamentstocks.js b/src/services/inventory/filamentstocks.js index 74da5e7..591764d 100644 --- a/src/services/inventory/filamentstocks.js +++ b/src/services/inventory/filamentstocks.js @@ -15,6 +15,7 @@ import { searchObjects, getPropertyValues, } from '../../database/database.js'; +import { stockEventModel } from '../../database/schemas/inventory/stockevent.schema.js'; const logger = log4js.getLogger('Filament Stocks'); logger.level = config.server.logLevel; @@ -195,6 +196,28 @@ 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); }; diff --git a/src/services/inventory/stocktransfers.js b/src/services/inventory/stocktransfers.js index 52a971d..43ae682 100644 --- a/src/services/inventory/stocktransfers.js +++ b/src/services/inventory/stocktransfers.js @@ -32,12 +32,6 @@ const normalizeLineInput = (l) => ({ toStockLocation: l.toStockLocation?._id ?? l.toStockLocation, }); -const stockModelByType = { - filamentStock: filamentStockModel, - partStock: partStockModel, - productStock: productStockModel, -}; - async function createStockEvent(newData, user) { const result = await newObject({ model: stockEventModel, @@ -52,13 +46,6 @@ async function createStockEvent(newData, user) { return result; } -async function recalculateStock(stockType, stock, user) { - const model = stockModelByType[stockType]; - if (!model?.recalculate) return; - - await model.recalculate(stock, user); -} - async function createStockEventsForLine({ transferId, fromId, fromType, toId, toType, qty, unit, user }) { const ts = new Date(); await Promise.all([ @@ -151,11 +138,6 @@ async function executePostedLine(transferId, line, user) { user, }); - await Promise.all([ - recalculateStock('filamentStock', src, user), - recalculateStock('filamentStock', dest, user), - ]); - return { toStockType: 'filamentStock', toStock: dest._id }; } @@ -191,11 +173,6 @@ async function executePostedLine(transferId, line, user) { user, }); - await Promise.all([ - recalculateStock('partStock', src, user), - recalculateStock('partStock', dest, user), - ]); - return { toStockType: 'partStock', toStock: dest._id }; } @@ -230,11 +207,6 @@ async function executePostedLine(transferId, line, user) { user, }); - await Promise.all([ - recalculateStock('productStock', src, user), - recalculateStock('productStock', dest, user), - ]); - return { toStockType: 'productStock', toStock: dest._id }; }