feat(medusa): Add inventory for variants (#2970)
* initial get-inventory impl * add inventory management to create-variant * update create-variant endpoint * move if statement * use middleware for module checking * add endpoint to medusa-js * export return type from get-inventory * add jsdoc * revert create variant * rename variable * initial setInventoryPattern for variant and product endpoints * remove cache * sort imports * add sales channel info to inventory calculations * add missing import * remove promise.all from single promise * update oas * initial feedback * checkout joinLevels from develop * add variant middleware * add comments
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/medusa": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Feat(medusa): Add multi warehouse to create-variant and add list-inventory for variant endpoints
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
import { AdminGetVariantsParams, AdminVariantsListRes } from "@medusajs/medusa"
|
import {
|
||||||
|
AdminGetVariantsVariantInventoryRes,
|
||||||
|
AdminGetVariantsParams,
|
||||||
|
AdminVariantsListRes,
|
||||||
|
} from "@medusajs/medusa"
|
||||||
import qs from "qs"
|
import qs from "qs"
|
||||||
import { ResponsePromise } from "../.."
|
import { ResponsePromise } from "../.."
|
||||||
import BaseResource from "../base"
|
import BaseResource from "../base"
|
||||||
|
|
||||||
class AdminVariantsResource extends BaseResource {
|
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(
|
list(
|
||||||
query?: AdminGetVariantsParams,
|
query?: AdminGetVariantsParams,
|
||||||
customHeaders: Record<string, any> = {}
|
customHeaders: Record<string, any> = {}
|
||||||
@@ -17,6 +27,21 @@ class AdminVariantsResource extends BaseResource {
|
|||||||
|
|
||||||
return this.client.request("GET", path, undefined, {}, customHeaders)
|
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<string, any> = {}
|
||||||
|
): ResponsePromise<AdminGetVariantsVariantInventoryRes> {
|
||||||
|
const path = `/admin/variants/${variantId}/inventory`
|
||||||
|
|
||||||
|
return this.client.request("GET", path, undefined, {}, customHeaders)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AdminVariantsResource
|
export default AdminVariantsResource
|
||||||
|
|||||||
+48
@@ -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 }
|
||||||
@@ -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<SalesChannel, "beforeInsert"> & {
|
||||||
|
locations: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResponseInventoryItem = Partial<InventoryItemDTO> & {
|
||||||
|
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
|
||||||
|
}[]
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { ProductVariant } from "../../../../models/product-variant"
|
|||||||
import { PaginatedResponse } from "../../../../types/common"
|
import { PaginatedResponse } from "../../../../types/common"
|
||||||
import { PricedVariant } from "../../../../types/pricing"
|
import { PricedVariant } from "../../../../types/pricing"
|
||||||
import middlewares, { transformQuery } from "../../../middlewares"
|
import middlewares, { transformQuery } from "../../../middlewares"
|
||||||
|
import { checkRegisteredModules } from "../../../middlewares/check-registered-modules"
|
||||||
import { AdminGetVariantsParams } from "./list-variants"
|
import { AdminGetVariantsParams } from "./list-variants"
|
||||||
|
|
||||||
const route = Router()
|
const route = Router()
|
||||||
@@ -21,6 +22,15 @@ export default (app) => {
|
|||||||
middlewares.wrap(require("./list-variants").default)
|
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
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,3 +82,4 @@ export type AdminVariantsListRes = PaginatedResponse & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export * from "./list-variants"
|
export * from "./list-variants"
|
||||||
|
export * from "./get-inventory"
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import { IsOptional, IsString } from "class-validator"
|
||||||
import { defaultStoreProductsRelations } from "."
|
import { defaultStoreProductsRelations } from "."
|
||||||
|
import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys"
|
||||||
import {
|
import {
|
||||||
CartService,
|
CartService,
|
||||||
PricingService,
|
PricingService,
|
||||||
ProductService,
|
ProductService,
|
||||||
|
ProductVariantInventoryService,
|
||||||
RegionService,
|
RegionService,
|
||||||
} from "../../../../services"
|
} from "../../../../services"
|
||||||
import { PriceSelectionParams } from "../../../../types/price-selection"
|
import { PriceSelectionParams } from "../../../../types/price-selection"
|
||||||
|
import { FlagRouter } from "../../../../utils/flag-router"
|
||||||
import { validator } from "../../../../utils/validator"
|
import { validator } from "../../../../utils/validator"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,6 +19,7 @@ import { validator } from "../../../../utils/validator"
|
|||||||
* description: "Retrieves a Product."
|
* description: "Retrieves a Product."
|
||||||
* parameters:
|
* parameters:
|
||||||
* - (path) id=* {string} The id of the Product.
|
* - (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) 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.
|
* - (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
|
* - in: query
|
||||||
@@ -71,6 +76,8 @@ export default async (req, res) => {
|
|||||||
|
|
||||||
const customer_id = req.user?.customer_id
|
const customer_id = req.user?.customer_id
|
||||||
|
|
||||||
|
const productVariantInventoryService: ProductVariantInventoryService =
|
||||||
|
req.scope.resolve("productVariantInventoryService")
|
||||||
const productService: ProductService = req.scope.resolve("productService")
|
const productService: ProductService = req.scope.resolve("productService")
|
||||||
const pricingService: PricingService = req.scope.resolve("pricingService")
|
const pricingService: PricingService = req.scope.resolve("pricingService")
|
||||||
const cartService: CartService = req.scope.resolve("cartService")
|
const cartService: CartService = req.scope.resolve("cartService")
|
||||||
@@ -79,6 +86,14 @@ export default async (req, res) => {
|
|||||||
relations: defaultStoreProductsRelations,
|
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 regionId = validated.region_id
|
||||||
let currencyCode = validated.currency_code
|
let currencyCode = validated.currency_code
|
||||||
if (validated.cart_id) {
|
if (validated.cart_id) {
|
||||||
@@ -92,15 +107,27 @@ export default async (req, res) => {
|
|||||||
currencyCode = region.currency_code
|
currencyCode = region.currency_code
|
||||||
}
|
}
|
||||||
|
|
||||||
const [product] = await pricingService.setProductPrices([rawProduct], {
|
const pricedProductArray = await pricingService.setProductPrices(
|
||||||
|
[rawProduct],
|
||||||
|
{
|
||||||
cart_id: validated.cart_id,
|
cart_id: validated.cart_id,
|
||||||
customer_id: customer_id,
|
customer_id: customer_id,
|
||||||
region_id: regionId,
|
region_id: regionId,
|
||||||
currency_code: currencyCode,
|
currency_code: currencyCode,
|
||||||
include_discount_prices: true,
|
include_discount_prices: true,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const [product] = await productVariantInventoryService.setProductAvailability(
|
||||||
|
pricedProductArray,
|
||||||
|
sales_channel_id
|
||||||
|
)
|
||||||
|
|
||||||
res.json({ product })
|
res.json({ product })
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StoreGetProductsProductParams extends PriceSelectionParams {}
|
export class StoreGetProductsProductParams extends PriceSelectionParams {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
sales_channel_id?: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CartService,
|
CartService,
|
||||||
ProductService,
|
ProductService,
|
||||||
|
ProductVariantInventoryService,
|
||||||
RegionService,
|
RegionService,
|
||||||
} from "../../../../services"
|
} from "../../../../services"
|
||||||
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
|
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
|
||||||
@@ -171,6 +172,8 @@ import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/pub
|
|||||||
*/
|
*/
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
const productService: ProductService = req.scope.resolve("productService")
|
const productService: ProductService = req.scope.resolve("productService")
|
||||||
|
const productVariantInventoryService: ProductVariantInventoryService =
|
||||||
|
req.scope.resolve("productVariantInventoryService")
|
||||||
const pricingService: PricingService = req.scope.resolve("pricingService")
|
const pricingService: PricingService = req.scope.resolve("pricingService")
|
||||||
const cartService: CartService = req.scope.resolve("cartService")
|
const cartService: CartService = req.scope.resolve("cartService")
|
||||||
const regionService: RegionService = req.scope.resolve("regionService")
|
const regionService: RegionService = req.scope.resolve("regionService")
|
||||||
@@ -214,7 +217,7 @@ export default async (req, res) => {
|
|||||||
currencyCode = region.currency_code
|
currencyCode = region.currency_code
|
||||||
}
|
}
|
||||||
|
|
||||||
const products = await pricingService.setProductPrices(rawProducts, {
|
const pricedProducts = await pricingService.setProductPrices(rawProducts, {
|
||||||
cart_id: cart_id,
|
cart_id: cart_id,
|
||||||
region_id: regionId,
|
region_id: regionId,
|
||||||
currency_code: currencyCode,
|
currency_code: currencyCode,
|
||||||
@@ -222,6 +225,11 @@ export default async (req, res) => {
|
|||||||
include_discount_prices: true,
|
include_discount_prices: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const products = await productVariantInventoryService.setProductAvailability(
|
||||||
|
pricedProducts,
|
||||||
|
filterableFields.sales_channel_id
|
||||||
|
)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
products,
|
products,
|
||||||
count,
|
count,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
CartService,
|
CartService,
|
||||||
PricingService,
|
PricingService,
|
||||||
|
ProductVariantInventoryService,
|
||||||
ProductVariantService,
|
ProductVariantService,
|
||||||
RegionService,
|
RegionService,
|
||||||
} from "../../../../services"
|
} from "../../../../services"
|
||||||
@@ -8,6 +9,9 @@ import {
|
|||||||
import { PriceSelectionParams } from "../../../../types/price-selection"
|
import { PriceSelectionParams } from "../../../../types/price-selection"
|
||||||
import { defaultStoreVariantRelations } from "."
|
import { defaultStoreVariantRelations } from "."
|
||||||
import { validator } from "../../../../utils/validator"
|
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}
|
* @oas [get] /variants/{variant_id}
|
||||||
@@ -17,6 +21,7 @@ import { validator } from "../../../../utils/validator"
|
|||||||
* parameters:
|
* parameters:
|
||||||
* - (path) variant_id=* {string} The id of the Product Variant.
|
* - (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) 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.
|
* - (query) region_id {string} The id of the Region to set prices based on.
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: currency_code
|
* name: currency_code
|
||||||
@@ -65,6 +70,8 @@ export default async (req, res) => {
|
|||||||
"productVariantService"
|
"productVariantService"
|
||||||
)
|
)
|
||||||
const pricingService: PricingService = req.scope.resolve("pricingService")
|
const pricingService: PricingService = req.scope.resolve("pricingService")
|
||||||
|
const productVariantInventoryService: ProductVariantInventoryService =
|
||||||
|
req.scope.resolve("productVariantInventoryService")
|
||||||
const cartService: CartService = req.scope.resolve("cartService")
|
const cartService: CartService = req.scope.resolve("cartService")
|
||||||
const regionService: RegionService = req.scope.resolve("regionService")
|
const regionService: RegionService = req.scope.resolve("regionService")
|
||||||
|
|
||||||
@@ -74,6 +81,14 @@ export default async (req, res) => {
|
|||||||
relations: defaultStoreVariantRelations,
|
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 regionId = validated.region_id
|
||||||
let currencyCode = validated.currency_code
|
let currencyCode = validated.currency_code
|
||||||
if (validated.cart_id) {
|
if (validated.cart_id) {
|
||||||
@@ -87,7 +102,7 @@ export default async (req, res) => {
|
|||||||
currencyCode = region.currency_code
|
currencyCode = region.currency_code
|
||||||
}
|
}
|
||||||
|
|
||||||
const [variant] = await pricingService.setVariantPrices([rawVariant], {
|
const variantRes = await pricingService.setVariantPrices([rawVariant], {
|
||||||
cart_id: validated.cart_id,
|
cart_id: validated.cart_id,
|
||||||
customer_id: customer_id,
|
customer_id: customer_id,
|
||||||
region_id: regionId,
|
region_id: regionId,
|
||||||
@@ -95,7 +110,16 @@ export default async (req, res) => {
|
|||||||
include_discount_prices: true,
|
include_discount_prices: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [variant] = await productVariantInventoryService.setVariantAvailability(
|
||||||
|
variantRes,
|
||||||
|
sales_channel_id
|
||||||
|
)
|
||||||
|
|
||||||
res.json({ variant })
|
res.json({ variant })
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StoreGetVariantsVariantParams extends PriceSelectionParams {}
|
export class StoreGetVariantsVariantParams extends PriceSelectionParams {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
sales_channel_id?: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
import { ProductVariant } from "../../../../"
|
import { ProductVariant } from "../../../../"
|
||||||
import { Router } from "express"
|
import { RequestHandler, Router } from "express"
|
||||||
import middlewares from "../../../middlewares"
|
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()
|
const route = Router()
|
||||||
|
|
||||||
export default (app) => {
|
export default (app) => {
|
||||||
app.use("/variants", route)
|
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("/", middlewares.wrap(require("./list-variants").default))
|
||||||
route.get("/:id", middlewares.wrap(require("./get-variant").default))
|
route.get("/:id", middlewares.wrap(require("./get-variant").default))
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { IsInt, IsOptional, IsString } from "class-validator"
|
|||||||
import {
|
import {
|
||||||
CartService,
|
CartService,
|
||||||
PricingService,
|
PricingService,
|
||||||
|
ProductVariantInventoryService,
|
||||||
ProductVariantService,
|
ProductVariantService,
|
||||||
RegionService,
|
RegionService,
|
||||||
} from "../../../../services"
|
} from "../../../../services"
|
||||||
@@ -14,6 +15,8 @@ import { PriceSelectionParams } from "../../../../types/price-selection"
|
|||||||
import { FilterableProductVariantProps } from "../../../../types/product-variant"
|
import { FilterableProductVariantProps } from "../../../../types/product-variant"
|
||||||
import { validator } from "../../../../utils/validator"
|
import { validator } from "../../../../utils/validator"
|
||||||
import { IsType } from "../../../../utils/validators/is-type"
|
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
|
* @oas [get] /variants
|
||||||
@@ -22,6 +25,7 @@ import { IsType } from "../../../../utils/validators/is-type"
|
|||||||
* description: "Retrieves a list of Product Variants"
|
* description: "Retrieves a list of Product Variants"
|
||||||
* parameters:
|
* parameters:
|
||||||
* - (query) ids {string} A comma separated list of Product Variant ids to filter by.
|
* - (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) 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) offset=0 {number} How many product variants to skip in the result.
|
||||||
* - (query) limit=100 {number} Maximum number of Product Variants to return.
|
* - (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(",")
|
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 pricingService: PricingService = req.scope.resolve("pricingService")
|
||||||
const variantService: ProductVariantService = req.scope.resolve(
|
const variantService: ProductVariantService = req.scope.resolve(
|
||||||
"productVariantService"
|
"productVariantService"
|
||||||
)
|
)
|
||||||
const cartService: CartService = req.scope.resolve("cartService")
|
const cartService: CartService = req.scope.resolve("cartService")
|
||||||
|
const productVariantInventoryService: ProductVariantInventoryService =
|
||||||
|
req.scope.resolve("productVariantInventoryService")
|
||||||
const regionService: RegionService = req.scope.resolve("regionService")
|
const regionService: RegionService = req.scope.resolve("regionService")
|
||||||
|
|
||||||
const rawVariants = await variantService.list(filterableFields, listConfig)
|
const rawVariants = await variantService.list(filterableFields, listConfig)
|
||||||
@@ -146,7 +160,7 @@ export default async (req, res) => {
|
|||||||
currencyCode = region.currency_code
|
currencyCode = region.currency_code
|
||||||
}
|
}
|
||||||
|
|
||||||
const variants = await pricingService.setVariantPrices(rawVariants, {
|
const pricedVariants = await pricingService.setVariantPrices(rawVariants, {
|
||||||
cart_id: validated.cart_id,
|
cart_id: validated.cart_id,
|
||||||
region_id: regionId,
|
region_id: regionId,
|
||||||
currency_code: currencyCode,
|
currency_code: currencyCode,
|
||||||
@@ -154,6 +168,11 @@ export default async (req, res) => {
|
|||||||
include_discount_prices: true,
|
include_discount_prices: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const variants = await productVariantInventoryService.setVariantAvailability(
|
||||||
|
pricedVariants,
|
||||||
|
sales_channel_id
|
||||||
|
)
|
||||||
|
|
||||||
res.json({ variants })
|
res.json({ variants })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +195,10 @@ export class StoreGetVariantsParams extends PriceSelectionParams {
|
|||||||
@IsString()
|
@IsString()
|
||||||
ids?: string
|
ids?: string
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sales_channel_id?: string
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsType([String, [String]])
|
@IsType([String, [String]])
|
||||||
id?: string | string[]
|
id?: string | string[]
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ export const ProductVariantInventoryServiceMock = {
|
|||||||
validateInventoryAtLocation: jest
|
validateInventoryAtLocation: jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementation((items, locationId) => {}),
|
.mockImplementation((items, locationId) => {}),
|
||||||
|
setVariantAvailability: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((variants, salesChannelId) => {
|
||||||
|
return variants
|
||||||
|
}),
|
||||||
|
setProductAvailability: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((products, salesChannelId) => {
|
||||||
|
return products
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const mock = jest.fn().mockImplementation(() => {
|
const mock = jest.fn().mockImplementation(() => {
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
import { isDefined, MedusaError } from "medusa-core-utils"
|
import { isDefined, MedusaError } from "medusa-core-utils"
|
||||||
import { EntityManager, In } from "typeorm"
|
import { EntityManager, In } from "typeorm"
|
||||||
import {
|
import {
|
||||||
IStockLocationService,
|
|
||||||
IInventoryService,
|
IInventoryService,
|
||||||
|
IStockLocationService,
|
||||||
TransactionBaseService,
|
TransactionBaseService,
|
||||||
} from "../interfaces"
|
} from "../interfaces"
|
||||||
|
import { LineItem, Product, ProductVariant } from "../models"
|
||||||
import { ProductVariantInventoryItem } from "../models/product-variant-inventory-item"
|
import { ProductVariantInventoryItem } from "../models/product-variant-inventory-item"
|
||||||
import { ProductVariantService, SalesChannelLocationService } from "./"
|
|
||||||
import {
|
import {
|
||||||
InventoryItemDTO,
|
InventoryItemDTO,
|
||||||
ReservationItemDTO,
|
ReservationItemDTO,
|
||||||
ReserveQuantityContext,
|
ReserveQuantityContext,
|
||||||
} from "../types/inventory"
|
} from "../types/inventory"
|
||||||
import { LineItem, ProductVariant } from "../models"
|
import { PricedProduct, PricedVariant } from "../types/pricing"
|
||||||
|
import {
|
||||||
|
CacheService,
|
||||||
|
ProductVariantService,
|
||||||
|
SalesChannelInventoryService,
|
||||||
|
SalesChannelLocationService,
|
||||||
|
} from "./"
|
||||||
|
|
||||||
type InjectedDependencies = {
|
type InjectedDependencies = {
|
||||||
manager: EntityManager
|
manager: EntityManager
|
||||||
salesChannelLocationService: SalesChannelLocationService
|
salesChannelLocationService: SalesChannelLocationService
|
||||||
|
salesChannelInventoryService: SalesChannelInventoryService
|
||||||
productVariantService: ProductVariantService
|
productVariantService: ProductVariantService
|
||||||
stockLocationService: IStockLocationService
|
stockLocationService: IStockLocationService
|
||||||
inventoryService: IInventoryService
|
inventoryService: IInventoryService
|
||||||
@@ -27,14 +34,17 @@ class ProductVariantInventoryService extends TransactionBaseService {
|
|||||||
protected transactionManager_: EntityManager | undefined
|
protected transactionManager_: EntityManager | undefined
|
||||||
|
|
||||||
protected readonly salesChannelLocationService_: SalesChannelLocationService
|
protected readonly salesChannelLocationService_: SalesChannelLocationService
|
||||||
|
protected readonly salesChannelInventoryService_: SalesChannelInventoryService
|
||||||
protected readonly productVariantService_: ProductVariantService
|
protected readonly productVariantService_: ProductVariantService
|
||||||
protected readonly stockLocationService_: IStockLocationService
|
protected readonly stockLocationService_: IStockLocationService
|
||||||
protected readonly inventoryService_: IInventoryService
|
protected readonly inventoryService_: IInventoryService
|
||||||
|
protected readonly cacheService_: CacheService
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
manager,
|
manager,
|
||||||
stockLocationService,
|
stockLocationService,
|
||||||
salesChannelLocationService,
|
salesChannelLocationService,
|
||||||
|
salesChannelInventoryService,
|
||||||
productVariantService,
|
productVariantService,
|
||||||
inventoryService,
|
inventoryService,
|
||||||
}: InjectedDependencies) {
|
}: InjectedDependencies) {
|
||||||
@@ -43,6 +53,7 @@ class ProductVariantInventoryService extends TransactionBaseService {
|
|||||||
|
|
||||||
this.manager_ = manager
|
this.manager_ = manager
|
||||||
this.salesChannelLocationService_ = salesChannelLocationService
|
this.salesChannelLocationService_ = salesChannelLocationService
|
||||||
|
this.salesChannelInventoryService_ = salesChannelInventoryService
|
||||||
this.stockLocationService_ = stockLocationService
|
this.stockLocationService_ = stockLocationService
|
||||||
this.productVariantService_ = productVariantService
|
this.productVariantService_ = productVariantService
|
||||||
this.inventoryService_ = inventoryService
|
this.inventoryService_ = inventoryService
|
||||||
@@ -598,6 +609,74 @@ class ProductVariantInventoryService extends TransactionBaseService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setVariantAvailability(
|
||||||
|
variants: ProductVariant[] | PricedVariant[],
|
||||||
|
salesChannelId: string | undefined
|
||||||
|
): Promise<ProductVariant[] | PricedVariant[]> {
|
||||||
|
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
|
export default ProductVariantInventoryService
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class ProductVariantService extends TransactionBaseService {
|
|||||||
protected readonly regionService_: RegionService
|
protected readonly regionService_: RegionService
|
||||||
protected readonly priceSelectionStrategy_: IPriceSelectionStrategy
|
protected readonly priceSelectionStrategy_: IPriceSelectionStrategy
|
||||||
protected readonly moneyAmountRepository_: typeof MoneyAmountRepository
|
protected readonly moneyAmountRepository_: typeof MoneyAmountRepository
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
protected readonly productOptionValueRepository_: typeof ProductOptionValueRepository
|
protected readonly productOptionValueRepository_: typeof ProductOptionValueRepository
|
||||||
protected readonly cartRepository_: typeof CartRepository
|
protected readonly cartRepository_: typeof CartRepository
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ class ProductVariantService extends TransactionBaseService {
|
|||||||
cartRepository,
|
cartRepository,
|
||||||
priceSelectionStrategy,
|
priceSelectionStrategy,
|
||||||
}) {
|
}) {
|
||||||
|
// eslint-disable-next-line prefer-rest-params
|
||||||
super(arguments[0])
|
super(arguments[0])
|
||||||
|
|
||||||
this.manager_ = manager
|
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<boolean> {
|
||||||
|
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.
|
* Creates a query object to be used for list queries.
|
||||||
* @param selector - the selector to create the query from
|
* @param selector - the selector to create the query from
|
||||||
|
|||||||
Reference in New Issue
Block a user