feat: Variant inventory quantity in GET requests (#7701)
* feat: Variant inventory quantity in GET requests * clean up * fix link name
This commit is contained in:
@@ -994,6 +994,91 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /admin/products/:id/variants", () => {
|
||||
it("should get product variants with inventory quantity computed", async () => {
|
||||
const stockLocation = (
|
||||
await api.post(
|
||||
`/admin/stock-locations`,
|
||||
{ name: "loc" },
|
||||
adminHeaders
|
||||
)
|
||||
).data.stock_location
|
||||
|
||||
const inventoryItem1 = (
|
||||
await api.post(
|
||||
`/admin/inventory-items`,
|
||||
{ sku: "inventory-1" },
|
||||
adminHeaders
|
||||
)
|
||||
).data.inventory_item
|
||||
|
||||
const inventoryItem2 = (
|
||||
await api.post(
|
||||
`/admin/inventory-items`,
|
||||
{ sku: "inventory-2" },
|
||||
adminHeaders
|
||||
)
|
||||
).data.inventory_item
|
||||
|
||||
await api.post(
|
||||
`/admin/inventory-items/${inventoryItem1.id}/location-levels`,
|
||||
{
|
||||
location_id: stockLocation.id,
|
||||
stocked_quantity: 8,
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
await api.post(
|
||||
`/admin/inventory-items/${inventoryItem2.id}/location-levels`,
|
||||
{
|
||||
location_id: stockLocation.id,
|
||||
stocked_quantity: 4,
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const payload = {
|
||||
title: "Test product - 1",
|
||||
handle: "test-1",
|
||||
variants: [
|
||||
{
|
||||
title: "Custom inventory 1",
|
||||
prices: [{ currency_code: "usd", amount: 100 }],
|
||||
manage_inventory: true,
|
||||
inventory_items: [
|
||||
{
|
||||
inventory_item_id: inventoryItem1.id,
|
||||
required_quantity: 4,
|
||||
},
|
||||
{
|
||||
inventory_item_id: inventoryItem2.id,
|
||||
required_quantity: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const product = (
|
||||
await api.post(`/admin/products`, payload, adminHeaders)
|
||||
).data.product
|
||||
|
||||
const variants = (
|
||||
await api.get(
|
||||
`/admin/products/${product.id}/variants?fields=%2Binventory_quantity`,
|
||||
adminHeaders
|
||||
)
|
||||
).data.variants
|
||||
|
||||
expect(variants).toEqual([
|
||||
expect.objectContaining({
|
||||
inventory_quantity: 2,
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /admin/products", () => {
|
||||
it("creates a product", async () => {
|
||||
const response = await api
|
||||
|
||||
@@ -30,6 +30,7 @@ export const ProductVariantSection = ({
|
||||
product.id,
|
||||
{
|
||||
...searchParams,
|
||||
fields: "*inventory_items.inventory.location_levels,+inventory_quantity",
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
import { createProductVariantsWorkflow } from "@medusajs/core-flows"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import {
|
||||
AuthenticatedMedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "../../../../../types/routing"
|
||||
|
||||
import { createProductVariantsWorkflow } from "@medusajs/core-flows"
|
||||
import { wrapVariantsWithInventoryQuantity } from "../../../../utils/middlewares"
|
||||
import {
|
||||
refetchEntities,
|
||||
refetchEntity,
|
||||
} from "../../../../utils/refetch-entity"
|
||||
import {
|
||||
remapKeysForProduct,
|
||||
remapKeysForVariant,
|
||||
remapProductResponse,
|
||||
remapVariantResponse,
|
||||
} from "../../helpers"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import {
|
||||
refetchEntities,
|
||||
refetchEntity,
|
||||
} from "../../../../utils/refetch-entity"
|
||||
|
||||
export const GET = async (
|
||||
req: AuthenticatedMedusaRequest,
|
||||
res: MedusaResponse<HttpTypes.AdminProductVariantListResponse>
|
||||
) => {
|
||||
const productId = req.params.id
|
||||
|
||||
const withInventoryQuantity = req.remoteQueryConfig.fields.some((field) =>
|
||||
field.includes("inventory_quantity")
|
||||
)
|
||||
|
||||
if (withInventoryQuantity) {
|
||||
req.remoteQueryConfig.fields = req.remoteQueryConfig.fields.filter(
|
||||
(field) => !field.includes("inventory_quantity")
|
||||
)
|
||||
}
|
||||
|
||||
const { rows: variants, metadata } = await refetchEntities(
|
||||
"variant",
|
||||
{ ...req.filterableFields, product_id: productId },
|
||||
@@ -29,6 +40,10 @@ export const GET = async (
|
||||
req.remoteQueryConfig.pagination
|
||||
)
|
||||
|
||||
if (withInventoryQuantity) {
|
||||
await wrapVariantsWithInventoryQuantity(req, variants || [])
|
||||
}
|
||||
|
||||
res.json({
|
||||
variants: variants.map(remapVariantResponse),
|
||||
count: metadata.count,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { LinkDefinition } from "@medusajs/modules-sdk"
|
||||
import {
|
||||
BatchMethodResponse,
|
||||
HttpTypes,
|
||||
MedusaContainer,
|
||||
ProductDTO,
|
||||
ProductVariantDTO,
|
||||
HttpTypes,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isPresent } from "@medusajs/utils"
|
||||
import { MedusaError, isPresent } from "@medusajs/utils"
|
||||
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
|
||||
import { refetchProduct, wrapVariantsWithInventoryQuantity } from "../helpers"
|
||||
import { wrapVariantsWithInventoryQuantity } from "../../../utils/middlewares"
|
||||
import { refetchProduct } from "../helpers"
|
||||
import { StoreGetProductsParamsType } from "../validators"
|
||||
import { MedusaError } from "@medusajs/utils"
|
||||
|
||||
export const GET = async (
|
||||
req: MedusaRequest<StoreGetProductsParamsType>,
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { InventoryItemDTO, MedusaContainer } from "@medusajs/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
MedusaError,
|
||||
remoteQueryObjectFromString,
|
||||
} from "@medusajs/utils"
|
||||
import { MedusaContainer } from "@medusajs/types"
|
||||
import { MedusaRequest } from "../../../types/routing"
|
||||
import { refetchEntities, refetchEntity } from "../../utils/refetch-entity"
|
||||
|
||||
@@ -15,99 +10,6 @@ export const refetchProduct = async (
|
||||
return await refetchEntity("product", idOrFilter, scope, fields)
|
||||
}
|
||||
|
||||
type VariantInventoryType = {
|
||||
variant_id: string
|
||||
variant: { manage_inventory: boolean }
|
||||
required_quantity: number
|
||||
inventory: InventoryItemDTO
|
||||
}
|
||||
|
||||
export const wrapVariantsWithInventoryQuantity = async (
|
||||
req: MedusaRequest,
|
||||
variants: {
|
||||
id: string
|
||||
inventory_quantity?: number
|
||||
manage_inventory?: boolean
|
||||
}[]
|
||||
) => {
|
||||
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
const variantIds = variants.map((variant) => variant.id).flat(1)
|
||||
|
||||
if (!variantIds.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!req.context?.stock_location_id?.length) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Stock locations are required to compute inventory`
|
||||
)
|
||||
}
|
||||
|
||||
const linkQuery = remoteQueryObjectFromString({
|
||||
entryPoint: "product_variant_inventory_item",
|
||||
variables: {
|
||||
filters: { variant_id: variantIds },
|
||||
inventory: {
|
||||
filters: {
|
||||
location_levels: {
|
||||
location_id: req.context?.stock_location_id || [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
"variant_id",
|
||||
"variant.manage_inventory",
|
||||
"required_quantity",
|
||||
"inventory.*",
|
||||
"inventory.location_levels.*",
|
||||
],
|
||||
})
|
||||
|
||||
const links: VariantInventoryType[] = await remoteQuery(linkQuery)
|
||||
const variantInventoriesMap = new Map<string, VariantInventoryType[]>()
|
||||
|
||||
links.forEach((link) => {
|
||||
const array: VariantInventoryType[] =
|
||||
variantInventoriesMap.get(link.variant_id) || []
|
||||
|
||||
array.push(link)
|
||||
|
||||
variantInventoriesMap.set(link.variant_id, array)
|
||||
})
|
||||
|
||||
for (const variant of variants || []) {
|
||||
if (!variant.manage_inventory) {
|
||||
continue
|
||||
}
|
||||
|
||||
const links = variantInventoriesMap.get(variant.id) || []
|
||||
const inventoryQuantities: number[] = []
|
||||
|
||||
for (const link of links) {
|
||||
const requiredQuantity = link.required_quantity
|
||||
const availableQuantity = (link.inventory?.location_levels || []).reduce(
|
||||
(sum, level) => 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)
|
||||
}
|
||||
|
||||
// Since each of these inventory items need to be available to perform an order,
|
||||
// we pick the smallest of the deliverable quantities as the total inventory quantity.
|
||||
variant.inventory_quantity = inventoryQuantities.length
|
||||
? Math.min(...inventoryQuantities)
|
||||
: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const maybeApplyStockLocationId = async (req: MedusaRequest, ctx) => {
|
||||
const withInventoryQuantity = req.remoteQueryConfig.fields.some((field) =>
|
||||
field.includes("variants.inventory_quantity")
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
remoteQueryObjectFromString,
|
||||
} from "@medusajs/utils"
|
||||
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
|
||||
import { wrapVariantsWithInventoryQuantity } from "./helpers"
|
||||
import { wrapVariantsWithInventoryQuantity } from "../../utils/middlewares"
|
||||
import { StoreGetProductsParamsType } from "./validators"
|
||||
|
||||
export const GET = async (
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from "./filter-by-valid-sales-channels"
|
||||
export * from "./set-pricing-context"
|
||||
export * from "./variant-inventory-quantity"
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
LINKS,
|
||||
remoteQueryObjectFromString,
|
||||
} from "@medusajs/utils"
|
||||
import { MedusaRequest } from "../../../../types/routing"
|
||||
|
||||
export async function getVariantInventoryItems({
|
||||
req,
|
||||
variantIds,
|
||||
additionalFilters = {},
|
||||
asMap = true,
|
||||
}) {
|
||||
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
|
||||
const linkQuery = remoteQueryObjectFromString({
|
||||
service: LINKS.ProductVariantInventoryItem,
|
||||
variables: {
|
||||
filters: {
|
||||
variant_id: variantIds,
|
||||
},
|
||||
...additionalFilters,
|
||||
},
|
||||
fields: [
|
||||
"variant_id",
|
||||
"variant.manage_inventory",
|
||||
"required_quantity",
|
||||
"inventory.*",
|
||||
"inventory.location_levels.*",
|
||||
],
|
||||
})
|
||||
|
||||
const links = await remoteQuery(linkQuery)
|
||||
|
||||
if (!asMap) {
|
||||
return links
|
||||
}
|
||||
|
||||
const variantInventoriesMap = new Map()
|
||||
|
||||
links.forEach((link) => {
|
||||
const array = variantInventoriesMap.get(link.variant_id) || []
|
||||
|
||||
array.push(link)
|
||||
|
||||
variantInventoriesMap.set(link.variant_id, array)
|
||||
})
|
||||
|
||||
return variantInventoriesMap
|
||||
}
|
||||
|
||||
export async function computeVariantInventoryQuantity({
|
||||
variantInventoryItems,
|
||||
}) {
|
||||
const links = variantInventoryItems
|
||||
const inventoryQuantities: number[] = []
|
||||
|
||||
for (const link of links) {
|
||||
const requiredQuantity = link.required_quantity
|
||||
const availableQuantity = (link.inventory?.location_levels || []).reduce(
|
||||
(sum, level) => 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)
|
||||
}
|
||||
|
||||
// Since each of these inventory items need to be available to perform an order,
|
||||
// we pick the smallest of the deliverable quantities as the total inventory quantity.
|
||||
return inventoryQuantities.length ? Math.min(...inventoryQuantities) : 0
|
||||
}
|
||||
|
||||
export const wrapVariantsWithInventoryQuantity = async (
|
||||
req: MedusaRequest,
|
||||
variants: {
|
||||
id: string
|
||||
inventory_quantity?: number
|
||||
manage_inventory?: boolean
|
||||
}[]
|
||||
) => {
|
||||
variants ??= []
|
||||
const variantIds = variants.map((variant) => variant.id).flat(1)
|
||||
|
||||
if (!variantIds.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const variantInventoriesMap = await getVariantInventoryItems({
|
||||
req,
|
||||
variantIds,
|
||||
})
|
||||
|
||||
for (const variant of variants) {
|
||||
if (!variant.manage_inventory) {
|
||||
continue
|
||||
}
|
||||
|
||||
const links = variantInventoriesMap.get(variant.id) || []
|
||||
variant.inventory_quantity = await computeVariantInventoryQuantity({
|
||||
variantInventoryItems: links,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user