fix(core-flows, types): reservation of shared inventory item (#11403)

**What**
- if a cart contains variants that share inventory items, reservation of the item would fail also causing complete cart to fail
- include `completed_at` when compensating cart update
- account for multiple reservations of the same item when creating the locking key

---

CLOSES SUP-587
This commit is contained in:
Frane Polić
2025-02-13 09:03:53 +01:00
committed by GitHub
parent 90815793df
commit bbef0da5dd
7 changed files with 272 additions and 21 deletions

View File

@@ -1171,7 +1171,7 @@ medusaIntegrationTestRunner({
it("should add price from price list and set compare_at_unit_price for order item", async () => { it("should add price from price list and set compare_at_unit_price for order item", async () => {
const response = await api.post( const response = await api.post(
`/store/carts/${cart.id}/complete`, `/store/carts/${cart.id}/complete`,
{ variant_id: product.variants[0].id, quantity: 1 }, {},
storeHeaders storeHeaders
) )
@@ -1190,6 +1190,239 @@ medusaIntegrationTestRunner({
) )
}) })
}) })
describe("with inventory kit", () => {
let stockLocation, inventoryItem, product, cart
beforeEach(async () => {
stockLocation = (
await api.post(
`/admin/stock-locations`,
{ name: "test location" },
adminHeaders
)
).data.stock_location
inventoryItem = (
await api.post(
`/admin/inventory-items`,
{ sku: "bottle" },
adminHeaders
)
).data.inventory_item
await api.post(
`/admin/inventory-items/${inventoryItem.id}/location-levels`,
{
location_id: stockLocation.id,
stocked_quantity: 10,
},
adminHeaders
)
await api.post(
`/admin/stock-locations/${stockLocation.id}/sales-channels`,
{ add: [salesChannel.id] },
adminHeaders
)
product = (
await api.post(
"/admin/products",
{
title: `Test fixture ${shippingProfile.id}`,
shipping_profile_id: shippingProfile.id,
options: [
{ title: "pack", values: ["1-pack", "2-pack", "3-pack"] },
],
variants: [
{
title: "2-pack",
sku: "2-pack",
inventory_items: [
{
inventory_item_id: inventoryItem.id,
required_quantity: 2,
},
],
prices: [
{
currency_code: "usd",
amount: 100,
},
],
options: {
pack: "2-pack",
},
},
{
title: "3-pack",
sku: "3-pack",
inventory_items: [
{
inventory_item_id: inventoryItem.id,
required_quantity: 3,
},
],
prices: [
{
currency_code: "usd",
amount: 140,
},
],
options: {
pack: "3-pack",
},
},
],
},
adminHeaders
)
).data.product
cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
items: [
{ variant_id: product.variants[0].id, quantity: 1 },
{ variant_id: product.variants[1].id, quantity: 1 },
],
},
storeHeadersWithCustomer
)
).data.cart
const fulfillmentSets = (
await api.post(
`/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`,
{
name: `Test-inventory`,
type: "test-type",
},
adminHeaders
)
).data.stock_location.fulfillment_sets
const fulfillmentSet = (
await api.post(
`/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`,
{
name: `Test-inventory`,
geo_zones: [{ type: "country", country_code: "US" }],
},
adminHeaders
)
).data.fulfillment_set
await api.post(
`/admin/stock-locations/${stockLocation.id}/fulfillment-providers`,
{ add: ["manual_test-provider"] },
adminHeaders
)
const shippingOption = (
await api.post(
`/admin/shipping-options`,
{
name: `Test shipping option ${fulfillmentSet.id}`,
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: "manual_test-provider",
price_type: "flat",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [{ currency_code: "usd", amount: 1000 }],
rules: [],
},
adminHeaders
)
).data.shipping_option
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
)
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
})
it("should complete a cart with inventory item shared between variants", async () => {
console.log(cart)
const response = await api.post(
`/store/carts/${cart.id}/complete`,
{},
storeHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order).toEqual(
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
title: "2-pack",
quantity: 1,
}),
expect.objectContaining({
title: "3-pack",
quantity: 1,
}),
]),
})
)
const reservations = (
await api.get(`/admin/reservations`, adminHeaders)
).data.reservations
expect(reservations).toEqual(
expect.arrayContaining([
expect.objectContaining({
location_id: stockLocation.id,
inventory_item_id: inventoryItem.id,
quantity: 2, // 2-pack
inventory_item: expect.objectContaining({
id: inventoryItem.id,
sku: "bottle",
reserved_quantity: 5,
stocked_quantity: 10,
}),
}),
expect.objectContaining({
location_id: stockLocation.id,
inventory_item_id: inventoryItem.id,
quantity: 3, // 3-pack
inventory_item: expect.objectContaining({
id: inventoryItem.id,
sku: "bottle",
reserved_quantity: 5,
stocked_quantity: 10,
}),
}),
])
)
})
})
}) })
describe("shipping validation", () => { describe("shipping validation", () => {

View File

@@ -2,7 +2,6 @@ import { MathBN, Modules } from "@medusajs/framework/utils"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { BigNumberInput } from "@medusajs/types" import { BigNumberInput } from "@medusajs/types"
/** /**
* The details of the items and their quantity to reserve. * The details of the items and their quantity to reserve.
*/ */
@@ -45,7 +44,7 @@ export const reserveInventoryStepId = "reserve-inventory-step"
/** /**
* This step reserves the quantity of line items from the associated * This step reserves the quantity of line items from the associated
* variant's inventory. * variant's inventory.
* *
* @example * @example
* const data = reserveInventoryStep({ * const data = reserveInventoryStep({
* "items": [{ * "items": [{
@@ -80,7 +79,9 @@ export const reserveInventoryStep = createStep(
} }
}) })
const reservations = await locking.execute(inventoryItemIds, async () => { const lockingKeys = Array.from(new Set(inventoryItemIds))
const reservations = await locking.execute(lockingKeys, async () => {
return await inventoryService.createReservationItems(items) return await inventoryService.createReservationItems(items)
}) })
@@ -98,7 +99,9 @@ export const reserveInventoryStep = createStep(
const locking = container.resolve(Modules.LOCKING) const locking = container.resolve(Modules.LOCKING)
const inventoryItemIds = data.inventoryItemIds const inventoryItemIds = data.inventoryItemIds
await locking.execute(inventoryItemIds, async () => { const lockingKeys = Array.from(new Set(inventoryItemIds))
await locking.execute(lockingKeys, async () => {
await inventoryService.deleteReservationItems(data.reservations) await inventoryService.deleteReservationItems(data.reservations)
}) })

View File

@@ -17,7 +17,7 @@ export type UpdateCartsStepInput = UpdateCartWorkflowInputDTO[]
export const updateCartsStepId = "update-carts" export const updateCartsStepId = "update-carts"
/** /**
* This step updates a cart. * This step updates a cart.
* *
* @example * @example
* const data = updateCartsStep([{ * const data = updateCartsStep([{
* id: "cart_123", * id: "cart_123",
@@ -57,6 +57,7 @@ export const updateCartsStep = createStep(
email: cart.email, email: cart.email,
currency_code: cart.currency_code, currency_code: cart.currency_code,
metadata: cart.metadata, metadata: cart.metadata,
completed_at: cart.completed_at,
}) })
} }

View File

@@ -73,8 +73,10 @@ export const prepareConfirmInventoryInput = (data: {
if (inventory_items) { if (inventory_items) {
const inventoryItemId = inventory_items.inventory_item_id const inventoryItemId = inventory_items.inventory_item_id
if (!productVariantInventoryItems.has(inventoryItemId)) { const mapKey = `${inventoryItemId}-${inventory_items.variant_id}`
productVariantInventoryItems.set(inventoryItemId, {
if (!productVariantInventoryItems.has(mapKey)) {
productVariantInventoryItems.set(mapKey, {
variant_id: inventory_items.variant_id, variant_id: inventory_items.variant_id,
inventory_item_id: inventoryItemId, inventory_item_id: inventoryItemId,
required_quantity: inventory_items.required_quantity, required_quantity: inventory_items.required_quantity,

View File

@@ -6,14 +6,15 @@ import { MathBN, Modules } from "@medusajs/framework/utils"
/** /**
* The data to adjust the inventory levels. * The data to adjust the inventory levels.
*/ */
export type AdjustInventoryLevelsStepInput = InventoryTypes.BulkAdjustInventoryLevelInput[] export type AdjustInventoryLevelsStepInput =
InventoryTypes.BulkAdjustInventoryLevelInput[]
export const adjustInventoryLevelsStepId = "adjust-inventory-levels-step" export const adjustInventoryLevelsStepId = "adjust-inventory-levels-step"
/** /**
* This step adjusts the stocked quantity of one or more inventory levels. You can * This step adjusts the stocked quantity of one or more inventory levels. You can
* pass a positive value in `adjustment` to add to the stocked quantity, or a negative value to * pass a positive value in `adjustment` to add to the stocked quantity, or a negative value to
* subtract from the stocked quantity. * subtract from the stocked quantity.
* *
* @example * @example
* const data = adjustInventoryLevelsStep([ * const data = adjustInventoryLevelsStep([
* { * {
@@ -25,16 +26,15 @@ export const adjustInventoryLevelsStepId = "adjust-inventory-levels-step"
*/ */
export const adjustInventoryLevelsStep = createStep( export const adjustInventoryLevelsStep = createStep(
adjustInventoryLevelsStepId, adjustInventoryLevelsStepId,
async ( async (input: AdjustInventoryLevelsStepInput, { container }) => {
input: AdjustInventoryLevelsStepInput,
{ container }
) => {
const inventoryService = container.resolve(Modules.INVENTORY) const inventoryService = container.resolve(Modules.INVENTORY)
const locking = container.resolve(Modules.LOCKING) const locking = container.resolve(Modules.LOCKING)
const inventoryItemIds = input.map((item) => item.inventory_item_id) const inventoryItemIds = input.map((item) => item.inventory_item_id)
const lockingKeys = Array.from(new Set(inventoryItemIds))
const adjustedLevels: InventoryTypes.InventoryLevelDTO[] = const adjustedLevels: InventoryTypes.InventoryLevelDTO[] =
await locking.execute(inventoryItemIds, async () => { await locking.execute(lockingKeys, async () => {
return await inventoryService.adjustInventory( return await inventoryService.adjustInventory(
input.map((item) => { input.map((item) => {
return { return {
@@ -67,13 +67,15 @@ export const adjustInventoryLevelsStep = createStep(
(item) => item.inventory_item_id (item) => item.inventory_item_id
) )
const lockingKeys = Array.from(new Set(inventoryItemIds))
/** /**
* @todo * @todo
* The method "adjustInventory" was broken, it was receiving the * The method "adjustInventory" was broken, it was receiving the
* "inventoryItemId" and "locationId" as snake case, whereas * "inventoryItemId" and "locationId" as snake case, whereas
* the expected object needed these properties as camelCase * the expected object needed these properties as camelCase
*/ */
await locking.execute(inventoryItemIds, async () => { await locking.execute(lockingKeys, async () => {
await inventoryService.adjustInventory( await inventoryService.adjustInventory(
adjustedLevels.map((level) => { adjustedLevels.map((level) => {
return { return {

View File

@@ -6,12 +6,13 @@ import { Modules } from "@medusajs/framework/utils"
/** /**
* The data to create reservation items. * The data to create reservation items.
*/ */
export type CreateReservationsStepInput = InventoryTypes.CreateReservationItemInput[] export type CreateReservationsStepInput =
InventoryTypes.CreateReservationItemInput[]
export const createReservationsStepId = "create-reservations-step" export const createReservationsStepId = "create-reservations-step"
/** /**
* This step creates one or more reservations. * This step creates one or more reservations.
* *
* @example * @example
* const data = createReservationsStep([ * const data = createReservationsStep([
* { * {
@@ -29,7 +30,9 @@ export const createReservationsStep = createStep(
const inventoryItemIds = data.map((item) => item.inventory_item_id) const inventoryItemIds = data.map((item) => item.inventory_item_id)
const created = await locking.execute(inventoryItemIds, async () => { const lockingKeys = Array.from(new Set(inventoryItemIds))
const created = await locking.execute(lockingKeys, async () => {
return await service.createReservationItems(data) return await service.createReservationItems(data)
}) })
@@ -47,7 +50,9 @@ export const createReservationsStep = createStep(
const locking = container.resolve(Modules.LOCKING) const locking = container.resolve(Modules.LOCKING)
const inventoryItemIds = data.inventoryItemIds const inventoryItemIds = data.inventoryItemIds
await locking.execute(inventoryItemIds, async () => { const lockingKeys = Array.from(new Set(inventoryItemIds))
await locking.execute(lockingKeys, async () => {
await service.deleteReservationItems(data.reservations) await service.deleteReservationItems(data.reservations)
}) })
} }

View File

@@ -200,6 +200,11 @@ export interface UpdateCartDataDTO {
* Holds custom data in key-value pairs. * Holds custom data in key-value pairs.
*/ */
metadata?: Record<string, unknown> | null metadata?: Record<string, unknown> | null
/**
* The date and time the cart was completed.
*/
completed_at?: string | Date | null
} }
/** /**