From 0fbf75e33d14fac2165050e372304daeeb47a79e Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Thu, 28 Nov 2024 17:25:45 +0100 Subject: [PATCH] feat(core): Introduce new default MultiChannelStockLocationStrategy Fixes #2356. This commit introduces a much more sophisticated stock location strategy that takes into account the active channel, as well as available stock levels in each available StockLocation. With v3.1.0 it will become the default strategy. --- .../stock-control-multi-location.e2e-spec.ts | 7 +- .../default-stock-location-strategy.ts | 65 ++++-- .../multi-channel-stock-location-strategy.ts | 191 ++++++++++++++++++ packages/core/src/config/default-config.ts | 3 +- packages/core/src/config/index.ts | 1 + 5 files changed, 240 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/config/catalog/multi-channel-stock-location-strategy.ts diff --git a/packages/core/e2e/stock-control-multi-location.e2e-spec.ts b/packages/core/e2e/stock-control-multi-location.e2e-spec.ts index f6a776d1da..0f4513f5e4 100644 --- a/packages/core/e2e/stock-control-multi-location.e2e-spec.ts +++ b/packages/core/e2e/stock-control-multi-location.e2e-spec.ts @@ -36,7 +36,7 @@ import { TRANSITION_TO_STATE, } from './graphql/shop-definitions'; -describe('Stock control', () => { +describe('Stock control (multi-location)', () => { let defaultStockLocationId: string; let secondStockLocationId: string; const { server, adminClient, shopClient } = createTestEnvironment( @@ -111,9 +111,8 @@ describe('Stock control', () => { }); it('default StockLocation exists', async () => { - const { stockLocations } = await adminClient.query( - GET_STOCK_LOCATIONS, - ); + const { stockLocations } = + await adminClient.query(GET_STOCK_LOCATIONS); expect(stockLocations.items.length).toBe(1); expect(stockLocations.items[0].name).toBe('Default Stock Location'); defaultStockLocationId = stockLocations.items[0].id; diff --git a/packages/core/src/config/catalog/default-stock-location-strategy.ts b/packages/core/src/config/catalog/default-stock-location-strategy.ts index c82cabbdfb..efac62f7a5 100644 --- a/packages/core/src/config/catalog/default-stock-location-strategy.ts +++ b/packages/core/src/config/catalog/default-stock-location-strategy.ts @@ -11,39 +11,25 @@ import { Allocation } from '../../entity/stock-movement/allocation.entity'; import { AvailableStock, LocationWithQuantity, StockLocationStrategy } from './stock-location-strategy'; -/** - * @description - * The DefaultStockLocationStrategy is the default implementation of the {@link StockLocationStrategy}. - * It assumes only a single StockLocation and that all stock is allocated from that location. - * - * @docsCategory products & stock - * @since 2.0.0 - */ -export class DefaultStockLocationStrategy implements StockLocationStrategy { +export abstract class BaseStockLocationStrategy implements StockLocationStrategy { protected connection: TransactionalConnection; init(injector: Injector) { this.connection = injector.get(TransactionalConnection); } - getAvailableStock(ctx: RequestContext, productVariantId: ID, stockLevels: StockLevel[]): AvailableStock { - let stockOnHand = 0; - let stockAllocated = 0; - for (const stockLevel of stockLevels) { - stockOnHand += stockLevel.stockOnHand; - stockAllocated += stockLevel.stockAllocated; - } - return { stockOnHand, stockAllocated }; - } + abstract getAvailableStock( + ctx: RequestContext, + productVariantId: ID, + stockLevels: StockLevel[], + ): AvailableStock | Promise; - forAllocation( + abstract forAllocation( ctx: RequestContext, stockLocations: StockLocation[], orderLine: OrderLine, quantity: number, - ): LocationWithQuantity[] | Promise { - return [{ location: stockLocations[0], quantity }]; - } + ): LocationWithQuantity[] | Promise; async forCancellation( ctx: RequestContext, @@ -105,3 +91,38 @@ export class DefaultStockLocationStrategy implements StockLocationStrategy { })); } } + +/** + * @description + * The DefaultStockLocationStrategy was the default implementation of the {@link StockLocationStrategy} + * prior to the introduction of the {@link MultiChannelStockLocationStrategy}. + * It assumes only a single StockLocation and that all stock is allocated from that location. When + * more than one StockLocation or Channel is used, it will not behave as expected. + * + * @docsCategory products & stock + * @since 2.0.0 + */ +export class DefaultStockLocationStrategy extends BaseStockLocationStrategy { + init(injector: Injector) { + super.init(injector); + } + + getAvailableStock(ctx: RequestContext, productVariantId: ID, stockLevels: StockLevel[]): AvailableStock { + let stockOnHand = 0; + let stockAllocated = 0; + for (const stockLevel of stockLevels) { + stockOnHand += stockLevel.stockOnHand; + stockAllocated += stockLevel.stockAllocated; + } + return { stockOnHand, stockAllocated }; + } + + forAllocation( + ctx: RequestContext, + stockLocations: StockLocation[], + orderLine: OrderLine, + quantity: number, + ): LocationWithQuantity[] | Promise { + return [{ location: stockLocations[0], quantity }]; + } +} diff --git a/packages/core/src/config/catalog/multi-channel-stock-location-strategy.ts b/packages/core/src/config/catalog/multi-channel-stock-location-strategy.ts new file mode 100644 index 0000000000..420d7d8672 --- /dev/null +++ b/packages/core/src/config/catalog/multi-channel-stock-location-strategy.ts @@ -0,0 +1,191 @@ +import type { GlobalSettingsService } from '../../service/index'; +import { GlobalFlag } from '@vendure/common/lib/generated-types'; +import { ID } from '@vendure/common/lib/shared-types'; +import ms from 'ms'; +import { filter } from 'rxjs/operators'; + +import { RequestContext } from '../../api/common/request-context'; +import { Cache, CacheService, RequestContextCacheService } from '../../cache/index'; +import { Injector } from '../../common/injector'; +import { ProductVariant } from '../../entity/index'; +import { OrderLine } from '../../entity/order-line/order-line.entity'; +import { StockLevel } from '../../entity/stock-level/stock-level.entity'; +import { StockLocation } from '../../entity/stock-location/stock-location.entity'; +import { EventBus, StockLocationEvent } from '../../event-bus/index'; + +import { BaseStockLocationStrategy } from './default-stock-location-strategy'; +import { AvailableStock, LocationWithQuantity, StockLocationStrategy } from './stock-location-strategy'; + +/** + * @description + * The MultiChannelStockLocationStrategy is an implementation of the {@link StockLocationStrategy}. + * which is suitable for both single- and multichannel setups. It takes into account the active + * channel when determining stock levels, and also ensures that allocations are made only against + * stock locations which are associated with the active channel. + * + * This strategy became the default in Vendure 3.1.0. If you want to use the previous strategy which + * does not take channels into account, update your VendureConfig to use to {@link DefaultStockLocationStrategy}. + * + * @docsCategory products & stock + * @since 3.1.0 + */ +export class MultiChannelStockLocationStrategy extends BaseStockLocationStrategy { + protected cacheService: CacheService; + protected channelIdCache: Cache; + protected eventBus: EventBus; + protected globalSettingsService: GlobalSettingsService; + protected requestContextCache: RequestContextCacheService; + + /** @internal */ + async init(injector: Injector) { + super.init(injector); + this.eventBus = injector.get(EventBus); + this.cacheService = injector.get(CacheService); + this.requestContextCache = injector.get(RequestContextCacheService); + // Dynamically import the GlobalSettingsService to avoid circular dependency + const GlobalSettingsService = (await import('../../service/services/global-settings.service.js')) + .GlobalSettingsService; + this.globalSettingsService = injector.get(GlobalSettingsService); + this.channelIdCache = this.cacheService.createCache({ + options: { + ttl: ms('7 days'), + tags: ['StockLocation'], + }, + getKey: id => this.getCacheKey(id), + }); + + // When a StockLocation is updated, we need to invalidate the cache + this.eventBus + .ofType(StockLocationEvent) + .pipe(filter(event => event.type !== 'created')) + .subscribe(({ entity }) => this.channelIdCache.delete(this.getCacheKey(entity.id))); + } + + /** + * @description + * Returns the available stock for the given ProductVariant, taking into account the active Channel. + */ + async getAvailableStock( + ctx: RequestContext, + productVariantId: ID, + stockLevels: StockLevel[], + ): Promise { + let stockOnHand = 0; + let stockAllocated = 0; + for (const stockLevel of stockLevels) { + const applies = await this.stockLevelAppliesToActiveChannel(ctx, stockLevel); + if (applies) { + stockOnHand += stockLevel.stockOnHand; + stockAllocated += stockLevel.stockAllocated; + } + } + return { stockOnHand, stockAllocated }; + } + + /** + * @description + * This method takes into account whether the stock location is applicable to the active channel. + * It furthermore respects the `trackInventory` and `outOfStockThreshold` settings of the ProductVariant, + * in order to allocate stock only from locations which are relevant to the active channel and which + * have sufficient stock available. + */ + async forAllocation( + ctx: RequestContext, + stockLocations: StockLocation[], + orderLine: OrderLine, + quantity: number, + ): Promise { + const stockLevels = await this.getStockLevelsForVariant(ctx, orderLine.productVariantId); + const variant = await this.connection.getEntityOrThrow( + ctx, + ProductVariant, + orderLine.productVariantId, + { loadEagerRelations: false }, + ); + let totalAllocated = 0; + const locations: LocationWithQuantity[] = []; + const { inventoryNotTracked, effectiveOutOfStockThreshold } = await this.getVariantStockSettings( + ctx, + variant, + ); + for (const stockLocation of stockLocations) { + const stockLevel = stockLevels.find(sl => sl.stockLocationId === stockLocation.id); + if (stockLevel && (await this.stockLevelAppliesToActiveChannel(ctx, stockLevel))) { + const quantityAvailable = inventoryNotTracked + ? Number.MAX_SAFE_INTEGER + : stockLevel.stockOnHand - stockLevel.stockAllocated - effectiveOutOfStockThreshold; + if (quantityAvailable > 0) { + const quantityToAllocate = Math.min(quantity, quantityAvailable); + locations.push({ + location: stockLocation, + quantity: quantityToAllocate, + }); + totalAllocated += quantityToAllocate; + } + } + if (totalAllocated >= quantity) { + break; + } + } + return locations; + } + + /** + * @description + * Determines whether the given StockLevel applies to the active Channel. Uses a cache to avoid + * repeated DB queries. + */ + private async stockLevelAppliesToActiveChannel( + ctx: RequestContext, + stockLevel: StockLevel, + ): Promise { + const channelIds = await this.channelIdCache.get(stockLevel.stockLocationId, async () => { + const stockLocation = await this.connection.getEntityOrThrow( + ctx, + StockLocation, + stockLevel.stockLocationId, + { + relations: { + channels: true, + }, + }, + ); + return stockLocation.channels.map(c => c.id); + }); + return channelIds.includes(ctx.channelId); + } + + private getCacheKey(stockLocationId: ID) { + return `MultiChannelStockLocationStrategy:StockLocationChannelIds:${stockLocationId}`; + } + + private getStockLevelsForVariant(ctx: RequestContext, productVariantId: ID): Promise { + return this.requestContextCache.get( + ctx, + `MultiChannelStockLocationStrategy.stockLevels.${productVariantId}`, + () => + this.connection.getRepository(ctx, StockLevel).find({ + where: { + productVariantId, + }, + loadEagerRelations: false, + }), + ); + } + + private async getVariantStockSettings(ctx: RequestContext, variant: ProductVariant) { + const { outOfStockThreshold, trackInventory } = await this.globalSettingsService.getSettings(ctx); + + const inventoryNotTracked = + variant.trackInventory === GlobalFlag.FALSE || + (variant.trackInventory === GlobalFlag.INHERIT && trackInventory === false); + const effectiveOutOfStockThreshold = variant.useGlobalOutOfStockThreshold + ? outOfStockThreshold + : variant.outOfStockThreshold; + + return { + inventoryNotTracked, + effectiveOutOfStockThreshold, + }; + } +} diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index 39b7a5d9c0..f2d31af6b3 100644 --- a/packages/core/src/config/default-config.ts +++ b/packages/core/src/config/default-config.ts @@ -24,6 +24,7 @@ import { DefaultProductVariantPriceSelectionStrategy } from './catalog/default-p import { DefaultProductVariantPriceUpdateStrategy } from './catalog/default-product-variant-price-update-strategy'; import { DefaultStockDisplayStrategy } from './catalog/default-stock-display-strategy'; import { DefaultStockLocationStrategy } from './catalog/default-stock-location-strategy'; +import { MultiChannelStockLocationStrategy } from './catalog/multi-channel-stock-location-strategy'; import { AutoIncrementIdStrategy } from './entity/auto-increment-id-strategy'; import { DefaultMoneyStrategy } from './entity/default-money-strategy'; import { defaultEntityDuplicators } from './entity/entity-duplicators/index'; @@ -119,7 +120,7 @@ export const defaultConfig: RuntimeVendureConfig = { syncPricesAcrossChannels: false, }), stockDisplayStrategy: new DefaultStockDisplayStrategy(), - stockLocationStrategy: new DefaultStockLocationStrategy(), + stockLocationStrategy: new MultiChannelStockLocationStrategy(), }, assetOptions: { assetNamingStrategy: new DefaultAssetNamingStrategy(), diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 4e71db6a6f..ab480f30d7 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -15,6 +15,7 @@ export * from './catalog/default-product-variant-price-selection-strategy'; export * from './catalog/default-product-variant-price-update-strategy'; export * from './catalog/default-stock-display-strategy'; export * from './catalog/default-stock-location-strategy'; +export * from './catalog/multi-channel-stock-location-strategy'; export * from './catalog/product-variant-price-calculation-strategy'; export * from './catalog/product-variant-price-selection-strategy'; export * from './catalog/product-variant-price-update-strategy';