Kaynağa Gözat

feat(core): Make Orders ChannelAware

* feat(core): Make Order entity ChannelAware
* test(core): Add e2e tests for ChannelAware Orders
* fix(core): Fix orders not being added to default channel

Co-authored-by: Hendrik Depauw <hendrik@advantitge.com>

Closes #440 

BREAKING CHANGE: Orders are now channel-aware which requires a non-destructive DB migration to apply the schema changes required for this relation.
Hendrik Depauw 5 yıl önce
ebeveyn
işleme
9bb5750f0f

+ 193 - 0
packages/core/e2e/order-channel.e2e-spec.ts

@@ -0,0 +1,193 @@
+/* tslint:disable:no-non-null-assertion */
+import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import {
+    AssignProductsToChannel,
+    CreateChannel,
+    CurrencyCode,
+    GetCustomerList,
+    GetOrder,
+    GetOrderList,
+    GetProductWithVariants,
+    LanguageCode,
+} from './graphql/generated-e2e-admin-types';
+import { AddItemToOrder, GetActiveOrder } from './graphql/generated-e2e-shop-types';
+import {
+    ASSIGN_PRODUCT_TO_CHANNEL,
+    CREATE_CHANNEL,
+    GET_CUSTOMER_LIST,
+    GET_ORDER,
+    GET_PRODUCT_WITH_VARIANTS,
+} from './graphql/shared-definitions';
+import { ADD_ITEM_TO_ORDER, GET_ACTIVE_ORDER } from './graphql/shop-definitions';
+import { GET_ORDERS_LIST } from './order.e2e-spec';
+
+describe('Channelaware orders', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
+    const SECOND_CHANNEL_TOKEN = 'second_channel_token';
+    const THIRD_CHANNEL_TOKEN = 'third_channel_token';
+    let customerUser: GetCustomerList.Items;
+    let product1: GetProductWithVariants.Product;
+    let product2: GetProductWithVariants.Product;
+    let order1Id: string;
+    let order2Id: string;
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+
+        const { customers } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+            GET_CUSTOMER_LIST,
+            {
+                options: { take: 1 },
+            },
+        );
+        customerUser = customers.items[0];
+        await shopClient.asUserWithCredentials(customerUser.emailAddress, 'test');
+
+        await adminClient.query<CreateChannel.Mutation, CreateChannel.Variables>(CREATE_CHANNEL, {
+            input: {
+                code: 'second-channel',
+                token: SECOND_CHANNEL_TOKEN,
+                defaultLanguageCode: LanguageCode.en,
+                currencyCode: CurrencyCode.GBP,
+                pricesIncludeTax: true,
+                defaultShippingZoneId: 'T_1',
+                defaultTaxZoneId: 'T_1',
+            },
+        });
+
+        await adminClient.query<CreateChannel.Mutation, CreateChannel.Variables>(CREATE_CHANNEL, {
+            input: {
+                code: 'third-channel',
+                token: THIRD_CHANNEL_TOKEN,
+                defaultLanguageCode: LanguageCode.en,
+                currencyCode: CurrencyCode.GBP,
+                pricesIncludeTax: true,
+                defaultShippingZoneId: 'T_1',
+                defaultTaxZoneId: 'T_1',
+            },
+        });
+
+        product1 = (
+            await adminClient.query<GetProductWithVariants.Query, GetProductWithVariants.Variables>(
+                GET_PRODUCT_WITH_VARIANTS,
+                {
+                    id: 'T_1',
+                },
+            )
+        ).product!;
+
+        await adminClient.query<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
+            ASSIGN_PRODUCT_TO_CHANNEL,
+            {
+                input: {
+                    channelId: 'T_2',
+                    productIds: [product1.id],
+                },
+            },
+        );
+
+        product2 = (
+            await adminClient.query<GetProductWithVariants.Query, GetProductWithVariants.Variables>(
+                GET_PRODUCT_WITH_VARIANTS,
+                {
+                    id: 'T_2',
+                },
+            )
+        ).product!;
+
+        await adminClient.query<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
+            ASSIGN_PRODUCT_TO_CHANNEL,
+            {
+                input: {
+                    channelId: 'T_3',
+                    productIds: [product2.id],
+                },
+            },
+        );
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('creates order on current channel', async () => {
+        shopClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+        const { addItemToOrder } = await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(
+            ADD_ITEM_TO_ORDER,
+            {
+                productVariantId: product1.variants[0].id,
+                quantity: 1,
+            },
+        );
+
+        expect(addItemToOrder!.lines.length).toBe(1);
+        expect(addItemToOrder!.lines[0].quantity).toBe(1);
+        expect(addItemToOrder!.lines[0].productVariant.id).toBe(product1.variants[0].id);
+
+        order1Id = addItemToOrder!.id;
+    });
+
+    it('sets active order to null when switching channel', async () => {
+        shopClient.setChannelToken(THIRD_CHANNEL_TOKEN);
+        const result = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
+        expect(result.activeOrder).toBeNull();
+    });
+
+    it('creates new order on current channel when already active order on other channel', async () => {
+        const { addItemToOrder } = await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(
+            ADD_ITEM_TO_ORDER,
+            {
+                productVariantId: product2.variants[0].id,
+                quantity: 1,
+            },
+        );
+
+        expect(addItemToOrder!.lines.length).toBe(1);
+        expect(addItemToOrder!.lines[0].quantity).toBe(1);
+        expect(addItemToOrder!.lines[0].productVariant.id).toBe(product2.variants[0].id);
+
+        order2Id = addItemToOrder!.id;
+    });
+
+    it('goes back to most recent active order when switching channel', async () => {
+        shopClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+        const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
+        expect(activeOrder!.id).toBe(order1Id);
+    });
+
+    it('returns null when requesting order from other channel', async () => {
+        const result = await shopClient.query<GetOrder.Query>(GET_ORDER, {
+            id: order2Id,
+        });
+        expect(result!.order).toBeNull();
+    });
+
+    it('returns order when requesting order from correct channel', async () => {
+        const result = await shopClient.query<GetOrder.Query>(GET_ORDER, {
+            id: order1Id,
+        });
+        expect(result.order!.id).toBe(order1Id);
+    });
+
+    it('returns all orders on default channel', async () => {
+        adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+        const result = await adminClient.query<GetOrderList.Query>(GET_ORDERS_LIST);
+        expect(result.orders.items.map((o) => o.id)).toEqual([order1Id, order2Id]);
+    });
+
+    it('returns only channel specific orders when on other than default channel', async () => {
+        adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+        const result = await adminClient.query<GetOrderList.Query>(GET_ORDERS_LIST);
+        expect(result.orders.items.map((o) => o.id)).toEqual([order1Id]);
+    });
+});

