فهرست منبع

feat(server): Implement state machine for Order process

Relates to #26
Michael Bromley 7 سال پیش
والد
کامیت
6d30b835c5

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
schema.json


+ 2 - 1
server/dev-config.ts

@@ -1,7 +1,7 @@
 import * as path from 'path';
 import { API_PATH, API_PORT } from 'shared/shared-constants';
 
-import { VendureConfig } from './src/config/vendure-config';
+import { OrderProcessOptions, VendureConfig } from './src/config/vendure-config';
 import { DefaultAssetServerPlugin } from './src/plugin/default-asset-server/default-asset-server-plugin';
 
 /**
@@ -25,6 +25,7 @@ export const devConfig: VendureConfig = {
         password: '',
         database: 'vendure-dev',
     },
+    orderProcessOptions: {} as OrderProcessOptions<any>,
     customFields: {
         Facet: [{ name: 'searchable', type: 'boolean' }],
         FacetValue: [{ name: 'link', type: 'string' }, { name: 'available', type: 'boolean' }],

+ 24 - 0
server/src/api/resolvers/order.resolver.ts

@@ -6,11 +6,13 @@ import {
     OrdersQueryArgs,
     Permission,
     RemoveItemFromOrderMutationArgs,
+    TransitionOrderToStateMutationArgs,
 } from 'shared/generated-types';
 import { PaginatedList } from 'shared/shared-types';
 
 import { Order } from '../../entity/order/order.entity';
 import { I18nError } from '../../i18n/i18n-error';
+import { OrderState } from '../../service/helpers/order-state-machine/order-state';
 import { AuthService } from '../../service/services/auth.service';
 import { OrderService } from '../../service/services/order.service';
 import { RequestContext } from '../common/request-context';
@@ -56,6 +58,28 @@ export class OrderResolver {
         }
     }
 
+    @Query()
+    @Allow(Permission.Owner)
+    async nextOrderStates(@Ctx() ctx: RequestContext): Promise<string[]> {
+        if (ctx.authorizedAsOwnerOnly) {
+            const sessionOrder = await this.getOrderFromContext(ctx);
+            return this.orderService.getNextOrderStates(sessionOrder);
+        }
+        return [];
+    }
+
+    @Mutation()
+    @Allow(Permission.Owner)
+    async transitionOrderToState(
+        @Ctx() ctx: RequestContext,
+        @Args() args: TransitionOrderToStateMutationArgs,
+    ): Promise<Order | undefined> {
+        if (ctx.authorizedAsOwnerOnly) {
+            const sessionOrder = await this.getOrderFromContext(ctx);
+            return this.orderService.transitionToState(ctx, sessionOrder.id, args.state as OrderState);
+        }
+    }
+
     @Mutation()
     @Allow(Permission.UpdateOrder, Permission.Owner)
     @Decode('productVariantId')

+ 2 - 0
server/src/api/types/order.api.graphql

@@ -1,6 +1,7 @@
 type Query {
     order(id: ID!): Order
     activeOrder: Order
+    nextOrderStates: [String!]!
     orders(options: OrderListOptions): OrderList!
 }
 
@@ -8,6 +9,7 @@ type Mutation {
     addItemToOrder(productVariantId: ID!, quantity: Int!): Order
     removeItemFromOrder(orderItemId: ID!): Order
     adjustItemQuantity(orderItemId: ID!, quantity: Int!): Order
+    transitionOrderToState(state: String!): Order
 }
 
 type OrderList implements PaginatedList {

+ 220 - 0
server/src/common/finite-state-machine.spec.ts

@@ -0,0 +1,220 @@
+import { of } from 'rxjs';
+
+import { FSM, Transitions } from './finite-state-machine';
+
+describe('Finite State Machine', () => {
+    type TestState = 'DoorsClosed' | 'DoorsOpen' | 'Moving';
+
+    const transitions: Transitions<TestState> = {
+        DoorsClosed: {
+            to: ['Moving', 'DoorsOpen'],
+        },
+        DoorsOpen: {
+            to: ['DoorsClosed'],
+        },
+        Moving: {
+            to: ['DoorsClosed'],
+        },
+    };
+
+    it('initialState works', () => {
+        const initialState = 'DoorsClosed';
+        const fsm = new FSM<TestState>({ transitions }, initialState);
+
+        expect(fsm.initialState).toBe(initialState);
+    });
+
+    it('getNextStates() works', () => {
+        const initialState = 'DoorsClosed';
+        const fsm = new FSM<TestState>({ transitions }, initialState);
+
+        expect(fsm.getNextStates()).toEqual(['Moving', 'DoorsOpen']);
+    });
+
+    it('allows valid transitions', () => {
+        const initialState = 'DoorsClosed';
+        const fsm = new FSM<TestState>({ transitions }, initialState);
+
+        fsm.transitionTo('Moving');
+        expect(fsm.currentState).toBe('Moving');
+        fsm.transitionTo('DoorsClosed');
+        expect(fsm.currentState).toBe('DoorsClosed');
+        fsm.transitionTo('DoorsOpen');
+        expect(fsm.currentState).toBe('DoorsOpen');
+        fsm.transitionTo('DoorsClosed');
+        expect(fsm.currentState).toBe('DoorsClosed');
+    });
+
+    it('does not allow invalid transitions', () => {
+        const initialState = 'DoorsOpen';
+        const fsm = new FSM<TestState>({ transitions }, initialState);
+
+        fsm.transitionTo('Moving');
+        expect(fsm.currentState).toBe('DoorsOpen');
+        fsm.transitionTo('DoorsClosed');
+        expect(fsm.currentState).toBe('DoorsClosed');
+        fsm.transitionTo('Moving');
+        expect(fsm.currentState).toBe('Moving');
+        fsm.transitionTo('DoorsOpen');
+        expect(fsm.currentState).toBe('Moving');
+    });
+
+    it('onTransitionStart() is invoked before a transition takes place', () => {
+        const initialState = 'DoorsClosed';
+        const spy = jest.fn();
+        const data = 123;
+        let currentStateDuringCallback = '';
+        const fsm = new FSM<TestState>(
+            {
+                transitions,
+                onTransitionStart: spy.mockImplementation(() => {
+                    currentStateDuringCallback = fsm.currentState;
+                }),
+            },
+            initialState,
+        );
+
+        fsm.transitionTo('Moving', data);
+
+        expect(spy).toHaveBeenCalledWith(initialState, 'Moving', data);
+        expect(currentStateDuringCallback).toBe(initialState);
+    });
+
+    it('onTransitionEnd() is invoked after a transition takes place', () => {
+        const initialState = 'DoorsClosed';
+        const spy = jest.fn();
+        const data = 123;
+        let currentStateDuringCallback = '';
+        const fsm = new FSM<TestState>(
+            {
+                transitions,
+                onTransitionEnd: spy.mockImplementation(() => {
+                    currentStateDuringCallback = fsm.currentState;
+                }),
+            },
+            initialState,
+        );
+
+        fsm.transitionTo('Moving', data);
+
+        expect(spy).toHaveBeenCalledWith(initialState, 'Moving', data);
+        expect(currentStateDuringCallback).toBe('Moving');
+    });
+
+    it('onTransitionStart() cancels transition when it returns false', async () => {
+        const initialState = 'DoorsClosed';
+        const fsm = new FSM<TestState>(
+            {
+                transitions,
+                onTransitionStart: () => false,
+            },
+            initialState,
+        );
+
+        await fsm.transitionTo('Moving');
+        expect(fsm.currentState).toBe(initialState);
+    });
+
+    it('onTransitionStart() cancels transition when it returns Promise<false>', async () => {
+        const initialState = 'DoorsClosed';
+        const fsm = new FSM<TestState>(
+            {
+                transitions,
+                onTransitionStart: () => Promise.resolve(false),
+            },
+            initialState,
+        );
+
+        await fsm.transitionTo('Moving');
+        expect(fsm.currentState).toBe(initialState);
+    });
+
+    it('onTransitionStart() cancels transition when it returns Observable<false>', async () => {
+        const initialState = 'DoorsClosed';
+        const fsm = new FSM<TestState>(
+            {
+                transitions,
+                onTransitionStart: () => of(false),
+            },
+            initialState,
+        );
+
+        await fsm.transitionTo('Moving');
+        expect(fsm.currentState).toBe(initialState);
+    });
+
+    it('onTransitionStart() cancels transition when it returns a string', async () => {
+        const initialState = 'DoorsClosed';
+        const fsm = new FSM<TestState>(
+            {
+                transitions,
+                onTransitionStart: () => 'foo',
+            },
+            initialState,
+        );
+
+        await fsm.transitionTo('Moving');
+        expect(fsm.currentState).toBe(initialState);
+    });
+
+    it('onTransitionStart() allows transition when it returns true', async () => {
+        const initialState = 'DoorsClosed';
+        const fsm = new FSM<TestState>(
+            {
+                transitions,
+                onTransitionStart: () => true,
+            },
+            initialState,
+        );
+
+        await fsm.transitionTo('Moving');
+        expect(fsm.currentState).toBe('Moving');
+    });
+
+    it('onTransitionStart() allows transition when it returns void', async () => {
+        const initialState = 'DoorsClosed';
+        const fsm = new FSM<TestState>(
+            {
+                transitions,
+                onTransitionStart: () => {
+                    /* empty */
+                },
+            },
+            initialState,
+        );
+
+        await fsm.transitionTo('Moving');
+        expect(fsm.currentState).toBe('Moving');
+    });
+
+    it('onError() is invoked for invalid transitions', async () => {
+        const initialState = 'DoorsOpen';
+        const spy = jest.fn();
+        const fsm = new FSM<TestState>(
+            {
+                transitions,
+                onError: spy,
+            },
+            initialState,
+        );
+
+        await fsm.transitionTo('Moving');
+        expect(spy).toHaveBeenCalledWith(initialState, 'Moving', undefined);
+    });
+
+    it('onTransitionStart() invokes onError() if it returns a string', async () => {
+        const initialState = 'DoorsClosed';
+        const spy = jest.fn();
+        const fsm = new FSM<TestState>(
+            {
+                transitions,
+                onTransitionStart: () => 'error',
+                onError: spy,
+            },
+            initialState,
+        );
+
+        await fsm.transitionTo('Moving');
+        expect(spy).toHaveBeenCalledWith(initialState, 'Moving', 'error');
+    });
+});

