Skip to content

Commit 62090c9

Browse files
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.
1 parent 5cff832 commit 62090c9

5 files changed

+240
-27
lines changed

packages/core/e2e/stock-control-multi-location.e2e-spec.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
TRANSITION_TO_STATE,
3737
} from './graphql/shop-definitions';
3838

39-
describe('Stock control', () => {
39+
describe('Stock control (multi-location)', () => {
4040
let defaultStockLocationId: string;
4141
let secondStockLocationId: string;
4242
const { server, adminClient, shopClient } = createTestEnvironment(
@@ -111,9 +111,8 @@ describe('Stock control', () => {
111111
});
112112

113113
it('default StockLocation exists', async () => {
114-
const { stockLocations } = await adminClient.query<Codegen.GetStockLocationsQuery>(
115-
GET_STOCK_LOCATIONS,
116-
);
114+
const { stockLocations } =
115+
await adminClient.query<Codegen.GetStockLocationsQuery>(GET_STOCK_LOCATIONS);
117116
expect(stockLocations.items.length).toBe(1);
118117
expect(stockLocations.items[0].name).toBe('Default Stock Location');
119118
defaultStockLocationId = stockLocations.items[0].id;

packages/core/src/config/catalog/default-stock-location-strategy.ts

+43-22
Original file line numberDiff line numberDiff line change
@@ -11,39 +11,25 @@ import { Allocation } from '../../entity/stock-movement/allocation.entity';
1111

1212
import { AvailableStock, LocationWithQuantity, StockLocationStrategy } from './stock-location-strategy';
1313

14-
/**
15-
* @description
16-
* The DefaultStockLocationStrategy is the default implementation of the {@link StockLocationStrategy}.
17-
* It assumes only a single StockLocation and that all stock is allocated from that location.
18-
*
19-
* @docsCategory products & stock
20-
* @since 2.0.0
21-
*/
22-
export class DefaultStockLocationStrategy implements StockLocationStrategy {
14+
export abstract class BaseStockLocationStrategy implements StockLocationStrategy {
2315
protected connection: TransactionalConnection;
2416

2517
init(injector: Injector) {
2618
this.connection = injector.get(TransactionalConnection);
2719
}
2820

29-
getAvailableStock(ctx: RequestContext, productVariantId: ID, stockLevels: StockLevel[]): AvailableStock {
30-
let stockOnHand = 0;
31-
let stockAllocated = 0;
32-
for (const stockLevel of stockLevels) {
33-
stockOnHand += stockLevel.stockOnHand;
34-
stockAllocated += stockLevel.stockAllocated;
35-
}
36-
return { stockOnHand, stockAllocated };
37-
}
21+
abstract getAvailableStock(
22+
ctx: RequestContext,
23+
productVariantId: ID,
24+
stockLevels: StockLevel[],
25+
): AvailableStock | Promise<AvailableStock>;
3826

39-
forAllocation(
27+
abstract forAllocation(
4028
ctx: RequestContext,
4129
stockLocations: StockLocation[],
4230
orderLine: OrderLine,
4331
quantity: number,
44-
): LocationWithQuantity[] | Promise<LocationWithQuantity[]> {
45-
return [{ location: stockLocations[0], quantity }];
46-
}
32+
): LocationWithQuantity[] | Promise<LocationWithQuantity[]>;
4733

4834
async forCancellation(
4935
ctx: RequestContext,
@@ -105,3 +91,38 @@ export class DefaultStockLocationStrategy implements StockLocationStrategy {
10591
}));
10692
}
10793
}
94+
95+
/**
96+
* @description
97+
* The DefaultStockLocationStrategy was the default implementation of the {@link StockLocationStrategy}
98+
* prior to the introduction of the {@link MultiChannelStockLocationStrategy}.
99+
* It assumes only a single StockLocation and that all stock is allocated from that location. When
100+
* more than one StockLocation or Channel is used, it will not behave as expected.
101+
*
102+
* @docsCategory products & stock
103+
* @since 2.0.0
104+
*/
105+
export class DefaultStockLocationStrategy extends BaseStockLocationStrategy {
106+
init(injector: Injector) {
107+
super.init(injector);
108+
}
109+
110+
getAvailableStock(ctx: RequestContext, productVariantId: ID, stockLevels: StockLevel[]): AvailableStock {
111+
let stockOnHand = 0;
112+
let stockAllocated = 0;
113+
for (const stockLevel of stockLevels) {
114+
stockOnHand += stockLevel.stockOnHand;
115+
stockAllocated += stockLevel.stockAllocated;
116+
}
117+
return { stockOnHand, stockAllocated };
118+
}
119+
120+
forAllocation(
121+
ctx: RequestContext,
122+
stockLocations: StockLocation[],
123+
orderLine: OrderLine,
124+
quantity: number,
125+
): LocationWithQuantity[] | Promise<LocationWithQuantity[]> {
126+
return [{ location: stockLocations[0], quantity }];
127+
}
128+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import type { GlobalSettingsService } from '../../service/index';
2+
import { GlobalFlag } from '@vendure/common/lib/generated-types';
3+
import { ID } from '@vendure/common/lib/shared-types';
4+
import ms from 'ms';
5+
import { filter } from 'rxjs/operators';
6+
7+
import { RequestContext } from '../../api/common/request-context';
8+
import { Cache, CacheService, RequestContextCacheService } from '../../cache/index';
9+
import { Injector } from '../../common/injector';
10+
import { ProductVariant } from '../../entity/index';
11+
import { OrderLine } from '../../entity/order-line/order-line.entity';
12+
import { StockLevel } from '../../entity/stock-level/stock-level.entity';
13+
import { StockLocation } from '../../entity/stock-location/stock-location.entity';
14+
import { EventBus, StockLocationEvent } from '../../event-bus/index';
15+
16+
import { BaseStockLocationStrategy } from './default-stock-location-strategy';
17+
import { AvailableStock, LocationWithQuantity, StockLocationStrategy } from './stock-location-strategy';
18+
19+
/**
20+
* @description
21+
* The MultiChannelStockLocationStrategy is an implementation of the {@link StockLocationStrategy}.
22+
* which is suitable for both single- and multichannel setups. It takes into account the active
23+
* channel when determining stock levels, and also ensures that allocations are made only against
24+
* stock locations which are associated with the active channel.
25+
*
26+
* This strategy became the default in Vendure 3.1.0. If you want to use the previous strategy which
27+
* does not take channels into account, update your VendureConfig to use to {@link DefaultStockLocationStrategy}.
28+
*
29+
* @docsCategory products & stock
30+
* @since 3.1.0
31+
*/
32+
export class MultiChannelStockLocationStrategy extends BaseStockLocationStrategy {
33+
protected cacheService: CacheService;
34+
protected channelIdCache: Cache;
35+
protected eventBus: EventBus;
36+
protected globalSettingsService: GlobalSettingsService;
37+
protected requestContextCache: RequestContextCacheService;
38+
39+
/** @internal */
40+
async init(injector: Injector) {
41+
super.init(injector);
42+
this.eventBus = injector.get(EventBus);
43+
this.cacheService = injector.get(CacheService);
44+
this.requestContextCache = injector.get(RequestContextCacheService);
45+
// Dynamically import the GlobalSettingsService to avoid circular dependency
46+
const GlobalSettingsService = (await import('../../service/services/global-settings.service.js'))
47+
.GlobalSettingsService;
48+
this.globalSettingsService = injector.get(GlobalSettingsService);
49+
this.channelIdCache = this.cacheService.createCache({
50+
options: {
51+
ttl: ms('7 days'),
52+
tags: ['StockLocation'],
53+
},
54+
getKey: id => this.getCacheKey(id),
55+
});
56+
57+
// When a StockLocation is updated, we need to invalidate the cache
58+
this.eventBus
59+
.ofType(StockLocationEvent)
60+
.pipe(filter(event => event.type !== 'created'))
61+
.subscribe(({ entity }) => this.channelIdCache.delete(this.getCacheKey(entity.id)));
62+
}
63+
64+
/**
65+
* @description
66+
* Returns the available stock for the given ProductVariant, taking into account the active Channel.
67+
*/
68+
async getAvailableStock(
69+
ctx: RequestContext,
70+
productVariantId: ID,
71+
stockLevels: StockLevel[],
72+
): Promise<AvailableStock> {
73+
let stockOnHand = 0;
74+
let stockAllocated = 0;
75+
for (const stockLevel of stockLevels) {
76+
const applies = await this.stockLevelAppliesToActiveChannel(ctx, stockLevel);
77+
if (applies) {
78+
stockOnHand += stockLevel.stockOnHand;
79+
stockAllocated += stockLevel.stockAllocated;
80+
}
81+
}
82+
return { stockOnHand, stockAllocated };
83+
}
84+
85+
/**
86+
* @description
87+
* This method takes into account whether the stock location is applicable to the active channel.
88+
* It furthermore respects the `trackInventory` and `outOfStockThreshold` settings of the ProductVariant,
89+
* in order to allocate stock only from locations which are relevant to the active channel and which
90+
* have sufficient stock available.
91+
*/
92+
async forAllocation(
93+
ctx: RequestContext,
94+
stockLocations: StockLocation[],
95+
orderLine: OrderLine,
96+
quantity: number,
97+
): Promise<LocationWithQuantity[]> {
98+
const stockLevels = await this.getStockLevelsForVariant(ctx, orderLine.productVariantId);
99+
const variant = await this.connection.getEntityOrThrow(
100+
ctx,
101+
ProductVariant,
102+
orderLine.productVariantId,
103+
{ loadEagerRelations: false },
104+
);
105+
let totalAllocated = 0;
106+
const locations: LocationWithQuantity[] = [];
107+
const { inventoryNotTracked, effectiveOutOfStockThreshold } = await this.getVariantStockSettings(
108+
ctx,
109+
variant,
110+
);
111+
for (const stockLocation of stockLocations) {
112+
const stockLevel = stockLevels.find(sl => sl.stockLocationId === stockLocation.id);
113+
if (stockLevel && (await this.stockLevelAppliesToActiveChannel(ctx, stockLevel))) {
114+
const quantityAvailable = inventoryNotTracked
115+
? Number.MAX_SAFE_INTEGER
116+
: stockLevel.stockOnHand - stockLevel.stockAllocated - effectiveOutOfStockThreshold;
117+
if (quantityAvailable > 0) {
118+
const quantityToAllocate = Math.min(quantity, quantityAvailable);
119+
locations.push({
120+
location: stockLocation,
121+
quantity: quantityToAllocate,
122+
});
123+
totalAllocated += quantityToAllocate;
124+
}
125+
}
126+
if (totalAllocated >= quantity) {
127+
break;
128+
}
129+
}
130+
return locations;
131+
}
132+
133+
/**
134+
* @description
135+
* Determines whether the given StockLevel applies to the active Channel. Uses a cache to avoid
136+
* repeated DB queries.
137+
*/
138+
private async stockLevelAppliesToActiveChannel(
139+
ctx: RequestContext,
140+
stockLevel: StockLevel,
141+
): Promise<boolean> {
142+
const channelIds = await this.channelIdCache.get(stockLevel.stockLocationId, async () => {
143+
const stockLocation = await this.connection.getEntityOrThrow(
144+
ctx,
145+
StockLocation,
146+
stockLevel.stockLocationId,
147+
{
148+
relations: {
149+
channels: true,
150+
},
151+
},
152+
);
153+
return stockLocation.channels.map(c => c.id);
154+
});
155+
return channelIds.includes(ctx.channelId);
156+
}
157+
158+
private getCacheKey(stockLocationId: ID) {
159+
return `MultiChannelStockLocationStrategy:StockLocationChannelIds:${stockLocationId}`;
160+
}
161+
162+
private getStockLevelsForVariant(ctx: RequestContext, productVariantId: ID): Promise<StockLevel[]> {
163+
return this.requestContextCache.get(
164+
ctx,
165+
`MultiChannelStockLocationStrategy.stockLevels.${productVariantId}`,
166+
() =>
167+
this.connection.getRepository(ctx, StockLevel).find({
168+
where: {
169+
productVariantId,
170+
},
171+
loadEagerRelations: false,
172+
}),
173+
);
174+
}
175+
176+
private async getVariantStockSettings(ctx: RequestContext, variant: ProductVariant) {
177+
const { outOfStockThreshold, trackInventory } = await this.globalSettingsService.getSettings(ctx);
178+
179+
const inventoryNotTracked =
180+
variant.trackInventory === GlobalFlag.FALSE ||
181+
(variant.trackInventory === GlobalFlag.INHERIT && trackInventory === false);
182+
const effectiveOutOfStockThreshold = variant.useGlobalOutOfStockThreshold
183+
? outOfStockThreshold
184+
: variant.outOfStockThreshold;
185+
186+
return {
187+
inventoryNotTracked,
188+
effectiveOutOfStockThreshold,
189+
};
190+
}
191+
}

packages/core/src/config/default-config.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { DefaultProductVariantPriceSelectionStrategy } from './catalog/default-p
2424
import { DefaultProductVariantPriceUpdateStrategy } from './catalog/default-product-variant-price-update-strategy';
2525
import { DefaultStockDisplayStrategy } from './catalog/default-stock-display-strategy';
2626
import { DefaultStockLocationStrategy } from './catalog/default-stock-location-strategy';
27+
import { MultiChannelStockLocationStrategy } from './catalog/multi-channel-stock-location-strategy';
2728
import { AutoIncrementIdStrategy } from './entity/auto-increment-id-strategy';
2829
import { DefaultMoneyStrategy } from './entity/default-money-strategy';
2930
import { defaultEntityDuplicators } from './entity/entity-duplicators/index';
@@ -119,7 +120,7 @@ export const defaultConfig: RuntimeVendureConfig = {
119120
syncPricesAcrossChannels: false,
120121
}),
121122
stockDisplayStrategy: new DefaultStockDisplayStrategy(),
122-
stockLocationStrategy: new DefaultStockLocationStrategy(),
123+
stockLocationStrategy: new MultiChannelStockLocationStrategy(),
123124
},
124125
assetOptions: {
125126
assetNamingStrategy: new DefaultAssetNamingStrategy(),

packages/core/src/config/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from './catalog/default-product-variant-price-selection-strategy';
1515
export * from './catalog/default-product-variant-price-update-strategy';
1616
export * from './catalog/default-stock-display-strategy';
1717
export * from './catalog/default-stock-location-strategy';
18+
export * from './catalog/multi-channel-stock-location-strategy';
1819
export * from './catalog/product-variant-price-calculation-strategy';
1920
export * from './catalog/product-variant-price-selection-strategy';
2021
export * from './catalog/product-variant-price-update-strategy';

0 commit comments

Comments
 (0)