Forráskód Böngészése

fix(core): Fix stock constraint error when using OrderLine custom fields

Fixes #2702
Michael Bromley 1 éve
szülő
commit
2f93eb729d

+ 81 - 5
packages/core/e2e/shop-order.e2e-spec.ts

@@ -10,11 +10,10 @@ import {
 import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
 import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
 import gql from 'graphql-tag';
 import gql from 'graphql-tag';
 import path from 'path';
 import path from 'path';
-import { vi } from 'vitest';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
 
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 
 import {
 import {
     testErrorPaymentMethod,
     testErrorPaymentMethod,
@@ -25,15 +24,16 @@ import {
     countryCodeShippingEligibilityChecker,
     countryCodeShippingEligibilityChecker,
     hydratingShippingEligibilityChecker,
     hydratingShippingEligibilityChecker,
 } from './fixtures/test-shipping-eligibility-checkers';
 } from './fixtures/test-shipping-eligibility-checkers';
+import * as Codegen from './graphql/generated-e2e-admin-types';
 import {
 import {
     CreateAddressInput,
     CreateAddressInput,
     CreateShippingMethodDocument,
     CreateShippingMethodDocument,
     CreateShippingMethodInput,
     CreateShippingMethodInput,
+    GlobalFlag,
     LanguageCode,
     LanguageCode,
 } from './graphql/generated-e2e-admin-types';
 } from './graphql/generated-e2e-admin-types';
-import * as Codegen from './graphql/generated-e2e-admin-types';
-import { ErrorCode, RemoveItemFromOrderDocument } from './graphql/generated-e2e-shop-types';
 import * as CodegenShop from './graphql/generated-e2e-shop-types';
 import * as CodegenShop from './graphql/generated-e2e-shop-types';
+import { ErrorCode, RemoveItemFromOrderDocument } from './graphql/generated-e2e-shop-types';
 import {
 import {
     ATTEMPT_LOGIN,
     ATTEMPT_LOGIN,
     CANCEL_ORDER,
     CANCEL_ORDER,
@@ -692,6 +692,7 @@ describe('Shop orders', () => {
             const order = await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
             const order = await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
             expect(order.activeOrder?.lines[1].quantity).toBe(100);
             expect(order.activeOrder?.lines[1].quantity).toBe(100);
 
 
+            // clean up
             const { adjustOrderLine: adjustLine2 } = await shopClient.query<
             const { adjustOrderLine: adjustLine2 } = await shopClient.query<
                 CodegenShop.AdjustItemQuantityMutation,
                 CodegenShop.AdjustItemQuantityMutation,
                 CodegenShop.AdjustItemQuantityMutationVariables
                 CodegenShop.AdjustItemQuantityMutationVariables
@@ -704,6 +705,81 @@ describe('Shop orders', () => {
             expect(adjustLine2.lines.map(i => i.productVariant.id)).toEqual(['T_1']);
             expect(adjustLine2.lines.map(i => i.productVariant.id)).toEqual(['T_1']);
         });
         });
 
 
+        // https://github.com/vendure-ecommerce/vendure/issues/2702
+        it('stockOnHand check works with multiple order lines with different custom fields', async () => {
+            const variantId = 'T_27';
+            const { updateProductVariants } = await adminClient.query<
+                Codegen.UpdateProductVariantsMutation,
+                Codegen.UpdateProductVariantsMutationVariables
+            >(UPDATE_PRODUCT_VARIANTS, {
+                input: [
+                    {
+                        id: variantId,
+                        stockOnHand: 10,
+                        outOfStockThreshold: 0,
+                        useGlobalOutOfStockThreshold: false,
+                        trackInventory: GlobalFlag.TRUE,
+                    },
+                ],
+            });
+
+            expect(updateProductVariants[0]?.stockOnHand).toBe(10);
+            expect(updateProductVariants[0]?.id).toBe('T_27');
+            expect(updateProductVariants[0]?.trackInventory).toBe(GlobalFlag.TRUE);
+
+            const { addItemToOrder: add1 } = await shopClient.query<CodegenShop.AddItemToOrderMutation, any>(
+                ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS,
+                {
+                    productVariantId: variantId,
+                    quantity: 9,
+                    customFields: {
+                        notes: 'abc',
+                    },
+                },
+            );
+
+            orderResultGuard.assertSuccess(add1);
+
+            expect(add1.lines.length).toBe(2);
+            expect(add1.lines[1].quantity).toBe(9);
+            expect(add1.lines[1].productVariant.id).toBe(variantId);
+
+            const { addItemToOrder: add2 } = await shopClient.query<CodegenShop.AddItemToOrderMutation, any>(
+                ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS,
+                {
+                    productVariantId: variantId,
+                    quantity: 2,
+                    customFields: {
+                        notes: 'def',
+                    },
+                },
+            );
+
+            orderResultGuard.assertErrorResult(add2);
+
+            expect(add2.errorCode).toBe('INSUFFICIENT_STOCK_ERROR');
+            expect(add2.message).toBe('Only 1 item was added to the order due to insufficient stock');
+
+            const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
+            expect(activeOrder?.lines.length).toBe(3);
+            expect(activeOrder?.lines[1].quantity).toBe(9);
+            expect(activeOrder?.lines[2].quantity).toBe(1);
+
+            // clean up
+            await shopClient.query<
+                CodegenShop.RemoveItemFromOrderMutation,
+                CodegenShop.RemoveItemFromOrderMutationVariables
+            >(REMOVE_ITEM_FROM_ORDER, {
+                orderLineId: activeOrder!.lines[1].id,
+            });
+            await shopClient.query<
+                CodegenShop.RemoveItemFromOrderMutation,
+                CodegenShop.RemoveItemFromOrderMutationVariables
+            >(REMOVE_ITEM_FROM_ORDER, {
+                orderLineId: activeOrder!.lines[2].id,
+            });
+        });
+
         it('adjustOrderLine errors when going beyond orderItemsLimit', async () => {
         it('adjustOrderLine errors when going beyond orderItemsLimit', async () => {
             const { adjustOrderLine } = await shopClient.query<
             const { adjustOrderLine } = await shopClient.query<
                 CodegenShop.AdjustItemQuantityMutation,
                 CodegenShop.AdjustItemQuantityMutation,

+ 16 - 4
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -99,17 +99,29 @@ export class OrderModifier {
      * @description
      * @description
      * Ensure that the ProductVariant has sufficient saleable stock to add the given
      * Ensure that the ProductVariant has sufficient saleable stock to add the given
      * quantity to an Order.
      * quantity to an Order.
+     *
+     * - `existingOrderLineQuantity` is used when adding an item to the order, since if an OrderLine
+     * already exists then we will be adding the new quantity to the existing quantity.
+     * - `quantityInOtherOrderLines` is used when we have more than 1 OrderLine containing the same
+     * ProductVariant. This occurs when there are custom fields defined on the OrderLine and the lines
+     * have differing values for one or more custom fields. In this case, we need to take _all_ of these
+     * OrderLines into account when constraining the quantity. See https://github.com/vendure-ecommerce/vendure/issues/2702
+     * for more on this.
      */
      */
     async constrainQuantityToSaleable(
     async constrainQuantityToSaleable(
         ctx: RequestContext,
         ctx: RequestContext,
         variant: ProductVariant,
         variant: ProductVariant,
         quantity: number,
         quantity: number,
-        existingQuantity = 0,
+        existingOrderLineQuantity = 0,
+        quantityInOtherOrderLines = 0,
     ) {
     ) {
-        let correctedQuantity = quantity + existingQuantity;
+        let correctedQuantity = quantity + existingOrderLineQuantity;
         const saleableStockLevel = await this.productVariantService.getSaleableStockLevel(ctx, variant);
         const saleableStockLevel = await this.productVariantService.getSaleableStockLevel(ctx, variant);
-        if (saleableStockLevel < correctedQuantity) {
-            correctedQuantity = Math.max(saleableStockLevel - existingQuantity, 0);
+        if (saleableStockLevel < correctedQuantity + quantityInOtherOrderLines) {
+            correctedQuantity = Math.max(
+                saleableStockLevel - existingOrderLineQuantity - quantityInOtherOrderLines,
+                0,
+            );
         }
         }
         return correctedQuantity;
         return correctedQuantity;
     }
     }

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

@@ -520,11 +520,20 @@ export class OrderService {
         if (variant.product.enabled === false) {
         if (variant.product.enabled === false) {
             throw new EntityNotFoundError('ProductVariant', productVariantId);
             throw new EntityNotFoundError('ProductVariant', productVariantId);
         }
         }
+        const existingQuantityInOtherLines = summate(
+            order.lines.filter(
+                l =>
+                    idsAreEqual(l.productVariantId, productVariantId) &&
+                    !idsAreEqual(l.id, existingOrderLine?.id),
+            ),
+            'quantity',
+        );
         const correctedQuantity = await this.orderModifier.constrainQuantityToSaleable(
         const correctedQuantity = await this.orderModifier.constrainQuantityToSaleable(
             ctx,
             ctx,
             variant,
             variant,
             quantity,
             quantity,
             existingOrderLine?.quantity,
             existingOrderLine?.quantity,
+            existingQuantityInOtherLines,
         );
         );
         if (correctedQuantity === 0) {
         if (correctedQuantity === 0) {
             return new InsufficientStockError({ order, quantityAvailable: correctedQuantity });
             return new InsufficientStockError({ order, quantityAvailable: correctedQuantity });