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:
Riqwan Thamir
2024-09-10 19:59:03 +02:00
committed by GitHub
parent 4bf42f7889
commit c097931469
8 changed files with 108 additions and 87 deletions

View File

@@ -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,
})
)

View File

@@ -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,
},
],
},

View File

@@ -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)
}
)

View File

@@ -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"

View File

@@ -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))
}

View File

@@ -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)
}
)

View File

@@ -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.
*/

View File

@@ -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],
},
})