feat(medusa): Add purchasable prop on variants when setting availability (#3811)
* write integration tests * update variant inventory decorator * update types * add changeset * feedback comments * add yaml schemas * different oas approach * pr feedback * update oas --------- Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
co-authored by
Oliver Windall Juhl
parent
7d1a78a84f
commit
2be144ff05
@@ -14,7 +14,9 @@ export interface AdminProductsListRes {
|
||||
"collection" | "images" | "options" | "tags" | "type" | "variants"
|
||||
>,
|
||||
{
|
||||
variants: Array<SetRelation<ProductVariant, "options" | "prices">>
|
||||
variants: Array<
|
||||
SetRelation<ProductVariant, "options" | "prices" | "purchasable">
|
||||
>
|
||||
}
|
||||
>
|
||||
>
|
||||
|
||||
@@ -6,7 +6,9 @@ import { SetRelation, Merge } from "../core/ModelUtils"
|
||||
import type { PricedVariant } from "./PricedVariant"
|
||||
|
||||
export interface AdminVariantsListRes {
|
||||
variants: Array<SetRelation<PricedVariant, "options" | "prices" | "product">>
|
||||
variants: Array<
|
||||
SetRelation<PricedVariant, "options" | "prices" | "product" | "purchasable">
|
||||
>
|
||||
/**
|
||||
* The total number of items available
|
||||
*/
|
||||
|
||||
@@ -120,4 +120,15 @@ export interface ProductVariant {
|
||||
* An optional key-value map with additional details
|
||||
*/
|
||||
metadata: Record<string, any> | null
|
||||
/**
|
||||
* Only used with the inventory modules.
|
||||
* A boolean value indicating whether the Product Variant is purchasable.
|
||||
* A variant is purchasable if:
|
||||
* - inventory is not managed
|
||||
* - it has no inventory items
|
||||
* - it is in stock
|
||||
* - it is backorderable.
|
||||
*
|
||||
*/
|
||||
purchasable?: boolean
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ export interface StoreProductsListRes {
|
||||
>,
|
||||
{
|
||||
options: Array<SetRelation<ProductOption, "values">>
|
||||
variants: Array<SetRelation<ProductVariant, "options" | "prices">>
|
||||
variants: Array<
|
||||
SetRelation<ProductVariant, "options" | "prices" | "purchasable">
|
||||
>
|
||||
}
|
||||
>
|
||||
>
|
||||
|
||||
@@ -15,7 +15,9 @@ export interface StoreProductsRes {
|
||||
>,
|
||||
{
|
||||
options: Array<SetRelation<ProductOption, "values">>
|
||||
variants: Array<SetRelation<ProductVariant, "options" | "prices">>
|
||||
variants: Array<
|
||||
SetRelation<ProductVariant, "options" | "prices" | "purchasable">
|
||||
>
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
@@ -6,5 +6,7 @@ import { SetRelation, Merge } from "../core/ModelUtils"
|
||||
import type { PricedVariant } from "./PricedVariant"
|
||||
|
||||
export interface StoreVariantsListRes {
|
||||
variants: Array<SetRelation<PricedVariant, "prices" | "options" | "product">>
|
||||
variants: Array<
|
||||
SetRelation<PricedVariant, "prices" | "options" | "product" | "purchasable">
|
||||
>
|
||||
}
|
||||
|
||||
@@ -6,5 +6,8 @@ import { SetRelation, Merge } from "../core/ModelUtils"
|
||||
import type { PricedVariant } from "./PricedVariant"
|
||||
|
||||
export interface StoreVariantsRes {
|
||||
variant: SetRelation<PricedVariant, "prices" | "options" | "product">
|
||||
variant: SetRelation<
|
||||
PricedVariant,
|
||||
"prices" | "options" | "product" | "purchasable"
|
||||
>
|
||||
}
|
||||
|
||||
@@ -114,13 +114,6 @@ export default async (req, res) => {
|
||||
select: defaultOrderFields,
|
||||
})
|
||||
|
||||
// TODO: Re-enable when we have a way to handle inventory for draft orders on creation
|
||||
if (!inventoryService) {
|
||||
await reserveQuantityForDraftOrder(order, {
|
||||
productVariantInventoryService,
|
||||
})
|
||||
}
|
||||
|
||||
return order
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Router } from "express"
|
||||
import "reflect-metadata"
|
||||
import { Product, ProductTag, ProductType, ProductVariant } from "../../../.."
|
||||
|
||||
import { FindParams, PaginatedResponse } from "../../../../types/common"
|
||||
import { PricedProduct } from "../../../../types/pricing"
|
||||
import { FlagRouter } from "../../../../utils/flag-router"
|
||||
import { Product, ProductTag, ProductType, ProductVariant } from "../../../.."
|
||||
import middlewares, { transformQuery } from "../../../middlewares"
|
||||
import { validateSalesChannelsExist } from "../../../middlewares/validators/sales-channel-existence"
|
||||
|
||||
import { AdminGetProductsParams } from "./list-products"
|
||||
import { FlagRouter } from "../../../../utils/flag-router"
|
||||
import { Router } from "express"
|
||||
import { validateSalesChannelsExist } from "../../../middlewares/validators/sales-channel-existence"
|
||||
import { PricedProduct } from "../../../../types/pricing"
|
||||
|
||||
const route = Router()
|
||||
|
||||
@@ -258,6 +260,8 @@ export type AdminProductsDeleteRes = {
|
||||
* - variants
|
||||
* - variants.options
|
||||
* - variants.prices
|
||||
* totals:
|
||||
* - variants.purchasable
|
||||
* required:
|
||||
* - products
|
||||
* - count
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
} from "../../../../services"
|
||||
|
||||
import { FilterableProductProps } from "../../../../types/product"
|
||||
import { IInventoryService } from "@medusajs/types"
|
||||
import { PricedProduct } from "../../../../types/pricing"
|
||||
import { Product } from "../../../../models"
|
||||
import { Type } from "class-transformer"
|
||||
import { IInventoryService } from "@medusajs/types"
|
||||
|
||||
/**
|
||||
* @oas [get] /admin/products
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PricingService, ProductVariantService } from "../../../../services"
|
||||
|
||||
import { FindParams } from "../../../../types/common"
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Router } from "express"
|
||||
|
||||
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 { AdminGetVariantParams } from "./get-variant"
|
||||
import { AdminGetVariantsParams } from "./list-variants"
|
||||
import { PaginatedResponse } from "../../../../types/common"
|
||||
import { PricedVariant } from "../../../../types/pricing"
|
||||
import { ProductVariant } from "../../../../models/product-variant"
|
||||
import { Router } from "express"
|
||||
import { checkRegisteredModules } from "../../../middlewares/check-registered-modules"
|
||||
|
||||
const route = Router()
|
||||
|
||||
@@ -81,6 +81,8 @@ export const defaultAdminVariantFields: (keyof ProductVariant)[] = [
|
||||
* - options
|
||||
* - prices
|
||||
* - product
|
||||
* totals:
|
||||
* - purchasable
|
||||
* required:
|
||||
* - variants
|
||||
* - count
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Router } from "express"
|
||||
import "reflect-metadata"
|
||||
|
||||
import { Product } from "../../../.."
|
||||
import { PaginatedResponse } from "../../../../types/common"
|
||||
import { FlagRouter } from "../../../../utils/flag-router"
|
||||
import middlewares, { transformStoreQuery } from "../../../middlewares"
|
||||
|
||||
import { FlagRouter } from "../../../../utils/flag-router"
|
||||
import { PaginatedResponse } from "../../../../types/common"
|
||||
import { PricedProduct } from "../../../../types/pricing"
|
||||
import { Product } from "../../../.."
|
||||
import { Router } from "express"
|
||||
import { StoreGetProductsParams } from "./list-products"
|
||||
import { StoreGetProductsProductParams } from "./get-product"
|
||||
import { extendRequestParams } from "../../../middlewares/publishable-api-key/extend-request-params"
|
||||
import { validateProductSalesChannelAssociation } from "../../../middlewares/publishable-api-key/validate-product-sales-channel-association"
|
||||
import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param"
|
||||
import { StoreGetProductsProductParams } from "./get-product"
|
||||
import { StoreGetProductsParams } from "./list-products"
|
||||
|
||||
const route = Router()
|
||||
|
||||
@@ -122,6 +124,8 @@ export * from "./search"
|
||||
* - variants
|
||||
* - variants.options
|
||||
* - variants.prices
|
||||
* totals:
|
||||
* - variants.purchasable
|
||||
* required:
|
||||
* - product
|
||||
* properties:
|
||||
@@ -129,7 +133,7 @@ export * from "./search"
|
||||
* $ref: "#/components/schemas/PricedProduct"
|
||||
*/
|
||||
export type StoreProductsRes = {
|
||||
product: Product
|
||||
product: PricedProduct
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +167,8 @@ export type StorePostSearchRes = {
|
||||
* - variants
|
||||
* - variants.options
|
||||
* - variants.prices
|
||||
* totals:
|
||||
* - variants.purchasable
|
||||
* required:
|
||||
* - products
|
||||
* - count
|
||||
@@ -184,5 +190,5 @@ export type StorePostSearchRes = {
|
||||
* description: The number of items per page
|
||||
*/
|
||||
export type StoreProductsListRes = PaginatedResponse & {
|
||||
products: Product[]
|
||||
products: PricedProduct[]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Router } from "express"
|
||||
import { PricedVariant } from "../../../../types/pricing"
|
||||
import middlewares from "../../../middlewares"
|
||||
import { extendRequestParams } from "../../../middlewares/publishable-api-key/extend-request-params"
|
||||
import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param"
|
||||
import middlewares from "../../../middlewares"
|
||||
import { validateProductVariantSalesChannelAssociation } from "../../../middlewares/publishable-api-key/validate-variant-sales-channel-association"
|
||||
import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param"
|
||||
import { PricedVariant } from "../../../../types/pricing"
|
||||
|
||||
const route = Router()
|
||||
|
||||
@@ -29,6 +29,8 @@ export const defaultStoreVariantRelations = ["prices", "options", "product"]
|
||||
* - prices
|
||||
* - options
|
||||
* - product
|
||||
* totals:
|
||||
* - purchasable
|
||||
* required:
|
||||
* - variant
|
||||
* properties:
|
||||
@@ -48,6 +50,8 @@ export type StoreVariantsRes = {
|
||||
* - prices
|
||||
* - options
|
||||
* - product
|
||||
* totals:
|
||||
* - purchasable
|
||||
* required:
|
||||
* - variants
|
||||
* properties:
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { DbAwareColumn, generateEntityId } from "../utils"
|
||||
import { MoneyAmount } from "./money-amount"
|
||||
import { Product } from "./product"
|
||||
import { ProductOptionValue } from "./product-option-value"
|
||||
import { SoftDeletableEntity } from "../interfaces"
|
||||
import {
|
||||
BeforeInsert,
|
||||
Column,
|
||||
@@ -10,10 +5,15 @@ import {
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany
|
||||
OneToMany,
|
||||
} from "typeorm"
|
||||
import { DbAwareColumn, generateEntityId } from "../utils"
|
||||
|
||||
import { MoneyAmount } from "./money-amount"
|
||||
import { Product } from "./product"
|
||||
import { ProductOptionValue } from "./product-option-value"
|
||||
import { ProductVariantInventoryItem } from "./product-variant-inventory-item"
|
||||
import { SoftDeletableEntity } from "../interfaces"
|
||||
|
||||
@Entity()
|
||||
export class ProductVariant extends SoftDeletableEntity {
|
||||
@@ -103,6 +103,8 @@ export class ProductVariant extends SoftDeletableEntity {
|
||||
@DbAwareColumn({ type: "jsonb", nullable: true })
|
||||
metadata: Record<string, unknown>
|
||||
|
||||
purchasable?: boolean
|
||||
|
||||
@BeforeInsert()
|
||||
private beforeInsert(): void {
|
||||
this.id = generateEntityId(this.id, "variant")
|
||||
@@ -264,4 +266,14 @@ export class ProductVariant extends SoftDeletableEntity {
|
||||
* nullable: true
|
||||
* type: object
|
||||
* example: {car: "white"}
|
||||
* purchasable:
|
||||
* description: |
|
||||
* Only used with the inventory modules.
|
||||
* A boolean value indicating whether the Product Variant is purchasable.
|
||||
* A variant is purchasable if:
|
||||
* - inventory is not managed
|
||||
* - it has no inventory items
|
||||
* - it is in stock
|
||||
* - it is backorderable.
|
||||
* type: boolean
|
||||
*/
|
||||
|
||||
@@ -638,28 +638,50 @@ class ProductVariantInventoryService extends TransactionBaseService {
|
||||
|
||||
async setVariantAvailability(
|
||||
variants: ProductVariant[] | PricedVariant[],
|
||||
salesChannelId: string | string[] | undefined
|
||||
salesChannelId: string | string[] | undefined,
|
||||
variantInventoryMap: Map<string, ProductVariantInventoryItem[]> = new Map()
|
||||
): Promise<ProductVariant[] | PricedVariant[]> {
|
||||
if (!this.inventoryService_) {
|
||||
return variants
|
||||
}
|
||||
|
||||
if (!variantInventoryMap.size) {
|
||||
const variantInventories = await this.listByVariant(
|
||||
variants.map((v) => v.id)
|
||||
)
|
||||
|
||||
variantInventories.forEach((inventory) => {
|
||||
const variantId = inventory.variant_id
|
||||
const currentInventories = variantInventoryMap.get(variantId) || []
|
||||
currentInventories.push(inventory)
|
||||
variantInventoryMap.set(variantId, currentInventories)
|
||||
})
|
||||
}
|
||||
|
||||
return await Promise.all(
|
||||
variants.map(async (variant) => {
|
||||
if (!variant.id) {
|
||||
return variant
|
||||
}
|
||||
|
||||
if (!salesChannelId) {
|
||||
delete variant.inventory_quantity
|
||||
variant.purchasable = variant.allow_backorder
|
||||
|
||||
if (!variant.manage_inventory) {
|
||||
variant.purchasable = true
|
||||
return variant
|
||||
}
|
||||
|
||||
// first get all inventory items required for a variant
|
||||
const variantInventory = await this.listByVariant(variant.id)
|
||||
const variantInventory = variantInventoryMap.get(variant.id) || []
|
||||
|
||||
if (!variantInventory.length) {
|
||||
variant.inventory_quantity = 0
|
||||
delete variant.inventory_quantity
|
||||
variant.purchasable = true
|
||||
return variant
|
||||
}
|
||||
|
||||
if (!salesChannelId) {
|
||||
delete variant.inventory_quantity
|
||||
variant.purchasable = false
|
||||
return variant
|
||||
}
|
||||
|
||||
@@ -678,6 +700,9 @@ class ProductVariantInventoryService extends TransactionBaseService {
|
||||
0
|
||||
)
|
||||
|
||||
variant.purchasable =
|
||||
variant.inventory_quantity > 0 || variant.allow_backorder
|
||||
|
||||
return variant
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PriceSelectionContext } from "../interfaces/price-selection-strategy"
|
||||
import { MoneyAmount, Product, ProductVariant, ShippingOption } from "../models"
|
||||
|
||||
import { PriceSelectionContext } from "../interfaces/price-selection-strategy"
|
||||
import { TaxServiceRate } from "./tax-service"
|
||||
|
||||
export type ProductVariantPricing = {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
DateComparisonOperator,
|
||||
NumericalComparisonOperator,
|
||||
StringComparisonOperator,
|
||||
WithRequiredProperty,
|
||||
} from "./common"
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
@@ -7,15 +13,11 @@ import {
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from "class-validator"
|
||||
|
||||
import { IsType } from "../utils/validators/is-type"
|
||||
import {
|
||||
DateComparisonOperator,
|
||||
NumericalComparisonOperator,
|
||||
StringComparisonOperator,
|
||||
WithRequiredProperty,
|
||||
} from "./common"
|
||||
import { XorConstraint } from "./validators/xor"
|
||||
import { PricedVariant } from "./pricing"
|
||||
import { ProductVariant } from "../models"
|
||||
import { XorConstraint } from "./validators/xor"
|
||||
|
||||
export type ProductVariantPrice = {
|
||||
id?: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Transform, Type } from "class-transformer"
|
||||
import { DateComparisonOperator, FindConfig, Selector } from "./common"
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
@@ -7,21 +7,22 @@ import {
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator"
|
||||
import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels"
|
||||
import {
|
||||
PriceList,
|
||||
Product,
|
||||
ProductCategory,
|
||||
ProductOptionValue,
|
||||
ProductStatus,
|
||||
SalesChannel,
|
||||
ProductCategory,
|
||||
} from "../models"
|
||||
import { Transform, Type } from "class-transformer"
|
||||
|
||||
import { FeatureFlagDecorators } from "../utils/feature-flag-decorators"
|
||||
import { optionalBooleanMapper } from "../utils/validators/is-boolean"
|
||||
import { IsType } from "../utils/validators/is-type"
|
||||
import { DateComparisonOperator, FindConfig, Selector } from "./common"
|
||||
import { PriceListLoadConfig } from "./price-list"
|
||||
import { FindOperator } from "typeorm"
|
||||
import { IsType } from "../utils/validators/is-type"
|
||||
import { PriceListLoadConfig } from "./price-list"
|
||||
import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels"
|
||||
import { optionalBooleanMapper } from "../utils/validators/is-boolean"
|
||||
|
||||
/**
|
||||
* API Level DTOs + Validation rules
|
||||
|
||||
Reference in New Issue
Block a user