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 () => {
|
||||
const response = await api.post(
|
||||
`/store/carts/${cart.id}/complete`,
|
||||
{ variant_id: product.variants[0].id, quantity: 1 },
|
||||
{},
|
||||
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", () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { MathBN, Modules } from "@medusajs/framework/utils"
|
||||
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||
import { BigNumberInput } from "@medusajs/types"
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
* variant's inventory.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* const data = reserveInventoryStep({
|
||||
* "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)
|
||||
})
|
||||
|
||||
@@ -98,7 +99,9 @@ export const reserveInventoryStep = createStep(
|
||||
const locking = container.resolve(Modules.LOCKING)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export type UpdateCartsStepInput = UpdateCartWorkflowInputDTO[]
|
||||
export const updateCartsStepId = "update-carts"
|
||||
/**
|
||||
* This step updates a cart.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* const data = updateCartsStep([{
|
||||
* id: "cart_123",
|
||||
@@ -57,6 +57,7 @@ export const updateCartsStep = createStep(
|
||||
email: cart.email,
|
||||
currency_code: cart.currency_code,
|
||||
metadata: cart.metadata,
|
||||
completed_at: cart.completed_at,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -73,8 +73,10 @@ export const prepareConfirmInventoryInput = (data: {
|
||||
|
||||
if (inventory_items) {
|
||||
const inventoryItemId = inventory_items.inventory_item_id
|
||||
if (!productVariantInventoryItems.has(inventoryItemId)) {
|
||||
productVariantInventoryItems.set(inventoryItemId, {
|
||||
const mapKey = `${inventoryItemId}-${inventory_items.variant_id}`
|
||||
|
||||
if (!productVariantInventoryItems.has(mapKey)) {
|
||||
productVariantInventoryItems.set(mapKey, {
|
||||
variant_id: inventory_items.variant_id,
|
||||
inventory_item_id: inventoryItemId,
|
||||
required_quantity: inventory_items.required_quantity,
|
||||
|
||||
@@ -6,14 +6,15 @@ import { MathBN, Modules } from "@medusajs/framework/utils"
|
||||
/**
|
||||
* The data to adjust the inventory levels.
|
||||
*/
|
||||
export type AdjustInventoryLevelsStepInput = InventoryTypes.BulkAdjustInventoryLevelInput[]
|
||||
export type AdjustInventoryLevelsStepInput =
|
||||
InventoryTypes.BulkAdjustInventoryLevelInput[]
|
||||
|
||||
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
|
||||
* subtract from the stocked quantity.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* const data = adjustInventoryLevelsStep([
|
||||
* {
|
||||
@@ -25,16 +26,15 @@ export const adjustInventoryLevelsStepId = "adjust-inventory-levels-step"
|
||||
*/
|
||||
export const adjustInventoryLevelsStep = createStep(
|
||||
adjustInventoryLevelsStepId,
|
||||
async (
|
||||
input: AdjustInventoryLevelsStepInput,
|
||||
{ container }
|
||||
) => {
|
||||
async (input: AdjustInventoryLevelsStepInput, { container }) => {
|
||||
const inventoryService = container.resolve(Modules.INVENTORY)
|
||||
const locking = container.resolve(Modules.LOCKING)
|
||||
const inventoryItemIds = input.map((item) => item.inventory_item_id)
|
||||
|
||||
const lockingKeys = Array.from(new Set(inventoryItemIds))
|
||||
|
||||
const adjustedLevels: InventoryTypes.InventoryLevelDTO[] =
|
||||
await locking.execute(inventoryItemIds, async () => {
|
||||
await locking.execute(lockingKeys, async () => {
|
||||
return await inventoryService.adjustInventory(
|
||||
input.map((item) => {
|
||||
return {
|
||||
@@ -67,13 +67,15 @@ export const adjustInventoryLevelsStep = createStep(
|
||||
(item) => item.inventory_item_id
|
||||
)
|
||||
|
||||
const lockingKeys = Array.from(new Set(inventoryItemIds))
|
||||
|
||||
/**
|
||||
* @todo
|
||||
* The method "adjustInventory" was broken, it was receiving the
|
||||
* "inventoryItemId" and "locationId" as snake case, whereas
|
||||
* the expected object needed these properties as camelCase
|
||||
*/
|
||||
await locking.execute(inventoryItemIds, async () => {
|
||||
await locking.execute(lockingKeys, async () => {
|
||||
await inventoryService.adjustInventory(
|
||||
adjustedLevels.map((level) => {
|
||||
return {
|
||||
|
||||
@@ -6,12 +6,13 @@ import { Modules } from "@medusajs/framework/utils"
|
||||
/**
|
||||
* The data to create reservation items.
|
||||
*/
|
||||
export type CreateReservationsStepInput = InventoryTypes.CreateReservationItemInput[]
|
||||
export type CreateReservationsStepInput =
|
||||
InventoryTypes.CreateReservationItemInput[]
|
||||
|
||||
export const createReservationsStepId = "create-reservations-step"
|
||||
/**
|
||||
* This step creates one or more reservations.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* const data = createReservationsStep([
|
||||
* {
|
||||
@@ -29,7 +30,9 @@ export const createReservationsStep = createStep(
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -47,7 +50,9 @@ export const createReservationsStep = createStep(
|
||||
const locking = container.resolve(Modules.LOCKING)
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -200,6 +200,11 @@ export interface UpdateCartDataDTO {
|
||||
* Holds custom data in key-value pairs.
|
||||
*/
|
||||
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