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';