fix: allow backorder variants to be added to cart even if no locations (#12083)
* fix: allow backorder variants to be added to cart even if no locations * document and unit test prepareConfirmInventoryInput
This commit is contained in:
@@ -151,7 +151,7 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
|
||||
describe("POST /store/carts", () => {
|
||||
it("should succesffully create a cart", async () => {
|
||||
it("should successfully create a cart", async () => {
|
||||
const response = await api.post(
|
||||
`/store/carts`,
|
||||
{
|
||||
@@ -255,10 +255,10 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
|
||||
describe("POST /store/carts/:id/line-items", () => {
|
||||
let shippingOption, shippingOptionExpensive
|
||||
let shippingOption, shippingOptionExpensive, stockLocation
|
||||
|
||||
beforeEach(async () => {
|
||||
const stockLocation = (
|
||||
stockLocation = (
|
||||
await api.post(
|
||||
`/admin/stock-locations`,
|
||||
{ name: "test location" },
|
||||
@@ -894,6 +894,67 @@ medusaIntegrationTestRunner({
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("with manage_inventory true", () => {
|
||||
let inventoryItem
|
||||
beforeEach(async () => {
|
||||
await api.post(
|
||||
`/admin/products/${product.id}/variants/${product.variants[0].id}`,
|
||||
{ manage_inventory: true },
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
inventoryItem = (
|
||||
await api.post(
|
||||
`/admin/inventory-items`,
|
||||
{ sku: "bottle" },
|
||||
adminHeaders
|
||||
)
|
||||
).data.inventory_item
|
||||
})
|
||||
|
||||
describe("with allow_backorder true", () => {
|
||||
beforeEach(async () => {
|
||||
await api.post(
|
||||
`/admin/products/${product.id}/variants/${product.variants[0].id}`,
|
||||
{ allow_backorder: true },
|
||||
adminHeaders
|
||||
)
|
||||
})
|
||||
|
||||
it("should add item to cart even if no inventory locations", async () => {
|
||||
let response = await api.post(
|
||||
`/store/carts/${cart.id}/line-items`,
|
||||
{
|
||||
variant_id: product.variants[0].id,
|
||||
quantity: 1,
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("should add item to cart even if inventory is empty", async () => {
|
||||
await api.post(
|
||||
`/admin/inventory-items/${inventoryItem.id}/location-levels/batch`,
|
||||
{ create: [{ location_id: stockLocation.id }] },
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
let response = await api.post(
|
||||
`/store/carts/${cart.id}/line-items`,
|
||||
{
|
||||
variant_id: product.variants[0].id,
|
||||
quantity: 1,
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /store/carts/:id/line-items/:id", () => {
|
||||
|
||||
@@ -80,7 +80,7 @@ async function populateData(api: any) {
|
||||
console.log(err)
|
||||
})
|
||||
|
||||
await setTimeout(2000)
|
||||
await setTimeout(10000)
|
||||
}
|
||||
|
||||
process.env.ENABLE_INDEX_MODULE = "true"
|
||||
|
||||
@@ -153,7 +153,7 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
|
||||
// Timeout to allow indexing to finish
|
||||
await setTimeout(4000)
|
||||
await setTimeout(10000)
|
||||
|
||||
const { data: results } = await fetchAndRetry(
|
||||
async () =>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "../../../helpers/create-admin-user"
|
||||
import { setupTaxStructure } from "../fixtures"
|
||||
|
||||
jest.setTimeout(50000)
|
||||
jest.setTimeout(100000)
|
||||
|
||||
const env = { MEDUSA_FF_MEDUSA_V2: true }
|
||||
|
||||
@@ -165,6 +165,14 @@ medusaIntegrationTestRunner({
|
||||
inventory_item_id: inventoryItem.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
[Modules.PRODUCT]: {
|
||||
variant_id: product_2.variants[0].id,
|
||||
},
|
||||
[Modules.INVENTORY]: {
|
||||
inventory_item_id: inventoryItem.id,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
await setupTaxStructure(taxModule)
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
import { ConfirmVariantInventoryWorkflowInputDTO } from "@medusajs/framework/types"
|
||||
import { MedusaError } from "@medusajs/framework/utils"
|
||||
import { prepareConfirmInventoryInput } from "../prepare-confirm-inventory-input"
|
||||
|
||||
describe("prepareConfirmInventoryInput", () => {
|
||||
it("should use the quantity from the itemsToUpdate", () => {
|
||||
const input: ConfirmVariantInventoryWorkflowInputDTO = {
|
||||
sales_channel_id: "sc_1",
|
||||
variants: [
|
||||
{
|
||||
id: "pv_1",
|
||||
manage_inventory: true,
|
||||
inventory_items: [
|
||||
{
|
||||
inventory_item_id: "ii_1",
|
||||
variant_id: "pv_1",
|
||||
required_quantity: 3,
|
||||
inventory: [
|
||||
{
|
||||
location_levels: {
|
||||
stock_locations: [
|
||||
{
|
||||
id: "sl_1",
|
||||
sales_channels: [{ id: "sc_1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
variant_id: "pv_1",
|
||||
quantity: 1,
|
||||
id: "item_1",
|
||||
},
|
||||
],
|
||||
itemsToUpdate: [
|
||||
{
|
||||
variant_id: "pv_1",
|
||||
quantity: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const output = prepareConfirmInventoryInput({ input })
|
||||
|
||||
expect(output).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: "item_1",
|
||||
inventory_item_id: "ii_1",
|
||||
required_quantity: 3,
|
||||
quantity: 2, // overrides the quantity from the items array
|
||||
allow_backorder: false,
|
||||
location_ids: ["sl_1"],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should only return variants with manage_inventory set to true", () => {
|
||||
const input: ConfirmVariantInventoryWorkflowInputDTO = {
|
||||
sales_channel_id: "sc_1",
|
||||
variants: [
|
||||
{
|
||||
id: "pv_1",
|
||||
manage_inventory: true,
|
||||
inventory_items: [
|
||||
{
|
||||
inventory_item_id: "ii_1",
|
||||
variant_id: "pv_1",
|
||||
required_quantity: 1,
|
||||
inventory: [
|
||||
{
|
||||
location_levels: {
|
||||
stock_locations: [
|
||||
{
|
||||
id: "sl_1",
|
||||
sales_channels: [{ id: "sc_1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pv_2",
|
||||
manage_inventory: false,
|
||||
inventory_items: [
|
||||
{
|
||||
inventory_item_id: "ii_2",
|
||||
variant_id: "pv_2",
|
||||
required_quantity: 1,
|
||||
inventory: [
|
||||
{
|
||||
location_levels: {
|
||||
stock_locations: [
|
||||
{
|
||||
id: "sl_1",
|
||||
sales_channels: [{ id: "sc_1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
variant_id: "pv_1",
|
||||
quantity: 1,
|
||||
id: "item_1",
|
||||
},
|
||||
{
|
||||
variant_id: "pv_2",
|
||||
quantity: 1,
|
||||
id: "item_2",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const output = prepareConfirmInventoryInput({ input })
|
||||
|
||||
expect(output).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: "item_1",
|
||||
inventory_item_id: "ii_1",
|
||||
required_quantity: 1,
|
||||
quantity: 1,
|
||||
allow_backorder: false,
|
||||
location_ids: ["sl_1"],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should return all inventory items for a variant", () => {
|
||||
const input: ConfirmVariantInventoryWorkflowInputDTO = {
|
||||
sales_channel_id: "sc_1",
|
||||
variants: [
|
||||
{
|
||||
id: "pv_1",
|
||||
manage_inventory: true,
|
||||
inventory_items: [
|
||||
{
|
||||
inventory_item_id: "ii_1",
|
||||
variant_id: "pv_1",
|
||||
required_quantity: 1,
|
||||
inventory: [
|
||||
{
|
||||
location_levels: {
|
||||
stock_locations: [
|
||||
{
|
||||
id: "sl_1",
|
||||
sales_channels: [{ id: "sc_1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
inventory_item_id: "ii_2",
|
||||
variant_id: "pv_1",
|
||||
required_quantity: 2,
|
||||
inventory: [
|
||||
{
|
||||
location_levels: {
|
||||
stock_locations: [
|
||||
{
|
||||
id: "sl_1",
|
||||
sales_channels: [{ id: "sc_1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
variant_id: "pv_1",
|
||||
quantity: 1,
|
||||
id: "item_1",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const output = prepareConfirmInventoryInput({ input })
|
||||
|
||||
expect(output).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: "item_1",
|
||||
inventory_item_id: "ii_1",
|
||||
required_quantity: 1,
|
||||
quantity: 1,
|
||||
allow_backorder: false,
|
||||
location_ids: ["sl_1"],
|
||||
},
|
||||
{
|
||||
id: "item_1",
|
||||
inventory_item_id: "ii_2",
|
||||
required_quantity: 2,
|
||||
quantity: 1,
|
||||
allow_backorder: false,
|
||||
location_ids: ["sl_1"],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw an error if any variant has no stock locations linked to the sales channel", () => {
|
||||
const input = {
|
||||
sales_channel_id: "sc_1",
|
||||
variants: [
|
||||
{
|
||||
id: "pv_1",
|
||||
manage_inventory: true,
|
||||
inventory_items: [
|
||||
{
|
||||
inventory_item_id: "ii_1",
|
||||
variant_id: "pv_1",
|
||||
required_quantity: 1,
|
||||
inventory: [
|
||||
{
|
||||
location_levels: {
|
||||
stock_locations: [
|
||||
{
|
||||
id: "sl_1",
|
||||
sales_channels: [{ id: "sc_1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pv_2",
|
||||
manage_inventory: true,
|
||||
inventory_items: [
|
||||
{
|
||||
inventory_item_id: "ii_2",
|
||||
variant_id: "pv_2",
|
||||
required_quantity: 1,
|
||||
inventory: [
|
||||
{
|
||||
location_levels: {
|
||||
stock_locations: [
|
||||
{
|
||||
id: "sl_2",
|
||||
sales_channels: [{ id: "sc_2" }], // Different sales channel
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
variant_id: "pv_1",
|
||||
quantity: 1,
|
||||
id: "item_1",
|
||||
},
|
||||
{
|
||||
variant_id: "pv_2",
|
||||
quantity: 1,
|
||||
id: "item_2",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(() => prepareConfirmInventoryInput({ input })).toThrow(MedusaError)
|
||||
})
|
||||
|
||||
it("if allow_backorder is true, it should return normally even if there's no stock location for the sales channel", () => {
|
||||
const input = {
|
||||
sales_channel_id: "sc_1",
|
||||
variants: [
|
||||
{
|
||||
id: "pv_1",
|
||||
manage_inventory: true,
|
||||
allow_backorder: true,
|
||||
inventory_items: [
|
||||
{
|
||||
inventory_item_id: "ii_1",
|
||||
variant_id: "pv_1",
|
||||
required_quantity: 1,
|
||||
inventory: [
|
||||
{
|
||||
location_levels: {
|
||||
stock_locations: [
|
||||
{
|
||||
id: "sl_2",
|
||||
sales_channels: [{ id: "sc_2" }], // Different sales channel
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
variant_id: "pv_1",
|
||||
quantity: 1,
|
||||
id: "item_1",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = prepareConfirmInventoryInput({ input })
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: "item_1",
|
||||
inventory_item_id: "ii_1",
|
||||
required_quantity: 1,
|
||||
quantity: 1,
|
||||
allow_backorder: true,
|
||||
location_ids: [], // TODO: what should this be?
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should return only stock locations with availability, if any", () => {
|
||||
const input = {
|
||||
sales_channel_id: "sc_1",
|
||||
variants: [
|
||||
{
|
||||
id: "pv_1",
|
||||
manage_inventory: true,
|
||||
inventory_items: [
|
||||
{
|
||||
inventory_item_id: "ii_1",
|
||||
variant_id: "pv_1",
|
||||
required_quantity: 1,
|
||||
inventory: [
|
||||
{
|
||||
location_levels: {
|
||||
stocked_quantity: 10, // 10 - 9 = 1 < 2: no availability
|
||||
reserved_quantity: 9,
|
||||
location_id: "sl_1",
|
||||
stock_locations: [
|
||||
{
|
||||
id: "sl_1",
|
||||
sales_channels: [{ id: "sc_1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
location_levels: {
|
||||
stocked_quantity: 7, // 7 - 5 = 2 >= 2: availability
|
||||
reserved_quantity: 5,
|
||||
location_id: "sl_2",
|
||||
stock_locations: [
|
||||
{
|
||||
id: "sl_2",
|
||||
sales_channels: [{ id: "sc_1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
variant_id: "pv_1",
|
||||
quantity: 2,
|
||||
id: "item_1",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = prepareConfirmInventoryInput({ input })
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: "item_1",
|
||||
inventory_item_id: "ii_1",
|
||||
required_quantity: 1,
|
||||
quantity: 2,
|
||||
allow_backorder: false,
|
||||
location_ids: ["sl_2"], // Only includes location with available stock
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should return all locations if none has availability", () => {
|
||||
const input = {
|
||||
sales_channel_id: "sc_1",
|
||||
variants: [
|
||||
{
|
||||
id: "pv_1",
|
||||
manage_inventory: true,
|
||||
inventory_items: [
|
||||
{
|
||||
inventory_item_id: "ii_1",
|
||||
variant_id: "pv_1",
|
||||
required_quantity: 1,
|
||||
inventory: [
|
||||
{
|
||||
location_levels: {
|
||||
stocked_quantity: 1,
|
||||
reserved_quantity: 1, // 1 - 1 = 0 < 2: no availability
|
||||
location_id: "sl_1",
|
||||
stock_locations: [
|
||||
{
|
||||
id: "sl_1",
|
||||
sales_channels: [{ id: "sc_1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
location_levels: {
|
||||
stocked_quantity: 4,
|
||||
reserved_quantity: 3, // 4 - 3 = 1 < 2: no availability
|
||||
location_id: "sl_2",
|
||||
stock_locations: [
|
||||
{
|
||||
id: "sl_2",
|
||||
sales_channels: [{ id: "sc_1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
variant_id: "pv_1",
|
||||
quantity: 2,
|
||||
id: "item_1",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = prepareConfirmInventoryInput({ input })
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: "item_1",
|
||||
inventory_item_id: "ii_1",
|
||||
required_quantity: 1,
|
||||
quantity: 2,
|
||||
allow_backorder: false,
|
||||
location_ids: ["sl_1", "sl_2"], // Includes all locations since none has availability
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw an error if any variant has no inventory items", () => {
|
||||
const input = {
|
||||
sales_channel_id: "sc_1",
|
||||
variants: [
|
||||
{
|
||||
id: "pv_1",
|
||||
manage_inventory: true,
|
||||
inventory_items: [
|
||||
{
|
||||
inventory_item_id: "ii_1",
|
||||
variant_id: "pv_1",
|
||||
required_quantity: 3,
|
||||
inventory: [
|
||||
{
|
||||
location_levels: {
|
||||
stock_locations: [
|
||||
{
|
||||
id: "sl_1",
|
||||
sales_channels: [{ id: "sc_1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pv_2",
|
||||
manage_inventory: true,
|
||||
inventory_items: [], // No inventory items
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
variant_id: "pv_1",
|
||||
quantity: 1,
|
||||
id: "item_1",
|
||||
},
|
||||
{
|
||||
variant_id: "pv_2",
|
||||
quantity: 1,
|
||||
id: "item_2",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(() => prepareConfirmInventoryInput({ input })).toThrow(MedusaError)
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,17 @@ interface ConfirmInventoryItem {
|
||||
location_ids: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* This function prepares the input for the confirm inventory workflow.
|
||||
* In essesnce, it maps a list of cart items to a list of inventory items,
|
||||
* serving as a bridge between the cart and inventory domains.
|
||||
*
|
||||
* @throws {MedusaError} INVALID_DATA if any cart item is for a variant that has no inventory items.
|
||||
* @throws {MedusaError} INVALID_DATA if any cart item is for a variant with no stock locations in the input.sales_channel_id. An exception is made for variants with allow_backorder set to true.
|
||||
*
|
||||
* @returns {ConfirmInventoryPreparationInput}
|
||||
* A list of inventory items to confirm. Only inventory items for variants with managed inventory are included.
|
||||
*/
|
||||
export const prepareConfirmInventoryInput = (data: {
|
||||
input: ConfirmVariantInventoryWorkflowInputDTO
|
||||
}) => {
|
||||
@@ -45,7 +56,7 @@ export const prepareConfirmInventoryInput = (data: {
|
||||
const stockLocationIds = new Set<string>()
|
||||
const allVariants = new Map<string, any>()
|
||||
const mapLocationAvailability = new Map<string, Map<string, BigNumberInput>>()
|
||||
let hasSalesChannelStockLocation = false
|
||||
const variantsWithLocationForChannel = new Set<string>()
|
||||
let hasManagedInventory = false
|
||||
|
||||
const salesChannelId = data.input.sales_channel_id
|
||||
@@ -75,11 +86,8 @@ export const prepareConfirmInventoryInput = (data: {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!hasSalesChannelStockLocation &&
|
||||
sales_channels?.id === salesChannelId
|
||||
) {
|
||||
hasSalesChannelStockLocation = true
|
||||
if (salesChannelId && sales_channels?.id === salesChannelId) {
|
||||
variantsWithLocationForChannel.add(variants.id)
|
||||
}
|
||||
|
||||
if (location_levels && inventory_items) {
|
||||
@@ -140,11 +148,18 @@ export const prepareConfirmInventoryInput = (data: {
|
||||
return { items: [] }
|
||||
}
|
||||
|
||||
if (salesChannelId && !hasSalesChannelStockLocation) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Sales channel ${salesChannelId} is not associated with any stock location.`
|
||||
)
|
||||
if (salesChannelId) {
|
||||
for (const variant of allVariants.values()) {
|
||||
if (
|
||||
!variantsWithLocationForChannel.has(variant.id) &&
|
||||
!variant.allow_backorder
|
||||
) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Sales channel ${salesChannelId} is not associated with any stock location for variant ${variant.id}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const items = formatInventoryInput({
|
||||
|
||||
Reference in New Issue
Block a user