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:
1 parent
d2a5201eeb
commit
6ee0a2c1b5
9 files changed
+224
-111
No files matched your search
@@ -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", () => {
|
describe("POST /admin/products", () => {
|
||||||
it("creates a product", async () => {
|
it("creates a product", async () => {
|
||||||
const response = await api
|
const response = await api
|
||||||
|
|||||||
+1
@@ -30,6 +30,7 @@ export const ProductVariantSection = ({
|
|||||||
product.id,
|
product.id,
|
||||||
{
|
{
|
||||||
...searchParams,
|
...searchParams,
|
||||||
|
fields: "*inventory_items.inventory.location_levels,+inventory_quantity",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
|
import { createProductVariantsWorkflow } from "@medusajs/core-flows"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
AuthenticatedMedusaRequest,
|
AuthenticatedMedusaRequest,
|
||||||
MedusaResponse,
|
MedusaResponse,
|
||||||
} from "../../../../../types/routing"
|
} from "../../../../../types/routing"
|
||||||
|
import { wrapVariantsWithInventoryQuantity } from "../../../../utils/middlewares"
|
||||||
import { createProductVariantsWorkflow } from "@medusajs/core-flows"
|
import {
|
||||||
|
refetchEntities,
|
||||||
|
refetchEntity,
|
||||||
|
} from "../../../../utils/refetch-entity"
|
||||||
import {
|
import {
|
||||||
remapKeysForProduct,
|
remapKeysForProduct,
|
||||||
remapKeysForVariant,
|
remapKeysForVariant,
|
||||||
remapProductResponse,
|
remapProductResponse,
|
||||||
remapVariantResponse,
|
remapVariantResponse,
|
||||||
} from "../../helpers"
|
} from "../../helpers"
|
||||||
import { HttpTypes } from "@medusajs/types"
|
|
||||||
import {
|
|
||||||
refetchEntities,
|
|
||||||
refetchEntity,
|
|
||||||
} from "../../../../utils/refetch-entity"
|
|
||||||
|
|
||||||
export const GET = async (
|
export const GET = async (
|
||||||
req: AuthenticatedMedusaRequest,
|
req: AuthenticatedMedusaRequest,
|
||||||
res: MedusaResponse<HttpTypes.AdminProductVariantListResponse>
|
res: MedusaResponse<HttpTypes.AdminProductVariantListResponse>
|
||||||
) => {
|
) => {
|
||||||
const productId = req.params.id
|
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(
|
const { rows: variants, metadata } = await refetchEntities(
|
||||||
"variant",
|
"variant",
|
||||||
{ ...req.filterableFields, product_id: productId },
|
{ ...req.filterableFields, product_id: productId },
|
||||||
@@ -29,6 +40,10 @@ export const GET = async (
|
|||||||
req.remoteQueryConfig.pagination
|
req.remoteQueryConfig.pagination
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (withInventoryQuantity) {
|
||||||
|
await wrapVariantsWithInventoryQuantity(req, variants || [])
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
variants: variants.map(remapVariantResponse),
|
variants: variants.map(remapVariantResponse),
|
||||||
count: metadata.count,
|
count: metadata.count,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { LinkDefinition } from "@medusajs/modules-sdk"
|
import { LinkDefinition } from "@medusajs/modules-sdk"
|
||||||
import {
|
import {
|
||||||
BatchMethodResponse,
|
BatchMethodResponse,
|
||||||
|
HttpTypes,
|
||||||
MedusaContainer,
|
MedusaContainer,
|
||||||
ProductDTO,
|
ProductDTO,
|
||||||
ProductVariantDTO,
|
ProductVariantDTO,
|
||||||
HttpTypes,
|
|
||||||
} from "@medusajs/types"
|
} from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
ContainerRegistrationKeys,
|
ContainerRegistrationKeys,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { isPresent } from "@medusajs/utils"
|
import { MedusaError, isPresent } from "@medusajs/utils"
|
||||||
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
|
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 { StoreGetProductsParamsType } from "../validators"
|
||||||
import { MedusaError } from "@medusajs/utils"
|
|
||||||
|
|
||||||
export const GET = async (
|
export const GET = async (
|
||||||
req: MedusaRequest<StoreGetProductsParamsType>,
|
req: MedusaRequest<StoreGetProductsParamsType>,
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import { InventoryItemDTO, MedusaContainer } from "@medusajs/types"
|
import { MedusaContainer } from "@medusajs/types"
|
||||||
import {
|
|
||||||
ContainerRegistrationKeys,
|
|
||||||
MedusaError,
|
|
||||||
remoteQueryObjectFromString,
|
|
||||||
} from "@medusajs/utils"
|
|
||||||
import { MedusaRequest } from "../../../types/routing"
|
import { MedusaRequest } from "../../../types/routing"
|
||||||
import { refetchEntities, refetchEntity } from "../../utils/refetch-entity"
|
import { refetchEntities, refetchEntity } from "../../utils/refetch-entity"
|
||||||
|
|
||||||
@@ -15,99 +10,6 @@ export const refetchProduct = async (
|
|||||||
return await refetchEntity("product", idOrFilter, scope, fields)
|
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) => {
|
export const maybeApplyStockLocationId = async (req: MedusaRequest, ctx) => {
|
||||||
const withInventoryQuantity = req.remoteQueryConfig.fields.some((field) =>
|
const withInventoryQuantity = req.remoteQueryConfig.fields.some((field) =>
|
||||||
field.includes("variants.inventory_quantity")
|
field.includes("variants.inventory_quantity")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
remoteQueryObjectFromString,
|
remoteQueryObjectFromString,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
|
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
|
||||||
import { wrapVariantsWithInventoryQuantity } from "./helpers"
|
import { wrapVariantsWithInventoryQuantity } from "../../utils/middlewares"
|
||||||
import { StoreGetProductsParamsType } from "./validators"
|
import { StoreGetProductsParamsType } from "./validators"
|
||||||
|
|
||||||
export const GET = async (
|
export const GET = async (
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
export * from "./filter-by-valid-sales-channels"
|
export * from "./filter-by-valid-sales-channels"
|
||||||
export * from "./set-pricing-context"
|
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