From c4dd29046183bfe3c99b7ec00414449e53478d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Wed, 28 May 2025 09:52:01 +0200 Subject: [PATCH] fix(core-flows): reservation management on order edit and draft order confirm (#12546) --- .changeset/calm-items-mix.md | 5 + .../draft-order/admin/draft-order.spec.ts | 165 ++++++-- .../__tests__/order-edits/order-edits.spec.ts | 354 +++++++++++++++++- .../workflows/confirm-draft-order-edit.ts | 55 ++- .../order-edit/confirm-order-edit-request.ts | 49 ++- 5 files changed, 543 insertions(+), 85 deletions(-) create mode 100644 .changeset/calm-items-mix.md diff --git a/.changeset/calm-items-mix.md b/.changeset/calm-items-mix.md new file mode 100644 index 0000000000..71a11e1ad2 --- /dev/null +++ b/.changeset/calm-items-mix.md @@ -0,0 +1,5 @@ +--- +"@medusajs/core-flows": patch +--- + +fix(core-flows): reservation management on order edit and draft order confirm diff --git a/integration-tests/http/__tests__/draft-order/admin/draft-order.spec.ts b/integration-tests/http/__tests__/draft-order/admin/draft-order.spec.ts index d1cf775509..ea13f8ed7b 100644 --- a/integration-tests/http/__tests__/draft-order/admin/draft-order.spec.ts +++ b/integration-tests/http/__tests__/draft-order/admin/draft-order.spec.ts @@ -200,18 +200,54 @@ medusaIntegrationTestRunner({ describe("POST /draft-orders/:id/edit/items/:item_id", () => { let product + let inventoryItemLarge + let inventoryItemMedium + let inventoryItemSmall beforeEach(async () => { - const inventoryItem = ( + inventoryItemLarge = ( await api.post( `/admin/inventory-items`, - { sku: "shirt" }, + { sku: "shirt-large" }, + adminHeaders + ) + ).data.inventory_item + + inventoryItemMedium = ( + await api.post( + `/admin/inventory-items`, + { sku: "shirt-medium" }, + adminHeaders + ) + ).data.inventory_item + + inventoryItemSmall = ( + await api.post( + `/admin/inventory-items`, + { sku: "shirt-small" }, adminHeaders ) ).data.inventory_item await api.post( - `/admin/inventory-items/${inventoryItem.id}/location-levels`, + `/admin/inventory-items/${inventoryItemLarge.id}/location-levels`, + { + location_id: stockLocation.id, + stocked_quantity: 10, + }, + adminHeaders + ) + + await api.post( + `/admin/inventory-items/${inventoryItemMedium.id}/location-levels`, + { + location_id: stockLocation.id, + stocked_quantity: 10, + }, + adminHeaders + ) + await api.post( + `/admin/inventory-items/${inventoryItemSmall.id}/location-levels`, { location_id: stockLocation.id, stocked_quantity: 10, @@ -224,14 +260,34 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "Shirt", - options: [{ title: "size", values: ["large", "small"] }], + options: [ + { title: "size", values: ["large", "medium", "small"] }, + ], variants: [ { title: "L shirt", options: { size: "large" }, + manage_inventory: true, inventory_items: [ { - inventory_item_id: inventoryItem.id, + inventory_item_id: inventoryItemLarge.id, + required_quantity: 1, + }, + ], + prices: [ + { + currency_code: "usd", + amount: 10, + }, + ], + }, + { + title: "M shirt", + options: { size: "medium" }, + manage_inventory: true, + inventory_items: [ + { + inventory_item_id: inventoryItemMedium.id, required_quantity: 1, }, ], @@ -245,9 +301,10 @@ medusaIntegrationTestRunner({ { title: "S shirt", options: { size: "small" }, + manage_inventory: true, inventory_items: [ { - inventory_item_id: inventoryItem.id, + inventory_item_id: inventoryItemSmall.id, required_quantity: 1, }, ], @@ -265,7 +322,12 @@ medusaIntegrationTestRunner({ ).data.product }) - it("should create reservations for added items", async () => { + it("should manage reservations on order edit", async () => { + let reservations = (await api.get(`/admin/reservations`, adminHeaders)) + .data.reservations + + expect(reservations.length).toBe(0) + // 1. Create first edit and add items to it let edit = ( await api.post( @@ -278,7 +340,27 @@ medusaIntegrationTestRunner({ await api.post( `/admin/draft-orders/${testDraftOrder.id}/edit/items`, { - items: [{ variant_id: product.variants[0].id, quantity: 1 }], + items: [ + { + variant_id: product.variants.find((v) => v.title === "L shirt") + .id, + quantity: 1, + }, + ], + }, + adminHeaders + ) + + await api.post( + `/admin/draft-orders/${testDraftOrder.id}/edit/items`, + { + items: [ + { + variant_id: product.variants.find((v) => v.title === "M shirt") + .id, + quantity: 1, + }, + ], }, adminHeaders ) @@ -291,7 +373,23 @@ medusaIntegrationTestRunner({ ) ).data.draft_order_preview - // Create second edit and add items to it + reservations = (await api.get(`/admin/reservations`, adminHeaders)).data + .reservations + + expect(reservations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: inventoryItemLarge.id, + quantity: 1, + }), + expect.objectContaining({ + inventory_item_id: inventoryItemMedium.id, + quantity: 1, + }), + ]) + ) + + // Create second edit edit = ( await api.post( `/admin/draft-orders/${testDraftOrder.id}/edit`, @@ -300,14 +398,39 @@ medusaIntegrationTestRunner({ ) ).data.draft_order_preview + // Add item await api.post( `/admin/draft-orders/${testDraftOrder.id}/edit/items`, { - items: [{ variant_id: product.variants[1].id, quantity: 2 }], + items: [ + { + variant_id: product.variants.find((v) => v.title === "S shirt") + .id, + quantity: 1, + }, + ], }, adminHeaders ) + // Remove item + await api.post( + `/admin/draft-orders/${testDraftOrder.id}/edit/items/item/${ + edit.items.find((i) => i.subtitle === "M shirt").id + }`, + { quantity: 0 }, + adminHeaders + ) + + // Update item + await api.post( + `/admin/draft-orders/${testDraftOrder.id}/edit/items/item/${ + edit.items.find((i) => i.subtitle === "L shirt").id + }`, + { quantity: 2 }, + adminHeaders + ) + edit = ( await api.post( `/admin/draft-orders/${testDraftOrder.id}/edit/confirm`, @@ -316,29 +439,19 @@ medusaIntegrationTestRunner({ ) ).data.draft_order_preview - const reservations = ( - await api.get(`/admin/reservations`, adminHeaders) - ).data.reservations + reservations = (await api.get(`/admin/reservations`, adminHeaders)).data + .reservations - const lineItem1Id = edit.items.find( - (item) => item.variant_id === product.variants[0].id - )?.id - - const lineItem2Id = edit.items.find( - (item) => item.variant_id === product.variants[1].id - )?.id - - // second edit didn't override the reservations for the first edit expect(reservations.length).toBe(2) expect(reservations).toEqual( expect.arrayContaining([ expect.objectContaining({ - line_item_id: lineItem1Id, - quantity: 1, + inventory_item_id: inventoryItemLarge.id, + quantity: 2, }), expect.objectContaining({ - line_item_id: lineItem2Id, - quantity: 2, + inventory_item_id: inventoryItemSmall.id, + quantity: 1, }), ]) ) diff --git a/integration-tests/http/__tests__/order-edits/order-edits.spec.ts b/integration-tests/http/__tests__/order-edits/order-edits.spec.ts index 623ed0c7fd..fb88274365 100644 --- a/integration-tests/http/__tests__/order-edits/order-edits.spec.ts +++ b/integration-tests/http/__tests__/order-edits/order-edits.spec.ts @@ -13,7 +13,7 @@ import { } from "../../../helpers/create-admin-user" import { medusaTshirtProduct } from "../../__fixtures__/product" -jest.setTimeout(30000) +jest.setTimeout(300000) medusaIntegrationTestRunner({ testSuite: ({ dbConnection, getContainer, api }) => { @@ -510,6 +510,358 @@ medusaIntegrationTestRunner({ }) }) + describe("Order Edit Inventory", () => { + let product + let inventoryItemLarge + let inventoryItemMedium + let inventoryItemSmall + + beforeEach(async () => { + const container = getContainer() + + inventoryItemLarge = ( + await api.post( + `/admin/inventory-items`, + { sku: "shirt-large" }, + adminHeaders + ) + ).data.inventory_item + + inventoryItemMedium = ( + await api.post( + `/admin/inventory-items`, + { sku: "shirt-medium" }, + adminHeaders + ) + ).data.inventory_item + + inventoryItemSmall = ( + await api.post( + `/admin/inventory-items`, + { sku: "shirt-small" }, + adminHeaders + ) + ).data.inventory_item + + location = ( + await api.post( + `/admin/stock-locations`, + { + name: "Test location", + }, + adminHeaders + ) + ).data.stock_location + + await api.post( + `/admin/inventory-items/${inventoryItemLarge.id}/location-levels`, + { + location_id: location.id, + stocked_quantity: 10, + }, + adminHeaders + ) + + await api.post( + `/admin/inventory-items/${inventoryItemMedium.id}/location-levels`, + { + location_id: location.id, + stocked_quantity: 10, + }, + adminHeaders + ) + await api.post( + `/admin/inventory-items/${inventoryItemSmall.id}/location-levels`, + { + location_id: location.id, + stocked_quantity: 10, + }, + adminHeaders + ) + + product = ( + await api.post( + "/admin/products", + { + title: "Shirt", + options: [ + { title: "size", values: ["large", "medium", "small"] }, + ], + variants: [ + { + title: "L shirt", + options: { size: "large" }, + manage_inventory: true, + inventory_items: [ + { + inventory_item_id: inventoryItemLarge.id, + required_quantity: 1, + }, + ], + prices: [ + { + currency_code: "usd", + amount: 10, + }, + ], + }, + { + title: "M shirt", + options: { size: "medium" }, + manage_inventory: true, + inventory_items: [ + { + inventory_item_id: inventoryItemMedium.id, + required_quantity: 1, + }, + ], + prices: [ + { + currency_code: "usd", + amount: 10, + }, + ], + }, + { + title: "S shirt", + options: { size: "small" }, + manage_inventory: true, + inventory_items: [ + { + inventory_item_id: inventoryItemSmall.id, + required_quantity: 1, + }, + ], + prices: [ + { + currency_code: "usd", + amount: 10, + }, + ], + }, + ], + }, + adminHeaders + ) + ).data.product + + const region = ( + await api.post( + "/admin/regions", + { + name: "test-region", + currency_code: "usd", + }, + adminHeaders + ) + ).data.region + + const customer = ( + await api.post( + "/admin/customers", + { + first_name: "joe2", + email: "joe2@admin.com", + }, + adminHeaders + ) + ).data.customer + + const taxRegion = ( + await api.post( + "/admin/tax-regions", + { + provider_id: "tp_system", + country_code: "UK", + }, + adminHeaders + ) + ).data.tax_region + + taxLine = ( + await api.post( + "/admin/tax-rates", + { + rate: 10, + code: "standard", + name: "Taxation is theft", + is_default: true, + tax_region_id: taxRegion.id, + }, + adminHeaders + ) + ).data.tax_rate + + const salesChannel = ( + await api.post( + "/admin/sales-channels", + { + name: "Test channel", + }, + adminHeaders + ) + ).data.sales_channel + + const orderModule = container.resolve(Modules.ORDER) + + order = await orderModule.createOrders({ + region_id: region.id, + email: "foo@bar.com", + items: [ + { + title: "Medusa T-shirt", + subtitle: "L shirt", + variant_id: product.variants.find((v) => v.title === "L shirt") + .id, + quantity: 2, + unit_price: 25, + }, + { + title: "Medusa T-shirt", + subtitle: "M shirt", + variant_id: product.variants.find((v) => v.title === "M shirt") + .id, + quantity: 2, + unit_price: 25, + }, + ], + sales_channel_id: salesChannel.id, + shipping_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + phone: "12345", + }, + billing_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + }, + shipping_methods: [ + { + name: "Test shipping method", + amount: 10, + }, + ], + currency_code: "usd", + customer_id: customer.id, + }) + + const remoteLink = container.resolve( + ContainerRegistrationKeys.REMOTE_LINK + ) + + await remoteLink.create([ + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + ]) + }) + + it("should manage reservations on order edit", async () => { + let edit = ( + await api.post( + `/admin/order-edits`, + { order_id: order.id }, + adminHeaders + ) + ).data.order_change + + // Add item + await api.post( + `/admin/order-edits/${order.id}/items`, + { + items: [ + { + variant_id: product.variants.find((v) => v.title === "S shirt") + .id, + quantity: 1, + }, + ], + }, + adminHeaders + ) + + // Remove item + await api.post( + `/admin/order-edits/${order.id}/items/item/${ + order.items.find((i) => i.subtitle === "M shirt").id + }`, + { quantity: 0 }, + adminHeaders + ) + + // Update item + await api.post( + `/admin/order-edits/${order.id}/items/item/${ + order.items.find((i) => i.subtitle === "L shirt").id + }`, + { quantity: 2 }, + adminHeaders + ) + + edit = ( + await api.post( + `/admin/order-edits/${order.id}/request`, + {}, + adminHeaders + ) + ).data.order_change + + edit = ( + await api.post( + `/admin/order-edits/${order.id}/confirm`, + {}, + adminHeaders + ) + ).data.order_change + + order = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data + .order + + expect(order.items.length).toBe(2) + expect(order.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subtitle: "L shirt", + quantity: 2, + }), + expect.objectContaining({ + subtitle: "S shirt", + quantity: 1, + }), + ]) + ) + let reservations = (await api.get(`/admin/reservations`, adminHeaders)) + .data.reservations + + expect(reservations.length).toBe(2) + expect(reservations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: inventoryItemLarge.id, + quantity: 2, + }), + expect.objectContaining({ + inventory_item_id: inventoryItemSmall.id, + quantity: 1, + }), + ]) + ) + }) + }) + describe("Order Edit Shipping Methods", () => { it("should add a shipping method through an order edit", async () => { await api.post( diff --git a/packages/core/core-flows/src/draft-order/workflows/confirm-draft-order-edit.ts b/packages/core/core-flows/src/draft-order/workflows/confirm-draft-order-edit.ts index 0a99e48153..be046591f9 100644 --- a/packages/core/core-flows/src/draft-order/workflows/confirm-draft-order-edit.ts +++ b/packages/core/core-flows/src/draft-order/workflows/confirm-draft-order-edit.ts @@ -36,10 +36,10 @@ export interface ConfirmDraftOrderEditWorkflowInput { /** * This workflow confirms a draft order edit. It's used by the * [Confirm Draft Order Edit Admin API Route](https://docs.medusajs.com/api/admin#draft-orders_postdraftordersideditconfirm). - * + * * You can use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around * confirming a draft order edit. - * + * * @example * const { result } = await confirmDraftOrderEditWorkflow(container) * .run({ @@ -48,9 +48,9 @@ export interface ConfirmDraftOrderEditWorkflowInput { * confirmed_by: "user_123", * } * }) - * + * * @summary - * + * * Confirm a draft order edit. */ export const confirmDraftOrderEditWorkflow = createWorkflow( @@ -134,31 +134,21 @@ export const confirmDraftOrderEditWorkflow = createWorkflow( throw_if_key_not_found: true, }).config({ name: "order-items-query" }) - const { removedLineItemIds } = transform( - { orderItems, previousOrderItems: order.items }, - (data) => { - const previousItemIds = (data.previousOrderItems || []).map( - ({ id }) => id - ) - const currentItemIds = data.orderItems.items.map(({ id }) => id) + const { variants, items, toRemoveReservationLineItemIds } = transform( + { orderItems, previousOrderItems: order.items, orderPreview }, + ({ orderItems, previousOrderItems, orderPreview }) => { + const allItems: any[] = [] + const allVariants: any[] = [] + + const previousItemIds = (previousOrderItems || []).map(({ id }) => id) + const currentItemIds = orderItems.items.map(({ id }) => id) const removedItemIds = previousItemIds.filter( (id) => !currentItemIds.includes(id) ) - return { - removedLineItemIds: removedItemIds, - } - } - ) + const updatedItemIds: string[] = [] - deleteReservationsByLineItemsStep(removedLineItemIds) - - const { variants, items } = transform( - { orderItems, orderPreview }, - ({ orderItems, orderPreview }) => { - const allItems: any[] = [] - const allVariants: any[] = [] orderItems.items.forEach((ordItem) => { const itemAction = orderPreview.items?.find( (item) => @@ -185,17 +175,13 @@ export const confirmDraftOrderEditWorkflow = createWorkflow( (a) => a.action === ChangeActionType.ITEM_UPDATE ) - const quantity: BigNumberInput = - itemAction.raw_quantity ?? itemAction.quantity - - const newQuantity = updateAction - ? MathBN.sub(quantity, ordItem.raw_quantity) - : quantity - - if (MathBN.lte(newQuantity, 0)) { - return + if (updateAction) { + updatedItemIds.push(ordItem.id) } + const newQuantity: BigNumberInput = + itemAction.raw_quantity ?? itemAction.quantity + const reservationQuantity = MathBN.sub( newQuantity, ordItem.raw_fulfilled_quantity @@ -214,6 +200,10 @@ export const confirmDraftOrderEditWorkflow = createWorkflow( return { variants: allVariants, items: allItems, + toRemoveReservationLineItemIds: [ + ...removedItemIds, + ...updatedItemIds, + ], } } ) @@ -229,6 +219,7 @@ export const confirmDraftOrderEditWorkflow = createWorkflow( prepareConfirmInventoryInput ) + deleteReservationsByLineItemsStep(toRemoveReservationLineItemIds) reserveInventoryStep(formatedInventoryItems) createOrUpdateOrderPaymentCollectionWorkflow.runAsStep({ diff --git a/packages/core/core-flows/src/order/workflows/order-edit/confirm-order-edit-request.ts b/packages/core/core-flows/src/order/workflows/order-edit/confirm-order-edit-request.ts index 77a877d5a4..cbb8578f10 100644 --- a/packages/core/core-flows/src/order/workflows/order-edit/confirm-order-edit-request.ts +++ b/packages/core/core-flows/src/order/workflows/order-edit/confirm-order-edit-request.ts @@ -191,25 +191,21 @@ export const confirmOrderEditRequestWorkflow = createWorkflow( throw_if_key_not_found: true, }).config({ name: "order-items-query" }) - const lineItemIds = transform( - { orderItems, previousOrderItems: order.items }, - - (data) => { - const previousItemIds = (data.previousOrderItems || []).map( - ({ id }) => id - ) // items that have been removed with the change - const newItemIds = data.orderItems.items.map(({ id }) => id) - return [...new Set([...previousItemIds, ...newItemIds])] - } - ) - - deleteReservationsByLineItemsStep(lineItemIds) - - const { variants, items } = transform( - { orderItems, orderPreview }, - ({ orderItems, orderPreview }) => { + const { variants, items, toRemoveReservationLineItemIds } = transform( + { orderItems, previousOrderItems: order.items, orderPreview }, + ({ orderItems, previousOrderItems, orderPreview }) => { const allItems: any[] = [] const allVariants: any[] = [] + + const previousItemIds = (previousOrderItems || []).map(({ id }) => id) + const currentItemIds = orderItems.items.map(({ id }) => id) + + const removedItemIds = previousItemIds.filter( + (id) => !currentItemIds.includes(id) + ) + + const updatedItemIds: string[] = [] + orderItems.items.forEach((ordItem) => { const itemAction = orderPreview.items?.find( (item) => @@ -236,17 +232,13 @@ export const confirmOrderEditRequestWorkflow = createWorkflow( (a) => a.action === ChangeActionType.ITEM_UPDATE ) - const quantity: BigNumberInput = - itemAction.raw_quantity ?? itemAction.quantity - - const newQuantity = updateAction - ? MathBN.sub(quantity, ordItem.raw_quantity) - : quantity - - if (MathBN.lte(newQuantity, 0)) { - return + if (updateAction) { + updatedItemIds.push(ordItem.id) } + const newQuantity: BigNumberInput = + itemAction.raw_quantity ?? itemAction.quantity + const reservationQuantity = MathBN.sub( newQuantity, ordItem.raw_fulfilled_quantity @@ -265,6 +257,10 @@ export const confirmOrderEditRequestWorkflow = createWorkflow( return { variants: allVariants, items: allItems, + toRemoveReservationLineItemIds: [ + ...removedItemIds, + ...updatedItemIds, + ], } } ) @@ -280,6 +276,7 @@ export const confirmOrderEditRequestWorkflow = createWorkflow( prepareConfirmInventoryInput ) + deleteReservationsByLineItemsStep(toRemoveReservationLineItemIds) reserveInventoryStep(formatedInventoryItems) createOrUpdateOrderPaymentCollectionWorkflow.runAsStep({