diff --git a/integration-tests/modules/__tests__/product/store/index.spec.ts b/integration-tests/modules/__tests__/product/store/index.spec.ts index 9001bc78e1..5a9a533c50 100644 --- a/integration-tests/modules/__tests__/product/store/index.spec.ts +++ b/integration-tests/modules/__tests__/product/store/index.spec.ts @@ -1,5 +1,10 @@ import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { ApiKeyType, ProductStatus } from "@medusajs/utils" +import { + ApiKeyType, + ContainerRegistrationKeys, + Modules, + ProductStatus, +} from "@medusajs/utils" import { medusaIntegrationTestRunner } from "medusa-test-utils" import { createAdminUser } from "../../../../helpers/create-admin-user" import { createDefaultRuleTypes } from "../../../helpers/create-default-rule-types" @@ -89,6 +94,7 @@ medusaIntegrationTestRunner({ variants: [ { title: "test variant 1", + manage_inventory: true, prices: [{ amount: 3000, currency_code: "usd" }], }, ], @@ -96,7 +102,9 @@ medusaIntegrationTestRunner({ ;[product2, [variant2]] = await createProducts({ title: "test product 2 uniquely", status: ProductStatus.PUBLISHED, - variants: [{ title: "test variant 2", prices: [] }], + variants: [ + { title: "test variant 2", manage_inventory: false, prices: [] }, + ], }) ;[product3, [variant3]] = await createProducts({ title: "product not in price list", @@ -356,6 +364,215 @@ medusaIntegrationTestRunner({ ]) ) }) + + describe("with inventory items", () => { + let location1 + let location2 + let inventoryItem1 + let inventoryItem2 + let salesChannel1 + let publishableKey1 + + beforeEach(async () => { + location1 = ( + await api.post( + `/admin/stock-locations`, + { name: "test location" }, + adminHeaders + ) + ).data.stock_location + + location2 = ( + await api.post( + `/admin/stock-locations`, + { name: "test location 2" }, + adminHeaders + ) + ).data.stock_location + + salesChannel1 = await createSalesChannel( + { name: "sales channel test" }, + [product.id, product2.id] + ) + + const api1Res = await api.post( + `/admin/api-keys`, + { title: "Test publishable KEY", type: ApiKeyType.PUBLISHABLE }, + adminHeaders + ) + + publishableKey1 = api1Res.data.api_key + + await api.post( + `/admin/api-keys/${publishableKey1.id}/sales-channels`, + { add: [salesChannel1.id] }, + adminHeaders + ) + + inventoryItem1 = ( + await api.post( + `/admin/inventory-items`, + { sku: "test-sku" }, + adminHeaders + ) + ).data.inventory_item + + inventoryItem2 = ( + await api.post( + `/admin/inventory-items`, + { sku: "test-sku-2" }, + adminHeaders + ) + ).data.inventory_item + + inventoryItem1 = ( + await api.post( + `/admin/inventory-items/${inventoryItem1.id}/location-levels`, + { + location_id: location1.id, + stocked_quantity: 20, + }, + adminHeaders + ) + ).data.inventory_item + + inventoryItem2 = ( + await api.post( + `/admin/inventory-items/${inventoryItem2.id}/location-levels`, + { + location_id: location2.id, + stocked_quantity: 30, + }, + adminHeaders + ) + ).data.inventory_item + + const remoteLink = appContainer.resolve( + ContainerRegistrationKeys.REMOTE_LINK + ) + + // TODO: Missing API endpoint. Remove this when its available + await remoteLink.create([ + { + [Modules.SALES_CHANNEL]: { sales_channel_id: salesChannel1.id }, + [Modules.STOCK_LOCATION]: { stock_location_id: location1.id }, + }, + { + [Modules.SALES_CHANNEL]: { sales_channel_id: salesChannel1.id }, + [Modules.STOCK_LOCATION]: { stock_location_id: location2.id }, + }, + ]) + }) + + it("should list all inventory items for a variant", async () => { + const remoteLink = appContainer.resolve( + ContainerRegistrationKeys.REMOTE_LINK + ) + + // TODO: Missing API endpoint. Remove this when its available + await remoteLink.create([ + { + [Modules.PRODUCT]: { variant_id: variant.id }, + [Modules.INVENTORY]: { inventory_item_id: inventoryItem1.id }, + data: { required_quantity: 20 }, + }, + { + [Modules.PRODUCT]: { variant_id: variant.id }, + [Modules.INVENTORY]: { inventory_item_id: inventoryItem2.id }, + data: { required_quantity: 20 }, + }, + ]) + + let response = await api.get( + `/store/products?sales_channel_id[]=${salesChannel1.id}&fields=variants.inventory_items.inventory.location_levels.*`, + { headers: { "x-publishable-api-key": publishableKey1.token } } + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(2) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: product.id, + variants: expect.arrayContaining([ + expect.objectContaining({ + inventory_items: expect.arrayContaining([ + expect.objectContaining({ + inventory_item_id: inventoryItem1.id, + }), + expect.objectContaining({ + inventory_item_id: inventoryItem2.id, + }), + ]), + }), + ]), + }), + ]) + ) + }) + + it("should return inventory quantity when variant's manage_inventory is true", async () => { + const remoteLink = appContainer.resolve( + ContainerRegistrationKeys.REMOTE_LINK + ) + + // TODO: Missing API endpoint. Remove this when its available + await remoteLink.create([ + { + [Modules.PRODUCT]: { variant_id: variant.id }, + [Modules.INVENTORY]: { inventory_item_id: inventoryItem1.id }, + data: { required_quantity: 20 }, + }, + { + [Modules.PRODUCT]: { variant_id: variant.id }, + [Modules.INVENTORY]: { inventory_item_id: inventoryItem2.id }, + data: { required_quantity: 20 }, + }, + ]) + + let response = await api.get( + `/store/products?sales_channel_id[]=${salesChannel1.id}&fields=%2bvariants.inventory_quantity`, + { + headers: { "x-publishable-api-key": publishableKey1.token }, + } + ) + + const product1Res = response.data.products.find( + (p) => p.id === product.id + ) + + const product2Res = response.data.products.find( + (p) => p.id === product2.id + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(2) + expect(product1Res).toEqual( + expect.objectContaining({ + id: product.id, + variants: expect.arrayContaining([ + expect.objectContaining({ + inventory_quantity: 1, + manage_inventory: true, + }), + ]), + }) + ) + expect(product2Res).toEqual( + expect.objectContaining({ + id: product2.id, + variants: expect.arrayContaining([ + expect.objectContaining({ + manage_inventory: false, + }), + ]), + }) + ) + expect(product2Res.variants[0].inventory_quantity).toEqual( + undefined + ) + }) + }) }) describe("GET /store/products/:id", () => { diff --git a/packages/core/types/src/inventory/common.ts b/packages/core/types/src/inventory/common.ts index d8a7b7bab9..a7d5f846a7 100644 --- a/packages/core/types/src/inventory/common.ts +++ b/packages/core/types/src/inventory/common.ts @@ -88,6 +88,7 @@ export type InventoryItemDTO = { created_at: string | Date updated_at: string | Date deleted_at: string | Date | null + location_levels?: InventoryLevelDTO[] } /** @@ -195,6 +196,7 @@ export type InventoryLevelDTO = { location_id: string stocked_quantity: number reserved_quantity: number + available_quantity: number incoming_quantity: number metadata: Record | null created_at: string | Date diff --git a/packages/medusa/src/api/store/products/[id]/route.ts b/packages/medusa/src/api/store/products/[id]/route.ts index d359bb2f28..e7e4fae94c 100644 --- a/packages/medusa/src/api/store/products/[id]/route.ts +++ b/packages/medusa/src/api/store/products/[id]/route.ts @@ -1,12 +1,22 @@ import { isPresent } from "@medusajs/utils" import { MedusaRequest, MedusaResponse } from "../../../../types/routing" -import { refetchProduct } from "../helpers" +import { refetchProduct, wrapVariantsWithInventoryQuantity } from "../helpers" import { StoreGetProductsParamsType } from "../validators" export const GET = async ( req: MedusaRequest, res: MedusaResponse ) => { + const withInventoryQuantity = req.remoteQueryConfig.fields.some((field) => + field.includes("variants.inventory_quantity") + ) + + if (withInventoryQuantity) { + req.remoteQueryConfig.fields = req.remoteQueryConfig.fields.filter( + (field) => !field.includes("variants.inventory_quantity") + ) + } + const filters: object = { id: req.params.id, ...req.filterableFields, @@ -24,5 +34,9 @@ export const GET = async ( req.remoteQueryConfig.fields ) + if (withInventoryQuantity) { + await wrapVariantsWithInventoryQuantity(req, product.variants || []) + } + res.json({ product }) } diff --git a/packages/medusa/src/api/store/products/helpers.ts b/packages/medusa/src/api/store/products/helpers.ts index b18f8ed74a..d5ea5d789e 100644 --- a/packages/medusa/src/api/store/products/helpers.ts +++ b/packages/medusa/src/api/store/products/helpers.ts @@ -1,5 +1,11 @@ -import { MedusaContainer } from "@medusajs/types" -import { refetchEntity } from "../../utils/refetch-entity" +import { InventoryItemDTO, MedusaContainer } from "@medusajs/types" +import { + ContainerRegistrationKeys, + MedusaError, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { MedusaRequest } from "../../../types/routing" +import { refetchEntities, refetchEntity } from "../../utils/refetch-entity" export const refetchProduct = async ( idOrFilter: string | object, @@ -8,3 +14,117 @@ 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") + ) + + if (!withInventoryQuantity) { + return + } + + const salesChannelId = req.filterableFields.sales_channel_id || [] + + const entities = await refetchEntities( + "sales_channel_location", + { sales_channel_id: salesChannelId }, + req.scope, + ["stock_location_id"] + ) + + return entities.map((entity) => entity.stock_location_id) +} diff --git a/packages/medusa/src/api/store/products/middlewares.ts b/packages/medusa/src/api/store/products/middlewares.ts index 62dff73a9d..2d1265b15b 100644 --- a/packages/medusa/src/api/store/products/middlewares.ts +++ b/packages/medusa/src/api/store/products/middlewares.ts @@ -6,7 +6,9 @@ import { filterByValidSalesChannels, setPricingContext, } from "../../utils/middlewares" +import { setContext } from "../../utils/middlewares/common/set-context" import { validateAndTransformQuery } from "../../utils/validate-query" +import { maybeApplyStockLocationId } from "./helpers" import * as QueryConfig from "./query-config" import { StoreGetProductsParams, @@ -23,6 +25,9 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [ QueryConfig.listProductQueryConfig ), filterByValidSalesChannels(), + setContext({ + stock_location_id: maybeApplyStockLocationId, + }), maybeApplyLinkFilter({ entryPoint: "product_sales_channel", resourceId: "product_id", @@ -53,6 +58,9 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [ QueryConfig.retrieveProductQueryConfig ), filterByValidSalesChannels(), + setContext({ + stock_location_id: maybeApplyStockLocationId, + }), maybeApplyLinkFilter({ entryPoint: "product_sales_channel", resourceId: "product_id", diff --git a/packages/medusa/src/api/store/products/route.ts b/packages/medusa/src/api/store/products/route.ts index 2a704061b1..be1f8a3d9f 100644 --- a/packages/medusa/src/api/store/products/route.ts +++ b/packages/medusa/src/api/store/products/route.ts @@ -4,6 +4,7 @@ import { remoteQueryObjectFromString, } from "@medusajs/utils" import { MedusaRequest, MedusaResponse } from "../../../types/routing" +import { wrapVariantsWithInventoryQuantity } from "./helpers" import { StoreGetProductsParamsType } from "./validators" export const GET = async ( @@ -12,6 +13,15 @@ export const GET = async ( ) => { const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) const context: object = {} + const withInventoryQuantity = req.remoteQueryConfig.fields.some((field) => + field.includes("variants.inventory_quantity") + ) + + if (withInventoryQuantity) { + req.remoteQueryConfig.fields = req.remoteQueryConfig.fields.filter( + (field) => !field.includes("variants.inventory_quantity") + ) + } if (isPresent(req.pricingContext)) { context["variants.calculated_price"] = { @@ -31,6 +41,13 @@ export const GET = async ( const { rows: products, metadata } = await remoteQuery(queryObject) + if (withInventoryQuantity) { + await wrapVariantsWithInventoryQuantity( + req, + products.map((product) => product.variants).flat(1) + ) + } + res.json({ products, count: metadata.count, diff --git a/packages/medusa/src/api/utils/middlewares/common/set-context.ts b/packages/medusa/src/api/utils/middlewares/common/set-context.ts new file mode 100644 index 0000000000..c4e19f734e --- /dev/null +++ b/packages/medusa/src/api/utils/middlewares/common/set-context.ts @@ -0,0 +1,22 @@ +import { NextFunction } from "express" +import { MedusaRequest } from "../../../../types/routing" + +export function setContext(context: Record) { + return async (req: MedusaRequest, _, next: NextFunction) => { + const ctx: Record = { ...(req.context || {}) } + + for (const [contextKey, contextValue] of Object.entries(context || {})) { + let valueToApply = contextValue + + if (typeof contextValue === "function") { + valueToApply = await contextValue(req, ctx) + } + + ctx[contextKey] = valueToApply + } + + req.context = ctx + + return next() + } +} diff --git a/packages/medusa/src/types/routing.ts b/packages/medusa/src/types/routing.ts index dcdf5cf185..55b3c21437 100644 --- a/packages/medusa/src/types/routing.ts +++ b/packages/medusa/src/types/routing.ts @@ -57,6 +57,10 @@ export interface MedusaRequest * An object that carries the context that is used to calculate prices for variants */ pricingContext?: MedusaPricingContext + /** + * A generic context object that can be used across the request lifecycle + */ + context?: Record } export interface AuthContext {