diff --git a/.changeset/modern-apricots-chew.md b/.changeset/modern-apricots-chew.md new file mode 100644 index 0000000000..de2346fe04 --- /dev/null +++ b/.changeset/modern-apricots-chew.md @@ -0,0 +1,5 @@ +--- +"@medusajs/core-flows": patch +--- + +fix(core-flows): handle inventory kit items in mark-as-shipped and mark-as-delivered flows diff --git a/integration-tests/http/__tests__/order/admin/order.spec.ts b/integration-tests/http/__tests__/order/admin/order.spec.ts index a28af07867..339262920c 100644 --- a/integration-tests/http/__tests__/order/admin/order.spec.ts +++ b/integration-tests/http/__tests__/order/admin/order.spec.ts @@ -1534,6 +1534,367 @@ medusaIntegrationTestRunner({ message: "Fulfillment has already been marked delivered", }) }) + + describe("with inventory kit items", () => { + 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 }], + 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("set correct quantity as delivered on the line item when marking fulfillment as delivered", 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) + + // 3. mark the fulfillment as shipped + const shippedOrder = ( + await api.post( + `/admin/orders/${fulOrder.id}/fulfillments/${fulOrder.fulfillments[0].id}/shipments`, + { + items: [ + { + id: fulOrder.items[0].id, + quantity: 1, + }, + ], + }, + adminHeaders + ) + ).data.order + + expect(shippedOrder.items[0].detail.fulfilled_quantity).toEqual(1) + expect(shippedOrder.items[0].detail.shipped_quantity).toEqual(1) + + // 4. mark the fulfillment as delivered + const deliveredOrder = ( + await api.post( + `/admin/orders/${fulOrder.id}/fulfillments/${fulOrder.fulfillments[0].id}/mark-as-delivered`, + {}, + adminHeaders + ) + ).data.order + + // 5. 1 line item was fulfilled so 1 line item is delivered + expect(deliveredOrder.items[0].detail.fulfilled_quantity).toEqual(1) + expect(deliveredOrder.items[0].detail.shipped_quantity).toEqual(1) + expect(deliveredOrder.items[0].detail.delivered_quantity).toEqual(1) + + // 6. repeat the same steps for the rest of the line items + + // 7. create a partial fulfillment + const fulOrder2 = ( + await api.post( + `/admin/orders/${order.id}/fulfillments?fields=*fulfillments,*fulfillments.items`, + { + items: [{ id: order.items[0].id, quantity: 1 }], + }, + adminHeaders + ) + ).data.order + + expect(fulOrder2.items[0].detail.fulfilled_quantity).toEqual(2) + + const secondFulfillment = fulOrder2.fulfillments.find( + (f) => !f.shipped_at + )! + + // 8. mark the fulfillment as shipped + const shippedOrder2 = ( + await api.post( + `/admin/orders/${fulOrder2.id}/fulfillments/${secondFulfillment.id}/shipments`, + { + items: [ + { + id: fulOrder2.items[0].id, + quantity: 1, + }, + ], + }, + adminHeaders + ) + ).data.order + + expect(shippedOrder2.items[0].detail.fulfilled_quantity).toEqual(2) + expect(shippedOrder2.items[0].detail.shipped_quantity).toEqual(2) + expect(shippedOrder2.items[0].detail.delivered_quantity).toEqual(1) + + // 9. mark the fulfillment as delivered + const deliveredOrder2 = ( + await api.post( + `/admin/orders/${fulOrder2.id}/fulfillments/${secondFulfillment.id}/mark-as-delivered`, + {}, + adminHeaders + ) + ).data.order + + // 10. both items are fulfilled, shipped and delivered + expect(deliveredOrder2.items[0].detail.fulfilled_quantity).toEqual(2) + expect(deliveredOrder2.items[0].detail.shipped_quantity).toEqual(2) + expect(deliveredOrder2.items[0].detail.delivered_quantity).toEqual(2) + }) + }) }) describe("POST /orders/:id/credit-lines", () => { 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 81bb80a965..5698307633 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 @@ -168,7 +168,7 @@ function prepareCancelOrderFulfillmentData({ // 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 diff --git a/packages/core/core-flows/src/order/workflows/create-shipment.ts b/packages/core/core-flows/src/order/workflows/create-shipment.ts index 436c69390c..69f7beb0e1 100644 --- a/packages/core/core-flows/src/order/workflows/create-shipment.ts +++ b/packages/core/core-flows/src/order/workflows/create-shipment.ts @@ -1,10 +1,14 @@ import { AdditionalData, + BigNumberInput, FulfillmentDTO, + InventoryItemDTO, OrderDTO, + OrderLineItemDTO, OrderWorkflow, + ProductVariantDTO, } from "@medusajs/framework/types" -import { FulfillmentEvents, Modules } from "@medusajs/framework/utils" +import { FulfillmentEvents, MathBN, Modules } from "@medusajs/framework/utils" import { WorkflowData, WorkflowResponse, @@ -22,9 +26,17 @@ import { throwIfOrderIsCancelled, } from "../utils/order-validation" -/** - * The data to validate the order shipment creation. - */ +type OrderItemWithVariantDTO = OrderLineItemDTO & { + variant?: ProductVariantDTO & { + inventory_items: { + inventory: InventoryItemDTO + variant_id: string + inventory_item_id: string + required_quantity: number + }[] + } +} + export type CreateShipmentValidateOrderStepInput = { /** * The order to create the shipment for. @@ -38,16 +50,16 @@ export type CreateShipmentValidateOrderStepInput = { /** * This step validates that a shipment can be created for an order. If the order is cancelled, - * the items don't exist in the order, or the fulfillment doesn't exist in the order, + * the items don't exist in the order, or the fulfillment doesn't exist in the order, * the step will throw an error. - * + * * :::note - * + * * You can retrieve an order's details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query), * or [useQueryGraphStep](https://docs.medusajs.com/resources/references/medusa-workflows/steps/useQueryGraphStep). - * + * * ::: - * + * * @example * const data = createShipmentValidateOrder({ * order: { @@ -68,10 +80,7 @@ export type CreateShipmentValidateOrderStepInput = { */ export const createShipmentValidateOrder = createStep( "create-shipment-validate-order", - ({ - order, - input, - }: CreateShipmentValidateOrderStepInput) => { + ({ order, input }: CreateShipmentValidateOrderStepInput) => { const inputItems = input.items throwIfOrderIsCancelled({ order }) @@ -100,15 +109,48 @@ function prepareRegisterShipmentData({ const order_ = order as OrderDTO & { fulfillments: FulfillmentDTO[] } const fulfillment = order_.fulfillments.find((f) => f.id === fulfillId)! + 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, created_by: input.created_by, - items: (input.items ?? order.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 creating a shipment to compute quantity of line items being shipped 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.id, - quantity: i.quantity, + id: lineItemId as string, + quantity, } }), } @@ -117,17 +159,18 @@ function prepareRegisterShipmentData({ /** * The data to create a shipment for an order, along with custom data that's passed to the workflow's hooks. */ -export type CreateOrderShipmentWorkflowInput = OrderWorkflow.CreateOrderShipmentWorkflowInput & AdditionalData +export type CreateOrderShipmentWorkflowInput = + OrderWorkflow.CreateOrderShipmentWorkflowInput & AdditionalData export const createOrderShipmentWorkflowId = "create-order-shipment" /** * This workflow creates a shipment for an order. It's used by the [Create Order Shipment Admin API Route](https://docs.medusajs.com/api/admin#orders_postordersidfulfillmentsfulfillment_idshipments). - * - * This workflow has a hook that allows you to perform custom actions on the created shipment. For example, you can pass under `additional_data` custom data that + * + * This workflow has a hook that allows you to perform custom actions on the created shipment. For example, you can pass under `additional_data` custom data that * allows you to create custom data models linked to the shipment. - * + * * You can also use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around creating a shipment. - * + * * @example * const { result } = await createOrderShipmentWorkflow(container) * .run({ @@ -145,18 +188,16 @@ export const createOrderShipmentWorkflowId = "create-order-shipment" * } * } * }) - * + * * @summary - * + * * Creates a shipment for an order. - * + * * @property hooks.shipmentCreated - This hook is executed after the shipment is created. You can consume this hook to perform custom actions on the created shipment. */ export const createOrderShipmentWorkflow = createWorkflow( createOrderShipmentWorkflowId, - ( - input: WorkflowData - ) => { + (input: WorkflowData) => { const order: OrderDTO = useRemoteQueryStep({ entry_point: "orders", fields: [ @@ -164,8 +205,16 @@ export const createOrderShipmentWorkflow = createWorkflow( "status", "region_id", "currency_code", - "items.*", + "items.id", + "items.quantity", + "items.variant.manage_inventory", + "items.variant.inventory_items.inventory.id", + "items.variant.inventory_items.required_quantity", "fulfillments.*", + "fulfillments.items.id", + "fulfillments.items.quantity", + "fulfillments.items.line_item_id", + "fulfillments.items.inventory_item_id", ], variables: { id: input.order_id }, list: false, diff --git a/packages/core/core-flows/src/order/workflows/mark-order-fulfillment-as-delivered.ts b/packages/core/core-flows/src/order/workflows/mark-order-fulfillment-as-delivered.ts index 887984bf67..0bcbb5718d 100644 --- a/packages/core/core-flows/src/order/workflows/mark-order-fulfillment-as-delivered.ts +++ b/packages/core/core-flows/src/order/workflows/mark-order-fulfillment-as-delivered.ts @@ -1,9 +1,13 @@ import { + BigNumberInput, FulfillmentDTO, + InventoryItemDTO, OrderDTO, + OrderLineItemDTO, + ProductVariantDTO, RegisterOrderDeliveryDTO, } from "@medusajs/framework/types" -import { FulfillmentEvents, Modules } from "@medusajs/framework/utils" +import { FulfillmentEvents, MathBN, Modules } from "@medusajs/framework/utils" import { WorkflowData, WorkflowResponse, @@ -20,6 +24,17 @@ import { throwIfOrderIsCancelled, } from "../utils/order-validation" +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 deliverability. */ @@ -27,7 +42,7 @@ export type OrderFulfillmentDeliverabilityValidationStepInput = { /** * The order to validate the fulfillment deliverability for. */ - order: OrderDTO & { + order: OrderDTO & { /** * The fulfillments in the order. */ @@ -45,14 +60,14 @@ export const orderFulfillmentDeliverablilityValidationStepId = * This step validates that the order fulfillment can be delivered. If the order is cancelled, * the items to mark as delivered don't exist in the order, or the fulfillment doesn't exist in the order, * the step will throw an error. - * + * * :::note - * + * * You can retrieve an order and fulfillment's details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query), * or [useQueryGraphStep](https://docs.medusajs.com/resources/references/medusa-workflows/steps/useQueryGraphStep). - * + * * ::: - * + * * @example * const data = orderFulfillmentDeliverablilityValidationStep({ * order: { @@ -113,14 +128,48 @@ function prepareRegisterDeliveryData({ (f) => f.id === fulfillment.id )! + const lineItemIds = new Array( + ...new Set(orderFulfillment.items.map((i) => i.line_item_id)) + ) + return { order_id: order.id, reference: Modules.FULFILLMENT, reference_id: orderFulfillment.id, - items: orderFulfillment.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 = orderFulfillment.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 marking the fulfillment as delivered to compute quantity of line items being delivered 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!, - quantity: i.quantity!, + id: lineItemId as string, + quantity, } }), } @@ -145,10 +194,10 @@ export const markOrderFulfillmentAsDeliveredWorkflowId = /** * This workflow marks a fulfillment in an order as delivered. It's used by the * [Mark Fulfillment as Delivered Admin API Route](https://docs.medusajs.com/api/admin#orders_postordersidfulfillmentsfulfillment_idmarkasdelivered). - * + * * You can use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around * marking a fulfillment as delivered. - * + * * @example * const { result } = await markOrderFulfillmentAsDeliveredWorkflow(container) * .run({ @@ -157,9 +206,9 @@ export const markOrderFulfillmentAsDeliveredWorkflowId = * fulfillmentId: "ful_123", * } * }) - * + * * @summary - * + * * Mark a fulfillment in an order as delivered. */ export const markOrderFulfillmentAsDeliveredWorkflow = createWorkflow( @@ -185,8 +234,12 @@ export const markOrderFulfillmentAsDeliveredWorkflow = createWorkflow( "fulfillments.items.id", "fulfillments.items.quantity", "fulfillments.items.line_item_id", + "fulfillments.items.inventory_item_id", "items.id", "items.quantity", + "items.variant.manage_inventory", + "items.variant.inventory_items.inventory.id", + "items.variant.inventory_items.required_quantity", ], variables: { id: orderId }, throw_if_key_not_found: true,