From 923ccece24895d6e927fe132ef2ef7e0e0d1b41f Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Mon, 6 Feb 2023 09:02:43 -0300 Subject: [PATCH] feat(medusa,stock-location,inventory): Integration tests (#3149) --- .../plugins/__tests__/inventory/cart/cart.js | 270 +++++++ .../inventory/inventory-items/index.js | 366 +++++++++ .../inventory/products/create-variant.js | 173 +++++ .../plugins/__tests__/inventory/service.js | 717 ++++++++++++++++++ .../__snapshots__/index.js.snap | 16 +- .../__tests__/stock-location/service.js | 271 +++++++ .../plugins/helpers/cart-seeder.js | 7 +- integration-tests/plugins/medusa-config.js | 12 + .../inventory/src/services/inventory-level.ts | 24 +- .../src/services/reservation-item.ts | 25 +- .../inventory-items/utils/join-levels.ts | 7 +- .../transaction/create-product-variant.ts | 11 +- .../src/services/__mocks__/inventory.js | 93 +++ .../src/services/__mocks__/stock-location.js | 46 ++ .../src/services/sales-channel-location.ts | 10 +- packages/medusa/src/types/inventory.ts | 1 - .../src/services/stock-location.ts | 2 +- 17 files changed, 2019 insertions(+), 32 deletions(-) create mode 100644 integration-tests/plugins/__tests__/inventory/cart/cart.js create mode 100644 integration-tests/plugins/__tests__/inventory/inventory-items/index.js create mode 100644 integration-tests/plugins/__tests__/inventory/products/create-variant.js create mode 100644 integration-tests/plugins/__tests__/inventory/service.js create mode 100644 integration-tests/plugins/__tests__/stock-location/service.js create mode 100644 packages/medusa/src/services/__mocks__/inventory.js create mode 100644 packages/medusa/src/services/__mocks__/stock-location.js diff --git a/integration-tests/plugins/__tests__/inventory/cart/cart.js b/integration-tests/plugins/__tests__/inventory/cart/cart.js new file mode 100644 index 0000000000..6898bad7b1 --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/cart/cart.js @@ -0,0 +1,270 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../../helpers/use-db") +const { setPort, useApi } = require("../../../../helpers/use-api") + +const adminSeeder = require("../../../helpers/admin-seeder") +const cartSeeder = require("../../../helpers/cart-seeder") +const { simpleProductFactory } = require("../../../../api/factories") +const { simpleSalesChannelFactory } = require("../../../../api/factories") + +jest.setTimeout(30000) + +const adminHeaders = { headers: { Authorization: "Bearer test_token" } } + +describe("/store/carts", () => { + let express + let appContainer + let dbConnection + + let variantId + let inventoryItemId + let locationId + + const doAfterEach = async () => { + const db = useDb() + return await db.teardown() + } + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + setPort(port) + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + jest.clearAllMocks() + const db = useDb() + return await db.teardown() + }) + + describe("POST /store/carts/:id", () => { + beforeEach(async () => { + await simpleSalesChannelFactory(dbConnection, { + id: "test-channel", + is_default: true, + }) + + await adminSeeder(dbConnection) + await cartSeeder(dbConnection, { sales_channel_id: "test-channel" }) + + await simpleProductFactory( + dbConnection, + { + id: "product1", + sales_channels: [{ id: "test-channel" }], + variants: [], + }, + 100 + ) + + const api = useApi() + + // Add payment provider + await api.post( + `/admin/regions/test-region/payment-providers`, + { + provider_id: "test-pay", + }, + adminHeaders + ) + + const prodVarInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + + const response = await api.post( + `/admin/products/product1/variants`, + { + title: "Test Variant w. inventory", + sku: "MY_SKU", + material: "material", + origin_country: "UK", + hs_code: "hs001", + mid_code: "mids", + weight: 300, + length: 100, + height: 200, + width: 150, + options: [ + { + option_id: "product1-option", + value: "SS", + }, + ], + manage_inventory: true, + prices: [{ currency_code: "usd", amount: 2300 }], + }, + adminHeaders + ) + + const variant = response.data.product.variants[0] + + variantId = variant.id + + const inventoryItems = + await prodVarInventoryService.listInventoryItemsByVariant(variantId) + + inventoryItemId = inventoryItems[0].id + + // Add Stock location + const stockRes = await api.post( + `/admin/stock-locations`, + { + name: "Fake Warehouse", + }, + adminHeaders + ) + locationId = stockRes.data.stock_location.id + + // Add stock level + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: locationId, + stocked_quantity: 5, + }, + adminHeaders + ) + + // Associate Stock Location with sales channel + await api.post( + `/admin/sales-channels/test-channel/stock-locations`, + { + location_id: locationId, + }, + adminHeaders + ) + }) + + afterEach(async () => { + await doAfterEach() + }) + + it("reserve quantity when completing the cart", async () => { + const api = useApi() + + const cartId = "test-cart" + + // Add standard line item to cart + await api.post( + `/store/carts/${cartId}/line-items`, + { + variant_id: variantId, + quantity: 3, + }, + { withCredentials: true } + ) + + await api.post(`/store/carts/${cartId}/payment-sessions`) + await api.post(`/store/carts/${cartId}/payment-session`, { + provider_id: "test-pay", + }) + + const getRes = await api.post(`/store/carts/${cartId}/complete`) + + expect(getRes.status).toEqual(200) + expect(getRes.data.type).toEqual("order") + + 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.reserved_quantity).toEqual(3) + expect(stockLevel.stocked_quantity).toEqual(5) + }) + + it("fails to add a item on the cart if the inventory isn't enough", async () => { + const api = useApi() + + const cartId = "test-cart" + + // Add standard line item to cart + const addCart = await api + .post( + `/store/carts/${cartId}/line-items`, + { + variant_id: variantId, + quantity: 6, + }, + { withCredentials: true } + ) + .catch((e) => e) + + expect(addCart.response.status).toEqual(400) + expect(addCart.response.data.code).toEqual("insufficient_inventory") + expect(addCart.response.data.message).toEqual( + `Variant with id: ${variantId} does not have the required inventory` + ) + }) + + it("fails to complete cart with items inventory not covered", async () => { + const api = useApi() + + const cartId = "test-cart" + + // Add standard line item to cart + await api.post( + `/store/carts/${cartId}/line-items`, + { + variant_id: variantId, + quantity: 5, + }, + { withCredentials: true } + ) + + await api.post(`/store/carts/${cartId}/payment-sessions`) + await api.post(`/store/carts/${cartId}/payment-session`, { + provider_id: "test-pay", + }) + + // Another proccess reserves items before the cart is completed + const inventoryService = appContainer.resolve("inventoryService") + inventoryService.createReservationItem({ + line_item_id: "line_item_123", + inventory_item_id: inventoryItemId, + location_id: locationId, + quantity: 2, + }) + + const completeCartRes = await api + .post(`/store/carts/${cartId}/complete`) + .catch((e) => e) + + expect(completeCartRes.response.status).toEqual(409) + expect(completeCartRes.response.data.code).toEqual( + "insufficient_inventory" + ) + expect(completeCartRes.response.data.message).toEqual( + `Variant with id: ${variantId} does not have the required inventory` + ) + + const stockLevel = await inventoryService.retrieveInventoryLevel( + inventoryItemId, + locationId + ) + + expect(stockLevel.location_id).toEqual(locationId) + expect(stockLevel.inventory_item_id).toEqual(inventoryItemId) + expect(stockLevel.reserved_quantity).toEqual(2) + expect(stockLevel.stocked_quantity).toEqual(5) + }) + }) +}) diff --git a/integration-tests/plugins/__tests__/inventory/inventory-items/index.js b/integration-tests/plugins/__tests__/inventory/inventory-items/index.js new file mode 100644 index 0000000000..d5275a2d06 --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/inventory-items/index.js @@ -0,0 +1,366 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../../helpers/use-db") +const { setPort, useApi } = require("../../../../helpers/use-api") + +const adminSeeder = require("../../../helpers/admin-seeder") + +jest.setTimeout(30000) + +const { simpleProductFactory } = require("../../../factories") +const adminHeaders = { headers: { Authorization: "Bearer test_token" } } + +describe("Inventory Items endpoints", () => { + let appContainer + let dbConnection + let express + + let variantId + let inventoryItems + let locationId + let location2Id + let location3Id + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + setPort(port) + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + beforeEach(async () => { + // create inventory item + await adminSeeder(dbConnection) + + const api = useApi() + + await simpleProductFactory( + dbConnection, + { + id: "test-product", + variants: [], + }, + 100 + ) + + const prodVarInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + + const response = await api.post( + `/admin/products/test-product/variants`, + { + title: "Test Variant w. inventory", + sku: "MY_SKU", + material: "material", + origin_country: "UK", + hs_code: "hs001", + mid_code: "mids", + weight: 300, + length: 100, + height: 200, + width: 150, + manage_inventory: true, + options: [ + { + option_id: "test-product-option", + value: "SS", + }, + ], + prices: [{ currency_code: "usd", amount: 2300 }], + }, + adminHeaders + ) + + const variant = response.data.product.variants[0] + + variantId = variant.id + + inventoryItems = await prodVarInventoryService.listInventoryItemsByVariant( + variantId + ) + + const stockRes = await api.post( + `/admin/stock-locations`, + { + name: "Fake Warehouse", + }, + adminHeaders + ) + locationId = stockRes.data.stock_location.id + + const secondStockRes = await api.post( + `/admin/stock-locations`, + { + name: "Another random Warehouse", + }, + adminHeaders + ) + location2Id = secondStockRes.data.stock_location.id + + const thirdStockRes = await api.post( + `/admin/stock-locations`, + { + name: "Another random Warehouse", + }, + adminHeaders + ) + location3Id = thirdStockRes.data.stock_location.id + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + jest.clearAllMocks() + const db = useDb() + return await db.teardown() + }) + + describe("Inventory Items", () => { + it("Create, update and delete inventory location level", async () => { + const api = useApi() + 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("Update inventory item", async () => { + const api = useApi() + 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("Retrieve an inventory item", async () => { + const api = useApi() + 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("List inventory items", async () => { + const api = useApi() + 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 response = await api.get(`/admin/inventory-items`, adminHeaders) + + expect(response.data.inventory_items).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, + variants: expect.arrayContaining([ + expect.objectContaining({ + id: variantId, + title: "Test Variant w. inventory", + product_id: "test-product", + sku: "MY_SKU", + manage_inventory: true, + hs_code: "hs001", + origin_country: "UK", + mid_code: "mids", + material: "material", + weight: 300, + length: 100, + height: 200, + width: 150, + metadata: null, + product: expect.objectContaining({ + id: "test-product", + }), + }), + ]), + location_levels: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + inventory_item_id: inventoryItemId, + location_id: location2Id, + 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: location3Id, + stocked_quantity: 5, + reserved_quantity: 0, + incoming_quantity: 0, + metadata: null, + available_quantity: 5, + }), + ]), + }), + ]) + }) + }) +}) diff --git a/integration-tests/plugins/__tests__/inventory/products/create-variant.js b/integration-tests/plugins/__tests__/inventory/products/create-variant.js new file mode 100644 index 0000000000..4ac6b2c1b4 --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/products/create-variant.js @@ -0,0 +1,173 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../../helpers/use-db") +const { setPort, useApi } = require("../../../../helpers/use-api") + +const { + ProductVariantInventoryService, + ProductVariantService, +} = require("@medusajs/medusa") + +const adminSeeder = require("../../../helpers/admin-seeder") + +jest.setTimeout(30000) + +const { simpleProductFactory } = require("../../../factories") + +describe("Create Variant", () => { + let appContainer + let dbConnection + let express + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + setPort(port) + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + jest.clearAllMocks() + const db = useDb() + return await db.teardown() + }) + + describe("Inventory Items", () => { + it("When creating a new product variant it should create an Inventory Item", async () => { + await adminSeeder(dbConnection) + + const api = useApi() + + await simpleProductFactory( + dbConnection, + { + id: "test-product", + variants: [{ id: "test-variant" }], + }, + 100 + ) + + const response = await api.post( + `/admin/products/test-product/variants`, + { + title: "Test Variant w. inventory", + sku: "MY_SKU", + material: "material", + origin_country: "UK", + hs_code: "hs001", + mid_code: "mids", + weight: 300, + length: 100, + height: 200, + width: 150, + manage_inventory: true, + options: [ + { + option_id: "test-product-option", + value: "SS", + }, + ], + prices: [{ currency_code: "usd", amount: 2300 }], + }, + { headers: { Authorization: "Bearer test_token" } } + ) + + expect(response.status).toEqual(200) + + const variantId = response.data.product.variants.find( + (v) => v.id !== "test-variant" + ).id + + const variantInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + const inventory = + await variantInventoryService.listInventoryItemsByVariant(variantId) + + expect(inventory).toHaveLength(1) + expect(inventory).toEqual([ + expect.objectContaining({ + origin_country: "UK", + hs_code: "hs001", + mid_code: "mids", + weight: 300, + length: 100, + height: 200, + width: 150, + }), + ]) + }) + + it("When creating a new variant fails, it should revert all the transaction", async () => { + await adminSeeder(dbConnection) + + const api = useApi() + + await simpleProductFactory( + dbConnection, + { + id: "test-product", + variants: [{ id: "test-variant" }], + }, + 100 + ) + + jest + .spyOn(ProductVariantInventoryService.prototype, "attachInventoryItem") + .mockImplementation(() => { + throw new Error("Failure while attaching inventory item") + }) + + const prodVariantDeleteMock = jest.spyOn( + ProductVariantService.prototype, + "delete" + ) + + const error = await api + .post( + `/admin/products/test-product/variants`, + { + title: "Test Variant w. inventory", + sku: "MY_SKU", + material: "material", + origin_country: "UK", + hs_code: "hs001", + mid_code: "mids", + weight: 300, + length: 100, + height: 200, + width: 150, + manage_inventory: true, + options: [ + { + option_id: "test-product-option", + value: "SS", + }, + ], + prices: [{ currency_code: "usd", amount: 2300 }], + }, + { headers: { Authorization: "Bearer test_token" } } + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data.message).toEqual( + "Failure while attaching inventory item" + ) + + expect(prodVariantDeleteMock).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/integration-tests/plugins/__tests__/inventory/service.js b/integration-tests/plugins/__tests__/inventory/service.js new file mode 100644 index 0000000000..287b271b28 --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/service.js @@ -0,0 +1,717 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../helpers/use-db") + +jest.setTimeout(30000) + +describe("Inventory Module", () => { + let appContainer + let dbConnection + let express + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + describe("Inventory Module Interface", () => { + it("createInventoryItem", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + origin_country: "CH", + mid_code: "mid code", + material: "lycra", + weight: 100, + length: 200, + height: 50, + width: 50, + metadata: { + abc: 123, + }, + hs_code: "hs_code 123", + requires_shipping: true, + }) + + expect(inventoryItem).toEqual( + expect.objectContaining({ + id: expect.any(String), + sku: "sku_1", + origin_country: "CH", + hs_code: "hs_code 123", + mid_code: "mid code", + material: "lycra", + weight: 100, + length: 200, + height: 50, + width: 50, + requires_shipping: true, + metadata: { abc: 123 }, + deleted_at: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + }) + + it("updateInventoryItem", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const item = await inventoryService.createInventoryItem({ + sku: "sku_1", + origin_country: "CH", + mid_code: "mid code", + material: "lycra", + weight: 100, + length: 200, + height: 50, + width: 50, + metadata: { + abc: 123, + }, + hs_code: "hs_code 123", + requires_shipping: true, + }) + + const updatedInventoryItem = await inventoryService.updateInventoryItem( + item.id, + { + origin_country: "CZ", + mid_code: "mid code 345", + material: "lycra and polyester", + weight: 500, + metadata: { + dce: 456, + }, + } + ) + + expect(updatedInventoryItem).toEqual( + expect.objectContaining({ + id: item.id, + sku: item.sku, + origin_country: "CZ", + hs_code: item.hs_code, + mid_code: "mid code 345", + material: "lycra and polyester", + weight: 500, + length: item.length, + height: item.height, + width: item.width, + requires_shipping: true, + metadata: { dce: 456 }, + deleted_at: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + }) + + it("deleteInventoryItem and retrieveInventoryItem", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const item = await inventoryService.createInventoryItem({ + sku: "sku_1", + origin_country: "CH", + mid_code: "mid code", + material: "lycra", + weight: 100, + length: 200, + height: 50, + width: 50, + metadata: { + abc: 123, + }, + hs_code: "hs_code 123", + requires_shipping: true, + }) + + await inventoryService.deleteInventoryItem(item.id) + + const deletedItem = inventoryService.retrieveInventoryItem(item.id) + + await expect(deletedItem).rejects.toThrow( + `InventoryItem with id ${item.id} was not found` + ) + }) + + it("createInventoryLevel", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + origin_country: "CH", + mid_code: "mid code", + material: "lycra", + weight: 100, + length: 200, + height: 50, + width: 50, + metadata: { + abc: 123, + }, + hs_code: "hs_code 123", + requires_shipping: true, + }) + + const inventoryLevel = await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: "location_123", + stocked_quantity: 50, + reserved_quantity: 15, + incoming_quantity: 4, + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: "second_location", + stocked_quantity: 10, + reserved_quantity: 1, + }) + + expect(inventoryLevel).toEqual( + expect.objectContaining({ + id: expect.any(String), + incoming_quantity: 4, + inventory_item_id: inventoryItem.id, + location_id: "location_123", + metadata: null, + reserved_quantity: 15, + stocked_quantity: 50, + deleted_at: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + + expect( + await inventoryService.retrieveStockedQuantity(inventoryItem.id, [ + "location_123", + ]) + ).toEqual(50) + + expect( + await inventoryService.retrieveAvailableQuantity(inventoryItem.id, [ + "location_123", + ]) + ).toEqual(35) + + expect( + await inventoryService.retrieveReservedQuantity(inventoryItem.id, [ + "location_123", + ]) + ).toEqual(15) + + expect( + await inventoryService.retrieveStockedQuantity(inventoryItem.id, [ + "location_123", + "second_location", + ]) + ).toEqual(60) + + expect( + await inventoryService.retrieveAvailableQuantity(inventoryItem.id, [ + "location_123", + "second_location", + ]) + ).toEqual(44) + + expect( + await inventoryService.retrieveReservedQuantity(inventoryItem.id, [ + "location_123", + "second_location", + ]) + ).toEqual(16) + }) + + it("updateInventoryLevel", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + origin_country: "CH", + mid_code: "mid code", + material: "lycra", + weight: 100, + length: 200, + height: 50, + width: 50, + metadata: { + abc: 123, + }, + hs_code: "hs_code 123", + requires_shipping: true, + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: "location_123", + stocked_quantity: 50, + reserved_quantity: 0, + incoming_quantity: 0, + }) + + const updatedLevel = await inventoryService.updateInventoryLevel( + inventoryItem.id, + "location_123", + { + stocked_quantity: 25, + reserved_quantity: 4, + incoming_quantity: 10, + } + ) + + expect(updatedLevel).toEqual( + expect.objectContaining({ + id: expect.any(String), + incoming_quantity: 10, + inventory_item_id: inventoryItem.id, + location_id: "location_123", + metadata: null, + reserved_quantity: 4, + stocked_quantity: 25, + deleted_at: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + }) + + it("deleteInventoryLevel", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + origin_country: "CH", + mid_code: "mid code", + material: "lycra", + weight: 100, + length: 200, + height: 50, + width: 50, + metadata: { + abc: 123, + }, + hs_code: "hs_code 123", + requires_shipping: true, + }) + + const inventoryLevel = await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: "location_123", + stocked_quantity: 50, + reserved_quantity: 0, + incoming_quantity: 0, + }) + + await inventoryService.deleteInventoryLevel( + inventoryItem.id, + "location_123" + ) + + const deletedLevel = inventoryService.retrieveInventoryLevel( + inventoryItem.id, + "location_123" + ) + + await expect(deletedLevel).rejects.toThrow( + `Inventory level for item ${inventoryItem.id} and location location_123 not found` + ) + }) + + it("createReservationItem", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const locationId = "location_123" + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + }) + + const tryReserve = inventoryService.createReservationItem({ + line_item_id: "line_item_123", + inventory_item_id: inventoryItem.id, + location_id: locationId, + quantity: 10, + metadata: { + abc: 123, + }, + }) + + await expect(tryReserve).rejects.toThrow( + `Item ${inventoryItem.id} is not stocked at location ${locationId}` + ) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: locationId, + stocked_quantity: 50, + reserved_quantity: 0, + incoming_quantity: 0, + }) + + const inventoryReservation = await inventoryService.createReservationItem( + { + line_item_id: "line_item_123", + inventory_item_id: inventoryItem.id, + location_id: locationId, + quantity: 10, + metadata: { + abc: 123, + }, + } + ) + + expect(inventoryReservation).toEqual( + expect.objectContaining({ + id: expect.any(String), + line_item_id: "line_item_123", + inventory_item_id: inventoryItem.id, + location_id: locationId, + quantity: 10, + metadata: { abc: 123 }, + deleted_at: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + + const [available, reserved] = await Promise.all([ + inventoryService.retrieveAvailableQuantity(inventoryItem.id, [ + locationId, + ]), + inventoryService.retrieveReservedQuantity(inventoryItem.id, locationId), + ]) + + expect(available).toEqual(40) + expect(reserved).toEqual(10) + }) + + it("updateReservationItem", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const locationId = "location_123" + const newLocationId = "location_new" + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: locationId, + stocked_quantity: 50, + reserved_quantity: 0, + incoming_quantity: 0, + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: newLocationId, + stocked_quantity: 20, + reserved_quantity: 5, + incoming_quantity: 0, + }) + + const inventoryReservation = await inventoryService.createReservationItem( + { + line_item_id: "line_item_123", + inventory_item_id: inventoryItem.id, + location_id: locationId, + quantity: 15, + metadata: { + abc: 123, + }, + } + ) + + const [available, reserved] = await Promise.all([ + inventoryService.retrieveAvailableQuantity( + inventoryItem.id, + locationId + ), + inventoryService.retrieveReservedQuantity(inventoryItem.id, locationId), + ]) + + expect(available).toEqual(35) + expect(reserved).toEqual(15) + + const updatedReservation = await inventoryService.updateReservationItem( + inventoryReservation.id, + { + quantity: 5, + } + ) + + expect(updatedReservation).toEqual( + expect.objectContaining({ + id: expect.any(String), + line_item_id: "line_item_123", + inventory_item_id: inventoryItem.id, + location_id: locationId, + quantity: 5, + metadata: { abc: 123 }, + }) + ) + + const [newAvailable, newReserved] = await Promise.all([ + inventoryService.retrieveAvailableQuantity( + inventoryItem.id, + locationId + ), + inventoryService.retrieveReservedQuantity(inventoryItem.id, locationId), + ]) + + expect(newAvailable).toEqual(45) + expect(newReserved).toEqual(5) + + const updatedReservationLocation = + await inventoryService.updateReservationItem(inventoryReservation.id, { + quantity: 12, + location_id: newLocationId, + }) + + expect(updatedReservationLocation).toEqual( + expect.objectContaining({ + id: expect.any(String), + line_item_id: "line_item_123", + inventory_item_id: inventoryItem.id, + location_id: newLocationId, + quantity: 12, + metadata: { abc: 123 }, + }) + ) + + const [ + oldLocationAvailable, + oldLocationReserved, + newLocationAvailable, + newLocationReserved, + ] = await Promise.all([ + inventoryService.retrieveAvailableQuantity( + inventoryItem.id, + locationId + ), + inventoryService.retrieveReservedQuantity(inventoryItem.id, locationId), + inventoryService.retrieveAvailableQuantity( + inventoryItem.id, + newLocationId + ), + inventoryService.retrieveReservedQuantity( + inventoryItem.id, + newLocationId + ), + ]) + + expect(oldLocationAvailable).toEqual(50) + expect(oldLocationReserved).toEqual(0) + expect(newLocationAvailable).toEqual(3) + expect(newLocationReserved).toEqual(17) + }) + + it("deleteReservationItem and deleteReservationItemsByLineItem", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const locationId = "location_123" + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: locationId, + stocked_quantity: 10, + }) + + const inventoryReservation = await inventoryService.createReservationItem( + { + line_item_id: "line_item_123", + inventory_item_id: inventoryItem.id, + location_id: locationId, + quantity: 1, + } + ) + + for (let quant = 1; quant <= 3; quant++) { + await inventoryService.createReservationItem({ + line_item_id: "line_item_444", + inventory_item_id: inventoryItem.id, + location_id: locationId, + quantity: 1, + }) + } + + const [available, reserved] = await Promise.all([ + inventoryService.retrieveAvailableQuantity( + inventoryItem.id, + locationId + ), + inventoryService.retrieveReservedQuantity(inventoryItem.id, locationId), + ]) + + expect(available).toEqual(6) + expect(reserved).toEqual(4) + + await inventoryService.deleteReservationItemsByLineItem("line_item_444") + const [afterDeleteLineitemAvailable, afterDeleteLineitemReserved] = + await Promise.all([ + inventoryService.retrieveAvailableQuantity( + inventoryItem.id, + locationId + ), + inventoryService.retrieveReservedQuantity( + inventoryItem.id, + locationId + ), + ]) + + expect(afterDeleteLineitemAvailable).toEqual(9) + expect(afterDeleteLineitemReserved).toEqual(1) + + await inventoryService.deleteReservationItem(inventoryReservation.id) + const [afterDeleteReservationAvailable, afterDeleteReservationReserved] = + await Promise.all([ + inventoryService.retrieveAvailableQuantity( + inventoryItem.id, + locationId + ), + inventoryService.retrieveReservedQuantity( + inventoryItem.id, + locationId + ), + ]) + + expect(afterDeleteReservationAvailable).toEqual(10) + expect(afterDeleteReservationReserved).toEqual(0) + }) + + it("confirmInventory", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const locationId = "location_123" + const secondLocationId = "location_551" + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: locationId, + stocked_quantity: 10, + reserved_quantity: 5, + }) + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: secondLocationId, + stocked_quantity: 6, + reserved_quantity: 1, + }) + + expect( + await inventoryService.confirmInventory(inventoryItem.id, locationId, 5) + ).toBeTruthy() + + expect( + await inventoryService.confirmInventory( + inventoryItem.id, + [locationId, secondLocationId], + 10 + ) + ).toBeTruthy() + + expect( + await inventoryService.confirmInventory(inventoryItem.id, locationId, 6) + ).toBeFalsy() + + expect( + await inventoryService.confirmInventory( + inventoryItem.id, + [locationId, secondLocationId], + 11 + ) + ).toBeFalsy() + }) + + it("adjustInventory", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const locationId = "location_123" + const secondLocationId = "location_551" + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: locationId, + stocked_quantity: 10, + reserved_quantity: 5, + }) + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: secondLocationId, + stocked_quantity: 6, + reserved_quantity: 1, + }) + + expect( + await inventoryService.adjustInventory(inventoryItem.id, locationId, -5) + ).toEqual( + expect.objectContaining({ + id: expect.any(String), + inventory_item_id: inventoryItem.id, + location_id: locationId, + stocked_quantity: 5, + reserved_quantity: 5, + incoming_quantity: 0, + metadata: null, + deleted_at: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + + expect( + await inventoryService.adjustInventory( + inventoryItem.id, + secondLocationId, + -10 + ) + ).toEqual( + expect.objectContaining({ + id: expect.any(String), + inventory_item_id: inventoryItem.id, + location_id: secondLocationId, + stocked_quantity: -4, + reserved_quantity: 1, + incoming_quantity: 0, + metadata: null, + deleted_at: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + }) + }) +}) diff --git a/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap b/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap index 446911e4fd..186346654c 100644 --- a/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap +++ b/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap @@ -368,7 +368,7 @@ Object { "height": null, "hs_code": null, "id": "test-variant", - "inventory_quantity": 11, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, @@ -486,7 +486,7 @@ Object { "height": null, "hs_code": null, "id": "test-variant", - "inventory_quantity": 11, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, @@ -670,7 +670,7 @@ Object { "height": null, "hs_code": null, "id": "test-variant", - "inventory_quantity": 11, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, @@ -836,7 +836,7 @@ Object { "height": null, "hs_code": null, "id": "test-variant", - "inventory_quantity": 12, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, @@ -2022,7 +2022,7 @@ Object { "height": null, "hs_code": null, "id": "variant-2", - "inventory_quantity": 9, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, @@ -2130,7 +2130,7 @@ Object { "height": null, "hs_code": null, "id": "test-variant", - "inventory_quantity": 9, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, @@ -2252,7 +2252,7 @@ Object { "height": null, "hs_code": null, "id": "variant-2", - "inventory_quantity": 9, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, @@ -2368,7 +2368,7 @@ Object { "height": null, "hs_code": null, "id": "variant-2", - "inventory_quantity": 9, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, diff --git a/integration-tests/plugins/__tests__/stock-location/service.js b/integration-tests/plugins/__tests__/stock-location/service.js new file mode 100644 index 0000000000..fc633efc2e --- /dev/null +++ b/integration-tests/plugins/__tests__/stock-location/service.js @@ -0,0 +1,271 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../helpers/use-db") + +jest.setTimeout(30000) + +describe("Inventory Module", () => { + let appContainer + let dbConnection + let express + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + describe("Stock Location Module Interface", () => { + it("create", async () => { + const stockLocationService = appContainer.resolve("stockLocationService") + + expect( + await stockLocationService.create({ + name: "first location", + }) + ).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "first location", + deleted_at: null, + address_id: null, + metadata: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + + expect( + await stockLocationService.create({ + name: "second location", + metadata: { + extra: "abc", + }, + address: { + address_1: "addr_1", + address_2: "line 2", + country_code: "DK", + city: "city", + phone: "111222333", + province: "province", + postal_code: "555-714", + metadata: { + abc: 123, + }, + }, + }) + ).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "second location", + metadata: { + extra: "abc", + }, + address_id: expect.any(String), + }) + ) + }) + + it("update", async () => { + const stockLocationService = appContainer.resolve("stockLocationService") + + const loc = await stockLocationService.create({ + name: "location", + address: { + address_1: "addr_1", + address_2: "line 2", + country_code: "DK", + city: "city", + phone: "111222333", + province: "province", + postal_code: "555-714", + metadata: { + abc: 123, + }, + }, + }) + const addressId = loc.address_id + + expect( + await stockLocationService.retrieve(loc.id, { + relations: ["address"], + }) + ).toEqual( + expect.objectContaining({ + id: loc.id, + created_at: expect.any(Date), + updated_at: expect.any(Date), + deleted_at: null, + name: "location", + address_id: addressId, + metadata: null, + address: expect.objectContaining({ + id: addressId, + created_at: expect.any(Date), + updated_at: expect.any(Date), + deleted_at: null, + address_1: "addr_1", + address_2: "line 2", + company: null, + city: "city", + country_code: "DK", + phone: "111222333", + province: "province", + postal_code: "555-714", + metadata: { abc: 123 }, + }), + }) + ) + + expect( + await stockLocationService.update(loc.id, { + name: "location name", + address_id: addressId, + address: { + address_1: "addr_1 updated", + country_code: "US", + }, + }) + ).toEqual( + expect.objectContaining({ + id: loc.id, + name: "location name", + address_id: addressId, + }) + ) + + expect( + await stockLocationService.retrieve(loc.id, { + relations: ["address"], + }) + ).toEqual( + expect.objectContaining({ + id: loc.id, + created_at: expect.any(Date), + updated_at: expect.any(Date), + deleted_at: null, + name: "location name", + address_id: addressId, + metadata: null, + address: expect.objectContaining({ + id: addressId, + created_at: expect.any(Date), + updated_at: expect.any(Date), + deleted_at: null, + address_1: "addr_1 updated", + address_2: "line 2", + company: null, + city: "city", + country_code: "US", + phone: "111222333", + province: "province", + postal_code: "555-714", + metadata: { abc: 123 }, + }), + }) + ) + }) + + it("updateAddress", async () => { + const stockLocationService = appContainer.resolve("stockLocationService") + + const loc = await stockLocationService.create({ + name: "location", + address: { + address_1: "addr_1", + address_2: "line 2", + country_code: "DK", + city: "city", + phone: "111222333", + province: "province", + postal_code: "555-714", + metadata: { + abc: 123, + }, + }, + }) + const addressId = loc.address_id + + expect( + await stockLocationService.updateAddress(addressId, { + address_1: "addr_1 updated", + country_code: "US", + }) + ).toEqual( + expect.objectContaining({ + id: addressId, + address_1: "addr_1 updated", + address_2: "line 2", + country_code: "US", + city: "city", + phone: "111222333", + province: "province", + postal_code: "555-714", + metadata: { + abc: 123, + }, + }) + ) + + expect( + await stockLocationService.retrieve(loc.id, { + relations: ["address"], + }) + ).toEqual( + expect.objectContaining({ + id: loc.id, + address_id: addressId, + address: expect.objectContaining({ + id: addressId, + address_1: "addr_1 updated", + }), + }) + ) + }) + + it("delete", async () => { + const stockLocationService = appContainer.resolve("stockLocationService") + + const loc = await stockLocationService.create({ + name: "location", + address: { + address_1: "addr_1", + address_2: "line 2", + country_code: "DK", + city: "city", + phone: "111222333", + province: "province", + postal_code: "555-714", + metadata: { + abc: 123, + }, + }, + }) + + await stockLocationService.delete(loc.id) + + const deletedItem = stockLocationService.retrieve(loc.id) + + await expect(deletedItem).rejects.toThrow( + `StockLocation with id ${loc.id} was not found` + ) + }) + }) +}) diff --git a/integration-tests/plugins/helpers/cart-seeder.js b/integration-tests/plugins/helpers/cart-seeder.js index da7eb55b83..b24b85f48c 100644 --- a/integration-tests/plugins/helpers/cart-seeder.js +++ b/integration-tests/plugins/helpers/cart-seeder.js @@ -17,6 +17,8 @@ const { } = require("@medusajs/medusa") module.exports = async (connection, data = {}) => { + const salesChannelId = data?.sales_channel_id + const yesterday = ((today) => new Date(today.setDate(today.getDate() - 1)))( new Date() ) @@ -240,7 +242,7 @@ module.exports = async (connection, data = {}) => { is_disabled: false, starts_at: tenDaysAgo, ends_at: tenDaysFromToday, - valid_duration: "P1M", //one month + valid_duration: "P1M", // one month }) DynamicDiscount.regions = [r] @@ -381,6 +383,7 @@ module.exports = async (connection, data = {}) => { const cart = manager.create(Cart, { id: "test-cart", customer_id: "some-customer", + sales_channel_id: salesChannelId, email: "some-customer@email.com", shipping_address: { id: "test-shipping-address", @@ -397,6 +400,7 @@ module.exports = async (connection, data = {}) => { const cart2 = manager.create(Cart, { id: "test-cart-2", customer_id: "some-customer", + sales_channel_id: salesChannelId, email: "some-customer@email.com", shipping_address: { id: "test-shipping-address", @@ -413,6 +417,7 @@ module.exports = async (connection, data = {}) => { id: "swap-cart", type: "swap", customer_id: "some-customer", + sales_channel_id: salesChannelId, email: "some-customer@email.com", shipping_address: { id: "test-shipping-address", diff --git a/integration-tests/plugins/medusa-config.js b/integration-tests/plugins/medusa-config.js index 2b16170fa9..30192d0c20 100644 --- a/integration-tests/plugins/medusa-config.js +++ b/integration-tests/plugins/medusa-config.js @@ -28,4 +28,16 @@ module.exports = { jwt_secret: "test", cookie_secret: "test", }, + modules: { + stockLocationService: { + scope: "internal", + resources: "shared", + resolve: "@medusajs/stock-location", + }, + inventoryService: { + scope: "internal", + resources: "shared", + resolve: "@medusajs/inventory", + }, + }, } diff --git a/packages/inventory/src/services/inventory-level.ts b/packages/inventory/src/services/inventory-level.ts index 97f1f4dc63..210050c90d 100644 --- a/packages/inventory/src/services/inventory-level.ts +++ b/packages/inventory/src/services/inventory-level.ts @@ -223,8 +223,12 @@ export default class InventoryLevelService extends TransactionBaseService { */ async getStockedQuantity( inventoryItemId: string, - locationIds: string[] + locationIds: string[] | string ): Promise { + if (!Array.isArray(locationIds)) { + locationIds = [locationIds] + } + const manager = this.getManager() const levelRepository = manager.getRepository(InventoryLevel) @@ -235,7 +239,7 @@ export default class InventoryLevelService extends TransactionBaseService { .andWhere("location_id IN (:...locationIds)", { locationIds }) .getRawOne() - return result.quantity + return parseFloat(result.quantity) } /** @@ -246,8 +250,12 @@ export default class InventoryLevelService extends TransactionBaseService { */ async getAvailableQuantity( inventoryItemId: string, - locationIds: string[] + locationIds: string[] | string ): Promise { + if (!Array.isArray(locationIds)) { + locationIds = [locationIds] + } + const manager = this.getManager() const levelRepository = manager.getRepository(InventoryLevel) @@ -258,7 +266,7 @@ export default class InventoryLevelService extends TransactionBaseService { .andWhere("location_id IN (:...locationIds)", { locationIds }) .getRawOne() - return result.quantity + return parseFloat(result.quantity) } /** @@ -269,8 +277,12 @@ export default class InventoryLevelService extends TransactionBaseService { */ async getReservedQuantity( inventoryItemId: string, - locationIds: string[] + locationIds: string[] | string ): Promise { + if (!Array.isArray(locationIds)) { + locationIds = [locationIds] + } + const manager = this.getManager() const levelRepository = manager.getRepository(InventoryLevel) @@ -281,6 +293,6 @@ export default class InventoryLevelService extends TransactionBaseService { .andWhere("location_id IN (:...locationIds)", { locationIds }) .getRawOne() - return result.quantity + return parseFloat(result.quantity) } } diff --git a/packages/inventory/src/services/reservation-item.ts b/packages/inventory/src/services/reservation-item.ts index a105a32540..314821b8e8 100644 --- a/packages/inventory/src/services/reservation-item.ts +++ b/packages/inventory/src/services/reservation-item.ts @@ -173,8 +173,31 @@ export default class ReservationItemService extends TransactionBaseService { const shouldUpdateQuantity = isDefined(data.quantity) && data.quantity !== item.quantity + const shouldUpdateLocation = + isDefined(data.location_id) && + isDefined(data.quantity) && + data.location_id !== item.location_id + const ops: Promise[] = [] - if (shouldUpdateQuantity) { + + if (shouldUpdateLocation) { + ops.push( + this.inventoryLevelService_ + .withTransaction(manager) + .adjustReservedQuantity( + item.inventory_item_id, + item.location_id, + item.quantity * -1 + ), + this.inventoryLevelService_ + .withTransaction(manager) + .adjustReservedQuantity( + item.inventory_item_id, + data.location_id!, + data.quantity! + ) + ) + } else if (shouldUpdateQuantity) { const quantityDiff = data.quantity! - item.quantity ops.push( this.inventoryLevelService_ 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 index 383facf856..55b116651a 100644 --- 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 @@ -12,9 +12,9 @@ export const buildLevelsByInventoryItemId = ( inventoryLevels: InventoryLevelDTO[], locationIds: string[] ) => { - const filteredLevels = inventoryLevels.filter((level) => - locationIds?.includes(level.location_id) - ) + const filteredLevels = inventoryLevels.filter((level) => { + return !locationIds.length || locationIds.includes(level.location_id) + }) return filteredLevels.reduce((acc, level) => { acc[level.inventory_item_id] = acc[level.inventory_item_id] ?? [] @@ -58,6 +58,7 @@ export const joinLevels = async ( locationIds, inventoryService ) + return inventoryItems.map((inventoryItem) => ({ ...inventoryItem, location_levels: levelsByItemId[inventoryItem.id] || [], diff --git a/packages/medusa/src/api/routes/admin/products/transaction/create-product-variant.ts b/packages/medusa/src/api/routes/admin/products/transaction/create-product-variant.ts index fbb69c885c..e0f1e594b1 100644 --- a/packages/medusa/src/api/routes/admin/products/transaction/create-product-variant.ts +++ b/packages/medusa/src/api/routes/admin/products/transaction/create-product-variant.ts @@ -82,12 +82,7 @@ export const createVariantTransaction = async ( const productVariantServiceTx = productVariantService.withTransaction(manager) async function createVariant(variantInput: CreateProductVariantInput) { - const variant = await productVariantServiceTx.create( - productId, - variantInput - ) - - return { variant } + return await productVariantServiceTx.create(productId, variantInput) } async function removeVariant(variant: ProductVariant) { @@ -101,7 +96,7 @@ export const createVariantTransaction = async ( return } - const inventoryItem = await inventoryServiceTx!.createInventoryItem({ + return await inventoryServiceTx!.createInventoryItem({ sku: variant.sku, origin_country: variant.origin_country, hs_code: variant.hs_code, @@ -112,8 +107,6 @@ export const createVariantTransaction = async ( height: variant.height, width: variant.width, }) - - return { inventoryItem } } async function removeInventoryItem(inventoryItem: InventoryItemDTO) { diff --git a/packages/medusa/src/services/__mocks__/inventory.js b/packages/medusa/src/services/__mocks__/inventory.js new file mode 100644 index 0000000000..352df04b7c --- /dev/null +++ b/packages/medusa/src/services/__mocks__/inventory.js @@ -0,0 +1,93 @@ +export const InventoryServiceMock = { + withTransaction: function () { + return this + }, + + listInventoryItems: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + listReservationItems: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + listInventoryLevels: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + retrieveInventoryItem: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + retrieveInventoryLevel: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + createReservationItem: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + createInventoryItem: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + createInventoryLevel: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + updateInventoryLevel: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + updateInventoryItem: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + updateReservationItem: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + deleteReservationItemsByLineItem: jest + .fn() + .mockImplementation((id, config) => { + return Promise.resolve() + }), + + deleteReservationItem: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + deleteInventoryItem: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + deleteInventoryLevel: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + adjustInventory: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + confirmInventory: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + retrieveAvailableQuantity: jest.fn().mockImplementation((id, config) => { + return Promise.resolve(10) + }), + + retrieveStockedQuantity: jest.fn().mockImplementation((id, config) => { + return Promise.resolve(9) + }), + + retrieveReservedQuantity: jest.fn().mockImplementation((id, config) => { + return Promise.resolve(1) + }), +} + +const mock = jest.fn().mockImplementation(() => { + return InventoryServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__mocks__/stock-location.js b/packages/medusa/src/services/__mocks__/stock-location.js new file mode 100644 index 0000000000..f0afe3746c --- /dev/null +++ b/packages/medusa/src/services/__mocks__/stock-location.js @@ -0,0 +1,46 @@ +import { IdMap } from "medusa-test-utils" + +export const StockLocationServiceMock = { + withTransaction: function () { + return this + }, + + retrieve: jest.fn().mockImplementation((id, config) => { + return Promise.resolve({ + id: id, + name: "stock location 1 name", + }) + }), + update: jest.fn().mockImplementation((id, data) => { + return Promise.resolve({ id, ...data }) + }), + + listAndCount: jest.fn().mockImplementation(() => { + return Promise.resolve([ + [ + { + id: IdMap.getId("stock_location_1"), + name: "stock location 1 name", + }, + ], + 1, + ]) + }), + + create: jest.fn().mockImplementation((data) => { + return Promise.resolve({ + id: id, + ...data, + }) + }), + + delete: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), +} + +const mock = jest.fn().mockImplementation(() => { + return StockLocationServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/sales-channel-location.ts b/packages/medusa/src/services/sales-channel-location.ts index a53322f283..153f48d28c 100644 --- a/packages/medusa/src/services/sales-channel-location.ts +++ b/packages/medusa/src/services/sales-channel-location.ts @@ -69,11 +69,17 @@ class SalesChannelLocationService extends TransactionBaseService { const salesChannel = await this.salesChannelService_ .withTransaction(manager) .retrieve(salesChannelId) - const stockLocation = await this.stockLocationService.retrieve(locationId) + + const stockLocationId = locationId + + if (this.stockLocationService) { + const stockLocation = await this.stockLocationService.retrieve(locationId) + locationId = stockLocation.id + } const salesChannelLocation = manager.create(SalesChannelLocation, { sales_channel_id: salesChannel.id, - location_id: stockLocation.id, + location_id: stockLocationId, }) await manager.save(salesChannelLocation) diff --git a/packages/medusa/src/types/inventory.ts b/packages/medusa/src/types/inventory.ts index 68c0260b2f..1e6d631257 100644 --- a/packages/medusa/src/types/inventory.ts +++ b/packages/medusa/src/types/inventory.ts @@ -209,7 +209,6 @@ export type CreateInventoryItemInput = { } export type CreateReservationItemInput = { - type?: string line_item_id?: string inventory_item_id: string location_id: string diff --git a/packages/stock-location/src/services/stock-location.ts b/packages/stock-location/src/services/stock-location.ts index 75118b7841..9d57fc100a 100644 --- a/packages/stock-location/src/services/stock-location.ts +++ b/packages/stock-location/src/services/stock-location.ts @@ -199,7 +199,7 @@ export default class StockLocationService extends TransactionBaseService { } } - const { metadata, ...fields } = updateData + const { metadata, ...fields } = data const toSave = locationRepo.merge(item, fields) if (metadata) {