diff --git a/integration-tests/modules/__tests__/inventory/index.spec.ts b/integration-tests/modules/__tests__/inventory/index.spec.ts index 895f4fcb79..50815c9887 100644 --- a/integration-tests/modules/__tests__/inventory/index.spec.ts +++ b/integration-tests/modules/__tests__/inventory/index.spec.ts @@ -293,6 +293,102 @@ medusaIntegrationTestRunner({ }) }) + describe("Update inventory levels", () => { + let locationId + let inventoryItemId + beforeEach(async () => { + const invItemReps = await api.post( + `/admin/inventory-items`, + { sku: "test-sku" }, + adminHeaders + ) + + inventoryItemId = invItemReps.data.inventory_item.id + + const stockLocation = await appContainer + .resolve(ModuleRegistrationName.STOCK_LOCATION) + .create({ name: "test-location" }) + + locationId = stockLocation.id + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: locationId, + stocked_quantity: 10, + }, + adminHeaders + ) + }) + + it("should update the stocked and incoming quantity for an inventory level", async () => { + const result = await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}`, + { + stocked_quantity: 15, + incoming_quantity: 5, + }, + adminHeaders + ) + + expect(result.status).toEqual(200) + expect(result.data.inventory_item).toEqual( + expect.objectContaining({ + id: inventoryItemId, + location_levels: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + inventory_item_id: inventoryItemId, + location_id: locationId, + stocked_quantity: 15, + reserved_quantity: 0, + incoming_quantity: 5, + metadata: null, + }), + ]), + }) + ) + }) + + it("should fail to update a non-existing location level", async () => { + const error = await api + .post( + `/admin/inventory-items/${inventoryItemId}/location-levels/does-not-exist`, + { + stocked_quantity: 15, + incoming_quantity: 5, + }, + adminHeaders + ) + .catch((e) => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data).toEqual({ + type: "not_found", + message: `Item ${inventoryItemId} is not stocked at location does-not-exist`, + }) + }) + + it("should fail to update a non-existing inventory_item_id level", async () => { + const error = await api + .post( + `/admin/inventory-items/does-not-exist/location-levels/${locationId}`, + { + stocked_quantity: 15, + incoming_quantity: 5, + }, + adminHeaders + ) + .catch((e) => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data).toEqual({ + type: "not_found", + message: `Item does-not-exist is not stocked at location ${locationId}`, + }) + }) + }) + describe("Retrieve inventory item", () => { let location1 = "loc_1" let location2 = "loc_2" diff --git a/packages/core-flows/src/inventory/steps/index.ts b/packages/core-flows/src/inventory/steps/index.ts index 7d0b60fbac..4b66716c36 100644 --- a/packages/core-flows/src/inventory/steps/index.ts +++ b/packages/core-flows/src/inventory/steps/index.ts @@ -6,3 +6,4 @@ export * from "./create-inventory-levels" export * from "./validate-inventory-locations" export * from "./update-inventory-items" export * from "./delete-inventory-levels" +export * from "./update-inventory-levels" diff --git a/packages/core-flows/src/inventory/steps/update-inventory-levels.ts b/packages/core-flows/src/inventory/steps/update-inventory-levels.ts new file mode 100644 index 0000000000..f5e61236e1 --- /dev/null +++ b/packages/core-flows/src/inventory/steps/update-inventory-levels.ts @@ -0,0 +1,57 @@ +import { IInventoryServiceNext, InventoryNext } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" +import { + convertItemResponseToUpdateRequest, + getSelectsAndRelationsFromObjectArray, +} from "@medusajs/utils" + +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +export const updateInventoryLevelsStepId = "update-inventory-levels-step" +export const updateInventoryLevelsStep = createStep( + updateInventoryLevelsStepId, + async ( + input: InventoryNext.BulkUpdateInventoryLevelInput[], + { container } + ) => { + const inventoryService: IInventoryServiceNext = container.resolve( + ModuleRegistrationName.INVENTORY + ) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray(input) + + const dataBeforeUpdate = await inventoryService.listInventoryLevels( + { + $or: input.map(({ inventory_item_id, location_id }) => ({ + inventory_item_id, + location_id, + })), + }, + {} + ) + + const updatedLevels: InventoryNext.InventoryLevelDTO[] = + await inventoryService.updateInventoryLevels(input) + + return new StepResponse(updatedLevels, { + dataBeforeUpdate, + selects, + relations, + }) + }, + async (revertInput, { container }) => { + if (!revertInput?.dataBeforeUpdate?.length) { + return + } + + const { dataBeforeUpdate, selects, relations } = revertInput + + const inventoryService = container.resolve(ModuleRegistrationName.INVENTORY) + + await inventoryService.updateInventoryLevels( + dataBeforeUpdate.map((data) => + convertItemResponseToUpdateRequest(data, selects, relations) + ) as InventoryNext.BulkUpdateInventoryLevelInput[] + ) + } +) diff --git a/packages/core-flows/src/inventory/workflows/index.ts b/packages/core-flows/src/inventory/workflows/index.ts index 68e0ef85a5..d59c844079 100644 --- a/packages/core-flows/src/inventory/workflows/index.ts +++ b/packages/core-flows/src/inventory/workflows/index.ts @@ -3,3 +3,4 @@ export * from "./create-inventory-items" export * from "./create-inventory-levels" export * from "./update-inventory-items" export * from "./delete-inventory-levels" +export * from "./update-inventory-levels" diff --git a/packages/core-flows/src/inventory/workflows/update-inventory-levels.ts b/packages/core-flows/src/inventory/workflows/update-inventory-levels.ts new file mode 100644 index 0000000000..d4c7580b6e --- /dev/null +++ b/packages/core-flows/src/inventory/workflows/update-inventory-levels.ts @@ -0,0 +1,16 @@ +import { InventoryLevelDTO, InventoryNext } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" + +import { updateInventoryLevelsStep } from "../steps/update-inventory-levels" + +interface WorkflowInput { + updates: InventoryNext.BulkUpdateInventoryLevelInput[] +} +export const updateInventoryLevelsWorkflowId = + "update-inventory-levels-workflow" +export const updateInventoryLevelsWorkflow = createWorkflow( + updateInventoryLevelsWorkflowId, + (input: WorkflowData): WorkflowData => { + return updateInventoryLevelsStep(input.updates) + } +) diff --git a/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/[location_id]/route.ts b/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/[location_id]/route.ts index f8394b0b71..91230f65f7 100644 --- a/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/[location_id]/route.ts +++ b/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/[location_id]/route.ts @@ -5,7 +5,9 @@ import { } from "@medusajs/utils" import { MedusaRequest, MedusaResponse } from "../../../../../../types/routing" +import { AdminPostInventoryItemsItemLocationLevelsLevelReq } from "../../../validators" import { deleteInventoryLevelsWorkflow } from "@medusajs/core-flows" +import { updateInventoryLevelsWorkflow } from "@medusajs/core-flows" export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => { const { id, location_id } = req.params @@ -45,3 +47,36 @@ export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => { deleted: true, }) } + +export const POST = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const { id: inventory_item_id, location_id } = req.params + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const { errors } = await updateInventoryLevelsWorkflow(req.scope).run({ + input: { + updates: [{ inventory_item_id, location_id, ...req.validatedBody }], + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const [inventory_item] = await remoteQuery( + remoteQueryObjectFromString({ + entryPoint: "inventory", + variables: { + id: inventory_item_id, + }, + fields: req.remoteQueryConfig.fields, + }) + ) + + res.status(200).json({ + inventory_item, + }) +} diff --git a/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts b/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts index b06a6f4137..9b6aa5ad3f 100644 --- a/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts @@ -5,6 +5,8 @@ import { AdminGetInventoryItemsParams, AdminPostInventoryItemsInventoryItemParams, AdminPostInventoryItemsInventoryItemReq, + AdminPostInventoryItemsItemLocationLevelsLevelParams, + AdminPostInventoryItemsItemLocationLevelsLevelReq, AdminPostInventoryItemsItemLocationLevelsReq, AdminPostInventoryItemsReq, } from "./validators" @@ -39,11 +41,6 @@ export const adminInventoryRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, - { - method: ["POST"], - matcher: "/admin/inventory-items/:id/location-levels", - middlewares: [transformBody(AdminPostInventoryItemsItemLocationLevelsReq)], - }, { method: ["POST"], matcher: "/admin/inventory-items", @@ -55,6 +52,22 @@ export const adminInventoryRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/inventory-items/:id/location-levels", + middlewares: [transformBody(AdminPostInventoryItemsItemLocationLevelsReq)], + }, + { + method: ["POST"], + matcher: "/admin/inventory-items/:id/location-levels/:location_id", + middlewares: [ + transformBody(AdminPostInventoryItemsItemLocationLevelsLevelReq), + transformQuery( + AdminPostInventoryItemsItemLocationLevelsLevelParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, { method: ["POST"], matcher: "/admin/inventory-items/:id", diff --git a/packages/medusa/src/api-v2/admin/inventory-items/query-config.ts b/packages/medusa/src/api-v2/admin/inventory-items/query-config.ts index 6050e82836..e4a158a652 100644 --- a/packages/medusa/src/api-v2/admin/inventory-items/query-config.ts +++ b/packages/medusa/src/api-v2/admin/inventory-items/query-config.ts @@ -45,6 +45,17 @@ export const retrieveTransformQueryConfig = { isList: false, } +export const retrieveLocationLevelsTransformQueryConfig = { + defaults: defaultAdminLocationLevelFields, + allowed: defaultAdminLocationLevelFields, + isList: false, +} + +export const listLocationLevelsTransformQueryConfig = { + ...retrieveLocationLevelsTransformQueryConfig, + isList: true, +} + export const listTransformQueryConfig = { ...retrieveTransformQueryConfig, isList: true, diff --git a/packages/medusa/src/api-v2/admin/inventory-items/validators.ts b/packages/medusa/src/api-v2/admin/inventory-items/validators.ts index 6e68df12e7..bda00a7edf 100644 --- a/packages/medusa/src/api-v2/admin/inventory-items/validators.ts +++ b/packages/medusa/src/api-v2/admin/inventory-items/validators.ts @@ -13,6 +13,7 @@ import { IsObject, IsOptional, IsString, + Min, ValidateNested, } from "class-validator" import { Transform, Type } from "class-transformer" @@ -258,6 +259,31 @@ export class AdminPostInventoryItemsReq { metadata?: Record } +/** + * @schema AdminPostInventoryItemsItemLocationLevelsLevelReq + * type: object + * properties: + * stocked_quantity: + * description: the total stock quantity of an inventory item at the given location ID + * type: number + * incoming_quantity: + * description: the incoming stock quantity of an inventory item at the given location ID + * type: number + */ +export class AdminPostInventoryItemsItemLocationLevelsLevelReq { + @IsOptional() + @IsNumber() + @Min(0) + incoming_quantity?: number + + @IsOptional() + @IsNumber() + @Min(0) + stocked_quantity?: number +} + +// eslint-disable-next-line +export class AdminPostInventoryItemsItemLocationLevelsLevelParams extends FindParams {} /** * @schema AdminPostInventoryItemsInventoryItemReq * type: object diff --git a/packages/types/src/inventory/common/inventory-level.ts b/packages/types/src/inventory/common/inventory-level.ts index 4fd719bed1..2034d0e786 100644 --- a/packages/types/src/inventory/common/inventory-level.ts +++ b/packages/types/src/inventory/common/inventory-level.ts @@ -1,3 +1,5 @@ +import { BaseFilterable, OperatorMap } from "../../dal" + import { NumericalComparisonOperator } from "../../common" /** @@ -53,7 +55,8 @@ export interface InventoryLevelDTO { deleted_at: string | Date | null } -export interface FilterableInventoryLevelProps { +export interface FilterableInventoryLevelProps + extends BaseFilterable { /** * Filter inventory levels by the ID of their associated inventory item. */ @@ -65,13 +68,13 @@ export interface FilterableInventoryLevelProps { /** * Filters to apply on inventory levels' `stocked_quantity` attribute. */ - stocked_quantity?: number | NumericalComparisonOperator + stocked_quantity?: number | OperatorMap /** * Filters to apply on inventory levels' `reserved_quantity` attribute. */ - reserved_quantity?: number | NumericalComparisonOperator + reserved_quantity?: number | OperatorMap /** * Filters to apply on inventory levels' `incoming_quantity` attribute. */ - incoming_quantity?: number | NumericalComparisonOperator + incoming_quantity?: number | OperatorMap } diff --git a/packages/types/src/inventory/mutations/inventory-level.ts b/packages/types/src/inventory/mutations/inventory-level.ts index c4e10df46a..d8c69052c0 100644 --- a/packages/types/src/inventory/mutations/inventory-level.ts +++ b/packages/types/src/inventory/mutations/inventory-level.ts @@ -30,7 +30,7 @@ export interface UpdateInventoryLevelInput { /** * id of the inventory level to update */ - id: string + id?: string /** * The stocked quantity of the associated inventory item in the associated location. */