Explorar el Código

feat(server): PromotionCondition check fn can be async, add facet check

Michael Bromley hace 7 años
padre
commit
3271ae21c6

+ 22 - 2
server/src/config/common/config-args.ts

@@ -1,4 +1,5 @@
 import { ConfigArg } from '../../../../shared/generated-types';
+import { InternalServerError } from '../../common/error/errors';
 
 /**
  * Certain entities allow arbitrary configuration arguments to be specified which can then
@@ -7,7 +8,14 @@ import { ConfigArg } from '../../../../shared/generated-types';
  * 1. How the argument form field is rendered in the admin-ui
  * 2. The JavaScript type into which the value is coerced before being passed to the business logic.
  */
-export type ConfigArgType = 'percentage' | 'money' | 'int' | 'string' | 'datetime' | 'boolean';
+export type ConfigArgType =
+    | 'percentage'
+    | 'money'
+    | 'int'
+    | 'string'
+    | 'datetime'
+    | 'boolean'
+    | 'facetValueIds';
 
 export type ConfigArgs<T extends ConfigArgType> = {
     [name: string]: T;
@@ -21,7 +29,13 @@ export type ConfigArgs<T extends ConfigArgType> = {
 export type ConfigArgValues<T extends ConfigArgs<any>> = {
     [K in keyof T]: T[K] extends 'int' | 'money' | 'percentage'
         ? number
-        : T[K] extends 'datetime' ? Date : T[K] extends 'boolean' ? boolean : string
+        : T[K] extends 'datetime'
+            ? Date
+            : T[K] extends 'boolean'
+                ? boolean
+                : T[K] extends 'facetValueIds'
+                    ? string[]
+                    : string
 };
 
 export function argsArrayToHash<T>(args: ConfigArg[]): ConfigArgValues<T> {
@@ -43,6 +57,12 @@ function coerceValueToType<T>(arg: ConfigArg): ConfigArgValues<T>[keyof T] {
             return Date.parse(arg.value || '') as any;
         case 'boolean':
             return !!arg.value as any;
+        case 'facetValueIds':
+            try {
+                return JSON.parse(arg.value as any);
+            } catch (err) {
+                throw new InternalServerError(err.message);
+            }
         default:
             return (arg.value as string) as any;
     }

+ 21 - 1
server/src/config/promotion/default-promotion-conditions.ts

@@ -40,4 +40,24 @@ export const atLeastNOfProduct = new PromotionCondition({
     },
 });
 
-export const defaultPromotionConditions = [minimumOrderAmount, dateRange, atLeastNOfProduct];
+export const atLeastNWithFacets = new PromotionCondition({
+    code: 'at_least_n_with_facets',
+    description: 'Buy at least { minimum } with the given facets',
+    args: { minimum: 'int', facets: 'facetValueIds' },
+    async check(order: Order, args, { hasFacetValues }) {
+        let matches = 0;
+        for (const line of order.lines) {
+            if (await hasFacetValues(line, args.facets)) {
+                matches += line.quantity;
+            }
+        }
+        return args.minimum <= matches;
+    },
+});
+
+export const defaultPromotionConditions = [
+    minimumOrderAmount,
+    dateRange,
+    atLeastNOfProduct,
+    atLeastNWithFacets,
+];

+ 16 - 4
server/src/config/promotion/promotion-condition.ts

@@ -1,14 +1,26 @@
 import { ConfigArg } from '../../../../shared/generated-types';
 
+import { ID } from '../../../../shared/shared-types';
+import { OrderLine } from '../../entity';
 import { Order } from '../../entity/order/order.entity';
 import { argsArrayToHash, ConfigArgs, ConfigArgValues } from '../common/config-args';
 
-export type PromotionConditionArgType = 'int' | 'money' | 'string' | 'datetime' | 'boolean';
+export type PromotionConditionArgType = 'int' | 'money' | 'string' | 'datetime' | 'boolean' | 'facetValueIds';
 export type PromotionConditionArgs = ConfigArgs<PromotionConditionArgType>;
+
+/**
+ * An object containing utility methods which may be used in promotion `check` functions
+ * in order to determine whether a promotion should be applied.
+ */
+export interface PromotionUtils {
+    hasFacetValues: (orderLine: OrderLine, facetValueIds: ID[]) => Promise<boolean>;
+}
+
 export type CheckPromotionConditionFn<T extends PromotionConditionArgs> = (
     order: Order,
     args: ConfigArgValues<T>,
-) => boolean;
+    utils: PromotionUtils,
+) => boolean | Promise<boolean>;
 
 export class PromotionCondition<T extends PromotionConditionArgs = {}> {
     readonly code: string;
@@ -31,7 +43,7 @@ export class PromotionCondition<T extends PromotionConditionArgs = {}> {
         this.priorityValue = config.priorityValue || 0;
     }
 
-    check(order: Order, args: ConfigArg[]) {
-        return this.checkFn(order, argsArrayToHash<T>(args));
+    async check(order: Order, args: ConfigArg[], utils: PromotionUtils): Promise<boolean> {
+        return await this.checkFn(order, argsArrayToHash<T>(args), utils);
     }
 }

+ 18 - 6
server/src/entity/promotion/promotion.entity.ts

@@ -5,8 +5,13 @@ import { DeepPartial } from '../../../../shared/shared-types';
 import { AdjustmentSource } from '../../common/types/adjustment-source';
 import { ChannelAware } from '../../common/types/common-types';
 import { getConfig } from '../../config/config-helpers';
-import { PromotionItemAction, PromotionOrderAction } from '../../config/promotion/promotion-action';
+import {
+    PromotionAction,
+    PromotionItemAction,
+    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';
@@ -18,10 +23,17 @@ export class Promotion extends AdjustmentSource implements ChannelAware {
     private readonly allConditions: { [code: string]: PromotionCondition } = {};
     private readonly allActions: { [code: string]: PromotionItemAction | PromotionOrderAction } = {};
 
-    constructor(input?: DeepPartial<Promotion>) {
+    constructor(
+        input?: DeepPartial<Promotion> & {
+            promotionConditions?: Array<PromotionCondition<any>>;
+            promotionActions?: Array<PromotionAction<any>>;
+        },
+    ) {
         super(input);
-        const conditions = getConfig().promotionOptions.promotionConditions || [];
-        const actions = getConfig().promotionOptions.promotionActions || [];
+        const conditions =
+            (input && input.promotionConditions) || getConfig().promotionOptions.promotionConditions || [];
+        const actions =
+            (input && input.promotionActions) || getConfig().promotionOptions.promotionActions || [];
         this.allConditions = conditions.reduce((hash, o) => ({ ...hash, [o.code]: o }), {});
         this.allActions = actions.reduce((hash, o) => ({ ...hash, [o.code]: o }), {});
     }
@@ -80,10 +92,10 @@ export class Promotion extends AdjustmentSource implements ChannelAware {
         }
     }
 
-    test(order: Order): boolean {
+    async test(order: Order, utils: PromotionUtils): Promise<boolean> {
         for (const condition of this.conditions) {
             const promotionCondition = this.allConditions[condition.code];
-            if (!promotionCondition || !promotionCondition.check(order, condition.args)) {
+            if (!promotionCondition || !(await promotionCondition.check(order, condition.args, utils))) {
                 return false;
             }
         }

+ 185 - 1
server/src/service/helpers/order-calculator/order-calculator.spec.ts

@@ -2,10 +2,13 @@ import { Test } from '@nestjs/testing';
 import { Connection } from 'typeorm';
 
 import { Omit } from '../../../../../shared/omit';
+import { PromotionAction, PromotionItemAction, PromotionOrderAction } from '../../../config';
 import { ConfigService } from '../../../config/config.service';
 import { MockConfigService } from '../../../config/config.service.mock';
+import { PromotionCondition } from '../../../config/promotion/promotion-condition';
 import { DefaultTaxCalculationStrategy } from '../../../config/tax/default-tax-calculation-strategy';
 import { DefaultTaxZoneStrategy } from '../../../config/tax/default-tax-zone-strategy';
+import { Promotion } from '../../../entity';
 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';
@@ -73,7 +76,7 @@ describe('OrderCalculator', () => {
         });
     }
 
-    describe('taxes only', () => {
+    describe('taxes', () => {
         it('single line with taxes not included', async () => {
             const ctx = createRequestContext(false);
             const order = createOrder({
@@ -120,4 +123,185 @@ describe('OrderCalculator', () => {
             expect(order.subTotalBeforeTax).toBe(0);
         });
     });
+
+    describe('promotions', () => {
+        const alwaysTrueCondition = new PromotionCondition({
+            args: {},
+            code: 'always_true_condition',
+            description: '',
+            check() {
+                return true;
+            },
+        });
+
+        const orderTotalCondition = new PromotionCondition({
+            args: { minimum: 'money' },
+            code: 'order_total_condition',
+            description: '',
+            check(order, args) {
+                return args.minimum <= order.total;
+            },
+        });
+
+        const fixedPriceItemAction = new PromotionItemAction({
+            code: 'fixed_price_item_action',
+            description: '',
+            args: {},
+            execute(item) {
+                return -item.unitPrice + 42;
+            },
+        });
+
+        const fixedPriceOrderAction = new PromotionOrderAction({
+            code: 'fixed_price_item_action',
+            description: '',
+            args: {},
+            execute(order) {
+                return -order.total + 42;
+            },
+        });
+
+        it('single line with single applicable promotion', async () => {
+            const promotion = new Promotion({
+                id: 1,
+                conditions: [{ code: alwaysTrueCondition.code, args: [] }],
+                promotionConditions: [alwaysTrueCondition],
+                actions: [{ code: fixedPriceOrderAction.code, args: [] }],
+                promotionActions: [fixedPriceOrderAction],
+            });
+
+            const ctx = createRequestContext(true);
+            const order = createOrder({
+                lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 1 }],
+            });
+            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+
+            expect(order.subTotal).toBe(123);
+            expect(order.total).toBe(42);
+        });
+
+        it('condition based on order total', async () => {
+            const promotion = new Promotion({
+                id: 1,
+                name: 'Test Promotion 1',
+                conditions: [
+                    {
+                        code: orderTotalCondition.code,
+                        args: [{ name: 'minimum', type: 'money', value: '100' }],
+                    },
+                ],
+                promotionConditions: [orderTotalCondition],
+                actions: [{ code: fixedPriceOrderAction.code, args: [] }],
+                promotionActions: [fixedPriceOrderAction],
+            });
+
+            const ctx = createRequestContext(true);
+            const order = createOrder({
+                lines: [{ unitPrice: 50, taxCategory: taxCategoryStandard, quantity: 1 }],
+            });
+            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+
+            expect(order.subTotal).toBe(50);
+            expect(order.adjustments.length).toBe(0);
+            expect(order.total).toBe(50);
+
+            // increase the quantity to 2, which will take the total over the minimum set by the
+            // condition.
+            order.lines[0].items.push(new OrderItem({ unitPrice: 50 }));
+
+            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+
+            expect(order.subTotal).toBe(100);
+            // Now the fixedPriceOrderAction should be in effect
+            expect(order.adjustments.length).toBe(1);
+            expect(order.total).toBe(42);
+        });
+
+        it('interaction between promotions', async () => {
+            const orderQuantityCondition = new PromotionCondition({
+                args: { minimum: 'int' },
+                code: 'order_quantity_condition',
+                description: 'Passes if any order line has at least the minimum quantity',
+                check(_order, args) {
+                    for (const line of _order.lines) {
+                        if (args.minimum <= line.quantity) {
+                            return true;
+                        }
+                    }
+                    return false;
+                },
+            });
+
+            const orderPercentageDiscount = new PromotionOrderAction({
+                code: 'order_percentage_discount',
+                args: { discount: 'percentage' },
+                execute(_order, args) {
+                    return -_order.subTotal * (args.discount / 100);
+                },
+                description: 'Discount order by { discount }%',
+            });
+
+            const promotion1 = new Promotion({
+                id: 1,
+                name: 'Buy 3 Get 50% off order',
+                conditions: [
+                    {
+                        code: orderQuantityCondition.code,
+                        args: [{ name: 'minimum', type: 'int', value: '3' }],
+                    },
+                ],
+                promotionConditions: [orderQuantityCondition],
+                actions: [
+                    {
+                        code: orderPercentageDiscount.code,
+                        args: [{ name: 'discount', type: 'percentage', value: '50' }],
+                    },
+                ],
+                promotionActions: [orderPercentageDiscount],
+            });
+
+            const promotion2 = new Promotion({
+                id: 1,
+                name: 'Spend $100 Get 10% off order',
+                conditions: [
+                    {
+                        code: orderTotalCondition.code,
+                        args: [{ name: 'minimum', type: 'money', value: '100' }],
+                    },
+                ],
+                promotionConditions: [orderTotalCondition],
+                actions: [
+                    {
+                        code: orderPercentageDiscount.code,
+                        args: [{ name: 'discount', type: 'percentage', value: '10' }],
+                    },
+                ],
+                promotionActions: [orderPercentageDiscount],
+            });
+
+            const ctx = createRequestContext(true);
+            const order = createOrder({
+                lines: [{ unitPrice: 50, taxCategory: taxCategoryStandard, quantity: 2 }],
+            });
+
+            // initially the order is $100, so the second promotion applies
+            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion1, promotion2]);
+
+            expect(order.subTotal).toBe(100);
+            expect(order.adjustments.length).toBe(1);
+            expect(order.adjustments[0].description).toBe(promotion2.name);
+            expect(order.total).toBe(90);
+
+            // increase the quantity to 3, which will trigger the first promotion and thus
+            // bring the order total below the threshold for the second promotion.
+            order.lines[0].items.push(new OrderItem({ unitPrice: 50 }));
+
+            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion1, promotion2]);
+
+            expect(order.subTotal).toBe(150);
+            expect(order.adjustments.length).toBe(1);
+            // expect(order.adjustments[0].description).toBe(promotion1.name);
+            expect(order.total).toBe(75);
+        });
+    });
 });

+ 44 - 8
server/src/service/helpers/order-calculator/order-calculator.ts

@@ -1,10 +1,16 @@
 import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import { Connection } from 'typeorm';
 
+import { filterAsync } from '../../../../../shared/filter-async';
 import { AdjustmentType } from '../../../../../shared/generated-types';
 import { ID } from '../../../../../shared/shared-types';
+import { unique } from '../../../../../shared/unique';
 import { RequestContext } from '../../../api/common/request-context';
 import { idsAreEqual } from '../../../common/utils';
+import { PromotionUtils } from '../../../config';
 import { ConfigService } from '../../../config/config.service';
+import { OrderLine, ProductVariant } 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';
@@ -17,6 +23,7 @@ import { TaxCalculator } from '../tax-calculator/tax-calculator';
 @Injectable()
 export class OrderCalculator {
     constructor(
+        @InjectConnection() private connection: Connection,
         private configService: ConfigService,
         private zoneService: ZoneService,
         private taxRateService: TaxRateService,
@@ -24,6 +31,24 @@ export class OrderCalculator {
         private shippingCalculator: ShippingCalculator,
     ) {}
 
+    private readonly promotionUtils: PromotionUtils = {
+        hasFacetValues: async (orderLine: OrderLine, facetValueIds: ID[]): Promise<boolean> => {
+            const variant = await this.connection
+                .getRepository(ProductVariant)
+                .findOne(orderLine.productVariant.id, {
+                    relations: ['product', 'product.facetValues', 'facetValues'],
+                });
+            if (!variant) {
+                return false;
+            }
+            const allFacetValues = unique([...variant.facetValues, ...variant.product.facetValues], 'id');
+            return facetValueIds.reduce(
+                (result, id) => result && !!allFacetValues.find(fv => idsAreEqual(fv.id, id)),
+                true,
+            );
+        },
+    };
+
     /**
      * Applies taxes and promotions to an Order. Mutates the order object.
      */
@@ -36,7 +61,7 @@ export class OrderCalculator {
             // First apply taxes to the non-discounted prices
             this.applyTaxes(ctx, order, activeTaxZone);
             // Then test and apply promotions
-            this.applyPromotions(order, promotions);
+            await this.applyPromotions(order, promotions);
             // Finally, re-calculate taxes because the promotions may have
             // altered the unit prices, which in turn will alter the tax payable.
             this.applyTaxes(ctx, order, activeTaxZone);
@@ -79,14 +104,18 @@ export class OrderCalculator {
     /**
      * Applies any eligible promotions to each OrderItem in the order.
      */
-    private applyPromotions(order: Order, promotions: Promotion[]) {
+    private async applyPromotions(order: Order, promotions: Promotion[]) {
         for (const line of order.lines) {
-            const applicablePromotions = promotions.filter(p => p.test(order));
+            // 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, this.promotionUtils),
+            );
 
             line.clearAdjustments(AdjustmentType.PROMOTION);
 
             for (const promotion of applicablePromotions) {
-                if (promotion.test(order)) {
+                if (await promotion.test(order, this.promotionUtils)) {
                     for (const item of line.items) {
                         if (applicablePromotions) {
                             const adjustment = promotion.apply(item, line);
@@ -99,12 +128,19 @@ export class OrderCalculator {
                 this.calculateOrderTotals(order);
             }
         }
-        const applicableOrderPromotions = promotions.filter(p => p.test(order));
+
+        const applicableOrderPromotions = await filterAsync(promotions, p =>
+            p.test(order, this.promotionUtils),
+        );
         if (applicableOrderPromotions.length) {
             for (const promotion of applicableOrderPromotions) {
-                const adjustment = promotion.apply(order);
-                if (adjustment) {
-                    order.pendingAdjustments = order.pendingAdjustments.concat(adjustment);
+                // re-test the promotion on each iteration, since the order total
+                // may be modified by a previously-applied promotion
+                if (await promotion.test(order, this.promotionUtils)) {
+                    const adjustment = promotion.apply(order);
+                    if (adjustment) {
+                        order.pendingAdjustments = order.pendingAdjustments.concat(adjustment);
+                    }
                 }
             }
             this.calculateOrderTotals(order);

+ 1 - 1
server/src/service/services/product.service.ts

@@ -51,7 +51,7 @@ export class ProductService {
 
     async findAll(
         ctx: RequestContext,
-        options?: ListQueryOptions<Product> & { categoryId?: ID },
+        options?: ListQueryOptions<Product> & { categoryId?: string | null },
     ): Promise<PaginatedList<Translated<Product>>> {
         let where: FindConditions<Product> | undefined;
         if (options && options.categoryId) {

+ 33 - 0
shared/filter-async.spec.ts

@@ -0,0 +1,33 @@
+import { filterAsync } from './filter-async';
+
+describe('filterAsync', () => {
+    it('filters an array of promises', async () => {
+        const a = Promise.resolve(true);
+        const b = Promise.resolve(false);
+        const c = Promise.resolve(true);
+        const input = [a, b, c];
+
+        const result = await filterAsync(input, item => item);
+        expect(result).toEqual([a, c]);
+    });
+
+    it('filters a mixed array', async () => {
+        const a = { value: Promise.resolve(true) };
+        const b = { value: Promise.resolve(false) };
+        const c = { value: true };
+        const input = [a, b, c];
+
+        const result = await filterAsync(input, item => item.value);
+        expect(result).toEqual([a, c]);
+    });
+
+    it('filters a sync array', async () => {
+        const a = { value: true };
+        const b = { value: false };
+        const c = { value: true };
+        const input = [a, b, c];
+
+        const result = await filterAsync(input, item => item.value);
+        expect(result).toEqual([a, c]);
+    });
+});

+ 9 - 0
shared/filter-async.ts

@@ -0,0 +1,9 @@
+/**
+ * Performs a filter operation where the predicate is an async function returning a Promise.
+ */
+export async function filterAsync<T>(arr: T[], predicate: (item: T, index: number) => Promise<boolean> | boolean): Promise<T[]> {
+    const results: boolean[] = await Promise.all(
+        arr.map(async (value, index) => predicate(value, index)),
+    );
+    return arr.filter((_, i) => results[i]);
+}