Преглед на файлове

fix(core): Re-allocate stock when cancelling a Fulfillment

Closes #1250
Michael Bromley преди 4 години
родител
ревизия
693fd839af

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

@@ -473,6 +473,7 @@ describe('Stock control', () => {
 
             const trackedVariant2 = await getTrackedVariant();
             expect(trackedVariant2.stockOnHand).toBe(5);
+            expect(trackedVariant2.stockAllocated).toBe(1);
 
             const linesInput =
                 order?.lines
@@ -498,6 +499,7 @@ describe('Stock control', () => {
             const trackedVariant3 = await getTrackedVariant();
 
             expect(trackedVariant3.stockOnHand).toBe(4);
+            expect(trackedVariant3.stockAllocated).toBe(0);
 
             const { transitionFulfillmentToState } = await adminClient.query<
                 TransitionFulfillmentToState.Mutation,
@@ -510,6 +512,7 @@ describe('Stock control', () => {
             const trackedVariant4 = await getTrackedVariant();
 
             expect(trackedVariant4.stockOnHand).toBe(5);
+            expect(trackedVariant4.stockAllocated).toBe(1);
             expect(trackedVariant4.stockMovements.items).toEqual([
                 { id: 'T_4', quantity: 5, type: 'ADJUSTMENT' },
                 { id: 'T_7', quantity: 3, type: 'ALLOCATION' },
@@ -519,9 +522,25 @@ describe('Stock control', () => {
                 { id: 'T_16', quantity: 1, type: 'CANCELLATION' },
                 { id: 'T_21', quantity: 1, type: 'ALLOCATION' },
                 { id: 'T_22', quantity: -1, type: 'SALE' },
-                // This is the cancellation we are testing for
+                // This is the cancellation & allocation we are testing for
                 { id: 'T_23', quantity: 1, type: 'CANCELLATION' },
+                { id: 'T_24', quantity: 1, type: 'ALLOCATION' },
             ]);
+
+            const { cancelOrder } = await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(
+                CANCEL_ORDER,
+                {
+                    input: {
+                        orderId: order!.id,
+                        reason: 'Not needed',
+                    },
+                },
+            );
+            orderGuard.assertSuccess(cancelOrder);
+
+            const trackedVariant5 = await getTrackedVariant();
+            expect(trackedVariant5.stockOnHand).toBe(5);
+            expect(trackedVariant5.stockAllocated).toBe(0);
         });
     });
 

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

@@ -12,7 +12,6 @@ import { ConfigService } from '../../../config/config.service';
 import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
 import { Order } from '../../../entity/order/order.entity';
 import { HistoryService } from '../../services/history.service';
-import { StockMovementService } from '../../services/stock-movement.service';
 
 import {
     FulfillmentState,
@@ -25,11 +24,7 @@ export class FulfillmentStateMachine {
     readonly config: StateMachineConfig<FulfillmentState, FulfillmentTransitionData>;
     private readonly initialState: FulfillmentState = 'Created';
 
-    constructor(
-        private configService: ConfigService,
-        private historyService: HistoryService,
-        private stockMovementService: StockMovementService,
-    ) {
+    constructor(private configService: ConfigService, private historyService: HistoryService) {
         this.config = this.initConfig();
     }
 
@@ -98,10 +93,6 @@ export class FulfillmentStateMachine {
             }),
         );
         await Promise.all(historyEntryPromises);
-        if (toState === 'Cancelled') {
-            const { ctx, orders, fulfillment } = data;
-            await this.stockMovementService.createCancellationsForOrderItems(ctx, fulfillment.orderItems);
-        }
     }
 
     private initConfig(): StateMachineConfig<FulfillmentState, FulfillmentTransitionData> {

+ 30 - 0
packages/core/src/service/services/order.service.ts

@@ -836,6 +836,11 @@ export class OrderService {
             return result;
         }
         const { fulfillment, fromState, toState, orders } = result;
+        if (toState === 'Cancelled') {
+            await this.stockMovementService.createCancellationsForOrderItems(ctx, fulfillment.orderItems);
+            const lines = await this.groupOrderItemsIntoLines(ctx, fulfillment.orderItems);
+            await this.stockMovementService.createAllocationsForOrderLines(ctx, lines);
+        }
         await Promise.all(
             orders.map(order => this.handleFulfillmentStateTransitByOrder(ctx, order, fromState, toState)),
         );
@@ -1671,4 +1676,29 @@ export class OrderService {
         }
         return merged;
     }
+
+    private async groupOrderItemsIntoLines(
+        ctx: RequestContext,
+        orderItems: OrderItem[],
+    ): Promise<Array<{ orderLine: OrderLine; quantity: number }>> {
+        const orderLineIdQuantityMap = new Map<ID, number>();
+        for (const item of orderItems) {
+            const quantity = orderLineIdQuantityMap.get(item.lineId);
+            if (quantity == null) {
+                orderLineIdQuantityMap.set(item.lineId, 1);
+            } else {
+                orderLineIdQuantityMap.set(item.lineId, quantity + 1);
+            }
+        }
+        const orderLines = await this.connection
+            .getRepository(ctx, OrderLine)
+            .findByIds([...orderLineIdQuantityMap.keys()], {
+                relations: ['productVariant'],
+            });
+        return orderLines.map(orderLine => ({
+            orderLine,
+            // tslint:disable-next-line:no-non-null-assertion
+            quantity: orderLineIdQuantityMap.get(orderLine.id)!,
+        }));
+    }
 }

+ 19 - 5
packages/core/src/service/services/stock-movement.service.ts

@@ -101,23 +101,37 @@ export class StockMovementService {
         if (order.active !== false) {
             throw new InternalServerError('error.cannot-create-allocations-for-active-order');
         }
+        const lines = order.lines.map(orderLine => ({ orderLine, quantity: orderLine.quantity }));
+        return this.createAllocationsForOrderLines(ctx, lines);
+    }
+
+    /**
+     * @description
+     * Creates a new {@link Allocation} for each of the given OrderLines. For ProductVariants
+     * which are configured to track stock levels, the `ProductVariant.stockAllocated` value is
+     * increased, indicating that this quantity of stock is allocated and cannot be sold.
+     */
+    async createAllocationsForOrderLines(
+        ctx: RequestContext,
+        lines: Array<{ orderLine: OrderLine; quantity: number }>,
+    ): Promise<Allocation[]> {
         const allocations: Allocation[] = [];
         const globalTrackInventory = (await this.globalSettingsService.getSettings(ctx)).trackInventory;
-        for (const line of order.lines) {
+        for (const { orderLine, quantity } of lines) {
             const productVariant = await this.connection.getEntityOrThrow(
                 ctx,
                 ProductVariant,
-                line.productVariant.id,
+                orderLine.productVariant.id,
             );
             const allocation = new Allocation({
                 productVariant,
-                quantity: line.quantity,
-                orderLine: line,
+                quantity,
+                orderLine,
             });
             allocations.push(allocation);
 
             if (this.trackInventoryForVariant(productVariant, globalTrackInventory)) {
-                productVariant.stockAllocated += line.quantity;
+                productVariant.stockAllocated += quantity;
                 await this.connection
                     .getRepository(ctx, ProductVariant)
                     .save(productVariant, { reload: false });