From 4109523f59679e6eb6cee880143d7a3055238104 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Tue, 25 Aug 2026 01:59:22 +0100 Subject: [PATCH] Add stock quantity recalculation tests and enhance inventory schemas - Introduced a new test suite for stock quantity recalculation in `stockQuantity.recalculate.test.js`, ensuring comprehensive coverage of listing variant recalculation logic. - Enhanced the `filamentstock`, `partstock`, and `productstock` schemas by adding new rollup configurations for tracking unconsumed, used, and consumed states. - Implemented a pre-validation hook in the `partstock` and `productstock` schemas to automatically set the `part` field based on the associated `partSku`. - Updated the `stockevent` schema to include new rollup configurations and statistics methods for better inventory management. - Improved the `listing` and `listingvarient` schemas with additional recalculation logic and statistics methods to enhance data integrity and reporting capabilities. --- .../stockQuantity.recalculate.test.js | 412 ++++++++++++++++++ .../schemas/inventory/filamentstock.schema.js | 15 + .../schemas/inventory/partstock.schema.js | 41 +- .../schemas/inventory/productstock.schema.js | 72 ++- .../schemas/inventory/stockevent.schema.js | 67 ++- .../schemas/management/productsku.schema.js | 2 +- src/database/schemas/sales/client.schema.js | 34 ++ src/database/schemas/sales/listing.schema.js | 152 +++++++ .../schemas/sales/listingvarient.schema.js | 74 +++- .../schemas/sales/marketplace.schema.js | 50 ++- 10 files changed, 896 insertions(+), 23 deletions(-) create mode 100644 src/database/schemas/__tests__/stockQuantity.recalculate.test.js diff --git a/src/database/schemas/__tests__/stockQuantity.recalculate.test.js b/src/database/schemas/__tests__/stockQuantity.recalculate.test.js new file mode 100644 index 0000000..20b3be3 --- /dev/null +++ b/src/database/schemas/__tests__/stockQuantity.recalculate.test.js @@ -0,0 +1,412 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import mongoose from 'mongoose'; + +jest.unstable_mockModule('../../database.js', () => ({ + searchObjects: jest.fn(), + getPropertyValues: jest.fn(), + listObjects: jest.fn(), + getObject: jest.fn(), + editObject: jest.fn(), + editObjects: jest.fn(), + newObject: jest.fn(), + deleteObject: jest.fn(), + listObjectsByProperties: jest.fn(), + getModelStats: jest.fn(), + getModelHistory: jest.fn(), + aggregateRollups: jest.fn(), + aggregateRollupsHistory: jest.fn(), + checkStates: jest.fn(), + getObjectNeighbors: jest.fn(), +})); + +jest.unstable_mockModule('../../utils.js', () => ({ + generateId: jest.fn(() => () => 'test-id'), +})); + +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'); +const { productStockModel } = await import('../inventory/productstock.schema.js'); + +const listingId = new mongoose.Types.ObjectId(); +const productId = new mongoose.Types.ObjectId(); +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(); + editObject.mockReset(); + jest.restoreAllMocks(); + }); + + it('sums sibling listing varient stock quantities onto the listing', async () => { + aggregateRollups.mockResolvedValue({ stockQuantity: { sum: 12 } }); + editObject.mockResolvedValue({}); + + await listingVarientModel.recalculate({ listing: listingId, stockQuantity: 4 }, 'user-1'); + + expect(aggregateRollups).toHaveBeenCalledWith( + expect.objectContaining({ + model: listingVarientModel, + baseFilter: { listing: listingId }, + }) + ); + expect(editObject).toHaveBeenCalledWith({ + model: listingModel, + id: listingId, + updateData: { stockQuantity: 12 }, + user: 'user-1', + recalculate: false, + }); + }); + + it('writes the product sku stock total onto the listing varient before rolling up', async () => { + jest.spyOn(listingVarientModel, 'exists').mockResolvedValue({ _id: varientId }); + aggregateRollups.mockImplementation(async ({ model }) => { + if (model === productStockModel) { + return { stockQuantity: { sum: 9 } }; + } + return { stockQuantity: { sum: 12 } }; + }); + editObject.mockResolvedValue({}); + + await listingVarientModel.recalculate( + { + _id: varientId, + listing: { _id: listingId, stockLocation: stockLocationId, product: productId }, + product: productId, + productSku: productSkuId, + stockQuantity: 0, + }, + 'user-1' + ); + + expect(aggregateRollups).toHaveBeenCalledWith( + expect.objectContaining({ + model: productStockModel, + baseFilter: { + productSku: productSkuId, + stockLocation: stockLocationId, + }, + }) + ); + expect(editObject).toHaveBeenCalledWith({ + model: listingVarientModel, + id: varientId, + updateData: { stockQuantity: 9 }, + user: 'user-1', + recalculate: false, + }); + expect(editObject).toHaveBeenCalledWith({ + model: listingModel, + id: listingId, + updateData: { stockQuantity: 12 }, + user: 'user-1', + recalculate: false, + }); + }); +}); + +describe('productStock.recalculate', () => { + beforeEach(() => { + aggregateRollups.mockReset(); + editObject.mockReset(); + jest.restoreAllMocks(); + }); + + it('writes the sku/location total onto matching listing varients', async () => { + aggregateRollups.mockResolvedValue({ stockQuantity: { sum: 9 } }); + editObject.mockResolvedValue({}); + jest.spyOn(productSkuModel, 'findById').mockReturnValue({ + select: () => ({ lean: async () => ({ product: productId }) }), + }); + jest.spyOn(listingVarientModel, 'find').mockReturnValue({ + populate: () => ({ + lean: async () => [ + { + _id: varientId, + product: productId, + productSku: productSkuId, + stockQuantity: 0, + listing: { product: productId, stockLocation: stockLocationId }, + }, + ], + }), + }); + + await productStockModel.recalculate( + { productSku: productSkuId, stockLocation: stockLocationId, currentQuantity: 9 }, + 'user-1' + ); + + expect(aggregateRollups).toHaveBeenCalledWith( + expect.objectContaining({ + model: productStockModel, + baseFilter: { + productSku: productSkuId, + stockLocation: stockLocationId, + }, + }) + ); + expect(editObject).toHaveBeenCalledWith({ + model: listingVarientModel, + id: varientId, + updateData: { stockQuantity: 9 }, + user: 'user-1', + }); + }); +}); diff --git a/src/database/schemas/inventory/filamentstock.schema.js b/src/database/schemas/inventory/filamentstock.schema.js index bfbc16c..21ba3f4 100644 --- a/src/database/schemas/inventory/filamentstock.schema.js +++ b/src/database/schemas/inventory/filamentstock.schema.js @@ -58,6 +58,21 @@ const rollupConfigs = [ filter: {}, rollups: [{ name: 'totalCurrentWeight', property: 'currentWeight.net', operation: 'sum' }], }, + { + name: 'unconsumed', + filter: { 'state.type': 'unconsumed' }, + rollups: [{ name: 'unconsumed', property: 'state.type', operation: 'count' }], + }, + { + name: 'used', + filter: { 'state.type': 'used' }, + rollups: [{ name: 'used', property: 'state.type', operation: 'count' }], + }, + { + name: 'consumed', + filter: { 'state.type': 'consumed' }, + rollups: [{ name: 'consumed', property: 'state.type', operation: 'count' }], + }, ]; filamentStockSchema.statics.stats = async function () { diff --git a/src/database/schemas/inventory/partstock.schema.js b/src/database/schemas/inventory/partstock.schema.js index b640f0e..4d2aaa6 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,12 +33,34 @@ 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', filter: {}, rollups: [{ name: 'totalCurrentQuantity', property: 'currentQuantity', operation: 'sum' }], }, + { + name: 'new', + filter: { 'state.type': 'new' }, + rollups: [{ name: 'new', property: 'state.type', operation: 'count' }], + }, + { + name: 'used', + filter: { 'state.type': 'used' }, + rollups: [{ name: 'used', property: 'state.type', operation: 'count' }], + }, + { + name: 'consumed', + filter: { 'state.type': 'consumed' }, + rollups: [{ name: 'consumed', property: 'state.type', operation: 'count' }], + }, ]; partStockSchema.statics.stats = async function () { @@ -61,6 +84,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..aa21f56 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,19 @@ 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' }], + }, + { + name: 'used', + filter: { 'state.type': 'used' }, + rollups: [{ name: 'used', property: 'state.type', operation: 'count' }], + }, + { + name: 'consumed', + filter: { 'state.type': 'consumed' }, + rollups: [{ name: 'consumed', property: 'state.type', operation: 'count' }], }, ]; @@ -83,13 +122,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..10f3b7c 100644 --- a/src/database/schemas/inventory/stockevent.schema.js +++ b/src/database/schemas/inventory/stockevent.schema.js @@ -1,6 +1,6 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; -import { getObject, editObject } from '../../database.js'; +import { getObject, editObject, aggregateRollups, aggregateRollupsHistory } from '../../database.js'; const { Schema } = mongoose; const parentStockModelNames = { @@ -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: [ { @@ -232,6 +239,44 @@ const stockEventSchema = new Schema( stockEventSchema.index({ parentType: 'text', ownerType: 'text', unit: 'text' }); +const rollupConfigs = [ + { + name: 'partStock', + filter: { parentType: 'partStock' }, + rollups: [{ name: 'partStock', property: 'parentType', operation: 'count' }], + }, + { + name: 'filamentStock', + filter: { parentType: 'filamentStock' }, + rollups: [{ name: 'filamentStock', property: 'parentType', operation: 'count' }], + }, + { + name: 'productStock', + filter: { parentType: 'productStock' }, + rollups: [{ name: 'productStock', property: 'parentType', operation: 'count' }], + }, +]; + +stockEventSchema.statics.stats = async function () { + const results = await aggregateRollups({ + model: this, + rollupConfigs: rollupConfigs, + }); + + return results; +}; + +stockEventSchema.statics.history = async function (from, to) { + const results = await aggregateRollupsHistory({ + model: this, + startDate: from, + endDate: to, + rollupConfigs: rollupConfigs, + }); + + return results; +}; + stockEventSchema.statics.recalculate = async function (stockEvent, user) { const history = stockEvent.history || []; const lastEntry = history.at(-1); @@ -253,7 +298,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/client.schema.js b/src/database/schemas/sales/client.schema.js index 0fcfce9..5588d5d 100644 --- a/src/database/schemas/sales/client.schema.js +++ b/src/database/schemas/sales/client.schema.js @@ -1,5 +1,6 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; +import { aggregateRollups, aggregateRollupsHistory } from '../../database.js'; const addressSchema = new mongoose.Schema({ building: { required: false, type: String }, @@ -36,4 +37,37 @@ clientSchema.virtual('id').get(function () { clientSchema.set('toJSON', { virtuals: true }); +const rollupConfigs = [ + { + name: 'active', + filter: { active: true }, + rollups: [{ name: 'active', property: 'active', operation: 'count' }], + }, + { + name: 'inactive', + filter: { active: false }, + rollups: [{ name: 'inactive', property: 'active', operation: 'count' }], + }, +]; + +clientSchema.statics.stats = async function () { + const results = await aggregateRollups({ + model: this, + rollupConfigs: rollupConfigs, + }); + + return results; +}; + +clientSchema.statics.history = async function (from, to) { + const results = await aggregateRollupsHistory({ + model: this, + startDate: from, + endDate: to, + rollupConfigs: rollupConfigs, + }); + + return results; +}; + export const clientModel = mongoose.model('client', clientSchema); diff --git a/src/database/schemas/sales/listing.schema.js b/src/database/schemas/sales/listing.schema.js index 4a7a548..6b3f08f 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, aggregateRollups, aggregateRollupsHistory } from '../../database.js'; const { Schema } = mongoose; const listingSchema = new Schema( @@ -72,4 +73,155 @@ 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); + } +}; + +const rollupConfigs = [ + { + name: 'draft', + filter: { 'state.type': 'draft' }, + rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }], + }, + { + name: 'active', + filter: { 'state.type': 'active' }, + rollups: [{ name: 'active', property: 'state.type', operation: 'count' }], + }, + { + name: 'inactive', + filter: { 'state.type': 'inactive' }, + rollups: [{ name: 'inactive', property: 'state.type', operation: 'count' }], + }, + { + name: 'syncing', + filter: { 'state.type': 'syncing' }, + rollups: [{ name: 'syncing', property: 'state.type', operation: 'count' }], + }, + { + name: 'suspended', + filter: { 'state.type': 'suspended' }, + rollups: [{ name: 'suspended', property: 'state.type', operation: 'count' }], + }, + { + name: 'deleted', + filter: { 'state.type': 'deleted' }, + rollups: [{ name: 'deleted', property: 'state.type', operation: 'count' }], + }, +]; + +listingSchema.statics.stats = async function () { + const results = await aggregateRollups({ + model: this, + rollupConfigs: rollupConfigs, + }); + + return results; +}; + +listingSchema.statics.history = async function (from, to) { + const results = await aggregateRollupsHistory({ + model: this, + startDate: from, + endDate: to, + rollupConfigs: rollupConfigs, + }); + + return results; +}; + 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 113f7dc..164f04d 100644 --- a/src/database/schemas/sales/listingvarient.schema.js +++ b/src/database/schemas/sales/listingvarient.schema.js @@ -3,6 +3,12 @@ import { generateId } from '../../utils.js'; import { aggregateRollups, editObject } from '../../database.js'; const { Schema } = mongoose; +const toId = (value) => { + if (value == null) return null; + if (typeof value === 'object' && value._id) return String(value._id); + return String(value); +}; + const listingVarientSchema = new Schema( { _reference: { type: String, default: () => generateId()() }, @@ -29,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; @@ -48,6 +61,46 @@ listingVarientSchema.set('toJSON', { listingVarientSchema.statics.recalculate = async function (listingVarient, user) { const listingId = listingVarient?.listing?._id || listingVarient?.listing; + const varientId = listingVarient?._id; + const productSkuId = toId(listingVarient?.productSku); + + if (varientId && productSkuId && (await this.exists({ _id: varientId }))) { + let listing = listingVarient.listing; + if (!listing?.stockLocation) { + listing = await mongoose + .model('listing') + .findById(listingId) + .select('stockLocation product') + .lean(); + } + const stockLocationId = toId(listing?.stockLocation); + if (stockLocationId) { + const stockRollup = await aggregateRollups({ + model: mongoose.model('productStock'), + baseFilter: { + productSku: new mongoose.Types.ObjectId(productSkuId), + stockLocation: new mongoose.Types.ObjectId(stockLocationId), + }, + rollupConfigs: [ + { + name: 'stockQuantity', + rollups: [{ name: 'stockQuantity', property: 'currentQuantity', operation: 'sum' }], + }, + ], + }); + const stockQuantity = stockRollup.stockQuantity?.sum || 0; + if (listingVarient.stockQuantity !== stockQuantity) { + await editObject({ + model: this, + id: varientId, + updateData: { stockQuantity }, + user, + recalculate: false, + }); + } + } + } + if (!listingId) { return; } @@ -75,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/database/schemas/sales/marketplace.schema.js b/src/database/schemas/sales/marketplace.schema.js index 954d79d..85c79a1 100644 --- a/src/database/schemas/sales/marketplace.schema.js +++ b/src/database/schemas/sales/marketplace.schema.js @@ -1,5 +1,5 @@ import mongoose from 'mongoose'; -import { editObject } from '../../database.js'; +import { editObject, aggregateRollups, aggregateRollupsHistory } from '../../database.js'; import { generateId } from '../../utils.js'; const marketplaceSchema = new mongoose.Schema( @@ -56,6 +56,54 @@ marketplaceSchema.statics.recalculate = async function (marketplace, user) { }); }; +const rollupConfigs = [ + { + name: 'ready', + filter: { 'state.type': 'ready' }, + rollups: [{ name: 'ready', property: 'state.type', operation: 'count' }], + }, + { + name: 'syncing', + filter: { 'state.type': 'syncing' }, + rollups: [{ name: 'syncing', property: 'state.type', operation: 'count' }], + }, + { + name: 'disconnected', + filter: { 'state.type': 'disconnected' }, + rollups: [{ name: 'disconnected', property: 'state.type', operation: 'count' }], + }, + { + name: 'inactive', + filter: { 'state.type': 'inactive' }, + rollups: [{ name: 'inactive', property: 'state.type', operation: 'count' }], + }, + { + name: 'offline', + filter: { 'state.type': 'offline' }, + rollups: [{ name: 'offline', property: 'state.type', operation: 'count' }], + }, +]; + +marketplaceSchema.statics.stats = async function () { + const results = await aggregateRollups({ + model: this, + rollupConfigs: rollupConfigs, + }); + + return results; +}; + +marketplaceSchema.statics.history = async function (from, to) { + const results = await aggregateRollupsHistory({ + model: this, + startDate: from, + endDate: to, + rollupConfigs: rollupConfigs, + }); + + return results; +}; + marketplaceSchema.set('toJSON', { virtuals: true }); export const marketplaceModel = mongoose.model('marketplace', marketplaceSchema);