From e7dde4cf792340fa03a41ac4c470e1d124be0524 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" Date: Wed, 19 Feb 2025 18:14:40 -0300 Subject: [PATCH 1/3] chore(core-flows): reserve inventory from available location --- .changeset/rotten-seahorses-tell.md | 5 + .../cart/store/cart.workflows.spec.ts | 163 ++++++++++++++++++ .../core/core-flows/src/cart/utils/fields.ts | 8 + .../utils/prepare-confirm-inventory-input.ts | 50 +++++- .../workflows/confirm-variant-inventory.ts | 36 ++-- .../core/core-flows/src/order/utils/fields.ts | 4 + 6 files changed, 245 insertions(+), 21 deletions(-) create mode 100644 .changeset/rotten-seahorses-tell.md diff --git a/.changeset/rotten-seahorses-tell.md b/.changeset/rotten-seahorses-tell.md new file mode 100644 index 0000000000000..059f073f47744 --- /dev/null +++ b/.changeset/rotten-seahorses-tell.md @@ -0,0 +1,5 @@ +--- +"@medusajs/core-flows": patch +--- + +chore(core-flows): reserve inventory from locations with availability diff --git a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts index c9e1e5bce74da..dff19c92bef48 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -34,6 +34,7 @@ import { Modules, PriceListStatus, PriceListType, + remoteQueryObjectFromString, RuleOperator, } from "@medusajs/utils" import { @@ -842,6 +843,168 @@ 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, + }) + + console.log(`Cart ${i}`, cart.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", () => { diff --git a/packages/core/core-flows/src/cart/utils/fields.ts b/packages/core/core-flows/src/cart/utils/fields.ts index 91aeb81bfcee1..fb0d71a55c63a 100644 --- a/packages/core/core-flows/src/cart/utils/fields.ts +++ b/packages/core/core-flows/src/cart/utils/fields.ts @@ -105,6 +105,10 @@ export const completeCartFields = [ "items.variant.inventory_items.inventory_item_id", "items.variant.inventory_items.required_quantity", "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.name", "items.variant.inventory_items.inventory.location_levels.stock_locations.sales_channels.id", @@ -152,6 +156,10 @@ export const productVariantsFields = [ "inventory_items.inventory_item_id", "inventory_items.required_quantity", "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.name", "inventory_items.inventory.location_levels.stock_locations.sales_channels.id", diff --git a/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts b/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts index 4aa38d8df3994..7e47d45e27602 100644 --- a/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts +++ b/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts @@ -2,7 +2,12 @@ import { BigNumberInput, ConfirmVariantInventoryWorkflowInputDTO, } from "@medusajs/framework/types" -import { MedusaError, deepFlatMap } from "@medusajs/framework/utils" +import { + BigNumber, + MathBN, + MedusaError, + deepFlatMap, +} from "@medusajs/framework/utils" interface ConfirmInventoryPreparationInput { product_variant_inventory_items: { @@ -21,6 +26,7 @@ interface ConfirmInventoryPreparationInput { allow_backorder?: boolean }[] location_ids: string[] + stockAvailability: Map } interface ConfirmInventoryItem { @@ -38,6 +44,7 @@ export const prepareConfirmInventoryInput = (data: { const productVariantInventoryItems = new Map() const stockLocationIds = new Set() const allVariants = new Map() + const mapLocationAvailability = new Map() let hasSalesChannelStockLocation = false let hasManagedInventory = false @@ -55,7 +62,13 @@ export const prepareConfirmInventoryInput = (data: { deepFlatMap( data.input, "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) { return } @@ -67,6 +80,22 @@ export const prepareConfirmInventoryInput = (data: { hasSalesChannelStockLocation = true } + if (location_levels) { + const availabilty = MathBN.sub( + location_levels.raw_stocked_quantity ?? + location_levels.stocked_quantity ?? + 0, + location_levels.raw_reserved_quantity ?? + location_levels.reserved_quantity ?? + 0 + ) + + mapLocationAvailability.set( + location_levels.location_id, + new BigNumber(availabilty) + ) + } + if (stock_locations && sales_channels?.id === salesChannelId) { stockLocationIds.add(stock_locations.id) } @@ -114,6 +143,7 @@ export const prepareConfirmInventoryInput = (data: { productVariantInventoryItems.values() ), location_ids: Array.from(stockLocationIds), + stockAvailability: mapLocationAvailability, items: data.input.items, variants: Array.from(allVariants.values()), }) @@ -125,6 +155,7 @@ const formatInventoryInput = ({ product_variant_inventory_items, location_ids, items, + stockAvailability, variants, }: ConfirmInventoryPreparationInput) => { if (!product_variant_inventory_items.length) { @@ -156,16 +187,25 @@ const formatInventoryInput = ({ ) } - variantInventoryItems.forEach((variantInventoryItem) => + variantInventoryItems.forEach((variantInventoryItem) => { + const locationsWithAvailability = location_ids.filter((locId) => + MathBN.gte( + stockAvailability.get(locId) ?? 0, + variantInventoryItem.required_quantity + ) + ) + itemsToConfirm.push({ id: item.id, inventory_item_id: variantInventoryItem.inventory_item_id, required_quantity: variantInventoryItem.required_quantity, allow_backorder: !!variant.allow_backorder, quantity: item.quantity, - location_ids: location_ids, + location_ids: locationsWithAvailability.length + ? locationsWithAvailability + : location_ids, }) - ) + }) }) return itemsToConfirm diff --git a/packages/core/core-flows/src/cart/workflows/confirm-variant-inventory.ts b/packages/core/core-flows/src/cart/workflows/confirm-variant-inventory.ts index 3902229d73419..62514466178b6 100644 --- a/packages/core/core-flows/src/cart/workflows/confirm-variant-inventory.ts +++ b/packages/core/core-flows/src/cart/workflows/confirm-variant-inventory.ts @@ -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, * 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 * 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 - * + * * 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. - * + * * @example * You can retrieve a variant's required details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query): - * + * * ```ts workflow={false} * const { data: variants } = await query.graph({ * entity: "variant", @@ -73,6 +73,10 @@ export const confirmVariantInventoryWorkflowId = "confirm-item-inventory" * "inventory_items.inventory_item_id", * "inventory_items.required_quantity", * "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.name", * "inventory_items.inventory.location_levels.stock_locations.sales_channels.id", @@ -83,15 +87,15 @@ export const confirmVariantInventoryWorkflowId = "confirm-item-inventory" * } * }) * ``` - * + * * :::note - * + * * 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: - * + * * ```ts * const { result } = await confirmVariantInventoryWorkflow(container) * .run({ @@ -108,9 +112,9 @@ export const confirmVariantInventoryWorkflowId = "confirm-item-inventory" * } * }) * ``` - * + * * When updating an item quantity: - * + * * ```ts * const { result } = await confirmVariantInventoryWorkflow(container) * .run({ @@ -135,9 +139,9 @@ export const confirmVariantInventoryWorkflowId = "confirm-item-inventory" * } * }) * ``` - * + * * @summary - * + * * Validate that a variant is in-stock before adding to the cart. */ export const confirmVariantInventoryWorkflow = createWorkflow( diff --git a/packages/core/core-flows/src/order/utils/fields.ts b/packages/core/core-flows/src/order/utils/fields.ts index b80ae74020f55..be2af7f4dd32d 100644 --- a/packages/core/core-flows/src/order/utils/fields.ts +++ b/packages/core/core-flows/src/order/utils/fields.ts @@ -17,6 +17,10 @@ export const productVariantsFields = [ "inventory_items.inventory_item_id", "inventory_items.required_quantity", "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.name", "inventory_items.inventory.location_levels.stock_locations.sales_channels.id", From 6c54ebed86a199105e212e2798abbb7a33033aad Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" Date: Wed, 19 Feb 2025 18:17:59 -0300 Subject: [PATCH 2/3] mult quantity --- .../src/cart/utils/prepare-confirm-inventory-input.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts b/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts index 7e47d45e27602..3e80f2930e051 100644 --- a/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts +++ b/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts @@ -191,7 +191,7 @@ const formatInventoryInput = ({ const locationsWithAvailability = location_ids.filter((locId) => MathBN.gte( stockAvailability.get(locId) ?? 0, - variantInventoryItem.required_quantity + MathBN.mult(variantInventoryItem.required_quantity, item.quantity) ) ) From c1dfe26b66530309d4895640a0edfe0a5c9763f0 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" Date: Tue, 25 Feb 2025 10:36:35 -0300 Subject: [PATCH 3/3] map by location + item --- .../cart/store/cart.workflows.spec.ts | 2 -- .../utils/prepare-confirm-inventory-input.ts | 25 +++++++++++++------ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts index ec69d80612b35..ff4a908eaf232 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -949,8 +949,6 @@ medusaIntegrationTestRunner({ sales_channel_id: salesChannel.id, }) - console.log(`Cart ${i}`, cart.id) - await addToCartWorkflow(appContainer).run({ input: { items: [ diff --git a/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts b/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts index 3e80f2930e051..cb919d7c9e80e 100644 --- a/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts +++ b/packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts @@ -26,7 +26,7 @@ interface ConfirmInventoryPreparationInput { allow_backorder?: boolean }[] location_ids: string[] - stockAvailability: Map + stockAvailability: Map> } interface ConfirmInventoryItem { @@ -44,7 +44,7 @@ export const prepareConfirmInventoryInput = (data: { const productVariantInventoryItems = new Map() const stockLocationIds = new Set() const allVariants = new Map() - const mapLocationAvailability = new Map() + const mapLocationAvailability = new Map>() let hasSalesChannelStockLocation = false let hasManagedInventory = false @@ -80,8 +80,8 @@ export const prepareConfirmInventoryInput = (data: { hasSalesChannelStockLocation = true } - if (location_levels) { - const availabilty = MathBN.sub( + if (location_levels && inventory_items) { + const availability = MathBN.sub( location_levels.raw_stocked_quantity ?? location_levels.stocked_quantity ?? 0, @@ -90,9 +90,16 @@ export const prepareConfirmInventoryInput = (data: { 0 ) - mapLocationAvailability.set( - location_levels.location_id, - new BigNumber(availabilty) + 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) ) } @@ -190,7 +197,9 @@ const formatInventoryInput = ({ variantInventoryItems.forEach((variantInventoryItem) => { const locationsWithAvailability = location_ids.filter((locId) => MathBN.gte( - stockAvailability.get(locId) ?? 0, + stockAvailability + .get(locId) + ?.get(variantInventoryItem.inventory_item_id) ?? 0, MathBN.mult(variantInventoryItem.required_quantity, item.quantity) ) )