fix(core-flows, link-module): product <> inventory delete cascades (#9528)
**What** - remove cascade delete of inventory items on product delete - implement inventory deletion in product/variant delete workflows with checks: - product/variant cannot be deleted if there are reservations associated with their inventory items - inventory item will be cascade deleted if it's not used by other variants (that are not being deleted in the current flow) --- FIXES CC-581 CC-582
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/**
|
||||
|
||||
@@ -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<string[]>): WorkflowResponse<string[]> => {
|
||||
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 },
|
||||
|
||||
@@ -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<string> = 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 }) => {
|
||||
|
||||
@@ -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<string> = 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]: {
|
||||
|
||||
@@ -44,7 +44,6 @@ export const ProductVariantInventoryItem: ModuleJoinerConfig = {
|
||||
args: {
|
||||
methodSuffix: "InventoryItems",
|
||||
},
|
||||
deleteCascade: true,
|
||||
},
|
||||
],
|
||||
extends: [
|
||||
|
||||
Reference in New Issue
Block a user