feat(medusa, medusa-plugin-brightpearl): Inventory management for Brightpearl (#3192)

This commit is contained in:
Philip Korsholm
2023-04-23 12:50:19 +02:00
committed by GitHub
parent 8b6464180a
commit 4a85627435
20 changed files with 855 additions and 227 deletions

View File

@@ -0,0 +1,7 @@
---
"medusa-plugin-brightpearl": patch
"@medusajs/inventory": patch
"@medusajs/medusa": patch
---
feat(medusa-plugin-brightpearl, inventory, medusa): Multiwarehouse integration for brightpearl

View File

@@ -8,80 +8,6 @@ Object {
}
`;
exports[`sales channels GET /admin/orders/:id expands sales channel for single 1`] = `
Object {
"created_at": Any<String>,
"deleted_at": null,
"description": "test description",
"id": Any<String>,
"is_disabled": false,
"name": "test name",
"updated_at": Any<String>,
}
`;
exports[`sales channels GET /admin/orders?expand=sales_channels expands sales channel with parameter 1`] = `
Object {
"created_at": Any<String>,
"deleted_at": null,
"description": "test description",
"id": Any<String>,
"is_disabled": false,
"name": "test name",
"updated_at": Any<String>,
}
`;
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<String>,
"deleted_at": null,
"description": "test description 2",
"id": Any<String>,
"is_disabled": false,
"name": "test name 2",
"updated_at": Any<String>,
},
],
}
`;
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<String>,
"deleted_at": null,
"description": "test description",
"id": Any<String>,
"is_disabled": false,
"name": "test name",
"updated_at": Any<String>,
},
],
}
`;
exports[`sales channels GET /admin/sales-channels/:id should retrieve the requested sales channel 1`] = `
Object {
"created_at": Any<String>,
"deleted_at": null,
"description": "test description",
"id": Any<String>,
"is_disabled": false,
"name": "test name",
"updated_at": Any<String>,
}
`;
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<String>,
"deleted_at": null,
"description": "updated description",
"id": Any<String>,
"is_disabled": true,
"name": "updated name",
"updated_at": Any<String>,
}
`;

View File

@@ -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 },

View File

@@ -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<String>,
"deleted_at": null,
"description": "test description",
"id": Any<String>,
"is_disabled": false,
"name": "test name",
"updated_at": Any<String>,
}
`;

View File

@@ -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),
})
)
})
})

View File

@@ -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]

View File

@@ -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(),

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class addReservationType1675761451145 implements MigrationInterface {
name = "addReservationType1675761451145"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "reservation_item" ADD "external_id" character varying
`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "reservation_item" DROP COLUMN "external_id";
`)
}
}

View File

@@ -1,3 +1,3 @@
export * from "./reservation-item"
export {ReservationItem} from "./reservation-item"
export * from "./inventory-item"
export * from "./inventory-level"

View File

@@ -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<string, unknown> | null

View File

@@ -132,18 +132,19 @@ export default class ReservationItemService {
@MedusaContext() context: SharedContext = {}
): Promise<ReservationItem> {
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
}
/**

View File

@@ -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({

View File

@@ -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) => {

View File

@@ -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) => ({

View File

@@ -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"

View File

@@ -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<void> {
await queryRunner.query(
`ALTER TABLE "sales_channel" ADD COLUMN "metadata" jsonb NULL;`
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "sales_channel" DROP COLUMN "metadata"`
)
}
}

View File

@@ -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<string, unknown> | null
@OneToMany(
() => SalesChannelLocation,
(scLocation) => scLocation.sales_channel,

View File

@@ -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
}
/**

View File

@@ -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)
),
})
}
}

View File

@@ -217,6 +217,7 @@ export type CreateReservationItemInput = {
location_id: string
quantity: number
metadata?: Record<string, unknown> | null
external_id?: string
}
export type FilterableInventoryLevelProps = {