diff --git a/integration-tests/modules/__tests__/inventory/index.spec.ts b/integration-tests/modules/__tests__/inventory/index.spec.ts new file mode 100644 index 0000000000..02cf0189cf --- /dev/null +++ b/integration-tests/modules/__tests__/inventory/index.spec.ts @@ -0,0 +1,726 @@ +import { IInventoryServiceNext, IStockLocationService } from "@medusajs/types" + +import { ContainerRegistrationKeys } from "@medusajs/utils" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { createAdminUser } from "../../../helpers/create-admin-user" +import { remoteQueryObjectFromString } from "@medusajs/utils" + +const { medusaIntegrationTestRunner } = require("medusa-test-utils") + +jest.setTimeout(30000) + +const { simpleProductFactory } = require("../../../factories") +const adminHeaders = { headers: { "x-medusa-access-token": "test_token" } } + +medusaIntegrationTestRunner({ + env: { + MEDUSA_FF_MEDUSA_V2: true, + }, + testSuite: ({ dbConnection, getContainer, api }) => { + let appContainer + let shutdownServer + let service: IInventoryServiceNext + + let variantId + let inventoryItems + let locationId + let location2Id + let location3Id + + beforeEach(async () => { + appContainer = getContainer() + + await createAdminUser(dbConnection, adminHeaders, appContainer) + + service = appContainer.resolve(ModuleRegistrationName.INVENTORY) + }) + + describe("Inventory Items", () => { + it.skip("should create, update and delete the inventory location levels", async () => { + const inventoryItemId = inventoryItems[0].id + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: locationId, + stocked_quantity: 17, + incoming_quantity: 2, + }, + adminHeaders + ) + + const inventoryService = appContainer.resolve("inventoryService") + const stockLevel = await inventoryService.retrieveInventoryLevel( + inventoryItemId, + locationId + ) + + expect(stockLevel.location_id).toEqual(locationId) + expect(stockLevel.inventory_item_id).toEqual(inventoryItemId) + expect(stockLevel.stocked_quantity).toEqual(17) + expect(stockLevel.incoming_quantity).toEqual(2) + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}`, + { + stocked_quantity: 21, + incoming_quantity: 0, + }, + adminHeaders + ) + + const newStockLevel = await inventoryService.retrieveInventoryLevel( + inventoryItemId, + locationId + ) + expect(newStockLevel.stocked_quantity).toEqual(21) + expect(newStockLevel.incoming_quantity).toEqual(0) + + await api.delete( + `/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}`, + adminHeaders + ) + const invLevel = await inventoryService + .retrieveInventoryLevel(inventoryItemId, locationId) + .catch((e) => e) + + expect(invLevel.message).toEqual( + `Inventory level for item ${inventoryItemId} and location ${locationId} not found` + ) + }) + + it.skip("should update the inventory item", async () => { + const inventoryItemId = inventoryItems[0].id + + const response = await api.post( + `/admin/inventory-items/${inventoryItemId}`, + { + mid_code: "updated mid_code", + weight: 120, + }, + adminHeaders + ) + + expect(response.data.inventory_item).toEqual( + expect.objectContaining({ + origin_country: "UK", + hs_code: "hs001", + mid_code: "updated mid_code", + weight: 120, + length: 100, + height: 200, + width: 150, + }) + ) + }) + + it.skip("should fail to update the location level to negative quantity", async () => { + const inventoryItemId = inventoryItems[0].id + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: locationId, + stocked_quantity: 17, + incoming_quantity: 2, + }, + adminHeaders + ) + + const res = await api + .post( + `/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}`, + { + incoming_quantity: -1, + stocked_quantity: -1, + }, + adminHeaders + ) + .catch((error) => error) + + expect(res.response.status).toEqual(400) + expect(res.response.data).toEqual({ + type: "invalid_data", + message: + "incoming_quantity must not be less than 0, stocked_quantity must not be less than 0", + }) + }) + + it.skip("should retrieve the inventory item", async () => { + const inventoryItemId = inventoryItems[0].id + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: locationId, + stocked_quantity: 15, + incoming_quantity: 5, + }, + adminHeaders + ) + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: location2Id, + stocked_quantity: 7, + incoming_quantity: 0, + }, + adminHeaders + ) + + const response = await api.get( + `/admin/inventory-items/${inventoryItemId}`, + adminHeaders + ) + + expect(response.data).toEqual({ + inventory_item: expect.objectContaining({ + height: 200, + hs_code: "hs001", + id: inventoryItemId, + length: 100, + location_levels: [ + expect.objectContaining({ + available_quantity: 15, + deleted_at: null, + id: expect.any(String), + incoming_quantity: 5, + inventory_item_id: inventoryItemId, + location_id: locationId, + metadata: null, + reserved_quantity: 0, + stocked_quantity: 15, + }), + expect.objectContaining({ + available_quantity: 7, + deleted_at: null, + id: expect.any(String), + incoming_quantity: 0, + inventory_item_id: inventoryItemId, + location_id: location2Id, + metadata: null, + reserved_quantity: 0, + stocked_quantity: 7, + }), + ], + material: "material", + metadata: null, + mid_code: "mids", + origin_country: "UK", + requires_shipping: true, + sku: "MY_SKU", + weight: 300, + width: 150, + }), + }) + }) + + it.skip("should create the inventory item using the api", async () => { + const product = await simpleProductFactory(dbConnection, {}) + + const productRes = await api.get( + `/admin/products/${product.id}`, + adminHeaders + ) + + const variantId = productRes.data.product.variants[0].id + + let variantInventoryRes = await api.get( + `/admin/variants/${variantId}/inventory`, + adminHeaders + ) + + expect(variantInventoryRes.data).toEqual({ + variant: { + id: variantId, + inventory: [], + sales_channel_availability: [], + }, + }) + expect(variantInventoryRes.status).toEqual(200) + + const inventoryItemCreateRes = await api.post( + `/admin/inventory-items`, + { variant_id: variantId, sku: "attach_this_to_variant" }, + adminHeaders + ) + + variantInventoryRes = await api.get( + `/admin/variants/${variantId}/inventory`, + adminHeaders + ) + + expect(variantInventoryRes.data).toEqual({ + variant: expect.objectContaining({ + id: variantId, + inventory: [ + expect.objectContaining({ + ...inventoryItemCreateRes.data.inventory_item, + }), + ], + }), + }) + expect(variantInventoryRes.status).toEqual(200) + }) + + it.skip("should list the location levels based on id param constraint", async () => { + const inventoryItemId = inventoryItems[0].id + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: location2Id, + stocked_quantity: 10, + }, + adminHeaders + ) + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: location3Id, + stocked_quantity: 5, + }, + adminHeaders + ) + + const result = await api.get( + `/admin/inventory-items/${inventoryItemId}/location-levels?location_id[]=${location2Id}`, + adminHeaders + ) + + expect(result.status).toEqual(200) + expect(result.data.inventory_item.location_levels).toHaveLength(1) + expect(result.data.inventory_item.location_levels[0]).toEqual( + expect.objectContaining({ + stocked_quantity: 10, + }) + ) + }) + + describe.skip("Create inventory item level", () => { + let location1 + let location2 + + beforeEach(async () => { + await service.create([ + { + sku: "MY_SKU", + origin_country: "UK", + hs_code: "hs001", + mid_code: "mids", + material: "material", + weight: 300, + length: 100, + height: 200, + width: 150, + }, + ]) + + const stockLocationService: IStockLocationService = + appContainer.resolve(ModuleRegistrationName.STOCK_LOCATION) + + location1 = await stockLocationService.create({ + name: "location-1", + }) + + location2 = await stockLocationService.create({ + name: "location-2", + }) + }) + + it("should list the inventory items", async () => { + const [{ id: inventoryItemId }] = await service.list({}) + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: location1.id, + stocked_quantity: 10, + }, + adminHeaders + ) + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: location2.id, + stocked_quantity: 5, + }, + adminHeaders + ) + + const levels = await service.listInventoryLevels({ + inventory_item_id: inventoryItemId, + }) + + expect(levels).toHaveLength(2) + expect(levels).toEqual([ + expect.objectContaining({ + location_id: location1.id, + stocked_quantity: 10, + }), + expect.objectContaining({ + location_id: location2.id, + stocked_quantity: 5, + }), + ]) + }) + }) + + describe.skip("Create inventory items", () => { + it("should create inventory items", async () => { + const createResult = await api.post( + `/admin/products`, + { + title: "Test Product", + variants: [ + { + title: "Test Variant w. inventory 2", + sku: "MY_SKU1", + material: "material", + }, + ], + }, + adminHeaders + ) + + const inventoryItems = await service.list({}) + + expect(inventoryItems).toHaveLength(0) + + const response = await api.post( + `/admin/inventory-items`, + { + sku: "test-sku", + variant_id: createResult.data.product.variants[0].id, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.inventory_item).toEqual( + expect.objectContaining({ + sku: "test-sku", + }) + ) + }) + + it("should attach inventory items on creation", async () => { + const createResult = await api.post( + `/admin/products`, + { + title: "Test Product", + variants: [ + { + title: "Test Variant w. inventory 2", + sku: "MY_SKU1", + material: "material", + }, + ], + }, + adminHeaders + ) + + const inventoryItems = await service.list({}) + + expect(inventoryItems).toHaveLength(0) + + await api.post( + `/admin/inventory-items`, + { + sku: "test-sku", + variant_id: createResult.data.product.variants[0].id, + }, + adminHeaders + ) + + const remoteQuery = appContainer.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) + + const query = remoteQueryObjectFromString({ + entryPoint: "product_variant_inventory_item", + variables: { + variant_id: createResult.data.product.variants[0].id, + }, + fields: ["inventory_item_id", "variant_id"], + }) + + const existingItems = await remoteQuery(query) + + expect(existingItems).toHaveLength(1) + expect(existingItems[0].variant_id).toEqual( + createResult.data.product.variants[0].id + ) + }) + }) + + describe("List inventory items", () => { + let location1 = "loc_1" + let location2 = "loc_2" + beforeEach(async () => { + await service.create([ + { + sku: "MY_SKU", + origin_country: "UK", + hs_code: "hs001", + mid_code: "mids", + material: "material", + weight: 300, + length: 100, + height: 200, + width: 150, + }, + ]) + }) + + it("should list the inventory items", async () => { + const [{ id: inventoryItemId }] = await service.list({}) + + await service.createInventoryLevels([ + { + inventory_item_id: inventoryItemId, + location_id: location1, + stocked_quantity: 10, + }, + { + inventory_item_id: inventoryItemId, + location_id: location2, + stocked_quantity: 5, + }, + ]) + + const response = await api.get(`/admin/inventory-items`, adminHeaders) + + expect(response.data.inventory_items).toHaveLength(1) + expect(response.data.inventory_items[0]).toEqual( + expect.objectContaining({ + id: inventoryItemId, + sku: "MY_SKU", + origin_country: "UK", + hs_code: "hs001", + mid_code: "mids", + material: "material", + weight: 300, + length: 100, + height: 200, + width: 150, + requires_shipping: true, + metadata: null, + location_levels: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + inventory_item_id: inventoryItemId, + location_id: location1, + stocked_quantity: 10, + reserved_quantity: 0, + incoming_quantity: 0, + metadata: null, + available_quantity: 10, + }), + expect.objectContaining({ + id: expect.any(String), + inventory_item_id: inventoryItemId, + location_id: location2, + stocked_quantity: 5, + reserved_quantity: 0, + incoming_quantity: 0, + metadata: null, + available_quantity: 5, + }), + ]), + reserved_quantity: 0, + stocked_quantity: 15, + }) + ) + }) + + it("should list the inventory items searching by title, description and sku", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + await inventoryService.create([ + { + title: "Test Item", + }, + { + description: "Test Desc", + }, + { + sku: "Test Sku", + }, + ]) + + const response = await api.get( + `/admin/inventory-items?q=test`, + adminHeaders + ) + + expect(response.data.inventory_items).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sku: "MY_SKU", + }), + ]) + ) + expect(response.data.inventory_items).toHaveLength(3) + expect(response.data.inventory_items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sku: "Test Sku", + }), + expect.objectContaining({ + description: "Test Desc", + }), + expect.objectContaining({ + title: "Test Item", + }), + ]) + ) + }) + }) + + it.skip("should remove associated levels and reservations when deleting an inventory item", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const invItem2 = await inventoryService.createInventoryItem({ + sku: "1234567", + }) + + const stockRes = await api.post( + `/admin/stock-locations`, + { + name: "Fake Warehouse 1", + }, + adminHeaders + ) + + locationId = stockRes.data.stock_location.id + + const level = await inventoryService.createInventoryLevel({ + inventory_item_id: invItem2.id, + location_id: locationId, + stocked_quantity: 10, + }) + + const reservation = await inventoryService.createReservationItem({ + inventory_item_id: invItem2.id, + location_id: locationId, + quantity: 5, + }) + + const [, reservationCount] = + await inventoryService.listReservationItems({ + location_id: locationId, + }) + + expect(reservationCount).toEqual(1) + + const [, inventoryLevelCount] = + await inventoryService.listInventoryLevels({ + location_id: locationId, + }) + + expect(inventoryLevelCount).toEqual(1) + + const res = await api.delete( + `/admin/stock-locations/${locationId}`, + adminHeaders + ) + + expect(res.status).toEqual(200) + + const [, reservationCountPostDelete] = + await inventoryService.listReservationItems({ + location_id: locationId, + }) + + expect(reservationCountPostDelete).toEqual(0) + + const [, inventoryLevelCountPostDelete] = + await inventoryService.listInventoryLevels({ + location_id: locationId, + }) + + expect(inventoryLevelCountPostDelete).toEqual(0) + }) + + it.skip("should remove the product variant associations when deleting an inventory item", async () => { + await simpleProductFactory( + dbConnection, + { + id: "test-product-new", + variants: [], + }, + 5 + ) + + const response = await api.post( + `/admin/products/test-product-new/variants`, + { + title: "Test2", + sku: "MY_SKU2", + manage_inventory: true, + options: [ + { + option_id: "test-product-new-option", + value: "Blue", + }, + ], + prices: [{ currency_code: "usd", amount: 100 }], + }, + { headers: { "x-medusa-access-token": "test_token" } } + ) + + const secondVariantId = response.data.product.variants.find( + (v) => v.sku === "MY_SKU2" + ).id + + const inventoryService = appContainer.resolve("inventoryService") + const variantInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + + const invItem2 = await inventoryService.createInventoryItem({ + sku: "123456", + }) + + await variantInventoryService.attachInventoryItem( + variantId, + invItem2.id, + 2 + ) + await variantInventoryService.attachInventoryItem( + secondVariantId, + invItem2.id, + 2 + ) + + expect( + await variantInventoryService.listInventoryItemsByVariant(variantId) + ).toHaveLength(2) + + expect( + await variantInventoryService.listInventoryItemsByVariant( + secondVariantId + ) + ).toHaveLength(2) + + await api.delete(`/admin/inventory-items/${invItem2.id}`, { + headers: { "x-medusa-access-token": "test_token" }, + }) + + expect( + await variantInventoryService.listInventoryItemsByVariant(variantId) + ).toHaveLength(1) + + expect( + await variantInventoryService.listInventoryItemsByVariant( + secondVariantId + ) + ).toHaveLength(1) + }) + }) + }, +}) diff --git a/integration-tests/plugins/medusa-config.js b/integration-tests/plugins/medusa-config.js index 17621c03ac..a8f79f51eb 100644 --- a/integration-tests/plugins/medusa-config.js +++ b/integration-tests/plugins/medusa-config.js @@ -4,7 +4,9 @@ const DB_USERNAME = process.env.DB_USERNAME const DB_PASSWORD = process.env.DB_PASSWORD const DB_NAME = process.env.DB_TEMP_NAME const DB_URL = `postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}/${DB_NAME}` + process.env.POSTGRES_URL = DB_URL +process.env.LOG_LEVEL = "error" const enableMedusaV2 = process.env.MEDUSA_FF_MEDUSA_V2 == "true" @@ -38,6 +40,8 @@ module.exports = { medusa_v2: enableMedusaV2, }, modules: { + workflows: true, + [Modules.AUTH]: { scope: "internal", resources: "shared", @@ -64,6 +68,11 @@ module.exports = { resources: "shared", resolve: "@medusajs/inventory", }, + [Modules.PRICING]: { + scope: "internal", + resources: "shared", + resolve: "@medusajs/pricing", + }, [Modules.CACHE]: { resolve: "@medusajs/cache-inmemory", options: { ttl: 0 }, // Cache disabled diff --git a/packages/inventory-next/src/models/inventory-item.ts b/packages/inventory-next/src/models/inventory-item.ts index 74477a0dcb..8ba25d8a3c 100644 --- a/packages/inventory-next/src/models/inventory-item.ts +++ b/packages/inventory-next/src/models/inventory-item.ts @@ -3,7 +3,9 @@ import { Collection, Entity, Filter, + Formula, OnInit, + OnLoad, OneToMany, OptionalProps, PrimaryKey, @@ -106,7 +108,21 @@ export class InventoryItem { () => InventoryLevel, (inventoryLevel) => inventoryLevel.inventory_item ) - inventory_levels = new Collection(this) + location_levels = new Collection(this) + + @Formula( + (item) => + `(SELECT SUM(reserved_quantity) FROM inventory_level il WHERE il.inventory_item_id = ${item}.id)`, + { lazy: true, serializer: Number, hidden: true } + ) + reserved_quantity: number + + @Formula( + (item) => + `(SELECT SUM(stocked_quantity) FROM inventory_level il WHERE il.inventory_item_id = ${item}.id)`, + { lazy: true, serializer: Number, hidden: true } + ) + stocked_quantity: number @BeforeCreate() private beforeCreate(): void { diff --git a/packages/inventory-next/src/models/inventory-level.ts b/packages/inventory-next/src/models/inventory-level.ts index b60787dfb3..040e39c545 100644 --- a/packages/inventory-next/src/models/inventory-level.ts +++ b/packages/inventory-next/src/models/inventory-level.ts @@ -4,11 +4,12 @@ import { Filter, ManyToOne, OnInit, + OnLoad, PrimaryKey, Property, } from "@mikro-orm/core" +import { DALUtils, isDefined } from "@medusajs/utils" -import { DALUtils } from "@medusajs/utils" import { InventoryItem } from "./inventory-item" import { createPsqlIndexStatementHelper } from "@medusajs/utils" import { generateEntityId } from "@medusajs/utils" @@ -91,6 +92,8 @@ export class InventoryLevel { }) inventory_item: InventoryItem + available_quantity: number | null = null + @BeforeCreate() private beforeCreate(): void { this.id = generateEntityId(this.id, "ilev") @@ -101,4 +104,11 @@ export class InventoryLevel { private onInit(): void { this.id = generateEntityId(this.id, "ilev") } + + @OnLoad() + private onLoad(): void { + if (isDefined(this.stocked_quantity) && isDefined(this.reserved_quantity)) { + this.available_quantity = this.stocked_quantity - this.reserved_quantity + } + } } diff --git a/packages/inventory-next/src/repositories/index.ts b/packages/inventory-next/src/repositories/index.ts index dc31fdaa30..6992c162ee 100644 --- a/packages/inventory-next/src/repositories/index.ts +++ b/packages/inventory-next/src/repositories/index.ts @@ -1,2 +1,3 @@ export * from "./inventory-level" +export * from "./inventory-item" export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils" diff --git a/packages/inventory-next/src/repositories/inventory-item.ts b/packages/inventory-next/src/repositories/inventory-item.ts new file mode 100644 index 0000000000..6384a5adcc --- /dev/null +++ b/packages/inventory-next/src/repositories/inventory-item.ts @@ -0,0 +1,63 @@ +import { Context, DAL } from "@medusajs/types" +import { InventoryItem, InventoryLevel } from "@models" + +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { mikroOrmBaseRepositoryFactory } from "@medusajs/utils" + +export class InventoryItemRepository extends mikroOrmBaseRepositoryFactory( + InventoryItem +) { + async find( + findOptions: DAL.FindOptions = { + where: {}, + }, + context: Context = {} + ): Promise { + const findOptions_ = { ...findOptions } + findOptions_.options ??= {} + + this.applyFreeTextSearchFilters( + findOptions_, + this.getFreeTextSearchConstraints + ) + + return await super.find(findOptions_, context) + } + + async findAndCount( + findOptions: DAL.FindOptions = { + where: {}, + }, + context: Context = {} + ): Promise<[InventoryItem[], number]> { + const findOptions_ = { ...findOptions } + findOptions_.options ??= {} + + this.applyFreeTextSearchFilters( + findOptions_, + this.getFreeTextSearchConstraints + ) + + return await super.findAndCount(findOptions_, context) + } + + protected getFreeTextSearchConstraints(q: string) { + return [ + { + description: { + $ilike: `%${q}%`, + }, + }, + { + title: { + $ilike: `%${q}%`, + }, + }, + { + sku: { + $ilike: `%${q}%`, + }, + }, + ] + } +} diff --git a/packages/inventory-next/src/services/inventory.ts b/packages/inventory-next/src/services/inventory.ts index 997a14c1ab..c47183dafe 100644 --- a/packages/inventory-next/src/services/inventory.ts +++ b/packages/inventory-next/src/services/inventory.ts @@ -15,10 +15,10 @@ import { InjectManager, InjectTransactionManager, InventoryEvents, + isDefined, MedusaContext, MedusaError, ModulesSdkUtils, - isDefined, partitionArray, } from "@medusajs/utils" import { InventoryItem, InventoryLevel, ReservationItem } from "@models" @@ -100,7 +100,7 @@ export default class InventoryModuleService< { location_id: string; inventory_item_id: string }[] ] - const inventoryLevels = await this.inventoryLevelService_.list( + const inventoryLevels = await this.listInventoryLevels( { $or: [ { id: idData.filter(({ id }) => !!id).map((e) => e.id) }, diff --git a/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts b/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts new file mode 100644 index 0000000000..c957e532c3 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts @@ -0,0 +1,40 @@ +import * as QueryConfig from "./query-config" + +import { + AdminGetInventoryItemsItemParams, + AdminGetInventoryItemsParams, + AdminPostInventoryItemsItemLocationLevelsReq, + AdminPostInventoryItemsReq, +} from "./validators" +import { transformBody, transformQuery } from "../../../api/middlewares" + +import { MiddlewareRoute } from "../../../types/middlewares" +import { authenticate } from "../../../utils/authenticate-middleware" + +export const adminInventoryRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: "ALL", + matcher: "/admin/inventory-items*", + middlewares: [authenticate("admin", ["session", "bearer"])], + }, + { + method: ["GET"], + matcher: "/admin/inventory-items", + middlewares: [ + transformQuery( + AdminGetInventoryItemsParams, + QueryConfig.listTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/inventory-items/:id/location-levels", + middlewares: [transformBody(AdminPostInventoryItemsItemLocationLevelsReq)], + }, + { + method: ["POST"], + matcher: "/admin/inventory-items", + middlewares: [transformBody(AdminPostInventoryItemsReq)], + }, +] 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 new file mode 100644 index 0000000000..4dbbd6eb64 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/inventory-items/query-config.ts @@ -0,0 +1,56 @@ +import { InventoryNext } from "@medusajs/types" + +export const defaultAdminInventoryItemRelations = [] +export const allowedAdminInventoryItemRelations = [] + +// eslint-disable-next-line max-len +export const defaultAdminLocationLevelFields: (keyof InventoryNext.InventoryLevelDTO)[] = + [ + "id", + "inventory_item_id", + "location_id", + "stocked_quantity", + "reserved_quantity", + "incoming_quantity", + "available_quantity", + "metadata", + "created_at", + "updated_at", + ] + +export const defaultAdminInventoryItemFields = [ + "id", + "sku", + "title", + "description", + "thumbnail", + "origin_country", + "hs_code", + "requires_shipping", + "mid_code", + "material", + "weight", + "length", + "height", + "width", + "metadata", + "reserved_quantity", + "stocked_quantity", + "created_at", + "updated_at", + ...defaultAdminLocationLevelFields.map( + (field) => `location_levels.${field.toString()}` + ), +] + +export const retrieveTransformQueryConfig = { + defaultFields: defaultAdminInventoryItemFields, + defaultRelations: defaultAdminInventoryItemRelations, + allowedRelations: allowedAdminInventoryItemRelations, + isList: false, +} + +export const listTransformQueryConfig = { + ...retrieveTransformQueryConfig, + isList: true, +} diff --git a/packages/medusa/src/api-v2/admin/inventory-items/route.ts b/packages/medusa/src/api-v2/admin/inventory-items/route.ts new file mode 100644 index 0000000000..bc32976c57 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/inventory-items/route.ts @@ -0,0 +1,38 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../types/routing" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" + +// List inventory-items +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const query = remoteQueryObjectFromString({ + entryPoint: "inventory_items", + variables: { + filters: req.filterableFields, + order: req.listConfig.order, + skip: req.listConfig.skip, + take: req.listConfig.take, + }, + fields: [...(req.listConfig.select as string[])], + }) + + const { rows: inventory_items, metadata } = await remoteQuery({ + ...query, + }) + + res.status(200).json({ + inventory_items, + count: metadata.count, + offset: metadata.skip, + limit: metadata.take, + }) +} diff --git a/packages/medusa/src/api-v2/admin/inventory-items/validators.ts b/packages/medusa/src/api-v2/admin/inventory-items/validators.ts new file mode 100644 index 0000000000..c339a74095 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/inventory-items/validators.ts @@ -0,0 +1,268 @@ +import { + DateComparisonOperator, + FindParams, + NumericalComparisonOperator, + StringComparisonOperator, + extendedFindParamsMixin, +} from "../../../types/common" +import { + IsBoolean, + IsEmail, + IsNotEmpty, + IsNumber, + IsObject, + IsOptional, + IsString, + ValidateNested, +} from "class-validator" +import { Transform, Type } from "class-transformer" + +import { IsType } from "../../../utils" + +export class AdminGetInventoryItemsItemParams extends FindParams {} + +/** + * Parameters used to filter and configure the pagination of the retrieved inventory items. + */ +export class AdminGetInventoryItemsParams extends extendedFindParamsMixin({ + limit: 20, + offset: 0, +}) { + /** + * IDs to filter inventory items by. + */ + @IsOptional() + @IsType([String, [String]]) + id?: string | string[] + + /** + * Search terms to search inventory items' sku, title, and description. + */ + @IsOptional() + @IsString() + q?: string + + /** + * Location IDs to filter inventory items by. + */ + @IsOptional() + @IsType([String, [String]]) + location_id?: string | string[] + + /** + * SKUs to filter inventory items by. + */ + @IsOptional() + @IsType([String, [String]]) + sku?: string | string[] + + /** + * Origin countries to filter inventory items by. + */ + @IsOptional() + @IsType([String, [String]]) + origin_country?: string | string[] + + /** + * MID codes to filter inventory items by. + */ + @IsOptional() + @IsType([String, [String]]) + mid_code?: string | string[] + + /** + * Materials to filter inventory items by. + */ + @IsOptional() + @IsType([String, [String]]) + material?: string | string[] + + /** + * String filters to apply to inventory items' `hs_code` field. + */ + @IsOptional() + @IsType([String, [String], StringComparisonOperator]) + hs_code?: string | string[] | StringComparisonOperator + + /** + * Number filters to apply to inventory items' `weight` field. + */ + @IsOptional() + @IsType([Number, NumericalComparisonOperator]) + weight?: number | NumericalComparisonOperator + + /** + * Number filters to apply to inventory items' `length` field. + */ + @IsOptional() + @IsType([Number, NumericalComparisonOperator]) + length?: number | NumericalComparisonOperator + + /** + * Number filters to apply to inventory items' `height` field. + */ + @IsOptional() + @IsType([Number, NumericalComparisonOperator]) + height?: number | NumericalComparisonOperator + + /** + * Number filters to apply to inventory items' `width` field. + */ + @IsOptional() + @IsType([Number, NumericalComparisonOperator]) + width?: number | NumericalComparisonOperator + + /** + * Filter inventory items by whether they require shipping. + */ + @IsBoolean() + @IsOptional() + @Transform(({ value }) => value === "true") + requires_shipping?: boolean +} + +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 {} + +/** + * @schema AdminPostInventoryItemsReq + * type: object + * description: "The details of the inventory item to create." + * required: + * - variant_id + * properties: + * variant_id: + * description: The ID of the variant to create the inventory item for. + * type: string + * sku: + * description: The unique SKU of the associated Product Variant. + * type: string + * ean: + * description: The EAN number of the item. + * type: string + * upc: + * description: The UPC number of the item. + * type: string + * barcode: + * description: A generic GTIN field for the Product Variant. + * 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 + * inventory_quantity: + * description: The amount of stock kept of the associated Product Variant. + * type: integer + * default: 0 + * allow_backorder: + * description: Whether the associated Product Variant can be purchased when out of stock. + * type: boolean + * manage_inventory: + * description: Whether Medusa should keep track of the inventory for the associated Product Variant. + * type: boolean + * default: true + * weight: + * description: The weight 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 + * 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 + * 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 + * title: + * description: The inventory item's title. + * type: string + * description: + * description: The inventory item's description. + * type: string + * thumbnail: + * description: The inventory item's thumbnail. + * type: string + * metadata: + * description: An optional set of key-value pairs with additional information. + * type: object + * externalDocs: + * description: "Learn about the metadata attribute, and how to delete and update it." + * url: "https://docs.medusajs.com/development/entities/overview#metadata-attribute" + */ +export class AdminPostInventoryItemsReq { + @IsOptional() + @IsString() + variant_id: string + + @IsString() + @IsOptional() + sku?: string + + @IsString() + @IsOptional() + hs_code?: string + + @IsNumber() + @IsOptional() + weight?: number + + @IsNumber() + @IsOptional() + length?: number + + @IsNumber() + @IsOptional() + height?: number + + @IsNumber() + @IsOptional() + width?: number + + @IsString() + @IsOptional() + origin_country?: string + + @IsString() + @IsOptional() + mid_code?: string + + @IsString() + @IsOptional() + material?: string + + @IsString() + @IsOptional() + title?: string + + @IsString() + @IsOptional() + description?: string + + @IsString() + @IsOptional() + thumbnail?: string + + @IsObject() + @IsOptional() + metadata?: Record +} diff --git a/packages/medusa/src/api-v2/middlewares.ts b/packages/medusa/src/api-v2/middlewares.ts index 29a1148815..5b3d2e3d1a 100644 --- a/packages/medusa/src/api-v2/middlewares.ts +++ b/packages/medusa/src/api-v2/middlewares.ts @@ -5,6 +5,7 @@ import { adminCollectionRoutesMiddlewares } from "./admin/collections/middleware import { adminCurrencyRoutesMiddlewares } from "./admin/currencies/middlewares" import { adminCustomerGroupRoutesMiddlewares } from "./admin/customer-groups/middlewares" import { adminCustomerRoutesMiddlewares } from "./admin/customers/middlewares" +import { adminInventoryRoutesMiddlewares } from "./admin/inventory-items/middlewares" import { adminInviteRoutesMiddlewares } from "./admin/invites/middlewares" import { adminPaymentRoutesMiddlewares } from "./admin/payments/middlewares" import { adminPriceListsRoutesMiddlewares } from "./admin/price-lists/middlewares" @@ -49,6 +50,7 @@ export const config: MiddlewaresConfig = { ...adminProductRoutesMiddlewares, ...adminPaymentRoutesMiddlewares, ...adminPriceListsRoutesMiddlewares, + ...adminInventoryRoutesMiddlewares, ...adminCollectionRoutesMiddlewares, ...adminPricingRoutesMiddlewares, ], diff --git a/packages/medusa/src/loaders/medusa-app.ts b/packages/medusa/src/loaders/medusa-app.ts index 95135b93cb..be5c826653 100644 --- a/packages/medusa/src/loaders/medusa-app.ts +++ b/packages/medusa/src/loaders/medusa-app.ts @@ -1,12 +1,3 @@ -import { - MedusaApp, - MedusaAppMigrateUp, - MedusaAppOutput, - MedusaModule, - MODULE_PACKAGE_NAMES, - Modules, - ModulesDefinition, -} from "@medusajs/modules-sdk" import { CommonTypes, InternalModuleDeclaration, @@ -17,12 +8,22 @@ import { import { ContainerRegistrationKeys, FlagRouter, - isObject, MedusaV2Flag, + isObject, } from "@medusajs/utils" +import { + MODULE_PACKAGE_NAMES, + MedusaApp, + MedusaAppMigrateUp, + MedusaAppOutput, + MedusaModule, + Modules, + ModulesDefinition, +} from "@medusajs/modules-sdk" + import { asValue } from "awilix" -import { remoteQueryFetchData } from "../utils/remote-query-fetch-data" import { joinerConfig } from "../joiner-config" +import { remoteQueryFetchData } from "../utils/remote-query-fetch-data" export function mergeDefaultModules( modulesConfig: CommonTypes.ConfigModule["modules"] @@ -74,7 +75,6 @@ export async function migrateMedusaApp( debug: !!(configModule.projectConfig.database_logging ?? false), }, } - const configModules = mergeDefaultModules(configModule.modules) // Apply default options to legacy modules @@ -90,6 +90,7 @@ export async function migrateMedusaApp( database: { type: "postgres", url: sharedResourcesConfig.database.clientUrl, + clientUrl: sharedResourcesConfig.database.clientUrl, extra: configModule.projectConfig.database_extra, schema: configModule.projectConfig.database_schema, logging: configModule.projectConfig.database_logging, diff --git a/packages/types/src/inventory/common/inventory-level.ts b/packages/types/src/inventory/common/inventory-level.ts index bf0814d8e4..4fd719bed1 100644 --- a/packages/types/src/inventory/common/inventory-level.ts +++ b/packages/types/src/inventory/common/inventory-level.ts @@ -46,6 +46,7 @@ export interface InventoryLevelDTO { stocked_quantity: number reserved_quantity: number incoming_quantity: number + available_quantity: number metadata: Record | null created_at: string | Date updated_at: string | Date