+ 116 - 0
server/src/common/finite-state-machine.ts

@@ -0,0 +1,116 @@
+import { Observable } from 'rxjs';
+
+/**
+ * A type which is used to define all valid transitions and transition callbacks
+ */
+export type Transitions<T extends string> = {
+    [S in T]: {
+        to: T[];
+    }
+};
+
+/**
+ * The config object used to instantiate a new FSM instance.
+ */
+export type StateMachineConfig<T extends string, Data = undefined> = {
+    transitions: Transitions<T>;
+    /**
+     * Called before a transition takes place. If the function resolves to false or a string, then the transition
+     * will be cancelled. In the case of a string, the string will be forwarded to the onError handler.
+     */
+    onTransitionStart?(
+        fromState: T,
+        toState: T,
+        data: Data,
+    ): boolean | string | void | Promise<boolean | string | void> | Observable<boolean | string | void>;
+    onTransitionEnd?(fromState: T, toState: T, data: Data): void | Promise<void>;
+    onError?(fromState: T, toState: T, message?: string): void;
+};
+
+/**
+ * A simple type-safe finite state machine
+ */
+export class FSM<T extends string, Data = any> {
+    private readonly _initialState: T;
+    private _currentState: T;
+
+    constructor(private config: StateMachineConfig<T, Data>, initialState: T) {
+        this._currentState = initialState;
+        this._initialState = initialState;
+    }
+
+    /**
+     * Returns the state with which the FSM was initialized.
+     */
+    get initialState(): T {
+        return this._initialState;
+    }
+
+    /**
+     * Returns the current state.
+     */
+    get currentState(): T {
+        return this._currentState;
+    }
+
+    /**
+     * Attempts to transition from the current state to the given state. If this transition is not allowed
+     * per the config, then an error will be logged.
+     */
+    transitionTo(state: T, data?: Data);
+    async transitionTo(state: T, data: Data) {
+        if (this.canTransitionTo(state)) {
+            // If the onTransitionStart callback is defined, invoke it. If it returns false,
+            // then the transition will be cancelled.
+            if (typeof this.config.onTransitionStart === 'function') {
+                const transitionResult = this.config.onTransitionStart(this._currentState, state, data);
+                const canTransition = await (transitionResult instanceof Observable
+                    ? await transitionResult.toPromise()
+                    : transitionResult);
+                if (canTransition === false) {
+                    return;
+                } else if (typeof canTransition === 'string') {
+                    this.onError(this._currentState, state, canTransition);
+                    return;
+                }
+            }
+            const fromState = this._currentState;
+            // All is well, so transition to the new state.
+            this._currentState = state;
+            // If the onTransitionEnd callback is defined, invoke it.
+            if (typeof this.config.onTransitionEnd === 'function') {
+                await this.config.onTransitionEnd(fromState, state, data);
+            }
+        } else {
+            return this.onError(this._currentState, state);
+        }
+    }
+
+    /**
+     * Jumps from the current state to the given state without regard to whether this transition is allowed or not.
+     * None of the lifecycle callbacks will be invoked.
+     */
+    jumpTo(state: T) {
+        this._currentState = state;
+    }
+
+    /**
+     * Returns an array of state to which the machine may transition from the current state.
+     */
+    getNextStates(): T[] {
+        return this.config.transitions[this._currentState].to;
+    }
+
+    /**
+     * Returns true if the machine can transition from its current state to the given state.
+     */
+    canTransitionTo(state: T): boolean {
+        return -1 < this.config.transitions[this._currentState].to.indexOf(state);
+    }
+
+    private onError(fromState: T, toState: T, message?: string) {
+        if (typeof this.config.onError === 'function') {
+            return this.config.onError(fromState, toState, message);
+        }
+    }
+}

