Prechádzať zdrojové kódy

feat(core): Implement `transitionOrderToState` in Admin API

Michael Bromley 5 rokov pred
rodič
commit
3196b525df

+ 6 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -1862,6 +1862,7 @@ export type Mutation = {
     addNoteToOrder: Order;
     updateOrderNote: HistoryEntry;
     deleteOrderNote: DeletionResponse;
+    transitionOrderToState?: Maybe<Order>;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
     /** Create a new ProductOptionGroup */
@@ -2149,6 +2150,11 @@ export type MutationDeleteOrderNoteArgs = {
     id: Scalars['ID'];
 };
 
+export type MutationTransitionOrderToStateArgs = {
+    id: Scalars['ID'];
+    state: Scalars['String'];
+};
+
 export type MutationUpdatePaymentMethodArgs = {
     input: UpdatePaymentMethodInput;
 };

+ 7 - 0
packages/common/src/generated-types.ts

@@ -1861,6 +1861,7 @@ export type Mutation = {
   addNoteToOrder: Order;
   updateOrderNote: HistoryEntry;
   deleteOrderNote: DeletionResponse;
+  transitionOrderToState?: Maybe<Order>;
   /** Update an existing PaymentMethod */
   updatePaymentMethod: PaymentMethod;
   /** Create a new ProductOptionGroup */
@@ -2200,6 +2201,12 @@ export type MutationDeleteOrderNoteArgs = {
 };
 
 
+export type MutationTransitionOrderToStateArgs = {
+  id: Scalars['ID'];
+  state: Scalars['String'];
+};
+
+
 export type MutationUpdatePaymentMethodArgs = {
   input: UpdatePaymentMethodInput;
 };

+ 35 - 14
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1862,6 +1862,7 @@ export type Mutation = {
     addNoteToOrder: Order;
     updateOrderNote: HistoryEntry;
     deleteOrderNote: DeletionResponse;
+    transitionOrderToState?: Maybe<Order>;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
     /** Create a new ProductOptionGroup */
@@ -2149,6 +2150,11 @@ export type MutationDeleteOrderNoteArgs = {
     id: Scalars['ID'];
 };
 
+export type MutationTransitionOrderToStateArgs = {
+    id: Scalars['ID'];
+    state: Scalars['String'];
+};
+
 export type MutationUpdatePaymentMethodArgs = {
     input: UpdatePaymentMethodInput;
 };
@@ -5032,6 +5038,14 @@ export type GetCustomerHistoryQuery = { __typename?: 'Query' } & {
     >;
 };
 
+export type GetOrderQueryVariables = {
+    id: Scalars['ID'];
+};
+
+export type GetOrderQuery = { __typename?: 'Query' } & {
+    order?: Maybe<{ __typename?: 'Order' } & OrderWithLinesFragment>;
+};
+
 export type UpdateOptionGroupMutationVariables = {
     input: UpdateProductOptionGroupInput;
 };
@@ -5040,6 +5054,15 @@ export type UpdateOptionGroupMutation = { __typename?: 'Mutation' } & {
     updateProductOptionGroup: { __typename?: 'ProductOptionGroup' } & Pick<ProductOptionGroup, 'id'>;
 };
 
+export type AdminTransitionMutationVariables = {
+    id: Scalars['ID'];
+    state: Scalars['String'];
+};
+
+export type AdminTransitionMutation = { __typename?: 'Mutation' } & {
+    transitionOrderToState?: Maybe<{ __typename?: 'Order' } & Pick<Order, 'id' | 'state' | 'nextStates'>>;
+};
+
 export type DeletePromotionAdHoc1MutationVariables = {};
 
 export type DeletePromotionAdHoc1Mutation = { __typename?: 'Mutation' } & {
@@ -5077,14 +5100,6 @@ export type GetOrderListQuery = { __typename?: 'Query' } & {
         };
 };
 
-export type GetOrderQueryVariables = {
-    id: Scalars['ID'];
-};
-
-export type GetOrderQuery = { __typename?: 'Query' } & {
-    order?: Maybe<{ __typename?: 'Order' } & OrderWithLinesFragment>;
-};
-
 export type SettlePaymentMutationVariables = {
     id: Scalars['ID'];
 };
@@ -6706,12 +6721,24 @@ export namespace GetCustomerHistory {
     >;
 }
 
+export namespace GetOrder {
+    export type Variables = GetOrderQueryVariables;
+    export type Query = GetOrderQuery;
+    export type Order = OrderWithLinesFragment;
+}
+
 export namespace UpdateOptionGroup {
     export type Variables = UpdateOptionGroupMutationVariables;
     export type Mutation = UpdateOptionGroupMutation;
     export type UpdateProductOptionGroup = UpdateOptionGroupMutation['updateProductOptionGroup'];
 }
 
+export namespace AdminTransition {
+    export type Variables = AdminTransitionMutationVariables;
+    export type Mutation = AdminTransitionMutation;
+    export type TransitionOrderToState = NonNullable<AdminTransitionMutation['transitionOrderToState']>;
+}
+
 export namespace DeletePromotionAdHoc1 {
     export type Variables = DeletePromotionAdHoc1MutationVariables;
     export type Mutation = DeletePromotionAdHoc1Mutation;
@@ -6740,12 +6767,6 @@ export namespace GetOrderList {
     export type Items = OrderFragment;
 }
 
-export namespace GetOrder {
-    export type Variables = GetOrderQueryVariables;
-    export type Query = GetOrderQuery;
-    export type Order = OrderWithLinesFragment;
-}
-
 export namespace SettlePayment {
     export type Variables = SettlePaymentMutationVariables;
     export type Mutation = SettlePaymentMutation;

+ 10 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -8,6 +8,7 @@ import {
     CURRENT_USER_FRAGMENT,
     CUSTOMER_FRAGMENT,
     FACET_WITH_VALUES_FRAGMENT,
+    ORDER_WITH_LINES_FRAGMENT,
     PRODUCT_VARIANT_FRAGMENT,
     PRODUCT_WITH_VARIANTS_FRAGMENT,
     PROMOTION_FRAGMENT,
@@ -395,3 +396,12 @@ export const GET_CUSTOMER_HISTORY = gql`
         }
     }
 `;
+
+export const GET_ORDER = gql`
+    query GetOrder($id: ID!) {
+        order(id: $id) {
+            ...OrderWithLines
+        }
+    }
+    ${ORDER_WITH_LINES_FRAGMENT}
+`;

+ 287 - 73
packages/core/e2e/order-process.e2e-spec.ts

@@ -1,20 +1,31 @@
+/* tslint:disable:no-non-null-assertion */
 import { CustomOrderProcess, mergeConfig, OrderState } from '@vendure/core';
 import { createTestEnvironment } from '@vendure/testing';
+import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
+import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
+import { AdminTransition, GetOrder } from './graphql/generated-e2e-admin-types';
 import {
     AddItemToOrder,
+    AddPaymentToOrder,
     GetNextOrderStates,
     SetCustomerForOrder,
+    SetShippingAddress,
+    SetShippingMethod,
     TransitionToState,
 } from './graphql/generated-e2e-shop-types';
+import { GET_ORDER } from './graphql/shared-definitions';
 import {
     ADD_ITEM_TO_ORDER,
+    ADD_PAYMENT,
     GET_NEXT_STATES,
     SET_CUSTOMER,
+    SET_SHIPPING_ADDRESS,
+    SET_SHIPPING_METHOD,
     TRANSITION_TO_STATE,
 } from './graphql/shop-definitions';
 
@@ -74,6 +85,9 @@ describe('Order process', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(
         mergeConfig(testConfig, {
             orderOptions: { process: [customOrderProcess, customOrderProcess2] },
+            paymentOptions: {
+                paymentMethodHandlers: [testSuccessfulPaymentMethod],
+            },
         }),
     );
 
@@ -90,109 +104,309 @@ describe('Order process', () => {
         await server.destroy();
     });
 
-    it('CustomOrderProcess is injectable', () => {
-        expect(initSpy).toHaveBeenCalled();
-        expect(initSpy.mock.calls[0][0]).toBe('default');
-    });
-
-    it('replaced transition target', async () => {
-        await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
-            productVariantId: 'T_1',
-            quantity: 1,
+    describe('CustomOrderProcess', () => {
+        it('CustomOrderProcess is injectable', () => {
+            expect(initSpy).toHaveBeenCalled();
+            expect(initSpy.mock.calls[0][0]).toBe('default');
         });
 
-        const { nextOrderStates } = await shopClient.query<GetNextOrderStates.Query>(GET_NEXT_STATES);
+        it('replaced transition target', async () => {
+            await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_1',
+                quantity: 1,
+            });
 
-        expect(nextOrderStates).toEqual(['ValidatingCustomer']);
-    });
+            const { nextOrderStates } = await shopClient.query<GetNextOrderStates.Query>(GET_NEXT_STATES);
+
+            expect(nextOrderStates).toEqual(['ValidatingCustomer']);
+        });
 
-    it('custom onTransitionStart handler returning false', async () => {
-        transitionStartSpy.mockClear();
-        transitionEndSpy.mockClear();
+        it('custom onTransitionStart handler returning false', async () => {
+            transitionStartSpy.mockClear();
+            transitionEndSpy.mockClear();
 
-        const { transitionOrderToState } = await shopClient.query<
-            TransitionToState.Mutation,
-            TransitionToState.Variables
-        >(TRANSITION_TO_STATE, {
-            state: 'ValidatingCustomer',
+            const { transitionOrderToState } = await shopClient.query<
+                TransitionToState.Mutation,
+                TransitionToState.Variables
+            >(TRANSITION_TO_STATE, {
+                state: 'ValidatingCustomer',
+            });
+
+            expect(transitionStartSpy).toHaveBeenCalledTimes(1);
+            expect(transitionEndSpy).not.toHaveBeenCalled();
+            expect(transitionStartSpy.mock.calls[0].slice(0, 2)).toEqual([
+                'AddingItems',
+                'ValidatingCustomer',
+            ]);
+            expect(transitionOrderToState?.state).toBe('AddingItems');
         });
 
-        expect(transitionStartSpy).toHaveBeenCalledTimes(1);
-        expect(transitionEndSpy).not.toHaveBeenCalled();
-        expect(transitionStartSpy.mock.calls[0].slice(0, 2)).toEqual(['AddingItems', 'ValidatingCustomer']);
-        expect(transitionOrderToState?.state).toBe('AddingItems');
-    });
+        it('custom onTransitionStart handler returning error message', async () => {
+            transitionStartSpy.mockClear();
+            transitionErrorSpy.mockClear();
 
-    it('custom onTransitionStart handler returning error message', async () => {
-        transitionStartSpy.mockClear();
-        transitionErrorSpy.mockClear();
+            await shopClient.query<SetCustomerForOrder.Mutation, SetCustomerForOrder.Variables>(
+                SET_CUSTOMER,
+                {
+                    input: {
+                        firstName: 'Joe',
+                        lastName: 'Test',
+                        emailAddress: 'joetest@gmail.com',
+                    },
+                },
+            );
 
-        await shopClient.query<SetCustomerForOrder.Mutation, SetCustomerForOrder.Variables>(SET_CUSTOMER, {
-            input: {
-                firstName: 'Joe',
-                lastName: 'Test',
-                emailAddress: 'joetest@gmail.com',
-            },
+            try {
+                const { transitionOrderToState } = await shopClient.query<
+                    TransitionToState.Mutation,
+                    TransitionToState.Variables
+                >(TRANSITION_TO_STATE, {
+                    state: 'ValidatingCustomer',
+                });
+                fail('Should have thrown');
+            } catch (e) {
+                expect(e.message).toContain(VALIDATION_ERROR_MESSAGE);
+            }
+
+            expect(transitionStartSpy).toHaveBeenCalledTimes(1);
+            expect(transitionErrorSpy).toHaveBeenCalledTimes(1);
+            expect(transitionEndSpy).not.toHaveBeenCalled();
+            expect(transitionErrorSpy.mock.calls[0]).toEqual([
+                'AddingItems',
+                'ValidatingCustomer',
+                VALIDATION_ERROR_MESSAGE,
+            ]);
         });
 
-        try {
+        it('custom onTransitionStart handler allows transition', async () => {
+            transitionEndSpy.mockClear();
+
+            await shopClient.query<SetCustomerForOrder.Mutation, SetCustomerForOrder.Variables>(
+                SET_CUSTOMER,
+                {
+                    input: {
+                        firstName: 'Joe',
+                        lastName: 'Test',
+                        emailAddress: 'joetest@company.com',
+                    },
+                },
+            );
+
             const { transitionOrderToState } = await shopClient.query<
                 TransitionToState.Mutation,
                 TransitionToState.Variables
             >(TRANSITION_TO_STATE, {
                 state: 'ValidatingCustomer',
             });
-            fail('Should have thrown');
-        } catch (e) {
-            expect(e.message).toContain(VALIDATION_ERROR_MESSAGE);
-        }
 
-        expect(transitionStartSpy).toHaveBeenCalledTimes(1);
-        expect(transitionErrorSpy).toHaveBeenCalledTimes(1);
-        expect(transitionEndSpy).not.toHaveBeenCalled();
-        expect(transitionErrorSpy.mock.calls[0]).toEqual([
-            'AddingItems',
-            'ValidatingCustomer',
-            VALIDATION_ERROR_MESSAGE,
-        ]);
+            expect(transitionEndSpy).toHaveBeenCalledTimes(1);
+            expect(transitionEndSpy.mock.calls[0].slice(0, 2)).toEqual(['AddingItems', 'ValidatingCustomer']);
+            expect(transitionOrderToState?.state).toBe('ValidatingCustomer');
+        });
+
+        it('composes multiple CustomOrderProcesses', async () => {
+            transitionEndSpy.mockClear();
+            transitionEndSpy2.mockClear();
+
+            const { nextOrderStates } = await shopClient.query<GetNextOrderStates.Query>(GET_NEXT_STATES);
+
+            expect(nextOrderStates).toEqual(['ArrangingPayment', 'AddingItems', 'Cancelled']);
+
+            await shopClient.query<TransitionToState.Mutation, TransitionToState.Variables>(
+                TRANSITION_TO_STATE,
+                {
+                    state: 'AddingItems',
+                },
+            );
+
+            expect(transitionEndSpy.mock.calls[0].slice(0, 2)).toEqual(['ValidatingCustomer', 'AddingItems']);
+            expect(transitionEndSpy2.mock.calls[0].slice(0, 2)).toEqual([
+                'ValidatingCustomer',
+                'AddingItems',
+            ]);
+        });
     });
 
-    it('custom onTransitionStart handler allows transition', async () => {
-        transitionEndSpy.mockClear();
+    describe('Admin API transition constraints', () => {
+        let order: NonNullable<TransitionToState.Mutation['transitionOrderToState']>;
 
-        await shopClient.query<SetCustomerForOrder.Mutation, SetCustomerForOrder.Variables>(SET_CUSTOMER, {
-            input: {
-                firstName: 'Joe',
-                lastName: 'Test',
-                emailAddress: 'joetest@company.com',
-            },
+        beforeAll(async () => {
+            await shopClient.asAnonymousUser();
+            await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_1',
+                quantity: 1,
+            });
+            await shopClient.query<SetCustomerForOrder.Mutation, SetCustomerForOrder.Variables>(
+                SET_CUSTOMER,
+                {
+                    input: {
+                        firstName: 'Su',
+                        lastName: 'Test',
+                        emailAddress: 'sutest@company.com',
+                    },
+                },
+            );
+            await shopClient.query<SetShippingAddress.Mutation, SetShippingAddress.Variables>(
+                SET_SHIPPING_ADDRESS,
+                {
+                    input: {
+                        fullName: 'name',
+                        streetLine1: '12 the street',
+                        city: 'foo',
+                        postalCode: '123456',
+                        countryCode: 'US',
+                        phoneNumber: '4444444',
+                    },
+                },
+            );
+            await shopClient.query<SetShippingMethod.Mutation, SetShippingMethod.Variables>(
+                SET_SHIPPING_METHOD,
+                { id: 'T_1' },
+            );
+            await shopClient.query<TransitionToState.Mutation, TransitionToState.Variables>(
+                TRANSITION_TO_STATE,
+                {
+                    state: 'ValidatingCustomer',
+                },
+            );
+            const result = await shopClient.query<TransitionToState.Mutation, TransitionToState.Variables>(
+                TRANSITION_TO_STATE,
+                {
+                    state: 'ArrangingPayment',
+                },
+            );
+            order = result.transitionOrderToState!;
         });
 
-        const { transitionOrderToState } = await shopClient.query<
-            TransitionToState.Mutation,
-            TransitionToState.Variables
-        >(TRANSITION_TO_STATE, {
-            state: 'ValidatingCustomer',
+        it('cannot manually transition to PaymentAuthorized', async () => {
+            expect(order.state).toBe('ArrangingPayment');
+
+            try {
+                const { transitionOrderToState } = await adminClient.query<
+                    AdminTransition.Mutation,
+                    AdminTransition.Variables
+                >(ADMIN_TRANSITION_TO_STATE, {
+                    id: order.id,
+                    state: 'PaymentAuthorized',
+                });
+                fail('Should have thrown');
+            } catch (e) {
+                expect(e.message).toContain(
+                    'Cannot transition Order to the "PaymentAuthorized" state when the total is not covered by authorized Payments',
+                );
+            }
+
+            const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: order.id,
+            });
+            expect(result.order?.state).toBe('ArrangingPayment');
         });
 
-        expect(transitionEndSpy).toHaveBeenCalledTimes(1);
-        expect(transitionEndSpy.mock.calls[0].slice(0, 2)).toEqual(['AddingItems', 'ValidatingCustomer']);
-        expect(transitionOrderToState?.state).toBe('ValidatingCustomer');
-    });
+        it('cannot manually transition to PaymentSettled', async () => {
+            try {
+                const { transitionOrderToState } = await adminClient.query<
+                    AdminTransition.Mutation,
+                    AdminTransition.Variables
+                >(ADMIN_TRANSITION_TO_STATE, {
+                    id: order.id,
+                    state: 'PaymentSettled',
+                });
+                fail('Should have thrown');
+            } catch (e) {
+                expect(e.message).toContain(
+                    'Cannot transition Order to the "PaymentSettled" state when the total is not covered by settled Payments',
+                );
+            }
 
-    it('composes multiple CustomOrderProcesses', async () => {
-        transitionEndSpy.mockClear();
-        transitionEndSpy2.mockClear();
+            const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: order.id,
+            });
+            expect(result.order?.state).toBe('ArrangingPayment');
+        });
 
-        const { nextOrderStates } = await shopClient.query<GetNextOrderStates.Query>(GET_NEXT_STATES);
+        it('cannot manually transition to Cancelled', async () => {
+            const { addPaymentToOrder } = await shopClient.query<
+                AddPaymentToOrder.Mutation,
+                AddPaymentToOrder.Variables
+            >(ADD_PAYMENT, {
+                input: {
+                    method: testSuccessfulPaymentMethod.code,
+                    metadata: {},
+                },
+            });
+
+            expect(addPaymentToOrder?.state).toBe('PaymentSettled');
 
-        expect(nextOrderStates).toEqual(['ArrangingPayment', 'AddingItems', 'Cancelled']);
+            try {
+                const { transitionOrderToState } = await adminClient.query<
+                    AdminTransition.Mutation,
+                    AdminTransition.Variables
+                >(ADMIN_TRANSITION_TO_STATE, {
+                    id: order.id,
+                    state: 'Cancelled',
+                });
+                fail('Should have thrown');
+            } catch (e) {
+                expect(e.message).toContain(
+                    'Cannot transition Order to the "Cancelled" state unless all OrderItems are cancelled',
+                );
+            }
+            const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: order.id,
+            });
+            expect(result.order?.state).toBe('PaymentSettled');
+        });
 
-        await shopClient.query<TransitionToState.Mutation, TransitionToState.Variables>(TRANSITION_TO_STATE, {
-            state: 'Cancelled',
+        it('cannot manually transition to PartiallyFulfilled', async () => {
+            try {
+                const { transitionOrderToState } = await adminClient.query<
+                    AdminTransition.Mutation,
+                    AdminTransition.Variables
+                >(ADMIN_TRANSITION_TO_STATE, {
+                    id: order.id,
+                    state: 'PartiallyFulfilled',
+                });
+                fail('Should have thrown');
+            } catch (e) {
+                expect(e.message).toContain(
+                    'Cannot transition Order to the "PartiallyFulfilled" state unless some OrderItems are fulfilled',
+                );
+            }
+            const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: order.id,
+            });
+            expect(result.order?.state).toBe('PaymentSettled');
         });
 
-        expect(transitionEndSpy.mock.calls[0].slice(0, 2)).toEqual(['ValidatingCustomer', 'Cancelled']);
-        expect(transitionEndSpy2.mock.calls[0].slice(0, 2)).toEqual(['ValidatingCustomer', 'Cancelled']);
+        it('cannot manually transition to PartiallyFulfilled', async () => {
+            try {
+                const { transitionOrderToState } = await adminClient.query<
+                    AdminTransition.Mutation,
+                    AdminTransition.Variables
+                >(ADMIN_TRANSITION_TO_STATE, {
+                    id: order.id,
+                    state: 'Fulfilled',
+                });
+                fail('Should have thrown');
+            } catch (e) {
+                expect(e.message).toContain(
+                    'Cannot transition Order to the "Fulfilled" state unless all OrderItems are fulfilled',
+                );
+            }
+            const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: order.id,
+            });
+            expect(result.order?.state).toBe('PaymentSettled');
+        });
     });
 });
+
+export const ADMIN_TRANSITION_TO_STATE = gql`
+    mutation AdminTransition($id: ID!, $state: String!) {
+        transitionOrderToState(id: $id, state: $state) {
+            id
+            state
+            nextStates
+        }
+    }
+`;

+ 3 - 11
packages/core/e2e/order.e2e-spec.ts

@@ -12,7 +12,7 @@ import {
     singleStageRefundablePaymentMethod,
     twoStagePaymentMethod,
 } from './fixtures/test-payment-methods';
-import { ORDER_FRAGMENT, ORDER_WITH_LINES_FRAGMENT } from './graphql/fragments';
+import { ORDER_FRAGMENT } from './graphql/fragments';
 import {
     AddNoteToOrder,
     CancelOrder,
@@ -39,6 +39,7 @@ import {
 import { AddItemToOrder, DeletionResult, GetActiveOrder } from './graphql/generated-e2e-shop-types';
 import {
     GET_CUSTOMER_LIST,
+    GET_ORDER,
     GET_PRODUCT_WITH_VARIANTS,
     GET_STOCK_MOVEMENT,
     UPDATE_PRODUCT_VARIANTS,
@@ -690,7 +691,7 @@ describe('Orders resolver', () => {
         );
 
         it(
-            'throws if lines are ampty',
+            'throws if lines are empty',
             assertThrowsWithMessage(async () => {
                 const order = await addPaymentToOrder(shopClient, twoStagePaymentMethod);
                 expect(order.state).toBe('PaymentAuthorized');
@@ -1299,15 +1300,6 @@ export const GET_ORDERS_LIST = gql`
     ${ORDER_FRAGMENT}
 `;
 
-export const GET_ORDER = gql`
-    query GetOrder($id: ID!) {
-        order(id: $id) {
-            ...OrderWithLines
-        }
-    }
-    ${ORDER_WITH_LINES_FRAGMENT}
-`;
-
 export const SETTLE_PAYMENT = gql`
     mutation SettlePayment($id: ID!) {
         settlePayment(id: $id) {

+ 11 - 0
packages/core/src/api/resolvers/admin/order.resolver.ts

@@ -7,6 +7,7 @@ import {
     MutationRefundOrderArgs,
     MutationSettlePaymentArgs,
     MutationSettleRefundArgs,
+    MutationTransitionOrderToStateArgs,
     MutationUpdateOrderNoteArgs,
     Permission,
     QueryOrderArgs,
@@ -15,6 +16,7 @@ import {
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { Order } from '../../../entity/order/order.entity';
+import { OrderState } from '../../../service/helpers/order-state-machine/order-state';
 import { OrderService } from '../../../service/services/order.service';
 import { ShippingMethodService } from '../../../service/services/shipping-method.service';
 import { RequestContext } from '../../common/request-context';
@@ -84,4 +86,13 @@ export class OrderResolver {
     async deleteOrderNote(@Ctx() ctx: RequestContext, @Args() args: MutationDeleteOrderNoteArgs) {
         return this.orderService.deleteOrderNote(ctx, args.id);
     }
+
+    @Mutation()
+    @Allow(Permission.UpdateOrder)
+    async transitionOrderToState(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationTransitionOrderToStateArgs,
+    ) {
+        return this.orderService.transitionToState(ctx, args.id, args.state as OrderState);
+    }
 }

+ 1 - 0
packages/core/src/api/schema/admin-api/order.api.graphql

@@ -12,6 +12,7 @@ type Mutation {
     addNoteToOrder(input: AddNoteToOrderInput!): Order!
     updateOrderNote(input: UpdateOrderNoteInput!): HistoryEntry!
     deleteOrderNote(id: ID!): DeletionResponse!
+    transitionOrderToState(id: ID!, state: String!): Order
 }
 
 type Order {

+ 5 - 0
packages/core/src/i18n/messages/en.json

@@ -17,6 +17,11 @@
     "cannot-transition-refund-from-to": "Cannot transition Refund from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-to-shipping-when-order-is-empty": "Cannot transition Order to the \"ArrangingShipping\" state when it is empty",
     "cannot-transition-to-payment-without-customer": "Cannot transition Order to the \"ArrangingPayment\" state without Customer details",
+    "cannot-transition-unless-all-cancelled": "Cannot transition Order to the \"Cancelled\" state unless all OrderItems are cancelled",
+    "cannot-transition-unless-all-order-items-fulfilled": "Cannot transition Order to the \"Fulfilled\" state unless all OrderItems are fulfilled",
+    "cannot-transition-unless-some-order-items-fulfilled": "Cannot transition Order to the \"PartiallyFulfilled\" state unless some OrderItems are fulfilled",
+    "cannot-transition-without-authorized-payments": "Cannot transition Order to the \"PaymentAuthorized\" state when the total is not covered by authorized Payments",
+    "cannot-transition-without-settled-payments": "Cannot transition Order to the \"PaymentSettled\" state when the total is not covered by settled Payments",
     "cannot-use-registered-email-address-for-guest-order":  "Cannot use a registered email address for a guest order. Please log in first",
     "channel-not-found":  "No channel with the token \"{ token }\" exists",
     "collection-id-or-slug-must-be-provided": "Either the Collection id or slug must be provided",

+ 40 - 1
packages/core/src/service/helpers/order-state-machine/order-state-machine.ts

@@ -1,5 +1,8 @@
 import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
 import { HistoryEntryType } from '@vendure/common/lib/generated-types';
+import { from } from 'rxjs';
+import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { IllegalOperationError } from '../../../common/error/errors';
@@ -11,10 +14,18 @@ import {
 import { mergeTransitionDefinitions } from '../../../common/finite-state-machine/merge-transition-definitions';
 import { validateTransitionDefinition } from '../../../common/finite-state-machine/validate-transition-definition';
 import { ConfigService } from '../../../config/config.service';
+import { OrderItem } from '../../../entity/order-item/order-item.entity';
 import { Order } from '../../../entity/order/order.entity';
 import { HistoryService } from '../../services/history.service';
 import { PromotionService } from '../../services/promotion.service';
 import { StockMovementService } from '../../services/stock-movement.service';
+import { getEntityOrThrow } from '../utils/get-entity-or-throw';
+import {
+    orderItemsAreAllCancelled,
+    orderItemsAreFulfilled,
+    orderItemsArePartiallyFulfilled,
+    orderTotalIsCovered,
+} from '../utils/order-utils';
 
 import { OrderState, orderStateTransitions, OrderTransitionData } from './order-state';
 
@@ -24,6 +35,7 @@ export class OrderStateMachine {
     private readonly initialState: OrderState = 'AddingItems';
 
     constructor(
+        @InjectConnection() private connection: Connection,
         private configService: ConfigService,
         private stockMovementService: StockMovementService,
         private historyService: HistoryService,
@@ -54,7 +66,7 @@ export class OrderStateMachine {
     /**
      * Specific business logic to be executed on Order state transitions.
      */
-    private onTransitionStart(fromState: OrderState, toState: OrderState, data: OrderTransitionData) {
+    private async onTransitionStart(fromState: OrderState, toState: OrderState, data: OrderTransitionData) {
         if (toState === 'ArrangingPayment') {
             if (data.order.lines.length === 0) {
                 return `error.cannot-transition-to-payment-when-order-is-empty`;
@@ -63,6 +75,33 @@ export class OrderStateMachine {
                 return `error.cannot-transition-to-payment-without-customer`;
             }
         }
+        if (toState === 'PaymentAuthorized' && !orderTotalIsCovered(data.order, 'Authorized')) {
+            return `error.cannot-transition-without-authorized-payments`;
+        }
+        if (toState === 'PaymentSettled' && !orderTotalIsCovered(data.order, 'Settled')) {
+            return `error.cannot-transition-without-settled-payments`;
+        }
+        if (toState === 'Cancelled' && fromState !== 'AddingItems' && fromState !== 'ArrangingPayment') {
+            if (!orderItemsAreAllCancelled(data.order)) {
+                return `error.cannot-transition-unless-all-cancelled`;
+            }
+        }
+        if (toState === 'PartiallyFulfilled') {
+            const orderWithFulfillments = await getEntityOrThrow(this.connection, Order, data.order.id, {
+                relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
+            });
+            if (!orderItemsArePartiallyFulfilled(orderWithFulfillments)) {
+                return `error.cannot-transition-unless-some-order-items-fulfilled`;
+            }
+        }
+        if (toState === 'Fulfilled') {
+            const orderWithFulfillments = await getEntityOrThrow(this.connection, Order, data.order.id, {
+                relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
+            });
+            if (!orderItemsAreFulfilled(orderWithFulfillments)) {
+                return `error.cannot-transition-unless-all-order-items-fulfilled`;
+            }
+        }
     }
 
     /**

+ 44 - 0
packages/core/src/service/helpers/utils/order-utils.ts

@@ -0,0 +1,44 @@
+import { OrderItem } from '../../../entity/order-item/order-item.entity';
+import { Order } from '../../../entity/order/order.entity';
+import { PaymentState } from '../payment-state-machine/payment-state';
+
+/**
+ * Returns true if the Order total is covered by Payments in the specified state.
+ */
+export function orderTotalIsCovered(order: Order, state: PaymentState): boolean {
+    return (
+        order.payments.filter((p) => p.state === state).reduce((sum, p) => sum + p.amount, 0) === order.total
+    );
+}
+
+/**
+ * Returns true if all (non-cancelled) OrderItems are fulfilled.
+ */
+export function orderItemsAreFulfilled(order: Order) {
+    return getOrderItems(order)
+        .filter((orderItem) => !orderItem.cancelled)
+        .every(isFulfilled);
+}
+
+/**
+ * Returns true if at least one, but not all (non-cancelled) OrderItems are fulfilled.
+ */
+export function orderItemsArePartiallyFulfilled(order: Order) {
+    const nonCancelledItems = getOrderItems(order).filter((orderItem) => !orderItem.cancelled);
+    return nonCancelledItems.some(isFulfilled) && !nonCancelledItems.every(isFulfilled);
+}
+
+/**
+ * Returns true if all OrderItems in the order are cancelled
+ */
+export function orderItemsAreAllCancelled(order: Order) {
+    return getOrderItems(order).every((orderItem) => orderItem.cancelled);
+}
+
+function getOrderItems(order: Order): OrderItem[] {
+    return order.lines.reduce((orderItems, line) => [...orderItems, ...line.items], [] as OrderItem[]);
+}
+
+function isFulfilled(orderItem: OrderItem) {
+    return !!orderItem.fulfillment;
+}

+ 10 - 21
packages/core/src/service/services/order.service.ts

@@ -50,11 +50,15 @@ import { OrderCalculator } from '../helpers/order-calculator/order-calculator';
 import { OrderMerger } from '../helpers/order-merger/order-merger';
 import { OrderState } from '../helpers/order-state-machine/order-state';
 import { OrderStateMachine } from '../helpers/order-state-machine/order-state-machine';
-import { PaymentState } from '../helpers/payment-state-machine/payment-state';
 import { PaymentStateMachine } from '../helpers/payment-state-machine/payment-state-machine';
 import { RefundStateMachine } from '../helpers/refund-state-machine/refund-state-machine';
 import { ShippingCalculator } from '../helpers/shipping-calculator/shipping-calculator';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
+import {
+    orderItemsAreAllCancelled,
+    orderItemsAreFulfilled,
+    orderTotalIsCovered,
+} from '../helpers/utils/order-utils';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
 import { CountryService } from './country.service';
@@ -406,6 +410,7 @@ export class OrderService {
 
     async transitionToState(ctx: RequestContext, orderId: ID, state: OrderState): Promise<Order> {
         const order = await this.getOrderOrThrow(ctx, orderId);
+        order.payments = await this.getOrderPayments(orderId);
         const fromState = order.state;
         await this.orderStateMachine.transition(ctx, order, state);
         await this.connection.getRepository(Order).save(order, { reload: false });
@@ -433,17 +438,10 @@ export class OrderService {
             throw new InternalServerError(payment.errorMessage);
         }
 
-        function totalIsCovered(state: PaymentState): boolean {
-            return (
-                order.payments.filter((p) => p.state === state).reduce((sum, p) => sum + p.amount, 0) ===
-                order.total
-            );
-        }
-
-        if (totalIsCovered('Settled')) {
+        if (orderTotalIsCovered(order, 'Settled')) {
             return this.transitionToState(ctx, orderId, 'PaymentSettled');
         }
-        if (totalIsCovered('Authorized')) {
+        if (orderTotalIsCovered(order, 'Authorized')) {
             return this.transitionToState(ctx, orderId, 'PaymentAuthorized');
         }
         return order;
@@ -511,13 +509,7 @@ export class OrderService {
             if (!orderWithFulfillments) {
                 throw new InternalServerError('error.could-not-find-order');
             }
-            const allOrderItemsFulfilled = orderWithFulfillments.lines
-                .reduce((orderItems, line) => [...orderItems, ...line.items], [] as OrderItem[])
-                .filter((orderItem) => !orderItem.cancelled)
-                .every((orderItem) => {
-                    return !!orderItem.fulfillment;
-                });
-            if (allOrderItemsFulfilled) {
+            if (orderItemsAreFulfilled(orderWithFulfillments)) {
                 await this.transitionToState(ctx, order.id, 'Fulfilled');
             } else {
                 await this.transitionToState(ctx, order.id, 'PartiallyFulfilled');
@@ -626,10 +618,7 @@ export class OrderService {
                 reason: input.reason || undefined,
             },
         });
-        const allOrderItemsCancelled = orderWithItems.lines
-            .reduce((orderItems, line) => [...orderItems, ...line.items], [] as OrderItem[])
-            .every((orderItem) => orderItem.cancelled);
-        return allOrderItemsCancelled;
+        return orderItemsAreAllCancelled(orderWithItems);
     }
 
     async refundOrder(ctx: RequestContext, input: RefundOrderInput): Promise<Refund> {

+ 6 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -1862,6 +1862,7 @@ export type Mutation = {
     addNoteToOrder: Order;
     updateOrderNote: HistoryEntry;
     deleteOrderNote: DeletionResponse;
+    transitionOrderToState?: Maybe<Order>;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
     /** Create a new ProductOptionGroup */
@@ -2149,6 +2150,11 @@ export type MutationDeleteOrderNoteArgs = {
     id: Scalars['ID'];
 };
 
+export type MutationTransitionOrderToStateArgs = {
+    id: Scalars['ID'];
+    state: Scalars['String'];
+};
+
 export type MutationUpdatePaymentMethodArgs = {
     input: UpdatePaymentMethodInput;
 };

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
schema-admin.json


Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov