Jelajahi Sumber

feat(core): Create Sale stock movements when Order is completed

Relates to #81
Michael Bromley 6 tahun lalu
induk
melakukan
e0a0441ce6

+ 139 - 1
packages/core/e2e/stock-control.e2e-spec.ts

@@ -1,7 +1,10 @@
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { ProductVariant, StockMovementType, UpdateProductVariantInput } from '../../common/src/generated-types';
+import { PaymentInput } from '../../common/src/generated-shop-types';
+import { CreateAddressInput, ProductVariant, StockMovementType, UpdateProductVariantInput } from '../../common/src/generated-types';
+import { PaymentMethodHandler } from '../src/config/payment-method/payment-method-handler';
+import { OrderState } from '../src/service/helpers/order-state-machine/order-state';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { TestAdminClient, TestShopClient } from './test-client';
@@ -21,6 +24,11 @@ describe('Stock control', () => {
                 productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
                 customerCount: 2,
             },
+            {
+                paymentOptions: {
+                    paymentMethodHandlers: [testPaymentMethod],
+                },
+            },
         );
         await shopClient.init();
         await adminClient.init();
@@ -105,6 +113,82 @@ describe('Stock control', () => {
         );
     });
 
+    describe('sales', () => {
+
+        beforeAll(async () => {
+            const { product } = await adminClient.query(GET_STOCK_MOVEMENT, { id: 'T_2' });
+            const [variant1, variant2]: ProductVariant[] = product.variants;
+
+            await adminClient.query(UPDATE_STOCK_ON_HAND, {
+                input: [
+                    {
+                        id: variant1.id,
+                        stockOnHand: 5,
+                        trackInventory: false,
+                    },
+                    {
+                        id: variant2.id,
+                        stockOnHand: 5,
+                        trackInventory: true,
+                    },
+                ] as UpdateProductVariantInput[],
+            });
+
+            // Add items to order and check out
+            await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+            await shopClient.query(ADD_ITEM_TO_ORDER, { productVariantId: variant1.id, quantity: 2 });
+            await shopClient.query(ADD_ITEM_TO_ORDER, { productVariantId: variant2.id, quantity: 3 });
+            await shopClient.query(SET_SHIPPING_ADDRESS, {
+                input: {
+                    streetLine1: '1 Test Street',
+                    countryCode: 'GB',
+                } as CreateAddressInput,
+            });
+            await shopClient.query(TRANSITION_TO_STATE, { state: 'ArrangingPayment' as OrderState });
+            await shopClient.query(ADD_PAYMENT, {
+                input: {
+                    method: testPaymentMethod.code,
+                    metadata: {},
+                } as PaymentInput,
+            });
+        });
+
+        it('creates a Sale when order completed', async () => {
+            const result = await adminClient.query(GET_STOCK_MOVEMENT, { id: 'T_2' });
+            const [variant1, variant2]: ProductVariant[] = result.product.variants;
+
+            expect(variant1.stockMovements.totalItems).toBe(2);
+            expect(variant1.stockMovements.items[1].type).toBe(StockMovementType.SALE);
+            expect(variant1.stockMovements.items[1].quantity).toBe(-2);
+
+            expect(variant2.stockMovements.totalItems).toBe(2);
+            expect(variant2.stockMovements.items[1].type).toBe(StockMovementType.SALE);
+            expect(variant2.stockMovements.items[1].quantity).toBe(-3);
+        });
+
+        it('stockOnHand is updated according to trackInventory setting', async () => {
+            const result = await adminClient.query(GET_STOCK_MOVEMENT, { id: 'T_2' });
+            const [variant1, variant2]: ProductVariant[] = result.product.variants;
+
+            expect(variant1.stockOnHand).toBe(5); // untracked inventory
+            expect(variant2.stockOnHand).toBe(2); // tracked inventory
+        });
+    });
+
+});
+
+const testPaymentMethod = new PaymentMethodHandler({
+    code: 'test-payment-method',
+    description: 'Test Payment Method',
+    args: {},
+    createPayment: (order, args, metadata) => {
+        return {
+            amount: order.total,
+            state: 'Settled',
+            transactionId: '12345',
+            metadata,
+        };
+    },
 });
 
 const VARIANT_WITH_STOCK_FRAGMENT = gql`
@@ -144,3 +228,57 @@ const UPDATE_STOCK_ON_HAND = gql`
     }
     ${VARIANT_WITH_STOCK_FRAGMENT}
 `;
+
+const TEST_ORDER_FRAGMENT = gql`
+    fragment TestOrderFragment on Order {
+        id
+        code
+        state
+        active
+        lines {
+            id
+            quantity
+            productVariant {
+                id
+            }
+        }
+    }
+`;
+
+const ADD_ITEM_TO_ORDER = gql`
+    mutation AddItemToOrder($productVariantId: ID!, $quantity: Int!) {
+        addItemToOrder(productVariantId: $productVariantId, quantity: $quantity) {
+            ...TestOrderFragment
+        }
+    }
+    ${TEST_ORDER_FRAGMENT}
+`;
+
+const SET_SHIPPING_ADDRESS = gql`
+    mutation SetShippingAddress($input: CreateAddressInput!) {
+        setOrderShippingAddress(input: $input) {
+            shippingAddress {
+                streetLine1
+            }
+        }
+    }
+`;
+
+const TRANSITION_TO_STATE = gql`
+    mutation TransitionToState($state: String!) {
+        transitionOrderToState(state: $state) {
+            id
+            state
+        }
+    }
+`;
+
+const ADD_PAYMENT = gql`
+    mutation AddPaymentToOrder($input: PaymentInput!) {
+        addPaymentToOrder(input: $input) {
+            payments {
+                id
+            }
+        }
+    }
+`;

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

@@ -1,6 +1,7 @@
 {
   "error": {
     "cannot-modify-role": "The role '{ roleCode }' cannot be modified",
+    "cannot-create-sales-for-active-order": "Cannot create a Sale for an Order which is still active",
     "cannot-move-collection-into-self": "Cannot move a Collection into itself",
     "cannot-transition-order-from-to": "Cannot transition Order from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-to-shipping-when-order-is-empty": "Cannot transition Order to the \"ArrangingShipping\" state when it is empty",

+ 6 - 2
packages/core/src/service/helpers/order-state-machine/order-state-machine.ts

@@ -7,6 +7,7 @@ import { ConfigService } from '../../../config/config.service';
 import { Order } from '../../../entity/order/order.entity';
 import { EventBus } from '../../../event-bus/event-bus';
 import { OrderStateTransitionEvent } from '../../../event-bus/events/order-state-transition-event';
+import { StockMovementService } from '../../services/stock-movement.service';
 
 import { OrderState, orderStateTransitions, OrderTransitionData } from './order-state';
 
@@ -15,7 +16,9 @@ export class OrderStateMachine {
     private readonly config: StateMachineConfig<OrderState, OrderTransitionData>;
     private readonly initialState: OrderState = 'AddingItems';
 
-    constructor(private configService: ConfigService, private eventBus: EventBus) {
+    constructor(private configService: ConfigService,
+                private stockMovementService: StockMovementService,
+                private eventBus: EventBus) {
         this.config = this.initConfig();
     }
 
@@ -51,10 +54,11 @@ export class OrderStateMachine {
     /**
      * Specific business logic to be executed after Order state transition completes.
      */
-    private onTransitionEnd(fromState: OrderState, toState: OrderState, data: OrderTransitionData) {
+    private async onTransitionEnd(fromState: OrderState, toState: OrderState, data: OrderTransitionData) {
         if (toState === 'PaymentAuthorized' || toState === 'PaymentSettled') {
             data.order.active = false;
             data.order.orderPlacedAt = new Date();
+            await this.stockMovementService.createSalesForOrder(data.order);
         }
         this.eventBus.publish(new OrderStateTransitionEvent(fromState, toState, data.ctx, data.order));
     }

+ 25 - 0
packages/core/src/service/services/stock-movement.service.ts

@@ -5,10 +5,13 @@ import { Connection } from 'typeorm';
 
 import { ID, PaginatedList } from '../../../../common/lib/shared-types';
 import { RequestContext } from '../../api/common/request-context';
+import { InternalServerError } from '../../common/error/errors';
 import { ShippingCalculator } from '../../config/shipping-method/shipping-calculator';
 import { ShippingEligibilityChecker } from '../../config/shipping-method/shipping-eligibility-checker';
+import { Order } from '../../entity/order/order.entity';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { ShippingMethod } from '../../entity/shipping-method/shipping-method.entity';
+import { Sale } from '../../entity/stock-movement/sale.entity';
 import { StockAdjustment } from '../../entity/stock-movement/stock-adjustment.entity';
 import { StockMovement } from '../../entity/stock-movement/stock-movement.entity';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
@@ -54,4 +57,26 @@ export class StockMovementService {
         });
         return this.connection.getRepository(StockAdjustment).save(adjustment);
     }
+
+    async createSalesForOrder(order: Order): Promise<Sale[]> {
+        if (order.active !== false) {
+            throw new InternalServerError('error.cannot-create-sales-for-active-order');
+        }
+        const sales: Sale[] = [];
+        for (const line of order.lines) {
+            const { productVariant } = line;
+            const sale = new Sale({
+                productVariant,
+                quantity: line.quantity * -1,
+                orderLine: line,
+            });
+            sales.push(sale);
+
+            if (productVariant.trackInventory === true) {
+                productVariant.stockOnHand -= line.quantity;
+                await this.connection.getRepository(ProductVariant).save(productVariant);
+            }
+        }
+        return this.connection.getRepository(Sale).save(sales);
+    }
 }