+ 1 - 0
server/src/config/config.service.mock.ts

@@ -21,6 +21,7 @@ export class MockConfigService implements MockClass<ConfigService> {
     dbConnectionOptions = {};
     promotionConditions = [];
     promotionActions = [];
+    orderProcessOptions = {};
     customFields = {};
     middleware = [];
     plugins = [];

+ 5 - 1
server/src/config/config.service.ts

@@ -13,7 +13,7 @@ import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-str
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { PromotionAction } from './promotion/promotion-action';
 import { PromotionCondition } from './promotion/promotion-condition';
-import { AuthOptions, getConfig, VendureConfig } from './vendure-config';
+import { AuthOptions, getConfig, OrderProcessOptions, VendureConfig } from './vendure-config';
 import { VendurePlugin } from './vendure-plugin/vendure-plugin';
 
 @Injectable()
@@ -90,6 +90,10 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.promotionActions;
     }
 
+    get orderProcessOptions(): OrderProcessOptions<any> {
+        return this.activeConfig.orderProcessOptions;
+    }
+
     get customFields(): CustomFields {
         return this.activeConfig.customFields;
     }

+ 1 - 0
server/src/config/default-config.ts

@@ -42,6 +42,7 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
     uploadMaxFileSize: 20971520,
     promotionConditions: defaultPromotionConditions,
     promotionActions: defaultPromotionActions,
