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 }; }