chore(core-flows): reserve inventory from available location (#11538)

This commit is contained in:
Carlos R. L. Rodrigues
2025-02-26 06:10:41 -03:00
committed by GitHub
parent b966198258
commit 03731c7660
6 changed files with 252 additions and 21 deletions
+5
View File
@@ -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",