From 2b660ec61d1bae4fac26cd488c20dbdf4b1aea78 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 26 Jul 2026 18:38:04 +0100 Subject: [PATCH] Add history tracking to inventory schemas and stock event - Introduced a `history` field in filamentStock, partStock, productStock, and stockEvent schemas to maintain historical records of current quantities and weights. - Enhanced stock event processing to append history entries when values change, ensuring accurate tracking of stock changes over time. - Implemented utility functions to manage history entries and enforce rate limits on updates for improved data integrity. --- .../schemas/inventory/filamentstock.schema.js | 9 + .../schemas/inventory/partstock.schema.js | 6 + .../schemas/inventory/productstock.schema.js | 6 + .../schemas/inventory/stockevent.schema.js | 158 ++++++++++++++++-- 4 files changed, 169 insertions(+), 10 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);