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.
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit

This commit is contained in:
Tom Butcher 2026-07-26 18:37:55 +01:00
parent 9a22fd6452
commit fa74c464f9
6 changed files with 192 additions and 38 deletions

View File

@ -19,6 +19,15 @@ const filamentStockSchema = new Schema(
net: { type: Number, required: true }, net: { type: Number, required: true },
gross: { 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 }, filament: { type: mongoose.Schema.Types.ObjectId, ref: 'filament', required: true },
filamentSku: { type: mongoose.Schema.Types.ObjectId, ref: 'filamentSku', required: true }, filamentSku: { type: mongoose.Schema.Types.ObjectId, ref: 'filamentSku', required: true },
stockLocation: { stockLocation: {

View File

@ -18,6 +18,12 @@ const partStockSchema = new Schema(
required: false, required: false,
}, },
currentQuantity: { type: Number, required: true }, currentQuantity: { type: Number, required: true },
history: [
{
currentQuantity: { type: Number, required: true },
timestamp: { type: Date, default: Date.now },
},
],
sourceType: { type: String, required: true }, sourceType: { type: String, required: true },
source: { type: Schema.Types.ObjectId, refPath: 'sourceType', required: true }, source: { type: Schema.Types.ObjectId, refPath: 'sourceType', required: true },
}, },

View File

@ -25,6 +25,12 @@ const productStockSchema = new Schema(
required: false, required: false,
}, },
currentQuantity: { type: Number, required: true }, currentQuantity: { type: Number, required: true },
history: [
{
currentQuantity: { type: Number, required: true },
timestamp: { type: Date, default: Date.now },
},
],
partStocks: [partStockUsageSchema], partStocks: [partStockUsageSchema],
}, },
{ timestamps: true } { timestamps: true }

View File

