fix(core-flows): Avoid checking inventory items on fulfillment cancel for unmanaged inventory variants (#14177)

* Avoid checking existent inventory item on fulfillment cancellation for variants without managed inventory

* Add changeset

* Dismiss existent variant inventory links when updating to unmanaged inventory

* Update input type and step name

* Dismiss inventory when variant is updated to unmanaged inventory

* Review changes

* Fix

* Fix

* Comments

* Include Map to avoid iterating unnecessarily
This commit is contained in:
Nicolas Gorga
2026-01-05 12:11:23 -03:00
committed by GitHub
parent 4bc15b4dc4
commit a464e9d907
8 changed files with 409 additions and 6 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/core-flows": patch
---
fix(core-flows): Avoid checking inventory items on fulfillment cancel for unmanaged inventory variants

View File

@@ -2356,6 +2356,125 @@ medusaIntegrationTestRunner({
)
})
it("should update variant to manage_inventory false and unlink inventory items", async () => {
const inventoryItem1 = (
await api.post(
`/admin/inventory-items`,
{ sku: "inventory-item-1" },
adminHeaders
)
).data.inventory_item
const inventoryItem2 = (
await api.post(
`/admin/inventory-items`,
{ sku: "inventory-item-2" },
adminHeaders
)
).data.inventory_item
const createPayload = {
title: "Test product with inventory",
handle: "test-product-inventory",
options: [{ title: "size", values: ["large"] }],
shipping_profile_id: shippingProfile.id,
variants: [
{
title: "Variant with inventory",
prices: [{ currency_code: "usd", amount: 100 }],
manage_inventory: true,
options: { size: "large" },
inventory_items: [
{
inventory_item_id: inventoryItem1.id,
required_quantity: 5,
},
{
inventory_item_id: inventoryItem2.id,
required_quantity: 10,
},
],
},
],
}
const createdProduct = (
await api.post("/admin/products", createPayload, {
...adminHeaders,
params: {
fields:
"variants.inventory_items.*,variants.inventory_items.inventory.*",
},
})
).data.product
const variantWithInventory = createdProduct.variants[0]
expect(variantWithInventory.manage_inventory).toBe(true)
expect(variantWithInventory.inventory_items).toHaveLength(2)
expect(variantWithInventory.inventory_items).toEqual(
expect.arrayContaining([
expect.objectContaining({
inventory_item_id: inventoryItem1.id,
required_quantity: 5,
}),
expect.objectContaining({
inventory_item_id: inventoryItem2.id,
required_quantity: 10,
}),
])
)
const updatePayload = {
variants: [
{
id: variantWithInventory.id,
manage_inventory: false,
},
],
}
const updatedProduct = (
await api.post(
`/admin/products/${createdProduct.id}`,
updatePayload,
{
...adminHeaders,
params: {
fields:
"variants.inventory_items.*,variants.inventory_items.inventory.*",
},
}
)
).data.product
const updatedVariant = updatedProduct.variants.find(
(v) => v.id === variantWithInventory.id
)
expect(updatedVariant.manage_inventory).toBe(false)
expect(updatedVariant.inventory_items).toHaveLength(0)
expect(updatedVariant.inventory_items).toEqual([])
const inventoryItem1Response = await api.get(
`/admin/inventory-items/${inventoryItem1.id}`,
adminHeaders
)
const inventoryItem2Response = await api.get(
`/admin/inventory-items/${inventoryItem2.id}`,
adminHeaders
)
expect(inventoryItem1Response.status).toEqual(200)
expect(inventoryItem2Response.status).toEqual(200)
expect(inventoryItem1Response.data.inventory_item.id).toBe(
inventoryItem1.id
)
expect(inventoryItem2Response.data.inventory_item.id).toBe(
inventoryItem2.id
)
})
it("updates products sales channels", async () => {
const salesChannel1 = (
await api.post(

View File

@@ -649,6 +649,123 @@ medusaIntegrationTestRunner({
})
})
describe("POST /admin/products/:id/variants/:variant_id", () => {
it("should update variant to manage_inventory false and unlink inventory items", async () => {
const inventoryItem1 = (
await api.post(
`/admin/inventory-items`,
{ sku: "inventory-item-variant-1" },
adminHeaders
)
).data.inventory_item
const inventoryItem2 = (
await api.post(
`/admin/inventory-items`,
{ sku: "inventory-item-variant-2" },
adminHeaders
)
).data.inventory_item
const createPayload = {
title: "Test product for variant update",
handle: "test-product-variant-update",
options: [{ title: "size", values: ["large"] }],
shipping_profile_id: shippingProfile.id,
variants: [
{
title: "Variant with inventory",
prices: [{ currency_code: "usd", amount: 100 }],
manage_inventory: true,
options: { size: "large" },
inventory_items: [
{
inventory_item_id: inventoryItem1.id,
required_quantity: 5,
},
{
inventory_item_id: inventoryItem2.id,
required_quantity: 10,
},
],
},
],
}
const createdProduct = (
await api.post("/admin/products", createPayload, {
...adminHeaders,
params: {
fields:
"+variants.inventory_items.*,+variants.inventory_items.inventory.*",
},
})
).data.product
const variantWithInventory = createdProduct.variants[0]
expect(variantWithInventory.manage_inventory).toBe(true)
expect(variantWithInventory.inventory_items).toHaveLength(2)
expect(variantWithInventory.inventory_items).toEqual(
expect.arrayContaining([
expect.objectContaining({
inventory_item_id: inventoryItem1.id,
required_quantity: 5,
}),
expect.objectContaining({
inventory_item_id: inventoryItem2.id,
required_quantity: 10,
}),
])
)
const updatePayload = {
manage_inventory: false,
}
const updatedProduct = (
await api.post(
`/admin/products/${createdProduct.id}/variants/${variantWithInventory.id}`,
updatePayload,
{
...adminHeaders,
params: {
fields:
"+variants.inventory_items.*,+variants.inventory_items.inventory.*",
},
}
)
).data.product
const updatedVariant = updatedProduct.variants.find(
(v) => v.id === variantWithInventory.id
)
expect(updatedVariant.manage_inventory).toBe(false)
expect(updatedVariant.inventory_items).toHaveLength(0)
expect(updatedVariant.inventory_items).toEqual([])
const inventoryItem1Response = await api.get(
`/admin/inventory-items/${inventoryItem1.id}`,
adminHeaders
)
const inventoryItem2Response = await api.get(
`/admin/inventory-items/${inventoryItem2.id}`,
adminHeaders
)
expect(inventoryItem1Response.status).toEqual(200)
expect(inventoryItem2Response.status).toEqual(200)
expect(inventoryItem1Response.data.inventory_item.id).toBe(
inventoryItem1.id
)
expect(inventoryItem2Response.data.inventory_item.id).toBe(
inventoryItem2.id
)
})
})
describe("POST /admin/products/:id/variants/:variant_id/inventory-items", () => {
it("should throw an error when required attributes are not passed", async () => {
const { response } = await api

View File

@@ -160,7 +160,9 @@ function prepareCancelOrderFulfillmentData({
(i) => i.id === lineItemId
) as OrderItemWithVariantDTO
// find inventory items
const iitems = orderItem!.variant?.inventory_items
const iitems = orderItem!.variant?.manage_inventory
? orderItem!.variant?.inventory_items
: undefined
// find fulfillment item
const fitem = fulfillment.items.find(
(i) => i.line_item_id === lineItemId

View File

@@ -0,0 +1,100 @@
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
import { Link, Query } from "@medusajs/framework/modules-sdk"
import { LinkDefinition } from "@medusajs/types"
export const dismissProductVariantsInventoryStepId =
"dismiss-product-variants-inventory"
export type DismissProductVariantsInventoryStepInput = {
variantIds: string[]
}
async function dismissVariantsInventory(
variantIds: string[],
query: Query,
link: Link
): Promise<Record<string, string[]>> {
const dismissedVariantInventoryItems: Record<string, string[]> = {}
if (!variantIds.length) {
return dismissedVariantInventoryItems
}
const { data: variantInventoryItems } = await query.graph({
entity: "product_variant_inventory_item",
fields: ["inventory_item_id", "variant_id"],
filters: {
variant_id: variantIds,
},
})
const variantInventoryItemsMap = new Map<string, string[]>()
for (const item of variantInventoryItems) {
variantInventoryItemsMap.set(item.variant_id, [
...(variantInventoryItemsMap.get(item.variant_id) ?? []),
item.inventory_item_id,
])
}
const dismissLinks: LinkDefinition[] = []
for (const variantId of variantIds) {
if (!variantId) {
continue
}
dismissedVariantInventoryItems[variantId] =
variantInventoryItemsMap.get(variantId) ?? []
for (const inventoryItemId of variantInventoryItemsMap.get(variantId) ??
[]) {
dismissLinks.push({
[Modules.PRODUCT]: { variant_id: variantId },
[Modules.INVENTORY]: { inventory_item_id: inventoryItemId },
})
}
}
await link.dismiss(dismissLinks)
return dismissedVariantInventoryItems
}
export const dismissProductVariantsInventoryStep = createStep(
dismissProductVariantsInventoryStepId,
async (data: DismissProductVariantsInventoryStepInput, { container }) => {
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const link = container.resolve(ContainerRegistrationKeys.LINK)
const variantIds = data.variantIds || []
if (!variantIds.length) {
return new StepResponse(void 0)
}
const dismissedVariantInventoryItems = await dismissVariantsInventory(
variantIds,
query as Query,
link
)
return new StepResponse(void 0, dismissedVariantInventoryItems)
},
async (dismissedVariantInventoryItems, { container }) => {
if (!dismissedVariantInventoryItems) {
return
}
const linksToCreate: LinkDefinition[] = []
for (const [variantId, inventoryItemIds] of Object.entries(
dismissedVariantInventoryItems
)) {
for (const inventoryItemId of inventoryItemIds) {
linksToCreate.push({
[Modules.PRODUCT]: { variant_id: variantId },
[Modules.INVENTORY]: { inventory_item_id: inventoryItemId },
})
}
}
const link = container.resolve(ContainerRegistrationKeys.LINK)
await link.create(linksToCreate)
}
)

View File

@@ -32,3 +32,4 @@ export * from "./get-variant-availability"
export * from "./normalize-products"
export * from "./normalize-products-to-chunks"
export * from "./process-import-chunks"
export * from "./dismiss-product-variants-inventory"

View File

@@ -13,7 +13,10 @@ import {
} from "@medusajs/framework/workflows-sdk"
import { emitEventStep } from "../../common"
import { updatePriceSetsStep } from "../../pricing"
import { updateProductVariantsStep } from "../steps"
import {
dismissProductVariantsInventoryStep,
updateProductVariantsStep,
} from "../steps"
import { getVariantPricingLinkStep } from "../steps/get-variant-pricing-link"
/**
@@ -57,12 +60,12 @@ export const updateProductVariantsWorkflowId = "update-product-variants"
* allows you to update custom data models linked to the product variants.
*
* You can also use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around product-variant update.
*
*
* :::note
*
* Learn more about adding rules to the product variant's prices in the Pricing Module's
*
* Learn more about adding rules to the product variant's prices in the Pricing Module's
* [Price Rules](https://docs.medusajs.com/resources/commerce-modules/pricing/price-rules) documentation.
*
*
* :::
*
* @example
@@ -151,6 +154,32 @@ export const updateProductVariantsWorkflow = createWorkflow(
const updatedVariants = updateProductVariantsStep(updateWithoutPrices)
const variantsToDismissInventory = transform(
{ input, updatedVariants },
(data) => {
const variantIds: string[] = []
if ("product_variants" in data.input) {
for (const variant of data.input.product_variants) {
if (variant.id && variant.manage_inventory === false) {
variantIds.push(variant.id)
}
}
} else if (
data.input.update &&
data.input.update?.manage_inventory === false
) {
variantIds.push(...data.updatedVariants.map((v) => v.id))
}
return variantIds
}
)
dismissProductVariantsInventoryStep({
variantIds: variantsToDismissInventory,
})
// We don't want to do any pricing updates if the prices didn't change
const variantIds = transform({ input, updatedVariants }, (data) => {
if ("product_variants" in data.input) {

View File

@@ -27,6 +27,7 @@ import {
useRemoteQueryStep,
} from "../../common"
import { upsertVariantPricesWorkflow } from "./upsert-variant-prices"
import { dismissProductVariantsInventoryStep } from "../steps/dismiss-product-variants-inventory"
/**
* Update products that match a specified selector, along with custom data that's passed to the workflow's hooks.
@@ -444,6 +445,35 @@ export const updateProductsWorkflow = createWorkflow(
const toUpdateInput = transform({ input }, prepareUpdateProductInput)
const updatedProducts = updateProductsStep(toUpdateInput)
const variantsToDismissInventory = transform(
{ input, updatedProducts },
(data) => {
const variantIds: string[] = []
if ("products" in data.input) {
for (const product of data.input.products) {
for (const variant of product.variants ?? []) {
if (variant.id && variant.manage_inventory === false) {
variantIds.push(variant.id)
}
}
}
} else if (data.input.update?.variants?.length) {
for (const variant of data.input.update.variants) {
if (variant.id && variant.manage_inventory === false) {
variantIds.push(variant.id)
}
}
}
return variantIds
}
)
dismissProductVariantsInventoryStep({
variantIds: variantsToDismissInventory,
})
const salesChannelLinks = transform(
{ input, updatedProducts },
prepareSalesChannelLinks