+ 2 - 2
packages/core/src/api/resolvers/entity/order-entity.resolver.ts

@@ -54,11 +54,11 @@ export class OrderEntityResolver {
     }
 
     @ResolveField()
-    async promotions(@Parent() order: Order) {
+    async promotions(@Ctx() ctx: RequestContext, @Parent() order: Order) {
         if (order.promotions) {
             return order.promotions;
         }
-        return this.orderService.getOrderPromotions(order.id);
+        return this.orderService.getOrderPromotions(ctx, order.id);
     }
 }
 

+ 7 - 1
packages/core/src/entity/order/order.entity.ts

@@ -14,6 +14,8 @@ import { OrderLine } from '../order-line/order-line.entity';
 import { Payment } from '../payment/payment.entity';
 import { Promotion } from '../promotion/promotion.entity';
 import { ShippingMethod } from '../shipping-method/shipping-method.entity';
+import { ChannelAware } from '../../common/types/common-types';
+import { Channel } from '../channel/channel.entity';
 
 /**
  * @description
@@ -27,7 +29,7 @@ import { ShippingMethod } from '../shipping-method/shipping-method.entity';
  * @docsCategory entities
  */
 @Entity()
-export class Order extends VendureEntity implements HasCustomFields {
+export class Order extends VendureEntity implements ChannelAware, HasCustomFields {
     constructor(input?: DeepPartial<Order>) {
         super(input);
     }
@@ -94,6 +96,10 @@ export class Order extends VendureEntity implements HasCustomFields {
     @EntityId({ nullable: true })
     taxZoneId?: ID;
 
+    @ManyToMany((type) => Channel)
+    @JoinTable()
+    channels: Channel[];
+
     @Calculated()
     get totalBeforeTax(): number {
         return this.subTotalBeforeTax + this.promotionAdjustmentsTotal + (this.shipping || 0);

+ 43 - 14
packages/core/src/service/services/order.service.ts

@@ -54,6 +54,7 @@ import { OrderStateMachine } from '../helpers/order-state-machine/order-state-ma
 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 { findOneInChannel } from '../helpers/utils/channel-aware-orm-utils';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import {
     orderItemsAreAllCancelled,
@@ -63,6 +64,7 @@ import {
 import { patchEntity } from '../helpers/utils/patch-entity';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
+import { ChannelService } from './channel.service';
 import { CountryService } from './country.service';
 import { CustomerService } from './customer.service';
 import { HistoryService } from './history.service';
@@ -90,6 +92,7 @@ export class OrderService {
         private historyService: HistoryService,
         private promotionService: PromotionService,
         private eventBus: EventBus,
+        private channelService: ChannelService,
     ) {}
 
     getOrderProcessStates(): OrderProcessState[] {
@@ -101,7 +104,10 @@ export class OrderService {
 
     findAll(ctx: RequestContext, options?: ListQueryOptions<Order>): Promise<PaginatedList<Order>> {
         return this.listQueryBuilder
-            .build(Order, options, { relations: ['lines', 'customer', 'lines.productVariant'] })
+            .build(Order, options, {
+                relations: ['lines', 'customer', 'lines.productVariant', 'channels'],
+                channelId: ctx.channelId,
+            })
             .getManyAndCount()
             .then(([items, totalItems]) => {
                 return {
@@ -115,7 +121,9 @@ export class OrderService {
         const order = await this.connection
             .getRepository(Order)
             .createQueryBuilder('order')
+            .leftJoin('order.channels', 'channel')
             .leftJoinAndSelect('order.customer', 'customer')
+            .leftJoinAndSelect('customer.user', 'user') // Used in de 'Order' query, guess this didn't work before?
             .leftJoinAndSelect('order.lines', 'lines')
             .leftJoinAndSelect('lines.productVariant', 'productVariant')
             .leftJoinAndSelect('productVariant.taxCategory', 'prodVariantTaxCategory')
@@ -126,6 +134,7 @@ export class OrderService {
             .leftJoinAndSelect('items.fulfillment', 'fulfillment')
             .leftJoinAndSelect('lines.taxCategory', 'lineTaxCategory')
             .where('order.id = :orderId', { orderId })
+            .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
             .addOrderBy('lines.createdAt', 'ASC')
             .addOrderBy('items.createdAt', 'ASC')
             .getOne();
@@ -147,7 +156,7 @@ export class OrderService {
                 code: orderCode,
             },
         });
-        return order;
+        return order ? this.findOne(ctx, order.id) : undefined;
     }
 
     async findByCustomerId(
@@ -163,7 +172,9 @@ export class OrderService {
                     'lines.productVariant',
                     'lines.productVariant.options',
                     'customer',
+                    'channels',
                 ],
+                channelId: ctx.channelId,
             })
             .andWhere('order.customer.id = :customerId', { customerId })
             .getManyAndCount()
@@ -208,12 +219,16 @@ export class OrderService {
     async getActiveOrderForUser(ctx: RequestContext, userId: ID): Promise<Order | undefined> {
         const customer = await this.customerService.findOneByUserId(userId);
         if (customer) {
-            const activeOrder = await this.connection.getRepository(Order).findOne({
-                where: {
-                    customer,
-                    active: true,
-                },
-            });
+            const activeOrder = await this.connection
+                .createQueryBuilder(Order, 'order')
+                .innerJoinAndSelect('order.channels', 'channel', 'channel.id = :channelId', {
+                    channelId: ctx.channelId,
+                })
+                .leftJoinAndSelect('order.customer', 'customer')
+                .where('active = :active', { active: true })
+                .andWhere('order.customer.id = :customerId', { customerId: customer.id })
+                .orderBy('order.createdAt', 'DESC')
+                .getOne();
             if (activeOrder) {
                 return this.findOne(ctx, activeOrder.id);
             }
@@ -239,6 +254,7 @@ export class OrderService {
                 newOrder.customer = customer;
             }
         }
+        this.channelService.assignToCurrentChannel(newOrder, ctx);
         return this.connection.getRepository(Order).save(newOrder);
     }
 
@@ -381,8 +397,8 @@ export class OrderService {
         }
     }
 
-    async getOrderPromotions(orderId: ID): Promise<Promotion[]> {
-        const order = await getEntityOrThrow(this.connection, Order, orderId, {
+    async getOrderPromotions(ctx: RequestContext, orderId: ID): Promise<Promotion[]> {
+        const order = await getEntityOrThrow(this.connection, Order, orderId, ctx.channelId, {
             relations: ['promotions'],
         });
         return order.promotions || [];
@@ -499,6 +515,7 @@ export class OrderService {
             throw new UserInputError('error.create-fulfillment-nothing-to-fulfill');
         }
         const { items, orders } = await this.getOrdersAndItemsFromLines(
+            ctx,
             input.lines,
             (i) => !i.fulfillment,
             'error.create-fulfillment-items-already-fulfilled',
@@ -527,9 +544,15 @@ export class OrderService {
                     fulfillmentId: fulfillment.id,
                 },
             });
-            const orderWithFulfillments = await this.connection.getRepository(Order).findOne(order.id, {
-                relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
-            });
+            const orderWithFulfillments = await findOneInChannel(
+                this.connection,
+                Order,
+                order.id,
+                ctx.channelId,
+                {
+                    relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
+                },
+            );
             if (!orderWithFulfillments) {
                 throw new InternalServerError('error.could-not-find-order');
             }
@@ -605,6 +628,7 @@ export class OrderService {
             throw new UserInputError('error.cancel-order-lines-nothing-to-cancel');
         }
         const { items, orders } = await this.getOrdersAndItemsFromLines(
+            ctx,
             lines,
             (i) => !i.cancelled,
             'error.cancel-order-lines-quantity-too-high',
@@ -655,6 +679,7 @@ export class OrderService {
             throw new UserInputError('error.refund-order-lines-nothing-to-refund');
         }
         const { items, orders } = await this.getOrdersAndItemsFromLines(
+            ctx,
             input.lines,
             (i) => !i.cancelled,
             'error.refund-order-lines-quantity-too-high',
@@ -890,6 +915,7 @@ export class OrderService {
     }
 
     private async getOrdersAndItemsFromLines(
+        ctx: RequestContext,
         orderLinesInput: OrderLineInput[],
         itemMatcher: (i: OrderItem) => boolean,
         noMatchesError: string,
@@ -900,7 +926,7 @@ export class OrderService {
         const lines = await this.connection.getRepository(OrderLine).findByIds(
             orderLinesInput.map((l) => l.orderLineId),
             {
-                relations: ['order', 'items', 'items.fulfillment'],
+                relations: ['order', 'items', 'items.fulfillment', 'order.channels'],
                 order: { id: 'ASC' },
             },
         );
@@ -910,6 +936,9 @@ export class OrderService {
                 continue;
             }
             const order = line.order;
+            if (!order.channels.some((channel) => channel.id === ctx.channelId)) {
+                throw new EntityNotFoundError('Order', order.id);
+            }
             if (!orders.has(order.id)) {
                 orders.set(order.id, order);
             }