Skip to content

Commit

Permalink
feat(core): Extract hard-coded fulfillment state & process
Browse files Browse the repository at this point in the history
This commit makes it possible to completely configure the fulfillment process by extracting all
transition validation and allowing the developer to replace with custom logic.
  • Loading branch information
michaelbromley committed Jan 17, 2023
1 parent cff3b91 commit cdb2b75
Show file tree
Hide file tree
Showing 10 changed files with 154 additions and 94 deletions.
3 changes: 2 additions & 1 deletion packages/core/e2e/fulfillment-process.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* tslint:disable:no-non-null-assertion */
import {
CustomFulfillmentProcess,
defaultFulfillmentProcess,
manualFulfillmentHandler,
mergeConfig,
TransactionalConnection,
Expand Down Expand Up @@ -79,7 +80,7 @@ describe('Fulfillment process', () => {
mergeConfig(testConfig(), {
shippingOptions: {
...testConfig().shippingOptions,
customFulfillmentProcess: [customOrderProcess as any, customOrderProcess2 as any],
process: [defaultFulfillmentProcess, customOrderProcess as any, customOrderProcess2 as any],
},
paymentOptions: {
paymentMethodHandlers: [testSuccessfulPaymentMethod],
Expand Down
12 changes: 8 additions & 4 deletions packages/core/src/config/config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,19 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
mergeStrategy,
checkoutMergeStrategy,
orderItemPriceCalculationStrategy,
process,
process: orderProcess,
orderCodeStrategy,
orderByCodeAccessStrategy,
stockAllocationStrategy,
activeOrderStrategy,
changedPriceHandlingStrategy,
orderSellerStrategy,
} = this.configService.orderOptions;
const { customFulfillmentProcess, shippingLineAssignmentStrategy } =
this.configService.shippingOptions;
const {
customFulfillmentProcess,
process: fulfillmentProcess,
shippingLineAssignmentStrategy,
} = this.configService.shippingOptions;
const { customPaymentProcess } = this.configService.paymentOptions;
const { entityIdStrategy: entityIdStrategyDeprecated } = this.configService;
const { entityIdStrategy } = this.configService.entityOptions;
Expand All @@ -118,8 +121,9 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
...[entityIdStrategy].filter(notNullOrUndefined),
productVariantPriceCalculationStrategy,
orderItemPriceCalculationStrategy,
...process,
...orderProcess,
...customFulfillmentProcess,
...fulfillmentProcess,
...customPaymentProcess,
stockAllocationStrategy,
stockDisplayStrategy,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/config/default-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { defaultCollectionFilters } from './catalog/default-collection-filters';
import { DefaultProductVariantPriceCalculationStrategy } from './catalog/default-product-variant-price-calculation-strategy';
import { DefaultStockDisplayStrategy } from './catalog/default-stock-display-strategy';
import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
import { defaultFulfillmentProcess } from './fulfillment/default-fulfillment-process';
import { manualFulfillmentHandler } from './fulfillment/manual-fulfillment-handler';
import { DefaultLogger } from './logger/default-logger';
import { DefaultActiveOrderStrategy } from './order/default-active-order-strategy';
Expand Down Expand Up @@ -129,6 +130,7 @@ export const defaultConfig: RuntimeVendureConfig = {
shippingCalculators: [defaultShippingCalculator],
shippingLineAssignmentStrategy: new DefaultShippingLineAssignmentStrategy(),
customFulfillmentProcess: [],
process: [defaultFulfillmentProcess],
fulfillmentHandlers: [manualFulfillmentHandler],
},
orderOptions: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { HistoryEntryType } from '@vendure/common/lib/generated-types';

import { awaitPromiseOrObservable, Transitions } from '../../common/index';
import { FulfillmentState } from '../../service/index';

import { FulfillmentProcess } from './fulfillment-process';

declare module '../../service/helpers/fulfillment-state-machine/fulfillment-state' {
interface FulfillmentStates {
Shipped: never;
Delivered: never;
}
}

let configService: import('../config.service').ConfigService;
let historyService: import('../../service/index').HistoryService;

/**
* @description
* The default {@link FulfillmentProcess}
*
* @docsCategory fulfillment
*/
export const defaultFulfillmentProcess: FulfillmentProcess<FulfillmentState> = {
transitions: {
Created: {
to: ['Pending'],
},
Pending: {
to: ['Shipped', 'Delivered', 'Cancelled'],
},
Shipped: {
to: ['Delivered', 'Cancelled'],
},
Delivered: {
to: ['Cancelled'],
},
Cancelled: {
to: [],
},
},
async init(injector) {
// Lazily import these services to avoid a circular dependency error
// due to this being used as part of the DefaultConfig
const ConfigService = await import('../config.service').then(m => m.ConfigService);
const HistoryService = await import('../../service/index').then(m => m.HistoryService);
configService = injector.get(ConfigService);
historyService = injector.get(HistoryService);
},
async onTransitionStart(fromState, toState, data) {
const { fulfillmentHandlers } = configService.shippingOptions;
const fulfillmentHandler = fulfillmentHandlers.find(h => h.code === data.fulfillment.handlerCode);
if (fulfillmentHandler) {
const result = await awaitPromiseOrObservable(
fulfillmentHandler.onFulfillmentTransition(fromState, toState, data),
);
if (result === false || typeof result === 'string') {
return result;
}
}
},
async onTransitionEnd(fromState, toState, data) {
const historyEntryPromises = data.orders.map(order =>
historyService.createHistoryEntryForOrder({
orderId: order.id,
type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
ctx: data.ctx,
data: {
fulfillmentId: data.fulfillment.id,
from: fromState,
to: toState,
},
}),
);
await Promise.all(historyEntryPromises);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,31 @@ import {

/**
* @description
* Used to define extensions to or modifications of the default fulfillment process.
* A FulfillmentProcess is used to define the way the fulfillment process works as in: what states a Fulfillment can be
* in, and how it may transition from one state to another. Using the `onTransitionStart()` hook, a
* FulfillmentProcess can perform checks before allowing a state transition to occur, and the `onTransitionEnd()`
* hook allows logic to be executed after a state change.
*
* For detailed description of the interface members, see the {@link StateMachineConfig} docs.
*
* @docsCategory fulfillment
*/
export interface CustomFulfillmentProcess<State extends keyof CustomFulfillmentStates | string>
export interface FulfillmentProcess<State extends keyof CustomFulfillmentStates | string>
extends InjectableStrategy {
transitions?: Transitions<State, State | FulfillmentState> &
Partial<Transitions<FulfillmentState | State>>;
onTransitionStart?: OnTransitionStartFn<State | FulfillmentState, FulfillmentTransitionData>;
onTransitionEnd?: OnTransitionEndFn<State | FulfillmentState, FulfillmentTransitionData>;
onTransitionError?: OnTransitionErrorFn<State | FulfillmentState>;
}

/**
* @description
* Used to define extensions to or modifications of the default fulfillment process.
*
* For detailed description of the interface members, see the {@link StateMachineConfig} docs.
*
* @deprecated Use FulfillmentProcess
*/
export interface CustomFulfillmentProcess<State extends keyof CustomFulfillmentStates | string>
extends FulfillmentProcess<State> {}
3 changes: 2 additions & 1 deletion packages/core/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export * from './entity-id-strategy/entity-id-strategy';
export * from './entity-id-strategy/uuid-id-strategy';
export * from './entity-metadata/entity-metadata-modifier';
export * from './entity-metadata/add-foreign-key-indices';
export * from './fulfillment/custom-fulfillment-process';
export * from './fulfillment/default-fulfillment-process';
export * from './fulfillment/fulfillment-process';
export * from './fulfillment/fulfillment-handler';
export * from './fulfillment/manual-fulfillment-handler';
export * from './job-queue/inspectable-job-queue-strategy';
Expand Down
17 changes: 13 additions & 4 deletions packages/core/src/config/vendure-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import { StockDisplayStrategy } from './catalog/stock-display-strategy';
import { CustomFields } from './custom-field/custom-field-types';
import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
import { EntityMetadataModifier } from './entity-metadata/entity-metadata-modifier';
import { CustomFulfillmentProcess } from './fulfillment/custom-fulfillment-process';
import { FulfillmentHandler } from './fulfillment/fulfillment-handler';
import { FulfillmentProcess } from './fulfillment/fulfillment-process';
import { JobQueueStrategy } from './job-queue/job-queue-strategy';
import { VendureLogger } from './logger/vendure-logger';
import { ActiveOrderStrategy } from './order/active-order-strategy';
Expand Down Expand Up @@ -694,10 +694,19 @@ export interface ShippingOptions {
/**
* @description
* Allows the definition of custom states and transition logic for the fulfillment process state machine.
* Takes an array of objects implementing the {@link CustomFulfillmentProcess} interface.
* Takes an array of objects implementing the {@link FulfillmentProcess} interface.
*
* @deprecated use `process`
*/
customFulfillmentProcess?: Array<CustomFulfillmentProcess<any>>;

customFulfillmentProcess?: Array<FulfillmentProcess<any>>;
/**
* @description
* Allows the definition of custom states and transition logic for the fulfillment process state machine.
* Takes an array of objects implementing the {@link FulfillmentProcess} interface.
*
* @since 2.0.0
*/
process?: Array<FulfillmentProcess<any>>;
/**
* @description
* An array of available FulfillmentHandlers.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common';
import { HistoryEntryType } from '@vendure/common/lib/generated-types';

import { RequestContext } from '../../../api/common/request-context';
import { IllegalOperationError } from '../../../common/error/errors';
Expand All @@ -13,11 +12,7 @@ import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
import { Order } from '../../../entity/order/order.entity';
import { HistoryService } from '../../services/history.service';

import {
FulfillmentState,
fulfillmentStateTransitions,
FulfillmentTransitionData,
} from './fulfillment-state';
import { FulfillmentState, FulfillmentTransitionData } from './fulfillment-state';

@Injectable()
export class FulfillmentStateMachine {
Expand Down Expand Up @@ -52,64 +47,22 @@ export class FulfillmentStateMachine {
fulfillment.state = fsm.currentState;
}

/**
* Specific business logic to be executed on Fulfillment state transitions.
*/
private async onTransitionStart(
fromState: FulfillmentState,
toState: FulfillmentState,
data: FulfillmentTransitionData,
) {
const { fulfillmentHandlers } = this.configService.shippingOptions;
const fulfillmentHandler = fulfillmentHandlers.find(h => h.code === data.fulfillment.handlerCode);
if (fulfillmentHandler) {
const result = await awaitPromiseOrObservable(
fulfillmentHandler.onFulfillmentTransition(fromState, toState, data),
);
if (result === false || typeof result === 'string') {
return result;
}
}
}

/**
* Specific business logic to be executed after Fulfillment state transition completes.
*/
private async onTransitionEnd(
fromState: FulfillmentState,
toState: FulfillmentState,
data: FulfillmentTransitionData,
) {
const historyEntryPromises = data.orders.map(order =>
this.historyService.createHistoryEntryForOrder({
orderId: order.id,
type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
ctx: data.ctx,
data: {
fulfillmentId: data.fulfillment.id,
from: fromState,
to: toState,
},
}),
);
await Promise.all(historyEntryPromises);
}

private initConfig(): StateMachineConfig<FulfillmentState, FulfillmentTransitionData> {
// TODO: remove once the customFulfillmentProcess option is removed
const customProcesses = this.configService.shippingOptions.customFulfillmentProcess ?? [];

const allTransitions = customProcesses.reduce(
const processes = [...customProcesses, ...(this.configService.shippingOptions.process ?? [])];
const allTransitions = processes.reduce(
(transitions, process) =>
mergeTransitionDefinitions(transitions, process.transitions as Transitions<any>),
fulfillmentStateTransitions,
{} as Transitions<FulfillmentState>,
);

const validationResult = validateTransitionDefinition(allTransitions, 'Pending');

return {
transitions: allTransitions,
onTransitionStart: async (fromState, toState, data) => {
for (const process of customProcesses) {
for (const process of processes) {
if (typeof process.onTransitionStart === 'function') {
const result = await awaitPromiseOrObservable(
process.onTransitionStart(fromState, toState, data),
Expand All @@ -119,18 +72,16 @@ export class FulfillmentStateMachine {
}
}
}
return this.onTransitionStart(fromState, toState, data);
},
onTransitionEnd: async (fromState, toState, data) => {
for (const process of customProcesses) {
for (const process of processes) {
if (typeof process.onTransitionEnd === 'function') {
await awaitPromiseOrObservable(process.onTransitionEnd(fromState, toState, data));
}
}
await this.onTransitionEnd(fromState, toState, data);
},
onError: async (fromState, toState, message) => {
for (const process of customProcesses) {
for (const process of processes) {
if (typeof process.onTransitionError === 'function') {
await awaitPromiseOrObservable(
process.onTransitionError(fromState, toState, message),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,47 +1,37 @@
import { RequestContext } from '../../../api/common/request-context';
import { Transitions } from '../../../common/finite-state-machine/types';
import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
import { Order } from '../../../entity/order/order.entity';

/**
* @description
* An interface to extend standard {@link FulfillmentState}.
*
* @docsCategory fulfillment
* @deprecated use FulfillmentStates
*/
export interface CustomFulfillmentStates {}

/**
* @description
* These are the default states of the fulfillment process.
* An interface to extend standard {@link FulfillmentState}.
*
* @docsCategory fulfillment
*/
export interface FulfillmentStates {}

/**
* @description
* These are the default states of the fulfillment process. By default, they will be extended
* by the {@link defaultFulfillmentProcess} to also include `Shipped` and `Delivered`.
*
*
* @docsCategory fulfillment
*/
export type FulfillmentState =
| 'Created'
| 'Pending'
| 'Shipped'
| 'Delivered'
| 'Cancelled'
| keyof CustomFulfillmentStates;

export const fulfillmentStateTransitions: Transitions<FulfillmentState> = {
Created: {
to: ['Pending'],
},
Pending: {
to: ['Shipped', 'Delivered', 'Cancelled'],
},
Shipped: {
to: ['Delivered', 'Cancelled'],
},
Delivered: {
to: ['Cancelled'],
},
Cancelled: {
to: [],
},
};
| keyof CustomFulfillmentStates
| keyof FulfillmentStates;

/**
* @description
Expand Down
Loading

0 comments on commit cdb2b75

Please sign in to comment.