Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(medusa): calculate sales channel availability correctly for variants #10448

Merged
merged 5 commits into from
Dec 5, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions integration-tests/http/__tests__/product/store/product.spec.ts
Original file line number Diff line number Diff line change
@@ -1329,6 +1329,150 @@ medusaIntegrationTestRunner({
)
})

it("should handle inventory items and location levels correctly", async () => {
const container = getContainer()
const channelService = container.resolve("sales_channel")
const locationService = container.resolve("stock_location")
const inventoryService = container.resolve("inventory")
const productService = container.resolve("product")
const pubKeyService = container.resolve("api_key")
const linkService = container.resolve("remoteLink")

const [channelOne, channelTwo] =
await channelService.createSalesChannels([
{ name: "Sales Channel 1" },
{ name: "Sales Channel 2" },
])

const product = await productService.createProducts({
status: "published",
title: "my prod",
options: [{ title: "color", values: ["green", "blue"] }],
variants: [
{ title: "variant one", options: { color: "green" } },
{ title: "variant two", options: { color: "blue" } },
],
})
console.log(product)
const [variantOne, variantTwo] = product.variants

const [itemOne, itemTwo, itemThree] =
await inventoryService.createInventoryItems([
{ sku: "sku-one" },
{ sku: "sku-two" },
{ sku: "sku-three" },
])

const [locationOne, locationTwo] =
await locationService.createStockLocations([
{ name: "Location One" },
{ name: "Location Two" },
])

await inventoryService.createInventoryLevels([
{
location_id: locationOne.id,
inventory_item_id: itemOne.id,
stocked_quantity: 23,
},
{
location_id: locationOne.id,
inventory_item_id: itemTwo.id,
stocked_quantity: 10,
},
{
location_id: locationTwo.id,
inventory_item_id: itemThree.id,
stocked_quantity: 5,
},
])

const [pubKeyOne, pubKeyTwo] = await pubKeyService.createApiKeys([
{ title: "pub key one", type: "publishable", created_by: "me" },
{ title: "pub key two", type: "publishable", created_by: "me" },
])

await linkService.create([
{
product: { product_id: product.id },
sales_channel: { sales_channel_id: channelOne.id },
},
{
product: { product_id: product.id },
sales_channel: { sales_channel_id: channelTwo.id },
},
{
product: { variant_id: variantOne.id },
inventory: { inventory_item_id: itemOne.id },
},
{
product: { variant_id: variantTwo.id },
inventory: { inventory_item_id: itemTwo.id },
},
{
product: { variant_id: variantTwo.id },
inventory: { inventory_item_id: itemThree.id },
data: { required_quantity: 2 },
},
{
sales_channel: { sales_channel_id: channelOne.id },
stock_location: { stock_location_id: locationOne.id },
},
{
sales_channel: { sales_channel_id: channelTwo.id },
stock_location: { stock_location_id: locationOne.id },
},
{
sales_channel: { sales_channel_id: channelTwo.id },
stock_location: { stock_location_id: locationTwo.id },
},
{
api_key: { publishable_key_id: pubKeyOne.id },
sales_channel: { sales_channel_id: channelOne.id },
},
{
api_key: { publishable_key_id: pubKeyTwo.id },
sales_channel: { sales_channel_id: channelTwo.id },
},
])

let response = await api.get(
`/store/products?fields=+variants.inventory_quantity`,
{ headers: { "x-publishable-api-key": pubKeyOne.token } }
)

expect(response.status).toEqual(200)
for (const variant of response.data.products
.map((p) => p.variants)
.flat()) {
if (variant.id === variantOne.id) {
expect(variant.inventory_quantity).toEqual(23)
} else if (variant.id === variantTwo.id) {
expect(variant.inventory_quantity).toEqual(0)
} else {
throw new Error("Unexpected variant")
}
}

response = await api.get(
`/store/products?fields=+variants.inventory_quantity`,
{ headers: { "x-publishable-api-key": pubKeyTwo.token } }
)

expect(response.status).toEqual(200)
for (const variant of response.data.products
.map((p) => p.variants)
.flat()) {
if (variant.id === variantOne.id) {
expect(variant.inventory_quantity).toEqual(23)
} else if (variant.id === variantTwo.id) {
expect(variant.inventory_quantity).toEqual(2)
} else {
throw new Error("Unexpected variant")
}
}
})