@ -12,6 +12,48 @@ const parentStockModelNames = {
const initialStockStates = { const initialStockStates = {
filamentStock: 'unconsumed', filamentStock: 'unconsumed',
partStock: 'new', 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) => { const getStockEventTotal = async (parentId, parentType) => {
@ -20,7 +62,9 @@ const getStockEventTotal = async (parentId, parentType) => {
const objectId = const objectId =
parentId instanceof mongoose.Types.ObjectId ? parentId : new mongoose.Types.ObjectId(parentId); parentId instanceof mongoose.Types.ObjectId ? parentId : new mongoose.Types.ObjectId(parentId);
const [result] = await mongoose.model('stockEvent').aggregate([ const [result] = await mongoose
.model('stockEvent')
.aggregate([
{ $match: { parent: objectId, parentType } }, { $match: { parent: objectId, parentType } },
{ $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } }, { $group: { _id: null, total: { $sum: '$value' }, count: { $sum: 1 } } },
]); ]);
@ -33,6 +77,7 @@ const getStockEventTotal = async (parentId, parentType) => {
const buildParentUpdateData = (parentType, parentStock, events) => { const buildParentUpdateData = (parentType, parentStock, events) => {
const updateData = {}; const updateData = {};
let currentAmount;
if (parentType === 'filamentStock') { if (parentType === 'filamentStock') {
const net = events.total; const net = events.total;
@ -40,20 +85,85 @@ const buildParentUpdateData = (parentType, parentStock, events) => {
const startingGross = parentStock.startingWeight?.gross ?? 0; const startingGross = parentStock.startingWeight?.gross ?? 0;
const gross = startingNet > 0 ? (startingGross * net) / startingNet : net; const gross = startingNet > 0 ? (startingGross * net) / startingNet : net;
updateData.currentWeight = { net, gross }; updateData.currentWeight = { net, gross };
currentAmount = net;
} else { } else {
updateData.currentQuantity = events.total; updateData.currentQuantity = events.total;
currentAmount = events.total;
} }
if (parentStock.state?.type !== 'draft') { const state = buildParentState(
const initialState = initialStockStates[parentType]; parentType,
if (initialState && parentStock.state?.type === initialState) { parentStock,
updateData.state = { ...parentStock.state, type: 'used' }; currentAmount,
} getStartingAmount(parentType, parentStock)
);
if (state) {
updateData.state = state;
} }
return updateData; 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) => { const recalculateParentStock = async (parentType, parentId, user) => {
if (!parentType || !parentId) return; if (!parentType || !parentId) return;
@ -74,7 +184,11 @@ const recalculateParentStock = async (parentType, parentId, user) => {
await editObject({ await editObject({
model: parentModel, model: parentModel,
id: parentStock._id, id: parentStock._id,
updateData: buildParentUpdateData(parentType, parentStock, events), updateData: appendParentHistoryIfChanged(
parentType,
parentStock,
buildParentUpdateData(parentType, parentStock, events)
),
user, user,
recalculate: false, recalculate: false,
}); });
@ -105,6 +219,12 @@ const stockEventSchema = new Schema(
required: true, required: true,
enum: ['user', 'subJob', 'stockAudit', 'stockTransfer'], enum: ['user', 'subJob', 'stockAudit', 'stockTransfer'],
}, },
history: [
{
value: { type: Number, required: true },
timestamp: { type: Date, default: Date.now },
},
],
timestamp: { type: Date, default: Date.now }, timestamp: { type: Date, default: Date.now },
}, },
{ timestamps: true } { timestamps: true }
@ -113,6 +233,24 @@ const stockEventSchema = new Schema(
stockEventSchema.index({ parentType: 'text', ownerType: 'text', unit: 'text' }); stockEventSchema.index({ parentType: 'text', ownerType: 'text', unit: 'text' });
stockEventSchema.statics.recalculate = async function (stockEvent, user) { 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 parentType = stockEvent.parentType;
const parentId = stockEvent.parent?._id || stockEvent.parent; const parentId = stockEvent.parent?._id || stockEvent.parent;
await recalculateParentStock(parentType, parentId, user); await recalculateParentStock(parentType, parentId, user);

View File

@ -15,6 +15,7 @@ import {
searchObjects, searchObjects,
getPropertyValues, getPropertyValues,
} from '../../database/database.js'; } from '../../database/database.js';
import { stockEventModel } from '../../database/schemas/inventory/stockevent.schema.js';
const logger = log4js.getLogger('Filament Stocks'); const logger = log4js.getLogger('Filament Stocks');
logger.level = config.server.logLevel; logger.level = config.server.logLevel;
@ -195,6 +196,28 @@ export const newFilamentStockRouteHandler = async (req, res) => {
logger.debug(`New filament stock with ID: ${result._id}`); 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); res.send(result);
}; };

View File

@ -32,12 +32,6 @@ const normalizeLineInput = (l) => ({
toStockLocation: l.toStockLocation?._id ?? l.toStockLocation, toStockLocation: l.toStockLocation?._id ?? l.toStockLocation,
}); });
const stockModelByType = {
filamentStock: filamentStockModel,
partStock: partStockModel,
productStock: productStockModel,
};
async function createStockEvent(newData, user) { async function createStockEvent(newData, user) {
const result = await newObject({ const result = await newObject({
model: stockEventModel, model: stockEventModel,
@ -52,13 +46,6 @@ async function createStockEvent(newData, user) {
return result; 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 }) { async function createStockEventsForLine({ transferId, fromId, fromType, toId, toType, qty, unit, user }) {
const ts = new Date(); const ts = new Date();
await Promise.all([ await Promise.all([
@ -151,11 +138,6 @@ async function executePostedLine(transferId, line, user) {
user, user,
}); });
await Promise.all([
recalculateStock('filamentStock', src, user),
recalculateStock('filamentStock', dest, user),
]);
return { toStockType: 'filamentStock', toStock: dest._id }; return { toStockType: 'filamentStock', toStock: dest._id };
} }
@ -191,11 +173,6 @@ async function executePostedLine(transferId, line, user) {
user, user,
}); });
await Promise.all([
recalculateStock('partStock', src, user),
recalculateStock('partStock', dest, user),
]);
return { toStockType: 'partStock', toStock: dest._id }; return { toStockType: 'partStock', toStock: dest._id };
} }
@ -230,11 +207,6 @@ async function executePostedLine(transferId, line, user) {
user, user,
}); });
await Promise.all([
recalculateStock('productStock', src, user),
recalculateStock('productStock', dest, user),
]);
return { toStockType: 'productStock', toStock: dest._id }; return { toStockType: 'productStock', toStock: dest._id };
} }