Skip to content

Commit

Permalink
feat(core): Introduce new default MultiChannelStockLocationStrategy
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
michaelbromley committed Nov 29, 2024
1 parent 5cff832 commit 62090c9
Show file tree
Hide file tree
Showing 5 changed files with 240 additions and 27 deletions.
7 changes: 3 additions & 4 deletions packages/core/e2e/stock-control-multi-location.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -111,9 +111,8 @@ describe('Stock control', () => {
});

it('default StockLocation exists', async () => {
const { stockLocations } = await adminClient.query<Codegen.GetStockLocationsQuery>(
GET_STOCK_LOCATIONS,
);
const { stockLocations } =
await adminClient.query<Codegen.GetStockLocationsQuery>(GET_STOCK_LOCATIONS);
expect(stockLocations.items.length).toBe(1);
expect(stockLocations.items[0].name).toBe('Default Stock Location');
defaultStockLocationId = stockLocations.items[0].id;
Expand Down
65 changes: 43 additions & 22 deletions packages/core/src/config/catalog/default-stock-location-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AvailableStock>;

forAllocation(
abstract forAllocation(
ctx: RequestContext,
stockLocations: StockLocation[],
orderLine: OrderLine,
quantity: number,
): LocationWithQuantity[] | Promise<LocationWithQuantity[]> {
return [{ location: stockLocations[0], quantity }];
}
): LocationWithQuantity[] | Promise<LocationWithQuantity[]>;

async forCancellation(
ctx: RequestContext,
Expand Down Expand Up @@ -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<LocationWithQuantity[]> {
return [{ location: stockLocations[0], quantity }];
}
}
Original file line number Diff line number Diff line change
@@ -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<AvailableStock> {
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<LocationWithQuantity[]> {
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<boolean> {
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<StockLevel[]> {
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,
};
}
}
3 changes: 2 additions & 1 deletion packages/core/src/config/default-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -119,7 +120,7 @@ export const defaultConfig: RuntimeVendureConfig = {
syncPricesAcrossChannels: false,
}),
stockDisplayStrategy: new DefaultStockDisplayStrategy(),
stockLocationStrategy: new DefaultStockLocationStrategy(),
stockLocationStrategy: new MultiChannelStockLocationStrategy(),
},
assetOptions: {
assetNamingStrategy: new DefaultAssetNamingStrategy(),
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down

0 comments on commit 62090c9

Please sign in to comment.