diff --git a/.changeset/spicy-singers-applaud.md b/.changeset/spicy-singers-applaud.md new file mode 100644 index 0000000000..e10652c978 --- /dev/null +++ b/.changeset/spicy-singers-applaud.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +Feat(medusa): Add multi warehouse to create-variant and add list-inventory for variant endpoints diff --git a/packages/medusa-js/src/resources/admin/variants.ts b/packages/medusa-js/src/resources/admin/variants.ts index 5a6e153cd6..947c755955 100644 --- a/packages/medusa-js/src/resources/admin/variants.ts +++ b/packages/medusa-js/src/resources/admin/variants.ts @@ -1,9 +1,19 @@ -import { AdminGetVariantsParams, AdminVariantsListRes } from "@medusajs/medusa" +import { + AdminGetVariantsVariantInventoryRes, + AdminGetVariantsParams, + AdminVariantsListRes, +} from "@medusajs/medusa" import qs from "qs" import { ResponsePromise } from "../.." import BaseResource from "../base" class AdminVariantsResource extends BaseResource { + /** + * List product variants + * @param query Query to filter variants by + * @param customHeaders custom headers + * @returns A list of variants satisfying the criteria of the query + */ list( query?: AdminGetVariantsParams, customHeaders: Record = {} @@ -17,6 +27,21 @@ class AdminVariantsResource extends BaseResource { return this.client.request("GET", path, undefined, {}, customHeaders) } + + /** + * + * @param variantId id of the variant to fetch inventory for + * @param customHeaders custom headers + * @returns + */ + getInventory( + variantId: string, + customHeaders: Record = {} + ): ResponsePromise { + const path = `/admin/variants/${variantId}/inventory` + + return this.client.request("GET", path, undefined, {}, customHeaders) + } } export default AdminVariantsResource diff --git a/packages/medusa/src/api/middlewares/publishable-api-key/validate-variant-sales-channel-association.ts b/packages/medusa/src/api/middlewares/publishable-api-key/validate-variant-sales-channel-association.ts new file mode 100644 index 0000000000..cb7d00d127 --- /dev/null +++ b/packages/medusa/src/api/middlewares/publishable-api-key/validate-variant-sales-channel-association.ts @@ -0,0 +1,48 @@ +import { NextFunction, Request, Response } from "express" + +import PublishableApiKeyService from "../../../services/publishable-api-key" +import { ProductService, ProductVariantService } from "../../../services" + +/** + * The middleware check if requested product is assigned to a SC associated with PK in the header. + * + * @param req - request object + * @param res - response object + * @param next - next middleware call + */ +async function validateProductVariantSalesChannelAssociation( + req: Request, + res: Response, + next: NextFunction +) { + const pubKey = req.get("x-publishable-api-key") + + if (pubKey) { + const productVariantService: ProductVariantService = req.scope.resolve( + "productVariantService" + ) + const publishableKeyService: PublishableApiKeyService = req.scope.resolve( + "publishableApiKeyService" + ) + + const { sales_channel_id: salesChannelIds } = + await publishableKeyService.getResourceScopes(pubKey) + + if ( + salesChannelIds.length && + !(await productVariantService.isVariantInSalesChannels( + req.params.id, + salesChannelIds + )) + ) { + req.errors = req.errors ?? [] + req.errors.push( + `Variant with id: ${req.params.id} is not associated with sales channels defined by the Publishable API Key passed in the header of the request.` + ) + } + } + + next() +} + +export { validateProductVariantSalesChannelAssociation } diff --git a/packages/medusa/src/api/routes/admin/variants/get-inventory.ts b/packages/medusa/src/api/routes/admin/variants/get-inventory.ts new file mode 100644 index 0000000000..9807a1b441 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/variants/get-inventory.ts @@ -0,0 +1,180 @@ +import { + InventoryItemDTO, + InventoryLevelDTO, +} from "../../../../types/inventory" +import ProductVariantInventoryService from "../../../../services/product-variant-inventory" +import { + SalesChannelLocationService, + SalesChannelService, +} from "../../../../services" +import { SalesChannel } from "../../../../models" +import { IInventoryService } from "../../../../interfaces" +import ProductVariantService from "../../../../services/product-variant" +import { joinLevels } from "../inventory-items/utils/join-levels" + +/** + * @oas [get] /variants/{id}/inventory + * operationId: "GetVariantsVariantInventory" + * summary: "Get inventory of Variant." + * description: "Returns the available inventory of a Variant." + * x-authenticated: true + * parameters: + * - (path) id {string} The Product Variant id to get inventory for. + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.variants.list() + * .then(({ variants, limit, offset, count }) => { + * console.log(variants.length) + * }) + * - lang: Shell + * label: cURL + * source: | + * curl --location --request GET 'https://medusa-url.com/admin/variants' \ + * --header 'Authorization: Bearer {api_token}' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Product Variant + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * type: object + * properties: + * variant: + * type: object + * $ref: "#/components/schemas/AdminGetVariantsVariantInventoryRes" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req, res) => { + const { id } = req.params + + const inventoryService: IInventoryService = + req.scope.resolve("inventoryService") + const channelLocationService: SalesChannelLocationService = req.scope.resolve( + "salesChannelLocationService" + ) + const channelService: SalesChannelService = req.scope.resolve( + "salesChannelService" + ) + const productVariantInventoryService: ProductVariantInventoryService = + req.scope.resolve("productVariantInventoryService") + + const variantService: ProductVariantService = req.scope.resolve( + "productVariantService" + ) + + const variant = await variantService.retrieve(id, { select: ["id"] }) + + const responseVariant: AdminGetVariantsVariantInventoryRes = { + id: variant.id, + inventory: [], + sales_channel_availability: [], + } + + const [rawChannels] = await channelService.listAndCount({}) + const channels: SalesChannelDTO[] = await Promise.all( + rawChannels.map(async (channel) => { + const locations = await channelLocationService.listLocations(channel.id) + return { + ...channel, + locations, + } + }) + ) + + const inventory = + await productVariantInventoryService.listInventoryItemsByVariant(variant.id) + responseVariant.inventory = await joinLevels(inventory, [], inventoryService) + + // TODO: adjust for required quantity + if (inventory.length) { + responseVariant.sales_channel_availability = await Promise.all( + channels.map(async (channel) => { + if (!channel.locations.length) { + return { + channel_name: channel.name as string, + channel_id: channel.id as string, + available_quantity: 0, + } + } + + const quantity = await inventoryService.retrieveAvailableQuantity( + inventory[0].id, + channel.locations + ) + + return { + channel_name: channel.name as string, + channel_id: channel.id as string, + available_quantity: quantity, + } + }) + ) + } + + res.json({ + variant: responseVariant, + }) +} + +type SalesChannelDTO = Omit & { + locations: string[] +} + +type ResponseInventoryItem = Partial & { + location_levels?: InventoryLevelDTO[] +} + +/** + * @schema AdminGetVariantsVariantInventoryRes + * type: object + * properties: + * id: + * description: the id of the variant + * type: string + * inventory: + * description: the stock location address ID + * type: string + * sales_channel_availability: + * type: object + * description: An optional key-value map with additional details + * properties: + * channel_name: + * description: Sales channel name + * type: string + * channel_id: + * description: Sales channel id + * type: string + * available_quantity: + * description: Available quantity in sales channel + * type: number + */ +export type AdminGetVariantsVariantInventoryRes = { + id: string + inventory: ResponseInventoryItem[] + sales_channel_availability: { + channel_name: string + channel_id: string + available_quantity: number + }[] +} diff --git a/packages/medusa/src/api/routes/admin/variants/index.ts b/packages/medusa/src/api/routes/admin/variants/index.ts index 9ac27242fe..69ba329cd9 100644 --- a/packages/medusa/src/api/routes/admin/variants/index.ts +++ b/packages/medusa/src/api/routes/admin/variants/index.ts @@ -4,6 +4,7 @@ import { ProductVariant } from "../../../../models/product-variant" import { PaginatedResponse } from "../../../../types/common" import { PricedVariant } from "../../../../types/pricing" import middlewares, { transformQuery } from "../../../middlewares" +import { checkRegisteredModules } from "../../../middlewares/check-registered-modules" import { AdminGetVariantsParams } from "./list-variants" const route = Router() @@ -21,6 +22,15 @@ export default (app) => { middlewares.wrap(require("./list-variants").default) ) + route.get( + "/:id/inventory", + checkRegisteredModules({ + inventoryService: + "Inventory is not enabled. Please add an Inventory module to enable this functionality.", + }), + middlewares.wrap(require("./get-inventory").default) + ) + return app } @@ -72,3 +82,4 @@ export type AdminVariantsListRes = PaginatedResponse & { } export * from "./list-variants" +export * from "./get-inventory" diff --git a/packages/medusa/src/api/routes/store/products/get-product.ts b/packages/medusa/src/api/routes/store/products/get-product.ts index 89eb052cc8..290120f492 100644 --- a/packages/medusa/src/api/routes/store/products/get-product.ts +++ b/packages/medusa/src/api/routes/store/products/get-product.ts @@ -1,11 +1,15 @@ +import { IsOptional, IsString } from "class-validator" import { defaultStoreProductsRelations } from "." +import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys" import { CartService, PricingService, ProductService, + ProductVariantInventoryService, RegionService, } from "../../../../services" import { PriceSelectionParams } from "../../../../types/price-selection" +import { FlagRouter } from "../../../../utils/flag-router" import { validator } from "../../../../utils/validator" /** @@ -15,6 +19,7 @@ import { validator } from "../../../../utils/validator" * description: "Retrieves a Product." * parameters: * - (path) id=* {string} The id of the Product. + * - (query) sales_channel_id {string} The sales channel used when fetching the product. * - (query) cart_id {string} The ID of the customer's cart. * - (query) region_id {string} The ID of the region the customer is using. This is helpful to ensure correct prices are retrieved for a region. * - in: query @@ -71,6 +76,8 @@ export default async (req, res) => { const customer_id = req.user?.customer_id + const productVariantInventoryService: ProductVariantInventoryService = + req.scope.resolve("productVariantInventoryService") const productService: ProductService = req.scope.resolve("productService") const pricingService: PricingService = req.scope.resolve("pricingService") const cartService: CartService = req.scope.resolve("cartService") @@ -79,6 +86,14 @@ export default async (req, res) => { relations: defaultStoreProductsRelations, }) + let sales_channel_id = validated.sales_channel_id + const featureFlagRouter: FlagRouter = req.scope.resolve("featureFlagRouter") + if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) { + if (req.publishableApiKeyScopes?.sales_channel_id.length === 1) { + sales_channel_id = req.publishableApiKeyScopes.sales_channel_id[0] + } + } + let regionId = validated.region_id let currencyCode = validated.currency_code if (validated.cart_id) { @@ -92,15 +107,27 @@ export default async (req, res) => { currencyCode = region.currency_code } - const [product] = await pricingService.setProductPrices([rawProduct], { - cart_id: validated.cart_id, - customer_id: customer_id, - region_id: regionId, - currency_code: currencyCode, - include_discount_prices: true, - }) + const pricedProductArray = await pricingService.setProductPrices( + [rawProduct], + { + cart_id: validated.cart_id, + customer_id: customer_id, + region_id: regionId, + currency_code: currencyCode, + include_discount_prices: true, + } + ) + + const [product] = await productVariantInventoryService.setProductAvailability( + pricedProductArray, + sales_channel_id + ) res.json({ product }) } -export class StoreGetProductsProductParams extends PriceSelectionParams {} +export class StoreGetProductsProductParams extends PriceSelectionParams { + @IsString() + @IsOptional() + sales_channel_id?: string +} diff --git a/packages/medusa/src/api/routes/store/products/list-products.ts b/packages/medusa/src/api/routes/store/products/list-products.ts index 32128d7d96..70ff3e0d75 100644 --- a/packages/medusa/src/api/routes/store/products/list-products.ts +++ b/packages/medusa/src/api/routes/store/products/list-products.ts @@ -10,6 +10,7 @@ import { import { CartService, ProductService, + ProductVariantInventoryService, RegionService, } from "../../../../services" import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" @@ -171,6 +172,8 @@ import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/pub */ export default async (req, res) => { const productService: ProductService = req.scope.resolve("productService") + const productVariantInventoryService: ProductVariantInventoryService = + req.scope.resolve("productVariantInventoryService") const pricingService: PricingService = req.scope.resolve("pricingService") const cartService: CartService = req.scope.resolve("cartService") const regionService: RegionService = req.scope.resolve("regionService") @@ -214,7 +217,7 @@ export default async (req, res) => { currencyCode = region.currency_code } - const products = await pricingService.setProductPrices(rawProducts, { + const pricedProducts = await pricingService.setProductPrices(rawProducts, { cart_id: cart_id, region_id: regionId, currency_code: currencyCode, @@ -222,6 +225,11 @@ export default async (req, res) => { include_discount_prices: true, }) + const products = await productVariantInventoryService.setProductAvailability( + pricedProducts, + filterableFields.sales_channel_id + ) + res.json({ products, count, diff --git a/packages/medusa/src/api/routes/store/variants/get-variant.ts b/packages/medusa/src/api/routes/store/variants/get-variant.ts index f6f5f8f797..d1306e3bb5 100644 --- a/packages/medusa/src/api/routes/store/variants/get-variant.ts +++ b/packages/medusa/src/api/routes/store/variants/get-variant.ts @@ -1,6 +1,7 @@ import { CartService, PricingService, + ProductVariantInventoryService, ProductVariantService, RegionService, } from "../../../../services" @@ -8,6 +9,9 @@ import { import { PriceSelectionParams } from "../../../../types/price-selection" import { defaultStoreVariantRelations } from "." import { validator } from "../../../../utils/validator" +import { IsOptional, IsString } from "class-validator" +import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys" +import { FlagRouter } from "../../../../utils/flag-router" /** * @oas [get] /variants/{variant_id} @@ -17,6 +21,7 @@ import { validator } from "../../../../utils/validator" * parameters: * - (path) variant_id=* {string} The id of the Product Variant. * - (query) cart_id {string} The id of the Cart to set prices based on. + * - (query) sales_channel_id {string} A sales channel id for result configuration. * - (query) region_id {string} The id of the Region to set prices based on. * - in: query * name: currency_code @@ -65,6 +70,8 @@ export default async (req, res) => { "productVariantService" ) const pricingService: PricingService = req.scope.resolve("pricingService") + const productVariantInventoryService: ProductVariantInventoryService = + req.scope.resolve("productVariantInventoryService") const cartService: CartService = req.scope.resolve("cartService") const regionService: RegionService = req.scope.resolve("regionService") @@ -74,6 +81,14 @@ export default async (req, res) => { relations: defaultStoreVariantRelations, }) + let sales_channel_id = validated.sales_channel_id + const featureFlagRouter: FlagRouter = req.scope.resolve("featureFlagRouter") + if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) { + if (req.publishableApiKeyScopes?.sales_channel_id.length === 1) { + sales_channel_id = req.publishableApiKeyScopes.sales_channel_id[0] + } + } + let regionId = validated.region_id let currencyCode = validated.currency_code if (validated.cart_id) { @@ -87,7 +102,7 @@ export default async (req, res) => { currencyCode = region.currency_code } - const [variant] = await pricingService.setVariantPrices([rawVariant], { + const variantRes = await pricingService.setVariantPrices([rawVariant], { cart_id: validated.cart_id, customer_id: customer_id, region_id: regionId, @@ -95,7 +110,16 @@ export default async (req, res) => { include_discount_prices: true, }) + const [variant] = await productVariantInventoryService.setVariantAvailability( + variantRes, + sales_channel_id + ) + res.json({ variant }) } -export class StoreGetVariantsVariantParams extends PriceSelectionParams {} +export class StoreGetVariantsVariantParams extends PriceSelectionParams { + @IsString() + @IsOptional() + sales_channel_id?: string +} diff --git a/packages/medusa/src/api/routes/store/variants/index.ts b/packages/medusa/src/api/routes/store/variants/index.ts index f972cc1cf5..b80c9790de 100644 --- a/packages/medusa/src/api/routes/store/variants/index.ts +++ b/packages/medusa/src/api/routes/store/variants/index.ts @@ -1,12 +1,26 @@ import { ProductVariant } from "../../../../" -import { Router } from "express" +import { RequestHandler, Router } from "express" import middlewares from "../../../middlewares" +import { featureFlagRouter } from "../../../../loaders/feature-flags" +import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys" +import { extendRequestParams } from "../../../middlewares/publishable-api-key/extend-request-params" +import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param" +import { validateProductVariantSalesChannelAssociation } from "../../../middlewares/publishable-api-key/validate-variant-sales-channel-association" const route = Router() export default (app) => { app.use("/variants", route) + if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) { + route.use( + "/", + extendRequestParams as unknown as RequestHandler, + validateSalesChannelParam as unknown as RequestHandler + ) + route.use("/:id", validateProductVariantSalesChannelAssociation) + } + route.get("/", middlewares.wrap(require("./list-variants").default)) route.get("/:id", middlewares.wrap(require("./get-variant").default)) diff --git a/packages/medusa/src/api/routes/store/variants/list-variants.ts b/packages/medusa/src/api/routes/store/variants/list-variants.ts index f50051f960..5a9120c846 100644 --- a/packages/medusa/src/api/routes/store/variants/list-variants.ts +++ b/packages/medusa/src/api/routes/store/variants/list-variants.ts @@ -2,6 +2,7 @@ import { IsInt, IsOptional, IsString } from "class-validator" import { CartService, PricingService, + ProductVariantInventoryService, ProductVariantService, RegionService, } from "../../../../services" @@ -14,6 +15,8 @@ import { PriceSelectionParams } from "../../../../types/price-selection" import { FilterableProductVariantProps } from "../../../../types/product-variant" import { validator } from "../../../../utils/validator" import { IsType } from "../../../../utils/validators/is-type" +import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys" +import { FlagRouter } from "../../../../utils/flag-router" /** * @oas [get] /variants @@ -22,6 +25,7 @@ import { IsType } from "../../../../utils/validators/is-type" * description: "Retrieves a list of Product Variants" * parameters: * - (query) ids {string} A comma separated list of Product Variant ids to filter by. + * - (query) sales_channel_id {string} A sales channel id for result configuration. * - (query) expand {string} A comma separated list of Product Variant relations to load. * - (query) offset=0 {number} How many product variants to skip in the result. * - (query) limit=100 {number} Maximum number of Product Variants to return. @@ -124,11 +128,21 @@ export default async (req, res) => { filterableFields.id = validated.ids.split(",") } + let sales_channel_id = validated.sales_channel_id + const featureFlagRouter: FlagRouter = req.scope.resolve("featureFlagRouter") + if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) { + if (req.publishableApiKeyScopes?.sales_channel_id.length === 1) { + sales_channel_id = req.publishableApiKeyScopes.sales_channel_id[0] + } + } + const pricingService: PricingService = req.scope.resolve("pricingService") const variantService: ProductVariantService = req.scope.resolve( "productVariantService" ) const cartService: CartService = req.scope.resolve("cartService") + const productVariantInventoryService: ProductVariantInventoryService = + req.scope.resolve("productVariantInventoryService") const regionService: RegionService = req.scope.resolve("regionService") const rawVariants = await variantService.list(filterableFields, listConfig) @@ -146,7 +160,7 @@ export default async (req, res) => { currencyCode = region.currency_code } - const variants = await pricingService.setVariantPrices(rawVariants, { + const pricedVariants = await pricingService.setVariantPrices(rawVariants, { cart_id: validated.cart_id, region_id: regionId, currency_code: currencyCode, @@ -154,6 +168,11 @@ export default async (req, res) => { include_discount_prices: true, }) + const variants = await productVariantInventoryService.setVariantAvailability( + pricedVariants, + sales_channel_id + ) + res.json({ variants }) } @@ -176,6 +195,10 @@ export class StoreGetVariantsParams extends PriceSelectionParams { @IsString() ids?: string + @IsOptional() + @IsString() + sales_channel_id?: string + @IsOptional() @IsType([String, [String]]) id?: string | string[] diff --git a/packages/medusa/src/services/__mocks__/product-variant-inventory.js b/packages/medusa/src/services/__mocks__/product-variant-inventory.js index 2607804741..f30feb4663 100644 --- a/packages/medusa/src/services/__mocks__/product-variant-inventory.js +++ b/packages/medusa/src/services/__mocks__/product-variant-inventory.js @@ -22,6 +22,16 @@ export const ProductVariantInventoryServiceMock = { validateInventoryAtLocation: jest .fn() .mockImplementation((items, locationId) => {}), + setVariantAvailability: jest + .fn() + .mockImplementation((variants, salesChannelId) => { + return variants + }), + setProductAvailability: jest + .fn() + .mockImplementation((products, salesChannelId) => { + return products + }), } const mock = jest.fn().mockImplementation(() => { diff --git a/packages/medusa/src/services/product-variant-inventory.ts b/packages/medusa/src/services/product-variant-inventory.ts index 33c22088e0..7e20e40ee7 100644 --- a/packages/medusa/src/services/product-variant-inventory.ts +++ b/packages/medusa/src/services/product-variant-inventory.ts @@ -1,22 +1,29 @@ import { isDefined, MedusaError } from "medusa-core-utils" import { EntityManager, In } from "typeorm" import { - IStockLocationService, IInventoryService, + IStockLocationService, TransactionBaseService, } from "../interfaces" +import { LineItem, Product, ProductVariant } from "../models" import { ProductVariantInventoryItem } from "../models/product-variant-inventory-item" -import { ProductVariantService, SalesChannelLocationService } from "./" import { InventoryItemDTO, ReservationItemDTO, ReserveQuantityContext, } from "../types/inventory" -import { LineItem, ProductVariant } from "../models" +import { PricedProduct, PricedVariant } from "../types/pricing" +import { + CacheService, + ProductVariantService, + SalesChannelInventoryService, + SalesChannelLocationService, +} from "./" type InjectedDependencies = { manager: EntityManager salesChannelLocationService: SalesChannelLocationService + salesChannelInventoryService: SalesChannelInventoryService productVariantService: ProductVariantService stockLocationService: IStockLocationService inventoryService: IInventoryService @@ -27,14 +34,17 @@ class ProductVariantInventoryService extends TransactionBaseService { protected transactionManager_: EntityManager | undefined protected readonly salesChannelLocationService_: SalesChannelLocationService + protected readonly salesChannelInventoryService_: SalesChannelInventoryService protected readonly productVariantService_: ProductVariantService protected readonly stockLocationService_: IStockLocationService protected readonly inventoryService_: IInventoryService + protected readonly cacheService_: CacheService constructor({ manager, stockLocationService, salesChannelLocationService, + salesChannelInventoryService, productVariantService, inventoryService, }: InjectedDependencies) { @@ -43,6 +53,7 @@ class ProductVariantInventoryService extends TransactionBaseService { this.manager_ = manager this.salesChannelLocationService_ = salesChannelLocationService + this.salesChannelInventoryService_ = salesChannelInventoryService this.stockLocationService_ = stockLocationService this.productVariantService_ = productVariantService this.inventoryService_ = inventoryService @@ -598,6 +609,74 @@ class ProductVariantInventoryService extends TransactionBaseService { ) } } + + async setVariantAvailability( + variants: ProductVariant[] | PricedVariant[], + salesChannelId: string | undefined + ): Promise { + if (!this.inventoryService_) { + return variants + } + + return await Promise.all( + variants.map(async (variant) => { + if (!variant.id) { + return variant + } + + if (!salesChannelId) { + delete variant.inventory_quantity + return variant + } + + // first get all inventory items required for a variant + const variantInventory = await this.listByVariant(variant.id) + + // the inventory quantity of the variant should be equal to the inventory + // item with the smallest stock, adjusted for quantity required to fulfill + // the given variant + variant.inventory_quantity = Math.min( + ...(await Promise.all( + variantInventory.map(async (variantInventory) => { + // get the total available quantity for the given sales channel + // divided by the required quantity to account for how many of the + // variant we can fulfill at the current time. Take the minimum we + // can fulfill and set that as quantity + return ( + // eslint-disable-next-line max-len + (await this.salesChannelInventoryService_.retrieveAvailableItemQuantity( + salesChannelId, + variantInventory.inventory_item_id + )) / variantInventory.required_quantity + ) + }) + )) + ) + + return variant + }) + ) + } + + async setProductAvailability( + products: (Product | PricedProduct)[], + salesChannelId: string | undefined + ): Promise<(Product | PricedProduct)[]> { + return await Promise.all( + products.map(async (product) => { + if (!product.variants || product.variants.length === 0) { + return product + } + + product.variants = await this.setVariantAvailability( + product.variants, + salesChannelId + ) + + return product + }) + ) + } } export default ProductVariantInventoryService diff --git a/packages/medusa/src/services/product-variant.ts b/packages/medusa/src/services/product-variant.ts index 4e29b3c04a..29c45c69af 100644 --- a/packages/medusa/src/services/product-variant.ts +++ b/packages/medusa/src/services/product-variant.ts @@ -47,6 +47,7 @@ class ProductVariantService extends TransactionBaseService { protected readonly regionService_: RegionService protected readonly priceSelectionStrategy_: IPriceSelectionStrategy protected readonly moneyAmountRepository_: typeof MoneyAmountRepository + // eslint-disable-next-line max-len protected readonly productOptionValueRepository_: typeof ProductOptionValueRepository protected readonly cartRepository_: typeof CartRepository @@ -61,6 +62,7 @@ class ProductVariantService extends TransactionBaseService { cartRepository, priceSelectionStrategy, }) { + // eslint-disable-next-line prefer-rest-params super(arguments[0]) this.manager_ = manager @@ -675,6 +677,28 @@ class ProductVariantService extends TransactionBaseService { }) } + /** + * Check if the variant is assigned to at least one of the provided sales channels. + * + * @param id - product variant id + * @param salesChannelIds - an array of sales channel ids + */ + async isVariantInSalesChannels( + id: string, + salesChannelIds: string[] + ): Promise { + const variant = await this.retrieve(id, { + relations: ["product", "product.sales_channels"], + }) + + // TODO: reimplement this to use db level check + const productsSalesChannels = variant.product.sales_channels.map( + (channel) => channel.id + ) + + return productsSalesChannels.some((id) => salesChannelIds.includes(id)) + } + /** * Creates a query object to be used for list queries. * @param selector - the selector to create the query from