From 9f4d32b2201d8c87d84c59b9a1ca59e00bcc6af1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Fri, 16 May 2025 10:50:10 +0200 Subject: [PATCH] fix(core-flows): fulfilment cancelation with shared inventory kit item (#12503) * fix(core-flows): fulfilment cancelation with shared inventory kit item * fix: typos, check if iitem exists * chore: typo --- .changeset/ten-laws-scream.md | 5 + .../http/__tests__/order/admin/order.spec.ts | 591 +++++++++++++++++- .../workflows/cancel-order-fulfillment.ts | 53 +- 3 files changed, 616 insertions(+), 33 deletions(-) create mode 100644 .changeset/ten-laws-scream.md diff --git a/.changeset/ten-laws-scream.md b/.changeset/ten-laws-scream.md new file mode 100644 index 0000000000..efad525594 --- /dev/null +++ b/.changeset/ten-laws-scream.md @@ -0,0 +1,5 @@ +--- +"@medusajs/core-flows": patch +--- + +fix(core-flows): fulfilment cancelation with shared inventory kit item diff --git a/integration-tests/http/__tests__/order/admin/order.spec.ts b/integration-tests/http/__tests__/order/admin/order.spec.ts index 5ef87cf83e..70c01fc0e0 100644 --- a/integration-tests/http/__tests__/order/admin/order.spec.ts +++ b/integration-tests/http/__tests__/order/admin/order.spec.ts @@ -1375,7 +1375,7 @@ medusaIntegrationTestRunner({ ]) ) - // create a fulfillment for the entiretablet order + // create a fulfillment for the entire tablet order const fulOrder = ( await api.post( `/admin/orders/${tabletOrder.id}/fulfillments?fields=*fulfillments,*fulfillments.items`, @@ -1394,7 +1394,7 @@ medusaIntegrationTestRunner({ ).data.reservations // no more reservations since everything is fuliflled - expect(reservations).toEqual(expect.arrayContaining([])) + expect(reservations.length).toEqual(0) // cancel the fulfillment await api.post( @@ -1421,7 +1421,7 @@ medusaIntegrationTestRunner({ ]) ) - // create a fulfillment for the entiretablet order again + // create a fulfillment for the entire tablet order again const fulOrder2 = ( await api.post( `/admin/orders/${tabletOrder.id}/fulfillments?fields=*fulfillments,*fulfillments.items`, @@ -1440,11 +1440,13 @@ medusaIntegrationTestRunner({ ).data.reservations // no more reservations since everything is fuliflled again - expect(reservations).toEqual(expect.arrayContaining([])) + expect(reservations.length).toEqual(0) // cancel the fulfillment await api.post( - `/admin/orders/${tabletOrder.id}/fulfillments/${fulOrder2.fulfillments[0].id}/cancel`, + `/admin/orders/${tabletOrder.id}/fulfillments/${ + fulOrder2.fulfillments.find((f) => !f.canceled_at).id + }/cancel`, {}, adminHeaders ) @@ -1477,10 +1479,18 @@ medusaIntegrationTestRunner({ expect.objectContaining({ inventory_item_id: inventoryItemDesk.id, quantity: 2, + inventory_item: expect.objectContaining({ + reserved_quantity: 2, + stocked_quantity: 10, + }), }), expect.objectContaining({ inventory_item_id: inventoryItemLeg.id, quantity: 8, + inventory_item: expect.objectContaining({ + reserved_quantity: 8, + stocked_quantity: 40, + }), }), ]) ) @@ -1514,8 +1524,12 @@ medusaIntegrationTestRunner({ expect(fulOrder.items[0].detail.fulfilled_quantity).toEqual(1) - reservations = (await api.get(`/admin/reservations`, adminHeaders)).data - .reservations + reservations = ( + await api.get( + `/admin/reservations?line_item_id[]=${lineItemId}`, + adminHeaders + ) + ).data.reservations // 3. reservations need to be reduced by half since we fulfilled 1 item out of 2 in the order expect(reservations).toEqual( @@ -1523,10 +1537,18 @@ medusaIntegrationTestRunner({ expect.objectContaining({ inventory_item_id: inventoryItemDesk.id, quantity: 1, + inventory_item: expect.objectContaining({ + reserved_quantity: 1, + stocked_quantity: 9, + }), }), expect.objectContaining({ inventory_item_id: inventoryItemLeg.id, quantity: 4, + inventory_item: expect.objectContaining({ + reserved_quantity: 4, + stocked_quantity: 36, + }), }), ]) ) @@ -1540,8 +1562,12 @@ medusaIntegrationTestRunner({ expect(data.order.fulfillments[0].canceled_at).toBeDefined() expect(data.order.items[0].detail.fulfilled_quantity).toEqual(0) - reservations = (await api.get(`/admin/reservations`, adminHeaders)).data - .reservations + reservations = ( + await api.get( + `/admin/reservations?line_item_id[]=${lineItemId}`, + adminHeaders + ) + ).data.reservations // 4. reservation qunatities are restored after partial fulfillment is canceled expect(reservations).toEqual( @@ -1549,10 +1575,18 @@ medusaIntegrationTestRunner({ expect.objectContaining({ inventory_item_id: inventoryItemDesk.id, quantity: 2, + inventory_item: expect.objectContaining({ + reserved_quantity: 2, + stocked_quantity: 10, + }), }), expect.objectContaining({ inventory_item_id: inventoryItemLeg.id, quantity: 8, + inventory_item: expect.objectContaining({ + reserved_quantity: 8, + stocked_quantity: 40, + }), }), ]) ) @@ -1604,8 +1638,12 @@ medusaIntegrationTestRunner({ adminHeaders ) - reservations = (await api.get(`/admin/reservations`, adminHeaders)).data - .reservations + reservations = ( + await api.get( + `/admin/reservations?line_item_id[]=${lineItemId}`, + adminHeaders + ) + ).data.reservations // 8. reservation need to be restored to the initiall quantities expect(reservations).toEqual( @@ -1613,15 +1651,546 @@ medusaIntegrationTestRunner({ expect.objectContaining({ inventory_item_id: inventoryItemDesk.id, quantity: 2, + inventory_item: expect.objectContaining({ + reserved_quantity: 2, + stocked_quantity: 10, + }), }), expect.objectContaining({ inventory_item_id: inventoryItemLeg.id, quantity: 8, + inventory_item: expect.objectContaining({ + reserved_quantity: 8, + stocked_quantity: 40, + }), }), ]) ) }) + it("should correctly manage reservations (with shared inventory item case)", async () => { + const inventoryItemBottle = ( + await api.post( + `/admin/inventory-items`, + { sku: "bottle" }, + adminHeaders + ) + ).data.inventory_item + + await api.post( + `/admin/inventory-items/${inventoryItemBottle.id}/location-levels`, + { + location_id: stockLocation.id, + stocked_quantity: 100, + }, + adminHeaders + ) + + const productBottle = ( + await api.post( + "/admin/products", + { + title: `Bottle Packs`, + shipping_profile_id: shippingProfile.id, + options: [{ title: "packs", values: ["one", "two", "three"] }], + variants: [ + { + title: "One Pack", + sku: "one-pack", + inventory_items: [ + { + inventory_item_id: inventoryItemBottle.id, + required_quantity: 1, + }, + ], + prices: [ + { + currency_code: "usd", + amount: 10, + }, + ], + options: { + packs: "one", + }, + }, + { + title: "Two Pack", + sku: "two-pack", + inventory_items: [ + { + inventory_item_id: inventoryItemBottle.id, + required_quantity: 2, + }, + ], + prices: [ + { + currency_code: "usd", + amount: 20, + }, + ], + options: { + packs: "two", + }, + }, + { + title: "Three Pack", + sku: "three-pack", + inventory_items: [ + { + inventory_item_id: inventoryItemBottle.id, + required_quantity: 3, + }, + ], + prices: [ + { + currency_code: "usd", + amount: 30, + }, + ], + options: { + packs: "three", + }, + }, + ], + }, + adminHeaders + ) + ).data.product + + const cartBottle = ( + await api.post( + `/store/carts`, + { + currency_code: "usd", + email: "tony@stark-industries.com", + region_id: region.id, + shipping_address: { + address_1: "test address 1", + address_2: "test address 2", + city: "ny", + country_code: "us", + province: "ny", + postal_code: "94016", + }, + billing_address: { + address_1: "test billing address 1", + address_2: "test billing address 2", + city: "ny", + country_code: "us", + province: "ny", + postal_code: "94016", + }, + sales_channel_id: salesChannel.id, + items: [ + { quantity: 2, variant_id: productBottle.variants[0].id }, + { quantity: 2, variant_id: productBottle.variants[1].id }, + { quantity: 2, variant_id: productBottle.variants[2].id }, + ], + }, + storeHeaders + ) + ).data.cart + + await api.post( + `/store/carts/${cartBottle.id}/shipping-methods`, + { option_id: shippingOption.id }, + storeHeaders + ) + + const paymentCollectionBottle = ( + await api.post( + `/store/payment-collections`, + { + cart_id: cartBottle.id, + }, + storeHeaders + ) + ).data.payment_collection + + await api.post( + `/store/payment-collections/${paymentCollectionBottle.id}/payment-sessions`, + { provider_id: "pp_system_default" }, + storeHeaders + ) + + const bottleOrder = ( + await api.post( + `/store/carts/${cartBottle.id}/complete`, + {}, + storeHeaders + ) + ).data.order + + const lineItemIds = bottleOrder.items.map((i) => i.id).join(",") + + const onePackItemId = bottleOrder.items.find( + (i) => i.subtitle === "One Pack" + )!.id + + const twoPackItemId = bottleOrder.items.find( + (i) => i.subtitle === "Two Pack" + )!.id + + const threePackItemId = bottleOrder.items.find( + (i) => i.subtitle === "Three Pack" + )!.id + + let reservations = ( + await api.get( + `/admin/reservations?line_item_id[]=${lineItemIds}`, + adminHeaders + ) + ).data.reservations + + expect(reservations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + line_item_id: onePackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 2, + }), + expect.objectContaining({ + line_item_id: twoPackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 4, + }), + expect.objectContaining({ + line_item_id: threePackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 6, + }), + ]) + ) + + expect(reservations[0].inventory_item).toEqual( + expect.objectContaining({ + id: inventoryItemBottle.id, + reserved_quantity: 12, // 2 * (onepack + twopack + threepack) + stocked_quantity: 100, + }) + ) + + // create a partial fulfillment only for one "Three Pack" + const fulOrder = ( + await api.post( + `/admin/orders/${bottleOrder.id}/fulfillments?fields=*fulfillments,*fulfillments.items`, + { + items: [ + { + id: threePackItemId, + quantity: 1, + }, + ], + }, + adminHeaders + ) + ).data.order + + expect( + fulOrder.items.find((i) => i.id === threePackItemId)!.detail + .fulfilled_quantity + ).toEqual(1) + + reservations = ( + await api.get( + `/admin/reservations?line_item_id[]=${lineItemIds}`, + adminHeaders + ) + ).data.reservations + + expect(reservations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + line_item_id: onePackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 2, + }), + expect.objectContaining({ + line_item_id: twoPackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 4, + }), + expect.objectContaining({ + line_item_id: threePackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 3, // This was partially fulfilled + }), + ]) + ) + + expect(reservations[0].inventory_item).toEqual( + expect.objectContaining({ + id: inventoryItemBottle.id, + reserved_quantity: 9, + stocked_quantity: 97, + }) + ) + + // cancel the first partial fulfillment + await api.post( + `/admin/orders/${bottleOrder.id}/fulfillments/${fulOrder.fulfillments[0].id}/cancel`, + {}, + adminHeaders + ) + + reservations = ( + await api.get( + `/admin/reservations?line_item_id[]=${lineItemIds}`, + adminHeaders + ) + ).data.reservations + + // reservations are restored to the initial quantities + expect(reservations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + line_item_id: onePackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 2, + }), + expect.objectContaining({ + line_item_id: twoPackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 4, + }), + expect.objectContaining({ + line_item_id: threePackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 6, + }), + ]) + ) + + expect(reservations[0].inventory_item).toEqual( + expect.objectContaining({ + id: inventoryItemBottle.id, + reserved_quantity: 12, // 2 * (onepack + twopack + threepack) + stocked_quantity: 100, + }) + ) + + // create a partial fulfillment only for the entier "Two Pack" item + const fulOrder2 = ( + await api.post( + `/admin/orders/${bottleOrder.id}/fulfillments?fields=*fulfillments,*fulfillments.items`, + { + items: [ + { + id: bottleOrder.items.find((i) => i.subtitle === "Two Pack")! + .id, + quantity: 2, + }, + ], + }, + adminHeaders + ) + ).data.order + + expect( + fulOrder2.items.find((i) => i.id === twoPackItemId)!.detail + .fulfilled_quantity + ).toEqual(2) + + expect( + fulOrder2.items.find((i) => i.id === threePackItemId)!.detail + .fulfilled_quantity + ).toEqual(0) + + reservations = ( + await api.get( + `/admin/reservations?line_item_id[]=${lineItemIds}`, + adminHeaders + ) + ).data.reservations + + expect(reservations.length).toEqual(2) + expect(reservations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + line_item_id: onePackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 2, + }), + + // Two pack was fully fulfilled + + expect.objectContaining({ + line_item_id: threePackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 6, + }), + ]) + ) + + expect(reservations[0].inventory_item).toEqual( + expect.objectContaining({ + id: inventoryItemBottle.id, + reserved_quantity: 8, + stocked_quantity: 96, + }) + ) + + const latestFulfillment = fulOrder2.fulfillments.find( + (f) => !f.canceled_at + )! + + // cancel the second partial fulfillment of the "Two Pack" item + await api.post( + `/admin/orders/${bottleOrder.id}/fulfillments/${latestFulfillment.id}/cancel`, + {}, + adminHeaders + ) + + reservations = ( + await api.get( + `/admin/reservations?line_item_id[]=${lineItemIds}`, + adminHeaders + ) + ).data.reservations + + // reservations are restored to the initial quantities + expect(reservations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + line_item_id: onePackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 2, + }), + expect.objectContaining({ + line_item_id: twoPackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 4, + }), + expect.objectContaining({ + line_item_id: threePackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 6, + }), + ]) + ) + + expect(reservations[0].inventory_item).toEqual( + expect.objectContaining({ + id: inventoryItemBottle.id, + reserved_quantity: 12, // 2 * (onepack + twopack + threepack) + stocked_quantity: 100, + }) + ) + + // finally create a full fulfillment for the entire order + const fulOrder3 = ( + await api.post( + `/admin/orders/${bottleOrder.id}/fulfillments?fields=*fulfillments,*fulfillments.items`, + { + items: [ + { + id: onePackItemId, + quantity: 2, + }, + { + id: twoPackItemId, + quantity: 2, + }, + { + id: threePackItemId, + quantity: 2, + }, + ], + }, + adminHeaders + ) + ).data.order + + reservations = ( + await api.get( + `/admin/reservations?line_item_id[]=${lineItemIds}`, + adminHeaders + ) + ).data.reservations + + expect(reservations.length).toEqual(0) + + expect(fulOrder3.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: onePackItemId, + detail: expect.objectContaining({ + fulfilled_quantity: 2, + }), + }), + expect.objectContaining({ + id: twoPackItemId, + detail: expect.objectContaining({ + fulfilled_quantity: 2, + }), + }), + expect.objectContaining({ + id: threePackItemId, + detail: expect.objectContaining({ + fulfilled_quantity: 2, + }), + }), + ]) + ) + + // cancel the fulfillment for the entire order + await api.post( + `/admin/orders/${bottleOrder.id}/fulfillments/${ + fulOrder3.fulfillments.find((f) => !f.canceled_at)!.id + }/cancel`, + {}, + adminHeaders + ) + + reservations = ( + await api.get( + `/admin/reservations?line_item_id[]=${lineItemIds}`, + adminHeaders + ) + ).data.reservations + + // reservations are restored to the initial quantities + expect(reservations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + line_item_id: onePackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 2, + }), + expect.objectContaining({ + line_item_id: twoPackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 4, + }), + expect.objectContaining({ + line_item_id: threePackItemId, + inventory_item_id: inventoryItemBottle.id, + quantity: 6, + }), + ]) + ) + + // inventory is back to the initial quantities + expect(reservations[0].inventory_item).toEqual( + expect.objectContaining({ + id: inventoryItemBottle.id, + reserved_quantity: 12, + stocked_quantity: 100, + }) + ) + + const finalOrder = ( + await api.get(`/admin/orders/${bottleOrder.id}`, adminHeaders) + ).data.order + + expect(finalOrder.fulfillments.every((f) => f.canceled_at)).toEqual( + true + ) + expect( + finalOrder.items.every((i) => i.detail.fulfilled_quantity === 0) + ).toEqual(true) + }) + it("should throw an error if the quantity to fulfill exceeds the reserved quantity (inventory kit case)", async () => { let reservations = (await api.get(`/admin/reservations`, adminHeaders)) .data.reservations diff --git a/packages/core/core-flows/src/order/workflows/cancel-order-fulfillment.ts b/packages/core/core-flows/src/order/workflows/cancel-order-fulfillment.ts index 6546e0eba9..13788819ec 100644 --- a/packages/core/core-flows/src/order/workflows/cancel-order-fulfillment.ts +++ b/packages/core/core-flows/src/order/workflows/cancel-order-fulfillment.ts @@ -215,11 +215,6 @@ function prepareInventoryUpdate({ return acc }, {}) - const reservationMap = reservations.reduce((acc, reservation) => { - acc[reservation.inventory_item_id as string] = reservation - return acc - }, {}) - for (const fulfillmentItem of fulfillment.items) { // if this is `null` this means that item is from variant that has `manage_inventory` false if (!fulfillmentItem.inventory_item_id) { @@ -228,24 +223,36 @@ function prepareInventoryUpdate({ const orderItem = orderItemsMap[fulfillmentItem.line_item_id as string] - orderItem?.variant?.inventory_items.forEach((iitem) => { - const reservation = - reservationMap[fulfillmentItem.inventory_item_id as string] + const iitem = orderItem?.variant?.inventory_items.find( + (i) => i.inventory.id === fulfillmentItem.inventory_item_id + ) - if (!reservation) { - toCreate.push({ - inventory_item_id: iitem.inventory.id, - location_id: fulfillment.location_id, - quantity: fulfillmentItem.quantity, // <- this is the inventory quantity that is being fulfilled so it menas it does include the required quantity - line_item_id: fulfillmentItem.line_item_id as string, - }) - } else { - toUpdate.push({ - id: reservation.id, - quantity: reservation.quantity + fulfillmentItem.quantity, - }) - } - }) + if (!iitem) { + continue + } + + const reservation = reservations.find( + (r) => + r.inventory_item_id === iitem.inventory.id && + r.line_item_id === fulfillmentItem.line_item_id + ) + + if (!reservation) { + toCreate.push({ + inventory_item_id: iitem.inventory.id, + location_id: fulfillment.location_id, + quantity: fulfillmentItem.quantity, // <- this is the inventory quantity that is being fulfilled so it means it does include the required quantity + line_item_id: fulfillmentItem.line_item_id as string, + }) + } else { + toUpdate.push({ + id: reservation.id, + quantity: MathBN.add( + reservation.quantity, + fulfillmentItem.quantity + ) as BigNumberInput, + }) + } inventoryAdjustment.push({ inventory_item_id: fulfillmentItem.inventory_item_id as string, @@ -309,6 +316,8 @@ export const cancelOrderFulfillmentWorkflow = createWorkflow( "items.variant.inventory_items.inventory.id", "items.variant.inventory_items.required_quantity", "fulfillments.id", + "fulfillments.canceled_at", + "fulfillments.shipped_at", "fulfillments.location_id", "fulfillments.items.id", "fulfillments.items.quantity",