diff --git a/.changeset/swift-cycles-judge.md b/.changeset/swift-cycles-judge.md new file mode 100644 index 0000000000..e9d7ec6967 --- /dev/null +++ b/.changeset/swift-cycles-judge.md @@ -0,0 +1,5 @@ +--- +"@medusajs/core-flows": patch +--- + +fix(core-flows): recreate fulfilment reservations on cancelation, handle inventory kits in fulfillment flows diff --git a/integration-tests/http/__tests__/order/admin/order.spec.ts b/integration-tests/http/__tests__/order/admin/order.spec.ts index bb8b26bc83..a28af07867 100644 --- a/integration-tests/http/__tests__/order/admin/order.spec.ts +++ b/integration-tests/http/__tests__/order/admin/order.spec.ts @@ -3,6 +3,8 @@ import { ModuleRegistrationName } from "@medusajs/utils" import { adminHeaders, createAdminUser, + generatePublishableKey, + generateStoreHeaders, } from "../../../../helpers/create-admin-user" import { setupTaxStructure } from "../../../../modules/__tests__/fixtures" import { createOrderSeeder } from "../../fixtures/order" @@ -1008,6 +1010,464 @@ medusaIntegrationTestRunner({ }) }) + describe("POST /orders/:id/fulfillments/:id/cancel", () => { + let inventoryItemDesk + let inventoryItemLeg + + beforeEach(async () => { + const container = getContainer() + + const publishableKey = await generatePublishableKey(container) + + const storeHeaders = generateStoreHeaders({ + publishableKey, + }) + + const region = ( + await api.post( + "/admin/regions", + { name: "Test region", currency_code: "usd" }, + adminHeaders + ) + ).data.region + + const salesChannel = ( + await api.post( + "/admin/sales-channels", + { name: "first channel", description: "channel" }, + adminHeaders + ) + ).data.sales_channel + + const stockLocation = ( + await api.post( + `/admin/stock-locations`, + { name: "test location" }, + adminHeaders + ) + ).data.stock_location + + inventoryItemDesk = ( + await api.post( + `/admin/inventory-items`, + { sku: "table-desk" }, + adminHeaders + ) + ).data.inventory_item + + inventoryItemLeg = ( + await api.post( + `/admin/inventory-items`, + { sku: "table-leg" }, + adminHeaders + ) + ).data.inventory_item + + await api.post( + `/admin/inventory-items/${inventoryItemDesk.id}/location-levels`, + { + location_id: stockLocation.id, + stocked_quantity: 10, + }, + adminHeaders + ) + + await api.post( + `/admin/inventory-items/${inventoryItemLeg.id}/location-levels`, + { + location_id: stockLocation.id, + stocked_quantity: 40, + }, + adminHeaders + ) + + await api.post( + `/admin/stock-locations/${stockLocation.id}/sales-channels`, + { add: [salesChannel.id] }, + adminHeaders + ) + + const shippingProfile = ( + await api.post( + `/admin/shipping-profiles`, + { name: `test-${stockLocation.id}`, type: "default" }, + adminHeaders + ) + ).data.shipping_profile + + const product = ( + await api.post( + "/admin/products", + { + title: `Wooden table`, + shipping_profile_id: shippingProfile.id, + options: [{ title: "color", values: ["green"] }], + variants: [ + { + title: "Green table", + sku: "green-table", + inventory_items: [ + { + inventory_item_id: inventoryItemDesk.id, + required_quantity: 1, + }, + { + inventory_item_id: inventoryItemLeg.id, + required_quantity: 4, + }, + ], + prices: [ + { + currency_code: "usd", + amount: 100, + }, + ], + options: { + color: "green", + }, + }, + ], + }, + adminHeaders + ) + ).data.product + + const fulfillmentSets = ( + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`, + { + name: `Test-${shippingProfile.id}`, + type: "test-type", + }, + adminHeaders + ) + ).data.stock_location.fulfillment_sets + + const fulfillmentSet = ( + await api.post( + `/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`, + { + name: `Test-${shippingProfile.id}`, + geo_zones: [{ type: "country", country_code: "us" }], + }, + adminHeaders + ) + ).data.fulfillment_set + + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-providers`, + { add: ["manual_test-provider"] }, + adminHeaders + ) + + const shippingOption = ( + await api.post( + `/admin/shipping-options`, + { + name: `Test shipping option ${fulfillmentSet.id}`, + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: "manual_test-provider", + price_type: "flat", + type: { + label: "Test type", + description: "Test description", + code: "test-code", + }, + prices: [ + { currency_code: "usd", amount: 1000 }, + { region_id: region.id, amount: 1100 }, + ], + rules: [], + }, + adminHeaders + ) + ).data.shipping_option + + const cart = ( + 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: product.variants[0].id }], + }, + storeHeaders + ) + ).data.cart + + await api.post( + `/store/carts/${cart.id}/shipping-methods`, + { option_id: shippingOption.id }, + storeHeaders + ) + + const paymentCollection = ( + await api.post( + `/store/payment-collections`, + { + cart_id: cart.id, + }, + storeHeaders + ) + ).data.payment_collection + + await api.post( + `/store/payment-collections/${paymentCollection.id}/payment-sessions`, + { provider_id: "pp_system_default" }, + storeHeaders + ) + + order = ( + await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders) + ).data.order + }) + + it("should correctly manage reservations when canceling a fulfillment (with inventory kit)", async () => { + let reservations = (await api.get(`/admin/reservations`, adminHeaders)) + .data.reservations + + expect(reservations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: inventoryItemDesk.id, + quantity: 2, + }), + expect.objectContaining({ + inventory_item_id: inventoryItemLeg.id, + quantity: 8, + }), + ]) + ) + + // 1. create a partial fulfillment + const fulOrder = ( + await api.post( + `/admin/orders/${order.id}/fulfillments?fields=*fulfillments,*fulfillments.items`, + { + items: [{ id: order.items[0].id, quantity: 1 }], + }, + adminHeaders + ) + ).data.order + + // 2. two fulfillment items are created for a single (inventory kit) line item + expect(fulOrder.fulfillments[0].items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: inventoryItemDesk.id, + quantity: 1, + }), + expect.objectContaining({ + inventory_item_id: inventoryItemLeg.id, + quantity: 4, + }), + ]) + ) + + expect(fulOrder.items[0].detail.fulfilled_quantity).toEqual(1) + + reservations = (await api.get(`/admin/reservations`, 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( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: inventoryItemDesk.id, + quantity: 1, + }), + expect.objectContaining({ + inventory_item_id: inventoryItemLeg.id, + quantity: 4, + }), + ]) + ) + + const { data } = await api.post( + `/admin/orders/${fulOrder.id}/fulfillments/${fulOrder.fulfillments[0].id}/cancel?fields=*fulfillments,*fulfillments.items`, + {}, + adminHeaders + ) + + 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 + + // 4. reservation qunatities are restored after partial fulfillment is canceled + expect(reservations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: inventoryItemDesk.id, + quantity: 2, + }), + expect.objectContaining({ + inventory_item_id: inventoryItemLeg.id, + quantity: 8, + }), + ]) + ) + + // 5. create a fullfillment for the entier quantity + const fulOrderFull = ( + await api.post( + `/admin/orders/${order.id}/fulfillments?fields=*fulfillments,*fulfillments.items`, + { + items: [{ id: order.items[0].id, quantity: 2 }], + }, + adminHeaders + ) + ).data.order + + reservations = (await api.get(`/admin/reservations`, adminHeaders)).data + .reservations + + // 6. no more reservations since the entier quantity is fulfilled + expect(reservations).toEqual([]) + + expect( + fulOrderFull.fulfillments.find((f) => !f.canceled_at)!.items + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: inventoryItemDesk.id, + quantity: 2, + }), + expect.objectContaining({ + inventory_item_id: inventoryItemLeg.id, + quantity: 8, + }), + ]) + ) + + expect(fulOrderFull.items[0].detail.fulfilled_quantity).toEqual(2) + + // 7. cancel the entire fulfillment once again + await api.post( + `/admin/orders/${fulOrderFull.id}/fulfillments/${ + fulOrderFull.fulfillments.find((f) => !f.canceled_at)!.id + }/cancel?fields=*fulfillments,*fulfillments.items`, + {}, + adminHeaders + ) + + reservations = (await api.get(`/admin/reservations`, adminHeaders)).data + .reservations + + // 8. reservation need to be restored to the initiall quantities + expect(reservations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: inventoryItemDesk.id, + quantity: 2, + }), + expect.objectContaining({ + inventory_item_id: inventoryItemLeg.id, + quantity: 8, + }), + ]) + ) + }) + + 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 + + expect(reservations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: inventoryItemDesk.id, + quantity: 2, + }), + expect.objectContaining({ + inventory_item_id: inventoryItemLeg.id, + quantity: 8, + }), + ]) + ) + + // 1. create a partial fulfillment + const fulOrder = ( + await api.post( + `/admin/orders/${order.id}/fulfillments?fields=*fulfillments,*fulfillments.items`, + { + items: [{ id: order.items[0].id, quantity: 1 }], + }, + adminHeaders + ) + ).data.order + + // 2. two fulfillment items are created for a single (inventory kit) line item + expect(fulOrder.fulfillments[0].items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: inventoryItemDesk.id, + quantity: 1, + }), + expect.objectContaining({ + inventory_item_id: inventoryItemLeg.id, + quantity: 4, + }), + ]) + ) + + expect(fulOrder.items[0].detail.fulfilled_quantity).toEqual(1) + + reservations = (await api.get(`/admin/reservations`, 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( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: inventoryItemDesk.id, + quantity: 1, + }), + expect.objectContaining({ + inventory_item_id: inventoryItemLeg.id, + quantity: 4, + }), + ]) + ) + + const res = await api + .post( + `/admin/orders/${order.id}/fulfillments?fields=*fulfillments,*fulfillments.items`, + { + items: [{ id: order.items[0].id, quantity: 2 }], + }, + adminHeaders + ) + .catch((e) => e) + + expect(res.response.status).toBe(400) + expect(res.response.data).toEqual({ + type: "invalid_data", + message: `Quantity to fulfill exceeds the reserved quantity for the item: ${order.items[0].id}`, + }) + }) + }) + describe("POST /orders/:id/fulfillments/:id/mark-as-delivered", () => { beforeEach(async () => { seeder = await createOrderSeeder({ api, container: getContainer() }) diff --git a/packages/core/core-flows/src/order/utils/build-reservations-map.ts b/packages/core/core-flows/src/order/utils/build-reservations-map.ts new file mode 100644 index 0000000000..46b702a589 --- /dev/null +++ b/packages/core/core-flows/src/order/utils/build-reservations-map.ts @@ -0,0 +1,21 @@ +import { ReservationItemDTO } from "@medusajs/types" + +/** + * Builds a map of reservations by line item id. + * + * @param reservations - The reservations to build the map from. + * @returns A map of reservations by line item id. + */ +export function buildReservationsMap(reservations: ReservationItemDTO[]) { + const map = new Map() + + for (const reservation of reservations) { + if (map.has(reservation.line_item_id as string)) { + map.get(reservation.line_item_id as string)!.push(reservation) + } else { + map.set(reservation.line_item_id as string, [reservation]) + } + } + + return map +} 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 b2f9ad34e8..81bb80a965 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 @@ -2,10 +2,15 @@ import { AdditionalData, BigNumberInput, FulfillmentDTO, + InventoryItemDTO, OrderDTO, + OrderLineItemDTO, OrderWorkflow, + ProductVariantDTO, + ReservationItemDTO, } from "@medusajs/framework/types" import { + MathBN, MedusaError, Modules, OrderWorkflowEvents, @@ -27,6 +32,19 @@ import { throwIfItemsDoesNotExistsInOrder, throwIfOrderIsCancelled, } from "../utils/order-validation" +import { createReservationsStep } from "../../reservation" +import { updateReservationsStep } from "../../reservation" + +type OrderItemWithVariantDTO = OrderLineItemDTO & { + variant?: ProductVariantDTO & { + inventory_items: { + inventory: InventoryItemDTO + variant_id: string + inventory_item_id: string + required_quantity: number + }[] + } +} /** * The data to validate the order fulfillment cancelation. @@ -90,6 +108,13 @@ export const cancelOrderFulfillmentValidateOrder = createStep( ) } + if (fulfillment.canceled_at) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "The fulfillment is already canceled" + ) + } + if (fulfillment.shipped_at) { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, @@ -114,14 +139,47 @@ function prepareCancelOrderFulfillmentData({ order: OrderDTO fulfillment: FulfillmentDTO }) { + const lineItemIds = new Array( + ...new Set(fulfillment.items.map((i) => i.line_item_id)) + ) + return { order_id: order.id, reference: Modules.FULFILLMENT, reference_id: fulfillment.id, - items: fulfillment.items!.map((i) => { + items: lineItemIds.map((lineItemId) => { + // find order item + const orderItem = order.items!.find( + (i) => i.id === lineItemId + ) as OrderItemWithVariantDTO + // find inventory items + const iitems = orderItem!.variant?.inventory_items + // find fulfillment item + const fitem = fulfillment.items.find( + (i) => i.line_item_id === lineItemId + )! + + let quantity: BigNumberInput = fitem.quantity + + // NOTE: if the order item has an inventory kit or `required_qunatity` > 1, fulfillment items wont't match 1:1 with order items. + // - for each inventory item in the kit, a fulfillment item will be created i.e. one line item could have multiple fulfillment items + // - the quantity of the fulfillment item will be the quantity of the order item multiplied by the required quantity of the inventory item + // + // We need to take this into account when canceling the fulfillment to compute quantity of line items not being fulfilled based on fulfillment items and qunatities. + // NOTE: for now we only need to find one inventory item of a line item to compute this since when a fulfillment is created all inventory items are fulfilled together. + // If we allow to cancel partial fulfillments for an order item, we need to change this. + // + if (iitems?.length) { + const iitem = iitems.find( + (i) => i.inventory.id === fitem.inventory_item_id + ) + + quantity = MathBN.div(quantity, iitem!.required_quantity) + } + return { - id: i.line_item_id as string, - quantity: i.quantity, + id: lineItemId as string, + quantity, } }), } @@ -129,29 +187,74 @@ function prepareCancelOrderFulfillmentData({ function prepareInventoryUpdate({ fulfillment, + reservations, + order, }: { - order: OrderDTO fulfillment: FulfillmentDTO + reservations: ReservationItemDTO[] + order: any }) { const inventoryAdjustment: { inventory_item_id: string location_id: string adjustment: BigNumberInput }[] = [] - for (const item of fulfillment.items) { + const toCreate: { + inventory_item_id: string + location_id: string + quantity: BigNumberInput + }[] = [] + const toUpdate: { + id: string + quantity: BigNumberInput + }[] = [] + + const orderItemsMap = order.items!.reduce((acc, item) => { + acc[item.id] = item + 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 (!item.inventory_item_id) { + if (!fulfillmentItem.inventory_item_id) { continue } + const orderItem = orderItemsMap[fulfillmentItem.line_item_id as string] + + orderItem?.variant?.inventory_items.forEach((iitem) => { + const reservation = + reservationMap[fulfillmentItem.inventory_item_id as string] + + 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 + }) + } else { + toUpdate.push({ + id: reservation.id, + quantity: reservation.quantity + fulfillmentItem.quantity, + }) + } + }) + inventoryAdjustment.push({ - inventory_item_id: item.inventory_item_id as string, + inventory_item_id: fulfillmentItem.inventory_item_id as string, location_id: fulfillment.location_id, - adjustment: item.quantity, + adjustment: fulfillmentItem.quantity, }) } return { + toCreate, + toUpdate, inventoryAdjustment, } } @@ -198,9 +301,17 @@ export const cancelOrderFulfillmentWorkflow = createWorkflow( fields: [ "id", "status", - "items.*", - "fulfillments.*", - "fulfillments.items.*", + "items.id", + "items.quantity", + "items.variant.manage_inventory", + "items.variant.inventory_items.inventory.id", + "items.variant.inventory_items.required_quantity", + "fulfillments.id", + "fulfillments.location_id", + "fulfillments.items.id", + "fulfillments.items.quantity", + "fulfillments.items.line_item_id", + "fulfillments.items.inventory_item_id", ], variables: { id: input.order_id }, list: false, @@ -213,16 +324,38 @@ export const cancelOrderFulfillmentWorkflow = createWorkflow( return order.fulfillments.find((f) => f.id === input.fulfillment_id)! }) + const lineItemIds = transform({ fulfillment }, ({ fulfillment }) => { + return fulfillment.items.map((i) => i.line_item_id) + }) + + const reservations = useRemoteQueryStep({ + entry_point: "reservations", + fields: [ + "id", + "line_item_id", + "quantity", + "inventory_item_id", + "location_id", + ], + variables: { + filter: { + line_item_id: lineItemIds, + }, + }, + }).config({ name: "get-reservations" }) + const cancelOrderFulfillmentData = transform( { order, fulfillment }, prepareCancelOrderFulfillmentData ) - const { inventoryAdjustment } = transform( - { order, fulfillment }, + const { toCreate, toUpdate, inventoryAdjustment } = transform( + { order, fulfillment, reservations }, prepareInventoryUpdate ) + adjustInventoryLevelsStep(inventoryAdjustment) + const eventData = transform({ order, fulfillment, input }, (data) => { return { order_id: data.order.id, @@ -233,7 +366,8 @@ export const cancelOrderFulfillmentWorkflow = createWorkflow( parallelize( cancelOrderFulfillmentStep(cancelOrderFulfillmentData), - adjustInventoryLevelsStep(inventoryAdjustment), + createReservationsStep(toCreate), + updateReservationsStep(toUpdate), emitEventStep({ eventName: OrderWorkflowEvents.FULFILLMENT_CANCELED, data: eventData, diff --git a/packages/core/core-flows/src/order/workflows/create-fulfillment.ts b/packages/core/core-flows/src/order/workflows/create-fulfillment.ts index bb71875344..47370d2376 100644 --- a/packages/core/core-flows/src/order/workflows/create-fulfillment.ts +++ b/packages/core/core-flows/src/order/workflows/create-fulfillment.ts @@ -2,10 +2,14 @@ import { AdditionalData, BigNumberInput, FulfillmentWorkflow, + InventoryItemDTO, OrderDTO, OrderLineItemDTO, OrderWorkflow, + ProductDTO, + ProductVariantDTO, ReservationItemDTO, + ShippingProfileDTO, } from "@medusajs/framework/types" import { MathBN, @@ -39,6 +43,21 @@ import { throwIfItemsDoesNotExistsInOrder, throwIfOrderIsCancelled, } from "../utils/order-validation" +import { buildReservationsMap } from "../utils/build-reservations-map" + +type OrderItemWithVariantDTO = OrderLineItemDTO & { + variant?: ProductVariantDTO & { + product?: ProductDTO & { + shipping_profile?: ShippingProfileDTO + } + inventory_items: { + inventory: InventoryItemDTO + variant_id: string + inventory_item_id: string + required_quantity: number + }[] + } +} /** * The data to validate the order fulfillment creation. @@ -143,9 +162,7 @@ function prepareFulfillmentData({ (itemsList ?? order.items)!.map((i) => [i.id, i]) ) - const reservationItemMap = new Map( - reservations.map((r) => [r.line_item_id as string, r]) - ) + const reservationItemMap = buildReservationsMap(reservations) // Note: If any of the items require shipping, we enable fulfillment // unless explicitly set to not require shipping by the item in the request @@ -157,31 +174,59 @@ function prepareFulfillmentData({ }) : true - const fulfillmentItems = fulfillableItems.map((i) => { - const orderItem = orderItemsMap.get(i.id)! - const reservation = reservationItemMap.get(i.id)! + const fulfillmentItems = fulfillableItems + .map((i) => { + const orderItem = orderItemsMap.get(i.id)! as OrderItemWithVariantDTO + const reservations = reservationItemMap.get(i.id) - if ( - orderItem.requires_shipping && - (orderItem as any).variant?.product && - (orderItem as any).variant?.product.shipping_profile?.id !== - shippingOption.shipping_profile_id - ) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Shipping profile ${shippingOption.shipping_profile_id} does not match the shipping profile of the order item ${orderItem.id}` - ) - } + if ( + orderItem.requires_shipping && + orderItem.variant?.product && + orderItem.variant?.product.shipping_profile?.id !== + shippingOption.shipping_profile_id + ) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Shipping profile ${shippingOption.shipping_profile_id} does not match the shipping profile of the order item ${orderItem.id}` + ) + } - return { - line_item_id: i.id, - inventory_item_id: reservation?.inventory_item_id, - quantity: i.quantity, - title: orderItem.variant_title ?? orderItem.title, - sku: orderItem.variant_sku || "", - barcode: orderItem.variant_barcode || "", - } as FulfillmentWorkflow.CreateFulfillmentItemWorkflowDTO - }) + if (!reservations?.length) { + return [ + { + line_item_id: i.id, + inventory_item_id: undefined, + quantity: i.quantity, + title: orderItem.variant_title ?? orderItem.title, + sku: orderItem.variant_sku || "", + barcode: orderItem.variant_barcode || "", + }, + ] as FulfillmentWorkflow.CreateFulfillmentItemWorkflowDTO[] + } + + // if line item is from a managed variant, create a fulfillment item for each reservation item + return reservations.map((r) => { + const iItem = orderItem?.variant?.inventory_items.find( + (ii) => ii.inventory.id === r.inventory_item_id + ) + + return { + line_item_id: i.id, + inventory_item_id: r.inventory_item_id, + quantity: MathBN.mult( + iItem?.required_quantity ?? 1, + i.quantity + ) as BigNumberInput, + title: + iItem?.inventory.title || + orderItem.variant_title || + orderItem.title, + sku: iItem?.inventory.sku || orderItem.variant_sku || "", + barcode: orderItem.variant_barcode || "", + } as FulfillmentWorkflow.CreateFulfillmentItemWorkflowDTO + }) + }) + .flat() let locationId: string | undefined | null = input.location_id @@ -223,11 +268,6 @@ function prepareInventoryUpdate({ inputItemsMap, itemsList, }) { - const reservationMap = reservations.reduce((acc, reservation) => { - acc[reservation.line_item_id as string] = reservation - return acc - }, {}) - const toDelete: string[] = [] const toUpdate: { id: string @@ -240,14 +280,21 @@ function prepareInventoryUpdate({ adjustment: BigNumberInput }[] = [] + const orderItemsMap = new Map["items"][0]>( + (itemsList ?? order.items)!.map((i) => [i.id, i]) + ) + + const reservationMap = buildReservationsMap(reservations) + const allItems = itemsList ?? order.items const itemsToFulfill = allItems.filter((i) => i.id in inputItemsMap) // iterate over items that are being fulfilled for (const item of itemsToFulfill) { - const reservation = reservationMap[item.id] + const reservations = reservationMap.get(item.id) + const orderItem = orderItemsMap.get(item.id)! as OrderItemWithVariantDTO - if (!reservation) { + if (!reservations?.length) { if (item.variant?.manage_inventory) { throw new Error( `No stock reservation found for item ${item.id} - ${item.title} (${item.variant_title})` @@ -258,32 +305,45 @@ function prepareInventoryUpdate({ const inputQuantity = inputItemsMap[item.id]?.quantity ?? item.quantity - if (MathBN.gt(inputQuantity, reservation.quantity)) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Quantity to fulfill exceeds the reserved quantity for the item: ${item.id}` + reservations.forEach((reservation) => { + if (MathBN.gt(inputQuantity, reservation.quantity)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Quantity to fulfill exceeds the reserved quantity for the item: ${item.id}` + ) + } + + const iItem = orderItem?.variant?.inventory_items.find( + (ii) => ii.inventory.id === reservation.inventory_item_id ) - } - const remainingReservationQuantity = reservation.quantity - inputQuantity + const adjustemntQuantity = MathBN.mult( + inputQuantity, + iItem?.required_quantity ?? 1 + ) - inventoryAdjustment.push({ - inventory_item_id: reservation.inventory_item_id, - location_id: input.location_id ?? reservation.location_id, - adjustment: MathBN.mult(inputQuantity, -1), - }) + const remainingReservationQuantity = MathBN.sub( + reservation.quantity, + adjustemntQuantity + ) - if (remainingReservationQuantity === 0) { - toDelete.push(reservation.id) - } else { - toUpdate.push({ - id: reservation.id, - quantity: remainingReservationQuantity, + inventoryAdjustment.push({ + inventory_item_id: reservation.inventory_item_id, location_id: input.location_id ?? reservation.location_id, + adjustment: MathBN.mult(adjustemntQuantity, -1), }) - } - } + if (MathBN.eq(remainingReservationQuantity, 0)) { + toDelete.push(reservation.id) + } else { + toUpdate.push({ + id: reservation.id, + quantity: remainingReservationQuantity, + location_id: input.location_id ?? reservation.location_id, + }) + } + }) + } return { toDelete, toUpdate, @@ -350,6 +410,10 @@ export const createOrderFulfillmentWorkflow = createWorkflow( "items.variant.width", "items.variant.material", "items.variant_title", + "items.variant.inventory_items.required_quantity", + "items.variant.inventory_items.inventory.id", + "items.variant.inventory_items.inventory.title", + "items.variant.inventory_items.inventory.sku", "shipping_address.*", "shipping_methods.id", "shipping_methods.shipping_option_id", @@ -406,6 +470,7 @@ export const createOrderFulfillmentWorkflow = createWorkflow( .filter((i) => i in inputItemsMap) } ) + const reservations = useRemoteQueryStep({ entry_point: "reservations", fields: [