From d184d23c6384d5f8bf52827826b62c6bef37f884 Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Tue, 18 Jul 2023 11:17:57 +0200 Subject: [PATCH] Feat/bulk operations for inventory service (#4503) * initial push * bulk delete reservations by location ids * add method to interface (not implemented yet) * bulk update * delete reservations by location id bulk * add create bulk for inventory item * refactor attach inventory item method * add changeset * verbose false * method override instead of multiple methods * change up method signature * redo changes when updating interface * update createInventoryLevel method * rename variables * fix feedback * return correct string array when emitting event * refactor inventory service * redo order changes * snapshot * move prep methods --- .changeset/eight-ravens-juggle.md | 8 + .../inventory/inventory-items/index.js | 381 +++++++++++++++++- .../__tests__/inventory/order/draft-order.js | 2 +- .../__tests__/inventory/order/order.js | 24 +- .../inventory/products/get-product.js | 1 + .../inventory/products/get-variant.js | 1 + .../inventory/products/list-variants.js | 1 + .../plugins/__tests__/inventory/service.js | 98 +++++ .../inventory/variant-inventory-service.js | 289 +++++++++++++ .../factories/simple-product-factory.ts | 6 +- .../inventory/src/services/inventory-item.ts | 52 +-- .../inventory/src/services/inventory-level.ts | 36 +- packages/inventory/src/services/inventory.ts | 180 ++++++--- .../src/services/reservation-item.ts | 77 ++-- .../src/services/brightpearl.js | 14 +- .../transaction/create-product-variant.ts | 21 +- .../src/services/product-variant-inventory.ts | 180 ++++++--- packages/medusa/src/utils/diff-set.ts | 14 + packages/types/src/inventory/common.ts | 5 + packages/types/src/inventory/inventory.ts | 32 +- 20 files changed, 1194 insertions(+), 228 deletions(-) create mode 100644 .changeset/eight-ravens-juggle.md create mode 100644 integration-tests/plugins/__tests__/inventory/variant-inventory-service.js create mode 100644 packages/medusa/src/utils/diff-set.ts diff --git a/.changeset/eight-ravens-juggle.md b/.changeset/eight-ravens-juggle.md new file mode 100644 index 0000000000..400616d963 --- /dev/null +++ b/.changeset/eight-ravens-juggle.md @@ -0,0 +1,8 @@ +--- +"medusa-plugin-brightpearl": patch +"@medusajs/inventory": patch +"@medusajs/medusa": patch +"@medusajs/types": patch +--- + +feat(medusa,inventory,types,brightpearl): update some inventory methods to be bulk-operation enabled diff --git a/integration-tests/plugins/__tests__/inventory/inventory-items/index.js b/integration-tests/plugins/__tests__/inventory/inventory-items/index.js index 7cbd4745d8..9d5c9e2e75 100644 --- a/integration-tests/plugins/__tests__/inventory/inventory-items/index.js +++ b/integration-tests/plugins/__tests__/inventory/inventory-items/index.js @@ -8,7 +8,10 @@ const adminSeeder = require("../../../helpers/admin-seeder") jest.setTimeout(30000) -const { simpleProductFactory } = require("../../../factories") +const { + simpleProductFactory, + simpleOrderFactory, +} = require("../../../factories") const adminHeaders = { headers: { Authorization: "Bearer test_token" } } describe("Inventory Items endpoints", () => { @@ -127,7 +130,7 @@ describe("Inventory Items endpoints", () => { }) describe("Inventory Items", () => { - it("Create, update and delete inventory location level", async () => { + it("should create, update and delete the inventory location levels", async () => { const api = useApi() const inventoryItemId = inventoryItems[0].id @@ -181,7 +184,7 @@ describe("Inventory Items endpoints", () => { ) }) - it("Update inventory item", async () => { + it("should update the inventory item", async () => { const api = useApi() const inventoryItemId = inventoryItems[0].id @@ -207,7 +210,7 @@ describe("Inventory Items endpoints", () => { ) }) - it("fails to update location level to negative quantity", async () => { + it("should fail to update the location level to negative quantity", async () => { const api = useApi() const inventoryItemId = inventoryItems[0].id @@ -241,7 +244,7 @@ describe("Inventory Items endpoints", () => { }) }) - it("Retrieve an inventory item", async () => { + it("should retrieve the inventory item", async () => { const api = useApi() const inventoryItemId = inventoryItems[0].id @@ -312,7 +315,7 @@ describe("Inventory Items endpoints", () => { }) }) - it("Creates an inventory item using the api", async () => { + it("should create the inventory item using the api", async () => { const product = await simpleProductFactory(dbConnection, {}) const api = useApi() @@ -362,7 +365,7 @@ describe("Inventory Items endpoints", () => { expect(variantInventoryRes.status).toEqual(200) }) - it("lists location levels based on id param constraint", async () => { + it("should list the location levels based on id param constraint", async () => { const api = useApi() const inventoryItemId = inventoryItems[0].id @@ -397,8 +400,9 @@ describe("Inventory Items endpoints", () => { }) ) }) + describe("List inventory items", () => { - it("Lists inventory items with location", async () => { + it("should list inventory items with location", async () => { const api = useApi() await api.post( @@ -451,7 +455,7 @@ describe("Inventory Items endpoints", () => { ) }) - it("Lists inventory items", async () => { + it("should list the inventory items", async () => { const api = useApi() const inventoryItemId = inventoryItems[0].id @@ -539,21 +543,21 @@ describe("Inventory Items endpoints", () => { ) }) - it("Lists inventory items searching by title, description and sku", async () => { + it("should list the inventory items searching by title, description and sku", async () => { const api = useApi() const inventoryService = appContainer.resolve("inventoryService") - await Promise.all([ - inventoryService.createInventoryItem({ + await inventoryService.createInventoryItems([ + { title: "Test Item", - }), - inventoryService.createInventoryItem({ + }, + { description: "Test Desc", - }), - inventoryService.createInventoryItem({ + }, + { sku: "Test Sku", - }), + }, ]) const response = await api.get( @@ -585,7 +589,7 @@ describe("Inventory Items endpoints", () => { }) }) - it("When deleting an inventory item it removes associated levels and reservations", async () => { + it("should remove associated levels and reservations when deleting an inventory item", async () => { const api = useApi() const inventoryService = appContainer.resolve("inventoryService") @@ -650,7 +654,7 @@ describe("Inventory Items endpoints", () => { expect(inventoryLevelCountPostDelete).toEqual(0) }) - it("When deleting an inventory item it removes the product variants associated to it", async () => { + it("should remove the product variant associations when deleting an inventory item", async () => { const api = useApi() await simpleProductFactory( @@ -727,5 +731,344 @@ describe("Inventory Items endpoints", () => { ) ).toHaveLength(1) }) + + describe("inventory service", () => { + let inventoryService + let productInventoryService + + beforeAll(() => { + inventoryService = appContainer.resolve("inventoryService") + productInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + }) + + it("should bulk remove the inventory items", async () => { + const [items] = await inventoryService.listInventoryItems() + + const ids = items.map((item) => item.id) + + expect(ids).not.toBeFalsy() + + await inventoryService.deleteInventoryItem(ids) + + const [emptyItems] = await inventoryService.listInventoryItems() + expect(emptyItems).toHaveLength(0) + }) + + it("should bulk create the inventory levels", async () => { + const [items] = await inventoryService.listInventoryItems() + + const itemId = items[0].id + + await inventoryService.createInventoryLevels([ + { + inventory_item_id: itemId, + location_id: locationId, + stocked_quantity: 10, + }, + { + inventory_item_id: itemId, + location_id: location2Id, + stocked_quantity: 10, + }, + ]) + + const [levels] = await inventoryService.listInventoryLevels({ + inventory_item_id: itemId, + }) + expect(levels).toHaveLength(2) + expect(levels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: itemId, + location_id: locationId, + }), + expect.objectContaining({ + inventory_item_id: itemId, + location_id: location2Id, + }), + ]) + ) + }) + + it("should bulk create the inventory items", async () => { + const items = [ + { + sku: "sku-1", + }, + { + sku: "sku-2", + }, + ] + const createdItems = await inventoryService.createInventoryItems(items) + + expect(createdItems).toHaveLength(2) + expect(createdItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sku: "sku-1", + }), + expect.objectContaining({ + sku: "sku-2", + }), + ]) + ) + }) + + it("should bulk delete the inventory levels by location id", async () => { + const [items] = await inventoryService.listInventoryItems() + + const itemId = items[0].id + + await inventoryService.createInventoryLevels([ + { + inventory_item_id: itemId, + location_id: locationId, + stocked_quantity: 10, + }, + { + inventory_item_id: itemId, + location_id: location2Id, + stocked_quantity: 10, + }, + ]) + + await inventoryService.deleteInventoryItemLevelByLocationId([ + locationId, + location2Id, + ]) + + const [levels] = await inventoryService.listInventoryLevels({ + inventory_item_id: itemId, + }) + expect(levels).toHaveLength(0) + }) + + it("should bulk delete the inventory levels by location id", async () => { + const [items] = await inventoryService.listInventoryItems() + + const itemId = items[0].id + + await inventoryService.createInventoryLevels([ + { + inventory_item_id: itemId, + location_id: locationId, + stocked_quantity: 10, + }, + { + inventory_item_id: itemId, + location_id: location2Id, + stocked_quantity: 10, + }, + ]) + + await inventoryService.deleteInventoryItemLevelByLocationId([ + locationId, + location2Id, + ]) + + const [levels] = await inventoryService.listInventoryLevels({ + inventory_item_id: itemId, + }) + expect(levels).toHaveLength(0) + }) + + it("should fail to create the reservations with invalid configuration", async () => { + const order = await simpleOrderFactory(dbConnection, { + line_items: [ + { id: "line-item-1", quantity: 1 }, + { id: "line-item-2", quantity: 1 }, + ], + }) + + const [items] = await inventoryService.listInventoryItems() + + const itemId = items[0].id + + const error = await inventoryService + .createReservationItems([ + { + inventory_item_id: itemId, + location_id: locationId, + line_item_id: "line-item-1", + quantity: 1, + }, + { + inventory_item_id: itemId, + location_id: locationId, + line_item_id: "line-item-2", + quantity: 1, + }, + ]) + .catch((err) => err) + + expect(error.message).toEqual( + `Item ${itemId} is not stocked at location ${locationId}, Item ${itemId} is not stocked at location ${locationId}` + ) + }) + + it("should bulk delete the reservations by their line item ids", async () => { + const order = await simpleOrderFactory(dbConnection, { + line_items: [ + { id: "line-item-1", quantity: 1 }, + { id: "line-item-2", quantity: 1 }, + ], + }) + + const [items] = await inventoryService.listInventoryItems() + + const itemId = items[0].id + + await inventoryService.createInventoryLevel({ + inventory_item_id: itemId, + location_id: locationId, + stocked_quantity: 10, + }) + + await inventoryService.createReservationItems([ + { + inventory_item_id: itemId, + location_id: locationId, + line_item_id: "line-item-1", + quantity: 1, + }, + { + inventory_item_id: itemId, + location_id: locationId, + line_item_id: "line-item-2", + quantity: 1, + }, + ]) + + const [reservations] = await inventoryService.listReservationItems({ + inventory_item_id: itemId, + }) + expect(reservations).toHaveLength(2) + + await inventoryService.deleteReservationItemsByLineItem([ + "line-item-1", + "line-item-2", + ]) + + const [deletedReservations] = + await inventoryService.listReservationItems({ + inventory_item_id: itemId, + }) + expect(deletedReservations).toHaveLength(0) + }) + + it("should bulk delete the reservations by their location id", async () => { + const order = await simpleOrderFactory(dbConnection, { + line_items: [ + { id: "line-item-1", quantity: 1 }, + { id: "line-item-2", quantity: 1 }, + ], + }) + + const [items] = await inventoryService.listInventoryItems() + + const itemId = items[0].id + + await inventoryService.createInventoryLevel({ + inventory_item_id: itemId, + location_id: locationId, + stocked_quantity: 10, + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: itemId, + location_id: location2Id, + stocked_quantity: 10, + }) + + await inventoryService.createReservationItems([ + { + inventory_item_id: itemId, + location_id: locationId, + line_item_id: "line-item-1", + quantity: 1, + }, + { + inventory_item_id: itemId, + location_id: location2Id, + line_item_id: "line-item-2", + quantity: 1, + }, + ]) + + const [reservations] = await inventoryService.listReservationItems({ + inventory_item_id: itemId, + }) + expect(reservations).toHaveLength(2) + + await inventoryService.deleteReservationItemByLocationId([ + location2Id, + locationId, + ]) + + const [deletedReservations] = + await inventoryService.listReservationItems({ + inventory_item_id: itemId, + }) + expect(deletedReservations).toHaveLength(0) + }) + + it("should bulk update the inventory levels", async () => { + const [items] = await inventoryService.listInventoryItems() + + const itemId = items[0].id + + await inventoryService.createInventoryLevel({ + inventory_item_id: itemId, + location_id: locationId, + stocked_quantity: 10, + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: itemId, + location_id: location2Id, + stocked_quantity: 10, + }) + + const levels = await inventoryService.listInventoryLevels({ + inventory_item_id: itemId, + }) + expect(levels).toHaveLength(2) + + await inventoryService.updateInventoryLevels([ + { + inventory_item_id: itemId, + location_id: locationId, + stocked_quantity: 20, + }, + { + inventory_item_id: itemId, + location_id: location2Id, + stocked_quantity: 25, + }, + ]) + + const [updatedLevels] = await inventoryService.listInventoryLevels({ + inventory_item_id: itemId, + }) + + expect(updatedLevels).toHaveLength(2) + expect(updatedLevels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: itemId, + location_id: locationId, + stocked_quantity: 20, + }), + expect.objectContaining({ + inventory_item_id: itemId, + location_id: location2Id, + stocked_quantity: 25, + }), + ]) + ) + }) + }) }) }) diff --git a/integration-tests/plugins/__tests__/inventory/order/draft-order.js b/integration-tests/plugins/__tests__/inventory/order/draft-order.js index 6fccd061f0..0012ad78ca 100644 --- a/integration-tests/plugins/__tests__/inventory/order/draft-order.js +++ b/integration-tests/plugins/__tests__/inventory/order/draft-order.js @@ -119,7 +119,7 @@ describe("/store/carts", () => { }) }) - it("creates an order from a draft order and doesn't adjust reservations", async () => { + it("should create the order from a draft order and shouldn't adjust reservations", async () => { const api = useApi() let inventoryItem = await api.get( `/admin/inventory-items/${invItemId}`, diff --git a/integration-tests/plugins/__tests__/inventory/order/order.js b/integration-tests/plugins/__tests__/inventory/order/order.js index 1d675ceacb..f4ddbdfa2b 100644 --- a/integration-tests/plugins/__tests__/inventory/order/order.js +++ b/integration-tests/plugins/__tests__/inventory/order/order.js @@ -542,11 +542,9 @@ describe("/store/carts", () => { it("Deletes multiple reservations on successful fulfillment with reservation", async () => { const api = useApi() - const a = await inventoryService.updateInventoryLevel( - invItemId, - locationId, - { stocked_quantity: 2 } - ) + await inventoryService.updateInventoryLevel(invItemId, locationId, { + stocked_quantity: 2, + }) await prodVarInventoryService.reserveQuantity(variantId, 1, { locationId: locationId, @@ -663,11 +661,9 @@ describe("/store/carts", () => { it("Adjusts single reservation on successful fulfillment with over-reserved line item", async () => { const api = useApi() - const a = await inventoryService.updateInventoryLevel( - invItemId, - locationId, - { stocked_quantity: 3 } - ) + await inventoryService.updateInventoryLevel(invItemId, locationId, { + stocked_quantity: 3, + }) await prodVarInventoryService.reserveQuantity(variantId, 3, { locationId: locationId, @@ -737,11 +733,9 @@ describe("/store/carts", () => { stocked_quantity: 3, }) - const a = await inventoryService.updateInventoryLevel( - invItemId, - locationId, - { stocked_quantity: 3 } - ) + await inventoryService.updateInventoryLevel(invItemId, locationId, { + stocked_quantity: 3, + }) await prodVarInventoryService.reserveQuantity(variantId, 1, { locationId: locationId, diff --git a/integration-tests/plugins/__tests__/inventory/products/get-product.js b/integration-tests/plugins/__tests__/inventory/products/get-product.js index 54ca2507d4..399dc4922a 100644 --- a/integration-tests/plugins/__tests__/inventory/products/get-product.js +++ b/integration-tests/plugins/__tests__/inventory/products/get-product.js @@ -71,6 +71,7 @@ describe("Get products", () => { invItem = await inventoryService.createInventoryItem({ sku: "test-sku", }) + await productVariantInventoryService.attachInventoryItem( variantId, invItem.id diff --git a/integration-tests/plugins/__tests__/inventory/products/get-variant.js b/integration-tests/plugins/__tests__/inventory/products/get-variant.js index 86a58ab7fc..fc2adb332a 100644 --- a/integration-tests/plugins/__tests__/inventory/products/get-variant.js +++ b/integration-tests/plugins/__tests__/inventory/products/get-variant.js @@ -71,6 +71,7 @@ describe("Get variant", () => { invItem = await inventoryService.createInventoryItem({ sku: "test-sku", }) + await productVariantInventoryService.attachInventoryItem( variantId, invItem.id diff --git a/integration-tests/plugins/__tests__/inventory/products/list-variants.js b/integration-tests/plugins/__tests__/inventory/products/list-variants.js index 60a25907d8..59f96feea1 100644 --- a/integration-tests/plugins/__tests__/inventory/products/list-variants.js +++ b/integration-tests/plugins/__tests__/inventory/products/list-variants.js @@ -82,6 +82,7 @@ describe("List Variants", () => { invItem = await inventoryService.createInventoryItem({ sku: "test-sku", }) + const invItemId = invItem.id await prodVarInventoryService.attachInventoryItem(variantId, invItem.id) diff --git a/integration-tests/plugins/__tests__/inventory/service.js b/integration-tests/plugins/__tests__/inventory/service.js index c32faa9ab7..ef32c30f2a 100644 --- a/integration-tests/plugins/__tests__/inventory/service.js +++ b/integration-tests/plugins/__tests__/inventory/service.js @@ -296,6 +296,104 @@ describe("Inventory Module", () => { ) }) + describe("updateInventoryLevel", () => { + it("should pass along the correct context when doing a bulk update", 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, + }) + + let error + try { + await inventoryService.updateInventoryLevels( + [ + { + inventory_item_id: inventoryItem.id, + location_id: "location_123", + stocked_quantity: 25, + reserved_quantity: 4, + incoming_quantity: 10, + }, + ], + { + transactionManager: {}, + } + ) + } catch (e) { + error = e + } + expect(error.message).toEqual("manager.getRepository is not a function") + }) + + it("should pass along the correct context when doing a single update", 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, + }) + + let error + try { + await inventoryService.updateInventoryLevel( + inventoryItem.id, + "location_123", + { + stocked_quantity: 25, + reserved_quantity: 4, + incoming_quantity: 10, + }, + { + transactionManager: {}, + } + ) + } catch (e) { + error = e + } + expect(error.message).toEqual("manager.getRepository is not a function") + }) + }) + it("deleteInventoryLevel", async () => { const inventoryService = appContainer.resolve("inventoryService") diff --git a/integration-tests/plugins/__tests__/inventory/variant-inventory-service.js b/integration-tests/plugins/__tests__/inventory/variant-inventory-service.js new file mode 100644 index 0000000000..28c0b00f23 --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/variant-inventory-service.js @@ -0,0 +1,289 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../helpers/use-db") +const { + simpleProductVariantFactory, + simpleProductFactory, +} = require("../../factories") + +jest.setTimeout(50000) + +describe("Inventory Module", () => { + let appContainer + let dbConnection + let express + + let invItem1 + let invItem2 + let variant1 + let variant2 + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd, verbose: false }) + appContainer = container + + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + describe("ProductVariantInventoryService", () => { + describe("attachInventoryItem", () => { + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + beforeEach(async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const { variants } = await simpleProductFactory(dbConnection, { + variants: [{}, {}], + }) + + variant1 = variants[0] + variant2 = variants[1] + + invItem1 = await inventoryService.createInventoryItem({ + sku: "test-sku-1", + }) + + invItem2 = await inventoryService.createInventoryItem({ + sku: "test-sku-2", + }) + }) + + it("should attach the single item with spread params", async () => { + const pviService = appContainer.resolve( + "productVariantInventoryService" + ) + await pviService.attachInventoryItem(variant1.id, invItem1.id) + + const variantItems = await pviService.listByVariant(variant1.id) + expect(variantItems.length).toEqual(1) + expect(variantItems[0]).toEqual( + expect.objectContaining({ + inventory_item_id: invItem1.id, + variant_id: variant1.id, + }) + ) + }) + + it("should attach multiple inventory items and variants at once", async () => { + const pviService = appContainer.resolve( + "productVariantInventoryService" + ) + await pviService.attachInventoryItem([ + { + variantId: variant1.id, + inventoryItemId: invItem1.id, + }, + { + variantId: variant2.id, + inventoryItemId: invItem2.id, + }, + ]) + + const variantItems = await pviService.listByVariant([ + variant1.id, + variant2.id, + ]) + expect(variantItems.length).toEqual(2) + expect(variantItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: invItem1.id, + variant_id: variant1.id, + }), + expect.objectContaining({ + variant_id: variant2.id, + inventory_item_id: invItem2.id, + }), + ]) + ) + }) + + it("should skip existing attachments when attaching a singular inventory item", async () => { + const pviService = appContainer.resolve( + "productVariantInventoryService" + ) + await pviService.attachInventoryItem(variant1.id, invItem1.id) + await pviService.attachInventoryItem(variant1.id, invItem1.id) + + const variantItems = await pviService.listByVariant(variant1.id) + expect(variantItems.length).toEqual(1) + expect(variantItems[0]).toEqual( + expect.objectContaining({ + inventory_item_id: invItem1.id, + variant_id: variant1.id, + }) + ) + }) + + it("should skip existing attachments when attaching multiple inventory items in bulk", async () => { + const pviService = appContainer.resolve( + "productVariantInventoryService" + ) + await pviService.attachInventoryItem(variant1.id, invItem1.id) + + await pviService.attachInventoryItem([ + { + variantId: variant1.id, + inventoryItemId: invItem1.id, + }, + { + variantId: variant2.id, + inventoryItemId: invItem2.id, + }, + ]) + + const variantItems = await pviService.listByVariant([ + variant1.id, + variant2.id, + ]) + expect(variantItems.length).toEqual(2) + expect(variantItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: invItem1.id, + variant_id: variant1.id, + }), + expect.objectContaining({ + variant_id: variant2.id, + inventory_item_id: invItem2.id, + }), + ]) + ) + }) + + it("should fail to attach items when a single item has a required_quantity below 1", async () => { + const pviService = appContainer.resolve( + "productVariantInventoryService" + ) + + let e + try { + await pviService.attachInventoryItem(variant1.id, invItem1.id, 0) + } catch (err) { + e = err + } + + expect(e.message).toEqual( + `"requiredQuantity" must be greater than 0, the following entries are invalid: ${JSON.stringify( + { + variantId: variant1.id, + inventoryItemId: invItem1.id, + requiredQuantity: 0, + } + )}` + ) + + try { + await pviService.attachInventoryItem([ + { + variantId: variant1.id, + inventoryItemId: invItem1.id, + }, + { + variantId: variant2.id, + inventoryItemId: invItem2.id, + requiredQuantity: 0, + }, + ]) + } catch (err) { + e = err + } + + expect(e.message).toEqual( + `"requiredQuantity" must be greater than 0, the following entries are invalid: ${JSON.stringify( + { + variantId: variant2.id, + inventoryItemId: invItem2.id, + requiredQuantity: 0, + } + )}` + ) + }) + + it("should fail to attach items when attaching to a non-existing variant", async () => { + const pviService = appContainer.resolve( + "productVariantInventoryService" + ) + + let e + try { + await pviService.attachInventoryItem("variant1.id", invItem1.id) + } catch (err) { + e = err + } + + expect(e.message).toEqual( + `Variants not found for the following ids: variant1.id` + ) + + try { + await pviService.attachInventoryItem([ + { + variantId: "variant1.id", + inventoryItemId: invItem1.id, + }, + { + variantId: variant2.id, + inventoryItemId: invItem2.id, + }, + ]) + } catch (err) { + e = err + } + + expect(e.message).toEqual( + `Variants not found for the following ids: variant1.id` + ) + }) + it("should fail to attach items when attaching to a non-existing inventory item", async () => { + const pviService = appContainer.resolve( + "productVariantInventoryService" + ) + + let e + try { + await pviService.attachInventoryItem(variant1.id, "invItem1.id") + } catch (err) { + e = err + } + + expect(e.message).toEqual( + `Inventory items not found for the following ids: invItem1.id` + ) + + try { + await pviService.attachInventoryItem([ + { + variantId: variant1.id, + inventoryItemId: invItem1.id, + }, + { + variantId: variant2.id, + inventoryItemId: "invItem2.id", + }, + ]) + } catch (err) { + e = err + } + + expect(e.message).toEqual( + `Inventory items not found for the following ids: invItem2.id` + ) + }) + }) + }) +}) diff --git a/integration-tests/plugins/factories/simple-product-factory.ts b/integration-tests/plugins/factories/simple-product-factory.ts index c321037bc5..3d7cf86202 100644 --- a/integration-tests/plugins/factories/simple-product-factory.ts +++ b/integration-tests/plugins/factories/simple-product-factory.ts @@ -75,7 +75,7 @@ export const simpleProductFactory = async ( }, ] - for (const pv of variants) { + product.variants = await Promise.all(variants.map(async (pv) => { const factoryData = { ...pv, product_id: prodId, @@ -85,8 +85,8 @@ export const simpleProductFactory = async ( { option_id: optionId, value: faker.commerce.productAdjective() }, ] } - await simpleProductVariantFactory(connection, factoryData) - } + return await simpleProductVariantFactory(connection, factoryData) + })) return product } diff --git a/packages/inventory/src/services/inventory-item.ts b/packages/inventory/src/services/inventory-item.ts index 9756450798..6c58f169df 100644 --- a/packages/inventory/src/services/inventory-item.ts +++ b/packages/inventory/src/services/inventory-item.ts @@ -12,7 +12,7 @@ import { MedusaContext, MedusaError, } from "@medusajs/utils" -import { DeepPartial, EntityManager, FindManyOptions } from "typeorm" +import { DeepPartial, EntityManager, FindManyOptions, In } from "typeorm" import { InventoryItem } from "../models" import { getListQuery } from "../utils/query" import { buildQuery } from "../utils/build-query" @@ -120,33 +120,35 @@ export default class InventoryItemService { */ @InjectEntityManager() async create( - data: CreateInventoryItemInput, + data: CreateInventoryItemInput[], @MedusaContext() context: SharedContext = {} - ): Promise { + ): Promise { const manager = context.transactionManager! const itemRepository = manager.getRepository(InventoryItem) - const inventoryItem = itemRepository.create({ - sku: data.sku, - origin_country: data.origin_country, - metadata: data.metadata, - hs_code: data.hs_code, - mid_code: data.mid_code, - material: data.material, - weight: data.weight, - length: data.length, - height: data.height, - width: data.width, - requires_shipping: data.requires_shipping, - description: data.description, - thumbnail: data.thumbnail, - title: data.title, - }) + const inventoryItem = itemRepository.create( + data.map((tc) => ({ + sku: tc.sku, + origin_country: tc.origin_country, + metadata: tc.metadata, + hs_code: tc.hs_code, + mid_code: tc.mid_code, + material: tc.material, + weight: tc.weight, + length: tc.length, + height: tc.height, + width: tc.width, + requires_shipping: tc.requires_shipping, + description: tc.description, + thumbnail: tc.thumbnail, + title: tc.title, + })) + ) const result = await itemRepository.save(inventoryItem) await this.eventBusService_?.emit?.(InventoryItemService.Events.CREATED, { - id: result.id, + ids: result.map((i) => i.id), }) return result @@ -195,16 +197,20 @@ export default class InventoryItemService { */ @InjectEntityManager() async delete( - inventoryItemId: string, + inventoryItemId: string | string[], @MedusaContext() context: SharedContext = {} ): Promise { const manager = context.transactionManager! const itemRepository = manager.getRepository(InventoryItem) - await itemRepository.softRemove({ id: inventoryItemId }) + const ids = Array.isArray(inventoryItemId) + ? inventoryItemId + : [inventoryItemId] + + await itemRepository.softDelete({ id: In(ids) }) await this.eventBusService_?.emit?.(InventoryItemService.Events.DELETED, { - id: inventoryItemId, + ids: inventoryItemId, }) } } diff --git a/packages/inventory/src/services/inventory-level.ts b/packages/inventory/src/services/inventory-level.ts index 22ca4fd40f..b7a21219d1 100644 --- a/packages/inventory/src/services/inventory-level.ts +++ b/packages/inventory/src/services/inventory-level.ts @@ -120,24 +120,28 @@ export default class InventoryLevelService { */ @InjectEntityManager() async create( - data: CreateInventoryLevelInput, + data: CreateInventoryLevelInput[], @MedusaContext() context: SharedContext = {} - ): Promise { + ): Promise { const manager = context.transactionManager! + const toCreate = data.map((d) => { + return { + location_id: d.location_id, + inventory_item_id: d.inventory_item_id, + stocked_quantity: d.stocked_quantity, + reserved_quantity: d.reserved_quantity, + incoming_quantity: d.incoming_quantity, + } + }) + const levelRepository = manager.getRepository(InventoryLevel) - const inventoryLevel = levelRepository.create({ - location_id: data.location_id, - inventory_item_id: data.inventory_item_id, - stocked_quantity: data.stocked_quantity, - reserved_quantity: data.reserved_quantity, - incoming_quantity: data.incoming_quantity, - }) + const inventoryLevels = levelRepository.create(toCreate) - const saved = await levelRepository.save(inventoryLevel) + const saved = await levelRepository.save(inventoryLevels) await this.eventBusService_?.emit?.(InventoryLevelService.Events.CREATED, { - id: saved.id, + ids: saved.map((i) => i.id), }) return saved @@ -254,7 +258,7 @@ export default class InventoryLevelService { await levelRepository.delete({ id: In(ids) }) await this.eventBusService_?.emit?.(InventoryLevelService.Events.DELETED, { - id: inventoryLevelId, + ids: inventoryLevelId, }) } @@ -265,16 +269,18 @@ export default class InventoryLevelService { */ @InjectEntityManager() async deleteByLocationId( - locationId: string, + locationId: string | string[], @MedusaContext() context: SharedContext = {} ): Promise { const manager = context.transactionManager! const levelRepository = manager.getRepository(InventoryLevel) - await levelRepository.delete({ location_id: locationId }) + const ids = Array.isArray(locationId) ? locationId : [locationId] + + await levelRepository.delete({ location_id: In(ids) }) await this.eventBusService_?.emit?.(InventoryLevelService.Events.DELETED, { - location_id: locationId, + location_ids: ids, }) } diff --git a/packages/inventory/src/services/inventory.ts b/packages/inventory/src/services/inventory.ts index a5c5850437..08670f14ca 100644 --- a/packages/inventory/src/services/inventory.ts +++ b/packages/inventory/src/services/inventory.ts @@ -1,5 +1,6 @@ import { InternalModuleDeclaration } from "@medusajs/modules-sdk" import { + BulkUpdateInventoryLevelInput, CreateInventoryItemInput, CreateInventoryLevelInput, CreateReservationItemInput, @@ -32,6 +33,7 @@ type InjectedDependencies = { inventoryLevelService: InventoryLevelService reservationItemService: ReservationItemService } + export default class InventoryService implements IInventoryService { protected readonly manager_: EntityManager @@ -184,10 +186,63 @@ export default class InventoryService implements IInventoryService { ) } + private async ensureInventoryLevels( + data: { location_id: string; inventory_item_id: string }[], + context: SharedContext = {} + ): Promise { + const inventoryLevels = await this.inventoryLevelService_.list( + { + inventory_item_id: data.map((e) => e.inventory_item_id), + location_id: data.map((e) => e.location_id), + }, + {}, + context + ) + + const inventoryLevelMap: Map< + string, + Map + > = inventoryLevels.reduce((acc, curr) => { + const inventoryLevelMap = acc.get(curr.inventory_item_id) ?? new Map() + inventoryLevelMap.set(curr.location_id, curr) + acc.set(curr.inventory_item_id, inventoryLevelMap) + return acc + }, new Map()) + + const missing = data.filter( + (i) => !inventoryLevelMap.get(i.inventory_item_id)?.get(i.location_id) + ) + + if (missing.length) { + const error = missing + .map((missing) => { + return `Item ${missing.inventory_item_id} is not stocked at location ${missing.location_id}` + }) + .join(", ") + throw new MedusaError(MedusaError.Types.NOT_FOUND, error) + } + + return inventoryLevels.map( + (i) => inventoryLevelMap.get(i.inventory_item_id)!.get(i.location_id)! + ) + } + + @InjectEntityManager( + (target) => + target.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.ISOLATED + ) + async createReservationItems( + input: CreateReservationItemInput[], + @MedusaContext() context: SharedContext = {} + ): Promise { + await this.ensureInventoryLevels(input, context) + + return await this.reservationItemService_.create(input, context) + } + /** * Creates a reservation item * @param input - the input object - * @param context * @return The created reservation item */ @InjectEntityManager( @@ -198,29 +253,20 @@ export default class InventoryService implements IInventoryService { input: CreateReservationItemInput, @MedusaContext() context: SharedContext = {} ): Promise { - // Verify that the item is stocked at the location - const [inventoryLevel] = await this.inventoryLevelService_.list( - { - inventory_item_id: input.inventory_item_id, - location_id: input.location_id, - }, - { take: 1 }, - context - ) + const [result] = await this.createReservationItems([input], context) - if (!inventoryLevel) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Item ${input.inventory_item_id} is not stocked at location ${input.location_id}` - ) - } + return result + } - const reservationItem = await this.reservationItemService_.create( - input, - context - ) - - return { ...reservationItem } + @InjectEntityManager( + (target) => + target.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.ISOLATED + ) + async createInventoryItems( + input: CreateInventoryItemInput[], + @MedusaContext() context: SharedContext = {} + ): Promise { + return await this.inventoryItemService_.create(input, context) } /** @@ -237,11 +283,20 @@ export default class InventoryService implements IInventoryService { input: CreateInventoryItemInput, @MedusaContext() context: SharedContext = {} ): Promise { - const inventoryItem = await this.inventoryItemService_.create( - input, - context - ) - return { ...inventoryItem } + const [result] = await this.createInventoryItems([input], context) + + return result + } + + @InjectEntityManager( + (target) => + target.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.ISOLATED + ) + async createInventoryLevels( + input: CreateInventoryLevelInput[], + @MedusaContext() context: SharedContext = {} + ): Promise { + return await this.inventoryLevelService_.create(input, context) } /** @@ -258,7 +313,9 @@ export default class InventoryService implements IInventoryService { input: CreateInventoryLevelInput, @MedusaContext() context: SharedContext = {} ): Promise { - return await this.inventoryLevelService_.create(input, context) + const [result] = await this.createInventoryLevels([input], context) + + return result } /** @@ -295,7 +352,7 @@ export default class InventoryService implements IInventoryService { target.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.ISOLATED ) async deleteInventoryItem( - inventoryItemId: string, + inventoryItemId: string | string[], @MedusaContext() context: SharedContext = {} ): Promise { await this.inventoryLevelService_.deleteByInventoryItemId( @@ -311,7 +368,7 @@ export default class InventoryService implements IInventoryService { target.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.ISOLATED ) async deleteInventoryItemLevelByLocationId( - locationId: string, + locationId: string | string[], @MedusaContext() context: SharedContext = {} ): Promise { return await this.inventoryLevelService_.deleteByLocationId( @@ -325,7 +382,7 @@ export default class InventoryService implements IInventoryService { target.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.ISOLATED ) async deleteReservationItemByLocationId( - locationId: string, + locationId: string | string[], @MedusaContext() context: SharedContext = {} ): Promise { return await this.reservationItemService_.deleteByLocationId( @@ -362,6 +419,38 @@ export default class InventoryService implements IInventoryService { return await this.inventoryLevelService_.delete(inventoryLevel.id, context) } + @InjectEntityManager( + (target) => + target.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.ISOLATED + ) + async updateInventoryLevels( + updates: ({ + inventory_item_id: string + location_id: string + } & UpdateInventoryLevelInput)[], + context?: SharedContext + ): Promise { + const inventoryLevels = await this.ensureInventoryLevels(updates) + + const levelMap = inventoryLevels.reduce((acc, curr) => { + const inventoryLevelMap = acc.get(curr.inventory_item_id) ?? new Map() + inventoryLevelMap.set(curr.location_id, curr.id) + acc.set(curr.inventory_item_id, inventoryLevelMap) + return acc + }, new Map()) + + return await Promise.all( + updates.map(async (update) => { + const levelId = levelMap + .get(update.inventory_item_id) + .get(update.location_id) + + // TODO make this bulk + return this.inventoryLevelService_.update(levelId, update, context) + }) + ) + } + /** * Updates an inventory level * @param inventoryItemId - the id of the inventory item associated with the level @@ -376,28 +465,21 @@ export default class InventoryService implements IInventoryService { ) async updateInventoryLevel( inventoryItemId: string, - locationId: string, - input: UpdateInventoryLevelInput, + locationIdOrContext?: string, + input?: UpdateInventoryLevelInput, @MedusaContext() context: SharedContext = {} ): Promise { - const [inventoryLevel] = await this.inventoryLevelService_.list( - { inventory_item_id: inventoryItemId, location_id: locationId }, - { take: 1 }, - context - ) + const updates: BulkUpdateInventoryLevelInput[] = [ + { + inventory_item_id: inventoryItemId, + location_id: locationIdOrContext as string, + ...input, + }, + ] - if (!inventoryLevel) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Inventory level for item ${inventoryItemId} and location ${locationId} not found` - ) - } + const [result] = await this.updateInventoryLevels(updates, context) - return await this.inventoryLevelService_.update( - inventoryLevel.id, - input, - context - ) + return result } /** diff --git a/packages/inventory/src/services/reservation-item.ts b/packages/inventory/src/services/reservation-item.ts index 7dae650d62..ff437474e1 100644 --- a/packages/inventory/src/services/reservation-item.ts +++ b/packages/inventory/src/services/reservation-item.ts @@ -131,38 +131,44 @@ export default class ReservationItemService { */ @InjectEntityManager() async create( - data: CreateReservationItemInput, + data: CreateReservationItemInput[], @MedusaContext() context: SharedContext = {} - ): Promise { + ): Promise { const manager = context.transactionManager! const reservationItemRepository = manager.getRepository(ReservationItem) - const reservationItem = reservationItemRepository.create({ - inventory_item_id: data.inventory_item_id, - line_item_id: data.line_item_id, - location_id: data.location_id, - quantity: data.quantity, - metadata: data.metadata, - external_id: data.external_id, - description: data.description, - created_by: data.created_by, - }) + const reservationItems = reservationItemRepository.create( + data.map((tc) => ({ + inventory_item_id: tc.inventory_item_id, + line_item_id: tc.line_item_id, + location_id: tc.location_id, + quantity: tc.quantity, + metadata: tc.metadata, + external_id: tc.external_id, + description: tc.description, + created_by: tc.created_by, + })) + ) - const [newReservationItem] = await Promise.all([ - reservationItemRepository.save(reservationItem), - this.inventoryLevelService_.adjustReservedQuantity( - data.inventory_item_id, - data.location_id, - data.quantity, - context + const [newReservationItems] = await Promise.all([ + reservationItemRepository.save(reservationItems), + ...data.map( + async (data) => + // TODO make bulk + await this.inventoryLevelService_.adjustReservedQuantity( + data.inventory_item_id, + data.location_id, + data.quantity, + context + ) ), ]) await this.eventBusService_?.emit?.(ReservationItemService.Events.CREATED, { - id: newReservationItem.id, + ids: newReservationItems.map((i) => i.id), }) - return newReservationItem + return newReservationItems } /** @@ -244,24 +250,24 @@ export default class ReservationItemService { const manager = context.transactionManager! const itemRepository = manager.getRepository(ReservationItem) - const itemsIds = Array.isArray(lineItemId) ? lineItemId : [lineItemId] + const lineItemIds = Array.isArray(lineItemId) ? lineItemId : [lineItemId] - const items = await this.list( - { line_item_id: itemsIds }, + const reservationItems = await this.list( + { line_item_id: lineItemIds }, undefined, context ) const ops: Promise[] = [ - itemRepository.softDelete({ line_item_id: In(itemsIds) }), + itemRepository.softDelete({ line_item_id: In(lineItemIds) }), ] - for (const item of items) { + for (const reservation of reservationItems) { ops.push( this.inventoryLevelService_.adjustReservedQuantity( - item.inventory_item_id, - item.location_id, - item.quantity * -1, + reservation.inventory_item_id, + reservation.location_id, + reservation.quantity * -1, context ) ) @@ -281,18 +287,15 @@ export default class ReservationItemService { */ @InjectEntityManager() async deleteByLocationId( - locationId: string, + locationId: string | string[], @MedusaContext() context: SharedContext = {} ): Promise { const manager = context.transactionManager! const itemRepository = manager.getRepository(ReservationItem) - await itemRepository - .createQueryBuilder("reservation_item") - .softDelete() - .where("location_id = :locationId", { locationId }) - .andWhere("deleted_at IS NULL") - .execute() + const ids = Array.isArray(locationId) ? locationId : [locationId] + + await itemRepository.softDelete({ location_id: In(ids) }) await this.eventBusService_?.emit?.(ReservationItemService.Events.DELETED, { location_id: locationId, @@ -330,7 +333,7 @@ export default class ReservationItemService { await Promise.all(promises) await this.eventBusService_?.emit?.(ReservationItemService.Events.DELETED, { - id: reservationItemId, + ids: reservationItemId, }) } } diff --git a/packages/medusa-plugin-brightpearl/src/services/brightpearl.js b/packages/medusa-plugin-brightpearl/src/services/brightpearl.js index 1095ff894f..ab51967693 100644 --- a/packages/medusa-plugin-brightpearl/src/services/brightpearl.js +++ b/packages/medusa-plugin-brightpearl/src/services/brightpearl.js @@ -1,8 +1,8 @@ -import { humanizeAmount, MedusaError } from "medusa-core-utils" -import { updateInventoryAndReservations } from "@medusajs/medusa" +import { MedusaError, humanizeAmount } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" import Brightpearl from "../utils/brightpearl" +import { updateInventoryAndReservations } from "@medusajs/medusa" class BrightpearlService extends BaseService { constructor( @@ -117,7 +117,7 @@ class BrightpearlService extends BaseService { httpMethod: "POST", uriTemplate: `${this.options.backend_url}/brightpearl/inventory-update`, bodyTemplate: - '{"account": "${account-code}", "lifecycle_event": "${lifecycle-event}", "resource_type": "${resource-type}", "id": "${resource-id}" }', + "{\"account\": \"${account-code}\", \"lifecycle_event\": \"${lifecycle-event}\", \"resource_type\": \"${resource-type}\", \"id\": \"${resource-id}\" }", contentType: "application/json", idSetAccepted: false, }, @@ -760,8 +760,14 @@ class BrightpearlService extends BaseService { ) } + /** + * create reservation based on reservation created event + * @param {{ ids: string[] }} eventData Event data from reservation created + */ async createReservation(eventData) { - const { id } = eventData + const { ids } = eventData + + const [id] = ids if (!id) { return 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 f59500c75d..c1e1d62396 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 @@ -1,13 +1,3 @@ -import { IInventoryService, InventoryItemDTO } from "@medusajs/types" -import { MedusaError } from "@medusajs/utils" -import { EntityManager } from "typeorm" -import { ulid } from "ulid" -import { ProductVariant } from "../../../../../models" -import { - ProductVariantInventoryService, - ProductVariantService, -} from "../../../../../services" -import { CreateProductVariantInput } from "../../../../../types/product-variant" import { DistributedTransaction, TransactionHandlerType, @@ -16,6 +6,17 @@ import { TransactionState, TransactionStepsDefinition, } from "../../../../../utils/transaction" +import { IInventoryService, InventoryItemDTO } from "@medusajs/types" +import { + ProductVariantInventoryService, + ProductVariantService, +} from "../../../../../services" + +import { CreateProductVariantInput } from "../../../../../types/product-variant" +import { EntityManager } from "typeorm" +import { MedusaError } from "@medusajs/utils" +import { ProductVariant } from "../../../../../models" +import { ulid } from "ulid" enum actions { createVariants = "createVariants", diff --git a/packages/medusa/src/services/product-variant-inventory.ts b/packages/medusa/src/services/product-variant-inventory.ts index a9c186532b..6be14da8f1 100644 --- a/packages/medusa/src/services/product-variant-inventory.ts +++ b/packages/medusa/src/services/product-variant-inventory.ts @@ -3,21 +3,22 @@ import { ICacheService, IEventBusService, IInventoryService, + IStockLocationService, InventoryItemDTO, InventoryLevelDTO, - IStockLocationService, ReservationItemDTO, ReserveQuantityContext, } from "@medusajs/types" import { LineItem, Product, ProductVariant } from "../models" -import { isDefined, MedusaError } from "@medusajs/utils" +import { MedusaError, isDefined } from "@medusajs/utils" import { PricedProduct, PricedVariant } from "../types/pricing" -import { TransactionBaseService } from "../interfaces" import { ProductVariantInventoryItem } from "../models/product-variant-inventory-item" import ProductVariantService from "./product-variant" import SalesChannelInventoryService from "./sales-channel-inventory" import SalesChannelLocationService from "./sales-channel-location" +import { TransactionBaseService } from "../interfaces" +import { getSetDifference } from "../utils/diff-set" type InjectedDependencies = { manager: EntityManager @@ -253,61 +254,146 @@ class ProductVariantInventoryService extends TransactionBaseService { * @param requiredQuantity quantity of variant to attach * @returns the variant inventory item */ + async attachInventoryItem( + attachments: { + variantId: string + inventoryItemId: string + requiredQuantity?: number + }[] + ): Promise async attachInventoryItem( variantId: string, inventoryItemId: string, requiredQuantity?: number - ): Promise { + ): Promise + async attachInventoryItem( + variantIdOrAttachments: + | string + | { + variantId: string + inventoryItemId: string + requiredQuantity?: number + }[], + inventoryItemId?: string, + requiredQuantity?: number + ): Promise { + const data = Array.isArray(variantIdOrAttachments) + ? variantIdOrAttachments + : [ + { + variantId: variantIdOrAttachments, + inventoryItemId: inventoryItemId!, + requiredQuantity, + }, + ] + + const invalidDataEntries = data.filter( + (d) => typeof d.requiredQuantity === "number" && d.requiredQuantity < 1 + ) + + if (invalidDataEntries.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `"requiredQuantity" must be greater than 0, the following entries are invalid: ${invalidDataEntries + .map((d) => JSON.stringify(d)) + .join(", ")}` + ) + } + // Verify that variant exists - await this.productVariantService_ + const variants = await this.productVariantService_ .withTransaction(this.activeManager_) - .retrieve(variantId, { - select: ["id"], + .list({ + id: data.map((d) => d.variantId), }) + const foundVariantIds = new Set(variants.map((v) => v.id)) + const requestedVariantIds = new Set(data.map((v) => v.variantId)) + if (foundVariantIds.size !== requestedVariantIds.size) { + const difference = getSetDifference(requestedVariantIds, foundVariantIds) + + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Variants not found for the following ids: ${[...difference].join( + ", " + )}` + ) + } + // Verify that item exists - await this.inventoryService_.retrieveInventoryItem( - inventoryItemId, + const [inventoryItems] = await this.inventoryService_.listInventoryItems( + { + id: data.map((d) => d.inventoryItemId), + }, { select: ["id"], }, - { transactionManager: this.activeManager_ } + { + transactionManager: this.activeManager_, + } ) + const foundInventoryItemIds = new Set(inventoryItems.map((v) => v.id)) + const requestedInventoryItemIds = new Set( + data.map((v) => v.inventoryItemId) + ) + + if (foundInventoryItemIds.size !== requestedInventoryItemIds.size) { + const difference = getSetDifference( + requestedInventoryItemIds, + foundInventoryItemIds + ) + + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Inventory items not found for the following ids: ${[ + ...difference, + ].join(", ")}` + ) + } + const variantInventoryRepo = this.activeManager_.getRepository( ProductVariantInventoryItem ) - const existing = await variantInventoryRepo.findOne({ - where: { - variant_id: variantId, - inventory_item_id: inventoryItemId, + const existingAttachments = await variantInventoryRepo.find({ + where: data.map((d) => ({ + variant_id: d.variantId, + inventory_item_id: d.inventoryItemId, + })), + }) + + const existingMap: Map> = existingAttachments.reduce( + (acc, curr) => { + const existingSet = acc.get(curr.variant_id) || new Set() + existingSet.add(curr.inventory_item_id) + acc.set(curr.variant_id, existingSet) + return acc }, - }) + new Map() + ) - if (existing) { - return existing - } + const toCreate = ( + await Promise.all( + data.map(async (d) => { + if (existingMap.get(d.variantId)?.has(d.inventoryItemId)) { + return null + } - let quantityToStore = 1 - if (typeof requiredQuantity !== "undefined") { - if (requiredQuantity < 1) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - "Quantity must be greater than 0" - ) - } else { - quantityToStore = requiredQuantity - } - } + return variantInventoryRepo.create({ + variant_id: d.variantId, + inventory_item_id: d.inventoryItemId, + required_quantity: d.requiredQuantity ?? 1, + }) + }) + ) + ).filter( + ( + tc: ProductVariantInventoryItem | null + ): tc is ProductVariantInventoryItem => !!tc + ) - const variantInventory = variantInventoryRepo.create({ - variant_id: variantId, - inventory_item_id: inventoryItemId, - required_quantity: quantityToStore, - }) - - return await variantInventoryRepo.save(variantInventory) + return await variantInventoryRepo.save(toCreate) } /** @@ -411,22 +497,22 @@ class ProductVariantInventoryService extends TransactionBaseService { locationId = locations[0].location_id } - const reservationItems = await Promise.all( - variantInventory.map(async (inventoryPart) => { - const itemQuantity = inventoryPart.required_quantity * quantity - return await this.inventoryService_.createReservationItem( - { + const reservationItems = + await this.inventoryService_.createReservationItems( + variantInventory.map((inventoryPart) => { + const itemQuantity = inventoryPart.required_quantity * quantity + + return { ...toReserve, location_id: locationId as string, inventory_item_id: inventoryPart.inventory_item_id, quantity: itemQuantity, - }, - moduleContext - ) - }) - ) + } + }), + moduleContext + ) - return reservationItems + return reservationItems.flat() } /** diff --git a/packages/medusa/src/utils/diff-set.ts b/packages/medusa/src/utils/diff-set.ts new file mode 100644 index 0000000000..d107f89761 --- /dev/null +++ b/packages/medusa/src/utils/diff-set.ts @@ -0,0 +1,14 @@ +export function getSetDifference( + orignalSet: Set, + compareSet: Set +): Set { + const difference = new Set() + + orignalSet.forEach((element) => { + if (!compareSet.has(element)) { + difference.add(element) + } + }) + + return difference +} diff --git a/packages/types/src/inventory/common.ts b/packages/types/src/inventory/common.ts index 8a656071b7..1e3af18cea 100644 --- a/packages/types/src/inventory/common.ts +++ b/packages/types/src/inventory/common.ts @@ -268,6 +268,11 @@ export type UpdateInventoryLevelInput = { incoming_quantity?: number } +export type BulkUpdateInventoryLevelInput = { + inventory_item_id: string + location_id: string +} & UpdateInventoryLevelInput + export type UpdateReservationItemInput = { quantity?: number location_id?: string diff --git a/packages/types/src/inventory/inventory.ts b/packages/types/src/inventory/inventory.ts index 3922b4bd65..6f144ee20f 100644 --- a/packages/types/src/inventory/inventory.ts +++ b/packages/types/src/inventory/inventory.ts @@ -56,16 +56,38 @@ export interface IInventoryService { context?: SharedContext ): Promise - // TODO make it bulk + createReservationItems( + input: CreateReservationItemInput[], + context?: SharedContext + ): Promise + createInventoryItem( input: CreateInventoryItemInput, context?: SharedContext ): Promise + createInventoryItems( + input: CreateInventoryItemInput[], + context?: SharedContext + ): Promise + createInventoryLevel( - data: CreateInventoryLevelInput, + data: CreateInventoryLevelInput , context?: SharedContext ): Promise + + createInventoryLevels( + data: CreateInventoryLevelInput[], + context?: SharedContext + ): Promise + + updateInventoryLevels( + updates: ({ + inventory_item_id: string + location_id: string + } & UpdateInventoryLevelInput)[], + context?: SharedContext + ): Promise updateInventoryLevel( inventoryItemId: string, @@ -98,17 +120,17 @@ export interface IInventoryService { // TODO make it bulk deleteInventoryItem( - inventoryItemId: string, + inventoryItemId: string | string[], context?: SharedContext ): Promise deleteInventoryItemLevelByLocationId( - locationId: string, + locationId: string | string[], context?: SharedContext ): Promise deleteReservationItemByLocationId( - locationId: string, + locationId: string | string[], context?: SharedContext ): Promise