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:
Philip Korsholm
2023-04-20 08:54:26 +02:00
committed by GitHub
co-authored by Oliver Windall Juhl
parent 7d1a78a84f
commit 2be144ff05
27 changed files with 395 additions and 70 deletions
@@ -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:
+18 -6
View File
@@ -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
})
)
+2 -1
View File
@@ -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 = {
+9 -7
View File
@@ -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
+8 -7
View File
@@ -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