diff --git a/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts b/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts index 24cb8f69cc..4064a52bcb 100644 --- a/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts +++ b/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts @@ -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, }) ) diff --git a/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/location-levels-table/location-actions.tsx b/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/location-levels-table/location-actions.tsx index 9c5d6ae8a4..581606b154 100644 --- a/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/location-levels-table/location-actions.tsx +++ b/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/location-levels-table/location-actions.tsx @@ -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: , label: t("actions.delete"), onClick: handleDelete, + disabled: + level.reserved_quantity > 0 || level.stocked_quantity > 0, }, ], }, diff --git a/packages/core/core-flows/src/inventory/steps/delete-levels-by-item-and-location.ts b/packages/core/core-flows/src/inventory/steps/delete-levels-by-item-and-location.ts deleted file mode 100644 index 1ae842ae12..0000000000 --- a/packages/core/core-flows/src/inventory/steps/delete-levels-by-item-and-location.ts +++ /dev/null @@ -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( - 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( - ModuleRegistrationName.INVENTORY - ) - - await service.restoreInventoryLevels(prevLevelIds) - } -) diff --git a/packages/core/core-flows/src/inventory/steps/index.ts b/packages/core/core-flows/src/inventory/steps/index.ts index 03f2fc3ade..ece16087de 100644 --- a/packages/core/core-flows/src/inventory/steps/index.ts +++ b/packages/core/core-flows/src/inventory/steps/index.ts @@ -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" diff --git a/packages/core/core-flows/src/inventory/workflows/bulk-create-delete-levels.ts b/packages/core/core-flows/src/inventory/workflows/bulk-create-delete-levels.ts index 04fd13de1d..989e5102ab 100644 --- a/packages/core/core-flows/src/inventory/workflows/bulk-create-delete-levels.ts +++ b/packages/core/core-flows/src/inventory/workflows/bulk-create-delete-levels.ts @@ -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 ): WorkflowResponse => { - deleteInventoryLevelsFromItemAndLocationsStep(input.deletes) + deleteInventoryLevelsWorkflow.runAsStep({ + input: { + $or: input.deletes, + }, + }) return new WorkflowResponse(createInventoryLevelsStep(input.creates)) } diff --git a/packages/core/core-flows/src/inventory/workflows/delete-inventory-levels.ts b/packages/core/core-flows/src/inventory/workflows/delete-inventory-levels.ts index ff87b78c54..db44263f24 100644 --- a/packages/core/core-flows/src/inventory/workflows/delete-inventory-levels.ts +++ b/packages/core/core-flows/src/inventory/workflows/delete-inventory-levels.ts @@ -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): WorkflowResponse => { - return new WorkflowResponse(deleteInventoryLevelsStep(input.ids)) + (input: WorkflowData) => { + 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) } ) diff --git a/packages/core/types/src/inventory/common/inventory-level.ts b/packages/core/types/src/inventory/common/inventory-level.ts index 252a4a26b4..78a43272f2 100644 --- a/packages/core/types/src/inventory/common/inventory-level.ts +++ b/packages/core/types/src/inventory/common/inventory-level.ts @@ -65,6 +65,11 @@ export interface InventoryLevelDTO { */ export interface FilterableInventoryLevelProps extends BaseFilterable { + /** + * Filter inventory levels by the ID + */ + id?: string | string[] + /** * Filter inventory levels by the ID of their associated inventory item. */ diff --git a/packages/medusa/src/api/admin/inventory-items/[id]/location-levels/[location_id]/route.ts b/packages/medusa/src/api/admin/inventory-items/[id]/location-levels/[location_id]/route.ts index 211837afff..00a72f16c4 100644 --- a/packages/medusa/src/api/admin/inventory-items/[id]/location-levels/[location_id]/route.ts +++ b/packages/medusa/src/api/admin/inventory-items/[id]/location-levels/[location_id]/route.ts @@ -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], }, })