diff --git a/.changeset/bright-cars-accept.md b/.changeset/bright-cars-accept.md new file mode 100644 index 0000000000..41703c26c3 --- /dev/null +++ b/.changeset/bright-cars-accept.md @@ -0,0 +1,5 @@ +--- +"@medusajs/core-flows": patch +--- + +fix(core-flows): Avoid checking inventory items on fulfillment cancel for unmanaged inventory variants diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index a283ed4af9..0c6a4a4aa2 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -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( diff --git a/integration-tests/http/__tests__/product/admin/variant.spec.ts b/integration-tests/http/__tests__/product/admin/variant.spec.ts index 1703aaa5a7..f2782181ac 100644 --- a/integration-tests/http/__tests__/product/admin/variant.spec.ts +++ b/integration-tests/http/__tests__/product/admin/variant.spec.ts @@ -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 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 e4b94ca6a7..a6a580cccd 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 @@ -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 diff --git a/packages/core/core-flows/src/product/steps/dismiss-product-variants-inventory.ts b/packages/core/core-flows/src/product/steps/dismiss-product-variants-inventory.ts new file mode 100644 index 0000000000..f604f10c15 --- /dev/null +++ b/packages/core/core-flows/src/product/steps/dismiss-product-variants-inventory.ts @@ -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> { + const dismissedVariantInventoryItems: Record = {} + 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() + 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) + } +) diff --git a/packages/core/core-flows/src/product/steps/index.ts b/packages/core/core-flows/src/product/steps/index.ts index 85e50f5947..ed6d04ebfe 100644 --- a/packages/core/core-flows/src/product/steps/index.ts +++ b/packages/core/core-flows/src/product/steps/index.ts @@ -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" diff --git a/packages/core/core-flows/src/product/workflows/update-product-variants.ts b/packages/core/core-flows/src/product/workflows/update-product-variants.ts index 26afe3aa02..bc5aa2e34c 100644 --- a/packages/core/core-flows/src/product/workflows/update-product-variants.ts +++ b/packages/core/core-flows/src/product/workflows/update-product-variants.ts @@ -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) { diff --git a/packages/core/core-flows/src/product/workflows/update-products.ts b/packages/core/core-flows/src/product/workflows/update-products.ts index 2259eb9b2f..a94d3f75df 100644 --- a/packages/core/core-flows/src/product/workflows/update-products.ts +++ b/packages/core/core-flows/src/product/workflows/update-products.ts @@ -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