Ver código fonte

feat(server): Implement order-level promotion actions

Relates to #29
Michael Bromley 7 anos atrás
pai
commit
291a173a79

+ 2 - 2
server/e2e/promotion.e2e-spec.ts

@@ -15,7 +15,7 @@ import {
     GET_PROMOTION_LIST,
     UPDATE_PROMOTION,
 } from '../../admin-ui/src/app/data/definitions/promotion-definitions';
-import { PromotionAction } from '../src/config/promotion/promotion-action';
+import { PromotionAction, PromotionOrderAction } from '../src/config/promotion/promotion-action';
 import { PromotionCondition } from '../src/config/promotion/promotion-condition';
 
 import { TestClient } from './test-client';
@@ -140,7 +140,7 @@ function generateTestCondition(code: string): PromotionCondition<any> {
 }
 
 function generateTestAction(code: string): PromotionAction<any> {
-    return new PromotionAction({
+    return new PromotionOrderAction({
         code,
         description: `description for ${code}`,
         args: { percentage: 'percentage' },

+ 0 - 1
server/src/common/types/adjustment-source.ts

@@ -1,5 +1,4 @@
 import { Adjustment, AdjustmentType } from 'shared/generated-types';
-import { ID } from 'shared/shared-types';
 
 import { VendureEntity } from '../../entity/base/base.entity';
 

+ 6 - 6
server/src/config/promotion/default-promotion-actions.ts

@@ -1,15 +1,15 @@
-import { PromotionAction } from './promotion-action';
+import { PromotionAction, PromotionItemAction, PromotionOrderAction } from './promotion-action';
 
-export const orderPercentageDiscount = new PromotionAction({
+export const orderPercentageDiscount = new PromotionOrderAction({
     code: 'order_percentage_discount',
     args: { discount: 'percentage' },
-    execute(orderItem, orderLine, args) {
-        return -orderLine.unitPrice * (args.discount / 100);
+    execute(order, args) {
+        return -order.subTotal * (args.discount / 100);
     },
     description: 'Discount order by { discount }%',
 });
 
-export const itemPercentageDiscount = new PromotionAction({
+export const itemPercentageDiscount = new PromotionItemAction({
     code: 'item_percentage_discount',
     args: { discount: 'percentage' },
     execute(orderItem, orderLine, args) {
@@ -18,7 +18,7 @@ export const itemPercentageDiscount = new PromotionAction({
     description: 'Discount every item by { discount }%',
 });
 
-export const buy1Get1Free = new PromotionAction({
+export const buy1Get1Free = new PromotionItemAction({
     code: 'buy_1_get_1_free',
     args: {},
     execute(orderItem, orderLine, args) {

+ 2 - 2
server/src/config/promotion/default-promotion-conditions.ts

@@ -11,9 +11,9 @@ export const minimumOrderAmount = new PromotionCondition({
     },
     check(order, args) {
         if (args.taxInclusive) {
-            return order.totalPrice >= args.amount;
+            return order.subTotal >= args.amount;
         } else {
-            return order.totalPriceBeforeTax >= args.amount;
+            return order.subTotalBeforeTax >= args.amount;
         }
     },
     priorityValue: 10,

+ 52 - 17
server/src/config/promotion/promotion-action.ts

@@ -9,38 +9,43 @@ export type PromotionActionArgs = {
     [name: string]: PromotionActionArgType;
 };
 export type ArgumentValues<T extends PromotionActionArgs> = { [K in keyof T]: number };
-export type ExecutePromotionActionFn<T extends PromotionActionArgs> = (
+export type ExecutePromotionItemActionFn<T extends PromotionActionArgs> = (
     orderItem: OrderItem,
     orderLine: OrderLine,
     args: ArgumentValues<T>,
 ) => number;
+export type ExecutePromotionOrderActionFn<T extends PromotionActionArgs> = (
+    order: Order,
+    args: ArgumentValues<T>,
+) => number;
 
-export class PromotionAction<T extends PromotionActionArgs = {}> {
+export interface PromotionActionConfig<T extends PromotionActionArgs> {
+    args: T;
+    code: string;
+    description: string;
+    priorityValue?: number;
+}
+export interface PromotionItemActionConfig<T extends PromotionActionArgs> extends PromotionActionConfig<T> {
+    execute: ExecutePromotionItemActionFn<T>;
+}
+export interface PromotionOrderActionConfig<T extends PromotionActionArgs> extends PromotionActionConfig<T> {
+    execute: ExecutePromotionOrderActionFn<T>;
+}
+
+export abstract class PromotionAction<T extends PromotionActionArgs = {}> {
     readonly code: string;
     readonly args: PromotionActionArgs;
     readonly description: string;
     readonly priorityValue: number;
-    private readonly executeFn: ExecutePromotionActionFn<T>;
-
-    constructor(config: {
-        args: T;
-        execute: ExecutePromotionActionFn<T>;
-        code: string;
-        description: string;
-        priorityValue?: number;
-    }) {
+
+    protected constructor(config: PromotionActionConfig<T>) {
         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[]) {
-        return this.executeFn(orderItem, orderLine, this.argsArrayToHash(args));
-    }
-
-    private argsArrayToHash(args: AdjustmentArg[]): ArgumentValues<T> {
+    protected argsArrayToHash(args: AdjustmentArg[]): ArgumentValues<T> {
         const output: ArgumentValues<T> = {} as any;
         for (const arg of args) {
             if (arg.value != null) {
@@ -50,3 +55,33 @@ export class PromotionAction<T extends PromotionActionArgs = {}> {
         return output;
     }
 }
+
+/**
+ * Represents a PromotionAction which applies to individual OrderItems.
+ */
+export class PromotionItemAction<T extends PromotionActionArgs = {}> extends PromotionAction<T> {
+    private readonly executeFn: ExecutePromotionItemActionFn<T>;
+    constructor(config: PromotionItemActionConfig<T>) {
+        super(config);
+        this.executeFn = config.execute;
+    }
+
+    execute(orderItem: OrderItem, orderLine: OrderLine, args: AdjustmentArg[]) {
+        return this.executeFn(orderItem, orderLine, this.argsArrayToHash(args));
+    }
+}
+
+/**
+ * Represents a PromotionAction which applies to the Order as a whole.
+ */
+export class PromotionOrderAction<T extends PromotionActionArgs = {}> extends PromotionAction<T> {
+    private readonly executeFn: ExecutePromotionOrderActionFn<T>;
+    constructor(config: PromotionOrderActionConfig<T>) {
+        super(config);
+        this.executeFn = config.execute;
+    }
+
+    execute(order: Order, args: AdjustmentArg[]) {
+        return this.executeFn(order, this.argsArrayToHash(args));
+    }
+}

+ 1 - 1
server/src/entity/order-item/order-item.entity.ts

@@ -75,7 +75,7 @@ export class OrderItem extends VendureEntity {
     }
 
     get promotionAdjustmentsTotal(): number {
-        return this.pendingAdjustments
+        return this.adjustments
             .filter(a => a.type === AdjustmentType.PROMOTION)
             .reduce((total, a) => total + a.amount, 0);
     }

+ 32 - 3
server/src/entity/order/order.entity.ts

@@ -1,7 +1,8 @@
-import { AdjustmentType } from 'shared/generated-types';
+import { Adjustment, AdjustmentType } from 'shared/generated-types';
 import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 
+import { Calculated } from '../../common/calculated-decorator';
 import { VendureEntity } from '../base/base.entity';
 import { Customer } from '../customer/customer.entity';
 import { OrderItem } from '../order-item/order-item.entity';
@@ -21,15 +22,43 @@ export class Order extends VendureEntity {
     @OneToMany(type => OrderLine, line => line.order)
     lines: OrderLine[];
 
-    @Column() totalPriceBeforeTax: number;
+    @Column('simple-json') pendingAdjustments: Adjustment[];
 
-    @Column() totalPrice: number;
+    @Column() subTotalBeforeTax: number;
+
+    @Column() subTotal: number;
+
+    @Calculated()
+    get totalBeforeTax(): number {
+        return this.subTotalBeforeTax + this.promotionAdjustmentsTotal;
+    }
+
+    @Calculated()
+    get total(): number {
+        return this.subTotal + this.promotionAdjustmentsTotal;
+    }
+
+    @Calculated()
+    get adjustments(): Adjustment[] {
+        return this.pendingAdjustments;
+    }
+
+    get promotionAdjustmentsTotal(): number {
+        return this.adjustments
+            .filter(a => a.type === AdjustmentType.PROMOTION)
+            .reduce((total, a) => total + a.amount, 0);
+    }
 
     /**
      * Clears Adjustments from all OrderItems of the given type. If no type
      * is specified, then all adjustments are removed.
      */
     clearAdjustments(type?: AdjustmentType) {
+        if (!type) {
+            this.pendingAdjustments = [];
+        } else {
+            this.pendingAdjustments = this.pendingAdjustments.filter(a => a.type !== type);
+        }
         this.lines.forEach(line => line.clearAdjustments(type));
     }
 

+ 5 - 2
server/src/entity/order/order.graphql

@@ -5,6 +5,9 @@ type Order implements Node {
     code: String!
     customer: Customer
     lines: [OrderLine!]!
-    totalPriceBeforeTax: Int!
-    totalPrice: Int!
+    adjustments: [Adjustment!]!
+    subTotalBeforeTax: Int!
+    subTotal: Int!
+    totalBeforeTax: Int!
+    total: Int!
 }

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

@@ -3,7 +3,7 @@ import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
 
 import { AdjustmentSource } from '../../common/types/adjustment-source';
-import { PromotionAction } from '../../config/promotion/promotion-action';
+import { PromotionItemAction, PromotionOrderAction } from '../../config/promotion/promotion-action';
 import { PromotionCondition } from '../../config/promotion/promotion-condition';
 import { getConfig } from '../../config/vendure-config';
 import { Channel } from '../channel/channel.entity';
@@ -15,7 +15,7 @@ import { Order } from '../order/order.entity';
 export class Promotion extends AdjustmentSource {
     type = AdjustmentType.PROMOTION;
     private readonly allConditions: { [code: string]: PromotionCondition } = {};
-    private readonly allActions: { [code: string]: PromotionAction } = {};
+    private readonly allActions: { [code: string]: PromotionItemAction | PromotionOrderAction } = {};
 
     constructor(input?: DeepPartial<Promotion>) {
         super(input);
@@ -53,12 +53,22 @@ export class Promotion extends AdjustmentSource {
      */
     @Column() priorityScore: number;
 
-    apply(orderItem: OrderItem, orderLine: OrderLine): Adjustment | undefined {
+    apply(order: Order): Adjustment | undefined;
+    apply(orderItem: OrderItem, orderLine: OrderLine): Adjustment | undefined;
+    apply(orderItemOrOrder: OrderItem | Order, orderLine?: OrderLine): Adjustment | undefined {
         let amount = 0;
 
         for (const action of this.actions) {
             const promotionAction = this.allActions[action.code];
-            amount += Math.round(promotionAction.execute(orderItem, orderLine, action.args));
+            if (this.isItemAction(promotionAction)) {
+                if (orderItemOrOrder instanceof OrderItem && orderLine) {
+                    amount += Math.round(promotionAction.execute(orderItemOrOrder, orderLine, action.args));
+                }
+            } else {
+                if (orderItemOrOrder instanceof Order) {
+                    amount += Math.round(promotionAction.execute(orderItemOrOrder, action.args));
+                }
+            }
         }
         if (amount !== 0) {
             return {
@@ -79,4 +89,8 @@ export class Promotion extends AdjustmentSource {
         }
         return true;
     }
+
+    private isItemAction(value: PromotionItemAction | PromotionOrderAction): value is PromotionItemAction {
+        return value instanceof PromotionItemAction;
+    }
 }

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

@@ -68,8 +68,8 @@ describe('OrderCalculator', () => {
             });
             orderCalculator.applyTaxesAndPromotions(ctx, order, []);
 
-            expect(order.totalPrice).toBe(148);
-            expect(order.totalPriceBeforeTax).toBe(123);
+            expect(order.subTotal).toBe(148);
+            expect(order.subTotalBeforeTax).toBe(123);
         });
 
         it('single line with taxes not included, multiple items', () => {
@@ -79,8 +79,8 @@ describe('OrderCalculator', () => {
             });
             orderCalculator.applyTaxesAndPromotions(ctx, order, []);
 
-            expect(order.totalPrice).toBe(444);
-            expect(order.totalPriceBeforeTax).toBe(369);
+            expect(order.subTotal).toBe(444);
+            expect(order.subTotalBeforeTax).toBe(369);
         });
 
         it('single line with taxes included', () => {
@@ -90,21 +90,21 @@ describe('OrderCalculator', () => {
             });
             orderCalculator.applyTaxesAndPromotions(ctx, order, []);
 
-            expect(order.totalPrice).toBe(123);
-            expect(order.totalPriceBeforeTax).toBe(102);
+            expect(order.subTotal).toBe(123);
+            expect(order.subTotalBeforeTax).toBe(102);
         });
 
         it('resets totals when lines array is empty', () => {
             const ctx = createRequestContext(true, zoneDefault);
             const order = createOrder({
                 lines: [],
-                totalPrice: 148,
-                totalPriceBeforeTax: 123,
+                subTotal: 148,
+                subTotalBeforeTax: 123,
             });
             orderCalculator.applyTaxesAndPromotions(ctx, order, []);
 
-            expect(order.totalPrice).toBe(0);
-            expect(order.totalPriceBeforeTax).toBe(0);
+            expect(order.subTotal).toBe(0);
+            expect(order.subTotalBeforeTax).toBe(0);
         });
     });
 });

+ 12 - 2
server/src/service/helpers/order-calculator/order-calculator.ts

@@ -84,6 +84,16 @@ export class OrderCalculator {
                 this.calculateOrderTotals(order);
             }
         }
+        const applicableOrderPromotions = promotions.filter(p => p.test(order));
+        if (applicableOrderPromotions.length) {
+            for (const promotion of applicableOrderPromotions) {
+                const adjustment = promotion.apply(order);
+                if (adjustment) {
+                    order.pendingAdjustments = order.pendingAdjustments.concat(adjustment);
+                }
+            }
+            this.calculateOrderTotals(order);
+        }
     }
 
     private calculateOrderTotals(order: Order) {
@@ -96,7 +106,7 @@ export class OrderCalculator {
         }
         const totalPriceBeforeTax = totalPrice - totalTax;
 
-        order.totalPriceBeforeTax = totalPriceBeforeTax;
-        order.totalPrice = totalPrice;
+        order.subTotalBeforeTax = totalPriceBeforeTax;
+        order.subTotal = totalPrice;
     }
 }

+ 3 - 2
server/src/service/services/order.service.ts

@@ -64,8 +64,9 @@ export class OrderService {
         const newOrder = new Order({
             code: generatePublicId(),
             lines: [],
-            totalPrice: 0,
-            totalPriceBeforeTax: 0,
+            pendingAdjustments: [],
+            subTotal: 0,
+            subTotalBeforeTax: 0,
         });
         return this.connection.getRepository(Order).save(newOrder);
     }