From 6ee0a2c1b5c4a5553f0010a9f13f71934fd690e7 Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Thu, 13 Jun 2024 13:41:54 +0200 Subject: [PATCH] feat: Variant inventory quantity in GET requests (#7701) * feat: Variant inventory quantity in GET requests * clean up * fix link name --- .../__tests__/product/admin/product.spec.ts | 85 ++++++++++++++ .../product-variant-section.tsx | 1 + .../api/admin/products/[id]/variants/route.ts | 29 +++-- .../medusa/src/api/admin/products/helpers.ts | 2 +- .../src/api/store/products/[id]/route.ts | 6 +- .../medusa/src/api/store/products/helpers.ts | 100 +--------------- .../medusa/src/api/store/products/route.ts | 2 +- .../api/utils/middlewares/products/index.ts | 2 + .../products/variant-inventory-quantity.ts | 108 ++++++++++++++++++ 9 files changed, 224 insertions(+), 111 deletions(-) create mode 100644 packages/medusa/src/api/utils/middlewares/products/variant-inventory-quantity.ts diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index 9800af534d..a294d5955e 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -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 diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx index d6f3a970a9..6444b40234 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx @@ -30,6 +30,7 @@ export const ProductVariantSection = ({ product.id, { ...searchParams, + fields: "*inventory_items.inventory.location_levels,+inventory_quantity", }, { placeholderData: keepPreviousData, diff --git a/packages/medusa/src/api/admin/products/[id]/variants/route.ts b/packages/medusa/src/api/admin/products/[id]/variants/route.ts index 8321ded3e6..f99abed49c 100644 --- a/packages/medusa/src/api/admin/products/[id]/variants/route.ts +++ b/packages/medusa/src/api/admin/products/[id]/variants/route.ts @@ -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 ) => { 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, diff --git a/packages/medusa/src/api/admin/products/helpers.ts b/packages/medusa/src/api/admin/products/helpers.ts index d0a15d228e..52f973542a 100644 --- a/packages/medusa/src/api/admin/products/helpers.ts +++ b/packages/medusa/src/api/admin/products/helpers.ts @@ -1,10 +1,10 @@ import { LinkDefinition } from "@medusajs/modules-sdk" import { BatchMethodResponse, + HttpTypes, MedusaContainer, ProductDTO, ProductVariantDTO, - HttpTypes, } from "@medusajs/types" import { ContainerRegistrationKeys, diff --git a/packages/medusa/src/api/store/products/[id]/route.ts b/packages/medusa/src/api/store/products/[id]/route.ts index 57dd6bdf25..d3488d977a 100644 --- a/packages/medusa/src/api/store/products/[id]/route.ts +++ b/packages/medusa/src/api/store/products/[id]/route.ts @@ -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, diff --git a/packages/medusa/src/api/store/products/helpers.ts b/packages/medusa/src/api/store/products/helpers.ts index 08dad71b1a..774ffd3de1 100644 --- a/packages/medusa/src/api/store/products/helpers.ts +++ b/packages/medusa/src/api/store/products/helpers.ts @@ -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() - - 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") diff --git a/packages/medusa/src/api/store/products/route.ts b/packages/medusa/src/api/store/products/route.ts index be1f8a3d9f..54d4af32e9 100644 --- a/packages/medusa/src/api/store/products/route.ts +++ b/packages/medusa/src/api/store/products/route.ts @@ -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 ( diff --git a/packages/medusa/src/api/utils/middlewares/products/index.ts b/packages/medusa/src/api/utils/middlewares/products/index.ts index a2b3d9daf0..599fbbbb9c 100644 --- a/packages/medusa/src/api/utils/middlewares/products/index.ts +++ b/packages/medusa/src/api/utils/middlewares/products/index.ts @@ -1,2 +1,4 @@ export * from "./filter-by-valid-sales-channels" export * from "./set-pricing-context" +export * from "./variant-inventory-quantity" + diff --git a/packages/medusa/src/api/utils/middlewares/products/variant-inventory-quantity.ts b/packages/medusa/src/api/utils/middlewares/products/variant-inventory-quantity.ts new file mode 100644 index 0000000000..cd7a7399fc --- /dev/null +++ b/packages/medusa/src/api/utils/middlewares/products/variant-inventory-quantity.ts @@ -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, + }) + } +}