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 },
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: {

View File

@ -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 },
},

View File

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

View File

@ -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);

View File

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

View File

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