Enhance inventory schemas and introduce stock audit level management
Some checks failed
farmcontrol/farmcontrol-ws/pipeline/head There was a failure building this commit

- Added `stockAuditLevelModel` and its schema to manage stock audit levels, including normalization functions for audit lines.
- Updated `filamentStock`, `partStock`, and `productStock` schemas to integrate stock audit calculations, ensuring accurate stock quantity tracking.
- Introduced a `recalculate` method in the stock audit schema to handle stock quantity updates based on audit levels.
- Removed the obsolete `stockEvent` schema to streamline inventory management.
- Enhanced existing schemas with new fields and methods for improved inventory auditing and reporting capabilities.
This commit is contained in:
Tom Butcher 2026-09-01 15:41:40 +01:00
parent ffcc7cd0aa
commit 5349538046
7 changed files with 450 additions and 66 deletions

View File

@ -2,6 +2,13 @@ import mongoose from 'mongoose';
import { generateId } from '../../utils.js'; import { generateId } from '../../utils.js';
const { Schema } = mongoose; const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory } from '../../database.js'; import { aggregateRollups, aggregateRollupsHistory } from '../../database.js';
import { updateDraftStockAuditCurrents } from './stockaudit.schema.js';
const toId = (value) => {
if (value == null) return null;
if (typeof value === 'object' && value._id) return String(value._id);
return String(value);
};
// Define the main filamentStock schema // Define the main filamentStock schema
const filamentStockSchema = new Schema( const filamentStockSchema = new Schema(
@ -96,6 +103,19 @@ filamentStockSchema.statics.history = async function (from, to) {
return results; return results;
}; };
filamentStockSchema.statics.recalculate = async function (filamentStock, user) {
const itemSkuId = toId(filamentStock?.filamentSku);
const stockLocationId = toId(filamentStock?.stockLocation);
if (!itemSkuId || !stockLocationId) return;
await updateDraftStockAuditCurrents({
itemType: 'filament',
itemSkuId,
stockLocationId,
user,
});
};
// Add virtual id getter // Add virtual id getter
filamentStockSchema.virtual('id').get(function () { filamentStockSchema.virtual('id').get(function () {
return this._id; return this._id;

View File

@ -1,6 +1,13 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
import { generateId } from '../../utils.js'; import { generateId } from '../../utils.js';
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js'; import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
import { updateDraftStockAuditCurrents } from './stockaudit.schema.js';
const toId = (value) => {
if (value == null) return null;
if (typeof value === 'object' && value._id) return String(value._id);
return String(value);
};
// Define the main partStock schema // Define the main partStock schema
const partStockSchema = new mongoose.Schema( const partStockSchema = new mongoose.Schema(
@ -89,6 +96,18 @@ partStockSchema.statics.history = async function (from, to) {
partStockSchema.statics.recalculate = async function (partStock, user) { partStockSchema.statics.recalculate = async function (partStock, user) {
if (!partStock?._id) return; if (!partStock?._id) return;
const itemSkuId = toId(partStock.partSku);
const stockLocationId = toId(partStock.stockLocation);
if (itemSkuId && stockLocationId) {
await updateDraftStockAuditCurrents({
itemType: 'part',
itemSkuId,
stockLocationId,
user,
});
}
if (partStock.state?.type === 'draft' || partStock.state?.type === 'consumed') return; if (partStock.state?.type === 'draft' || partStock.state?.type === 'consumed') return;
if ((Number(partStock.currentQuantity) || 0) > 0) return; if ((Number(partStock.currentQuantity) || 0) > 0) return;

View File

@ -2,6 +2,7 @@ import mongoose from 'mongoose';
import { generateId } from '../../utils.js'; import { generateId } from '../../utils.js';
const { Schema } = mongoose; const { Schema } = mongoose;
import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js'; import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js';
import { updateDraftStockAuditCurrents } from './stockaudit.schema.js';
const partStockListItemSchema = new Schema({ const partStockListItemSchema = new Schema({
part: { type: Schema.Types.ObjectId, ref: 'part', required: true }, part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
@ -122,6 +123,17 @@ productStockSchema.statics.history = async function (from, to) {
}; };
productStockSchema.statics.recalculate = async function (productStock, user) { productStockSchema.statics.recalculate = async function (productStock, user) {
const productSkuId = toId(productStock?.productSku);
const stockLocationId = toId(productStock?.stockLocation);
if (productSkuId && stockLocationId) {
await updateDraftStockAuditCurrents({
itemType: 'product',
itemSkuId: productSkuId,
stockLocationId,
user,
});
}
if ( if (
productStock?._id && productStock?._id &&
productStock.state?.type !== 'draft' && productStock.state?.type !== 'draft' &&
@ -139,8 +151,6 @@ productStockSchema.statics.recalculate = async function (productStock, user) {
}); });
} }
const productSkuId = toId(productStock?.productSku);
const stockLocationId = toId(productStock?.stockLocation);
if (!productSkuId || !stockLocationId) { if (!productSkuId || !stockLocationId) {
return; return;
} }

View File

@ -1,42 +1,311 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
import { generateId } from '../../utils.js'; import { generateId } from '../../utils.js';
import { editObject, getObject } from '../../database.js';
import { stockAuditLevelModel, normalizeAuditLevelLine } from '../management/stockauditlevel.schema.js';
import { filamentModel } from '../management/filament.schema.js';
import { filamentSkuModel } from '../management/filamentsku.schema.js';
import { partModel } from '../management/part.schema.js';
import { partSkuModel } from '../management/partsku.schema.js';
import { productModel } from '../management/product.schema.js';
import { productSkuModel } from '../management/productsku.schema.js';
const { Schema } = mongoose; const { Schema } = mongoose;
const stockAuditItemSchema = new Schema({ const itemModelsByType = {
type: { type: String, enum: ['filament', 'part'], required: true }, filament: filamentModel,
stock: { type: Schema.Types.ObjectId, required: true }, part: partModel,
expectedQuantity: { type: Number, required: true }, product: productModel,
actualQuantity: { type: Number, required: true }, };
notes: { type: String },
}); const skuModelsByType = {
filament: filamentSkuModel,
part: partSkuModel,
product: productSkuModel,
};
const parentFieldByType = {
filament: 'filament',
part: 'part',
product: 'product',
};
const toId = (value) => {
if (value == null) return null;
if (typeof value === 'object' && value._id != null) return String(value._id);
return String(value);
};
const stockAuditLineSchema = new Schema(
{
itemType: {
type: String,
enum: ['filament', 'part', 'product'],
required: true,
},
item: { type: Schema.Types.ObjectId, refPath: 'auditLines.itemType', required: true },
itemSku: {
type: Schema.Types.ObjectId,
ref: function () {
return ['filament', 'part', 'product'].includes(this.itemType)
? this.itemType + 'Sku'
: null;
},
required: true,
},
current: { type: Number, required: true, default: 0 },
actual: { type: Number, required: true, default: 0 },
new: { type: Number, required: true, default: 0 },
},
{ _id: true }
);
const stockAuditSchema = new Schema( const stockAuditSchema = new Schema(
{ {
_reference: { type: String, default: () => generateId()() }, _reference: { type: String, default: () => generateId()() },
type: { type: String, required: true }, state: {
status: { type: { type: String, required: true, default: 'draft' },
type: String, progress: { type: Number, required: false },
enum: ['pending', 'in_progress', 'completed', 'cancelled'], },
default: 'pending', auditLevel: {
type: Schema.Types.ObjectId,
ref: 'stockAuditLevel',
required: true, required: true,
}, },
notes: { type: String }, stockLocation: {
items: [stockAuditItemSchema], type: Schema.Types.ObjectId,
createdBy: { type: Schema.Types.ObjectId, ref: 'user', required: true }, ref: 'stockLocation',
completedAt: { type: Date }, required: true,
},
postedAt: { type: Date, required: false },
auditLines: { type: [stockAuditLineSchema], default: [] },
}, },
{ timestamps: true } { timestamps: true }
); );
stockAuditSchema.index({ type: 'text', status: 'text', notes: 'text' }); stockAuditSchema.index({ 'state.type': 'text' });
stockAuditSchema.statics.stats = async function () {
const [draft, complete] = await Promise.all([
this.countDocuments({ 'state.type': 'draft' }),
this.countDocuments({ 'state.type': 'complete' }),
]);
return {
draft: { count: draft },
complete: { count: complete },
};
};
stockAuditSchema.statics.history = async function () {
return [];
};
async function fetchItemsForLevelLine(levelLine) {
const itemType = levelLine.itemType;
const itemModel = itemModelsByType[itemType];
if (!itemModel) return [];
if (levelLine.allItems) {
return itemModel.find().select('_id').lean();
}
const itemId = toId(levelLine.item);
if (!itemId) return [];
return [{ _id: itemId }];
}
async function fetchSkusForLevelLine(levelLine, itemId) {
const itemType = levelLine.itemType;
const skuModel = skuModelsByType[itemType];
const parentField = parentFieldByType[itemType];
if (!skuModel || !parentField) return [];
if (levelLine.allSkus) {
return skuModel.find({ [parentField]: itemId }).select('_id').lean();
}
const skuId = toId(levelLine.itemSku);
if (!skuId) return [];
return [{ _id: skuId }];
}
export async function getCurrentQuantityAtLocation(itemType, itemSkuId, stockLocationId) {
const locationId = toId(stockLocationId);
const skuId = toId(itemSkuId);
if (!locationId || !skuId) return 0;
if (itemType === 'filament') {
const stocks = await mongoose
.model('filamentStock')
.find({ filamentSku: skuId, stockLocation: locationId })
.select('currentWeight.net')
.lean();
return stocks.reduce((sum, stock) => sum + (Number(stock.currentWeight?.net) || 0), 0);
}
if (itemType === 'part') {
const stocks = await mongoose
.model('partStock')
.find({ partSku: skuId, stockLocation: locationId })
.select('currentQuantity')
.lean();
return stocks.reduce((sum, stock) => sum + (Number(stock.currentQuantity) || 0), 0);
}
if (itemType === 'product') {
const stocks = await mongoose
.model('productStock')
.find({ productSku: skuId, stockLocation: locationId })
.select('currentQuantity')
.lean();
return stocks.reduce((sum, stock) => sum + (Number(stock.currentQuantity) || 0), 0);
}
return 0;
}
function buildAuditLineQuantities(current, actual) {
const currentVal = Number(current) || 0;
const actualVal = Number(actual) || 0;
return {
current: currentVal,
actual: actualVal,
new: actualVal,
};
}
function getExistingActual(line) {
if (line?.actual != null) return Number(line.actual);
if (line?.actualQuantity != null) return Number(line.actualQuantity);
return null;
}
function buildAuditLineKey(itemType, itemId, itemSkuId) {
return `${itemType}:${toId(itemId)}:${toId(itemSkuId)}`;
}
async function expandLevelLinesToAuditLines(levelLines, stockLocationId, existingLines = []) {
const existingByKey = new Map();
for (const line of existingLines) {
existingByKey.set(
buildAuditLineKey(line.itemType, line.item, line.itemSku),
line
);
}
const auditLines = [];
for (const levelLine of levelLines || []) {
const items = await fetchItemsForLevelLine(levelLine);
for (const item of items) {
const itemId = toId(item._id);
const skus = await fetchSkusForLevelLine(levelLine, itemId);
for (const sku of skus) {
const itemSkuId = toId(sku._id);
const current = await getCurrentQuantityAtLocation(
levelLine.itemType,
itemSkuId,
stockLocationId
);
const key = buildAuditLineKey(levelLine.itemType, itemId, itemSkuId);
const existing = existingByKey.get(key);
const existingActual = getExistingActual(existing);
const actual = existingActual != null ? existingActual : current;
auditLines.push({
itemType: levelLine.itemType,
item: itemId,
itemSku: itemSkuId,
...buildAuditLineQuantities(current, actual),
});
}
}
}
return auditLines;
}
stockAuditSchema.statics.recalculate = async function (stockAudit, user) {
if (stockAudit?.state?.type !== 'draft') return;
const auditLevelId = toId(stockAudit.auditLevel?._id ?? stockAudit.auditLevel);
const stockLocationId = toId(stockAudit.stockLocation?._id ?? stockAudit.stockLocation);
if (!auditLevelId || !stockLocationId) return;
const auditLevel = await getObject({
model: stockAuditLevelModel,
id: auditLevelId,
populate: [
{ path: 'auditLines.item' },
{ path: 'auditLines.itemSku' },
],
});
if (!auditLevel || auditLevel.error) return;
const auditLines = await expandLevelLinesToAuditLines(
(auditLevel.auditLines || []).map((line) => normalizeAuditLevelLine(line)),
stockLocationId,
stockAudit.auditLines
);
await editObject({
model: this,
id: stockAudit._id,
updateData: { auditLines },
user,
recalculate: false,
});
};
// Add virtual id getter
stockAuditSchema.virtual('id').get(function () { stockAuditSchema.virtual('id').get(function () {
return this._id; return this._id;
}); });
// Configure JSON serialization to include virtuals
stockAuditSchema.set('toJSON', { virtuals: true }); stockAuditSchema.set('toJSON', { virtuals: true });
// Create and export the model
export const stockAuditModel = mongoose.model('stockAudit', stockAuditSchema); export const stockAuditModel = mongoose.model('stockAudit', stockAuditSchema);
export async function updateDraftStockAuditCurrents({
itemType,
itemSkuId,
stockLocationId,
user,
}) {
const skuId = toId(itemSkuId);
const locationId = toId(stockLocationId);
if (!itemType || !skuId || !locationId) return;
const current = await getCurrentQuantityAtLocation(itemType, skuId, locationId);
const draftAudits = await stockAuditModel
.find({
'state.type': 'draft',
stockLocation: locationId,
})
.lean();
for (const audit of draftAudits) {
let changed = false;
const auditLines = (audit.auditLines || []).map((line) => {
if (line.itemType !== itemType || toId(line.itemSku) !== skuId) {
return line;
}
const lineCurrent = Number(line.current) || 0;
if (lineCurrent === current) {
return line;
}
changed = true;
return { ...line, current };
});
if (!changed) continue;
await editObject({
model: stockAuditModel,
id: audit._id,
updateData: { auditLines },
user,
recalculate: false,
});
}
}

View File

@ -1,44 +0,0 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
const { Schema } = mongoose;
const stockEventSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
value: { type: Number, required: true },
unit: { type: String, required: true },
parent: {
type: Schema.Types.ObjectId,
refPath: 'parentType',
required: true,
},
parentType: {
type: String,
required: true,
enum: ['filamentStock', 'partStock', 'productStock'], // Add other models as needed
},
owner: {
type: Schema.Types.ObjectId,
refPath: 'ownerType',
required: true,
},
ownerType: {
type: String,
required: true,
enum: ['user', 'subJob', 'stockAudit'],
},
timestamp: { type: Date, default: Date.now },
},
{ timestamps: true }
);
// Add virtual id getter
stockEventSchema.virtual('id').get(function () {
return this._id;
});
// Configure JSON serialization to include virtuals
stockEventSchema.set('toJSON', { virtuals: true });
// Create and export the model
export const stockEventModel = mongoose.model('stockEvent', stockEventSchema);

View File

@ -0,0 +1,108 @@
import mongoose from 'mongoose';
import { generateId } from '../../utils.js';
import { editObject } from '../../database.js';
const { Schema } = mongoose;
const toId = (value) => {
if (value == null) return null;
if (typeof value === 'object' && value._id != null) return String(value._id);
return String(value);
};
export function normalizeAuditLevelLine(line) {
const allItems = Boolean(line?.allItems);
const allSkus = allItems ? true : Boolean(line?.allSkus);
const item = allItems ? null : line?.item?._id ?? line?.item ?? null;
const itemSku = allItems || allSkus ? null : line?.itemSku?._id ?? line?.itemSku ?? null;
return {
_id: line?._id,
itemType: line?.itemType,
allItems,
allSkus,
item,
itemSku,
};
}
function auditLevelLineChanged(before, after) {
return (
Boolean(before?.allItems) !== Boolean(after.allItems) ||
Boolean(before?.allSkus) !== Boolean(after.allSkus) ||
toId(before?.item) !== toId(after.item) ||
toId(before?.itemSku) !== toId(after.itemSku)
);
}
const stockAuditLevelLineSchema = new Schema(
{
itemType: {
type: String,
enum: ['filament', 'part', 'product'],
required: true,
},
allItems: { type: Boolean, default: false },
item: { type: Schema.Types.ObjectId, refPath: 'auditLines.itemType', required: false },
allSkus: { type: Boolean, default: false },
itemSku: {
type: Schema.Types.ObjectId,
ref: function () {
return ['filament', 'part', 'product'].includes(this.itemType)
? this.itemType + 'Sku'
: null;
},
required: false,
},
},
{ _id: true }
);
const stockAuditLevelSchema = new Schema(
{
_reference: { type: String, default: () => generateId()() },
name: { type: String, required: true },
tags: [{ type: String }],
auditLines: { type: [stockAuditLevelLineSchema], default: [] },
},
{ timestamps: true }
);
stockAuditLevelSchema.index({ name: 'text', tags: 'text' });
stockAuditLevelSchema.statics.stats = async function () {
const count = await this.countDocuments();
return { total: { count } };
};
stockAuditLevelSchema.statics.history = async function () {
return [];
};
stockAuditLevelSchema.statics.recalculate = async function (stockAuditLevel, user) {
if (!stockAuditLevel?._id) return;
const auditLines = stockAuditLevel.auditLines || [];
const normalizedLines = auditLines.map((line) => normalizeAuditLevelLine(line));
const changed = auditLines.some((line, index) =>
auditLevelLineChanged(line, normalizedLines[index])
);
if (!changed) return;
await editObject({
model: this,
id: stockAuditLevel._id,
updateData: { auditLines: normalizedLines },
user,
recalculate: false,
});
};
stockAuditLevelSchema.virtual('id').get(function () {
return this._id;
});
stockAuditLevelSchema.set('toJSON', { virtuals: true });
export const stockAuditLevelModel = mongoose.model('stockAuditLevel', stockAuditLevelSchema);

View File

@ -18,6 +18,7 @@ import { purchaseOrderModel } from './inventory/purchaseorder.schema.js';
import { orderItemModel } from './inventory/orderitem.schema.js'; import { orderItemModel } from './inventory/orderitem.schema.js';
import { stockEventModel } from './inventory/stockevent.schema.js'; import { stockEventModel } from './inventory/stockevent.schema.js';
import { stockAuditModel } from './inventory/stockaudit.schema.js'; import { stockAuditModel } from './inventory/stockaudit.schema.js';
import { stockAuditLevelModel } from './management/stockauditlevel.schema.js';
import { partStockModel } from './inventory/partstock.schema.js'; import { partStockModel } from './inventory/partstock.schema.js';
import { productStockModel } from './inventory/productstock.schema.js'; import { productStockModel } from './inventory/productstock.schema.js';
import { stockLocationModel } from './inventory/stocklocation.schema.js'; import { stockLocationModel } from './inventory/stocklocation.schema.js';
@ -88,6 +89,7 @@ export const models = {
FLS: modelEntry(() => filamentStockModel, 'filamentStock', 'Filament Stock'), FLS: modelEntry(() => filamentStockModel, 'filamentStock', 'Filament Stock'),
SEV: modelEntry(() => stockEventModel, 'stockEvent', 'Stock Event'), SEV: modelEntry(() => stockEventModel, 'stockEvent', 'Stock Event'),
SAU: modelEntry(() => stockAuditModel, 'stockAudit', 'Stock Audit'), SAU: modelEntry(() => stockAuditModel, 'stockAudit', 'Stock Audit'),
SAL: modelEntry(() => stockAuditLevelModel, 'stockAuditLevel', 'Stock Audit Level'),
PTS: modelEntry(() => partStockModel, 'partStock', 'Part Stock'), PTS: modelEntry(() => partStockModel, 'partStock', 'Part Stock'),
PDS: modelEntry(() => productStockModel, 'productStock', 'Product Stock'), PDS: modelEntry(() => productStockModel, 'productStock', 'Product Stock'),
SLN: modelEntry(() => stockLocationModel, 'stockLocation', 'Stock Location'), SLN: modelEntry(() => stockLocationModel, 'stockLocation', 'Stock Location'),