From f65f590a2771d6e526d7dfc7ca721be74c8f79a9 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Mon, 23 Jan 2023 09:06:23 -0300 Subject: [PATCH] feat: inventory items api (#2971) What: Admin endpoints to handle inventory items and their stock levels per location FIXES: CORE-975 Co-authored-by: Sebastian Rindom <7554214+srindom@users.noreply.github.com> --- .changeset/mean-rings-decide.md | 7 + .../inventory/src/services/inventory-level.ts | 1 - .../src/resources/admin/inventory-item.ts | 168 ++++++++++++ .../src/resources/admin/stock-locations.ts | 42 ++- packages/medusa/src/api/index.js | 1 + packages/medusa/src/api/routes/admin/index.js | 2 + .../inventory-items/create-location-level.ts | 137 ++++++++++ .../inventory-items/delete-inventory-item.ts | 62 +++++ .../inventory-items/delete-location-level.ts | 89 +++++++ .../inventory-items/get-inventory-item.ts | 74 ++++++ .../api/routes/admin/inventory-items/index.ts | 246 ++++++++++++++++++ .../inventory-items/list-inventory-items.ts | 211 +++++++++++++++ .../inventory-items/list-location-levels.ts | 83 ++++++ .../inventory-items/update-inventory-item.ts | 163 ++++++++++++ .../inventory-items/update-location-level.ts | 111 ++++++++ .../inventory-items/utils/join-levels.ts | 65 +++++ .../inventory-items/utils/join-variants.ts | 31 +++ .../stock-locations/delete-stock-location.ts | 72 +++++ .../api/routes/admin/stock-locations/index.ts | 25 +- .../src/interfaces/services/stock-location.ts | 2 + packages/medusa/src/types/inventory.ts | 94 ++++++- 21 files changed, 1671 insertions(+), 15 deletions(-) create mode 100644 .changeset/mean-rings-decide.md create mode 100644 packages/medusa-js/src/resources/admin/inventory-item.ts create mode 100644 packages/medusa/src/api/routes/admin/inventory-items/create-location-level.ts create mode 100644 packages/medusa/src/api/routes/admin/inventory-items/delete-inventory-item.ts create mode 100644 packages/medusa/src/api/routes/admin/inventory-items/delete-location-level.ts create mode 100644 packages/medusa/src/api/routes/admin/inventory-items/get-inventory-item.ts create mode 100644 packages/medusa/src/api/routes/admin/inventory-items/index.ts create mode 100644 packages/medusa/src/api/routes/admin/inventory-items/list-inventory-items.ts create mode 100644 packages/medusa/src/api/routes/admin/inventory-items/list-location-levels.ts create mode 100644 packages/medusa/src/api/routes/admin/inventory-items/update-inventory-item.ts create mode 100644 packages/medusa/src/api/routes/admin/inventory-items/update-location-level.ts create mode 100644 packages/medusa/src/api/routes/admin/inventory-items/utils/join-levels.ts create mode 100644 packages/medusa/src/api/routes/admin/inventory-items/utils/join-variants.ts create mode 100644 packages/medusa/src/api/routes/admin/stock-locations/delete-stock-location.ts diff --git a/.changeset/mean-rings-decide.md b/.changeset/mean-rings-decide.md new file mode 100644 index 0000000000..17576bac6c --- /dev/null +++ b/.changeset/mean-rings-decide.md @@ -0,0 +1,7 @@ +--- +"@medusajs/inventory": patch +"@medusajs/medusa": minor +"@medusajs/medusa-js": minor +--- + +Adding inventory items api diff --git a/packages/inventory/src/services/inventory-level.ts b/packages/inventory/src/services/inventory-level.ts index a46f51effe..97f1f4dc63 100644 --- a/packages/inventory/src/services/inventory-level.ts +++ b/packages/inventory/src/services/inventory-level.ts @@ -139,7 +139,6 @@ export default class InventoryLevelService extends TransactionBaseService { * Updates an existing inventory level. * @param inventoryLevelId - The ID of the inventory level to update. * @param data - An object containing the properties to update on the inventory level. - * @param autoSave - A flag indicating whether to save the changes automatically. * @return The updated inventory level. * @throws If the inventory level ID is not defined or the given ID was not found. */ diff --git a/packages/medusa-js/src/resources/admin/inventory-item.ts b/packages/medusa-js/src/resources/admin/inventory-item.ts new file mode 100644 index 0000000000..b3956de917 --- /dev/null +++ b/packages/medusa-js/src/resources/admin/inventory-item.ts @@ -0,0 +1,168 @@ +import { + AdminGetInventoryItemsParams, + AdminInventoryItemsRes, + AdminPostInventoryItemsInventoryItemReq, + AdminGetInventoryItemsItemLocationLevelsParams, + AdminPostInventoryItemsItemLocationLevelsLevelReq, + AdminInventoryItemsDeleteRes, + AdminGetInventoryItemsItemParams, + AdminInventoryItemsListWithVariantsAndLocationLevelsRes, + AdminInventoryItemsLocationLevelsRes, +} from "@medusajs/medusa" +import { ResponsePromise } from "../../typings" +import BaseResource from "../base" +import qs from "qs" + +class AdminInventoryItemsResource extends BaseResource { + /** + * Retrieve an Inventory Item + * @experimental This feature is under development and may change in the future. + * To use this feature please install @medusajs/inventory + * @description gets an Inventory Item + * @returns an Inventory Item + */ + retrieve( + inventoryItemId: string, + query?: AdminGetInventoryItemsItemParams, + customHeaders: Record = {} + ): ResponsePromise { + let path = `/admin/inventory-items/${inventoryItemId}` + + if (query) { + const queryString = qs.stringify(query) + path += `?${queryString}` + } + + return this.client.request("GET", path, undefined, {}, customHeaders) + } + + /** + * Update an Inventory Item + * @experimental This feature is under development and may change in the future. + * To use this feature please install @medusajs/inventory + * @description updates an Inventory Item + * @returns the updated Inventory Item + */ + update( + inventoryItemId: string, + payload: AdminPostInventoryItemsInventoryItemReq, + query?: AdminGetInventoryItemsItemParams, + customHeaders: Record = {} + ): ResponsePromise { + let path = `/admin/inventory-items/${inventoryItemId}` + + if (query) { + const queryString = qs.stringify(query) + path += `?${queryString}` + } + + return this.client.request("POST", path, payload, {}, customHeaders) + } + + /** + * Delete an Inventory Item + * @experimental This feature is under development and may change in the future. + * To use this feature please install @medusajs/inventory + * @description deletes an Inventory Item + * @returns the deleted Inventory Item + */ + delete( + inventoryItemId: string, + customHeaders: Record = {} + ): ResponsePromise { + const path = `/admin/inventory-items/${inventoryItemId}` + return this.client.request("DELETE", path, undefined, {}, customHeaders) + } + + /** + * Retrieve a list of Inventory Items + * @experimental This feature is under development and may change in the future. + * To use this feature please install @medusajs/inventory + * @description Retrieve a list of Inventory Items + * @returns the list of Inventory Items as well as the pagination properties + */ + list( + query?: AdminGetInventoryItemsParams, + customHeaders: Record = {} + ): ResponsePromise { + let path = `/admin/inventory-items` + + if (query) { + const queryString = qs.stringify(query) + path += `?${queryString}` + } + + return this.client.request("GET", path, undefined, {}, customHeaders) + } + + /** + * Update an Inventory Item's stock level at a Stock Location + * @experimental This feature is under development and may change in the future. + * To use this feature please install @medusajs/inventory + * @description updates an Inventory Item + * @returns the updated Inventory Item + */ + updateLocationLevel( + inventoryItemId: string, + locationId: string, + payload: AdminPostInventoryItemsItemLocationLevelsLevelReq, + query?: AdminGetInventoryItemsParams, + customHeaders: Record = {} + ): ResponsePromise { + let path = `/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}` + + if (query) { + const queryString = qs.stringify(query) + path += `?${queryString}` + } + + return this.client.request("POST", path, payload, {}, customHeaders) + } + + /** + * Removes an Inventory Item from a Stock Location. This erases trace of any quantity currently at the location. + * @experimental This feature is under development and may change in the future. + * To use this feature please install @medusajs/inventory + * @description deletes a location level of an Inventory Item + * @returns the Inventory Item + */ + deleteLocationLevel( + inventoryItemId: string, + locationId: string, + query?: AdminGetInventoryItemsParams, + customHeaders: Record = {} + ): ResponsePromise { + let path = `/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}` + + if (query) { + const queryString = qs.stringify(query) + path += `?${queryString}` + } + + return this.client.request("DELETE", path, undefined, {}, customHeaders) + } + + /** + * Retrieve a list of Inventory Levels related to an Inventory Item across Stock Locations + * @experimental This feature is under development and may change in the future. + * To use this feature please install @medusajs/inventory + * @description Retrieve a list of location levels related to an Inventory Item + * @returns the list of inventory levels related to an Inventory Item as well as the pagination properties + */ + listLocationLevels( + inventoryItemId: string, + query?: AdminGetInventoryItemsItemLocationLevelsParams, + customHeaders: Record = {} + ): ResponsePromise { + let path = `/admin/inventory-items/${inventoryItemId}` + + if (query) { + const queryString = qs.stringify(query) + path += `?${queryString}` + } + + return this.client.request("GET", path, undefined, {}, customHeaders) + } +} + +export default AdminInventoryItemsResource diff --git a/packages/medusa-js/src/resources/admin/stock-locations.ts b/packages/medusa-js/src/resources/admin/stock-locations.ts index 148b945b4f..9ec0914902 100644 --- a/packages/medusa-js/src/resources/admin/stock-locations.ts +++ b/packages/medusa-js/src/resources/admin/stock-locations.ts @@ -4,17 +4,19 @@ import { AdminPostStockLocationsLocationReq, AdminPostStockLocationsReq, AdminStockLocationsListRes, + AdminStockLocationsDeleteRes, } from "@medusajs/medusa" import { ResponsePromise } from "../../typings" import BaseResource from "../base" import qs from "qs" class AdminStockLocationsResource extends BaseResource { - /** retrieve an stock location + /** + * Create a Stock Location * @experimental This feature is under development and may change in the future. * To use this feature please install @medusajs/stock-location - * @description gets a medusa stock location - * @returns a medusa stock location + * @description gets a medusa Stock Location + * @returns a medusa Stock Location */ create( payload: AdminPostStockLocationsReq, @@ -24,11 +26,12 @@ class AdminStockLocationsResource extends BaseResource { return this.client.request("POST", path, payload, {}, customHeaders) } - /** retrieve an stock location + /** + * Retrieve a Stock Location * @experimental This feature is under development and may change in the future. * To use this feature please install @medusajs/stock-location - * @description gets a medusa stock location - * @returns a medusa stock location + * @description gets a medusa Stock Location + * @returns a medusa Stock Location */ retrieve( itemId: string, @@ -38,11 +41,12 @@ class AdminStockLocationsResource extends BaseResource { return this.client.request("GET", path, undefined, {}, customHeaders) } - /** update an stock location + /** + * Update a Stock Location * @experimental This feature is under development and may change in the future. * To use this feature please install @medusajs/stock-location - * @description updates an stock location - * @returns the updated medusa stock location + * @description updates a Stock Location + * @returns the updated medusa Stock Location */ update( stockLocationId: string, @@ -54,11 +58,25 @@ class AdminStockLocationsResource extends BaseResource { } /** - * Retrieve a list of stock locations + * Delete a Stock Location * @experimental This feature is under development and may change in the future. * To use this feature please install @medusajs/stock-location - * @description Retrieve a list of stock locations - * @returns the list of stock locations as well as the pagination properties + * @description deletes a Stock Location + */ + delete( + id: string, + customHeaders: Record = {} + ): ResponsePromise { + const path = `/admin/stock-locations/${id}` + return this.client.request("DELETE", path, undefined, {}, customHeaders) + } + + /** + * Retrieve a list of Stock Locations + * @experimental This feature is under development and may change in the future. + * To use this feature please install @medusajs/stock-location + * @description Retrieve a list of Stock Locations + * @returns the list of Stock Locations as well as the pagination properties */ list( query?: AdminGetStockLocationsParams, diff --git a/packages/medusa/src/api/index.js b/packages/medusa/src/api/index.js index bf4e498927..d4086f3a94 100644 --- a/packages/medusa/src/api/index.js +++ b/packages/medusa/src/api/index.js @@ -26,6 +26,7 @@ export * from "./routes/admin/customers" export * from "./routes/admin/discounts" export * from "./routes/admin/draft-orders" export * from "./routes/admin/gift-cards" +export * from "./routes/admin/inventory-items" export * from "./routes/admin/invites" export * from "./routes/admin/notes" export * from "./routes/admin/notifications" diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index ce23ec273a..1f22a5ff59 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -12,6 +12,7 @@ import customerRoutes from "./customers" import discountRoutes from "./discounts" import draftOrderRoutes from "./draft-orders" import giftCardRoutes from "./gift-cards" +import inventoryItemRoutes from "./inventory-items" import inviteRoutes, { unauthenticatedInviteRoutes } from "./invites" import noteRoutes from "./notes" import notificationRoutes from "./notifications" @@ -84,6 +85,7 @@ export default (app, container, config) => { discountRoutes(route) draftOrderRoutes(route) giftCardRoutes(route) + inventoryItemRoutes(route) inviteRoutes(route) noteRoutes(route) notificationRoutes(route) diff --git a/packages/medusa/src/api/routes/admin/inventory-items/create-location-level.ts b/packages/medusa/src/api/routes/admin/inventory-items/create-location-level.ts new file mode 100644 index 0000000000..4401fc1ed9 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/inventory-items/create-location-level.ts @@ -0,0 +1,137 @@ +import { Request, Response } from "express" +import { IsNumber, IsOptional, IsString } from "class-validator" + +import { + IInventoryService, + IStockLocationService, +} from "../../../../interfaces" +import { FindParams } from "../../../../types/common" + +/** + * @oas [post] /inventory-items/{id}/location-levels + * operationId: "PostInventoryItemsInventoryItemLocationLevels" + * summary: "Create an Inventory Location Level for a given Inventory Item." + * description: "Creates an Inventory Location Level for a given Inventory Item." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The ID of the Inventory Item. + * - (query) expand {string} Comma separated list of relations to include in the results. + * - (query) fields {string} Comma separated list of fields to include in the results. + * requestBody: + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/AdminPostInventoryItemsItemLocationLevelsReq" + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.inventoryItems.createLocationLevel(inventoryItemId, { + * location_id: 'sloc', + * stocked_quantity: 10, + * }) + * .then(({ inventory_item }) => { + * console.log(inventory_item.id); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request POST 'https://medusa-url.com/admin/inventory-items/{id}/location-levels' \ + * --header 'Authorization: Bearer {api_token}' \ + * --header 'Content-Type: application/json' \ + * --data-raw '{ + * "location_id": "sloc", + * "stocked_quantity": 10 + * }' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Inventory Items + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/AdminInventoryItemsRes" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response) => { + const { id } = req.params + + const inventoryService: IInventoryService = + req.scope.resolve("inventoryService") + + const stockLocationService: IStockLocationService | undefined = + req.scope.resolve("stockLocationService") + + const validatedBody = + req.validatedBody as AdminPostInventoryItemsItemLocationLevelsReq + + const location_id = validatedBody.location_id + if (stockLocationService) { + // will throw an error if not found + await stockLocationService.retrieve(location_id) + } + + await inventoryService.createInventoryLevel({ + inventory_item_id: id, + location_id, + stocked_quantity: validatedBody.stocked_quantity, + incoming_quantity: validatedBody.incoming_quantity, + }) + + const inventoryItem = await inventoryService.retrieveInventoryItem( + id, + req.retrieveConfig + ) + + res.status(200).json({ inventory_item: inventoryItem }) +} + +/** + * @schema AdminPostInventoryItemsItemLocationLevelsReq + * type: object + * required: + * - location_id + * - stocked_quantity + * properties: + * location_id: + * description: the item location ID + * type: string + * stocked_quantity: + * description: the 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 AdminPostInventoryItemsItemLocationLevelsReq { + @IsString() + location_id: string + + @IsNumber() + stocked_quantity: number + + @IsOptional() + @IsNumber() + incoming_quantity?: number +} + +// eslint-disable-next-line +export class AdminPostInventoryItemsItemLocationLevelsParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/admin/inventory-items/delete-inventory-item.ts b/packages/medusa/src/api/routes/admin/inventory-items/delete-inventory-item.ts new file mode 100644 index 0000000000..9d6e807235 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/inventory-items/delete-inventory-item.ts @@ -0,0 +1,62 @@ +import { Request, Response } from "express" +import { EntityManager } from "typeorm" +import { IInventoryService } from "../../../../interfaces" + +/** + * @oas [delete] /inventory-items/{id} + * operationId: "DeleteInventoryItemsInventoryItem" + * summary: "Delete an Inventory Item" + * description: "Delete an Inventory Item" + * x-authenticated: true + * parameters: + * - (path) id=* {string} The ID of the Inventory Item to delete. + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.inventoryItems.delete(inventoryItemId) + * .then(({ id, object, deleted }) => { + * console.log(id) + * }) + * - lang: Shell + * label: cURL + * source: | + * curl --location --request DELETE 'https://medusa-url.com/admin/inventory-items/{id}' \ + * --header 'Authorization: Bearer {api_token}' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - InventoryItem + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/AdminInventoryItemsDeleteRes" + * "400": + * $ref: "#/components/responses/400_error" + */ +export default async (req: Request, res: Response) => { + const { id } = req.params + + const inventoryService: IInventoryService = + req.scope.resolve("inventoryService") + + const manager: EntityManager = req.scope.resolve("manager") + await manager.transaction(async (transactionManager) => { + await inventoryService + .withTransaction(transactionManager) + .deleteInventoryItem(id) + }) + + res.status(200).send({ + id, + object: "inventory_item", + deleted: true, + }) +} diff --git a/packages/medusa/src/api/routes/admin/inventory-items/delete-location-level.ts b/packages/medusa/src/api/routes/admin/inventory-items/delete-location-level.ts new file mode 100644 index 0000000000..4471b76fa7 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/inventory-items/delete-location-level.ts @@ -0,0 +1,89 @@ +import { Request, Response } from "express" +import { MedusaError } from "medusa-core-utils" +import { EntityManager } from "typeorm" +import { IInventoryService } from "../../../../interfaces" + +/** + * @oas [delete] /inventory-items/{id}/location-levels/{location_id} + * operationId: "DeleteInventoryItemsInventoryIteLocationLevelsLocation" + * summary: "Delete a location level of an Inventory Item." + * description: "Delete a location level of an Inventory Item." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The ID of the Inventory Item. + * - (path) location_id=* {string} The ID of the location. + * - (query) expand {string} Comma separated list of relations to include in the results. + * - (query) fields {string} Comma separated list of fields to include in the results. + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.inventoryItems.deleteLocationLevel(inventoryItemId, locationId) + * .then(({ inventory_item }) => { + * console.log(inventory_item.id); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request DELETE 'https://medusa-url.com/admin/inventory-items/{id}/location-levels/{location_id}' \ + * --header 'Authorization: Bearer {api_token}' \ + * --header 'Content-Type: application/json' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Inventory Items + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/AdminInventoryItemsRes" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response) => { + const { id, location_id } = req.params + + const inventoryService: IInventoryService = + req.scope.resolve("inventoryService") + const manager: EntityManager = req.scope.resolve("manager") + + const reservedQuantity = await inventoryService.retrieveReservedQuantity(id, [ + location_id, + ]) + + if (reservedQuantity > 0) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Cannot remove Inventory Level ${id} at Location ${location_id} because there are reserved items.` + ) + } + + await manager.transaction(async (transactionManager) => { + await inventoryService + .withTransaction(transactionManager) + .deleteInventoryLevel(id, location_id) + }) + + const inventoryItem = await inventoryService.retrieveInventoryItem( + id, + req.retrieveConfig + ) + + res.status(200).json({ inventory_item: inventoryItem }) +} diff --git a/packages/medusa/src/api/routes/admin/inventory-items/get-inventory-item.ts b/packages/medusa/src/api/routes/admin/inventory-items/get-inventory-item.ts new file mode 100644 index 0000000000..3efc087308 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/inventory-items/get-inventory-item.ts @@ -0,0 +1,74 @@ +import { IInventoryService } from "../../../../interfaces" +import { Request, Response } from "express" +import { FindParams } from "../../../../types/common" +import { joinLevels } from "./utils/join-levels" + +/** + * @oas [get] /inventory-items/{id} + * operationId: "GetInventoryItemsInventoryItem" + * summary: "Retrive an Inventory Item." + * description: "Retrives an Inventory Item." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The ID of the Inventory Item. + * - (query) expand {string} Comma separated list of relations to include in the results. + * - (query) fields {string} Comma separated list of fields to include in the results. + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.inventoryItems.retrieve(inventoryItemId) + * .then(({ inventory_item }) => { + * console.log(inventory_item.id); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request GET 'https://medusa-url.com/admin/inventory-items/{id}' \ + * --header 'Authorization: Bearer {api_token}' \ + * --header 'Content-Type: application/json' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Inventory Items + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/AdminInventoryItemsRes" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response) => { + const { id } = req.params + + const inventoryService: IInventoryService = + req.scope.resolve("inventoryService") + + const inventoryItem = await inventoryService.retrieveInventoryItem( + id, + req.retrieveConfig + ) + + const [data] = await joinLevels([inventoryItem], [], inventoryService) + + res.status(200).json({ inventory_item: data }) +} + +export class AdminGetInventoryItemsItemParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/admin/inventory-items/index.ts b/packages/medusa/src/api/routes/admin/inventory-items/index.ts new file mode 100644 index 0000000000..40ac0023fa --- /dev/null +++ b/packages/medusa/src/api/routes/admin/inventory-items/index.ts @@ -0,0 +1,246 @@ +import { Router } from "express" +import "reflect-metadata" +import { DeleteResponse, PaginatedResponse } from "../../../../types/common" +import { + InventoryItemDTO, + InventoryLevelDTO, +} from "../../../../types/inventory" +import middlewares, { + transformBody, + transformQuery, +} from "../../../middlewares" +import { AdminGetInventoryItemsParams } from "./list-inventory-items" +import { AdminGetInventoryItemsItemParams } from "./get-inventory-item" +import { AdminPostInventoryItemsInventoryItemReq } from "./update-inventory-item" +import { AdminGetInventoryItemsItemLocationLevelsParams } from "./list-location-levels" +import { + AdminPostInventoryItemsItemLocationLevelsReq, + AdminPostInventoryItemsItemLocationLevelsParams, +} from "./create-location-level" +import { + AdminPostInventoryItemsItemLocationLevelsLevelReq, + AdminPostInventoryItemsItemLocationLevelsLevelParams, +} from "./update-location-level" +import { checkRegisteredModules } from "../../../middlewares/check-registered-modules" +import { ProductVariant } from "../../../../models" + +const route = Router() + +export default (app) => { + app.use( + "/inventory-items", + checkRegisteredModules({ + inventoryService: + "Inventory is not enabled. Please add an Inventory module to enable this functionality.", + }), + route + ) + + route.get( + "/", + transformQuery(AdminGetInventoryItemsParams, { + defaultFields: defaultAdminInventoryItemFields, + defaultRelations: defaultAdminInventoryItemRelations, + isList: true, + }), + middlewares.wrap(require("./list-inventory-items").default) + ) + + route.post( + "/:id", + transformQuery(AdminGetInventoryItemsItemParams, { + defaultFields: defaultAdminInventoryItemFields, + defaultRelations: defaultAdminInventoryItemRelations, + isList: false, + }), + transformBody(AdminPostInventoryItemsInventoryItemReq), + middlewares.wrap(require("./update-inventory-item").default) + ) + + route.delete( + "/:id", + middlewares.wrap(require("./delete-inventory-item").default) + ) + + route.post( + "/:id/location-levels", + transformQuery(AdminPostInventoryItemsItemLocationLevelsParams, { + defaultFields: defaultAdminInventoryItemFields, + defaultRelations: defaultAdminInventoryItemRelations, + isList: false, + }), + transformBody(AdminPostInventoryItemsItemLocationLevelsReq), + middlewares.wrap(require("./create-location-level").default) + ) + + route.get( + "/:id/location-levels", + transformQuery(AdminGetInventoryItemsItemLocationLevelsParams, { + defaultFields: defaultAdminInventoryItemFields, + defaultRelations: defaultAdminInventoryItemRelations, + isList: false, + }), + middlewares.wrap(require("./list-location-levels").default) + ) + + route.delete( + "/:id/location-levels/:location_id", + middlewares.wrap(require("./delete-location-level").default) + ) + + route.post( + "/:id/location-levels/:location_id", + transformQuery(AdminPostInventoryItemsItemLocationLevelsLevelParams, { + defaultFields: defaultAdminInventoryItemFields, + defaultRelations: defaultAdminInventoryItemRelations, + isList: false, + }), + transformBody(AdminPostInventoryItemsItemLocationLevelsLevelReq), + middlewares.wrap(require("./update-location-level").default) + ) + + route.get( + "/:id", + transformQuery(AdminGetInventoryItemsItemParams, { + defaultFields: defaultAdminInventoryItemFields, + defaultRelations: defaultAdminInventoryItemRelations, + isList: false, + }), + middlewares.wrap(require("./get-inventory-item").default) + ) + + return app +} + +export const defaultAdminInventoryItemFields: (keyof InventoryItemDTO)[] = [ + "id", + "sku", + "origin_country", + "hs_code", + "requires_shipping", + "mid_code", + "material", + "weight", + "length", + "height", + "width", + "metadata", + "created_at", + "updated_at", +] + +export const defaultAdminInventoryItemRelations = [] + +/** + * @schema AdminInventoryItemsRes + * type: object + * properties: + * inventory_item: + * $ref: "#/components/schemas/InventoryItemDTO" + */ +export type AdminInventoryItemsRes = { + inventory_item: InventoryItemDTO +} + +/** + * @schema AdminInventoryItemsDeleteRes + * type: object + * properties: + * id: + * type: string + * description: The ID of the deleted Inventory Item. + * object: + * type: string + * description: The type of the object that was deleted. + * format: inventory_item + * deleted: + * type: boolean + * description: Whether or not the Inventory Item was deleted. + * default: true + */ +export type AdminInventoryItemsDeleteRes = DeleteResponse + +/** + * @schema AdminInventoryItemsListRes + * type: object + * properties: + * inventory_items: + * type: array + * items: + * $ref: "#/components/schemas/InventoryItemDTO" + * count: + * type: integer + * description: The total number of items available + * offset: + * type: integer + * description: The number of items skipped before these items + * limit: + * type: integer + * description: The number of items per page + */ +export type AdminInventoryItemsListRes = PaginatedResponse & { + inventory_items: InventoryItemDTO[] +} + +/** + * @schema AdminInventoryItemsListWithVariantsAndLocationLevelsRes + * type: object + * properties: + * inventory_items: + * type: array + * items: + * allOf: + * - $ref: "#/components/schemas/InventoryItemDTO" + * - type: object + * properties: + * location_levels: + * type: array + * items: + * allOf: + * - $ref: "#/components/schemas/InventoryLevelDTO" + * variants: + * type: array + * items: + * allOf: + * - $ref: "#/components/schemas/ProductVariant" + * count: + * type: integer + * description: The total number of items available + * offset: + * type: integer + * description: The number of items skipped before these items + * limit: + * type: integer + * description: The number of items per page + */ +export type AdminInventoryItemsListWithVariantsAndLocationLevelsRes = + Partial & { + location_levels?: InventoryLevelDTO[] + variants?: ProductVariant[] + } + +/** + * @schema AdminInventoryItemsLocationLevelsRes + * type: object + * properties: + * id: + * description: The id of the location + * location_levels: + * description: List of stock levels at a given location + * type: array + * items: + * $ref: "#/components/schemas/InventoryLevelDTO" + */ +export type AdminInventoryItemsLocationLevelsRes = { + inventory_item: { + id + location_levels: InventoryLevelDTO[] + } +} + +export * from "./list-inventory-items" +export * from "./get-inventory-item" +export * from "./update-inventory-item" +export * from "./list-location-levels" +export * from "./create-location-level" +export * from "./update-location-level" diff --git a/packages/medusa/src/api/routes/admin/inventory-items/list-inventory-items.ts b/packages/medusa/src/api/routes/admin/inventory-items/list-inventory-items.ts new file mode 100644 index 0000000000..2377af2d94 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/inventory-items/list-inventory-items.ts @@ -0,0 +1,211 @@ +import { Request, Response } from "express" +import { IsString, IsBoolean, IsOptional } from "class-validator" +import { Transform } from "class-transformer" +import { IsType } from "../../../../utils/validators/is-type" +import { getLevelsByInventoryItemId } from "./utils/join-levels" +import { + getVariantsByInventoryItemId, + InventoryItemsWithVariants, +} from "./utils/join-variants" +import { + ProductVariantInventoryService, + ProductVariantService, +} from "../../../../services" +import { IInventoryService } from "../../../../interfaces" +import { + extendedFindParamsMixin, + StringComparisonOperator, + NumericalComparisonOperator, +} from "../../../../types/common" +import { AdminInventoryItemsListWithVariantsAndLocationLevelsRes } from "." + +/** + * @oas [get] /inventory-items + * operationId: "GetInventoryItems" + * summary: "List inventory items." + * description: "Lists inventory items." + * x-authenticated: true + * parameters: + * - (query) offset=0 {integer} How many inventory items to skip in the result. + * - (query) limit=20 {integer} Limit the number of inventory items returned. + * - (query) expand {string} Comma separated list of relations to include in the results. + * - (query) fields {string} Comma separated list of fields to include in the results. + * - (query) q {string} Query used for searching product inventory items and their properties. + * - in: query + * name: location_id + * style: form + * explode: false + * description: Locations ids to search for. + * schema: + * type: array + * items: + * type: string + * - (query) id {string} id to search for. + * - (query) sku {string} sku to search for. + * - (query) origin_country {string} origin_country to search for. + * - (query) mid_code {string} mid_code to search for. + * - (query) material {string} material to search for. + * - (query) hs_code {string} hs_code to search for. + * - (query) weight {string} weight to search for. + * - (query) length {string} length to search for. + * - (query) height {string} height to search for. + * - (query) width {string} width to search for. + * - (query) requires_shipping {string} requires_shipping to search for. + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.inventoryItems.list() + * .then(({ inventory_items }) => { + * console.log(inventory_items.length); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request GET 'https://medusa-url.com/admin/inventory-items' \ + * --header 'Authorization: Bearer {api_token}' \ + * --header 'Content-Type: application/json' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Inventory Items + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/AdminInventoryItemsListWithVariantsAndLocationLevelsRes" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ + +export default async (req: Request, res: Response) => { + const inventoryService: IInventoryService = + req.scope.resolve("inventoryService") + const productVariantInventoryService: ProductVariantInventoryService = + req.scope.resolve("productVariantInventoryService") + const productVariantService: ProductVariantService = req.scope.resolve( + "productVariantService" + ) + + const { filterableFields, listConfig } = req + const { skip, take } = listConfig + + let locationIds: string[] = [] + + if (filterableFields.location_id) { + locationIds = Array.isArray(filterableFields.location_id) + ? filterableFields.location_id + : [filterableFields.location_id] + } + + const [inventoryItems, count] = await inventoryService.listInventoryItems( + filterableFields, + listConfig + ) + + const levelsByItemId = await getLevelsByInventoryItemId( + inventoryItems, + locationIds, + inventoryService + ) + + const variantsByInventoryItemId: InventoryItemsWithVariants = + await getVariantsByInventoryItemId( + inventoryItems, + productVariantInventoryService, + productVariantService + ) + + const inventoryItemsWithVariantsAndLocationLevels = inventoryItems.map( + ( + inventoryItem + ): AdminInventoryItemsListWithVariantsAndLocationLevelsRes => { + return { + ...inventoryItem, + variants: variantsByInventoryItemId[inventoryItem.id] ?? [], + location_levels: levelsByItemId[inventoryItem.id] ?? [], + } + } + ) + + res.status(200).json({ + inventory_items: inventoryItemsWithVariantsAndLocationLevels, + count, + offset: skip, + limit: take, + }) +} + +export class AdminGetInventoryItemsParams extends extendedFindParamsMixin({ + limit: 20, + offset: 0, +}) { + @IsOptional() + @IsType([String, [String]]) + id?: string | string[] + + @IsOptional() + @IsString() + q?: string + + @IsOptional() + @IsType([String, [String]]) + location_id?: string | string[] + + @IsOptional() + @IsType([String, [String]]) + sku?: string | string[] + + @IsOptional() + @IsType([String, [String]]) + origin_country?: string | string[] + + @IsOptional() + @IsType([String, [String]]) + mid_code?: string | string[] + + @IsOptional() + @IsType([String, [String]]) + material?: string | string[] + + @IsOptional() + @IsType([String, [String], StringComparisonOperator]) + hs_code?: string | string[] | StringComparisonOperator + + @IsOptional() + @IsType([Number, NumericalComparisonOperator]) + weight?: number | NumericalComparisonOperator + + @IsOptional() + @IsType([Number, NumericalComparisonOperator]) + length?: number | NumericalComparisonOperator + + @IsOptional() + @IsType([Number, NumericalComparisonOperator]) + height?: number | NumericalComparisonOperator + + @IsOptional() + @IsType([Number, NumericalComparisonOperator]) + width?: number | NumericalComparisonOperator + + @IsBoolean() + @IsOptional() + @Transform(({ value }) => value === "true") + requires_shipping?: boolean +} diff --git a/packages/medusa/src/api/routes/admin/inventory-items/list-location-levels.ts b/packages/medusa/src/api/routes/admin/inventory-items/list-location-levels.ts new file mode 100644 index 0000000000..3842b59c0f --- /dev/null +++ b/packages/medusa/src/api/routes/admin/inventory-items/list-location-levels.ts @@ -0,0 +1,83 @@ +import { Request, Response } from "express" + +import { IInventoryService } from "../../../../interfaces" +import { FindParams } from "../../../../types/common" + +/** + * @oas [get] /inventory-items/{id}/location-levels + * operationId: "GetInventoryItemsInventoryItemLocationLevels" + * summary: "List stock levels of a given location." + * description: "Lists stock levels of a given location." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The ID of the Inventory Item. + * - (query) offset=0 {integer} How many stock locations levels to skip in the result. + * - (query) limit=20 {integer} Limit the number of stock locations levels returned. + * - (query) expand {string} Comma separated list of relations to include in the results. + * - (query) fields {string} Comma separated list of fields to include in the results. + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.inventoryItems.listLocationLevels(inventoryItemId) + * .then(({ inventory_item }) => { + * console.log(inventory_item.location_levels); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request GET 'https://medusa-url.com/admin/inventory-items/{id}/location-levels' \ + * --header 'Authorization: Bearer {api_token}' \ + * --header 'Content-Type: application/json' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Inventory Items + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/AdminInventoryItemsLocationLevelsRes" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ + +export default async (req: Request, res: Response) => { + const { id } = req.params + + const inventoryService: IInventoryService = + req.scope.resolve("inventoryService") + + const [levels] = await inventoryService.listInventoryLevels( + { + inventory_item_id: id, + }, + req.retrieveConfig + ) + + res.status(200).json({ + inventory_item: { + id, + location_levels: levels, + }, + }) +} + +// eslint-disable-next-line max-len +export class AdminGetInventoryItemsItemLocationLevelsParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/admin/inventory-items/update-inventory-item.ts b/packages/medusa/src/api/routes/admin/inventory-items/update-inventory-item.ts new file mode 100644 index 0000000000..8e7974fa01 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/inventory-items/update-inventory-item.ts @@ -0,0 +1,163 @@ +import { Request, Response } from "express" +import { IsBoolean, IsNumber, IsOptional, IsString } from "class-validator" + +import { IInventoryService } from "../../../../interfaces" +import { FindParams } from "../../../../types/common" + +/** + * @oas [post] /inventory-items/{id} + * operationId: "PostInventoryItemsInventoryItem" + * summary: "Update an Inventory Item." + * description: "Updates an Inventory Item." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The ID of the Inventory Item. + * - (query) expand {string} Comma separated list of relations to include in the results. + * - (query) fields {string} Comma separated list of fields to include in the results. + * requestBody: + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/AdminPostInventoryItemsInventoryItemReq" + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.inventoryItems.update(inventoryItemId, { + * origin_country: "US", + * }) + * .then(({ inventory_item }) => { + * console.log(inventory_item.id); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request POST 'https://medusa-url.com/admin/inventory-items/{id}' \ + * --header 'Authorization: Bearer {api_token}' \ + * --header 'Content-Type: application/json' \ + * --data-raw '{ + * "origin_country": "US" + * }' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Inventory Items + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/AdminInventoryItemsRes" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response) => { + const { id } = req.params + + const inventoryService: IInventoryService = + req.scope.resolve("inventoryService") + + await inventoryService.updateInventoryItem( + id, + req.validatedBody as AdminPostInventoryItemsInventoryItemReq + ) + + const inventoryItem = await inventoryService.retrieveInventoryItem( + id, + req.retrieveConfig + ) + + res.status(200).json({ inventory_item: inventoryItem }) +} + +/** + * @schema AdminPostInventoryItemsInventoryItemReq + * type: object + * properties: + * hs_code: + * description: The Harmonized System code of the Inventory Item. May be used by Fulfillment Providers to pass customs information to shipping carriers. + * type: string + * origin_country: + * description: The country in which the Inventory Item was produced. May be used by Fulfillment Providers to pass customs information to shipping carriers. + * type: string + * mid_code: + * description: The Manufacturers Identification code that identifies the manufacturer of the Inventory Item. May be used by Fulfillment Providers to pass customs information to shipping carriers. + * type: string + * material: + * description: The material and composition that the Inventory Item is made of, May be used by Fulfillment Providers to pass customs information to shipping carriers. + * type: string + * weight: + * description: The weight of the Inventory Item. May be used in shipping rate calculations. + * type: number + * height: + * description: The height of the Inventory Item. May be used in shipping rate calculations. + * type: number + * width: + * description: The width of the Inventory Item. May be used in shipping rate calculations. + * type: number + * length: + * description: The length of the Inventory Item. May be used in shipping rate calculations. + * type: number + * requires_shipping: + * description: Whether the item requires shipping. + * type: boolean + */ + +export class AdminPostInventoryItemsInventoryItemReq { + @IsString() + @IsOptional() + sku?: string + + @IsOptional() + @IsString() + origin_country?: string + + @IsOptional() + @IsString() + hs_code?: string + + @IsOptional() + @IsString() + mid_code?: string + + @IsOptional() + @IsString() + material?: string + + @IsOptional() + @IsNumber() + weight?: number + + @IsOptional() + @IsNumber() + height?: number + + @IsOptional() + @IsNumber() + length?: number + + @IsOptional() + @IsNumber() + width?: number + + @IsBoolean() + @IsOptional() + requires_shipping?: boolean +} + +export class AdminPostInventoryItemsInventoryItemParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/admin/inventory-items/update-location-level.ts b/packages/medusa/src/api/routes/admin/inventory-items/update-location-level.ts new file mode 100644 index 0000000000..0be5bc1649 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/inventory-items/update-location-level.ts @@ -0,0 +1,111 @@ +import { Request, Response } from "express" +import { IsOptional, IsNumber } from "class-validator" + +import { IInventoryService } from "../../../../interfaces" +import { FindParams } from "../../../../types/common" + +/** + * @oas [post] /inventory-items/{id}/location-levels/{location_id} + * operationId: "PostInventoryItemsInventoryItemLocationLevelsLocationLevel" + * summary: "Update an Inventory Location Level for a given Inventory Item." + * description: "Updates an Inventory Location Level for a given Inventory Item." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The ID of the Inventory Item. + * - (path) location_id=* {string} The ID of the Location. + * - (query) expand {string} Comma separated list of relations to include in the results. + * - (query) fields {string} Comma separated list of fields to include in the results. + * requestBody: + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/AdminPostInventoryItemsItemLocationLevelsLevelReq" + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.inventoryItems.updateLocationLevel(inventoryItemId, locationId, { + * stocked_quantity: 15, + * }) + * .then(({ inventory_item }) => { + * console.log(inventory_item.id); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request POST 'https://medusa-url.com/admin/inventory-items/{id}/location-levels/{location_id}' \ + * --header 'Authorization: Bearer {api_token}' \ + * --header 'Content-Type: application/json' \ + * --data-raw '{ + * "stocked_quantity": 15 + * }' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Inventory Items + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/AdminInventoryItemsRes" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response) => { + const { id, location_id } = req.params + + const inventoryService: IInventoryService = + req.scope.resolve("inventoryService") + + const validatedBody = + req.validatedBody as AdminPostInventoryItemsItemLocationLevelsLevelReq + + await inventoryService.updateInventoryLevel(id, location_id, validatedBody) + + const inventoryItem = await inventoryService.retrieveInventoryItem( + id, + req.retrieveConfig + ) + + res.status(200).json({ inventory_item: inventoryItem }) +} + +/** + * @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() + incoming_quantity?: number + + @IsOptional() + @IsNumber() + stocked_quantity?: number +} + +// eslint-disable-next-line +export class AdminPostInventoryItemsItemLocationLevelsLevelParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/admin/inventory-items/utils/join-levels.ts b/packages/medusa/src/api/routes/admin/inventory-items/utils/join-levels.ts new file mode 100644 index 0000000000..383facf856 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/inventory-items/utils/join-levels.ts @@ -0,0 +1,65 @@ +import { IInventoryService } from "../../../../../interfaces" +import { + InventoryItemDTO, + InventoryLevelDTO, +} from "../../../../../types/inventory" + +type LevelWithAvailability = InventoryLevelDTO & { + available_quantity: number +} + +export const buildLevelsByInventoryItemId = ( + inventoryLevels: InventoryLevelDTO[], + locationIds: string[] +) => { + const filteredLevels = inventoryLevels.filter((level) => + locationIds?.includes(level.location_id) + ) + + return filteredLevels.reduce((acc, level) => { + acc[level.inventory_item_id] = acc[level.inventory_item_id] ?? [] + acc[level.inventory_item_id].push(level) + return acc + }, {}) +} + +export const getLevelsByInventoryItemId = async ( + items: InventoryItemDTO[], + locationIds: string[], + inventoryService: IInventoryService +) => { + const [levels] = await inventoryService.listInventoryLevels({ + inventory_item_id: items.map((inventoryItem) => inventoryItem.id), + }) + + const levelsWithAvailability: LevelWithAvailability[] = await Promise.all( + levels.map(async (level) => { + const availability = await inventoryService.retrieveAvailableQuantity( + level.inventory_item_id, + [level.location_id] + ) + return { + ...level, + available_quantity: availability, + } + }) + ) + + return buildLevelsByInventoryItemId(levelsWithAvailability, locationIds) +} + +export const joinLevels = async ( + inventoryItems: InventoryItemDTO[], + locationIds: string[], + inventoryService: IInventoryService +) => { + const levelsByItemId = await getLevelsByInventoryItemId( + inventoryItems, + locationIds, + inventoryService + ) + return inventoryItems.map((inventoryItem) => ({ + ...inventoryItem, + location_levels: levelsByItemId[inventoryItem.id] || [], + })) +} diff --git a/packages/medusa/src/api/routes/admin/inventory-items/utils/join-variants.ts b/packages/medusa/src/api/routes/admin/inventory-items/utils/join-variants.ts new file mode 100644 index 0000000000..8f4be84ed0 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/inventory-items/utils/join-variants.ts @@ -0,0 +1,31 @@ +import { + ProductVariantInventoryService, + ProductVariantService, +} from "../../../../../services" +import { InventoryItemDTO } from "../../../../../types/inventory" +import { ProductVariant } from "../../../../../models" + +export type InventoryItemsWithVariants = Partial & { + variants?: ProductVariant[] +} + +export const getVariantsByInventoryItemId = async ( + inventoryItems: InventoryItemDTO[], + productVariantInventoryService: ProductVariantInventoryService, + productVariantService: ProductVariantService +): Promise> => { + const variantInventory = await productVariantInventoryService.listByItem( + inventoryItems.map((item) => item.id) + ) + + const variants = await productVariantService.list({ + id: variantInventory.map((varInventory) => varInventory.variant_id), + }) + const variantMap = new Map(variants.map((variant) => [variant.id, variant])) + + return variantInventory.reduce((acc, cur) => { + acc[cur.inventory_item_id] = acc[cur.inventory_item_id] ?? [] + acc[cur.inventory_item_id].push(variantMap.get(cur.variant_id)) + return acc + }, {}) +} diff --git a/packages/medusa/src/api/routes/admin/stock-locations/delete-stock-location.ts b/packages/medusa/src/api/routes/admin/stock-locations/delete-stock-location.ts new file mode 100644 index 0000000000..662b7c05e4 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/stock-locations/delete-stock-location.ts @@ -0,0 +1,72 @@ +import { EntityManager } from "typeorm" +import { IStockLocationService } from "../../../../interfaces" + +/** + * @oas [delete] /stock-locations/{id} + * operationId: "DeleteStockLocationsStockLocation" + * summary: "Delete a Stock Location" + * description: "Delete a Stock Location" + * x-authenticated: true + * parameters: + * - (path) id=* {string} The ID of the Stock Location to delete. + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.stockLocations.delete(stock_location_id) + * .then(({ id, object, deleted }) => { + * console.log(id) + * }) + * - lang: Shell + * label: cURL + * source: | + * curl --location --request DELETE 'https://medusa-url.com/admin/stock-locations/{id}' \ + * --header 'Authorization: Bearer {api_token}' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - StockLocation + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * description: The ID of the deleted Stock Location. + * object: + * type: string + * description: The type of the object that was deleted. + * format: stock_location + * deleted: + * type: boolean + * description: Whether or not the Stock Location was deleted. + * default: true + * "400": + * $ref: "#/components/responses/400_error" + */ +export default async (req, res) => { + const { id } = req.params + + const stockLocationService: IStockLocationService = req.scope.resolve( + "stockLocationService" + ) + + const manager: EntityManager = req.scope.resolve("manager") + await manager.transaction(async (transactionManager) => { + await stockLocationService.withTransaction(transactionManager).delete(id) + }) + + res.status(200).send({ + id, + object: "stock_location", + deleted: true, + }) +} diff --git a/packages/medusa/src/api/routes/admin/stock-locations/index.ts b/packages/medusa/src/api/routes/admin/stock-locations/index.ts index 2994ceef93..0347f9cc54 100644 --- a/packages/medusa/src/api/routes/admin/stock-locations/index.ts +++ b/packages/medusa/src/api/routes/admin/stock-locations/index.ts @@ -1,6 +1,6 @@ import { Router } from "express" import "reflect-metadata" -import { PaginatedResponse } from "../../../../types/common" +import { DeleteResponse, PaginatedResponse } from "../../../../types/common" import { StockLocationDTO } from "../../../../types/stock-location" import middlewares, { transformBody, @@ -71,6 +71,11 @@ export default (app) => { middlewares.wrap(require("./update-stock-location").default) ) + route.delete( + "/:id", + middlewares.wrap(require("./delete-stock-location").default) + ) + return app } @@ -85,6 +90,24 @@ export const defaultAdminStockLocationFields: (keyof StockLocationDTO)[] = [ export const defaultAdminStockLocationRelations = [] +/** + * @schema AdminStockLocationsDeleteRes + * type: object + * properties: + * id: + * type: string + * description: The ID of the deleted Stock Location. + * object: + * type: string + * description: The type of the object that was deleted. + * default: stock_location + * deleted: + * type: boolean + * description: Whether or not the items were deleted. + * default: true + */ +export type AdminStockLocationsDeleteRes = DeleteResponse + /** * @schema AdminStockLocationsRes * type: object diff --git a/packages/medusa/src/interfaces/services/stock-location.ts b/packages/medusa/src/interfaces/services/stock-location.ts index 829ca9cb08..3826337743 100644 --- a/packages/medusa/src/interfaces/services/stock-location.ts +++ b/packages/medusa/src/interfaces/services/stock-location.ts @@ -28,4 +28,6 @@ export interface IStockLocationService { create(input: CreateStockLocationInput): Promise update(id: string, input: UpdateStockLocationInput): Promise + + delete(id: string): Promise } diff --git a/packages/medusa/src/types/inventory.ts b/packages/medusa/src/types/inventory.ts index 78c97938c2..68c0260b2f 100644 --- a/packages/medusa/src/types/inventory.ts +++ b/packages/medusa/src/types/inventory.ts @@ -1,5 +1,58 @@ import { NumericalComparisonOperator, StringComparisonOperator } from "./common" +/** + * @schema InventoryItemDTO + * type: object + * required: + * - sku + * properties: + * sku: + * description: The Stock Keeping Unit (SKU) code of the Inventory Item. + * type: string + * hs_code: + * description: The Harmonized System code of the Inventory Item. May be used by Fulfillment Providers to pass customs information to shipping carriers. + * type: string + * origin_country: + * description: The country in which the Inventory Item was produced. May be used by Fulfillment Providers to pass customs information to shipping carriers. + * type: string + * mid_code: + * description: The Manufacturers Identification code that identifies the manufacturer of the Inventory Item. May be used by Fulfillment Providers to pass customs information to shipping carriers. + * type: string + * material: + * description: The material and composition that the Inventory Item is made of, May be used by Fulfillment Providers to pass customs information to shipping carriers. + * type: string + * weight: + * description: The weight of the Inventory Item. May be used in shipping rate calculations. + * type: number + * height: + * description: The height of the Inventory Item. May be used in shipping rate calculations. + * type: number + * width: + * description: The width of the Inventory Item. May be used in shipping rate calculations. + * type: number + * length: + * description: The length of the Inventory Item. May be used in shipping rate calculations. + * type: number + * requires_shipping: + * description: Whether the item requires shipping. + * type: boolean + * metadata: + * type: object + * description: An optional key-value map with additional details + * example: {car: "white"} + * created_at: + * type: string + * description: "The date with timezone at which the resource was created." + * format: date-time + * updated_at: + * type: string + * description: "The date with timezone at which the resource was updated." + * format: date-time + * deleted_at: + * type: string + * description: "The date with timezone at which the resource was deleted." + * format: date-time + */ export type InventoryItemDTO = { id: string sku?: string | null @@ -12,7 +65,7 @@ export type InventoryItemDTO = { length?: number | null height?: number | null width?: number | null - metadata: Record | null + metadata?: Record | null created_at: string | Date updated_at: string | Date deleted_at: string | Date | null @@ -70,6 +123,45 @@ export type ReservationItemDTO = { deleted_at: string | Date | null } +/** + * @schema InventoryLevelDTO + * type: object + * required: + * - inventory_item_id + * - location_id + * - stocked_quantity + * - reserved_quantity + * - incoming_quantity + * properties: + * location_id: + * description: the item location ID + * type: string + * stocked_quantity: + * description: the total stock quantity of an inventory item at the given location ID + * type: number + * reserved_quantity: + * description: the reserved 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 + * metadata: + * type: object + * description: An optional key-value map with additional details + * example: {car: "white"} + * created_at: + * type: string + * description: "The date with timezone at which the resource was created." + * format: date-time + * updated_at: + * type: string + * description: "The date with timezone at which the resource was updated." + * format: date-time + * deleted_at: + * type: string + * description: "The date with timezone at which the resource was deleted." + * format: date-time + */ export type InventoryLevelDTO = { id: string inventory_item_id: string