diff --git a/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts b/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts index 408f15571b..f8366721da 100644 --- a/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts +++ b/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts @@ -782,7 +782,7 @@ medusaIntegrationTestRunner({ }) describe("DELETE /admin/inventory-items/:id", () => { - it("should remove associated levels and reservations when deleting an inventory item", async () => { + it("should throw if inventory item with reservations is being removed", async () => { await api.post( `/admin/inventory-items/${inventoryItem1.id}/location-levels`, { @@ -821,30 +821,16 @@ medusaIntegrationTestRunner({ ).data expect(levelsResponse.count).toEqual(1) - const res = await api.delete( - `/admin/inventory-items/${inventoryItem1.id}`, - adminHeaders + const res = await api + .delete(`/admin/inventory-items/${inventoryItem1.id}`, adminHeaders) + .catch((err) => { + return err.response + }) + + expect(res.status).toEqual(400) + expect(res.data.message).toEqual( + `Cannot remove following inventory item(s) since they have reservations: [${inventoryItem1.id}].` ) - - expect(res.status).toEqual(200) - - const reservationsResponseAfterDelete = ( - await api.get( - `/admin/reservations?location_id[]=${stockLocation1.id}`, - adminHeaders - ) - ).data - - expect(reservationsResponseAfterDelete.count).toEqual(0) - - const levelsResponseAfterDelete = ( - await api.get( - `/admin/inventory-items/${inventoryItem1.id}/location-levels?location_id[]=${stockLocation1.id}`, - adminHeaders - ) - ).data - - expect(levelsResponseAfterDelete.count).toEqual(0) }) it("should remove the product variant associations when deleting an inventory item", async () => { diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index 31a856d9c7..68142cb03a 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -2379,6 +2379,218 @@ medusaIntegrationTestRunner({ expect(variantPost).not.toBeTruthy() }) + it("deletes product and inventory items that are only associated with that product's variants", async () => { + const stockLocation = ( + await api.post( + `/admin/stock-locations`, + { name: "loc" }, + adminHeaders + ) + ).data.stock_location + + const inventoryItem1 = ( + await api.post( + `/admin/inventory-items`, + { sku: "inventory-1" }, + adminHeaders + ) + ).data.inventory_item + + const inventoryItem2 = ( + await api.post( + `/admin/inventory-items`, + { sku: "inventory-2-reused-across-products" }, + adminHeaders + ) + ).data.inventory_item + + await api.post( + `/admin/inventory-items/${inventoryItem1.id}/location-levels`, + { + location_id: stockLocation.id, + stocked_quantity: 8, + }, + adminHeaders + ) + + await api.post( + `/admin/inventory-items/${inventoryItem2.id}/location-levels`, + { + location_id: stockLocation.id, + stocked_quantity: 4, + }, + adminHeaders + ) + + const productWithInventoryItems = ( + await api.post( + `/admin/products`, + { + title: "Test product - 1", + handle: "test-1", + options: [{ title: "size", values: ["l"] }], + variants: [ + { + title: "Custom inventory 1", + prices: [{ currency_code: "usd", amount: 100 }], + manage_inventory: true, + options: { size: "l" }, + inventory_items: [ + { + inventory_item_id: inventoryItem1.id, + required_quantity: 4, + }, + { + inventory_item_id: inventoryItem2.id, + required_quantity: 2, + }, + ], + }, + ], + }, + adminHeaders + ) + ).data.product + + // Another product that shares inventory item in the inventory kit + await api.post( + `/admin/products`, + { + title: "Test product - 2", + handle: "test-2", + options: [{ title: "size", values: ["l"] }], + variants: [ + { + title: "W/ shared inventory item", + prices: [{ currency_code: "usd", amount: 100 }], + manage_inventory: true, + options: { size: "l" }, + inventory_items: [ + { + inventory_item_id: inventoryItem2.id, + required_quantity: 2, + }, + ], + }, + ], + }, + adminHeaders + ) + + const response = await api + .delete( + `/admin/products/${productWithInventoryItems.id}`, + adminHeaders + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data).toEqual( + expect.objectContaining({ deleted: true }) + ) + + const item1Response = await api + .get(`/admin/inventory-items/${inventoryItem1.id}`, adminHeaders) + .catch((err) => { + return err.response + }) + + const item2Response = await api + .get(`/admin/inventory-items/${inventoryItem2.id}`, adminHeaders) + .catch((err) => { + console.log(err) + }) + + expect(item1Response.status).toEqual(404) // deleted since it's used only by the deleted product + expect(item2Response.status).toEqual(200) // not deleted since it belongs to other products + expect(item2Response.data.inventory_item).toEqual( + expect.objectContaining({ id: inventoryItem2.id }) + ) + }) + + it("should throw if product that has a reservation is being deleted", async () => { + const stockLocation = ( + await api.post( + `/admin/stock-locations`, + { name: "loc" }, + adminHeaders + ) + ).data.stock_location + + const inventoryItem1 = ( + await api.post( + `/admin/inventory-items`, + { sku: "inventory-1" }, + adminHeaders + ) + ).data.inventory_item + + await api.post( + `/admin/inventory-items/${inventoryItem1.id}/location-levels`, + { + location_id: stockLocation.id, + stocked_quantity: 8, + }, + adminHeaders + ) + + const productWithInventoryItems = ( + await api.post( + `/admin/products`, + { + title: "Test product - 1", + handle: "test-1", + options: [{ title: "size", values: ["l"] }], + variants: [ + { + title: "Custom inventory 1", + prices: [{ currency_code: "usd", amount: 100 }], + manage_inventory: true, + options: { size: "l" }, + inventory_items: [ + { + inventory_item_id: inventoryItem1.id, + required_quantity: 4, + }, + ], + }, + ], + }, + adminHeaders + ) + ).data.product + + const reservation = ( + await api.post( + `/admin/reservations`, + { + line_item_id: "line-item-id-1", + inventory_item_id: inventoryItem1.id, + location_id: stockLocation.id, + description: "test description", + quantity: 1, + }, + adminHeaders + ) + ).data.reservation + + const response = await api + .delete( + `/admin/products/${productWithInventoryItems.id}`, + adminHeaders + ) + .catch((err) => { + return err.response + }) + + expect(response.status).toEqual(400) + expect(response.data.message).toEqual( + `Cannot remove following inventory item(s) since they have reservations: [${inventoryItem1.id}].` + ) + }) + // TODO: Enable with http calls it.skip("successfully deletes a product variant and its associated prices", async () => { // // Validate that the price exists diff --git a/packages/core/core-flows/src/inventory/steps/delete-inventory-items.ts b/packages/core/core-flows/src/inventory/steps/delete-inventory-items.ts index c375308bd8..fbec629b91 100644 --- a/packages/core/core-flows/src/inventory/steps/delete-inventory-items.ts +++ b/packages/core/core-flows/src/inventory/steps/delete-inventory-items.ts @@ -1,6 +1,30 @@ import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" -import { Modules } from "@medusajs/framework/utils" +import { MathBN, MedusaError, Modules } from "@medusajs/framework/utils" +import { BigNumberInput } from "@medusajs/types" + +export interface ValidateInventoryDeleteStepInput { + inventory_items: { id: string; reserved_quantity: BigNumberInput }[] +} + +export const validateVariantInventoryStepId = "validate-inventory-item-delete" + +export const validateInventoryDeleteStep = createStep( + validateVariantInventoryStepId, + async (data: ValidateInventoryDeleteStepInput) => { + const nonDeletable = data.inventory_items.filter((inventoryItem) => { + return MathBN.gt(inventoryItem.reserved_quantity, 0) + }) + if (nonDeletable.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot remove following inventory item(s) since they have reservations: [${nonDeletable + .map((inventoryItem) => inventoryItem.id) + .join(", ")}].` + ) + } + } +) export const deleteInventoryItemStepId = "delete-inventory-item-step" /** diff --git a/packages/core/core-flows/src/inventory/workflows/delete-inventory-items.ts b/packages/core/core-flows/src/inventory/workflows/delete-inventory-items.ts index 753d26360b..7d34b8b191 100644 --- a/packages/core/core-flows/src/inventory/workflows/delete-inventory-items.ts +++ b/packages/core/core-flows/src/inventory/workflows/delete-inventory-items.ts @@ -3,11 +3,12 @@ import { WorkflowData, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" - -import { deleteInventoryItemStep } from "../steps" -import { removeRemoteLinkStep } from "../../common/steps/remove-remote-links" import { Modules } from "@medusajs/framework/utils" +import { deleteInventoryItemStep, validateInventoryDeleteStep } from "../steps" +import { removeRemoteLinkStep } from "../../common/steps/remove-remote-links" +import { useQueryGraphStep } from "../../common" + export const deleteInventoryItemWorkflowId = "delete-inventory-item-workflow" /** * This workflow deletes one or more inventory items. @@ -15,6 +16,16 @@ export const deleteInventoryItemWorkflowId = "delete-inventory-item-workflow" export const deleteInventoryItemWorkflow = createWorkflow( deleteInventoryItemWorkflowId, (input: WorkflowData): WorkflowResponse => { + const { data: inventoryItemsToDelete } = useQueryGraphStep({ + entity: "inventory", + fields: ["id", "reserved_quantity"], + filters: { + id: input, + }, + }) + + validateInventoryDeleteStep({ inventory_items: inventoryItemsToDelete }) + deleteInventoryItemStep(input) removeRemoteLinkStep({ [Modules.INVENTORY]: { inventory_item_id: input }, diff --git a/packages/core/core-flows/src/product/workflows/delete-product-variants.ts b/packages/core/core-flows/src/product/workflows/delete-product-variants.ts index 6c251a34d1..1b0cf86263 100644 --- a/packages/core/core-flows/src/product/workflows/delete-product-variants.ts +++ b/packages/core/core-flows/src/product/workflows/delete-product-variants.ts @@ -9,8 +9,13 @@ import { createWorkflow, transform, } from "@medusajs/framework/workflows-sdk" -import { emitEventStep, removeRemoteLinkStep } from "../../common" +import { + emitEventStep, + removeRemoteLinkStep, + useQueryGraphStep, +} from "../../common" import { deleteProductVariantsStep } from "../steps" +import { deleteInventoryItemWorkflow } from "../../inventory" export type DeleteProductVariantsWorkflowInput = { ids: string[] } @@ -25,6 +30,47 @@ export const deleteProductVariantsWorkflow = createWorkflow( [Modules.PRODUCT]: { variant_id: input.ids }, }).config({ name: "remove-variant-link-step" }) + const variantsWithInventoryStepResponse = useQueryGraphStep({ + entity: "variants", + fields: [ + "id", + "manage_inventory", + "inventory.id", + "inventory.variants.id", + ], + filters: { + id: input.ids, + }, + }) + + const toDeleteInventoryItemIds = transform( + { variants: variantsWithInventoryStepResponse.data }, + (data) => { + const variants = data.variants || [] + + const variantsMap = new Map(variants.map((v) => [v.id, true])) + const toDeleteIds: Set = new Set() + + variants.forEach((variant) => { + if (!variant.manage_inventory) { + return + } + + for (const inventoryItem of variant.inventory) { + if (inventoryItem.variants.every((v) => variantsMap.has(v.id))) { + toDeleteIds.add(inventoryItem.id) + } + } + }) + + return Array.from(toDeleteIds) + } + ) + + deleteInventoryItemWorkflow.runAsStep({ + input: toDeleteInventoryItemIds, + }) + const deletedProductVariants = deleteProductVariantsStep(input.ids) const variantIdEvents = transform({ input }, ({ input }) => { diff --git a/packages/core/core-flows/src/product/workflows/delete-products.ts b/packages/core/core-flows/src/product/workflows/delete-products.ts index a3f55ed751..78c7297298 100644 --- a/packages/core/core-flows/src/product/workflows/delete-products.ts +++ b/packages/core/core-flows/src/product/workflows/delete-products.ts @@ -7,9 +7,14 @@ import { parallelize, transform, } from "@medusajs/framework/workflows-sdk" -import { emitEventStep, removeRemoteLinkStep } from "../../common" +import { + emitEventStep, + removeRemoteLinkStep, + useQueryGraphStep, +} from "../../common" import { deleteProductsStep } from "../steps/delete-products" import { getProductsStep } from "../steps/get-products" +import { deleteInventoryItemWorkflow } from "../../inventory" export type DeleteProductsWorkflowInput = { ids: string[] } @@ -27,6 +32,47 @@ export const deleteProductsWorkflow = createWorkflow( .map((variant) => variant.id) }) + const variantsWithInventoryStepResponse = useQueryGraphStep({ + entity: "variants", + fields: [ + "id", + "manage_inventory", + "inventory.id", + "inventory.variants.id", + ], + filters: { + id: variantsToBeDeleted, + }, + }) + + const toDeleteInventoryItemIds = transform( + { variants: variantsWithInventoryStepResponse.data }, + (data) => { + const variants = data.variants || [] + + const variantsMap = new Map(variants.map((v) => [v.id, true])) + const toDeleteIds: Set = new Set() + + variants.forEach((variant) => { + if (!variant.manage_inventory) { + return + } + + for (const inventoryItem of variant.inventory) { + if (inventoryItem.variants.every((v) => variantsMap.has(v.id))) { + toDeleteIds.add(inventoryItem.id) + } + } + }) + + return Array.from(toDeleteIds) + } + ) + + deleteInventoryItemWorkflow.runAsStep({ + input: toDeleteInventoryItemIds, + }) + const [, deletedProduct] = parallelize( removeRemoteLinkStep({ [Modules.PRODUCT]: { diff --git a/packages/modules/link-modules/src/definitions/product-variant-inventory-item.ts b/packages/modules/link-modules/src/definitions/product-variant-inventory-item.ts index 9e122490bc..b18beb0063 100644 --- a/packages/modules/link-modules/src/definitions/product-variant-inventory-item.ts +++ b/packages/modules/link-modules/src/definitions/product-variant-inventory-item.ts @@ -44,7 +44,6 @@ export const ProductVariantInventoryItem: ModuleJoinerConfig = { args: { methodSuffix: "InventoryItems", }, - deleteCascade: true, }, ], extends: [