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

feat(server): Implement a priority system for Promotions

This helps to ensure we apply promotions in a sensible sequence with the aim of yielding the most expected result when applying multiple promotions to a single order.
Michael Bromley 7 лет назад
Родитель
Сommit
dfe2501971

+ 16 - 1
server/src/config/promotion/default-promotion-actions.ts

@@ -18,4 +18,19 @@ export const itemPercentageDiscount = new PromotionAction({
     description: 'Discount every item by { discount }%',
 });
 
-export const defaultPromotionActions = [orderPercentageDiscount, itemPercentageDiscount];
+export const buy1Get1Free = new PromotionAction({
+    code: 'buy_1_get_1_free',
+    args: {},
+    execute(orderItem, orderLine, args) {
+        if (orderLine.quantity >= 2) {
+            const lineIndex = orderLine.items.indexOf(orderItem) + 1;
+            if (lineIndex % 2 === 0) {
+                return -orderLine.unitPrice;
+            }
+        }
+        return 0;
+    },
+    description: 'Discount every item by { discount }%',
+});
+
+export const defaultPromotionActions = [orderPercentageDiscount, itemPercentageDiscount, buy1Get1Free];

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

@@ -16,6 +16,7 @@ export const minimumOrderAmount = new PromotionCondition({
             return order.totalPriceBeforeTax >= args.amount;
         }
     },
+    priorityValue: 10,
 });
 
 export const dateRange = new PromotionCondition({

+ 3 - 0
server/src/config/promotion/promotion-action.ts

@@ -19,6 +19,7 @@ export class PromotionAction<T extends PromotionActionArgs = {}> {
     readonly code: string;
     readonly args: PromotionActionArgs;
     readonly description: string;
+    readonly priorityValue: number;
     private readonly executeFn: ExecutePromotionActionFn<T>;
 
     constructor(config: {
@@ -26,11 +27,13 @@ export class PromotionAction<T extends PromotionActionArgs = {}> {
         execute: ExecutePromotionActionFn<T>;
         code: string;
         description: string;
+        priorityValue?: number;
     }) {
         this.code = config.code;
         this.description = config.description;
         this.args = config.args;
         this.executeFn = config.execute;
+        this.priorityValue = config.priorityValue || 0;
     }
 
     execute(orderItem: OrderItem, orderLine: OrderLine, args: AdjustmentArg[]) {

+ 9 - 1
server/src/config/promotion/promotion-condition.ts

@@ -21,13 +21,21 @@ export class PromotionCondition<T extends PromotionConditionArgs = {}> {
     readonly code: string;
     readonly description: string;
     readonly args: PromotionConditionArgs;
+    readonly priorityValue: number;
     private readonly checkFn: CheckPromotionConditionFn<T>;
 
-    constructor(config: { args: T; check: CheckPromotionConditionFn<T>; code: string; description: string }) {
+    constructor(config: {
+        args: T;
+        check: CheckPromotionConditionFn<T>;
+        code: string;
+        description: string;
+        priorityValue?: number;
+    }) {
         this.code = config.code;
         this.description = config.description;
         this.args = config.args;
         this.checkFn = config.check;
+        this.priorityValue = config.priorityValue || 0;
     }
 
     check(order: Order, args: AdjustmentArg[]) {

+ 15 - 0
server/src/entity/promotion/promotion.entity.ts

@@ -38,6 +38,21 @@ export class Promotion extends AdjustmentSource {
 
     @Column('simple-json') actions: AdjustmentOperation[];
 
+    /**
+     * The PriorityScore is used to determine the sequence in which multiple promotions are tested
+     * on a given order. A higher number moves the Promotion towards the end of the sequence.
+     *
+     * The score is derived from the sum of the priorityValues of the PromotionConditions and
+     * PromotionActions comprising this Promotion.
+     *
+     * An example illustrating the need for a priority is this:
+     *
+     * Consider 2 Promotions, 1) buy 1 get one free and 2) 10% off when order total is over $50.
+     * If Promotion 2 is evaluated prior to Promotion 1, then it can trigger the 10% discount even
+     * if the subsequent application of Promotion 1 brings the order total down to way below $50.
+     */
+    @Column() priorityScore: number;
+
     apply(orderItem: OrderItem, orderLine: OrderLine): Adjustment | undefined {
         let amount = 0;
 

+ 9 - 7
server/src/service/helpers/order-calculator/order-calculator.ts

@@ -70,17 +70,19 @@ export class OrderCalculator {
 
             line.clearAdjustments(AdjustmentType.PROMOTION);
 
-            for (const item of line.items) {
-                if (applicablePromotions) {
-                    for (const promotion of applicablePromotions) {
-                        const adjustment = promotion.apply(item, line);
-                        if (adjustment) {
-                            item.pendingAdjustments = item.pendingAdjustments.concat(adjustment);
+            for (const promotion of applicablePromotions) {
+                if (promotion.test(order)) {
+                    for (const item of line.items) {
+                        if (applicablePromotions) {
+                            const adjustment = promotion.apply(item, line);
+                            if (adjustment) {
+                                item.pendingAdjustments = item.pendingAdjustments.concat(adjustment);
+                            }
                         }
                     }
                 }
+                this.calculateOrderTotals(order);
             }
-            this.calculateOrderTotals(order);
         }
     }
 

+ 13 - 1
server/src/service/services/promotion.service.ts

@@ -94,6 +94,7 @@ export class PromotionService {
             enabled: input.enabled,
             conditions: input.conditions.map(c => this.parseOperationArgs('condition', c)),
             actions: input.actions.map(a => this.parseOperationArgs('action', a)),
+            priorityScore: this.calculatePriorityScore(input),
         });
         this.channelService.assignToChannels(adjustmentSource, ctx);
         const newAdjustmentSource = await this.connection.manager.save(adjustmentSource);
@@ -118,7 +119,8 @@ export class PromotionService {
         if (input.actions) {
             updatedAdjustmentSource.actions = input.actions.map(a => this.parseOperationArgs('action', a));
         }
-        await this.connection.manager.save(updatedAdjustmentSource);
+        (adjustmentSource.priorityScore = this.calculatePriorityScore(input)),
+            await this.connection.manager.save(updatedAdjustmentSource);
         await this.updatePromotions();
         return assertFound(this.findOne(updatedAdjustmentSource.id));
     }
@@ -145,6 +147,16 @@ export class PromotionService {
         return output;
     }
 
+    private calculatePriorityScore(input: CreatePromotionInput | UpdatePromotionInput): number {
+        const conditions = input.conditions
+            ? input.conditions.map(c => this.getAdjustmentOperationByCode('condition', c.code))
+            : [];
+        const actions = input.actions
+            ? input.actions.map(c => this.getAdjustmentOperationByCode('action', c.code))
+            : [];
+        return [...conditions, ...actions].reduce((score, op) => score + op.priorityValue, 0);
+    }
+
     private getAdjustmentOperationByCode(
         type: 'condition' | 'action',
         code: string,