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:
Oli Juhl
2024-06-13 13:41:54 +02:00
committed by GitHub
parent d2a5201eeb
commit 6ee0a2c1b5
9 changed files with 224 additions and 111 deletions

View File

@@ -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

View File

@@ -30,6 +30,7 @@ export const ProductVariantSection = ({
product.id,
{
...searchParams,
fields: "*inventory_items.inventory.location_levels,+inventory_quantity",
},
{
placeholderData: keepPreviousData,

View File

@@ -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,

View File

@@ -1,10 +1,10 @@
import { LinkDefinition } from "@medusajs/modules-sdk"
import {
BatchMethodResponse,
HttpTypes,
MedusaContainer,
ProductDTO,
ProductVariantDTO,
HttpTypes,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,

View File

@@ -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>,

View File

@@ -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")

View File

@@ -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 (

View File

@@ -1,2 +1,4 @@
export * from "./filter-by-valid-sales-channels"
export * from "./set-pricing-context"
export * from "./variant-inventory-quantity"

View File

@@ -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,
})
}
}