Przeglądaj źródła

feat(core): Allow multiple Fulfillments per OrderItem

Relates to #565. This change means that a Fulfillment can be cancelled, and then the related
OrderItems can be assigned to a new Fulfillment.

BREAKING CHANGE: A change to the relation between OrderItems and Fulfillments means a database
migration will be required to preserve fulfillment data of existing Orders.
See the release blog post for details.
Michael Bromley 5 lat temu
rodzic
commit
3245e0049a

+ 140 - 49
packages/core/e2e/order.e2e-spec.ts

@@ -39,6 +39,7 @@ import {
     GetStockMovement,
     GlobalFlag,
     HistoryEntryType,
+    OrderLineInput,
     PaymentFragment,
     RefundFragment,
     RefundOrder,
@@ -336,9 +337,14 @@ describe('Orders resolver', () => {
     });
 
     describe('fulfillment', () => {
+        const orderId = 'T_2';
+        let f1Id: string;
+        let f2Id: string;
+        let f3Id: string;
+
         it('return error result if lines is empty', async () => {
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                id: 'T_2',
+                id: orderId,
             });
             expect(order!.state).toBe('PaymentSettled');
             const { addFulfillmentToOrder } = await adminClient.query<
@@ -358,7 +364,7 @@ describe('Orders resolver', () => {
 
         it('returns error result if all quantities are zero', async () => {
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                id: 'T_2',
+                id: orderId,
             });
             expect(order!.state).toBe('PaymentSettled');
             const { addFulfillmentToOrder } = await adminClient.query<
@@ -378,7 +384,7 @@ describe('Orders resolver', () => {
 
         it('creates the first fulfillment', async () => {
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                id: 'T_2',
+                id: orderId,
             });
             expect(order!.state).toBe('PaymentSettled');
             const lines = order!.lines;
@@ -400,9 +406,10 @@ describe('Orders resolver', () => {
             expect(addFulfillmentToOrder.trackingCode).toBe('111');
             expect(addFulfillmentToOrder.state).toBe('Pending');
             expect(addFulfillmentToOrder.orderItems).toEqual([{ id: lines[0].items[0].id }]);
+            f1Id = addFulfillmentToOrder.id;
 
             const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                id: 'T_2',
+                id: orderId,
             });
 
             expect(result.order!.lines[0].items[0].fulfillment!.id).toBe(addFulfillmentToOrder!.id);
@@ -415,25 +422,14 @@ describe('Orders resolver', () => {
         });
 
         it('creates the second fulfillment', async () => {
-            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                id: 'T_2',
-            });
-
-            const unfulfilledItems =
-                order?.lines.filter(l => {
-                    const items = l.items.filter(i => i.fulfillment === null);
-                    return items.length > 0 ? true : false;
-                }) || [];
+            const lines = await getUnfulfilledOrderLineInput(adminClient, orderId);
 
             const { addFulfillmentToOrder } = await adminClient.query<
                 CreateFulfillment.Mutation,
                 CreateFulfillment.Variables
             >(CREATE_FULFILLMENT, {
                 input: {
-                    lines: unfulfilledItems.map(l => ({
-                        orderLineId: l.id,
-                        quantity: l.items.length,
-                    })),
+                    lines,
                     method: 'Test2',
                     trackingCode: '222',
                 },
@@ -444,11 +440,61 @@ describe('Orders resolver', () => {
             expect(addFulfillmentToOrder.method).toBe('Test2');
             expect(addFulfillmentToOrder.trackingCode).toBe('222');
             expect(addFulfillmentToOrder.state).toBe('Pending');
+            f2Id = addFulfillmentToOrder.id;
+        });
+
+        it('cancels second fulfillment', async () => {
+            const { transitionFulfillmentToState } = await adminClient.query<
+                TransitFulfillment.Mutation,
+                TransitFulfillment.Variables
+            >(TRANSIT_FULFILLMENT, {
+                id: f2Id,
+                state: 'Cancelled',
+            });
+            fulfillmentGuard.assertSuccess(transitionFulfillmentToState);
+
+            expect(transitionFulfillmentToState.id).toBe('T_2');
+            expect(transitionFulfillmentToState.state).toBe('Cancelled');
+        });
+
+        it('order.fulfillments still lists second (cancelled) fulfillment', async () => {
+            const { order } = await adminClient.query<
+                GetOrderFulfillments.Query,
+                GetOrderFulfillments.Variables
+            >(GET_ORDER_FULFILLMENTS, {
+                id: orderId,
+            });
+
+            expect(order?.fulfillments?.map(pick(['id', 'state']))).toEqual([
+                { id: f1Id, state: 'Pending' },
+                { id: f2Id, state: 'Cancelled' },
+            ]);
+        });
+
+        it('creates third fulfillment with same items from second fulfillment', async () => {
+            const lines = await getUnfulfilledOrderLineInput(adminClient, orderId);
+            const { addFulfillmentToOrder } = await adminClient.query<
+                CreateFulfillment.Mutation,
+                CreateFulfillment.Variables
+            >(CREATE_FULFILLMENT, {
+                input: {
+                    lines,
+                    method: 'Test3',
+                    trackingCode: '333',
+                },
+            });
+            fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
+
+            expect(addFulfillmentToOrder.id).toBe('T_3');
+            expect(addFulfillmentToOrder.method).toBe('Test3');
+            expect(addFulfillmentToOrder.trackingCode).toBe('333');
+            expect(addFulfillmentToOrder.state).toBe('Pending');
+            f3Id = addFulfillmentToOrder.id;
         });
 
         it('returns error result if an OrderItem already part of a Fulfillment', async () => {
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                id: 'T_2',
+                id: orderId,
             });
             const { addFulfillmentToOrder } = await adminClient.query<
                 CreateFulfillment.Mutation,
@@ -472,78 +518,78 @@ describe('Orders resolver', () => {
             expect(addFulfillmentToOrder.errorCode).toBe(ErrorCode.ITEMS_ALREADY_FULFILLED_ERROR);
         });
 
-        it('transits the first fulfillment from created to Shipped and automatically change the order state to PartiallyShipped', async () => {
+        it('transitions the first fulfillment from created to Shipped and automatically change the order state to PartiallyShipped', async () => {
             const { transitionFulfillmentToState } = await adminClient.query<
                 TransitFulfillment.Mutation,
                 TransitFulfillment.Variables
             >(TRANSIT_FULFILLMENT, {
-                id: 'T_1',
+                id: f1Id,
                 state: 'Shipped',
             });
             fulfillmentGuard.assertSuccess(transitionFulfillmentToState);
 
-            expect(transitionFulfillmentToState.id).toBe('T_1');
+            expect(transitionFulfillmentToState.id).toBe(f1Id);
             expect(transitionFulfillmentToState.state).toBe('Shipped');
 
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                id: 'T_2',
+                id: orderId,
             });
             expect(order?.state).toBe('PartiallyShipped');
         });
 
-        it('transits the second fulfillment from created to Shipped and automatically change the order state to Shipped', async () => {
+        it('transitions the third fulfillment from created to Shipped and automatically change the order state to Shipped', async () => {
             const { transitionFulfillmentToState } = await adminClient.query<
                 TransitFulfillment.Mutation,
                 TransitFulfillment.Variables
             >(TRANSIT_FULFILLMENT, {
-                id: 'T_2',
+                id: f3Id,
                 state: 'Shipped',
             });
             fulfillmentGuard.assertSuccess(transitionFulfillmentToState);
 
-            expect(transitionFulfillmentToState.id).toBe('T_2');
+            expect(transitionFulfillmentToState.id).toBe(f3Id);
             expect(transitionFulfillmentToState.state).toBe('Shipped');
 
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                id: 'T_2',
+                id: orderId,
             });
             expect(order?.state).toBe('Shipped');
         });
 
-        it('transits the first fulfillment from Shipped to Delivered and change the order state to PartiallyDelivered', async () => {
+        it('transitions the first fulfillment from Shipped to Delivered and change the order state to PartiallyDelivered', async () => {
             const { transitionFulfillmentToState } = await adminClient.query<
                 TransitFulfillment.Mutation,
                 TransitFulfillment.Variables
             >(TRANSIT_FULFILLMENT, {
-                id: 'T_1',
+                id: f1Id,
                 state: 'Delivered',
             });
             fulfillmentGuard.assertSuccess(transitionFulfillmentToState);
 
-            expect(transitionFulfillmentToState.id).toBe('T_1');
+            expect(transitionFulfillmentToState.id).toBe(f1Id);
             expect(transitionFulfillmentToState.state).toBe('Delivered');
 
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                id: 'T_2',
+                id: orderId,
             });
             expect(order?.state).toBe('PartiallyDelivered');
         });
 
-        it('transits the second fulfillment from Shipped to Delivered and change the order state to Delivered', async () => {
+        it('transitions the third fulfillment from Shipped to Delivered and change the order state to Delivered', async () => {
             const { transitionFulfillmentToState } = await adminClient.query<
                 TransitFulfillment.Mutation,
                 TransitFulfillment.Variables
             >(TRANSIT_FULFILLMENT, {
-                id: 'T_2',
+                id: f3Id,
                 state: 'Delivered',
             });
             fulfillmentGuard.assertSuccess(transitionFulfillmentToState);
 
-            expect(transitionFulfillmentToState.id).toBe('T_2');
+            expect(transitionFulfillmentToState.id).toBe(f3Id);
             expect(transitionFulfillmentToState.state).toBe('Delivered');
 
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                id: 'T_2',
+                id: orderId,
             });
             expect(order?.state).toBe('Delivered');
         });
@@ -552,7 +598,7 @@ describe('Orders resolver', () => {
             const { order } = await adminClient.query<GetOrderHistory.Query, GetOrderHistory.Variables>(
                 GET_ORDER_HISTORY,
                 {
-                    id: 'T_2',
+                    id: orderId,
                     options: {
                         skip: 6,
                     },
@@ -561,28 +607,28 @@ describe('Orders resolver', () => {
             expect(order!.history.items.map(pick(['type', 'data']))).toEqual([
                 {
                     data: {
-                        fulfillmentId: 'T_1',
+                        fulfillmentId: f1Id,
                     },
                     type: HistoryEntryType.ORDER_FULFILLMENT,
                 },
                 {
                     data: {
                         from: 'Created',
-                        fulfillmentId: 'T_1',
+                        fulfillmentId: f1Id,
                         to: 'Pending',
                     },
                     type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
                 },
                 {
                     data: {
-                        fulfillmentId: 'T_2',
+                        fulfillmentId: f2Id,
                     },
                     type: HistoryEntryType.ORDER_FULFILLMENT,
                 },
                 {
                     data: {
                         from: 'Created',
-                        fulfillmentId: 'T_2',
+                        fulfillmentId: f2Id,
                         to: 'Pending',
                     },
                     type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
@@ -590,7 +636,29 @@ describe('Orders resolver', () => {
                 {
                     data: {
                         from: 'Pending',
-                        fulfillmentId: 'T_1',
+                        fulfillmentId: f2Id,
+                        to: 'Cancelled',
+                    },
+                    type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
+                },
+                {
+                    data: {
+                        fulfillmentId: f3Id,
+                    },
+                    type: HistoryEntryType.ORDER_FULFILLMENT,
+                },
+                {
+                    data: {
+                        from: 'Created',
+                        fulfillmentId: f3Id,
+                        to: 'Pending',
+                    },
+                    type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
+                },
+                {
+                    data: {
+                        from: 'Pending',
+                        fulfillmentId: f1Id,
                         to: 'Shipped',
                     },
                     type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
@@ -605,7 +673,7 @@ describe('Orders resolver', () => {
                 {
                     data: {
                         from: 'Pending',
-                        fulfillmentId: 'T_2',
+                        fulfillmentId: f3Id,
                         to: 'Shipped',
                     },
                     type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
@@ -620,7 +688,7 @@ describe('Orders resolver', () => {
                 {
                     data: {
                         from: 'Shipped',
-                        fulfillmentId: 'T_1',
+                        fulfillmentId: f1Id,
                         to: 'Delivered',
                     },
                     type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
@@ -635,7 +703,7 @@ describe('Orders resolver', () => {
                 {
                     data: {
                         from: 'Shipped',
-                        fulfillmentId: 'T_2',
+                        fulfillmentId: f3Id,
                         to: 'Delivered',
                     },
                     type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
@@ -655,12 +723,13 @@ describe('Orders resolver', () => {
                 GetOrderFulfillments.Query,
                 GetOrderFulfillments.Variables
             >(GET_ORDER_FULFILLMENTS, {
-                id: 'T_2',
+                id: orderId,
             });
 
             expect(order!.fulfillments).toEqual([
-                { id: 'T_1', method: 'Test1', state: 'Delivered', nextStates: ['Cancelled'] },
-                { id: 'T_2', method: 'Test2', state: 'Delivered', nextStates: ['Cancelled'] },
+                { id: f1Id, method: 'Test1', state: 'Delivered', nextStates: ['Cancelled'] },
+                { id: f2Id, method: 'Test2', state: 'Cancelled', nextStates: [] },
+                { id: f3Id, method: 'Test3', state: 'Delivered', nextStates: ['Cancelled'] },
             ]);
         });
 
@@ -671,8 +740,9 @@ describe('Orders resolver', () => {
 
             expect(orders.items[0].fulfillments).toEqual([]);
             expect(orders.items[1].fulfillments).toEqual([
-                { id: 'T_1', method: 'Test1', state: 'Delivered', nextStates: ['Cancelled'] },
-                { id: 'T_2', method: 'Test2', state: 'Delivered', nextStates: ['Cancelled'] },
+                { id: f1Id, method: 'Test1', state: 'Delivered', nextStates: ['Cancelled'] },
+                { id: f2Id, method: 'Test2', state: 'Cancelled', nextStates: [] },
+                { id: f3Id, method: 'Test3', state: 'Delivered', nextStates: ['Cancelled'] },
             ]);
         });
 
@@ -681,10 +751,11 @@ describe('Orders resolver', () => {
                 GetOrderFulfillmentItems.Query,
                 GetOrderFulfillmentItems.Variables
             >(GET_ORDER_FULFILLMENT_ITEMS, {
-                id: 'T_2',
+                id: orderId,
             });
             expect(order!.fulfillments![0].orderItems).toEqual([{ id: 'T_3' }]);
             expect(order!.fulfillments![1].orderItems).toEqual([{ id: 'T_4' }, { id: 'T_5' }, { id: 'T_6' }]);
+            expect(order!.fulfillments![2].orderItems).toEqual([{ id: 'T_4' }, { id: 'T_5' }, { id: 'T_6' }]);
         });
     });
 
@@ -1563,6 +1634,26 @@ async function createTestOrder(
     return { product, productVariantId, orderId };
 }
 
+async function getUnfulfilledOrderLineInput(
+    client: SimpleGraphQLClient,
+    id: string,
+): Promise<OrderLineInput[]> {
+    const { order } = await client.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+        id,
+    });
+
+    const unfulfilledItems =
+        order?.lines.filter(l => {
+            const items = l.items.filter(i => i.fulfillment === null);
+            return items.length > 0 ? true : false;
+        }) || [];
+
+    return unfulfilledItems.map(l => ({
+        orderLineId: l.id,
+        quantity: l.items.length,
+    }));
+}
+
 export const GET_ORDER_LIST_FULFILLMENTS = gql`
     query GetOrderListFulfillments {
         orders {

+ 2 - 2
packages/core/src/entity/fulfillment/fulfillment.entity.ts

@@ -1,4 +1,4 @@
-import { Column, Entity, OneToMany } from 'typeorm';
+import { Column, Entity, ManyToMany, OneToMany } from 'typeorm';
 
 import { DeepPartial } from '../../../../common/lib/shared-types';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
@@ -28,7 +28,7 @@ export class Fulfillment extends VendureEntity implements HasCustomFields {
     @Column()
     method: string;
 
-    @OneToMany(type => OrderItem, orderItem => orderItem.fulfillment)
+    @ManyToMany(type => OrderItem, orderItem => orderItem.fulfillments)
     orderItems: OrderItem[];
 
     @Column(type => CustomFulfillmentFields)

+ 8 - 6
packages/core/src/entity/order-item/order-item.entity.ts

@@ -1,6 +1,6 @@
 import { Adjustment, AdjustmentType } from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
-import { Column, Entity, ManyToOne, OneToOne } from 'typeorm';
+import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToOne } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
 import { VendureEntity } from '../base/base.entity';
@@ -39,11 +39,9 @@ export class OrderItem extends VendureEntity {
 
     @Column('simple-json') pendingAdjustments: Adjustment[];
 
-    @ManyToOne(type => Fulfillment)
-    fulfillment: Fulfillment;
-
-    @EntityId({ nullable: true })
-    fulfillmentId: ID | null;
+    @ManyToMany(type => Fulfillment, fulfillment => fulfillment.orderItems)
+    @JoinTable()
+    fulfillments: Fulfillment[];
 
     @ManyToOne(type => Refund)
     refund: Refund;
@@ -57,6 +55,10 @@ export class OrderItem extends VendureEntity {
     @Column({ default: false })
     cancelled: boolean;
 
+    get fulfillment(): Fulfillment | undefined {
+        return this.fulfillments?.find(f => f.state !== 'Cancelled');
+    }
+
     @Calculated()
     get unitPriceWithTax(): number {
         return Math.round(this.unitPrice * ((100 + this.taxRate) / 100));

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

@@ -62,7 +62,7 @@ export class OrderStateMachine {
 
     private async findOrderWithFulfillments(ctx: RequestContext, id: ID): Promise<Order> {
         return await this.connection.getEntityOrThrow(ctx, Order, id, {
-            relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
+            relations: ['lines', 'lines.items', 'lines.items.fulfillments'],
         });
     }
 

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

@@ -60,8 +60,8 @@ function getNonCancelledItems(order: Order): OrderItem[] {
 }
 
 function isDelivered(orderItem: OrderItem) {
-    return orderItem.fulfillment && orderItem.fulfillment.state === 'Delivered';
+    return orderItem.fulfillment?.state === 'Delivered';
 }
 function isShipped(orderItem: OrderItem) {
-    return orderItem.fulfillment && orderItem.fulfillment.state === 'Shipped';
+    return orderItem.fulfillment?.state === 'Shipped';
 }

+ 7 - 11
packages/core/src/service/services/order.service.ts

@@ -167,7 +167,7 @@ export class OrderService {
             .leftJoinAndSelect('productVariant.translations', 'translations')
             .leftJoinAndSelect('lines.featuredAsset', 'featuredAsset')
             .leftJoinAndSelect('lines.items', 'items')
-            .leftJoinAndSelect('items.fulfillment', 'fulfillment')
+            .leftJoinAndSelect('items.fulfillments', 'fulfillments')
             .leftJoinAndSelect('lines.taxCategory', 'lineTaxCategory')
             .where('order.id = :orderId', { orderId })
             .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
@@ -781,23 +781,19 @@ export class OrderService {
 
     async getOrderFulfillments(ctx: RequestContext, order: Order): Promise<Fulfillment[]> {
         let lines: OrderLine[];
-        if (
-            order.lines &&
-            order.lines[0] &&
-            order.lines[0].items &&
-            order.lines[0].items[0].fulfillment !== undefined
-        ) {
+        if (order.lines?.[0].items?.[0]?.fulfillments !== undefined) {
             lines = order.lines;
         } else {
             lines = await this.connection.getRepository(ctx, OrderLine).find({
                 where: {
                     order: order.id,
                 },
-                relations: ['items', 'items.fulfillment'],
+                relations: ['items', 'items.fulfillments'],
             });
         }
         const items = lines.reduce((acc, l) => [...acc, ...l.items], [] as OrderItem[]);
-        return unique(items.map(i => i.fulfillment).filter(notNullOrUndefined), 'id');
+        const fulfillments = items.reduce((acc, i) => [...acc, ...i.fulfillments], [] as Fulfillment[]);
+        return unique(fulfillments, 'id');
     }
 
     async cancelOrder(
@@ -1140,7 +1136,7 @@ export class OrderService {
 
     private async getOrderWithFulfillments(ctx: RequestContext, orderId: ID): Promise<Order> {
         return await this.connection.getEntityOrThrow(ctx, Order, orderId, {
-            relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
+            relations: ['lines', 'lines.items', 'lines.items.fulfillments'],
         });
     }
 
@@ -1167,7 +1163,7 @@ export class OrderService {
         const lines = await this.connection.getRepository(ctx, OrderLine).findByIds(
             orderLinesInput.map(l => l.orderLineId),
             {
-                relations: ['order', 'items', 'items.fulfillment', 'order.channels'],
+                relations: ['order', 'items', 'items.fulfillments', 'order.channels'],
                 order: { id: 'ASC' },
             },
         );