Просмотр исходного кода

refactor(core): Remove PromotionUtils, use init method instead

Michael Bromley 5 лет назад
Родитель
Сommit
bf0d536ecd

+ 2 - 2
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -2,8 +2,8 @@
 import { omit } from '@vendure/common/lib/omit';
 import { pick } from '@vendure/common/lib/pick';
 import {
-    atLeastNWithFacets,
     discountOnItemWithFacets,
+    hasFacetValues,
     minimumOrderAmount,
     orderPercentageDiscount,
 } from '@vendure/core';
@@ -288,7 +288,7 @@ describe('Promotions applied to Orders', () => {
                 name: 'Free if order contains 2 items with Sale facet value',
                 conditions: [
                     {
-                        code: atLeastNWithFacets.code,
+                        code: hasFacetValues.code,
                         arguments: [
                             { name: 'minimum', value: '2' },
                             { name: 'facets', value: `["${saleFacetValue.id}"]` },

+ 48 - 0
packages/core/src/common/ttl-cache.ts

@@ -0,0 +1,48 @@
+/**
+ * An in-memory cache with a configurable TTL (time to live) which means cache items
+ * expire after they have been in the cache longer than that time.
+ */
+export class TtlCache<K, V> {
+    private cache = new Map<K, { value: V; expires: number }>();
+    private readonly ttl: number = 30 * 1000;
+    private readonly cacheSize: number = 1000;
+    constructor(config?: { ttl?: number; cacheSize?: number }) {
+        if (config?.ttl) {
+            this.ttl = config.ttl;
+        }
+        if (config?.cacheSize) {
+            this.cacheSize = config.cacheSize;
+        }
+    }
+
+    get(key: K): V | undefined {
+        const hit = this.cache.get(key);
+        const now = new Date().getTime();
+        if (hit) {
+            if (hit.expires < now) {
+                return hit.value;
+            } else {
+                this.cache.delete(key);
+            }
+        }
+    }
+
+    set(key: K, value: V) {
+        if (this.cache.has(key)) {
+            // delete key to put the item to the end of
+            // the cache, marking it as new again
+            this.cache.delete(key);
+        } else if (this.cache.size === this.cacheSize) {
+            // evict oldest
+            this.cache.delete(this.first());
+        }
+        this.cache.set(key, {
+            value,
+            expires: new Date().getTime() + this.ttl,
+        });
+    }
+
+    private first() {
+        return this.cache.keys().next().value;
+    }
+}

+ 8 - 2
packages/core/src/config/promotion/actions/facet-values-discount-action.ts

@@ -1,6 +1,9 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 
 import { PromotionItemAction } from '../promotion-action';
+import { FacetValueChecker } from '../utils/facet-value-checker';
+
+let facetValueChecker: FacetValueChecker;
 
 export const discountOnItemWithFacets = new PromotionItemAction({
     code: 'facet_based_discount',
@@ -16,8 +19,11 @@ export const discountOnItemWithFacets = new PromotionItemAction({
             list: true,
         },
     },
-    async execute(orderItem, orderLine, args, { hasFacetValues }) {
-        if (await hasFacetValues(orderLine, args.facets)) {
+    init(injector) {
+        facetValueChecker = new FacetValueChecker(injector.getConnection());
+    },
+    async execute(orderItem, orderLine, args) {
+        if (await facetValueChecker.hasFacetValues(orderLine, args.facets)) {
             return -orderLine.unitPrice * (args.discount / 100);
         }
         return 0;

+ 8 - 3
packages/core/src/config/promotion/conditions/has-facet-values-condition.ts

@@ -2,6 +2,9 @@ import { LanguageCode } from '@vendure/common/lib/generated-types';
 
 import { Order } from '../../../entity/order/order.entity';
 import { PromotionCondition } from '../promotion-condition';
+import { FacetValueChecker } from '../utils/facet-value-checker';
+
+let facetValueChecker: FacetValueChecker;
 
 export const hasFacetValues = new PromotionCondition({
     code: 'at_least_n_with_facets',
@@ -12,12 +15,14 @@ export const hasFacetValues = new PromotionCondition({
         minimum: { type: 'int' },
         facets: { type: 'ID', list: true },
     },
-
+    init(injector) {
+        facetValueChecker = new FacetValueChecker(injector.getConnection());
+    },
     // tslint:disable-next-line:no-shadowed-variable
-    async check(order: Order, args, { hasFacetValues }) {
+    async check(order: Order, args) {
         let matches = 0;
         for (const line of order.lines) {
-            if (await hasFacetValues(line, args.facets)) {
+            if (await facetValueChecker.hasFacetValues(line, args.facets)) {
                 matches += line.quantity;
             }
         }

+ 4 - 8
packages/core/src/config/promotion/promotion-action.ts

@@ -10,8 +10,6 @@ import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { OrderLine } from '../../entity/order-line/order-line.entity';
 import { Order } from '../../entity/order/order.entity';
 
-import { PromotionUtils } from './promotion-condition';
-
 /**
  * @description
  * The function which is used by a PromotionItemAction to calculate the
@@ -24,7 +22,6 @@ export type ExecutePromotionItemActionFn<T extends ConfigArgs> = (
     orderItem: OrderItem,
     orderLine: OrderLine,
     args: ConfigArgValues<T>,
-    utils: PromotionUtils,
 ) => number | Promise<number>;
 
 /**
@@ -38,7 +35,6 @@ export type ExecutePromotionItemActionFn<T extends ConfigArgs> = (
 export type ExecutePromotionOrderActionFn<T extends ConfigArgs> = (
     order: Order,
     args: ConfigArgValues<T>,
-    utils: PromotionUtils,
 ) => number | Promise<number>;
 
 export interface PromotionActionConfig<T extends ConfigArgs> extends ConfigurableOperationDefOptions<T> {
@@ -117,8 +113,8 @@ export class PromotionItemAction<T extends ConfigArgs = ConfigArgs> extends Prom
     }
 
     /** @internal */
-    execute(orderItem: OrderItem, orderLine: OrderLine, args: ConfigArg[], utils: PromotionUtils) {
-        return this.executeFn(orderItem, orderLine, this.argsArrayToHash(args), utils);
+    execute(orderItem: OrderItem, orderLine: OrderLine, args: ConfigArg[]) {
+        return this.executeFn(orderItem, orderLine, this.argsArrayToHash(args));
     }
 }
 
@@ -150,7 +146,7 @@ export class PromotionOrderAction<T extends ConfigArgs = ConfigArgs> extends Pro
     }
 
     /** @internal */
-    execute(order: Order, args: ConfigArg[], utils: PromotionUtils) {
-        return this.executeFn(order, this.argsArrayToHash(args), utils);
+    execute(order: Order, args: ConfigArg[]) {
+        return this.executeFn(order, this.argsArrayToHash(args));
     }
 }

+ 2 - 23
packages/core/src/config/promotion/promotion-condition.ts

@@ -10,26 +10,6 @@ import {
 import { OrderLine } from '../../entity';
 import { Order } from '../../entity/order/order.entity';
 
-/**
- * @description
- * An object containing utility methods which may be used in promotion `check` functions
- * in order to determine whether a promotion should be applied.
- *
- * TODO: Remove this and use the new init() method to inject providers where needed.
- *
- * @docsCategory promotions
- * @docsPage promotion-condition
- */
-export interface PromotionUtils {
-    /**
-     * @description
-     * Checks a given {@link OrderLine} against the facetValueIds and returns
-     * `true` if the associated {@link ProductVariant} & {@link Product} together
-     * have *all* the specified {@link FacetValue}s.
-     */
-    hasFacetValues: (orderLine: OrderLine, facetValueIds: ID[]) => Promise<boolean>;
-}
-
 /**
  * @description
  * A function which checks whether or not a given {@link Order} satisfies the {@link PromotionCondition}.
@@ -40,7 +20,6 @@ export interface PromotionUtils {
 export type CheckPromotionConditionFn<T extends ConfigArgs> = (
     order: Order,
     args: ConfigArgValues<T>,
-    utils: PromotionUtils,
 ) => boolean | Promise<boolean>;
 
 export interface PromotionConditionConfig<T extends ConfigArgs> extends ConfigurableOperationDefOptions<T> {
@@ -67,7 +46,7 @@ export class PromotionCondition<T extends ConfigArgs = ConfigArgs> extends Confi
         this.priorityValue = config.priorityValue || 0;
     }
 
-    async check(order: Order, args: ConfigArg[], utils: PromotionUtils): Promise<boolean> {
-        return this.checkFn(order, this.argsArrayToHash(args), utils);
+    async check(order: Order, args: ConfigArg[]): Promise<boolean> {
+        return this.checkFn(order, this.argsArrayToHash(args));
     }
 }

+ 39 - 0
packages/core/src/config/promotion/utils/facet-value-checker.ts

@@ -0,0 +1,39 @@
+import { ID } from '@vendure/common/lib/shared-types';
+import { unique } from '@vendure/common/lib/unique';
+import { Connection } from 'typeorm';
+
+import { TtlCache } from '../../../common/ttl-cache';
+import { idsAreEqual } from '../../../common/utils';
+import { OrderLine } from '../../../entity/order-line/order-line.entity';
+import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
+
+export class FacetValueChecker {
+    private variantCache = new TtlCache<ID, ProductVariant>({ ttl: 5000 });
+
+    constructor(private connection: Connection) {}
+    /**
+     * @description
+     * Checks a given {@link OrderLine} against the facetValueIds and returns
+     * `true` if the associated {@link ProductVariant} & {@link Product} together
+     * have *all* the specified {@link FacetValue}s.
+     */
+    async hasFacetValues(orderLine: OrderLine, facetValueIds: ID[]): Promise<boolean> {
+        let variant = this.variantCache.get(orderLine.productVariant.id);
+        if (!variant) {
+            variant = await this.connection
+                .getRepository(ProductVariant)
+                .findOne(orderLine.productVariant.id, {
+                    relations: ['product', 'product.facetValues', 'facetValues'],
+                });
+            if (!variant) {
+                return false;
+            }
+            this.variantCache.set(variant.id, variant);
+        }
+        const allFacetValues = unique([...variant.facetValues, ...variant.product.facetValues], 'id');
+        return facetValueIds.reduce(
+            (result, id) => result && !!allFacetValues.find(fv => idsAreEqual(fv.id, id)),
+            true as boolean,
+        );
+    }
+}

+ 6 - 11
packages/core/src/entity/promotion/promotion.entity.ts

@@ -11,7 +11,6 @@ import {
     PromotionOrderAction,
 } from '../../config/promotion/promotion-action';
 import { PromotionCondition } from '../../config/promotion/promotion-condition';
-import { PromotionUtils } from '../../config/promotion/promotion-condition';
 import { Channel } from '../channel/channel.entity';
 import { OrderItem } from '../order-item/order-item.entity';
 import { OrderLine } from '../order-line/order-line.entity';
@@ -20,12 +19,10 @@ import { Order } from '../order/order.entity';
 export interface ApplyOrderItemActionArgs {
     orderItem: OrderItem;
     orderLine: OrderLine;
-    utils: PromotionUtils;
 }
 
 export interface ApplyOrderActionArgs {
     order: Order;
-    utils: PromotionUtils;
 }
 
 /**
@@ -109,15 +106,13 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
             const promotionAction = this.allActions[action.code];
             if (this.isItemAction(promotionAction)) {
                 if (this.isOrderItemArg(args)) {
-                    const { orderItem, orderLine, utils } = args;
-                    amount += Math.round(
-                        await promotionAction.execute(orderItem, orderLine, action.args, utils),
-                    );
+                    const { orderItem, orderLine } = args;
+                    amount += Math.round(await promotionAction.execute(orderItem, orderLine, action.args));
                 }
             } else {
                 if (!this.isOrderItemArg(args)) {
-                    const { order, utils } = args;
-                    amount += Math.round(await promotionAction.execute(order, action.args, utils));
+                    const { order } = args;
+                    amount += Math.round(await promotionAction.execute(order, action.args));
                 }
             }
         }
@@ -131,7 +126,7 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
         }
     }
 
-    async test(order: Order, utils: PromotionUtils): Promise<boolean> {
+    async test(order: Order): Promise<boolean> {
         if (this.endsAt && this.endsAt < new Date()) {
             return false;
         }
@@ -143,7 +138,7 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
         }
         for (const condition of this.conditions) {
             const promotionCondition = this.allConditions[condition.code];
-            if (!promotionCondition || !(await promotionCondition.check(order, condition.args, utils))) {
+            if (!promotionCondition || !(await promotionCondition.check(order, condition.args))) {
                 return false;
             }
         }

+ 14 - 14
packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts

@@ -172,7 +172,7 @@ describe('OrderCalculator', () => {
             code: 'percentage_item_action',
             description: [{ languageCode: LanguageCode.en, value: '' }],
             args: { discount: { type: 'int' } },
-            async execute(orderItem, orderLine, args, { hasFacetValues }) {
+            async execute(orderItem, orderLine, args) {
                 return -orderLine.unitPrice * (args.discount / 100);
             },
         });
@@ -212,7 +212,7 @@ describe('OrderCalculator', () => {
                 conditions: [
                     {
                         code: orderTotalCondition.code,
-                        args: [{ name: 'minimum', type: 'int', value: '100' }],
+                        args: [{ name: 'minimum', value: '100' }],
                     },
                 ],
                 promotionConditions: [orderTotalCondition],
@@ -251,7 +251,7 @@ describe('OrderCalculator', () => {
                 actions: [
                     {
                         code: percentageOrderAction.code,
-                        args: [{ name: 'discount', type: 'int', value: '50' }],
+                        args: [{ name: 'discount', value: '50' }],
                     },
                 ],
                 promotionActions: [percentageOrderAction],
@@ -278,7 +278,7 @@ describe('OrderCalculator', () => {
                 actions: [
                     {
                         code: percentageOrderAction.code,
-                        args: [{ name: 'discount', type: 'int', value: '50' }],
+                        args: [{ name: 'discount', value: '50' }],
                     },
                 ],
                 promotionActions: [percentageOrderAction],
@@ -305,7 +305,7 @@ describe('OrderCalculator', () => {
                 actions: [
                     {
                         code: percentageItemAction.code,
-                        args: [{ name: 'discount', type: 'int', value: '50' }],
+                        args: [{ name: 'discount', value: '50' }],
                     },
                 ],
                 promotionActions: [percentageItemAction],
@@ -332,7 +332,7 @@ describe('OrderCalculator', () => {
                 actions: [
                     {
                         code: percentageItemAction.code,
-                        args: [{ name: 'discount', type: 'int', value: '50' }],
+                        args: [{ name: 'discount', value: '50' }],
                     },
                 ],
                 promotionActions: [percentageItemAction],
@@ -374,14 +374,14 @@ describe('OrderCalculator', () => {
                 conditions: [
                     {
                         code: orderQuantityCondition.code,
-                        args: [{ name: 'minimum', type: 'int', value: '3' }],
+                        args: [{ name: 'minimum', value: '3' }],
                     },
                 ],
                 promotionConditions: [orderQuantityCondition],
                 actions: [
                     {
                         code: percentageOrderAction.code,
-                        args: [{ name: 'discount', type: 'int', value: '50' }],
+                        args: [{ name: 'discount', value: '50' }],
                     },
                 ],
                 promotionActions: [percentageOrderAction],
@@ -393,14 +393,14 @@ describe('OrderCalculator', () => {
                 conditions: [
                     {
                         code: orderTotalCondition.code,
-                        args: [{ name: 'minimum', type: 'int', value: '100' }],
+                        args: [{ name: 'minimum', value: '100' }],
                     },
                 ],
                 promotionConditions: [orderTotalCondition],
                 actions: [
                     {
                         code: percentageOrderAction.code,
-                        args: [{ name: 'discount', type: 'int', value: '10' }],
+                        args: [{ name: 'discount', value: '10' }],
                     },
                 ],
                 promotionActions: [percentageOrderAction],
@@ -479,7 +479,7 @@ describe('OrderCalculator', () => {
                 actions: [
                     {
                         code: percentageOrderAction.code,
-                        args: [{ name: 'discount', type: 'int', value: '10' }],
+                        args: [{ name: 'discount', value: '10' }],
                     },
                 ],
                 promotionActions: [percentageOrderAction],
@@ -494,7 +494,7 @@ describe('OrderCalculator', () => {
                 actions: [
                     {
                         code: percentageItemAction.code,
-                        args: [{ name: 'discount', type: 'int', value: '10' }],
+                        args: [{ name: 'discount', value: '10' }],
                     },
                 ],
                 promotionActions: [percentageItemAction],
@@ -550,14 +550,14 @@ describe('OrderCalculator', () => {
                     conditions: [
                         {
                             code: orderTotalCondition.code,
-                            args: [{ name: 'minimum', type: 'int', value: '10' }],
+                            args: [{ name: 'minimum', value: '10' }],
                         },
                     ],
                     promotionConditions: [orderTotalCondition],
                     actions: [
                         {
                             code: percentageOrderAction.code,
-                            args: [{ name: 'discount', type: 'int', value: '10' }],
+                            args: [{ name: 'discount', value: '10' }],
                         },
                     ],
                     promotionActions: [percentageOrderAction],

+ 11 - 44
packages/core/src/service/helpers/order-calculator/order-calculator.ts

@@ -2,16 +2,14 @@ import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { filterAsync } from '@vendure/common/lib/filter-async';
 import { AdjustmentType } from '@vendure/common/lib/generated-types';
-import { ID } from '@vendure/common/lib/shared-types';
-import { unique } from '@vendure/common/lib/unique';
 import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { InternalServerError } from '../../../common/error/errors';
 import { idsAreEqual } from '../../../common/utils';
-import { PromotionUtils, ShippingCalculationResult } from '../../../config';
+import { ShippingCalculationResult } from '../../../config';
 import { ConfigService } from '../../../config/config.service';
-import { OrderItem, OrderLine, ProductVariant, TaxCategory, TaxRate } from '../../../entity';
+import { OrderItem, OrderLine, TaxCategory, TaxRate } from '../../../entity';
 import { Order } from '../../../entity/order/order.entity';
 import { Promotion } from '../../../entity/promotion/promotion.entity';
 import { ShippingMethod } from '../../../entity/shipping-method/shipping-method.entity';
@@ -152,9 +150,8 @@ export class OrderCalculator {
      * any OrderItems which had their Adjustments modified.
      */
     private async applyPromotions(order: Order, promotions: Promotion[]): Promise<OrderItem[]> {
-        const utils = this.createPromotionUtils();
-        const updatedItems = await this.applyOrderItemPromotions(order, promotions, utils);
-        await this.applyOrderPromotions(order, promotions, utils);
+        const updatedItems = await this.applyOrderItemPromotions(order, promotions);
+        await this.applyOrderPromotions(order, promotions);
         return updatedItems;
     }
 
@@ -163,7 +160,7 @@ export class OrderCalculator {
      * of applying the promotions, and also due to added complexity in the name of performance
      * optimization. Therefore it is heavily annotated so that the purpose of each step is clear.
      */
-    private async applyOrderItemPromotions(order: Order, promotions: Promotion[], utils: PromotionUtils) {
+    private async applyOrderItemPromotions(order: Order, promotions: Promotion[]) {
         // The naive implementation updates *every* OrderItem after this function is run.
         // However, on a very large order with hundreds or thousands of OrderItems, this results in
         // very poor performance. E.g. updating a single quantity of an OrderLine results in saving
@@ -175,7 +172,7 @@ export class OrderCalculator {
         for (const line of order.lines) {
             // Must be re-calculated for each line, since the previous lines may have triggered promotions
             // which affected the order price.
-            const applicablePromotions = await filterAsync(promotions, p => p.test(order, utils));
+            const applicablePromotions = await filterAsync(promotions, p => p.test(order));
 
             const lineHasExistingPromotions =
                 line.items[0].pendingAdjustments &&
@@ -197,12 +194,11 @@ export class OrderCalculator {
                 // We need to test the promotion *again*, even though we've tested them for the line.
                 // This is because the previous Promotions may have adjusted the Order in such a way
                 // as to render later promotions no longer applicable.
-                if (await promotion.test(order, utils)) {
+                if (await promotion.test(order)) {
                     for (const item of line.items) {
                         const adjustment = await promotion.apply({
                             orderItem: item,
                             orderLine: line,
-                            utils,
                         });
                         if (adjustment) {
                             item.pendingAdjustments = item.pendingAdjustments.concat(adjustment);
@@ -255,15 +251,15 @@ export class OrderCalculator {
         return hasPromotionsThatAreNoLongerApplicable;
     }
 
-    private async applyOrderPromotions(order: Order, promotions: Promotion[], utils: PromotionUtils) {
+    private async applyOrderPromotions(order: Order, promotions: Promotion[]) {
         order.clearAdjustments(AdjustmentType.PROMOTION);
-        const applicableOrderPromotions = await filterAsync(promotions, p => p.test(order, utils));
+        const applicableOrderPromotions = await filterAsync(promotions, p => p.test(order));
         if (applicableOrderPromotions.length) {
             for (const promotion of applicableOrderPromotions) {
                 // re-test the promotion on each iteration, since the order total
                 // may be modified by a previously-applied promotion
-                if (await promotion.test(order, utils)) {
-                    const adjustment = await promotion.apply({ order, utils });
+                if (await promotion.test(order)) {
+                    const adjustment = await promotion.apply({ order });
                     if (adjustment) {
                         order.pendingAdjustments = order.pendingAdjustments.concat(adjustment);
                     }
@@ -300,33 +296,4 @@ export class OrderCalculator {
         order.subTotalBeforeTax = totalPriceBeforeTax;
         order.subTotal = totalPrice;
     }
-
-    /**
-     * Creates a new PromotionUtils object with a cache which lives as long as the created object.
-     */
-    private createPromotionUtils(): PromotionUtils {
-        const variantCache = new Map<ID, ProductVariant>();
-
-        return {
-            hasFacetValues: async (orderLine: OrderLine, facetValueIds: ID[]): Promise<boolean> => {
-                let variant = variantCache.get(orderLine.productVariant.id);
-                if (!variant) {
-                    variant = await this.connection
-                        .getRepository(ProductVariant)
-                        .findOne(orderLine.productVariant.id, {
-                            relations: ['product', 'product.facetValues', 'facetValues'],
-                        });
-                    if (!variant) {
-                        return false;
-                    }
-                    variantCache.set(variant.id, variant);
-                }
-                const allFacetValues = unique([...variant.facetValues, ...variant.product.facetValues], 'id');
-                return facetValueIds.reduce(
-                    (result, id) => result && !!allFacetValues.find(fv => idsAreEqual(fv.id, id)),
-                    true as boolean,
-                );
-            },
-        };
-    }
 }