it("should list all inventory items for a variant", async () => {
let response = await api.get(
`/store/products?sales_channel_id[]=${salesChannel1.id}&fields=variants.inventory_items.inventory.location_levels.*`,
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import {
ContainerRegistrationKeys,
getVariantAvailability,
} from "@medusajs/framework/utils"

export type GetVariantAvailabilityStepInput = {
variant_ids: string[]
sales_channel_id: string
}

export const getVariantAvailabilityId = "get-variant-availability"
/**
* Computes the varaint availability for a list of variants in a given sales channel
*/
export const getVariantAvailabilityStep = createStep(
getVariantAvailabilityId,
async (data: GetVariantAvailabilityStepInput, { container }) => {
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const availability = await getVariantAvailability(query, data)
return new StepResponse(availability)
}
)
178 changes: 178 additions & 0 deletions packages/core/utils/src/product/get-variant-availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { RemoteQueryFunction } from "@medusajs/types"

/**
* Computes the varaint availability for a list of variants in a given sales channel
*
* The availability algorithm works as follows:
* 1. For each variant, we retrieve its inventory items.
* 2. We calculate the available quantity for each inventory item, considering only the stock locations associated with the given sales channel.
* 3. For each inventory item, we calculate the maximum deliverable quantity by dividing the available quantity by the quantity required for the variant.
* 4. We take the minimum of these maximum deliverable quantities across all inventory items for the variant.
* 5. This minimum value represents the overall availability of the variant in the given sales channel.
*
* The algorithm takes into account:
* - Variant inventory items: The inventory records associated with each variant.
* - Required quantities: The quantity of each inventory item required to fulfill one unit of the variant.
* - Sales channels: The specific sales channel for which we're calculating availability.
* - Stock locations: The inventory locations associated with the sales channel.
*
* @param query - The Query function
* @param data - An object containing the variant ids and the sales channel id to compute the availability for
* @returns an object containing the variant ids and their availability
*/
export async function getVariantAvailability(
query: Omit<RemoteQueryFunction, symbol>,
data: VariantAvailabilityData
): Promise<{
[variant_id: string]: {
availability: number
sales_channel_id: string
}
}> {
const { variantInventoriesMap, locationIds } = await getDataForComputation(
query,
data
)

return data.variant_ids.reduce((acc, variantId) => {
const variantInventoryItems = variantInventoriesMap.get(variantId) || []
acc[variantId] = {
availability: computeVariantAvailability(
variantInventoryItems,
locationIds,
{ requireChannelCheck: true }
),
sales_channel_id: data.sales_channel_id,
}
return acc
}, {})
}

type TotalVariantAvailabilityData = {
variant_ids: string[]
}

/**
* Computes the total availability for a list of variants across all stock locations
*
* @param query - The Query function
* @param data - An object containing the variant ids to compute the availability for
* @returns the total availability for the given variants
*/
export async function getTotalVariantAvailability(
query: Omit<RemoteQueryFunction, symbol>,
data: TotalVariantAvailabilityData
): Promise<{
[variant_id: string]: {
availability: number
}
}> {
const { variantInventoriesMap, locationIds } = await getDataForComputation(
query,
data
)

return data.variant_ids.reduce((acc, variantId) => {
const variantInventoryItems = variantInventoriesMap.get(variantId) || []
acc[variantId] = {
availability: computeVariantAvailability(
variantInventoryItems,
locationIds,
{ requireChannelCheck: false }
),
}
return acc
}, {})
}

interface VariantItems {
variant_id: string
required_quantity: number
variant: {
manage_inventory: boolean
allow_backorder: boolean
}
inventory: {
location_levels: {
location_id: string
available_quantity: number
}[]
}
}

const computeVariantAvailability = (
variantInventoryItems: VariantItems[],
channelLocationsSet: Set<string>,
{ requireChannelCheck } = { requireChannelCheck: true }
) => {
const inventoryQuantities: number[] = []

for (const link of variantInventoryItems) {
const requiredQuantity = link.required_quantity
const availableQuantity = (link.inventory?.location_levels || []).reduce(
(sum, level) => {
if (
requireChannelCheck &&
!channelLocationsSet.has(level.location_id)
) {
return sum
}

return sum + (level?.available_quantity || 0)
},
0
)

// This will give us the maximum deliverable quantities for each inventory item
const maxInventoryQuantity = Math.floor(
availableQuantity / requiredQuantity
)

inventoryQuantities.push(maxInventoryQuantity)
}

return inventoryQuantities.length ? Math.min(...inventoryQuantities) : 0
}

type VariantAvailabilityData = {
variant_ids: string[]
sales_channel_id: string
}

const getDataForComputation = async (
query: Omit<RemoteQueryFunction, symbol>,
data: { variant_ids: string[]; sales_channel_id?: string }
) => {
const { data: variantInventoryItems } = await query.graph({
entity: "product_variant_inventory_items",
fields: [
"variant_id",
"required_quantity",
"variant.manage_inventory",
"variant.allow_backorder",
"inventory.*",
"inventory.location_levels.*",
],
filters: { variant_id: data.variant_ids },
})

const variantInventoriesMap = new Map()
variantInventoryItems.forEach((link) => {
const array = variantInventoriesMap.get(link.variant_id) || []
array.push(link)
variantInventoriesMap.set(link.variant_id, array)
})

const locationIds = new Set<string>()
if (data.sales_channel_id) {
const { data: channelLocations } = await query.graph({
entity: "sales_channel_locations",
fields: ["stock_location_id"],
filters: { sales_channel_id: data.sales_channel_id },
})

channelLocations.forEach((loc) => locationIds.add(loc.stock_location_id))
}

return { variantInventoriesMap, locationIds }
}
1 change: 1 addition & 0 deletions packages/core/utils/src/product/index.ts
Original file line number Diff line number Diff line change
@@ -6,3 +6,4 @@ export enum ProductStatus {
}

export * from "./events"
export * from "./get-variant-availability"
4 changes: 2 additions & 2 deletions packages/medusa/src/api/admin/product-variants/route.ts
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ import {
refetchEntities,
} from "@medusajs/framework/http"
import { HttpTypes } from "@medusajs/framework/types"
import { wrapVariantsWithInventoryQuantity } from "../../utils/middlewares"
import { wrapVariantsWithTotalInventoryQuantity } from "../../utils/middlewares"
import { remapKeysForVariant, remapVariantResponse } from "../products/helpers"

export const GET = async (
@@ -30,7 +30,7 @@ export const GET = async (
)

if (withInventoryQuantity) {
await wrapVariantsWithInventoryQuantity(req, variants || [])
await wrapVariantsWithTotalInventoryQuantity(req, variants || [])
}

res.json({
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { wrapVariantsWithInventoryQuantity } from "../../../../utils/middlewares"
import { wrapVariantsWithTotalInventoryQuantity } from "../../../../utils/middlewares"
import { refetchEntities, refetchEntity } from "@medusajs/framework/http"
import {
remapKeysForProduct,
@@ -38,7 +38,7 @@ export const GET = async (
)

if (withInventoryQuantity) {
await wrapVariantsWithInventoryQuantity(req, variants || [])
await wrapVariantsWithTotalInventoryQuantity(req, variants || [])
}

res.json({
Loading