chore(core-flows): reserve inventory from available location (#11538)
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/core-flows": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
chore(core-flows): reserve inventory from locations with availability
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
Modules,
|
Modules,
|
||||||
PriceListStatus,
|
PriceListStatus,
|
||||||
PriceListType,
|
PriceListType,
|
||||||
|
remoteQueryObjectFromString,
|
||||||
RuleOperator,
|
RuleOperator,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
import {
|
import {
|
||||||
@@ -844,6 +845,166 @@ medusaIntegrationTestRunner({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should complete cart reserving inventory from available locations", async () => {
|
||||||
|
const salesChannel = await scModuleService.createSalesChannels({
|
||||||
|
name: "Webshop",
|
||||||
|
})
|
||||||
|
|
||||||
|
const location = await stockLocationModule.createStockLocations({
|
||||||
|
name: "Warehouse",
|
||||||
|
})
|
||||||
|
|
||||||
|
const location2 = await stockLocationModule.createStockLocations({
|
||||||
|
name: "Side Warehouse",
|
||||||
|
})
|
||||||
|
|
||||||
|
const [product] = await productModule.createProducts([
|
||||||
|
{
|
||||||
|
title: "Test product",
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "Test variant",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const inventoryItem = await inventoryModule.createInventoryItems({
|
||||||
|
sku: "inv-1234",
|
||||||
|
})
|
||||||
|
|
||||||
|
await inventoryModule.createInventoryLevels([
|
||||||
|
{
|
||||||
|
inventory_item_id: inventoryItem.id,
|
||||||
|
location_id: location.id,
|
||||||
|
stocked_quantity: 1,
|
||||||
|
reserved_quantity: 0,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
await inventoryModule.createInventoryLevels([
|
||||||
|
{
|
||||||
|
inventory_item_id: inventoryItem.id,
|
||||||
|
location_id: location2.id,
|
||||||
|
stocked_quantity: 1,
|
||||||
|
reserved_quantity: 0,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const priceSet = await pricingModule.createPriceSets({
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 3000,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await pricingModule.createPricePreferences({
|
||||||
|
attribute: "currency_code",
|
||||||
|
value: "usd",
|
||||||
|
is_tax_inclusive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await remoteLink.create([
|
||||||
|
{
|
||||||
|
[Modules.PRODUCT]: {
|
||||||
|
variant_id: product.variants[0].id,
|
||||||
|
},
|
||||||
|
[Modules.PRICING]: {
|
||||||
|
price_set_id: priceSet.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[Modules.SALES_CHANNEL]: {
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
},
|
||||||
|
[Modules.STOCK_LOCATION]: {
|
||||||
|
stock_location_id: location.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[Modules.SALES_CHANNEL]: {
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
},
|
||||||
|
[Modules.STOCK_LOCATION]: {
|
||||||
|
stock_location_id: location2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[Modules.PRODUCT]: {
|
||||||
|
variant_id: product.variants[0].id,
|
||||||
|
},
|
||||||
|
[Modules.INVENTORY]: {
|
||||||
|
inventory_item_id: inventoryItem.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// complete 2 carts
|
||||||
|
for (let i = 1; i <= 2; i++) {
|
||||||
|
const cart = await cartModuleService.createCarts({
|
||||||
|
currency_code: "usd",
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await addToCartWorkflow(appContainer).run({
|
||||||
|
input: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
variant_id: product.variants[0].id,
|
||||||
|
quantity: 1,
|
||||||
|
requires_shipping: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cart_id: cart.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await createPaymentCollectionForCartWorkflow(appContainer).run({
|
||||||
|
input: {
|
||||||
|
cart_id: cart.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [payCol] = await remoteQuery(
|
||||||
|
remoteQueryObjectFromString({
|
||||||
|
entryPoint: "cart_payment_collection",
|
||||||
|
variables: { filters: { cart_id: cart.id } },
|
||||||
|
fields: ["payment_collection_id"],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await createPaymentSessionsWorkflow(appContainer).run({
|
||||||
|
input: {
|
||||||
|
payment_collection_id: payCol.payment_collection_id,
|
||||||
|
provider_id: "pp_system_default",
|
||||||
|
context: {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await completeCartWorkflow(appContainer).run({
|
||||||
|
input: {
|
||||||
|
id: cart.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservations = await api.get(
|
||||||
|
`/admin/reservations`,
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
|
||||||
|
const locations = reservations.data.reservations.map(
|
||||||
|
(r) => r.location_id
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(locations).toEqual(
|
||||||
|
expect.arrayContaining([location.id, location2.id])
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("UpdateCartWorkflow", () => {
|
describe("UpdateCartWorkflow", () => {
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ export const completeCartFields = [
|
|||||||
"items.variant.inventory_items.inventory_item_id",
|
"items.variant.inventory_items.inventory_item_id",
|
||||||
"items.variant.inventory_items.required_quantity",
|
"items.variant.inventory_items.required_quantity",
|
||||||
"items.variant.inventory_items.inventory.requires_shipping",
|
"items.variant.inventory_items.inventory.requires_shipping",
|
||||||
|
"items.variant.inventory_items.inventory.location_levels.stocked_quantity",
|
||||||
|
"items.variant.inventory_items.inventory.location_levels.reserved_quantity",
|
||||||
|
"items.variant.inventory_items.inventory.location_levels.raw_stocked_quantity",
|
||||||
|
"items.variant.inventory_items.inventory.location_levels.raw_reserved_quantity",
|
||||||
"items.variant.inventory_items.inventory.location_levels.stock_locations.id",
|
"items.variant.inventory_items.inventory.location_levels.stock_locations.id",
|
||||||
"items.variant.inventory_items.inventory.location_levels.stock_locations.name",
|
"items.variant.inventory_items.inventory.location_levels.stock_locations.name",
|
||||||
"items.variant.inventory_items.inventory.location_levels.stock_locations.sales_channels.id",
|
"items.variant.inventory_items.inventory.location_levels.stock_locations.sales_channels.id",
|
||||||
@@ -153,6 +157,10 @@ export const productVariantsFields = [
|
|||||||
"inventory_items.inventory_item_id",
|
"inventory_items.inventory_item_id",
|
||||||
"inventory_items.required_quantity",
|
"inventory_items.required_quantity",
|
||||||
"inventory_items.inventory.requires_shipping",
|
"inventory_items.inventory.requires_shipping",
|
||||||
|
"inventory_items.inventory.location_levels.stocked_quantity",
|
||||||
|
"inventory_items.inventory.location_levels.reserved_quantity",
|
||||||
|
"inventory_items.inventory.location_levels.raw_stocked_quantity",
|
||||||
|
"inventory_items.inventory.location_levels.raw_reserved_quantity",
|
||||||
"inventory_items.inventory.location_levels.stock_locations.id",
|
"inventory_items.inventory.location_levels.stock_locations.id",
|
||||||
"inventory_items.inventory.location_levels.stock_locations.name",
|
"inventory_items.inventory.location_levels.stock_locations.name",
|
||||||
"inventory_items.inventory.location_levels.stock_locations.sales_channels.id",
|
"inventory_items.inventory.location_levels.stock_locations.sales_channels.id",
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import {
|
|||||||
BigNumberInput,
|
BigNumberInput,
|
||||||
ConfirmVariantInventoryWorkflowInputDTO,
|
ConfirmVariantInventoryWorkflowInputDTO,
|
||||||
} from "@medusajs/framework/types"
|
} from "@medusajs/framework/types"
|
||||||
import { MedusaError, deepFlatMap } from "@medusajs/framework/utils"
|
import {
|
||||||
|
BigNumber,
|
||||||
|
MathBN,
|
||||||
|
MedusaError,
|
||||||
|
deepFlatMap,
|
||||||
|
} from "@medusajs/framework/utils"
|
||||||
|
|
||||||
interface ConfirmInventoryPreparationInput {
|
interface ConfirmInventoryPreparationInput {
|
||||||
product_variant_inventory_items: {
|
product_variant_inventory_items: {
|
||||||
@@ -21,6 +26,7 @@ interface ConfirmInventoryPreparationInput {
|
|||||||
allow_backorder?: boolean
|
allow_backorder?: boolean
|
||||||
}[]
|
}[]
|
||||||
location_ids: string[]
|
location_ids: string[]
|
||||||
|
stockAvailability: Map<string, Map<string, BigNumberInput>>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConfirmInventoryItem {
|
interface ConfirmInventoryItem {
|
||||||
@@ -38,6 +44,7 @@ export const prepareConfirmInventoryInput = (data: {
|
|||||||
const productVariantInventoryItems = new Map<string, any>()
|
const productVariantInventoryItems = new Map<string, any>()
|
||||||
const stockLocationIds = new Set<string>()
|
const stockLocationIds = new Set<string>()
|
||||||
const allVariants = new Map<string, any>()
|
const allVariants = new Map<string, any>()
|
||||||
|
const mapLocationAvailability = new Map<string, Map<string, BigNumberInput>>()
|
||||||
let hasSalesChannelStockLocation = false
|
let hasSalesChannelStockLocation = false
|
||||||
let hasManagedInventory = false
|
let hasManagedInventory = false
|
||||||
|
|
||||||
@@ -55,7 +62,13 @@ export const prepareConfirmInventoryInput = (data: {
|
|||||||
deepFlatMap(
|
deepFlatMap(
|
||||||
data.input,
|
data.input,
|
||||||
"variants.inventory_items.inventory.location_levels.stock_locations.sales_channels",
|
"variants.inventory_items.inventory.location_levels.stock_locations.sales_channels",
|
||||||
({ variants, inventory_items, stock_locations, sales_channels }) => {
|
({
|
||||||
|
variants,
|
||||||
|
inventory_items,
|
||||||
|
location_levels,
|
||||||
|
stock_locations,
|
||||||
|
sales_channels,
|
||||||
|
}) => {
|
||||||
if (!variants) {
|
if (!variants) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -67,6 +80,29 @@ export const prepareConfirmInventoryInput = (data: {
|
|||||||
hasSalesChannelStockLocation = true
|
hasSalesChannelStockLocation = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (location_levels && inventory_items) {
|
||||||
|
const availability = MathBN.sub(
|
||||||
|
location_levels.raw_stocked_quantity ??
|
||||||
|
location_levels.stocked_quantity ??
|
||||||
|
0,
|
||||||
|
location_levels.raw_reserved_quantity ??
|
||||||
|
location_levels.reserved_quantity ??
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!mapLocationAvailability.has(location_levels.location_id)) {
|
||||||
|
mapLocationAvailability.set(location_levels.location_id, new Map())
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationMap = mapLocationAvailability.get(
|
||||||
|
location_levels.location_id
|
||||||
|
)!
|
||||||
|
locationMap.set(
|
||||||
|
inventory_items.inventory_item_id,
|
||||||
|
new BigNumber(availability)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (stock_locations && sales_channels?.id === salesChannelId) {
|
if (stock_locations && sales_channels?.id === salesChannelId) {
|
||||||
stockLocationIds.add(stock_locations.id)
|
stockLocationIds.add(stock_locations.id)
|
||||||
}
|
}
|
||||||
@@ -114,6 +150,7 @@ export const prepareConfirmInventoryInput = (data: {
|
|||||||
productVariantInventoryItems.values()
|
productVariantInventoryItems.values()
|
||||||
),
|
),
|
||||||
location_ids: Array.from(stockLocationIds),
|
location_ids: Array.from(stockLocationIds),
|
||||||
|
stockAvailability: mapLocationAvailability,
|
||||||
items: data.input.items,
|
items: data.input.items,
|
||||||
variants: Array.from(allVariants.values()),
|
variants: Array.from(allVariants.values()),
|
||||||
})
|
})
|
||||||
@@ -125,6 +162,7 @@ const formatInventoryInput = ({
|
|||||||
product_variant_inventory_items,
|
product_variant_inventory_items,
|
||||||
location_ids,
|
location_ids,
|
||||||
items,
|
items,
|
||||||
|
stockAvailability,
|
||||||
variants,
|
variants,
|
||||||
}: ConfirmInventoryPreparationInput) => {
|
}: ConfirmInventoryPreparationInput) => {
|
||||||
if (!product_variant_inventory_items.length) {
|
if (!product_variant_inventory_items.length) {
|
||||||
@@ -156,16 +194,27 @@ const formatInventoryInput = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
variantInventoryItems.forEach((variantInventoryItem) =>
|
variantInventoryItems.forEach((variantInventoryItem) => {
|
||||||
|
const locationsWithAvailability = location_ids.filter((locId) =>
|
||||||
|
MathBN.gte(
|
||||||
|
stockAvailability
|
||||||
|
.get(locId)
|
||||||
|
?.get(variantInventoryItem.inventory_item_id) ?? 0,
|
||||||
|
MathBN.mult(variantInventoryItem.required_quantity, item.quantity)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
itemsToConfirm.push({
|
itemsToConfirm.push({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
inventory_item_id: variantInventoryItem.inventory_item_id,
|
inventory_item_id: variantInventoryItem.inventory_item_id,
|
||||||
required_quantity: variantInventoryItem.required_quantity,
|
required_quantity: variantInventoryItem.required_quantity,
|
||||||
allow_backorder: !!variant.allow_backorder,
|
allow_backorder: !!variant.allow_backorder,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
location_ids: location_ids,
|
location_ids: locationsWithAvailability.length
|
||||||
|
? locationsWithAvailability
|
||||||
|
: location_ids,
|
||||||
})
|
})
|
||||||
)
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return itemsToConfirm
|
return itemsToConfirm
|
||||||
|
|||||||
@@ -49,21 +49,21 @@ export const confirmVariantInventoryWorkflowId = "confirm-item-inventory"
|
|||||||
/**
|
/**
|
||||||
* This workflow validates that product variants are in-stock at the specified sales channel, before adding them or updating their quantity in the cart. If a variant doesn't have sufficient quantity in-stock,
|
* This workflow validates that product variants are in-stock at the specified sales channel, before adding them or updating their quantity in the cart. If a variant doesn't have sufficient quantity in-stock,
|
||||||
* the workflow throws an error. If all variants have sufficient inventory, the workflow returns the cart's items with their inventory details.
|
* the workflow throws an error. If all variants have sufficient inventory, the workflow returns the cart's items with their inventory details.
|
||||||
*
|
*
|
||||||
* This workflow is useful when confirming that a product variant has sufficient quantity to be added to or updated in the cart. It's executed
|
* This workflow is useful when confirming that a product variant has sufficient quantity to be added to or updated in the cart. It's executed
|
||||||
* by other cart-related workflows, such as {@link addToCartWorkflow}, to confirm that a product variant can be added to the cart at the specified quantity.
|
* by other cart-related workflows, such as {@link addToCartWorkflow}, to confirm that a product variant can be added to the cart at the specified quantity.
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* Learn more about the links between the product variant and sales channels and inventory items in [this documentation](https://docs.medusajs.com/resources/commerce-modules/product/links-to-other-modules).
|
* Learn more about the links between the product variant and sales channels and inventory items in [this documentation](https://docs.medusajs.com/resources/commerce-modules/product/links-to-other-modules).
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* You can use this workflow within your own customizations or custom workflows, allowing you to check whether a product variant has enough inventory quantity before adding them to the cart.
|
* You can use this workflow within your own customizations or custom workflows, allowing you to check whether a product variant has enough inventory quantity before adding them to the cart.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* You can retrieve a variant's required details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query):
|
* You can retrieve a variant's required details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query):
|
||||||
*
|
*
|
||||||
* ```ts workflow={false}
|
* ```ts workflow={false}
|
||||||
* const { data: variants } = await query.graph({
|
* const { data: variants } = await query.graph({
|
||||||
* entity: "variant",
|
* entity: "variant",
|
||||||
@@ -73,6 +73,10 @@ export const confirmVariantInventoryWorkflowId = "confirm-item-inventory"
|
|||||||
* "inventory_items.inventory_item_id",
|
* "inventory_items.inventory_item_id",
|
||||||
* "inventory_items.required_quantity",
|
* "inventory_items.required_quantity",
|
||||||
* "inventory_items.inventory.requires_shipping",
|
* "inventory_items.inventory.requires_shipping",
|
||||||
|
* "inventory_items.inventory.location_levels.stocked_quantity",
|
||||||
|
* "inventory_items.inventory.location_levels.reserved_quantity",
|
||||||
|
* "inventory_items.inventory.location_levels.raw_stocked_quantity",
|
||||||
|
* "inventory_items.inventory.location_levels.raw_reserved_quantity",
|
||||||
* "inventory_items.inventory.location_levels.stock_locations.id",
|
* "inventory_items.inventory.location_levels.stock_locations.id",
|
||||||
* "inventory_items.inventory.location_levels.stock_locations.name",
|
* "inventory_items.inventory.location_levels.stock_locations.name",
|
||||||
* "inventory_items.inventory.location_levels.stock_locations.sales_channels.id",
|
* "inventory_items.inventory.location_levels.stock_locations.sales_channels.id",
|
||||||
@@ -83,15 +87,15 @@ export const confirmVariantInventoryWorkflowId = "confirm-item-inventory"
|
|||||||
* }
|
* }
|
||||||
* })
|
* })
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* :::note
|
* :::note
|
||||||
*
|
*
|
||||||
* In a workflow, use [useQueryGraphStep](https://docs.medusajs.com/resources/references/medusa-workflows/steps/useQueryGraphStep) instead.
|
* In a workflow, use [useQueryGraphStep](https://docs.medusajs.com/resources/references/medusa-workflows/steps/useQueryGraphStep) instead.
|
||||||
*
|
*
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* Then, pass the variant's data with the other required data to the workflow:
|
* Then, pass the variant's data with the other required data to the workflow:
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
* const { result } = await confirmVariantInventoryWorkflow(container)
|
* const { result } = await confirmVariantInventoryWorkflow(container)
|
||||||
* .run({
|
* .run({
|
||||||
@@ -108,9 +112,9 @@ export const confirmVariantInventoryWorkflowId = "confirm-item-inventory"
|
|||||||
* }
|
* }
|
||||||
* })
|
* })
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* When updating an item quantity:
|
* When updating an item quantity:
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
* const { result } = await confirmVariantInventoryWorkflow(container)
|
* const { result } = await confirmVariantInventoryWorkflow(container)
|
||||||
* .run({
|
* .run({
|
||||||
@@ -135,9 +139,9 @@ export const confirmVariantInventoryWorkflowId = "confirm-item-inventory"
|
|||||||
* }
|
* }
|
||||||
* })
|
* })
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @summary
|
* @summary
|
||||||
*
|
*
|
||||||
* Validate that a variant is in-stock before adding to the cart.
|
* Validate that a variant is in-stock before adding to the cart.
|
||||||
*/
|
*/
|
||||||
export const confirmVariantInventoryWorkflow = createWorkflow(
|
export const confirmVariantInventoryWorkflow = createWorkflow(
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export const productVariantsFields = [
|
|||||||
"inventory_items.inventory_item_id",
|
"inventory_items.inventory_item_id",
|
||||||
"inventory_items.required_quantity",
|
"inventory_items.required_quantity",
|
||||||
"inventory_items.inventory.requires_shipping",
|
"inventory_items.inventory.requires_shipping",
|
||||||
|
"inventory_items.inventory.location_levels.stocked_quantity",
|
||||||
|
"inventory_items.inventory.location_levels.reserved_quantity",
|
||||||
|
"inventory_items.inventory.location_levels.raw_stocked_quantity",
|
||||||
|
"inventory_items.inventory.location_levels.raw_reserved_quantity",
|
||||||
"inventory_items.inventory.location_levels.stock_locations.id",
|
"inventory_items.inventory.location_levels.stock_locations.id",
|
||||||
"inventory_items.inventory.location_levels.stock_locations.name",
|
"inventory_items.inventory.location_levels.stock_locations.name",
|
||||||
"inventory_items.inventory.location_levels.stock_locations.sales_channels.id",
|
"inventory_items.inventory.location_levels.stock_locations.sales_channels.id",
|
||||||
|
|||||||
Reference in New Issue
Block a user