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:
@@ -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", () => {
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user