소스 검색

feat(core): Implement StockDisplayStrategy to display stockLevel in API

Relates to #442
Michael Bromley 4 년 전
부모
커밋
2709922b67

+ 3 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -2054,6 +2054,7 @@ export type ProductVariant = Node & {
   /** @deprecated price now always excludes tax */
   priceIncludesTax: Scalars['Boolean'];
   priceWithTax: Scalars['Int'];
+  stockLevel: Scalars['String'];
   taxRateApplied: TaxRate;
   taxCategory: TaxCategory;
   options: Array<ProductOption>;
@@ -4672,6 +4673,7 @@ export type ProductVariantFilterParameter = {
   currencyCode?: Maybe<StringOperators>;
   priceIncludesTax?: Maybe<BooleanOperators>;
   priceWithTax?: Maybe<NumberOperators>;
+  stockLevel?: Maybe<StringOperators>;
 };
 
 export type ProductVariantSortParameter = {
@@ -4686,6 +4688,7 @@ export type ProductVariantSortParameter = {
   name?: Maybe<SortOrder>;
   price?: Maybe<SortOrder>;
   priceWithTax?: Maybe<SortOrder>;
+  stockLevel?: Maybe<SortOrder>;
 };
 
 export type PromotionFilterParameter = {

+ 3 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -1853,6 +1853,7 @@ export type ProductVariant = Node & {
     /** @deprecated price now always excludes tax */
     priceIncludesTax: Scalars['Boolean'];
     priceWithTax: Scalars['Int'];
+    stockLevel: Scalars['String'];
     taxRateApplied: TaxRate;
     taxCategory: TaxCategory;
     options: Array<ProductOption>;
@@ -4384,6 +4385,7 @@ export type ProductVariantFilterParameter = {
     currencyCode?: Maybe<StringOperators>;
     priceIncludesTax?: Maybe<BooleanOperators>;
     priceWithTax?: Maybe<NumberOperators>;
+    stockLevel?: Maybe<StringOperators>;
 };
 
 export type ProductVariantSortParameter = {
@@ -4398,6 +4400,7 @@ export type ProductVariantSortParameter = {
     name?: Maybe<SortOrder>;
     price?: Maybe<SortOrder>;
     priceWithTax?: Maybe<SortOrder>;
+    stockLevel?: Maybe<SortOrder>;
 };
 
 export type PromotionFilterParameter = {

+ 3 - 0
packages/common/src/generated-shop-types.ts

@@ -2188,6 +2188,7 @@ export type ProductVariant = Node & {
     /** @deprecated price now always excludes tax */
     priceIncludesTax: Scalars['Boolean'];
     priceWithTax: Scalars['Int'];
+    stockLevel: Scalars['String'];
     taxRateApplied: TaxRate;
     taxCategory: TaxCategory;
     options: Array<ProductOption>;
@@ -2718,6 +2719,7 @@ export type ProductVariantFilterParameter = {
     currencyCode?: Maybe<StringOperators>;
     priceIncludesTax?: Maybe<BooleanOperators>;
     priceWithTax?: Maybe<NumberOperators>;
+    stockLevel?: Maybe<StringOperators>;
 };
 
 export type ProductVariantSortParameter = {
@@ -2729,6 +2731,7 @@ export type ProductVariantSortParameter = {
     name?: Maybe<SortOrder>;
     price?: Maybe<SortOrder>;
     priceWithTax?: Maybe<SortOrder>;
+    stockLevel?: Maybe<SortOrder>;
 };
 
 export type CustomerFilterParameter = {

+ 3 - 0
packages/common/src/generated-types.ts

@@ -2017,6 +2017,7 @@ export type ProductVariant = Node & {
   /** @deprecated price now always excludes tax */
   priceIncludesTax: Scalars['Boolean'];
   priceWithTax: Scalars['Int'];
+  stockLevel: Scalars['String'];
   taxRateApplied: TaxRate;
   taxCategory: TaxCategory;
   options: Array<ProductOption>;
@@ -4634,6 +4635,7 @@ export type ProductVariantFilterParameter = {
   currencyCode?: Maybe<StringOperators>;
   priceIncludesTax?: Maybe<BooleanOperators>;
   priceWithTax?: Maybe<NumberOperators>;
+  stockLevel?: Maybe<StringOperators>;
 };
 
 export type ProductVariantSortParameter = {
@@ -4648,6 +4650,7 @@ export type ProductVariantSortParameter = {
   name?: Maybe<SortOrder>;
   price?: Maybe<SortOrder>;
   priceWithTax?: Maybe<SortOrder>;
+  stockLevel?: Maybe<SortOrder>;
 };
 
 export type PromotionFilterParameter = {

+ 3 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1853,6 +1853,7 @@ export type ProductVariant = Node & {
     /** @deprecated price now always excludes tax */
     priceIncludesTax: Scalars['Boolean'];
     priceWithTax: Scalars['Int'];
+    stockLevel: Scalars['String'];
     taxRateApplied: TaxRate;
     taxCategory: TaxCategory;
     options: Array<ProductOption>;
@@ -4384,6 +4385,7 @@ export type ProductVariantFilterParameter = {
     currencyCode?: Maybe<StringOperators>;
     priceIncludesTax?: Maybe<BooleanOperators>;
     priceWithTax?: Maybe<NumberOperators>;
+    stockLevel?: Maybe<StringOperators>;
 };
 
 export type ProductVariantSortParameter = {
@@ -4398,6 +4400,7 @@ export type ProductVariantSortParameter = {
     name?: Maybe<SortOrder>;
     price?: Maybe<SortOrder>;
     priceWithTax?: Maybe<SortOrder>;
+    stockLevel?: Maybe<SortOrder>;
 };
 
 export type PromotionFilterParameter = {

+ 20 - 0
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -2109,6 +2109,7 @@ export type ProductVariant = Node & {
     /** @deprecated price now always excludes tax */
     priceIncludesTax: Scalars['Boolean'];
     priceWithTax: Scalars['Int'];
+    stockLevel: Scalars['String'];
     taxRateApplied: TaxRate;
     taxCategory: TaxCategory;
     options: Array<ProductOption>;
@@ -2603,6 +2604,7 @@ export type ProductVariantFilterParameter = {
     currencyCode?: Maybe<StringOperators>;
     priceIncludesTax?: Maybe<BooleanOperators>;
     priceWithTax?: Maybe<NumberOperators>;
+    stockLevel?: Maybe<StringOperators>;
 };
 
 export type ProductVariantSortParameter = {
@@ -2614,6 +2616,7 @@ export type ProductVariantSortParameter = {
     name?: Maybe<SortOrder>;
     price?: Maybe<SortOrder>;
     priceWithTax?: Maybe<SortOrder>;
+    stockLevel?: Maybe<SortOrder>;
 };
 
 export type CustomerFilterParameter = {
@@ -3188,6 +3191,14 @@ export type GetEligiblePaymentMethodsQuery = {
     >;
 };
 
+export type GetProductStockLevelQueryVariables = Exact<{
+    id: Scalars['ID'];
+}>;
+
+export type GetProductStockLevelQuery = {
+    product?: Maybe<Pick<Product, 'id'> & { variants: Array<Pick<ProductVariant, 'id' | 'stockLevel'>> }>;
+};
+
 type DiscriminateUnion<T, U> = T extends U ? T : never;
 
 export namespace TestOrderFragment {
@@ -3713,3 +3724,12 @@ export namespace GetEligiblePaymentMethods {
         NonNullable<GetEligiblePaymentMethodsQuery['eligiblePaymentMethods']>[number]
     >;
 }
+
+export namespace GetProductStockLevel {
+    export type Variables = GetProductStockLevelQueryVariables;
+    export type Query = GetProductStockLevelQuery;
+    export type Product = NonNullable<GetProductStockLevelQuery['product']>;
+    export type Variants = NonNullable<
+        NonNullable<NonNullable<GetProductStockLevelQuery['product']>['variants']>[number]
+    >;
+}

+ 12 - 0
packages/core/e2e/graphql/shop-definitions.ts

@@ -693,3 +693,15 @@ export const GET_ELIGIBLE_PAYMENT_METHODS = gql`
         }
     }
 `;
+
+export const GET_PRODUCT_WITH_STOCK_LEVEL = gql`
+    query GetProductStockLevel($id: ID!) {
+        product(id: $id) {
+            id
+            variants {
+                id
+                stockLevel
+            }
+        }
+    }
+`;

+ 17 - 0
packages/core/e2e/stock-control.e2e-spec.ts

@@ -30,6 +30,7 @@ import {
     AddItemToOrder,
     AddPaymentToOrder,
     ErrorCode,
+    GetProductStockLevel,
     PaymentInput,
     SetShippingAddress,
     TestOrderFragmentFragment,
@@ -49,6 +50,7 @@ import {
 import {
     ADD_ITEM_TO_ORDER,
     ADD_PAYMENT,
+    GET_PRODUCT_WITH_STOCK_LEVEL,
     SET_SHIPPING_ADDRESS,
     TRANSITION_TO_STATE,
 } from './graphql/shop-definitions';
@@ -469,6 +471,21 @@ describe('Stock control', () => {
             await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
         });
 
+        it('stockLevel uses DefaultStockDisplayStrategy', async () => {
+            const { product } = await shopClient.query<
+                GetProductStockLevel.Query,
+                GetProductStockLevel.Variables
+            >(GET_PRODUCT_WITH_STOCK_LEVEL, {
+                id: 'T_2',
+            });
+
+            expect(product?.variants.map(v => v.stockLevel)).toEqual([
+                'OUT_OF_STOCK',
+                'IN_STOCK',
+                'IN_STOCK',
+            ]);
+        });
+
         it('does not add an empty OrderLine if zero saleable stock', async () => {
             const variantId = 'T_5';
             const { addItemToOrder } = await shopClient.query<

+ 5 - 0
packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts

@@ -87,6 +87,11 @@ export class ProductVariantEntityResolver {
             return true;
         });
     }
+
+    @ResolveField()
+    async stockLevel(@Ctx() ctx: RequestContext, @Parent() productVariant: ProductVariant): Promise<string> {
+        return this.productVariantService.getDisplayStockLevel(ctx, productVariant);
+    }
 }
 
 @Resolver('ProductVariant')

+ 1 - 0
packages/core/src/api/schema/common/product.type.graphql

@@ -50,6 +50,7 @@ type ProductVariant implements Node {
     currencyCode: CurrencyCode!
     priceIncludesTax: Boolean! @deprecated(reason: "price now always excludes tax")
     priceWithTax: Int!
+    stockLevel: String!
     taxRateApplied: TaxRate!
     taxCategory: TaxCategory!
     options: [ProductOption!]!

+ 23 - 0
packages/core/src/config/catalog/default-stock-display-strategy.ts

@@ -0,0 +1,23 @@
+import { RequestContext } from '../../api/common/request-context';
+import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
+
+import { StockDisplayStrategy } from './stock-display-strategy';
+
+/**
+ * @description
+ * Displays the `ProductVariant.stockLevel` as either `'IN_STOCK'`, `'OUT_OF_STOCK'` or `'LOW_STOCK'`.
+ * Low stock is defined as a saleable stock level less than or equal to the `lowStockLevel` as passed in
+ * to the constructor (defaults to `2`).
+ *
+ * @docsCategory configuration
+ */
+export class DefaultStockDisplayStrategy implements StockDisplayStrategy {
+    constructor(private lowStockLevel: number = 2) {}
+    getStockLevel(ctx: RequestContext, productVariant: ProductVariant, saleableStockLevel: number): string {
+        return saleableStockLevel < 1
+            ? 'OUT_OF_STOCK'
+            : saleableStockLevel <= this.lowStockLevel
+            ? 'LOW_STOCK'
+            : 'IN_STOCK';
+    }
+}

+ 5 - 4
packages/core/src/config/catalog/product-variant-price-calculation-strategy.ts

@@ -8,7 +8,8 @@ import { TaxRateService } from '../../service/services/tax-rate.service';
  * @description
  * Defines how ProductVariant are calculated based on the input price, tax zone and current request context.
  *
- * @docsCategory tax
+ * @docsCategory configuration
+ * @docsPage ProductVariantPriceCalculationStrategy
  */
 export interface ProductVariantPriceCalculationStrategy extends InjectableStrategy {
     calculate(args: ProductVariantPriceCalculationArgs): PriceCalculationResult;
@@ -18,8 +19,8 @@ export interface ProductVariantPriceCalculationStrategy extends InjectableStrate
  * @description
  * The arguments passed the the `calculate` method of the configured {@link ProductVariantPriceCalculationStrategy}.
  *
- * @docsCategory tax
- * @docsPage Tax Types
+ * @docsCategory configuration
+ * @docsPage ProductVariantPriceCalculationStrategy
  */
 export interface ProductVariantPriceCalculationArgs {
     inputPrice: number;
@@ -33,6 +34,6 @@ export interface ProductVariantPriceCalculationArgs {
  * This is an alias of {@link ProductVariantPriceCalculationStrategy} to preserve compatibility when upgrading.
  *
  * @deprecated Use ProductVariantPriceCalculationStrategy
- * @docsCategory Orders
+ * @docsCategory tax
  */
 export interface TaxCalculationStrategy extends ProductVariantPriceCalculationStrategy {}

+ 25 - 0
packages/core/src/config/catalog/stock-display-strategy.ts

@@ -0,0 +1,25 @@
+import { RequestContext } from '../../api/common/request-context';
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
+
+/**
+ * @description
+ * Defines how the `ProductVariant.stockLevel` value is obtained. It is usually not desirable
+ * to directly expose stock levels over a public API, as this could be considered a leak of
+ * sensitive information. However, the storefront will usually want to display _some_ indication
+ * of whether a given ProductVariant is in stock.
+ *
+ * @docsCategory configuration
+ */
+export interface StockDisplayStrategy extends InjectableStrategy {
+    /**
+     * @description
+     * Returns a string representing the stock level, which will be used directly
+     * in the GraphQL `ProductVariant.stockLevel` field.
+     */
+    getStockLevel(
+        ctx: RequestContext,
+        productVariant: ProductVariant,
+        saleableStockLevel: number,
+    ): string | Promise<string>;
+}

+ 5 - 1
packages/core/src/config/config.module.ts

@@ -80,7 +80,10 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
             assetPreviewStrategy,
             assetStorageStrategy,
         } = this.configService.assetOptions;
-        const { productVariantPriceCalculationStrategy } = this.configService.catalogOptions;
+        const {
+            productVariantPriceCalculationStrategy,
+            stockDisplayStrategy,
+        } = this.configService.catalogOptions;
         const { adminAuthenticationStrategy, shopAuthenticationStrategy } = this.configService.authOptions;
         const { taxZoneStrategy } = this.configService.taxOptions;
         const { jobQueueStrategy } = this.configService.jobQueueOptions;
@@ -113,6 +116,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
             ...customFulfillmentProcess,
             ...customPaymentProcess,
             stockAllocationStrategy,
+            stockDisplayStrategy,
         ];
     }
 

+ 2 - 0
packages/core/src/config/default-config.ts

@@ -14,6 +14,7 @@ import { NoAssetStorageStrategy } from './asset-storage-strategy/no-asset-storag
 import { NativeAuthenticationStrategy } from './auth/native-authentication-strategy';
 import { defaultCollectionFilters } from './catalog/default-collection-filters';
 import { DefaultProductVariantPriceCalculationStrategy } from './catalog/default-product-variant-price-calculation-strategy';
+import { DefaultStockDisplayStrategy } from './catalog/default-stock-display-strategy';
 import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
 import { manualFulfillmentHandler } from './fulfillment/manual-fulfillment-handler';
 import { DefaultLogger } from './logger/default-logger';
@@ -84,6 +85,7 @@ export const defaultConfig: RuntimeVendureConfig = {
     catalogOptions: {
         collectionFilters: defaultCollectionFilters,
         productVariantPriceCalculationStrategy: new DefaultProductVariantPriceCalculationStrategy(),
+        stockDisplayStrategy: new DefaultStockDisplayStrategy(),
     },
     entityIdStrategy: new AutoIncrementIdStrategy(),
     assetOptions: {

+ 13 - 0
packages/core/src/config/vendure-config.ts

@@ -14,6 +14,7 @@ import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-str
 import { AuthenticationStrategy } from './auth/authentication-strategy';
 import { CollectionFilter } from './catalog/collection-filter';
 import { ProductVariantPriceCalculationStrategy } from './catalog/product-variant-price-calculation-strategy';
+import { StockDisplayStrategy } from './catalog/stock-display-strategy';
 import { CustomFields } from './custom-field/custom-field-types';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { CustomFulfillmentProcess } from './fulfillment/custom-fulfillment-process';
@@ -527,6 +528,18 @@ export interface CatalogOptions {
      * @default DefaultTaxCalculationStrategy
      */
     productVariantPriceCalculationStrategy: ProductVariantPriceCalculationStrategy;
+    /**
+     * @description
+     * Defines how the `ProductVariant.stockLevel` value is obtained. It is usually not desirable
+     * to directly expose stock levels over a public API, as this could be considered a leak of
+     * sensitive information. However, the storefront will usually want to display _some_ indication
+     * of whether a given ProductVariant is in stock. The default StockDisplayStrategy will
+     * display "IN_STOCK", "OUT_OF_STOCK" or "LOW_STOCK" rather than exposing the actual saleable
+     * stock level.
+     *
+     * @default DefaultStockDisplayStrategy
+     */
+    stockDisplayStrategy: StockDisplayStrategy;
 }
 
 /**

+ 12 - 0
packages/core/src/service/services/product-variant.service.ts

@@ -240,6 +240,7 @@ export class ProductVariantService {
      * for purchase by Customers.
      */
     async getSaleableStockLevel(ctx: RequestContext, variant: ProductVariant): Promise<number> {
+        // TODO: Use caching (RequestContextCacheService) to reduce DB calls
         const { outOfStockThreshold, trackInventory } = await this.globalSettingsService.getSettings(ctx);
         const inventoryNotTracked =
             variant.trackInventory === GlobalFlag.FALSE ||
@@ -255,6 +256,17 @@ export class ProductVariantService {
         return variant.stockOnHand - variant.stockAllocated - effectiveOutOfStockThreshold;
     }
 
+    /**
+     * @description
+     * Returns the stockLevel to display to the customer, as specified by the configured
+     * {@link StockDisplayStrategy}.
+     */
+    async getDisplayStockLevel(ctx: RequestContext, variant: ProductVariant): Promise<string> {
+        const { stockDisplayStrategy } = this.configService.catalogOptions;
+        const saleableStockLevel = await this.getSaleableStockLevel(ctx, variant);
+        return stockDisplayStrategy.getStockLevel(ctx, variant, saleableStockLevel);
+    }
+
     /**
      * @description
      * Returns the number of fulfillable units of the ProductVariant, equivalent to stockOnHand

+ 3 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -1853,6 +1853,7 @@ export type ProductVariant = Node & {
     /** @deprecated price now always excludes tax */
     priceIncludesTax: Scalars['Boolean'];
     priceWithTax: Scalars['Int'];
+    stockLevel: Scalars['String'];
     taxRateApplied: TaxRate;
     taxCategory: TaxCategory;
     options: Array<ProductOption>;
@@ -4384,6 +4385,7 @@ export type ProductVariantFilterParameter = {
     currencyCode?: Maybe<StringOperators>;
     priceIncludesTax?: Maybe<BooleanOperators>;
     priceWithTax?: Maybe<NumberOperators>;
+    stockLevel?: Maybe<StringOperators>;
 };
 
 export type ProductVariantSortParameter = {
@@ -4398,6 +4400,7 @@ export type ProductVariantSortParameter = {
     name?: Maybe<SortOrder>;
     price?: Maybe<SortOrder>;
     priceWithTax?: Maybe<SortOrder>;
+    stockLevel?: Maybe<SortOrder>;
 };
 
 export type PromotionFilterParameter = {

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
schema-admin.json


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
schema-shop.json


이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.