From 61ec9deecba378ab77f881e2d0497a35e59f1168 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Mon, 24 Aug 2026 22:40:07 +0100 Subject: [PATCH] Add utility functions for ObjectId handling and enhance expandObjectIds functionality This commit introduces several utility functions in `utils.js` to improve ObjectId handling, including `isByte`, `isTwelveByteBuffer`, and `objectIdToString`. The `expandObjectIds` function is updated to better handle various ObjectId formats, including BSON ObjectIds and serialized 12-byte buffers. Additionally, comprehensive tests are added in `utils.expandObjectIds.test.js` to verify the correct behavior of these enhancements, ensuring robust handling of ObjectId conversions and expansions. --- src/__tests__/utils.expandObjectIds.test.js | 45 ++++ src/database/database.js | 57 ++-- .../stockQuantity.recalculate.test.js | 253 +++++++++++++++++- .../schemas/inventory/partstock.schema.js | 26 +- .../schemas/inventory/productstock.schema.js | 62 ++++- .../schemas/inventory/stockevent.schema.js | 27 +- .../schemas/management/productsku.schema.js | 2 +- src/database/schemas/sales/listing.schema.js | 99 +++++++ .../schemas/sales/listingvarient.schema.js | 28 +- src/services/inventory/partstocks.js | 16 +- src/services/inventory/productstocks.js | 175 ++++++++++-- src/services/inventory/stocktransfers.js | 6 +- src/services/misc/export.js | 2 +- src/services/sales/listings.js | 32 +-- src/utils.js | 71 ++++- 15 files changed, 799 insertions(+), 102 deletions(-) create mode 100644 src/__tests__/utils.expandObjectIds.test.js diff --git a/src/__tests__/utils.expandObjectIds.test.js b/src/__tests__/utils.expandObjectIds.test.js new file mode 100644 index 0000000..6419c50 --- /dev/null +++ b/src/__tests__/utils.expandObjectIds.test.js @@ -0,0 +1,45 @@ +import { describe, expect, it } from '@jest/globals'; +import { mongoose } from 'mongoose'; +import { ObjectId as BsonObjectId } from 'mongodb'; +import { expandObjectIds } from '../utils.js'; + +const HEX = '692f07c4cb1de2995c4d543a'; +const BYTES = [105, 47, 7, 196, 203, 29, 226, 153, 92, 77, 84, 58]; + +describe('expandObjectIds', () => { + it('stores mongoose ObjectIds as hex text', () => { + const id = new mongoose.Types.ObjectId(HEX); + const result = expandObjectIds({ location: id, _id: id }); + + expect(result.location).toEqual({ _id: HEX }); + expect(result._id).toBe(HEX); + expect(result.location._id).not.toHaveProperty('buffer'); + }); + + it('stores BSON ObjectIds as hex text even when they are not mongoose instances', () => { + const id = new BsonObjectId(HEX); + expect(id instanceof mongoose.Types.ObjectId).toBe(false); + + const result = expandObjectIds({ product: id }); + + expect(result.product).toEqual({ _id: HEX }); + expect(typeof result.product._id).toBe('string'); + }); + + it('converts serialized 12-byte buffer objects to hex text', () => { + const serialized = { + buffer: Object.fromEntries(BYTES.map((byte, index) => [String(index), byte])), + }; + + const result = expandObjectIds({ sku: serialized }); + + expect(result.sku).toEqual({ _id: HEX }); + }); + + it('converts 12-byte Buffers to hex text instead of expanding index keys', () => { + const result = expandObjectIds({ sku: Buffer.from(HEX, 'hex') }); + + expect(result.sku).toEqual({ _id: HEX }); + expect(result.sku).not.toHaveProperty('0'); + }); +}); diff --git a/src/database/database.js b/src/database/database.js index 747c4ca..ed6f78a 100644 --- a/src/database/database.js +++ b/src/database/database.js @@ -44,15 +44,41 @@ const NEIGHBORS_CACHE_TTL_SECONDS = 60; const NEIGHBORS_CACHE_WINDOW = 25; const NEIGHBORS_CACHE_PREFIX = 'neighbors'; -const mergeObjectUpdates = (target, source) => - _.mergeWith(target, source, (objValue, srcValue, key) => { +const isMergeableObject = (value) => _.isPlainObject(value); + +const mergeObjectUpdates = (target, source) => { + if (!isMergeableObject(source)) { + return source; + } + + if (!isMergeableObject(target)) { + target = {}; + } + + for (const key of Object.keys(source)) { + const srcValue = source[key]; + const objValue = target[key]; + + // Key exists on source (including explicit undefined) — use source. + // Keys omitted from source keep the target value. + if (srcValue === undefined) { + target[key] = undefined; + continue; + } + if (Array.isArray(objValue) || Array.isArray(srcValue)) { - return srcValue; + target[key] = srcValue; + } else if (key === 'permissions') { + target[key] = srcValue; + } else if (isMergeableObject(objValue) && isMergeableObject(srcValue)) { + mergeObjectUpdates(objValue, srcValue); + } else { + target[key] = srcValue; } - if (key === 'permissions' && srcValue !== undefined) { - return srcValue; - } - }); + } + + return target; +}; export const retrieveObjectCache = async ({ model, id, populate = [] }) => { if (!model || !id) return undefined; @@ -739,9 +765,7 @@ function flattenRefIds(ids) { } async function fetchBasicObjectsByIds(refName, ids) { - const uniqueIds = [ - ...new Map(flattenRefIds(ids).map((id) => [id.toString(), id])).values(), - ]; + const uniqueIds = [...new Map(flattenRefIds(ids).map((id) => [id.toString(), id])).values()]; if (uniqueIds.length === 0) return []; const withObjectType = (value) => { @@ -846,10 +870,7 @@ export const getPropertyValues = async ({ model, property, filter = {} }) => { }, }); } - pipeline.push( - { $match: { [property]: { $ne: null } } }, - { $group: { _id: `$${property}` } } - ); + pipeline.push({ $match: { [property]: { $ne: null } } }, { $group: { _id: `$${property}` } }); const ids = (await model.aggregate(pipeline)).map((row) => row._id); return fetchBasicObjectsByIds(pathInfo.ref, ids); } @@ -1251,6 +1272,7 @@ export const editObject = async ({ model, id, updateData, user, populate, recalc object: updatedObject, populate, }); + console.log('updatedObject', updatedObject); await invalidateNeighborsCacheForObject({ model, id }); if (model.recalculate && recalculate == true) { @@ -1299,7 +1321,10 @@ export const editObjects = async ({ model, updates, user, populate, recalculate }; // Reusable function to create a new object -export const newObject = async ({ model, newData, user = null }, distributeChanges = true) => { +export const newObject = async ( + { model, newData, user = null, recalculate = true }, + distributeChanges = true +) => { try { const parentType = model.modelName ? model.modelName : 'unknown'; @@ -1324,7 +1349,7 @@ export const newObject = async ({ model, newData, user = null }, distributeChang populate: [], }); - if (model.recalculate) { + if (model.recalculate && recalculate == true) { logger.debug(`Recalculating ${model.modelName}`); await model.recalculate(created, user); } diff --git a/src/database/schemas/__tests__/stockQuantity.recalculate.test.js b/src/database/schemas/__tests__/stockQuantity.recalculate.test.js index d36c558..20b3be3 100644 --- a/src/database/schemas/__tests__/stockQuantity.recalculate.test.js +++ b/src/database/schemas/__tests__/stockQuantity.recalculate.test.js @@ -23,7 +23,7 @@ jest.unstable_mockModule('../../utils.js', () => ({ generateId: jest.fn(() => () => 'test-id'), })); -const { aggregateRollups, editObject } = await import('../../database.js'); +const { aggregateRollups, editObject, newObject, deleteObject } = await import('../../database.js'); const { listingModel } = await import('../sales/listing.schema.js'); const { listingVarientModel } = await import('../sales/listingvarient.schema.js'); const { productSkuModel } = await import('../management/productsku.schema.js'); @@ -35,6 +35,257 @@ const productSkuId = new mongoose.Types.ObjectId(); const stockLocationId = new mongoose.Types.ObjectId(); const varientId = new mongoose.Types.ObjectId(); +const mockFind = (docs) => ({ + sort: () => ({ lean: async () => docs }), +}); + +describe('listing.recalculate', () => { + beforeEach(() => { + editObject.mockReset(); + newObject.mockReset(); + deleteObject.mockReset(); + jest.restoreAllMocks(); + }); + + it('calls recalculate on each listing varient', async () => { + const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue(); + jest.spyOn(listingVarientModel, 'find').mockReturnValue( + mockFind([{ _id: varientId, listing: listingId }]) + ); + + const listing = { _id: listingId, stockLocation: stockLocationId }; + await listingModel.recalculate(listing, 'user-1'); + + expect(listingVarientModel.find).toHaveBeenCalledWith({ listing: listingId }); + expect(recalculate).toHaveBeenCalledWith({ _id: varientId, listing: listingId }, 'user-1'); + expect(newObject).not.toHaveBeenCalled(); + expect(deleteObject).not.toHaveBeenCalled(); + }); + + it('creates listing varients from product skus when none exist', async () => { + const skuBId = new mongoose.Types.ObjectId(); + const createdVarientId = new mongoose.Types.ObjectId(); + const productSkus = [{ _id: productSkuId }, { _id: skuBId }]; + const syncedVarients = [ + { _id: varientId, listing: listingId, product: productId, productSku: productSkuId }, + { _id: createdVarientId, listing: listingId, product: productId, productSku: skuBId }, + ]; + + const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue(); + jest + .spyOn(listingVarientModel, 'find') + .mockReturnValueOnce(mockFind([])) + .mockReturnValueOnce(mockFind(syncedVarients)); + jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind(productSkus)); + newObject.mockResolvedValue({ _id: createdVarientId }); + + await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1'); + + expect(productSkuModel.find).toHaveBeenCalledWith({ product: productId }); + expect(editObject).not.toHaveBeenCalled(); + expect(deleteObject).not.toHaveBeenCalled(); + expect(newObject).toHaveBeenCalledTimes(2); + expect(newObject).toHaveBeenCalledWith({ + model: listingVarientModel, + newData: expect.objectContaining({ + listing: listingId, + product: productId, + productSku: productSkuId, + state: { type: 'draft' }, + }), + user: 'user-1', + recalculate: false, + }); + expect(recalculate).toHaveBeenCalledTimes(2); + }); + + it('creates missing listing varients when one already matches a sku', async () => { + const skuBId = new mongoose.Types.ObjectId(); + const skuCId = new mongoose.Types.ObjectId(); + const existingVarient = { + _id: varientId, + listing: listingId, + product: productId, + productSku: productSkuId, + }; + + const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue(); + jest + .spyOn(listingVarientModel, 'find') + .mockReturnValueOnce(mockFind([existingVarient])) + .mockReturnValueOnce( + mockFind([ + existingVarient, + { _id: new mongoose.Types.ObjectId(), listing: listingId, product: productId, productSku: skuBId }, + { _id: new mongoose.Types.ObjectId(), listing: listingId, product: productId, productSku: skuCId }, + ]) + ); + jest + .spyOn(productSkuModel, 'find') + .mockReturnValue(mockFind([{ _id: productSkuId }, { _id: skuBId }, { _id: skuCId }])); + newObject.mockResolvedValue({ _id: new mongoose.Types.ObjectId() }); + + await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1'); + + expect(editObject).not.toHaveBeenCalled(); + expect(newObject).toHaveBeenCalledTimes(2); + expect(newObject).toHaveBeenCalledWith({ + model: listingVarientModel, + newData: expect.objectContaining({ + listing: listingId, + product: productId, + productSku: skuBId, + state: { type: 'draft' }, + }), + user: 'user-1', + recalculate: false, + }); + expect(recalculate).toHaveBeenCalledTimes(3); + }); + + it('rebuilds listing varients when an existing varient is missing a product sku', async () => { + const skuBId = new mongoose.Types.ObjectId(); + jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue(); + jest + .spyOn(listingVarientModel, 'find') + .mockReturnValueOnce( + mockFind([{ _id: varientId, listing: listingId, product: productId }]) + ) + .mockReturnValueOnce( + mockFind([ + { _id: varientId, listing: listingId, product: productId, productSku: productSkuId }, + { + _id: new mongoose.Types.ObjectId(), + listing: listingId, + product: productId, + productSku: skuBId, + }, + ]) + ); + jest + .spyOn(productSkuModel, 'find') + .mockReturnValue(mockFind([{ _id: productSkuId }, { _id: skuBId }])); + editObject.mockResolvedValue({}); + newObject.mockResolvedValue({ _id: new mongoose.Types.ObjectId() }); + + await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1'); + + expect(editObject).toHaveBeenCalledWith({ + model: listingVarientModel, + id: varientId, + updateData: expect.objectContaining({ + product: productId, + productSku: productSkuId, + }), + user: 'user-1', + recalculate: false, + }); + expect(newObject).toHaveBeenCalledTimes(1); + }); + + it('does not rebuild varients when they already match the product skus', async () => { + const existingVarient = { + _id: varientId, + listing: listingId, + product: productId, + productSku: productSkuId, + }; + const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue(); + jest.spyOn(listingVarientModel, 'find').mockReturnValue(mockFind([existingVarient])); + jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind([{ _id: productSkuId }])); + + await listingModel.recalculate( + { _id: listingId, product: productId, stockLocation: stockLocationId }, + 'user-1' + ); + + expect(newObject).not.toHaveBeenCalled(); + expect(deleteObject).not.toHaveBeenCalled(); + expect(editObject).not.toHaveBeenCalled(); + expect(recalculate).toHaveBeenCalledTimes(1); + }); + + it('creates and updates listing varients to match product skus when a product differs', async () => { + const otherProductId = new mongoose.Types.ObjectId(); + const skuBId = new mongoose.Types.ObjectId(); + const skuCId = new mongoose.Types.ObjectId(); + const createdVarientId = new mongoose.Types.ObjectId(); + const existingVarients = [{ _id: varientId, listing: listingId, product: otherProductId }]; + const productSkus = [{ _id: productSkuId }, { _id: skuBId }, { _id: skuCId }]; + const syncedVarients = [ + { _id: varientId, listing: listingId, product: productId, productSku: productSkuId }, + { _id: createdVarientId, listing: listingId, product: productId, productSku: skuBId }, + { _id: new mongoose.Types.ObjectId(), listing: listingId, product: productId, productSku: skuCId }, + ]; + + const recalculate = jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue(); + jest + .spyOn(listingVarientModel, 'find') + .mockReturnValueOnce(mockFind(existingVarients)) + .mockReturnValueOnce(mockFind(syncedVarients)); + jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind(productSkus)); + editObject.mockResolvedValue({}); + newObject.mockResolvedValue({ _id: createdVarientId }); + + await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1'); + + expect(editObject).toHaveBeenCalledWith({ + model: listingVarientModel, + id: varientId, + updateData: expect.objectContaining({ + product: productId, + productSku: productSkuId, + }), + user: 'user-1', + recalculate: false, + }); + expect(newObject).toHaveBeenCalledTimes(2); + expect(newObject).toHaveBeenCalledWith({ + model: listingVarientModel, + newData: expect.objectContaining({ + listing: listingId, + product: productId, + productSku: skuBId, + state: { type: 'draft' }, + }), + user: 'user-1', + recalculate: false, + }); + expect(deleteObject).not.toHaveBeenCalled(); + expect(recalculate).toHaveBeenCalledTimes(3); + }); + + it('deletes extra listing varients when the product has fewer skus', async () => { + const otherProductId = new mongoose.Types.ObjectId(); + const extraVarientId = new mongoose.Types.ObjectId(); + const existingVarients = [ + { _id: varientId, listing: listingId, product: otherProductId }, + { _id: extraVarientId, listing: listingId, product: otherProductId }, + ]; + + jest.spyOn(listingVarientModel, 'recalculate').mockResolvedValue(); + jest + .spyOn(listingVarientModel, 'find') + .mockReturnValueOnce(mockFind(existingVarients)) + .mockReturnValueOnce( + mockFind([{ _id: varientId, listing: listingId, product: productId, productSku: productSkuId }]) + ); + jest.spyOn(productSkuModel, 'find').mockReturnValue(mockFind([{ _id: productSkuId }])); + editObject.mockResolvedValue({}); + deleteObject.mockResolvedValue({}); + + await listingModel.recalculate({ _id: listingId, product: productId }, 'user-1'); + + expect(editObject).toHaveBeenCalledTimes(1); + expect(newObject).not.toHaveBeenCalled(); + expect(deleteObject).toHaveBeenCalledWith({ + model: listingVarientModel, + id: extraVarientId, + user: 'user-1', + }); + }); +}); + describe('listingVarient.recalculate', () => { beforeEach(() => { aggregateRollups.mockReset(); diff --git a/src/database/schemas/inventory/partstock.schema.js b/src/database/schemas/inventory/partstock.schema.js index b640f0e..5de8d41 100644 --- a/src/database/schemas/inventory/partstock.schema.js +++ b/src/database/schemas/inventory/partstock.schema.js @@ -1,7 +1,7 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; const { Schema } = mongoose; -import { aggregateRollups, aggregateRollupsHistory } from '../../database.js'; +import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js'; // Define the main partStock schema const partStockSchema = new Schema( @@ -11,6 +11,7 @@ const partStockSchema = new Schema( type: { type: String, required: true }, progress: { type: Number, required: false }, }, + part: { type: mongoose.Schema.Types.ObjectId, ref: 'part', required: true }, partSku: { type: mongoose.Schema.Types.ObjectId, ref: 'partSku', required: true }, stockLocation: { type: mongoose.Schema.Types.ObjectId, @@ -32,6 +33,13 @@ const partStockSchema = new Schema( partStockSchema.index({ sourceType: 'text', 'state.type': 'text' }); +partStockSchema.pre('validate', async function () { + if (!this.part && this.partSku) { + const sku = await mongoose.model('partSku').findById(this.partSku).select('part').lean(); + if (sku?.part) this.part = sku.part; + } +}); + const rollupConfigs = [ { name: 'totalCurrentQuantity', @@ -61,6 +69,22 @@ partStockSchema.statics.history = async function (from, to) { return results; }; +partStockSchema.statics.recalculate = async function (partStock, user) { + if (!partStock?._id) return; + if (partStock.state?.type === 'draft' || partStock.state?.type === 'consumed') return; + if ((Number(partStock.currentQuantity) || 0) > 0) return; + + await editObject({ + model: this, + id: partStock._id, + updateData: { + state: { ...(partStock.state || {}), type: 'consumed', progress: 0 }, + }, + user, + recalculate: false, + }); +}; + // Add virtual id getter partStockSchema.virtual('id').get(function () { return this._id; diff --git a/src/database/schemas/inventory/productstock.schema.js b/src/database/schemas/inventory/productstock.schema.js index 48dd9bd..c635642 100644 --- a/src/database/schemas/inventory/productstock.schema.js +++ b/src/database/schemas/inventory/productstock.schema.js @@ -3,12 +3,25 @@ import { generateId } from '../../utils.js'; const { Schema } = mongoose; import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js'; -const partStockUsageSchema = new Schema({ - partStock: { type: Schema.Types.ObjectId, ref: 'partStock', required: false }, +const partStockListItemSchema = new Schema({ + part: { type: Schema.Types.ObjectId, ref: 'part', required: true }, partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true }, - quantity: { type: Number, required: true }, + partStocks: [{ type: Schema.Types.ObjectId, ref: 'partStock', required: false }], + requiredQuantity: { type: Number, required: true }, }); +partStockListItemSchema.virtual('remainingQuantity').get(function () { + const required = this.requiredQuantity || 0; + const stocks = Array.isArray(this.partStocks) ? this.partStocks : []; + const available = stocks.reduce( + (sum, stock) => sum + (Number(stock?.currentQuantity) || 0), + 0 + ); + return required - available; +}); + +partStockListItemSchema.set('toJSON', { virtuals: true }); + const toId = (value) => { if (value == null) return null; if (typeof value === 'object' && value._id) return String(value._id); @@ -24,6 +37,7 @@ const productStockSchema = new Schema( progress: { type: Number, required: false }, }, postedAt: { type: Date, required: false }, + product: { type: mongoose.Schema.Types.ObjectId, ref: 'product', required: true }, productSku: { type: mongoose.Schema.Types.ObjectId, ref: 'productSku', required: true }, stockLocation: { type: mongoose.Schema.Types.ObjectId, @@ -37,13 +51,28 @@ const productStockSchema = new Schema( timestamp: { type: Date, default: Date.now }, }, ], - partStocks: [partStockUsageSchema], + partStockList: [partStockListItemSchema], }, { timestamps: true } ); productStockSchema.index({ 'state.type': 'text' }); +productStockSchema.pre('validate', async function () { + if (!this.product && this.productSku) { + const sku = await mongoose.model('productSku').findById(this.productSku).select('product').lean(); + if (sku?.product) this.product = sku.product; + } + if (this.partStockList?.length) { + for (const item of this.partStockList) { + if (!item.part && item.partSku) { + const sku = await mongoose.model('partSku').findById(item.partSku).select('part').lean(); + if (sku?.part) item.part = sku.part; + } + } + } +}); + const rollupConfigs = [ { name: 'totalCurrentQuantity', @@ -56,9 +85,9 @@ const rollupConfigs = [ rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }], }, { - name: 'posted', - filter: { 'state.type': 'posted' }, - rollups: [{ name: 'posted', property: 'state.type', operation: 'count' }], + name: 'new', + filter: { 'state.type': 'new' }, + rollups: [{ name: 'new', property: 'state.type', operation: 'count' }], }, ]; @@ -83,13 +112,30 @@ productStockSchema.statics.history = async function (from, to) { }; productStockSchema.statics.recalculate = async function (productStock, user) { + if ( + productStock?._id && + productStock.state?.type !== 'draft' && + productStock.state?.type !== 'consumed' && + (Number(productStock.currentQuantity) || 0) <= 0 + ) { + await editObject({ + model: this, + id: productStock._id, + updateData: { + state: { ...(productStock.state || {}), type: 'consumed', progress: 0 }, + }, + user, + recalculate: false, + }); + } + const productSkuId = toId(productStock?.productSku); const stockLocationId = toId(productStock?.stockLocation); if (!productSkuId || !stockLocationId) { return; } - let productId = toId(productStock?.productSku?.product); + let productId = toId(productStock?.product) || toId(productStock?.productSku?.product); if (!productId) { const productSku = await mongoose.model('productSku').findById(productSkuId).select('product').lean(); productId = toId(productSku?.product); diff --git a/src/database/schemas/inventory/stockevent.schema.js b/src/database/schemas/inventory/stockevent.schema.js index bebc78f..0f570af 100644 --- a/src/database/schemas/inventory/stockevent.schema.js +++ b/src/database/schemas/inventory/stockevent.schema.js @@ -12,7 +12,7 @@ const parentStockModelNames = { const initialStockStates = { filamentStock: 'unconsumed', partStock: 'new', - productStock: 'posted', + productStock: 'new', }; const getStartingAmount = (parentType, parentStock) => { @@ -75,7 +75,7 @@ const getStockEventTotal = async (parentId, parentType) => { }; }; -const buildParentUpdateData = (parentType, parentStock, events) => { +const buildParentUpdateData = (parentType, parentStock, events, stockEvent) => { const updateData = {}; let currentAmount; @@ -87,8 +87,16 @@ const buildParentUpdateData = (parentType, parentStock, events) => { updateData.currentWeight = { net, gross }; currentAmount = net; } else { - updateData.currentQuantity = events.total; - currentAmount = events.total; + const eventValue = Number(stockEvent?.value); + if (Number.isFinite(eventValue) && eventValue < 0) { + updateData.currentQuantity = Math.max( + 0, + (Number(parentStock.currentQuantity) || 0) + eventValue + ); + } else { + updateData.currentQuantity = events.total; + } + currentAmount = updateData.currentQuantity; } const state = buildParentState( @@ -164,7 +172,7 @@ const appendParentHistoryIfChanged = ( }; }; -const recalculateParentStock = async (parentType, parentId, user) => { +const recalculateParentStock = async (parentType, parentId, user, stockEvent) => { if (!parentType || !parentId) return; const modelName = parentStockModelNames[parentType]; @@ -174,7 +182,6 @@ const recalculateParentStock = async (parentType, parentId, user) => { const parentStock = await getObject({ model: parentModel, id: parentId, - cached: true, }); if (!parentStock || parentStock.error) return; @@ -187,10 +194,10 @@ const recalculateParentStock = async (parentType, parentId, user) => { updateData: appendParentHistoryIfChanged( parentType, parentStock, - buildParentUpdateData(parentType, parentStock, events) + buildParentUpdateData(parentType, parentStock, events, stockEvent) ), user, - recalculate: parentType === 'productStock', + recalculate: parentType === 'productStock' || parentType === 'partStock', }); }; @@ -217,7 +224,7 @@ const stockEventSchema = new Schema( ownerType: { type: String, required: true, - enum: ['user', 'subJob', 'stockAudit', 'stockTransfer'], + enum: ['user', 'subJob', 'stockAudit', 'stockTransfer', 'productStock'], }, history: [ { @@ -253,7 +260,7 @@ stockEventSchema.statics.recalculate = async function (stockEvent, user) { const parentType = stockEvent.parentType; const parentId = stockEvent.parent?._id || stockEvent.parent; - await recalculateParentStock(parentType, parentId, user); + await recalculateParentStock(parentType, parentId, user, stockEvent); }; // Add virtual id getter diff --git a/src/database/schemas/management/productsku.schema.js b/src/database/schemas/management/productsku.schema.js index 43db50b..59ac71e 100644 --- a/src/database/schemas/management/productsku.schema.js +++ b/src/database/schemas/management/productsku.schema.js @@ -23,7 +23,7 @@ const productSkuSchema = new Schema( overridePrice: { type: Boolean, default: false }, margin: { type: Number, required: false }, amount: { type: Number, required: false }, - parts: [partSkuUsageSchema], + parts: { type: [partSkuUsageSchema], default: [] }, priceTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false }, costTaxRate: { type: Schema.Types.ObjectId, ref: 'taxRate', required: false }, priceWithTax: { type: Number, required: false }, diff --git a/src/database/schemas/sales/listing.schema.js b/src/database/schemas/sales/listing.schema.js index 4a7a548..3043afc 100644 --- a/src/database/schemas/sales/listing.schema.js +++ b/src/database/schemas/sales/listing.schema.js @@ -1,5 +1,6 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +import { editObject, newObject, deleteObject } from '../../database.js'; const { Schema } = mongoose; const listingSchema = new Schema( @@ -72,4 +73,102 @@ listingSchema.set('toJSON', { }, }); +const refId = (value) => value?._id ?? value; + +listingSchema.statics.recalculate = async function (listing, user) { + const listingId = refId(listing); + if (!listingId) { + return; + } + + const listingVarientModel = mongoose.model('listingVarient'); + const productSkuModel = mongoose.model('productSku'); + const listingProductId = refId(listing.product); + + const findVarients = () => + listingVarientModel.find({ listing: listingId }).sort({ createdAt: 1 }).lean(); + + let varients = await findVarients(); + + if (listingProductId) { + const productSkus = await productSkuModel + .find({ product: listingProductId }) + .sort({ createdAt: 1 }) + .lean(); + + const varientsBySkuId = new Map(); + const unmatchedVarients = []; + for (const varient of varients) { + const skuId = refId(varient.productSku); + if (skuId && !varientsBySkuId.has(String(skuId))) { + varientsBySkuId.set(String(skuId), varient); + } else { + unmatchedVarients.push(varient); + } + } + + for (const sku of productSkus) { + const skuId = sku._id; + const varientUpdateData = { + product: listingProductId, + productSku: skuId, + }; + const existingVarient = varientsBySkuId.get(String(skuId)) || unmatchedVarients.shift(); + + if (existingVarient) { + varientsBySkuId.delete(String(skuId)); + const existingProductId = refId(existingVarient.product); + const existingSkuId = refId(existingVarient.productSku); + if ( + String(existingProductId) === String(listingProductId) && + String(existingSkuId) === String(skuId) + ) { + continue; + } + const varientResult = await editObject({ + model: listingVarientModel, + id: existingVarient._id, + updateData: varientUpdateData, + user, + recalculate: false, + }); + if (varientResult.error) { + throw varientResult; + } + } else { + const varientResult = await newObject({ + model: listingVarientModel, + newData: { + ...varientUpdateData, + listing: listingId, + state: { type: listing.state?.type || 'draft' }, + }, + user, + recalculate: false, + }); + if (varientResult.error) { + throw varientResult; + } + } + } + + for (const extra of [...varientsBySkuId.values(), ...unmatchedVarients]) { + const deleteResult = await deleteObject({ + model: listingVarientModel, + id: extra._id, + user, + }); + if (deleteResult.error) { + throw deleteResult; + } + } + + varients = await findVarients(); + } + + for (const varient of varients) { + await listingVarientModel.recalculate(varient, user); + } +}; + export const listingModel = mongoose.model('listing', listingSchema); diff --git a/src/database/schemas/sales/listingvarient.schema.js b/src/database/schemas/sales/listingvarient.schema.js index 2010b5e..164f04d 100644 --- a/src/database/schemas/sales/listingvarient.schema.js +++ b/src/database/schemas/sales/listingvarient.schema.js @@ -35,7 +35,14 @@ const listingVarientSchema = new Schema( ); listingVarientSchema.index({ currency: 'text', 'state.type': 'text' }); -listingVarientSchema.index({ listing: 1, externalReference: 1 }, { unique: true, sparse: true }); +listingVarientSchema.index( + { listing: 1, externalReference: 1 }, + { + unique: true, + name: 'listing_1_externalReference_1', + partialFilterExpression: { externalReference: { $type: 'string', $gt: '' } }, + } +); listingVarientSchema.virtual('id').get(function () { return this._id; @@ -121,3 +128,22 @@ listingVarientSchema.statics.recalculate = async function (listingVarient, user) }; export const listingVarientModel = mongoose.model('listingVarient', listingVarientSchema); + +async function replaceSparseExternalReferenceIndex() { + try { + const indexes = await listingVarientModel.collection.indexes(); + const current = indexes.find((idx) => idx.name === 'listing_1_externalReference_1'); + if (current && (current.sparse || !current.partialFilterExpression)) { + await listingVarientModel.collection.dropIndex('listing_1_externalReference_1'); + } + await listingVarientModel.createIndexes(); + } catch { + // Collection/index may not exist until Mongo is connected. + } +} + +if (mongoose.connection.readyState === 1) { + replaceSparseExternalReferenceIndex(); +} else { + mongoose.connection.once('open', replaceSparseExternalReferenceIndex); +} diff --git a/src/services/inventory/partstocks.js b/src/services/inventory/partstocks.js index 5a1ad86..fd498b1 100644 --- a/src/services/inventory/partstocks.js +++ b/src/services/inventory/partstocks.js @@ -19,6 +19,13 @@ import { const logger = log4js.getLogger('Part Stocks'); logger.level = config.server.logLevel; +const PART_STOCK_POPULATE = [ + { path: 'part' }, + { path: 'partSku', populate: 'part' }, + { path: 'stockLocation' }, + { path: 'source' }, +]; + export const listPartStocksRouteHandler = async ( req, res, @@ -39,7 +46,7 @@ export const listPartStocksRouteHandler = async ( search, sort, order, - populate: [{ path: 'partSku' }, { path: 'stockLocation' }, { path: 'source' }], + populate: PART_STOCK_POPULATE, }); if (result?.error) { @@ -63,7 +70,7 @@ export const listPartStocksByPropertiesRouteHandler = async ( model: partStockModel, properties, filter, - populate: ['partSku', 'stockLocation', 'source'], + populate: PART_STOCK_POPULATE, masterFilter, }); @@ -98,7 +105,7 @@ export const getPartStockRouteHandler = async (req, res) => { const result = await getObject({ model: partStockModel, id, - populate: [{ path: 'partSku' }, { path: 'stockLocation' }, { path: 'source' }], + populate: PART_STOCK_POPULATE, }); if (result?.error) { logger.warn(`Part Stock not found with supplied id.`); @@ -120,7 +127,7 @@ export const editPartStockRouteHandler = async (req, res) => { id, updateData, user: req.user, - populate: [{ path: 'partSku' }, { path: 'stockLocation' }, { path: 'source' }], + populate: PART_STOCK_POPULATE, }); if (result.error) { @@ -165,6 +172,7 @@ export const newPartStockRouteHandler = async (req, res) => { updatedAt: new Date(), startingQuantity: req.body.startingQuantity, currentQuantity: req.body.currentQuantity, + part: req.body.part, partSku: req.body.partSku, state: req.body.state, sourceType: req.body.sourceType, diff --git a/src/services/inventory/productstocks.js b/src/services/inventory/productstocks.js index 349cb7d..f37dcc0 100644 --- a/src/services/inventory/productstocks.js +++ b/src/services/inventory/productstocks.js @@ -18,9 +18,74 @@ import { getObjectNeighbors, } from '../../database/database.js'; import { productSkuModel } from '../../database/schemas/management/productsku.schema.js'; +import { stockEventModel } from '../../database/schemas/inventory/stockevent.schema.js'; const logger = log4js.getLogger('Product Stocks'); logger.level = config.server.logLevel; +const toStockId = (value) => { + if (value == null) return null; + if (typeof value === 'object' && value._id != null) return value._id; + return value; +}; + +const getPartStockAvailable = (partStock, remainingById) => { + const id = String(toStockId(partStock) || ''); + if (id && remainingById.has(id)) return remainingById.get(id); + return Math.max(0, Number(partStock?.currentQuantity) || 0); +}; + +const consumePartStocksForItem = (item, remainingById) => { + let remaining = Number(item?.requiredQuantity) || 0; + const partStocks = Array.isArray(item?.partStocks) ? item.partStocks : []; + const deductions = []; + + for (const partStock of partStocks) { + if (remaining <= 0) break; + const partStockId = toStockId(partStock); + if (partStockId == null) continue; + + const available = getPartStockAvailable(partStock, remainingById); + if (available <= 0) continue; + + const subtracted = Math.min(available, remaining); + remaining -= subtracted; + remainingById.set(String(partStockId), available - subtracted); + deductions.push({ partStockId, subtracted }); + } + + return { remaining, deductions }; +}; + +const createPartStockConsumptionEvent = async ({ + partStockId, + subtracted, + productStockId, + user, +}) => { + return newObject({ + model: stockEventModel, + newData: { + value: -Math.abs(subtracted), + unit: 'qty', + parent: partStockId, + parentType: 'partStock', + owner: productStockId, + ownerType: 'productStock', + timestamp: new Date(), + }, + user, + }); +}; + +const PRODUCT_STOCK_POPULATE = [ + 'product', + { path: 'partStockList.part' }, + { path: 'partStockList.partSku', populate: 'part' }, + { path: 'partStockList.partStocks' }, + { path: 'productSku', populate: ['parts.part', 'parts.partSku', 'product'] }, + { path: 'stockLocation' }, +]; + export const listProductStocksRouteHandler = async ( req, res, @@ -41,11 +106,7 @@ export const listProductStocksRouteHandler = async ( search, sort, order, - populate: [ - { path: 'productSku' }, - { path: 'partStocks.partStock' }, - { path: 'stockLocation' }, - ], + populate: PRODUCT_STOCK_POPULATE, }); if (result?.error) { @@ -69,7 +130,7 @@ export const listProductStocksByPropertiesRouteHandler = async ( model: productStockModel, properties, filter, - populate: ['productSku', 'partStocks.partStock', 'stockLocation'], + populate: PRODUCT_STOCK_POPULATE, masterFilter, }); @@ -104,12 +165,7 @@ export const getProductStockRouteHandler = async (req, res) => { const result = await getObject({ model: productStockModel, id, - populate: [ - { path: 'partStocks.partSku' }, - { path: 'partStocks.partStock' }, - { path: 'productSku' }, - { path: 'stockLocation' }, - ], + populate: PRODUCT_STOCK_POPULATE, }); if (result?.error) { logger.warn(`Product Stock not found with supplied id.`); @@ -139,12 +195,11 @@ export const editProductStockRouteHandler = async (req, res) => { } const updateData = { + product: req.body?.product, + productSku: req.body?.productSku, stockLocation: req.body?.stockLocation, - partStocks: req.body?.partStocks?.map((partStock) => ({ - quantity: partStock.quantity, - partStock: partStock.partStock, - partSku: partStock.partSku, - })), + currentQuantity: req.body?.currentQuantity, + partStockList: req.body?.partStockList, }; const result = await editObject({ @@ -152,12 +207,7 @@ export const editProductStockRouteHandler = async (req, res) => { id, updateData, user: req.user, - populate: [ - { path: 'partStocks.partSku' }, - { path: 'partStocks.partStock' }, - { path: 'productSku' }, - { path: 'stockLocation' }, - ], + populate: PRODUCT_STOCK_POPULATE, }); if (result.error) { @@ -203,16 +253,19 @@ export const newProductStockRouteHandler = async (req, res) => { model: productSkuModel, id: productSkuId, }); + const currentQuantity = Number(req.body.currentQuantity) || 0; const newData = { updatedAt: new Date(), - currentQuantity: req.body.currentQuantity, + currentQuantity, + product: req.body.product, productSku: req.body.productSku, state: req.body.state ?? { type: 'draft' }, stockLocation: req.body.stockLocation, - partStocks: (productSku.parts || []).map((part) => ({ + partStockList: (productSku.parts || []).map((part) => ({ + part: part.part, partSku: part.partSku, - quantity: part.quantity, - partStock: undefined, + requiredQuantity: (Number(part.quantity) || 0) * currentQuantity, + partStocks: [], })), }; const result = await newObject({ @@ -305,9 +358,72 @@ export const postProductStockRouteHandler = async (req, res) => { return; } + const productStock = await getObject({ + model: productStockModel, + id, + populate: PRODUCT_STOCK_POPULATE, + }); + if (productStock?.error) { + logger.error('Error loading product stock to post:', productStock.error); + res.status(productStock.code || 500).send(productStock); + return; + } + + const partStockList = Array.isArray(productStock?.partStockList) + ? productStock.partStockList + : []; + const remainingById = new Map(); + const deductions = []; + + for (const item of partStockList) { + const consumed = consumePartStocksForItem(item, remainingById); + if (consumed.remaining > 0) { + logger.error('Insufficient part stock to post product stock.'); + res.status(400).send({ + error: 'Insufficient part stock to post product stock.', + code: 400, + }); + return; + } + deductions.push(...consumed.deductions); + } + + for (const deduction of deductions) { + const stockEventResult = await createPartStockConsumptionEvent({ + partStockId: deduction.partStockId, + subtracted: deduction.subtracted, + productStockId: id, + user: req.user, + }); + if (stockEventResult?.error) { + logger.error('Error creating stock event:', stockEventResult.error); + res.status(stockEventResult.code || 500).send(stockEventResult); + return; + } + } + + const initialStockEventResult = await newObject({ + model: stockEventModel, + newData: { + value: productStock.currentQuantity, + unit: 'qty', + parent: { _id: id }, + parentType: 'productStock', + owner: { _id: req.user._id }, + ownerType: 'user', + }, + recalculate: true, + user: req.user, + }); + if (initialStockEventResult?.error) { + logger.error('Error creating initial stock event:', initialStockEventResult.error); + res.status(initialStockEventResult.code || 500).send(initialStockEventResult); + return; + } + const updateData = { updatedAt: new Date(), - state: { type: 'posted' }, + state: { type: 'new' }, postedAt: new Date(), }; const result = await editObject({ @@ -315,6 +431,7 @@ export const postProductStockRouteHandler = async (req, res) => { id, updateData, user: req.user, + populate: PRODUCT_STOCK_POPULATE, }); if (result.error) { @@ -326,6 +443,7 @@ export const postProductStockRouteHandler = async (req, res) => { logger.debug(`Posted product stock with ID: ${id}`); res.send(result); }; + export const getProductStockNeighborsRouteHandler = async ( req, res, @@ -357,4 +475,3 @@ export const getProductStockNeighborsRouteHandler = async ( logger.debug(`Retrieved productStock neighbors for ID: ${id}`); res.send(result); }; - diff --git a/src/services/inventory/stocktransfers.js b/src/services/inventory/stocktransfers.js index 58df26c..da4bae7 100644 --- a/src/services/inventory/stocktransfers.js +++ b/src/services/inventory/stocktransfers.js @@ -153,6 +153,7 @@ async function executePostedLine(transferId, line, user) { const dest = await createStock( partStockModel, { + part: src.part, partSku: src.partSku, currentQuantity: line.quantity, state: { type: 'new' }, @@ -187,11 +188,12 @@ async function executePostedLine(transferId, line, user) { const dest = await createStock( productStockModel, { + product: src.product, productSku: src.productSku, currentQuantity: line.quantity, - state: { type: 'posted' }, + state: { type: 'new' }, postedAt: new Date(), - partStocks: [], + partStockList: [], stockLocation: toLocId, }, user diff --git a/src/services/misc/export.js b/src/services/misc/export.js index 0bbaa5a..1473033 100644 --- a/src/services/misc/export.js +++ b/src/services/misc/export.js @@ -16,7 +16,7 @@ export const EXPORT_FILTER_BY_TYPE = { filament: ['material', 'material._id', 'vendor', 'name', 'diameter', 'cost'], filamentSku: ['filament', 'filament.vendor', 'costTaxRate'], material: ['name', 'tags'], - partStock: ['partSku'], + partStock: ['part', 'partSku'], partSku: ['part', 'vendor', 'priceTaxRate', 'costTaxRate'], productCategory: ['name'], product: ['productCategory', 'productCategory._id', 'vendor', 'priceTaxRate', 'costTaxRate'], diff --git a/src/services/sales/listings.js b/src/services/sales/listings.js index 855e43e..111c6d9 100644 --- a/src/services/sales/listings.js +++ b/src/services/sales/listings.js @@ -179,9 +179,20 @@ export const editListingRouteHandler = async (req, res) => { return; } - const marketplaceId = result.marketplace?._id || result.marketplace; - if (marketplaceId) { - pushToMarketplace(marketplaceId, { _id: id }, req.user, { isNew: false }); + const checkStatesResult = await checkStates({ model: listingModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking listing states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult == false) { + const marketplaceId = result.marketplace?._id || result.marketplace; + if (marketplaceId) { + pushToMarketplace(marketplaceId, { _id: id }, req.user, { isNew: false }); + } + return; } logger.debug(`Edited listing with ID: ${id}`); @@ -212,21 +223,6 @@ export const newListingRouteHandler = async (req, res) => { return res.status(result.code).send(result); } - try { - await newObject({ - model: listingVarientModel, - newData: { - listing: result._id, - state: { type: 'draft' }, - product: req.body.product || undefined, - }, - user: req.user, - }); - logger.debug(`Created default listing varient for listing ${result._id}`); - } catch (err) { - logger.warn(`Failed to create default listing varient: ${err.message}`); - } - const newMarketplaceId = result.marketplace?._id || result.marketplace; if (newMarketplaceId) { pushToMarketplace(newMarketplaceId, { _id: result._id }, req.user, { isNew: true }); diff --git a/src/utils.js b/src/utils.js index 2ce56c3..9ce604e 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1524,31 +1524,84 @@ function flatternObjectIds(object) { return result; } +function isByte(value) { + const num = Number(value); + return Number.isInteger(num) && num >= 0 && num <= 255; +} + +function isTwelveByteBuffer(val) { + if (!val) return false; + if (Buffer.isBuffer(val) || val instanceof Uint8Array) { + return val.length === 12; + } + if (typeof val !== 'object' || Array.isArray(val)) return false; + for (let i = 0; i < 12; i++) { + if (!isByte(val[i])) return false; + } + const indexKeys = Object.keys(val).filter((key) => /^\d+$/.test(key)); + return indexKeys.length === 12; +} + +function objectIdToString(val) { + if (val == null) return val; + if (typeof val === 'string') return val; + if (typeof val.toHexString === 'function') return val.toHexString(); + if (val instanceof mongoose.Types.ObjectId) return val.toString(); + if (isTwelveByteBuffer(val)) { + const bytes = + Buffer.isBuffer(val) || val instanceof Uint8Array + ? val + : Array.from({ length: 12 }, (_, i) => Number(val[i])); + return Buffer.from(bytes).toString('hex'); + } + if (val && typeof val === 'object' && isTwelveByteBuffer(val.buffer || val.id)) { + return objectIdToString(val.buffer || val.id); + } + return String(val); +} + function expandObjectIds(input) { const excludedFields = ['createdAt', 'updatedAt', 'name', '_id']; - // Helper to check if a value is an ObjectId or a 24-char hex string + function isObjectId(val) { - // Check for MongoDB ObjectId instance + if (val == null || typeof val === 'boolean' || typeof val === 'number') return false; + if (val instanceof Date) return false; if (val instanceof mongoose.Types.ObjectId) return true; - // Check for exactly 24 hex characters (no special characters) if (typeof val === 'string' && /^[a-fA-F\d]{24}$/.test(val)) return true; + if (typeof val === 'object' && (val._bsontype === 'ObjectId' || val._bsontype === 'ObjectID')) { + return true; + } + if (typeof val.toHexString === 'function' && isTwelveByteBuffer(val.id || val.buffer)) { + return true; + } + if (isTwelveByteBuffer(val)) return true; + if (val && typeof val === 'object' && !Array.isArray(val)) { + const keys = Object.keys(val); + if ( + keys.length > 0 && + keys.every((key) => key === 'buffer' || key === 'id') && + isTwelveByteBuffer(val.buffer || val.id) + ) { + return true; + } + } return false; } - // Recursive function function expand(value) { if (Array.isArray(value)) { return value.map(expand); } else if (value instanceof Date) { return value; - } else if (value && typeof value === 'object' && !(value instanceof mongoose.Types.ObjectId)) { + } else if (isObjectId(value)) { + return { _id: objectIdToString(value) }; + } else if (value && typeof value === 'object') { var result = {}; for (const [key, val] of Object.entries(value)) { if (excludedFields.includes(key)) { - // Do not expand keys that are excluded - result[key] = val == null ? val : val.toString(); + result[key] = val == null ? val : key === '_id' ? objectIdToString(val) : val.toString(); } else if (isObjectId(val)) { - result[key] = { _id: val }; + result[key] = { _id: objectIdToString(val) }; } else if (Array.isArray(val)) { result[key] = val.map(expand); } else if (val instanceof Date) { @@ -1560,8 +1613,6 @@ function expandObjectIds(input) { } } return result; - } else if (isObjectId(value)) { - return { _id: value }; } else { return value; }