From 4a8562743569f5bbb7bd0894b025a74725726529 Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Sun, 23 Apr 2023 12:50:19 +0200 Subject: [PATCH] feat(medusa, medusa-plugin-brightpearl): Inventory management for Brightpearl (#3192) --- .changeset/wet-teachers-compete.md | 7 + .../__snapshots__/sales-channels.js.snap | 86 --- .../api/__tests__/admin/sales-channels.js | 102 +-- .../__snapshots__/sales-channels.js.snap | 13 - .../api/__tests__/store/sales-channels.js | 18 +- packages/inventory/src/migrations/index.ts | 3 +- .../1665748086258-inventory_setup.ts | 1 - ...75761451145-add_reservation_external_id.ts | 17 + packages/inventory/src/models/index.ts | 2 +- .../inventory/src/models/reservation-item.ts | 3 + .../src/services/reservation-item.ts | 13 +- .../src/services/brightpearl.js | 718 ++++++++++++++++-- .../src/subscribers/order.js | 18 + .../src/utils/brightpearl.js | 15 + .../migrations/1656949291839-sales_channel.ts | 3 +- ...680714052628-add_sales_channel_metadata.ts | 22 + packages/medusa/src/models/sales-channel.ts | 5 +- .../src/services/product-variant-inventory.ts | 17 +- .../medusa/src/strategies/cart-completion.ts | 18 +- packages/types/src/inventory/common.ts | 1 + 20 files changed, 855 insertions(+), 227 deletions(-) create mode 100644 .changeset/wet-teachers-compete.md delete mode 100644 integration-tests/api/__tests__/store/__snapshots__/sales-channels.js.snap create mode 100644 packages/inventory/src/migrations/schema-migrations/1675761451145-add_reservation_external_id.ts create mode 100644 packages/medusa/src/migrations/1680714052628-add_sales_channel_metadata.ts diff --git a/.changeset/wet-teachers-compete.md b/.changeset/wet-teachers-compete.md new file mode 100644 index 0000000000..81a80ab36d --- /dev/null +++ b/.changeset/wet-teachers-compete.md @@ -0,0 +1,7 @@ +--- +"medusa-plugin-brightpearl": patch +"@medusajs/inventory": patch +"@medusajs/medusa": patch +--- + +feat(medusa-plugin-brightpearl, inventory, medusa): Multiwarehouse integration for brightpearl \ No newline at end of file diff --git a/integration-tests/api/__tests__/admin/__snapshots__/sales-channels.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/sales-channels.js.snap index 14680a74e9..cff285c22f 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/sales-channels.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/sales-channels.js.snap @@ -8,80 +8,6 @@ Object { } `; -exports[`sales channels GET /admin/orders/:id expands sales channel for single 1`] = ` -Object { - "created_at": Any, - "deleted_at": null, - "description": "test description", - "id": Any, - "is_disabled": false, - "name": "test name", - "updated_at": Any, -} -`; - -exports[`sales channels GET /admin/orders?expand=sales_channels expands sales channel with parameter 1`] = ` -Object { - "created_at": Any, - "deleted_at": null, - "description": "test description", - "id": Any, - "is_disabled": false, - "name": "test name", - "updated_at": Any, -} -`; - -exports[`sales channels GET /admin/sales-channels should list the sales channel using free text search 1`] = ` -Object { - "count": 1, - "limit": 20, - "offset": 0, - "sales_channels": ArrayContaining [ - Object { - "created_at": Any, - "deleted_at": null, - "description": "test description 2", - "id": Any, - "is_disabled": false, - "name": "test name 2", - "updated_at": Any, - }, - ], -} -`; - -exports[`sales channels GET /admin/sales-channels should list the sales channel using properties filters 1`] = ` -Object { - "count": 1, - "limit": 20, - "offset": 0, - "sales_channels": ArrayContaining [ - Object { - "created_at": Any, - "deleted_at": null, - "description": "test description", - "id": Any, - "is_disabled": false, - "name": "test name", - "updated_at": Any, - }, - ], -} -`; - -exports[`sales channels GET /admin/sales-channels/:id should retrieve the requested sales channel 1`] = ` -Object { - "created_at": Any, - "deleted_at": null, - "description": "test description", - "id": Any, - "is_disabled": false, - "name": "test name", - "updated_at": Any, -} -`; - exports[`sales channels POST /admin/sales-channels successfully creates a disabled sales channel 1`] = ` Object { "sales_channel": ObjectContaining { @@ -100,15 +26,3 @@ Object { }, } `; - -exports[`sales channels POST /admin/sales-channels/:id updates sales channel properties 1`] = ` -Object { - "created_at": Any, - "deleted_at": null, - "description": "updated description", - "id": Any, - "is_disabled": true, - "name": "updated name", - "updated_at": Any, -} -`; diff --git a/integration-tests/api/__tests__/admin/sales-channels.js b/integration-tests/api/__tests__/admin/sales-channels.js index 39df2bcc3e..5bd1890829 100644 --- a/integration-tests/api/__tests__/admin/sales-channels.js +++ b/integration-tests/api/__tests__/admin/sales-channels.js @@ -71,13 +71,15 @@ describe("sales channels", () => { expect(response.status).toEqual(200) expect(response.data.sales_channel).toBeTruthy() - expect(response.data.sales_channel).toMatchSnapshot({ - id: expect.any(String), - name: salesChannel.name, - description: salesChannel.description, - created_at: expect.any(String), - updated_at: expect.any(String), - }) + expect(response.data.sales_channel).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: salesChannel.name, + description: salesChannel.description, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) }) }) @@ -135,12 +137,12 @@ describe("sales channels", () => { expect(response.status).toEqual(200) expect(response.data.sales_channels).toBeTruthy() expect(response.data.sales_channels.length).toBe(1) - expect(response.data).toMatchSnapshot({ + expect(response.data).toEqual({ count: 1, limit: 20, offset: 0, sales_channels: expect.arrayContaining([ - { + expect.objectContaining({ id: expect.any(String), name: salesChannel2.name, description: salesChannel2.description, @@ -148,7 +150,7 @@ describe("sales channels", () => { deleted_at: null, created_at: expect.any(String), updated_at: expect.any(String), - }, + }), ]), }) }) @@ -163,12 +165,12 @@ describe("sales channels", () => { expect(response.status).toEqual(200) expect(response.data.sales_channels).toBeTruthy() expect(response.data.sales_channels.length).toBe(1) - expect(response.data).toMatchSnapshot({ + expect(response.data).toEqual({ count: 1, limit: 20, offset: 0, sales_channels: expect.arrayContaining([ - { + expect.objectContaining({ id: expect.any(String), name: salesChannel1.name, description: salesChannel1.description, @@ -176,7 +178,7 @@ describe("sales channels", () => { deleted_at: null, created_at: expect.any(String), updated_at: expect.any(String), - }, + }), ]), }) }) @@ -218,14 +220,16 @@ describe("sales channels", () => { ) expect(response.status).toEqual(200) - expect(response.data.sales_channel).toMatchSnapshot({ - id: expect.any(String), - name: payload.name, - description: payload.description, - is_disabled: payload.is_disabled, - created_at: expect.any(String), - updated_at: expect.any(String), - }) + expect(response.data.sales_channel).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: payload.name, + description: payload.description, + is_disabled: payload.is_disabled, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) }) }) @@ -445,14 +449,16 @@ describe("sales channels", () => { ) expect(response.data.order.sales_channel).toBeTruthy() - expect(response.data.order.sales_channel).toMatchSnapshot({ - id: expect.any(String), - name: "test name", - description: "test description", - is_disabled: false, - created_at: expect.any(String), - updated_at: expect.any(String), - }) + expect(response.data.order.sales_channel).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "test name", + description: "test description", + is_disabled: false, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) }) }) @@ -482,14 +488,16 @@ describe("sales channels", () => { ) expect(response.data.orders[0].sales_channel).toBeTruthy() - expect(response.data.orders[0].sales_channel).toMatchSnapshot({ - id: expect.any(String), - name: "test name", - description: "test description", - is_disabled: false, - created_at: expect.any(String), - updated_at: expect.any(String), - }) + expect(response.data.orders[0].sales_channel).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "test name", + description: "test description", + is_disabled: false, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) }) }) @@ -712,15 +720,17 @@ describe("sales channels", () => { ) expect(response.status).toEqual(200) - expect(response.data.sales_channel).toEqual({ - id: expect.any(String), - name: "test name", - description: "test description", - is_disabled: false, - created_at: expect.any(String), - updated_at: expect.any(String), - deleted_at: null, - }) + expect(response.data.sales_channel).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "test name", + description: "test description", + is_disabled: false, + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }) + ) const attachedProduct = await dbConnection.manager.findOne(Product, { where: { id: product.id }, diff --git a/integration-tests/api/__tests__/store/__snapshots__/sales-channels.js.snap b/integration-tests/api/__tests__/store/__snapshots__/sales-channels.js.snap deleted file mode 100644 index 24ecef7c7f..0000000000 --- a/integration-tests/api/__tests__/store/__snapshots__/sales-channels.js.snap +++ /dev/null @@ -1,13 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`sales channels GET /store/cart/:id returns cart with sales channel for single cart 1`] = ` -Object { - "created_at": Any, - "deleted_at": null, - "description": "test description", - "id": Any, - "is_disabled": false, - "name": "test name", - "updated_at": Any, -} -`; diff --git a/integration-tests/api/__tests__/store/sales-channels.js b/integration-tests/api/__tests__/store/sales-channels.js index 5e67f85054..20ddec88d7 100644 --- a/integration-tests/api/__tests__/store/sales-channels.js +++ b/integration-tests/api/__tests__/store/sales-channels.js @@ -281,14 +281,16 @@ describe("sales channels", () => { const response = await api.get(`/store/carts/${cart.id}`, adminReqConfig) expect(response.data.cart.sales_channel).toBeTruthy() - expect(response.data.cart.sales_channel).toMatchSnapshot({ - id: expect.any(String), - name: "test name", - description: "test description", - is_disabled: false, - created_at: expect.any(String), - updated_at: expect.any(String), - }) + expect(response.data.cart.sales_channel).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "test name", + description: "test description", + is_disabled: false, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) }) }) diff --git a/packages/inventory/src/migrations/index.ts b/packages/inventory/src/migrations/index.ts index 3a7a2e2c16..d08cd39512 100644 --- a/packages/inventory/src/migrations/index.ts +++ b/packages/inventory/src/migrations/index.ts @@ -1,3 +1,4 @@ import * as setup from "./schema-migrations/1665748086258-inventory_setup" +import * as addExternalId from "./schema-migrations/1675761451145-add_reservation_external_id" -export default [setup] +export default [setup, addExternalId] diff --git a/packages/inventory/src/migrations/schema-migrations/1665748086258-inventory_setup.ts b/packages/inventory/src/migrations/schema-migrations/1665748086258-inventory_setup.ts index 7928929de3..0d9213c382 100644 --- a/packages/inventory/src/migrations/schema-migrations/1665748086258-inventory_setup.ts +++ b/packages/inventory/src/migrations/schema-migrations/1665748086258-inventory_setup.ts @@ -26,7 +26,6 @@ export class inventorySetup1665748086258 implements MigrationInterface { CREATE UNIQUE INDEX "IDX_inventory_item_sku" ON "inventory_item" ("sku") WHERE deleted_at IS NULL; - CREATE TABLE "reservation_item" ( "id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), diff --git a/packages/inventory/src/migrations/schema-migrations/1675761451145-add_reservation_external_id.ts b/packages/inventory/src/migrations/schema-migrations/1675761451145-add_reservation_external_id.ts new file mode 100644 index 0000000000..bc42c8fbd8 --- /dev/null +++ b/packages/inventory/src/migrations/schema-migrations/1675761451145-add_reservation_external_id.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addReservationType1675761451145 implements MigrationInterface { + name = "addReservationType1675761451145" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "reservation_item" ADD "external_id" character varying + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "reservation_item" DROP COLUMN "external_id"; + `) + } +} diff --git a/packages/inventory/src/models/index.ts b/packages/inventory/src/models/index.ts index 8a77e08089..79daaa78ee 100644 --- a/packages/inventory/src/models/index.ts +++ b/packages/inventory/src/models/index.ts @@ -1,3 +1,3 @@ -export * from "./reservation-item" +export {ReservationItem} from "./reservation-item" export * from "./inventory-item" export * from "./inventory-level" diff --git a/packages/inventory/src/models/reservation-item.ts b/packages/inventory/src/models/reservation-item.ts index dab57adac9..f84b9548f4 100644 --- a/packages/inventory/src/models/reservation-item.ts +++ b/packages/inventory/src/models/reservation-item.ts @@ -18,6 +18,9 @@ export class ReservationItem extends SoftDeletableEntity { @Column() quantity: number + @Column({ type: "text", nullable: true }) + external_id: string | null + @Column({ type: "jsonb", nullable: true }) metadata: Record | null diff --git a/packages/inventory/src/services/reservation-item.ts b/packages/inventory/src/services/reservation-item.ts index 202ed68a67..568c530d4a 100644 --- a/packages/inventory/src/services/reservation-item.ts +++ b/packages/inventory/src/services/reservation-item.ts @@ -132,18 +132,19 @@ export default class ReservationItemService { @MedusaContext() context: SharedContext = {} ): Promise { const manager = context.transactionManager! - const itemRepository = manager.getRepository(ReservationItem) + const reservationItemRepository = manager.getRepository(ReservationItem) - const inventoryItem = itemRepository.create({ + 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, }) - const [newInventoryItem] = await Promise.all([ - itemRepository.save(inventoryItem), + const [newReservationItem] = await Promise.all([ + reservationItemRepository.save(reservationItem), this.inventoryLevelService_.adjustReservedQuantity( data.inventory_item_id, data.location_id, @@ -153,10 +154,10 @@ export default class ReservationItemService { ]) await this.eventBusService_?.emit?.(ReservationItemService.Events.CREATED, { - id: newInventoryItem.id, + id: newReservationItem.id, }) - return newInventoryItem + return newReservationItem } /** diff --git a/packages/medusa-plugin-brightpearl/src/services/brightpearl.js b/packages/medusa-plugin-brightpearl/src/services/brightpearl.js index 0c89c7f38f..eaa0fb7227 100644 --- a/packages/medusa-plugin-brightpearl/src/services/brightpearl.js +++ b/packages/medusa-plugin-brightpearl/src/services/brightpearl.js @@ -1,4 +1,9 @@ import { MedusaError, humanizeAmount } from "medusa-core-utils" +import { + ReservationType, + updateInventoryAndReservations, +} from "@medusajs/medusa" + import { BaseService } from "medusa-interfaces" import Brightpearl from "../utils/brightpearl" @@ -14,6 +19,13 @@ class BrightpearlService extends BaseService { swapService, claimService, discountService, + stockLocationService, + inventoryService, + lineItemService, + eventBusService, + productVariantInventoryService, + salesChannelLocationService, + logger, }, options ) { @@ -22,6 +34,7 @@ class BrightpearlService extends BaseService { this.manager_ = manager this.options = options this.productVariantService_ = productVariantService + this.productVariantInventoryService_ = productVariantInventoryService this.regionService_ = regionService this.orderService_ = orderService this.totalsService_ = totalsService @@ -29,6 +42,12 @@ class BrightpearlService extends BaseService { this.oauthService_ = oauthService this.swapService_ = swapService this.claimService_ = claimService + this.stockLocationService_ = stockLocationService + this.inventoryService_ = inventoryService + this.lineItemService_ = lineItemService + this.eventBusService_ = eventBusService + this.salesChannelLocationService_ = salesChannelLocationService + this.logger_ = logger } async getClient() { @@ -85,13 +104,14 @@ class BrightpearlService extends BaseService { async verifyWebhooks() { const brightpearl = await this.getClient() + const hooks = [ { subscribeTo: "goods-out-note.created", httpMethod: "POST", uriTemplate: `${this.options.backend_url}/brightpearl/goods-out`, 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, }, @@ -166,35 +186,216 @@ class BrightpearlService extends BaseService { availabilities = Object.assign(availabilities, chunkAvails) } - return Promise.all( - bpProducts.map(async (bpProduct) => { - const { SKU: sku, productId } = bpProduct - - const variant = await this.productVariantService_ - .retrieveBySKU(sku, { - select: ["id", "manage_inventory", "inventory_quantity"], + if (!this.inventoryService_) { + return await this.atomicPhase_(async (manager) => { + const [variants] = await this.productVariantService_ + .withTransaction(manager) + .listAndCount({ + sku: bpProducts.map(({ SKU }) => SKU), }) - .catch((_) => undefined) - const prodAvail = availabilities[productId] + const variantsMap = new Map( + variants.filter((variant) => !!variant.sku).map((v) => [v.sku, v]) + ) - let onHand = 0 - if ( - prodAvail && - prodAvail.warehouses && - prodAvail.warehouses[`${this.options.warehouse}`] - ) { - onHand = prodAvail.warehouses[`${this.options.warehouse}`].onHand - } + const variantUpdates = await Promise.all( + bpProducts.map(async (bpProduct) => { + const { SKU: sku, productId } = bpProduct - if (variant && variant.manage_inventory) { - if (parseInt(variant.inventory_quantity) !== parseInt(onHand)) { - return this.productVariantService_.update(variant.id, { - inventory_quantity: parseInt(onHand), - }) - } - } + const variant = variantsMap.get(sku) + + const productAvailability = availabilities[productId] + + let onHand = 0 + if ( + productAvailability && + productAvailability.warehouses && + productAvailability.warehouses[`${this.options.warehouse}`] + ) { + onHand = + productAvailability.warehouses[`${this.options.warehouse}`] + .onHand + } + + if (variant && variant.manage_inventory) { + if (parseInt(variant.inventory_quantity) !== parseInt(onHand)) { + return { + variant, + update: { inventory_quantity: parseInt(onHand) }, + } + } + } + }) + ) + return this.productVariantService_ + .withTransaction(manager) + .update(variantUpdates.filter(Boolean)) }) + } else { + return await this.atomicPhase_(async (manager) => { + const [inventoryItems, count] = + await this.inventoryService_.listInventoryItems( + { + sku: bpProducts.map(({ SKU }) => SKU), + }, + {}, + { transactionManager: manager } + ) + + const itemMap = new Map(inventoryItems.map((i) => [i.id, i.sku])) + + const [inventoryLevels, levelsCount] = + await this.inventoryService_.listInventoryLevels( + { + inventory_item_id: inventoryItems.map((i) => i.id), + }, + {}, + { transactionManager: manager } + ) + + const locations = ( + await this.stockLocationService_.list( + { + id: [...new Set(inventoryLevels.map((ri) => ri.location_id))], + }, + {}, + { transactionManager: manager } + ) + ).filter((location) => !!location.metadata?.bp_id) + + const inventoryMap = inventoryLevels.reduce((acc, level) => { + const itemSku = itemMap.get(level.inventory_item_id) + if (!itemSku) { + return acc + } + + const locationsMap = acc.get(itemSku) + if (!locationsMap) { + acc.set(itemSku, new Map([[level.location_id, level]])) + } else { + locationsMap.set(level.location_id, level) + } + + return acc + }, new Map()) + + this.logger_.info("Synchronizing inventory levels") + + await Promise.all( + bpProducts.map(async (bpProduct, index) => { + if (index % 100 === 0) { + this.logger_.info( + `Synchronizing ${index} of ${bpProducts.length}` + ) + } + + const { SKU: sku, productId } = bpProduct + + const productAvailability = availabilities[productId] + + if (productAvailability) { + await Promise.all( + locations.map(async (location) => { + const warehouseData = + productAvailability.warehouses[ + `${location.metadata.bp_id}` + ] + + const inventoryLevel = inventoryMap + .get(sku) + ?.get(location.id) + + if (!inventoryLevel || !warehouseData) { + return + } + + await this.adjustMedusaLocationLevel_( + location, + inventoryLevel, + warehouseData + ) + }) + ) + } + }) + ) + this.logger_.info("Finished synchronizing inventory levels") + }) + } + } + } + + async adjustCoreInventory_(variantId, productAvailability) { + let onHand = 0 + + if ( + productAvailability.warehouses && + productAvailability.warehouses[`${this.options.warehouse}`] + ) { + onHand = + productAvailability.warehouses[`${this.options.warehouse}`].onHand + } + + return await this.manager_.transaction((m) => { + return this.productVariantService_.withTransaction(m).update(variantId, { + inventory_quantity: onHand, + }) + }) + } + + async adjustMedusaLocationLevel_(location, inventoryLevel, warehouseData) { + const manager = this.transactionManager_ ?? this.manager_ + + if (inventoryLevel.stocked_quantity !== warehouseData.inStock) { + await this.inventoryService_.updateInventoryLevel( + inventoryLevel.inventory_item_id, + inventoryLevel.location_id, + { stocked_quantity: warehouseData.inStock }, + { transactionManager: manager } + ) + } + + const externallyReservedQuantityAdjustment = + warehouseData.inStock - + warehouseData.onHand - + inventoryLevel.reserved_quantity + + if (externallyReservedQuantityAdjustment === 0) { + return + } + + const [reservations] = await this.inventoryService_.listReservationItems( + { + inventory_item_id: inventoryLevel.inventory_item_id, + location_id: location.id, + external_id: "brightpearl", + }, + {}, + { transactionManager: manager } + ) + + const externalReservation = reservations.find( + (r) => r.external_id === "brightpearl" + ) + + if (externalReservation) { + await this.inventoryService_.updateReservationItem( + externalReservation.id, + { + quantity: + externalReservation.quantity + externallyReservedQuantityAdjustment, + }, + { transactionManager: manager } + ) + } else { + await this.inventoryService_.createReservationItem( + { + location_id: location.id, + inventory_item_id: inventoryLevel.inventory_item_id, + external_id: "brightpearl", + quantity: externallyReservedQuantityAdjustment, + }, + { transactionManager: manager } ) } } @@ -205,35 +406,75 @@ class BrightpearlService extends BaseService { .retrieveAvailability(productId) .catch(() => null) - if (availability) { - const brightpearlProduct = await client.products.retrieve(productId) + if (!availability) { + return + } - const prodAvail = availability[productId] + const brightpearlProduct = await client.products.retrieve(productId) - let onHand = 0 - if ( - prodAvail.warehouses && - prodAvail.warehouses[`${this.options.warehouse}`] - ) { - onHand = prodAvail.warehouses[`${this.options.warehouse}`].onHand - } + const sku = brightpearlProduct.identity.sku + if (!sku) { + return + } - const sku = brightpearlProduct.identity.sku - if (!sku) return + const productAvailability = availability[productId] + if (!this.inventoryService_) { const variant = await this.productVariantService_ .retrieveBySKU(sku) .catch((_) => undefined) - if (variant && variant.manage_inventory) { - await this.manager_.transaction((m) => { - return this.productVariantService_ - .withTransaction(m) - .update(variant.id, { - inventory_quantity: onHand, - }) - }) + + if (!variant?.manage_inventory) { + return } + + return this.adjustCoreInventory_(variant.id, productAvailability) } + + const [inventoryItems] = await this.inventoryService_.listInventoryItems({ + sku, + }) + + const [inventoryLevels] = await this.inventoryService_.listInventoryLevels({ + inventory_item_id: inventoryItems.map((i) => i.id), + }) + + const inventoryMap = inventoryLevels.reduce((acc, item) => { + acc[item.location_id] = acc[item.location_id] + ? [...acc[item.location_id], item] + : [item] + return acc + }, {}) + + const locations = ( + await this.stockLocationService_.list({ + id: inventoryLevels.map((ri) => ri.location_id), + }) + ).filter( + (location) => + location.metadata?.bp_id && + productAvailability.warehouses[`${location.metadata.bp_id}`] + ) + + await Promise.all( + locations.map(async (location) => { + // TODO: Assuming we have a 1 to 1 mapping of inventory items + const inventoryLevel = inventoryMap[location.id][0] + + const warehouseData = + productAvailability.warehouses[`${location.metadata.bp_id}`] + + if (!warehouseData) { + return + } + + await this.adjustMedusaLocationLevel_( + location, + inventoryLevel, + warehouseData + ) + }) + ) } async createGoodsOutNote(fromOrder, shipment) { @@ -363,6 +604,276 @@ class BrightpearlService extends BaseService { } } + async getBrightPearlWarehouseFromMedusaLocation_(locationId) { + let warehouse = this.options.warehouse + + if (locationId && this.stockLocationService_) { + const location = await this.stockLocationService_.retrieve(locationId) + if (location?.metadata?.bp_id) { + warehouse = location.metadata.bp_id + } + } + + return warehouse + } + + async getOrderFromReservation_(reservationItems) { + if (!reservationItems.some((item) => !!item.line_item_id)) { + return {} + } + + const lineItems = await this.lineItemService_.list( + { + id: reservationItems + .filter((item) => !!item.line_item_id) + .map((item) => item.line_item_id), + }, + {} + ) + + if (!lineItems.length || !lineItems[0].order_id) { + return {} + } + + const order = await this.orderService_ + .retrieve(lineItems[0].order_id) + .catch(() => undefined) + + if (order) { + return { order, lineItems } + } + + return {} + } + + async bulkCreateReservation(eventData) { + const { ids } = eventData + + if (!ids.length) { + return + } + + const [reservationItems] = + await this.inventoryService_.listReservationItems({ + id: ids, + }) + + const client = await this.getClient() + + const { order, lineItems } = await this.getOrderFromReservation_( + reservationItems + ) + + if (!order?.metadata?.brightpearl_sales_order_id || !lineItems?.length) { + return this.attemptRetryEvent( + "reservation-items.bulk-created", + eventData, + "Cannot create a brightpearl reservation without a brightpearl order" + ) + } + + const warehouse = await this.getBrightPearlWarehouseFromMedusaLocation_( + reservationItems[0].location_id + ) + + const variants = await this.productVariantService_.list( + { id: lineItems.map((item) => item.variant_id) }, + {} + ) + const lineItemMap = new Map(lineItems.map((item) => [item.id, item])) + const variantMap = new Map(variants.map((v) => [v.id, v])) + + const bpOrder = await client.orders.retrieve( + order.metadata.brightpearl_sales_order_id + ) + + const rows = await Promise.all( + reservationItems.map(async (item) => { + const lineItem = lineItemMap.get(item.line_item_id) + const variant = variantMap.get(lineItem.variant_id) + + const bpProduct = await this.retrieveProductBySKU(variant.sku) + + if (!lineItem || !variant || !bpProduct) { + return null + } + + const bpOrderRow = bpOrder.rows.find( + (row) => row.externalRef === lineItem.id + ) + + return { + productId: bpProduct.productId, + id: bpOrderRow.id, + quantity: item.quantity, + } + }) + ) + + order.rows = rows.filter((row) => !!row) + + const reservation = await client.warehouses + .retrieveReservation(order.metadata.brightpearl_sales_order_id) + .catch(() => undefined) + + if (!reservation) { + const reservationFailed = await client.warehouses + .createReservation( + { ...order, id: order.metadata.brightpearl_sales_order_id }, + warehouse + ) + .catch(() => true) + + // if we succeed in creating the reservation return early + if (!reservationFailed) { + return + } + } + + if (!reservation) { + return this.attemptRetryEvent( + "reservation-items.bulk-created", + eventData, + "Could not create reservation for order with id: " + + order.metadata.brightpearl_sales_order_id + ) + } + + const updatePayload = { + products: [ + ...order.rows.map((row) => ({ + productId: row.productId, + salesOrderRowId: row.id, + quantity: row.quantity, + })), + ...Object.entries(reservation[0].orderRows).map(([key, value]) => ({ + productId: value.productId, + quantity: value.quantity, + salesOrderRowId: key, + })), + ], + } + + return await client.warehouses.updateReservation( + order.metadata.brightpearl_sales_order_id, + updatePayload + ) + } + + async createReservation(eventData) { + const { id } = eventData + + if (!id) { + return + } + + const [[reservationItem]] = + await this.inventoryService_.listReservationItems({ + id, + }) + + const client = await this.getClient() + + const { order, lineItems } = await this.getOrderFromReservation_([ + reservationItem, + ]) + + if (!order.metadata?.brightpearl_sales_order_id) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Cannot create a brightpearl reservation without a brightpearl order" + ) + } + + const warehouse = await this.getBrightPearlWarehouseFromMedusaLocation_( + reservationItem.location_id + ) + + const variant = await this.productVariantService_.retrieve( + lineItems[0].variant_id + ) + + const bpProduct = await this.retrieveProductBySKU(variant.sku) + + const bpOrder = await client.orders.retrieve( + order.metadata.brightpearl_sales_order_id + ) + + const bpOrderRow = bpOrder.rows.find( + (row) => row.externalRef === lineItems[0].id + ) + + order.rows = [ + { + productId: bpProduct.productId, + id: bpOrderRow.id, + quantity: reservationItem.quantity, + }, + ] + + const reservation = await client.warehouses + .retrieveReservation(order.metadata.brightpearl_sales_order_id) + .catch(() => undefined) + + if (!reservation) { + const reservationFailed = await client.warehouses + .createReservation( + { ...order, id: order.metadata.brightpearl_sales_order_id }, + warehouse + ) + .catch(() => true) + + // if we succeed in creating the reservation return early + if (!reservationFailed) { + return + } + } + + if (!reservation) { + return this.attemptRetryEvent( + "product_variant_inventory.reservation_created", + eventData, + "Could not create reservation for order with id: " + + order.metadata.brightpearl_sales_order_id + ) + } + + const updatePayload = { + products: [ + { + productId: bpProduct.productId, + salesOrderRowId: bpOrderRow.id, + quantity: reservationItem.quantity, + }, + ...Object.entries(reservation[0].orderRows).map(([key, value]) => ({ + productId: value.productId, + quantity: value.quantity, + salesOrderRowId: key, + })), + ], + } + + return await client.warehouses.updateReservation( + order.metadata.brightpearl_sales_order_id, + updatePayload + ) + } + + attemptRetryEvent(eventName, eventData, errorMessage) { + const currentAttempts = eventData.retries || 0 + + if (currentAttempts > 3) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, errorMessage) + } + + const event = { + ...eventData, + retries: 1 + currentAttempts, + } + + this.eventBusService_.emit(eventName, event) + } + async createSalesCredit(fromOrder, fromReturn) { const region = fromOrder.region const client = await this.getClient() @@ -492,10 +1003,12 @@ class BrightpearlService extends BaseService { "billing_address", "shipping_methods", "payments", + "sales_channel", ], }) const client = await this.getClient() + let customer = await this.retrieveCustomerByEmail(fromOrder.email) // All sales orders must have a customer @@ -506,13 +1019,17 @@ class BrightpearlService extends BaseService { const authData = await this.getAuthData() const { shipping_address } = fromOrder + const order = { currency: { code: fromOrder.currency_code.toUpperCase(), }, ref: fromOrder.display_id, externalRef: fromOrder.id, - channelId: this.options.channel_id || `1`, + channelId: + fromOrder.sales_channel?.metadata?.bp_id || + this.options.channel_id || + `1`, installedIntegrationInstanceId: authData.installation_instance_id, statusId: this.options.default_status_id || `3`, customer: { @@ -545,12 +1062,14 @@ class BrightpearlService extends BaseService { return client.orders .create(order) .then(async (salesOrderId) => { - const order = await client.orders.retrieve(salesOrderId) - await client.warehouses - .createReservation(order, this.options.warehouse) - .catch((err) => { - console.log("Failed to allocate for order:", salesOrderId) - }) + if (!this.inventoryService_) { + const order = await client.orders.retrieve(salesOrderId) + await client.warehouses + .createReservation(order, this.options.warehouse) + .catch((err) => { + console.log("Failed to allocate for order:", salesOrderId) + }) + } return salesOrderId }) .then((salesOrderId) => { @@ -1157,7 +1676,7 @@ class BrightpearlService extends BaseService { } async createFulfillmentFromGoodsOut(id) { - await this.manager_.transaction(async (m) => { + await this.manager_.transaction(async (transactionManager) => { const client = await this.getClient() // Get goods out and associated order @@ -1165,7 +1684,19 @@ class BrightpearlService extends BaseService { const order = await client.orders.retrieve(goodsOut.orderId) // Only relevant for medusa orders check channel id - if (order.channelId !== parseInt(this.options.channel_id)) { + const { fulfillments: existingFulfillments, sales_channel } = + await this.orderService_ + .withTransaction(transactionManager) + .retrieve(order.externalRef, { + relations: ["fulfillments", "sales_channel"], + }) + + if ( + (sales_channel.metadata?.bp_id && + sales_channel.metadata.bp_id !== order.channelId) || + (this.options.channel_id && + order.channelId !== parseInt(this.options.channel_id)) + ) { return } @@ -1194,28 +1725,95 @@ class BrightpearlService extends BaseService { if (partId) { if (partId.startsWith("claim")) { - return this.claimService_ - .withTransaction(m) + return await this.claimService_ + .withTransaction(transactionManager) .createFulfillment(partId, { metadata: { goods_out_note: id }, }) } else { - return this.swapService_ - .withTransaction(m) + return await this.swapService_ + .withTransaction(transactionManager) .createFulfillment(partId, { metadata: { goods_out_note: id }, }) } } - return this.orderService_ - .withTransaction(m) + if (!(this.inventoryService_ && this.stockLocationService_)) { + return await this.orderService_ + .withTransaction(transactionManager) + .createFulfillment(order.externalRef, lines, { + metadata: { goods_out_note: id }, + }) + } + + const bpLocation = goodsOut.warehouseId + + const fulfillmentLocation = + await this.getMedusaLocationFromBrightPearlWarehouse( + bpLocation, + sales_channel.id, + { transactionManager: transactionManager } + ) + + const medusaOrder = await this.orderService_ + .withTransaction(transactionManager) .createFulfillment(order.externalRef, lines, { metadata: { goods_out_note: id }, + location_id: fulfillmentLocation.id, }) + + const existingFulfillmentMap = new Map( + existingFulfillments.map((fulfillment) => [fulfillment.id, fulfillment]) + ) + + const { fulfillments } = await this.orderService_ + .withTransaction(transactionManager) + .retrieve(order.externalRef, { + relations: [ + "fulfillments", + "fulfillments.items", + "fulfillments.items.item", + ], + }) + + await updateInventoryAndReservations( + fulfillments.filter((f) => !existingFulfillmentMap.get(f.id)), + { + inventoryService: + this.productVariantInventoryService_.withTransaction( + transactionManager + ), + locationId: fulfillmentLocation.id, + } + ) + + return medusaOrder }, "SERIALIZABLE") } + async getMedusaLocationFromBrightPearlWarehouse( + bpLocationId, + sales_channel_id, + context + ) { + const locationIds = await this.salesChannelLocationService_ + .withTransaction(context.transactionManager) + .listLocationIds(sales_channel_id) + + const locations = await this.stockLocationService_.list( + { id: locationIds }, + {}, + { transactionManager: context.transactionManager } + ) + + const fulfillmentLocation = locations.find( + (location) => location.metadata?.bp_id === bpLocationId + ) + + return fulfillmentLocation + } + async createCustomer(fromOrder) { const client = await this.getClient() const address = await client.addresses.create({ diff --git a/packages/medusa-plugin-brightpearl/src/subscribers/order.js b/packages/medusa-plugin-brightpearl/src/subscribers/order.js index 547a487e6d..95e12649a0 100644 --- a/packages/medusa-plugin-brightpearl/src/subscribers/order.js +++ b/packages/medusa-plugin-brightpearl/src/subscribers/order.js @@ -45,6 +45,24 @@ class OrderSubscriber { this.registerSwapPayment ) eventBusService.subscribe("swap.received", this.registerSwap) + + eventBusService.subscribe( + "reservation-item.created", + this.registerMedusaReservation + ) + + eventBusService.subscribe( + "reservation-items.bulk-created", + this.registerMedusaBulkReservation + ) + } + + registerMedusaReservation = (data) => { + return this.brightpearlService_.createReservation(data) + } + + registerMedusaBulkReservation = (data) => { + return this.brightpearlService_.bulkCreateReservation(data) } sendToBrightpearl = (data) => { diff --git a/packages/medusa-plugin-brightpearl/src/utils/brightpearl.js b/packages/medusa-plugin-brightpearl/src/utils/brightpearl.js index af82792d29..ad4b0b11ba 100644 --- a/packages/medusa-plugin-brightpearl/src/utils/brightpearl.js +++ b/packages/medusa-plugin-brightpearl/src/utils/brightpearl.js @@ -140,6 +140,12 @@ class BrightpearlClient { data, }) }, + delete: (id) => { + return this.client_.request({ + url: `/integration-service/webhook/${id}`, + method: "DELETE", + }) + }, } } @@ -198,6 +204,15 @@ class BrightpearlClient { data, }) }, + updateReservation: (orderId, data) => { + return this.client_ + .request({ + url: `/warehouse-service/order/${orderId}/reservation`, + method: "PUT", + data, + }) + .then(({ data }) => data.response) + }, createReservation: (order, warehouse) => { const id = order.id const data = order.rows.map((r) => ({ diff --git a/packages/medusa/src/migrations/1656949291839-sales_channel.ts b/packages/medusa/src/migrations/1656949291839-sales_channel.ts index 2179e5bcdc..24c39c4d34 100644 --- a/packages/medusa/src/migrations/1656949291839-sales_channel.ts +++ b/packages/medusa/src/migrations/1656949291839-sales_channel.ts @@ -1,6 +1,7 @@ import { MigrationInterface, QueryRunner } from "typeorm" +import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels" -export const featureFlag = "sales_channels" +export const featureFlag = SalesChannelFeatureFlag.key export class salesChannel1656949291839 implements MigrationInterface { name = "salesChannel1656949291839" diff --git a/packages/medusa/src/migrations/1680714052628-add_sales_channel_metadata.ts b/packages/medusa/src/migrations/1680714052628-add_sales_channel_metadata.ts new file mode 100644 index 0000000000..c516dddb4a --- /dev/null +++ b/packages/medusa/src/migrations/1680714052628-add_sales_channel_metadata.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels" + +export const featureFlag = SalesChannelFeatureFlag.key + +export class addSalesChannelMetadata1680714052628 + implements MigrationInterface +{ + name = "addSalesChannelMetadata1680714052628" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "sales_channel" ADD COLUMN "metadata" jsonb NULL;` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "sales_channel" DROP COLUMN "metadata"` + ) + } +} diff --git a/packages/medusa/src/models/sales-channel.ts b/packages/medusa/src/models/sales-channel.ts index d72cab21a7..faa2c2c067 100644 --- a/packages/medusa/src/models/sales-channel.ts +++ b/packages/medusa/src/models/sales-channel.ts @@ -2,7 +2,7 @@ import { BeforeInsert, Column, OneToMany } from "typeorm" import { FeatureFlagEntity } from "../utils/feature-flag-decorators" import { SoftDeletableEntity } from "../interfaces" -import { generateEntityId } from "../utils" +import { DbAwareColumn, generateEntityId } from "../utils" import { SalesChannelLocation } from "./sales-channel-location" @FeatureFlagEntity("sales_channels") @@ -16,6 +16,9 @@ export class SalesChannel extends SoftDeletableEntity { @Column({ default: false }) is_disabled: boolean + @DbAwareColumn({ type: "jsonb", nullable: true }) + metadata: Record | null + @OneToMany( () => SalesChannelLocation, (scLocation) => scLocation.sales_channel, diff --git a/packages/medusa/src/services/product-variant-inventory.ts b/packages/medusa/src/services/product-variant-inventory.ts index cee2017248..d6a1f82156 100644 --- a/packages/medusa/src/services/product-variant-inventory.ts +++ b/packages/medusa/src/services/product-variant-inventory.ts @@ -1,6 +1,7 @@ import { EntityManager, In } from "typeorm" import { ICacheService, + IEventBusService, IInventoryService, IStockLocationService, InventoryItemDTO, @@ -23,14 +24,19 @@ type InjectedDependencies = { productVariantService: ProductVariantService stockLocationService: IStockLocationService inventoryService: IInventoryService + eventBusService: IEventBusService } class ProductVariantInventoryService extends TransactionBaseService { + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined + protected readonly salesChannelLocationService_: SalesChannelLocationService protected readonly salesChannelInventoryService_: SalesChannelInventoryService protected readonly productVariantService_: ProductVariantService protected readonly stockLocationService_: IStockLocationService protected readonly inventoryService_: IInventoryService + protected readonly eventBusService_: IEventBusService protected readonly cacheService_: ICacheService constructor({ @@ -39,6 +45,7 @@ class ProductVariantInventoryService extends TransactionBaseService { salesChannelInventoryService, productVariantService, inventoryService, + eventBusService, }: InjectedDependencies) { // eslint-disable-next-line prefer-rest-params super(arguments[0]) @@ -48,6 +55,7 @@ class ProductVariantInventoryService extends TransactionBaseService { this.stockLocationService_ = stockLocationService this.productVariantService_ = productVariantService this.inventoryService_ = inventoryService + this.eventBusService_ = eventBusService } /** @@ -108,6 +116,10 @@ class ProductVariantInventoryService extends TransactionBaseService { locationIds = stockLocations.map((l) => l.id) } + if (locationIds.length === 0) { + return false + } + const hasInventory = await Promise.all( variantInventory.map(async (inventoryPart) => { const itemQuantity = inventoryPart.required_quantity * quantity @@ -346,7 +358,6 @@ class ProductVariantInventoryService extends TransactionBaseService { } const toReserve = { - type: "order", line_item_id: context.lineItemId, } @@ -385,7 +396,7 @@ class ProductVariantInventoryService extends TransactionBaseService { locationId = locations[0].location_id } - return await Promise.all( + const reservationItems = await Promise.all( variantInventory.map(async (inventoryPart) => { const itemQuantity = inventoryPart.required_quantity * quantity return await this.inventoryService_.createReservationItem({ @@ -396,6 +407,8 @@ class ProductVariantInventoryService extends TransactionBaseService { }) }) ) + + return reservationItems } /** diff --git a/packages/medusa/src/strategies/cart-completion.ts b/packages/medusa/src/strategies/cart-completion.ts index 0c09627a8d..c14411c5a4 100644 --- a/packages/medusa/src/strategies/cart-completion.ts +++ b/packages/medusa/src/strategies/cart-completion.ts @@ -2,7 +2,11 @@ import { AbstractCartCompletionStrategy, CartCompletionResponse, } from "../interfaces" -import { IInventoryService, ReservationItemDTO } from "@medusajs/types" +import { + IEventBusService, + IInventoryService, + ReservationItemDTO, +} from "@medusajs/types" import { IdempotencyKey, Order } from "../models" import OrderService, { ORDER_CART_ALREADY_EXISTS_ERROR, @@ -28,6 +32,7 @@ type InjectedDependencies = { swapService: SwapService manager: EntityManager inventoryService: IInventoryService + eventBusService: IEventBusService } class CartCompletionStrategy extends AbstractCartCompletionStrategy { @@ -39,6 +44,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { protected readonly orderService_: OrderService protected readonly swapService_: SwapService protected readonly inventoryService_: IInventoryService + protected readonly eventBusService_: IEventBusService constructor({ productVariantInventoryService, @@ -48,6 +54,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { orderService, swapService, inventoryService, + eventBusService, }: InjectedDependencies) { // eslint-disable-next-line prefer-rest-params super(arguments[0]) @@ -59,6 +66,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { this.orderService_ = orderService this.swapService_ = swapService this.inventoryService_ = inventoryService + this.eventBusService_ = eventBusService } async complete( @@ -384,6 +392,14 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { } else { throw error } + } else if (this.inventoryService_) { + await this.eventBusService_.emit("reservation-items.bulk-created", { + ids: reservations + .filter(([reservation]) => !!reservation) + .flatMap(([reservationItemArr]) => + reservationItemArr!.map((item) => item.id) + ), + }) } } diff --git a/packages/types/src/inventory/common.ts b/packages/types/src/inventory/common.ts index 1a1a2b8dc1..33e8f9fa66 100644 --- a/packages/types/src/inventory/common.ts +++ b/packages/types/src/inventory/common.ts @@ -217,6 +217,7 @@ export type CreateReservationItemInput = { location_id: string quantity: number metadata?: Record | null + external_id?: string } export type FilterableInventoryLevelProps = {