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:
1 parent
4bf42f7889
commit
c097931469
8 files changed
+108
-87
No files matched your search
@@ -128,7 +128,7 @@ medusaIntegrationTestRunner({
|
|||||||
`/admin/inventory-items/${inventoryItem1.id}/location-levels`,
|
`/admin/inventory-items/${inventoryItem1.id}/location-levels`,
|
||||||
{
|
{
|
||||||
location_id: stockLocation1.id,
|
location_id: stockLocation1.id,
|
||||||
stocked_quantity: 10,
|
stocked_quantity: 0,
|
||||||
},
|
},
|
||||||
adminHeaders
|
adminHeaders
|
||||||
)
|
)
|
||||||
@@ -138,11 +138,7 @@ medusaIntegrationTestRunner({
|
|||||||
const result = await api.post(
|
const result = await api.post(
|
||||||
`/admin/inventory-items/${inventoryItem1.id}/location-levels/batch`,
|
`/admin/inventory-items/${inventoryItem1.id}/location-levels/batch`,
|
||||||
{
|
{
|
||||||
create: [
|
create: [{ location_id: "location_2" }],
|
||||||
{
|
|
||||||
location_id: "location_2",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
delete: [stockLocation1.id],
|
delete: [stockLocation1.id],
|
||||||
},
|
},
|
||||||
adminHeaders
|
adminHeaders
|
||||||
@@ -157,6 +153,28 @@ medusaIntegrationTestRunner({
|
|||||||
expect(levelsListResult.status).toEqual(200)
|
expect(levelsListResult.status).toEqual(200)
|
||||||
expect(levelsListResult.data.inventory_levels).toHaveLength(1)
|
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", () => {
|
describe("DELETE /admin/inventory-items/:id/location-levels/:id", () => {
|
||||||
@@ -165,7 +183,7 @@ medusaIntegrationTestRunner({
|
|||||||
`/admin/inventory-items/${inventoryItem1.id}/location-levels`,
|
`/admin/inventory-items/${inventoryItem1.id}/location-levels`,
|
||||||
{
|
{
|
||||||
location_id: stockLocation1.id,
|
location_id: stockLocation1.id,
|
||||||
stocked_quantity: 10,
|
stocked_quantity: 0,
|
||||||
},
|
},
|
||||||
adminHeaders
|
adminHeaders
|
||||||
)
|
)
|
||||||
@@ -187,6 +205,12 @@ medusaIntegrationTestRunner({
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("should fail delete an inventory location level with reservations", async () => {
|
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(
|
await api.post(
|
||||||
`/admin/reservations`,
|
`/admin/reservations`,
|
||||||
{
|
{
|
||||||
@@ -621,11 +645,6 @@ medusaIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
).data.reservation
|
).data.reservation
|
||||||
|
|
||||||
await api.delete(
|
|
||||||
`/admin/inventory-items/${inventoryItem1.id}/location-levels/${item.location_levels[0].id}`,
|
|
||||||
adminHeaders
|
|
||||||
)
|
|
||||||
|
|
||||||
await api.delete(
|
await api.delete(
|
||||||
`/admin/reservations/${reservation.id}`,
|
`/admin/reservations/${reservation.id}`,
|
||||||
adminHeaders
|
adminHeaders
|
||||||
@@ -639,7 +658,7 @@ medusaIntegrationTestRunner({
|
|||||||
expect(response.data.inventory_item).toEqual(
|
expect(response.data.inventory_item).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: inventoryItem1.id,
|
id: inventoryItem1.id,
|
||||||
stocked_quantity: 10,
|
stocked_quantity: 20,
|
||||||
reserved_quantity: 1,
|
reserved_quantity: 1,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-2
@@ -1,10 +1,10 @@
|
|||||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||||
|
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
|
||||||
import { InventoryTypes } from "@medusajs/types"
|
import { InventoryTypes } from "@medusajs/types"
|
||||||
import { useDeleteInventoryItemLevel } from "../../../../../hooks/api/inventory"
|
|
||||||
import { usePrompt } from "@medusajs/ui"
|
import { usePrompt } from "@medusajs/ui"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
import { useDeleteInventoryItemLevel } from "../../../../../hooks/api/inventory"
|
||||||
|
|
||||||
export const LocationActions = ({
|
export const LocationActions = ({
|
||||||
level,
|
level,
|
||||||
@@ -51,6 +51,8 @@ export const LocationActions = ({
|
|||||||
icon: <Trash />,
|
icon: <Trash />,
|
||||||
label: t("actions.delete"),
|
label: t("actions.delete"),
|
||||||
onClick: handleDelete,
|
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 "./create-inventory-levels"
|
||||||
export * from "./delete-inventory-items"
|
export * from "./delete-inventory-items"
|
||||||
export * from "./delete-inventory-levels"
|
export * from "./delete-inventory-levels"
|
||||||
export * from "./delete-levels-by-item-and-location"
|
|
||||||
export * from "./update-inventory-items"
|
export * from "./update-inventory-items"
|
||||||
export * from "./update-inventory-levels"
|
export * from "./update-inventory-levels"
|
||||||
export * from "./validate-inventory-locations"
|
export * from "./validate-inventory-locations"
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { InventoryLevelDTO, InventoryTypes } from "@medusajs/types"
|
import { InventoryLevelDTO, InventoryTypes } from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
|
createWorkflow,
|
||||||
WorkflowData,
|
WorkflowData,
|
||||||
WorkflowResponse,
|
WorkflowResponse,
|
||||||
createWorkflow,
|
|
||||||
} from "@medusajs/workflows-sdk"
|
} from "@medusajs/workflows-sdk"
|
||||||
import {
|
import { createInventoryLevelsStep } from "../steps"
|
||||||
createInventoryLevelsStep,
|
import { deleteInventoryLevelsWorkflow } from "./delete-inventory-levels"
|
||||||
deleteInventoryLevelsFromItemAndLocationsStep,
|
|
||||||
} from "../steps"
|
|
||||||
|
|
||||||
export interface BulkCreateDeleteLevelsWorkflowInput {
|
export interface BulkCreateDeleteLevelsWorkflowInput {
|
||||||
creates: InventoryTypes.CreateInventoryLevelInput[]
|
creates: InventoryTypes.CreateInventoryLevelInput[]
|
||||||
@@ -24,7 +22,11 @@ export const bulkCreateDeleteLevelsWorkflow = createWorkflow(
|
|||||||
(
|
(
|
||||||
input: WorkflowData<BulkCreateDeleteLevelsWorkflowInput>
|
input: WorkflowData<BulkCreateDeleteLevelsWorkflowInput>
|
||||||
): WorkflowResponse<InventoryLevelDTO[]> => {
|
): WorkflowResponse<InventoryLevelDTO[]> => {
|
||||||
deleteInventoryLevelsFromItemAndLocationsStep(input.deletes)
|
deleteInventoryLevelsWorkflow.runAsStep({
|
||||||
|
input: {
|
||||||
|
$or: input.deletes,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return new WorkflowResponse(createInventoryLevelsStep(input.creates))
|
return new WorkflowResponse(createInventoryLevelsStep(input.creates))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,43 @@
|
|||||||
import {
|
import {
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
transform,
|
||||||
WorkflowData,
|
WorkflowData,
|
||||||
WorkflowResponse,
|
WorkflowResponse,
|
||||||
createWorkflow,
|
|
||||||
} from "@medusajs/workflows-sdk"
|
} 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 =
|
export const deleteInventoryLevelsWorkflowId =
|
||||||
"delete-inventory-levels-workflow"
|
"delete-inventory-levels-workflow"
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +45,28 @@ export const deleteInventoryLevelsWorkflowId =
|
|||||||
*/
|
*/
|
||||||
export const deleteInventoryLevelsWorkflow = createWorkflow(
|
export const deleteInventoryLevelsWorkflow = createWorkflow(
|
||||||
deleteInventoryLevelsWorkflowId,
|
deleteInventoryLevelsWorkflowId,
|
||||||
(input: WorkflowData<DeleteInventoryLevelsWorkflowInput>): WorkflowResponse<string[]> => {
|
(input: WorkflowData<FilterableInventoryLevelProps>) => {
|
||||||
return new WorkflowResponse(deleteInventoryLevelsStep(input.ids))
|
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
|
export interface FilterableInventoryLevelProps
|
||||||
extends BaseFilterable<FilterableInventoryLevelProps> {
|
extends BaseFilterable<FilterableInventoryLevelProps> {
|
||||||
|
/**
|
||||||
|
* Filter inventory levels by the ID
|
||||||
|
*/
|
||||||
|
id?: string | string[]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter inventory levels by the ID of their associated inventory item.
|
* Filter inventory levels by the ID of their associated inventory item.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+2
-2
@@ -9,9 +9,9 @@ import {
|
|||||||
deleteInventoryLevelsWorkflow,
|
deleteInventoryLevelsWorkflow,
|
||||||
updateInventoryLevelsWorkflow,
|
updateInventoryLevelsWorkflow,
|
||||||
} from "@medusajs/core-flows"
|
} from "@medusajs/core-flows"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
import { refetchInventoryItem } from "../../../helpers"
|
import { refetchInventoryItem } from "../../../helpers"
|
||||||
import { AdminUpdateInventoryLocationLevelType } from "../../../validators"
|
import { AdminUpdateInventoryLocationLevelType } from "../../../validators"
|
||||||
import { HttpTypes } from "@medusajs/types"
|
|
||||||
|
|
||||||
export const DELETE = async (
|
export const DELETE = async (
|
||||||
req: MedusaRequest,
|
req: MedusaRequest,
|
||||||
@@ -45,7 +45,7 @@ export const DELETE = async (
|
|||||||
|
|
||||||
await deleteInventoryLevelWorkflow.run({
|
await deleteInventoryLevelWorkflow.run({
|
||||||
input: {
|
input: {
|
||||||
ids: [levelId],
|
id: [levelId],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user