fix(core-flows): handle inventory kit items in mark-as-shipped/delivered flows (#12269)

* fix(core-flows): handle invetory kit items in mark-as-shipped and mark-as-delivered flows

* fix: typo

* chore: more assertion

* chore: fix comment

* Update packages/core/core-flows/src/order/workflows/create-shipment.ts

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>

* fix: undo comment

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Frane Polić
2025-04-26 19:38:00 +02:00
committed by GitHub
parent 43d282da8b
commit d7a273ff2d
5 changed files with 510 additions and 42 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/core-flows": patch
---
fix(core-flows): handle inventory kit items in mark-as-shipped and mark-as-delivered flows

View File

@@ -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", () => {

View File

@@ -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

View File

@@ -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<CreateOrderShipmentWorkflowInput>
) => {
(input: WorkflowData<CreateOrderShipmentWorkflowInput>) => {
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,

View File

@@ -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,