feat(core-flows,types,medusa): validate deleting location level when quantities exist (#9086)
what: - adds validation in workflow to prevent deleting a location level when reserved or stocked quantity exists - disabled delete button when quantities exist - consolidate delete workflows <img width="1079" alt="Screenshot 2024-09-10 at 16 39 02" src="https://github.com/user-attachments/assets/cf1f4b2e-75ea-4f7c-9b97-24622396c632"> RESOLVES CC-120
This commit is contained in:
@@ -128,7 +128,7 @@ medusaIntegrationTestRunner({
|
||||
`/admin/inventory-items/${inventoryItem1.id}/location-levels`,
|
||||
{
|
||||
location_id: stockLocation1.id,
|
||||
stocked_quantity: 10,
|
||||
stocked_quantity: 0,
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
@@ -138,11 +138,7 @@ medusaIntegrationTestRunner({
|
||||
const result = await api.post(
|
||||
`/admin/inventory-items/${inventoryItem1.id}/location-levels/batch`,
|
||||
{
|
||||
create: [
|
||||
{
|
||||
location_id: "location_2",
|
||||
},
|
||||
],
|
||||
create: [{ location_id: "location_2" }],
|
||||
delete: [stockLocation1.id],
|
||||
},
|
||||
adminHeaders
|
||||
@@ -157,6 +153,28 @@ medusaIntegrationTestRunner({
|
||||
expect(levelsListResult.status).toEqual(200)
|
||||
expect(levelsListResult.data.inventory_levels).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should not delete an inventory location level when there is stocked items", async () => {
|
||||
await api.post(
|
||||
`/admin/inventory-items/${inventoryItem1.id}/location-levels/${stockLocation1.id}`,
|
||||
{ stocked_quantity: 10 },
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const { response } = await api
|
||||
.post(
|
||||
`/admin/inventory-items/${inventoryItem1.id}/location-levels/batch`,
|
||||
{ delete: [stockLocation1.id] },
|
||||
adminHeaders
|
||||
)
|
||||
.catch((e) => e)
|
||||
|
||||
expect(response.status).toEqual(400)
|
||||
expect(response.data).toEqual({
|
||||
type: "not_allowed",
|
||||
message: `Cannot remove Inventory Levels for ${stockLocation1.id} because there are stocked or reserved items at the locations`,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("DELETE /admin/inventory-items/:id/location-levels/:id", () => {
|
||||
@@ -165,7 +183,7 @@ medusaIntegrationTestRunner({
|
||||
`/admin/inventory-items/${inventoryItem1.id}/location-levels`,
|
||||
{
|
||||
location_id: stockLocation1.id,
|
||||
stocked_quantity: 10,
|
||||
stocked_quantity: 0,
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
@@ -187,6 +205,12 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
|
||||
it("should fail delete an inventory location level with reservations", async () => {
|
||||
await api.post(
|
||||
`/admin/inventory-items/${inventoryItem1.id}/location-levels/${stockLocation1.id}`,
|
||||
{ stocked_quantity: 10 },
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
await api.post(
|
||||
`/admin/reservations`,
|
||||
{
|
||||
@@ -621,11 +645,6 @@ medusaIntegrationTestRunner({
|
||||
)
|
||||
).data.reservation
|
||||
|
||||
await api.delete(
|
||||
`/admin/inventory-items/${inventoryItem1.id}/location-levels/${item.location_levels[0].id}`,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
await api.delete(
|
||||
`/admin/reservations/${reservation.id}`,
|
||||
adminHeaders
|
||||
@@ -639,7 +658,7 @@ medusaIntegrationTestRunner({
|
||||
expect(response.data.inventory_item).toEqual(
|
||||
expect.objectContaining({
|
||||
id: inventoryItem1.id,
|
||||
stocked_quantity: 10,
|
||||
stocked_quantity: 20,
|
||||
reserved_quantity: 1,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { InventoryTypes } from "@medusajs/types"
|
||||
import { useDeleteInventoryItemLevel } from "../../../../../hooks/api/inventory"
|
||||
import { usePrompt } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { useDeleteInventoryItemLevel } from "../../../../../hooks/api/inventory"
|
||||
|
||||
export const LocationActions = ({
|
||||
level,
|
||||
@@ -51,6 +51,8 @@ export const LocationActions = ({
|
||||
icon: <Trash />,
|
||||
label: t("actions.delete"),
|
||||
onClick: handleDelete,
|
||||
disabled:
|
||||
level.reserved_quantity > 0 || level.stocked_quantity > 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { ModuleRegistrationName } from "@medusajs/utils"
|
||||
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
|
||||
|
||||
import { IInventoryService } from "@medusajs/types"
|
||||
import { MedusaError } from "@medusajs/utils"
|
||||
|
||||
export const deleteInventoryLevelsFromItemAndLocationsStepId =
|
||||
"delete-inventory-levels-from-item-and-location-step"
|
||||
/**
|
||||
* This step removes one or more inventory levels by their associated inventory item and location.
|
||||
*/
|
||||
export const deleteInventoryLevelsFromItemAndLocationsStep = createStep(
|
||||
deleteInventoryLevelsFromItemAndLocationsStepId,
|
||||
async (
|
||||
input: { inventory_item_id: string; location_id: string }[],
|
||||
{ container }
|
||||
) => {
|
||||
if (!input.length) {
|
||||
return new StepResponse(void 0, [])
|
||||
}
|
||||
|
||||
const service = container.resolve<IInventoryService>(
|
||||
ModuleRegistrationName.INVENTORY
|
||||
)
|
||||
|
||||
const items = await service.listInventoryLevels({ $or: input }, {})
|
||||
|
||||
if (items.some((i) => i.reserved_quantity > 0)) {
|
||||
const invalidDeletes = items.filter((i) => i.reserved_quantity > 0)
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
`Cannot remove Inventory Levels for ${invalidDeletes
|
||||
.map((i) => `Inventory Level ${i.id} at Location ${i.location_id}`)
|
||||
.join(
|
||||
", "
|
||||
)} because there are reserved quantities for items at locations`
|
||||
)
|
||||
}
|
||||
|
||||
const deletedIds = items.map((i) => i.id)
|
||||
await service.softDeleteInventoryLevels(deletedIds)
|
||||
|
||||
return new StepResponse(void 0, deletedIds)
|
||||
},
|
||||
async (prevLevelIds, { container }) => {
|
||||
if (!prevLevelIds?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const service = container.resolve<IInventoryService>(
|
||||
ModuleRegistrationName.INVENTORY
|
||||
)
|
||||
|
||||
await service.restoreInventoryLevels(prevLevelIds)
|
||||
}
|
||||
)
|
||||
@@ -4,7 +4,6 @@ export * from "./create-inventory-items"
|
||||
export * from "./create-inventory-levels"
|
||||
export * from "./delete-inventory-items"
|
||||
export * from "./delete-inventory-levels"
|
||||
export * from "./delete-levels-by-item-and-location"
|
||||
export * from "./update-inventory-items"
|
||||
export * from "./update-inventory-levels"
|
||||
export * from "./validate-inventory-locations"
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { InventoryLevelDTO, InventoryTypes } from "@medusajs/types"
|
||||
import {
|
||||
createWorkflow,
|
||||
WorkflowData,
|
||||
WorkflowResponse,
|
||||
createWorkflow,
|
||||
} from "@medusajs/workflows-sdk"
|
||||
import {
|
||||
createInventoryLevelsStep,
|
||||
deleteInventoryLevelsFromItemAndLocationsStep,
|
||||
} from "../steps"
|
||||
import { createInventoryLevelsStep } from "../steps"
|
||||
import { deleteInventoryLevelsWorkflow } from "./delete-inventory-levels"
|
||||
|
||||
export interface BulkCreateDeleteLevelsWorkflowInput {
|
||||
creates: InventoryTypes.CreateInventoryLevelInput[]
|
||||
@@ -24,7 +22,11 @@ export const bulkCreateDeleteLevelsWorkflow = createWorkflow(
|
||||
(
|
||||
input: WorkflowData<BulkCreateDeleteLevelsWorkflowInput>
|
||||
): WorkflowResponse<InventoryLevelDTO[]> => {
|
||||
deleteInventoryLevelsFromItemAndLocationsStep(input.deletes)
|
||||
deleteInventoryLevelsWorkflow.runAsStep({
|
||||
input: {
|
||||
$or: input.deletes,
|
||||
},
|
||||
})
|
||||
|
||||
return new WorkflowResponse(createInventoryLevelsStep(input.creates))
|
||||
}
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
import {
|
||||
createStep,
|
||||
createWorkflow,
|
||||
transform,
|
||||
WorkflowData,
|
||||
WorkflowResponse,
|
||||
createWorkflow,
|
||||
} from "@medusajs/workflows-sdk"
|
||||
|
||||
import { deleteInventoryLevelsStep } from "../steps"
|
||||
import { FilterableInventoryLevelProps } from "@medusajs/types"
|
||||
import {
|
||||
deduplicate,
|
||||
MedusaError,
|
||||
ModuleRegistrationName,
|
||||
} from "@medusajs/utils"
|
||||
import { useRemoteQueryStep } from "../../common"
|
||||
import { deleteEntitiesStep } from "../../common/steps/delete-entities"
|
||||
|
||||
/**
|
||||
* This step validates that inventory levels are deletable.
|
||||
*/
|
||||
export const validateInventoryLevelsDelete = createStep(
|
||||
"validate-inventory-levels-delete",
|
||||
async function ({ inventoryLevels }: { inventoryLevels: any[] }) {
|
||||
const undeleteableItems = inventoryLevels.filter(
|
||||
(i) => i.reserved_quantity > 0 || i.stocked_quantity > 0
|
||||
)
|
||||
|
||||
if (undeleteableItems.length) {
|
||||
const stockLocationIds = deduplicate(
|
||||
undeleteableItems.map((item) => item.location_id)
|
||||
)
|
||||
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
`Cannot remove Inventory Levels for ${stockLocationIds} because there are stocked or reserved items at the locations`
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export interface DeleteInventoryLevelsWorkflowInput {
|
||||
ids: string[]
|
||||
}
|
||||
export const deleteInventoryLevelsWorkflowId =
|
||||
"delete-inventory-levels-workflow"
|
||||
/**
|
||||
@@ -16,7 +45,28 @@ export const deleteInventoryLevelsWorkflowId =
|
||||
*/
|
||||
export const deleteInventoryLevelsWorkflow = createWorkflow(
|
||||
deleteInventoryLevelsWorkflowId,
|
||||
(input: WorkflowData<DeleteInventoryLevelsWorkflowInput>): WorkflowResponse<string[]> => {
|
||||
return new WorkflowResponse(deleteInventoryLevelsStep(input.ids))
|
||||
(input: WorkflowData<FilterableInventoryLevelProps>) => {
|
||||
const inventoryLevels = useRemoteQueryStep({
|
||||
entry_point: "inventory_levels",
|
||||
fields: ["id", "stocked_quantity", "reserved_quantity", "location_id"],
|
||||
variables: {
|
||||
filters: input,
|
||||
},
|
||||
})
|
||||
|
||||
validateInventoryLevelsDelete({ inventoryLevels })
|
||||
|
||||
const idsToDelete = transform({ inventoryLevels }, ({ inventoryLevels }) =>
|
||||
inventoryLevels.map((il) => il.id)
|
||||
)
|
||||
|
||||
deleteEntitiesStep({
|
||||
moduleRegistrationName: ModuleRegistrationName.INVENTORY,
|
||||
invokeMethod: "softDeleteInventoryLevels",
|
||||
compensateMethod: "restoreInventoryLevels",
|
||||
data: idsToDelete,
|
||||
})
|
||||
|
||||
return new WorkflowResponse(void 0)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -65,6 +65,11 @@ export interface InventoryLevelDTO {
|
||||
*/
|
||||
export interface FilterableInventoryLevelProps
|
||||
extends BaseFilterable<FilterableInventoryLevelProps> {
|
||||
/**
|
||||
* Filter inventory levels by the ID
|
||||
*/
|
||||
id?: string | string[]
|
||||
|
||||
/**
|
||||
* Filter inventory levels by the ID of their associated inventory item.
|
||||
*/
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
deleteInventoryLevelsWorkflow,
|
||||
updateInventoryLevelsWorkflow,
|
||||
} from "@medusajs/core-flows"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { refetchInventoryItem } from "../../../helpers"
|
||||
import { AdminUpdateInventoryLocationLevelType } from "../../../validators"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
export const DELETE = async (
|
||||
req: MedusaRequest,
|
||||
@@ -45,7 +45,7 @@ export const DELETE = async (
|
||||
|
||||
await deleteInventoryLevelWorkflow.run({
|
||||
input: {
|
||||
ids: [levelId],
|
||||
id: [levelId],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user