+    orderProcessOptions: {},
     customFields: {
         Address: [],
         Customer: [],

+ 33 - 0
server/src/config/vendure-config.ts

@@ -1,10 +1,14 @@
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { RequestHandler } from 'express';
+import { Observable } from 'rxjs';
 import { LanguageCode } from 'shared/generated-types';
 import { CustomFields, DeepPartial } from 'shared/shared-types';
 import { ConnectionOptions } from 'typeorm';
 
+import { Transitions } from '../common/finite-state-machine';
 import { ReadOnlyRequired } from '../common/types/common-types';
+import { Order } from '../entity/order/order.entity';
+import { OrderState } from '../service/helpers/order-state-machine/order-state';
 
 import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strategy';
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
@@ -57,6 +61,31 @@ export interface AuthOptions {
     sessionDuration?: string | number;
 }
 
+export interface OrderProcessOptions<T extends string> {
+    /**
+     * Define how the custom states fit in with the default order
+     * state transitions.
+     */
+    transtitions?: Partial<Transitions<T | OrderState>>;
+    /**
+     * Define logic to run before a state tranition takes place. Returning
+     * false will prevent the transition from going ahead.
+     */
+    onTransitionStart?(
+        fromState: T,
+        toState: T,
+        data: { order: Order },
+    ): boolean | Promise<boolean> | Observable<boolean> | void;
+    /**
+     * Define logic to run after a state transition has taken place.
+     */
+    onTransitionEnd?(fromState: T, toState: T, data: { order: Order }): void;
+    /**
+     * Define a custom error handler function for transition errors.
+     */
+    onError?(fromState: T, toState: T, message?: string): void;
+}
+
 export interface VendureConfig {
     /**
      * The name of the property which contains the token of the
@@ -127,6 +156,10 @@ export interface VendureConfig {
      * Defines custom fields which can be used to extend the built-in entities.
      */
     customFields?: CustomFields;
+    /**
+     * Defines custom states in the order process finite state machine.
+     */
+    orderProcessOptions?: OrderProcessOptions<any>;
     /**
      * The max file size in bytes for uploaded assets.
      */

+ 3 - 0
server/src/entity/order/order.entity.ts

@@ -3,6 +3,7 @@ import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
+import { OrderState } from '../../service/helpers/order-state-machine/order-state';
 import { VendureEntity } from '../base/base.entity';
 import { Customer } from '../customer/customer.entity';
 import { OrderItem } from '../order-item/order-item.entity';
@@ -16,6 +17,8 @@ export class Order extends VendureEntity {
 
     @Column() code: string;
 
+    @Column('varchar') state: OrderState;
+
     @ManyToOne(type => Customer)
     customer: Customer;
 

+ 1 - 0
server/src/entity/order/order.graphql

@@ -3,6 +3,7 @@ type Order implements Node {
     createdAt: DateTime!
     updatedAt: DateTime!
     code: String!
+    state: String!
     customer: Customer
     lines: [OrderLine!]!
     adjustments: [Adjustment!]!

+ 105 - 0
server/src/service/helpers/order-state-machine/order-state-machine.ts

@@ -0,0 +1,105 @@
+import { Injectable } from '@nestjs/common';
+
+import { FSM, StateMachineConfig, Transitions } from '../../../common/finite-state-machine';
+import { ConfigService } from '../../../config/config.service';
+import { Order } from '../../../entity/order/order.entity';
+import { I18nError } from '../../../i18n/i18n-error';
+
+import { OrderState, orderStateTransitions, OrderTransitionData } from './order-state';
+
+@Injectable()
+export class OrderStateMachine {
+    private readonly config: StateMachineConfig<OrderState, OrderTransitionData>;
+    private readonly initialState: OrderState = 'AddingItems';
+
+    constructor(private configService: ConfigService) {
+        this.config = this.initConfig();
+    }
+
+    getInitialState(): OrderState {
+        return this.initialState;
+    }
+
+    getNextStates(order: Order): OrderState[] {
+        const fsm = new FSM(this.config, order.state);
+        return fsm.getNextStates();
+    }
+
+    async transition(order: Order, state: OrderState) {
+        const fsm = new FSM(this.config, order.state);
+        await fsm.transitionTo(state, { order });
+        order.state = state;
+    }
+
+    /**
+     * Specific business logic to be executed on Order state transitions.
+     */
+    private onTransitionStart(fromState: OrderState, toState: OrderState, data: OrderTransitionData) {
+        if (toState === 'ArrangingShipping') {
+            if (data.order.lines.length === 0) {
+                return `error.order-is-empty`;
+            }
+        }
+    }
+
+    private initConfig(): StateMachineConfig<OrderState, OrderTransitionData> {
+        const {
+            transtitions,
+            onTransitionStart,
+            onTransitionEnd,
+            onError,
+        } = this.configService.orderProcessOptions;
+
+        const allTransitions = this.mergeTransitionDefinitions(orderStateTransitions, transtitions);
+        const initialState = 'AddingItems';
+
+        return {
+            transitions: allTransitions,
+            onTransitionStart: async (fromState, toState, data) => {
+                if (typeof onTransitionStart === 'function') {
+                    const result = onTransitionStart(fromState, toState, data);
+                    if (result === false || typeof result === 'string') {
+                        return result;
+                    }
+                }
+                return this.onTransitionStart(fromState, toState, data);
+            },
+            onTransitionEnd: (fromState, toState, data) => {
+                if (typeof onTransitionEnd === 'function') {
+                    return onTransitionEnd(fromState, toState, data);
+                }
+            },
+            onError: (fromState, toState, message) => {
+                if (typeof onError === 'function') {
+                    onError(fromState, toState, message);
+                }
+                if (!message) {
+                    throw new I18nError(`error.cannot-transition-order-from-to`, { fromState, toState });
+                } else {
+                    throw new I18nError(message);
+                }
+            },
+        };
+    }
+
+    /**
+     * Merge any custom transition definitions into the default transitions for the Order process.
+     */
+    private mergeTransitionDefinitions<T extends string>(
+        defaultTranstions: Transitions<T>,
+        customTranstitions?: any,
+    ): Transitions<T> {
+        if (!customTranstitions) {
+            return defaultTranstions;
+        }
+        const merged = defaultTranstions;
+        for (const key of Object.keys(customTranstitions)) {
+            if (merged.hasOwnProperty(key)) {
+                merged[key].to = merged[key].to.concat(customTranstitions[key].to);
+            } else {
+                merged[key] = customTranstitions[key];
+            }
+        }
+        return merged;
+    }
+}

+ 35 - 0
server/src/service/helpers/order-state-machine/order-state.ts

@@ -0,0 +1,35 @@
+import { Transitions } from '../../../common/finite-state-machine';
+import { Order } from '../../../entity/order/order.entity';
+
+/**
+ * These are the default states of the Order process. They can be augmented via
+ * the orderProcessOptions property in VendureConfig.
+ */
+export type OrderState =
+    | 'AddingItems'
+    | 'ArrangingShipping'
+    | 'ArrangingPayment'
+    | 'OrderComplete'
+    | 'Cancelled';
+
+export const orderStateTransitions: Transitions<OrderState> = {
+    AddingItems: {
+        to: ['ArrangingShipping'],
+    },
+    ArrangingShipping: {
+        to: ['ArrangingPayment', 'AddingItems'],
+    },
+    ArrangingPayment: {
+        to: ['OrderComplete', 'AddingItems'],
+    },
+    OrderComplete: {
+        to: ['Cancelled'],
+    },
+    Cancelled: {
+        to: [],
+    },
+};
+
+export interface OrderTransitionData {
+    order: Order;
+}

+ 2 - 0
server/src/service/service.module.ts

@@ -6,6 +6,7 @@ import { getConfig } from '../config/vendure-config';
 
 import { ListQueryBuilder } from './helpers/list-query-builder/list-query-builder';
 import { OrderCalculator } from './helpers/order-calculator/order-calculator';
+import { OrderStateMachine } from './helpers/order-state-machine/order-state-machine';
 import { PasswordCiper } from './helpers/password-cipher/password-ciper';
 import { TaxCalculator } from './helpers/tax-calculator/tax-calculator';
 import { TranslatableSaver } from './helpers/translatable-saver/translatable-saver';
@@ -66,6 +67,7 @@ const exportedProviders = [
         TranslatableSaver,
         TaxCalculator,
         OrderCalculator,
+        OrderStateMachine,
         ListQueryBuilder,
     ],
     exports: exportedProviders,

+ 15 - 0
server/src/service/services/order.service.ts

@@ -14,6 +14,8 @@ import { Promotion } from '../../entity/promotion/promotion.entity';
 import { I18nError } from '../../i18n/i18n-error';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { OrderCalculator } from '../helpers/order-calculator/order-calculator';
+import { OrderState } from '../helpers/order-state-machine/order-state';
+import { OrderStateMachine } from '../helpers/order-state-machine/order-state-machine';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
 import { ProductVariantService } from './product-variant.service';
@@ -23,6 +25,7 @@ export class OrderService {
         @InjectConnection() private connection: Connection,
         private productVariantService: ProductVariantService,
         private orderCalculator: OrderCalculator,
+        private orderStateMachine: OrderStateMachine,
         private listQueryBuilder: ListQueryBuilder,
     ) {}
 
@@ -63,6 +66,7 @@ export class OrderService {
     create(): Promise<Order> {
         const newOrder = new Order({
             code: generatePublicId(),
+            state: this.orderStateMachine.getInitialState(),
             lines: [],
             pendingAdjustments: [],
             subTotal: 0,
@@ -133,6 +137,17 @@ export class OrderService {
         return updatedOrder;
     }
 
+    getNextOrderStates(order: Order): OrderState[] {
+        return this.orderStateMachine.getNextStates(order);
+    }
+
+    async transitionToState(ctx: RequestContext, orderId: ID, state: OrderState): Promise<Order> {
+        const order = await this.getOrderOrThrow(ctx, orderId);
+        await this.orderStateMachine.transition(order, state);
+        await this.connection.getRepository(Order).save(order);
+        return order;
+    }
+
     private async getOrderOrThrow(ctx: RequestContext, orderId: ID): Promise<Order> {
         const order = await this.findOne(ctx, orderId);
         if (!order) {

+ 25 - 0
shared/generated-types.ts

@@ -58,6 +58,7 @@ export interface Query {
     facet?: Facet | null;
     order?: Order | null;
     activeOrder?: Order | null;
+    nextOrderStates: string[];
     orders: OrderList;
     productOptionGroups: ProductOptionGroup[];
     productOptionGroup?: ProductOptionGroup | null;
@@ -273,6 +274,7 @@ export interface Order extends Node {
     createdAt: DateTime;
     updatedAt: DateTime;
     code: string;
+    state: string;
     customer?: Customer | null;
     lines: OrderLine[];
     adjustments: Adjustment[];
@@ -526,6 +528,7 @@ export interface Mutation {
     addItemToOrder?: Order | null;
     removeItemFromOrder?: Order | null;
     adjustItemQuantity?: Order | null;
+    transitionOrderToState?: Order | null;
     createProductOptionGroup: ProductOptionGroup;
     updateProductOptionGroup: ProductOptionGroup;
     createProduct: Product;
@@ -1313,6 +1316,9 @@ export interface AdjustItemQuantityMutationArgs {
     orderItemId: string;
     quantity: number;
 }
+export interface TransitionOrderToStateMutationArgs {
+    state: string;
+}
 export interface CreateProductOptionGroupMutationArgs {
     input: CreateProductOptionGroupInput;
 }
@@ -1646,6 +1652,7 @@ export namespace QueryResolvers {
         facet?: FacetResolver<Facet | null, any, Context>;
         order?: OrderResolver<Order | null, any, Context>;
         activeOrder?: ActiveOrderResolver<Order | null, any, Context>;
+        nextOrderStates?: NextOrderStatesResolver<string[], any, Context>;
         orders?: OrdersResolver<OrderList, any, Context>;
         productOptionGroups?: ProductOptionGroupsResolver<ProductOptionGroup[], any, Context>;
         productOptionGroup?: ProductOptionGroupResolver<ProductOptionGroup | null, any, Context>;
@@ -1821,6 +1828,11 @@ export namespace QueryResolvers {
         Parent,
         Context
     >;
+    export type NextOrderStatesResolver<R = string[], Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
     export type OrdersResolver<R = OrderList, Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -2498,6 +2510,7 @@ export namespace OrderResolvers {
         createdAt?: CreatedAtResolver<DateTime, any, Context>;
         updatedAt?: UpdatedAtResolver<DateTime, any, Context>;
         code?: CodeResolver<string, any, Context>;
+        state?: StateResolver<string, any, Context>;
         customer?: CustomerResolver<Customer | null, any, Context>;
         lines?: LinesResolver<OrderLine[], any, Context>;
         adjustments?: AdjustmentsResolver<Adjustment[], any, Context>;
@@ -2511,6 +2524,7 @@ export namespace OrderResolvers {
     export type CreatedAtResolver<R = DateTime, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type UpdatedAtResolver<R = DateTime, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type CodeResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type StateResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type CustomerResolver<R = Customer | null, Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -3181,6 +3195,7 @@ export namespace MutationResolvers {
         addItemToOrder?: AddItemToOrderResolver<Order | null, any, Context>;
         removeItemFromOrder?: RemoveItemFromOrderResolver<Order | null, any, Context>;
         adjustItemQuantity?: AdjustItemQuantityResolver<Order | null, any, Context>;
+        transitionOrderToState?: TransitionOrderToStateResolver<Order | null, any, Context>;
         createProductOptionGroup?: CreateProductOptionGroupResolver<ProductOptionGroup, any, Context>;
         updateProductOptionGroup?: UpdateProductOptionGroupResolver<ProductOptionGroup, any, Context>;
         createProduct?: CreateProductResolver<Product, any, Context>;
@@ -3443,6 +3458,16 @@ export namespace MutationResolvers {
         quantity: number;
     }
 
+    export type TransitionOrderToStateResolver<R = Order | null, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        TransitionOrderToStateArgs
+    >;
+    export interface TransitionOrderToStateArgs {
+        state: string;
+    }
+
     export type CreateProductOptionGroupResolver<
         R = ProductOptionGroup,
         Parent = any,

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است