Wonder8.promotion行銷規則引擎,輕鬆搞掂千變萬化的行銷玩法

2023-10-31 21:00:26
超過10年沒有更新過內容了,不知道現在園子的氛圍這類文章還適不適合放首頁
想著整點內容,也是支援園子!

旺德發.行銷 引擎

概述

為了廣泛支援行銷活動的複雜與靈活,Wonder8.promotion(旺德發.行銷)引擎使用專門設計的表示式高度提煉資訊,可以輕鬆表達行銷活動與使用者選取的商品組合之間匹配範圍、要求、折扣方式,可以設定多條行銷規則的邏輯聯合、分組、優先順序,並且支援多種為使用者計算最優折扣的策略。

本引擎功能細節較多,建議用以下步驟來學習和應用:

  1. 先通過簡單需求場景來熟悉API,此時只需要用到表示式(Rule),表示式直譯器(Interpreter)和優惠計算策略(Strategy),大部分程式設計師掌握了表示式語法後,會嫌Builder麻煩而直接拼寫表示式字串,所以Builder都不一定要熟悉,比如:
  • 定義規則:買兩臺512G的黑或白色iPhone15,折扣400元,三臺折扣700元:
    • [#kiPhone15-black-512g#kiPhone15-white-512g].count(2)->-40000
    • [#kiPhone15-black-512g#kiPhone15-white-512g].count(3)->-70000
  • 然後呼叫Strategy.bestChoice(rules, items, MatchType.MultiRule)即可在使用者購買4臺iPhone時計算出折扣800元,購買5臺時折扣1100元,以及計算出應用折扣後如果還有餘出的物品,使用者如何拼單獲得更多的折扣。
  1. 嘗試編寫複雜的規則組合熟悉分組、商品組合、多種計算策略和計算範圍等概念。
  2. 嘗試為自己的業務場景擴充套件引擎功能,本引擎有清晰的結構,各部分相互獨立,往往能新增10來行程式碼即擴充套件新的功能。

功能特性:

  • 支援針對使用者選定的一批商品,從一堆行銷規則中自動應用最大的優惠;
  • 可以同時應用多個規則,規則之間可以是與和或的關係,可以限定規則組合的優先順序;
  • 可以對規則分組,限定先應用一組,再應用另一組;
    • 可以限定必須應用完一組優惠才能計算下一組優惠;
    • 也可以把各組優惠方式交叉對比最優組合;
  • 多種規則匹配方式求最佳優惠:
    • 最優的只匹配一次規則;
    • 最優的單規則多次匹配;
    • 最優的多規則多次匹配;
  • 可以支援類似於買12瓶水可以合成兩箱水(另一個SKU),而兩箱水又可以應用另一種規則;
  • 可以計算推薦使用者再新增什麼商品可以獲得下一個優惠;
  • 同時提供伺服器端Java實現和使用者端JS實現,便於下放優惠規則後,使用者端實時得出優惠結果;
  • 基於專門設計的字串表示式,各種變態組合玩法可以靈活直觀表達,並且提供Builder和Interpreter為字串和結構化物件間轉換;
  • 程式碼結構清晰,進行功能擴充套件和各類規則組合場景擴充套件比較方便;

功能說明

所有想法源自於一個行銷折扣的規則可以抽象成三個部分:

  1. 規則適用的範圍(Range)
    1. 我們暫且將範圍表達成三層:類目、SPU、SKU,不同場景可以擴充套件,由於字串可以自由串接,一般情況也不需要擴充套件,比如:大類目-小類目就相當於擴充套件了一層;
    2. 一個規則可以有適用多個範圍,即範圍可以是一個組合;
  2. 規則的要求(Predict & Validate)
    1. 規則的要求可以抽象出來,主要是:要求有多少個,要求達到多少總價值,要求含有多少種,必須搭售某個商品等;
    2. 計算方式(Predict)可以擴充套件;
  3. 優惠方案(Promotion)
    1. 優惠方案往往是:固定減多少錢,每滿多少錢減多少錢,按比例折扣,直接減到一個固定值(一口價)
      所以一條行銷規則就是:[range].predict(expectedValue)。

表示式語法

Wonder8.promotion使用表示式來表達和組合行銷規則,如表示當一組商品中包括食品-水果、食品-肉類、食品-蔬菜三大類中至少兩個時,優惠10%:"[#cFOOD-FRUIT#cFOOD-MEAT#cFOOD-VEGETABLE].countCate(2)->-10%":

  1. 一條行銷規則由三部分組成,規則適用的範圍,規則計算的方法,規則應用的優惠結果:
    1. [range].predict(expectedVaue)是一條規則的格式
  2. 使用者當前選擇了10個物品,但是並不是每一個物品都符合這條規則的範圍,則它不應計算在內。所以適用範圍是首要設定的:
    1. [#cCate1#cCate2#……]表示法中,#是一個範圍物件的表達開始,c是型別(可選c-類目,p-SPU,k-SKU,可以擴充套件),後面是ID,[]內可以放>=1個物件;
    2. $表示全部:$.sum(20000);
    3. ~表示複用上一條規則的範圍:[#ccate01#ccate02#ccate03].countCate(2) & ~.countSPU(5) & $.countSKU(5) & ~.sum(10000)),意味著在類目cate01,cate02,cate03這個範圍內,物品組合需要滿足類目涵蓋2個,SPU涵蓋5個,SKU涵蓋5個,總價達到10000。
  3. .predict()表示計算的方法,當前支援countCategory()計算範圍內含多少個類目,countSPU()計算範圍內含多少個SPU,countSKU()計算範圍內含多少個SKU,count()計算多少個物品,oneSKU()計算某種SKU含多少個,sum()計算價格的合計。
  4. expectedValue是一個int數位,表示計算結果要>=這個數, 才能通過。
  5. 規則可以聯合,用&表示並且,用|表示或者,規則可以分組,用(),比如(rule1&rule2&rule3)|rule4,表示,1、2、3都要達成或者4達成,均可通過規則:
    "([#pp01#pp02#pp03].countCate(2) & \$.countSPU(3) & \$.count(5) & \$.sum(10000)) | \$.sum(50000)"
    
  6. 每條規則由計算部分和一個規則優惠部分組成,中間用->連線;
  7. 優惠部分的語法是:
    1. -1000 表示固定優惠10塊錢(所以錢相關的計算單位是分)
    2. -1000/10000 表示每100塊錢優惠10塊錢
    3. -10% 表示優惠10%,即打9折,新增了小數點支援比如-0.5%表示優惠95.5%
    4. 8000 表示一口價,80塊錢
    5. -0表示優惠0元,0表示優惠到0元

規則物件

對應表示式,有一系統的結構化物件:

  1. Rule -- 對應一條完整的行銷規則,主要屬性是condition 表示條件規則,promotion表示優惠規則
    1. 實際使用過程中,因為要對使用者提示,顯示標籤等,所以需要擴充套件Rule類,提供更多與計算無關的附加屬性,參見測試用例中的RuleImpl類。
  2. SimplexRule -- 對應一條條件規則,主要屬性是range表示條件計算範圍,predict表示計算方法,expcteted表示達標的值。
  3. SameRangeRule -- 與前一條條件規則範圍相同的規則,用~複用前述規則的範圍表示式。
  4. AndCompositeRule -- 表示and邏輯的條件規則組,主要屬性是儲存子規則集合的components,可以addRule()新增子規則,子規則可以是Simplex/SameRange,也可以是AndComposite/OrComposite。
  5. OrCompositeRule -- 表示or邏輯的條件規則組,其它同AndComposite
  6. Rule的condition可以是Simplex/AndComposite/OrComposite,不能是SameRange,不然SameRange去哪裡複用範圍規則
  7. Rule/Simplex/SameRange/AndComposite/OrComposite都有對應的builder,通過Builder.rule()/simplex()/and()/or()可以找到builder的快捷入口。見[規則的建立]
  8. 條件可以通過Rule.toString()方法和Interprecter.parseString()來實現強型別範例與字串表示式之間的互相轉換。

規則的建立

/model/builder/目錄下有一整套builder用於以結構化的方式建立規則,語法清晰。

public class ConditionBuilderTest {
    @Test
    public void testBuildRule(){
        //建立規則有三種方法:
        //一種是Builder.rule().xxx().xxx().build()
        //第二種是new RuleBuiler().xxx().xxx().build()
        //第三種是直接new Rule(),通過contructor和properties來完成設定
        RuleComponent rule1 = Builder.rule()//上下文是Rule
                .simplex().addRangeAll()//注意這裡上下文切換到了simplex條件的編寫
                .predict(P.SUM).expected(100)
                .end() //通過.end()結束當前物件編寫,返回到上一級,也就是Rule
                .endRule()//因為.end()方法返回的是基礎類別,所以需要.backRule()切換回RuleBuilder才能直接呼叫.promotion()這樣特殊的方法,繼續編寫下去
                .promotion("-10")
                .build();

        System.out.println(rule1.toString());
    }

    @Test
    public void testBuildSimplexRule(){
        /*
          Builder除了能Builder.rule()來開始編排一個完整的行銷規則,
          也還有Builder.simplex()/.or()/.and()來開始編排一個單一/或組合/與組合
          但請注意,除.rule()是開始編寫一個完整的行銷規則,其它方法只是在開始編排規則中的條件部分
          最終.build()出來的一個是Rule,一個是Condition
        */
        SimplexRule rule1 = Builder.simplex() // same as => new SimplexRuleBuilder()
                .addRangeAll()
                .predict(P.SUM).expected(100)
                .build();
        System.out.println(rule1.toString());
    }

    @Test
    public void testParseRange(){
        SimplexRule rule1 = new SimplexRuleBuilder()
                .range("[#pSPU1#pSPU2]")
                .predict(P.SUM).expected(100)
                .build();
        System.out.println(rule1.toString());
    }


    @Test
    public void testBuildOrCompositeRule(){
        RuleComponent or =  new OrCompositeRuleBuilder()
                .simplex().addRangeAll().predict(P.SUM).expected(100).end()
                .simplex().addRange(R.SPU,"SPUID1").predict(P.COUNT).expected(5).end()
                .sameRange().predict(P.COUNT_SPU).expected(2).end()
                .build();
        System.out.println(or);
    }

    @Test
    public void testBuildAndCompositeRule(){
        RuleComponent and = new AndCompositeRuleBuilder()
                .simplex().addRanges(R.SPU, Arrays.asList("SPUID1","SPUID2")).predict(P.COUNT).expected(5).end()
                .simplex().addRangeAll().predict(P.COUNT_SPU).expected(5).end()
                .sameRange().predict(P.COUNT).expected(10).end()
                .build();
        System.out.println(and);
    }
}

具體用法可以參見test下的ConditionBuilderTest.java和RuleTest.java。

表示式的解析

Interprecter類實現對規則字串的解釋,可以將字串轉化成模型結構,Interprecter.parseString(ruleString)

public class InterpreterTest {

    @Test
    public void validateCondition() {
        String ruleStr = "($.count(5)&[#cCATEGORY1#cCATEGORY2].sum(10)&~.countSPU(2))|$.sum(100)";
        assertTrue(Interpreter.validateCondition(ruleStr));
    }

    @Test
    public void parseString() {
        String ruleStr = "($.count(5)&[#cCATEGORY1#cCATEGORY2].sum(10)&~.countSPU(2))|$.sum(100)";
        RuleComponent rule = Interpreter.parseString(ruleStr);
        System.out.println(rule);
        assertEquals(ruleStr,rule.toString());

        ruleStr = "($.count(5)|([#cCATEGORY1#cCATEGORY2].sum(10)&~.countSPU(2)))|$.sum(100)";
        rule = Interpreter.parseString(ruleStr);
        System.out.println(rule);
        assertEquals(ruleStr,rule.toString());

        ruleStr = "(($.count(5)&[#cCATEGORY1#cCATEGORY2].sum(10))|([#cCATEGORY1#cCATEGORY2].sum(10)&~.countSPU(2)))|$.sum(100)";
        rule = Interpreter.parseString(ruleStr);
        System.out.println(rule);
        assertEquals(ruleStr,rule.toString());

        ruleStr = "(($.count(5)&[#cCATEGORY1#cCATEGORY2].sum(10))|[#cCATEGORY1#cCATEGORY2].sum(10))|$.sum(100)";
        rule = Interpreter.parseString(ruleStr);
        System.out.println(rule);
        assertEquals(ruleStr,rule.toString());

        ruleStr = "(($.count(5)&[#cCATEGORY1#cCATEGORY2].sum(10))|[#cCATEGORY1#cCATEGORY2].sum(10))|($.sum(100)&~.countCate(2))";
        rule = Interpreter.parseString(ruleStr);
        System.out.println(rule);
        assertEquals(ruleStr,rule.toString());
    }

    @Test
    public void foldRuleString(){
        String rule = "[#c01#c02#c03].countCate(2)&[#c01#c02#c03].countSPU(5)|[#c01#c02#c03].count(10)&[#c01].sum(10)";
        String expected = "[#c01#c02#c03].countCate(2)&~.countSPU(5)|~.count(10)&[#c01].sum(10)";
        String actual = Interpreter.foldRuleString(rule);
        assertEquals(expected,actual);

        String rule2 = "[#c01#c02#c03].countCate(2)&[#c01#c02#c03].countSPU(5)|([#c01#c02#c03].count(10)&[#c01].sum(10))";
        String expected2 = "[#c01#c02#c03].countCate(2)&~.countSPU(5)|([#c01#c02#c03].count(10)&[#c01].sum(10))";
        String actual2 = Interpreter.foldRuleString(rule2);
        assertEquals(expected2,actual2);

    }

    @Test
    public void unfoldRuleString(){
        String rule = "[#c01#c02#c03].countCate(2)&~.countSPU(5)|~.count(10)&[#c01].sum(10)";
        String expected = "[#c01#c02#c03].countCate(2)&[#c01#c02#c03].countSPU(5)|[#c01#c02#c03].count(10)&[#c01].sum(10)";
        String actual = Interpreter.unfoldRuleString(rule);
        assertEquals(expected,actual);

        String expected2 = "[#c01#c02#c03].countCate(2)&[#c01#c02#c03].countSPU(5)|([#c01#c02#c03].count(10)&[#c01].sum(10))";
        String rule2 = "[#c01#c02#c03].countCate(2)&~.countSPU(5)|([#c01#c02#c03].count(10)&[#c01].sum(10))";
        String actual2 = Interpreter.unfoldRuleString(rule2);
        assertEquals(expected2,actual2);
    }
}

規則是否匹配

Rule.check(items);

規則匹配結果詳情

Rule.validate(tickets) -> RuleValidateResult物件
result.valid = result.expected vs. result.actual

優惠計算

Rule.discount(items) -> int. 返回一個負值,即優惠的數,注意一口價的規則,也是目標價格減去當前票價總和得出的優惠掉的值,比如當前所選票價總和是10000,一口價規則是8000,則返回-2000

result.isValid()?r.discount(selectedTickets):0

四種計算範圍

優惠計算有四種計算範圍:
假設總共9個物品,01號100塊的2個,02號121.2塊的6個,03號0.5塊的1個,規則是01,02號總共要6個,並且兩種都要有:

  1. Strategy.bestMatch()的策略是求最低成本下達成最多優惠,如果是比率折扣,它會取高價票,否則取低價票,上例結果是計算1張01和5張02;
    1. 如果規則A的promotion是滿折滿減(%,/),則會同時計算將更多票匹配到A是否會帶來更多的優惠
  2. Strategy.bestOfOnlyOnceDiscount()的策略是隻允許使用一次優惠規則,所以計算達成規則所需的最少張數,但是是最高價格的票,上例結果是計算1張01和5張02;
    1. 如果規則A的promotion是滿折滿減(%,/),則會同時計算將更多票匹配到A是否會帶來更多的優惠
  3. Rule.discount(),會對所有票應用優惠,上例結果是計算所有9張票;
  4. Rule.discountFilteredItems(),會對規則指定範圍內的所有票計算優惠,上例結果是計算2張01和6張02,不含03;
    注意,Strategy支援單規則多次匹配應用和多條規則聯合多次應用,更符合「最優」的概念。
test("4 discounting algorithm", () => {

    const ruleString = "[#k02#k01].count(6)&~.countCate(2) -> -50%";
    const items = [
        { category: "01", SPU: "01", SKU: "01",price: 10000 },
        { category: "01", SPU: "01", SKU: "01",price: 10000 },
        { category: "02", SPU: "02", SKU: "02",price: 121200 },
        { category: "02", SPU: "02", SKU: "02",price: 121200 },
        { category: "02", SPU: "02", SKU: "02",price: 121200 },
        { category: "02", SPU: "02", SKU: "02",price: 121200 },
        { category: "02", SPU: "02", SKU: "02",price: 121200 },
        { category: "02", SPU: "02", SKU: "02",price: 121200 },
        { category: "02", SPU: "02", SKU: "03",price: 50 },
    ];

    const rule = Interpreter.parseString(ruleString);
    let expected = 0, actual = 0;
    //為了做規則推薦的運算,規則本身算折扣的方法裡,
    // 並沒有判定規則是否已達成,所以呼叫前需做check()
    if(rule.check(items)){
        //第1種,rule.discountFilteredItems(items)
        //計算的是規則範圍內的這部分商品的折扣
        expected = rule.filterItem(items).map(t=>t.price).reduce((p1,p2)=>p1+p2,0) * -0.5;
        actual = rule.discountFilteredItems(items);
        console.log(expected, actual)
        expect(actual).toEqual(expected);

        //第2種,rule.discount(items)
        //計算的是所有商品應用折扣
        expected = items.map(t=>t.price).reduce((p1,p2)=>p1+p2,0) * -0.5;
        actual = rule.discount(items);
        console.log(expected, actual)
        expect(actual).toEqual(expected);
    }

    //第3種,Strategy.bestMath()
    //計算的是用最低成本達成規則匹配所需要的商品
    expected = (items[0].price * 2 + items[2].price *6 ) * -0.5;
    actual = Strategy.bestMatch([rule],items).totalDiscount();
    console.log(expected, actual)
    expect(actual).toEqual(expected);

    //第4種,Strategy.bestOfOnlyOnceDiscount()
    //計算達成規則所需的最少張數,但是是最高價格的商品
    expected = (items[0].price * 2 + items[2].price * 6 ) * -0.5;
    const match = Strategy.bestOfOnlyOnceDiscount([rule],items)
    actual = match.totalDiscount();
    console.log(expected, actual)
    expect(actual).toEqual(expected);
    console.log(match.more);
});

策略!

Strategy.bestMatch(rules,items)/Strategy.bestOfOnlyOnceDiscount(rules, items) 均已廢棄,統一使用bestChoice(rules, items, MatchType type, MatchGroup groupSetting)。

public static BestMatch bestChoice(List<Rule> rules, List<Item> items, MatchType type, MatchGroup groupSetting) {
    //... ...
}
test('bestMatch',()=> {
    //#region prepare
    let r1 = Builder.rule().simplex()
        .range("[#cc01]")
        .predict(P.COUNT)
        .expected(2)
        .endRule()
        .promotion("-200")
        .build();
    let r2 = Builder.rule().simplex()
        .addRange(R.CATEGORY, "c01")
        .predict(P.COUNT)
        .expected(3)
        .endRule()
        .promotion("-300")
        .build();
    let r3 = Builder.rule().simplex()
        .addRangeAll()
        .predict(P.COUNT)
        .expected(6)
        .endRule()
        .promotion("-10%")
        .build();

    let items = _getSelectedItems();
    let rules = [r1, r2];
    //#endregion
    let bestMatch = Strategy.bestMatch(rules, items);
    expect(bestMatch.matches.length).toEqual(2);
    expect(bestMatch.matches[0].rule).toEqual(r1);
    let bestMatch1 = Strategy.bestChoice(rules,items,MatchType.OneRule);
    expect(bestMatch.matches[0].rule).toEqual(bestMatch1.matches[0].rule);
    expect(bestMatch.totalDiscount()).toEqual(bestMatch1.totalDiscount());

    let bestOfOnce = Strategy.bestOfOnlyOnceDiscount(rules, items);
    bestMatch1 = Strategy.bestChoice(rules,items,MatchType.OneTime);
    expect(bestOfOnce.matches[0].rule).toEqual(bestMatch1.matches[0].rule);
    expect(bestOfOnce.totalDiscount()).toEqual(bestMatch1.totalDiscount());

    // 5 items matched
    items.push(new Item("c01", "p02", "k03", 4000));
    let bestOfMulti = Strategy.bestChoice(rules, items, MatchType.MultiRule);
    expect(2).toEqual(bestOfMulti.matches.length);
    expect(5).toEqual(bestOfMulti.chosen().length);
    expect(-500).toEqual(bestOfMulti.totalDiscount());

    // 6 items matched
    items.push(new Item("c01", "p02", "k03", 4000));
    bestOfMulti = Strategy.bestChoice(rules,items,MatchType.MultiRule);
    expect(6).toEqual(bestOfMulti.chosen().length);
    expect(-600).toEqual(bestOfMulti.totalDiscount());

    // 7 items matched
    items.push(new Item("c01", "p02", "k03", 4000));
    bestOfMulti = Strategy.bestChoice(rules,items,MatchType.MultiRule);
    expect(3).toEqual(bestOfMulti.matches.length);
    expect(7).toEqual(bestOfMulti.chosen().length);
    expect(-700).toEqual(bestOfMulti.totalDiscount());

    // 7 items matched
    const r4 = Builder.rule().simplex().addRange(R.SPU,"p02")
        .predict(P.COUNT).expected(4).endRule()
        .promotion("-2000").build();
    rules = [r1,r2,r3,r4];
    bestOfMulti = Strategy.bestChoice(rules,items,MatchType.MultiRule);
    //expect(3).toEqual(bestOfMulti.matches.length);
    expect(14).toEqual(bestOfMulti.chosen().length);
    expect(-400-300-2000-500-600-700-800-900-200-300).toEqual(bestOfMulti.totalDiscount());

    r3.promotion = "-100";
    bestOfMulti = Strategy.bestChoice(rules,items,MatchType.MultiRule);
    expect(13).toEqual(bestOfMulti.chosen().length);
    expect(-2400).toEqual(bestOfMulti.totalDiscount());
});

商品組合

行銷活動中存在購買一定數量A物品,就轉換成另一個SKU,比如買12瓶水會變成買一箱水,或者買幾個SKU合成另一個SKU,比如買一件上裝加一件下裝變成一個套裝,這個時候如果規則引擎能自動完成合並,那麼在組合規則時會少去應用層很多程式碼,所以提供了一個實現這一功能的promotion語法:

y:new SKU id:new SKU price

以下規則表示VIP A區的1,2排三個相鄰座可以合併成一個VIP套票,賣300000

[#zVIP:A:1:1-VIP:A:2:10].adjacentSeat(3)->y:VipPackage3:300000

規則分組

  1. 規則可以分組計算,組別為1的規則可以疊加在組別為0的規則應用的結果上,依此類推
  2. 各組規則可以按組依次計算、疊加,再取最優,即MatchGroup.SequentialMath
  3. 各組規則可以交織在一起計算、疊加,取所有可能的最優,即MatchGroup.CrossedMatch
  4. 規則字串後加@0,表示規則為第0組,@1表示為第1組
//以下例子應用了擴充套件場景-劇院座位,多了一個座位的屬性,多張鄰座票可以組合成一個聯票,形成聯票後又可以應用聯票的優惠規則

function getSeatedItems () {
    return [
        new Item("01", "01", "02", 10000, "二樓:A:1:1"),
        new Item("01", "01", "02", 10000, "二樓:A:1:3"),
        new Item("01", "01", "02", 10000, "二樓:A:1:2"),
        new Item("01", "01", "02", 10000, "二樓:A:1:5"),
        new Item("01", "01", "02", 10000, "二樓:A:1:4"),
        new Item("02", "02", "03", 121200, "VIP:A:1:4"),
        new Item("02", "02", "03", 121200, "VIP:A:1:2"),
        new Item("02", "02", "03", 121200, "VIP:A:1:3"),
        new Item("02", "02", "03", 121200, ''),
        new Item("02", "02", "03", 121200, "")];
}

test('testPackage',()=>{
    let testItems = getSeatedItems();
    const rule1 = Interpreter.parseString("[#zVIP:A:1:1-VIP:A:2:10].adjacentSeat(3)->y:VipPackage3:300000");
    rule1.group = 0;
    const rule2 = Interpreter.parseString("[#kVipPackage3].count(1)->-10%");
    rule2.group = 1;
    const bestMatch1 = Strategy.bestChoice([rule1],testItems,MatchType.MultiRule,MatchGroup.CrossedMatch);
    expect(300000-121200*3 ).toEqual(bestMatch1.totalDiscount());
    const bestMatch2 = Strategy.bestChoice([rule1,rule2], testItems, MatchType.MultiRule, MatchGroup.CrossedMatch);
    expect((300000-121200*3) - 30000).toEqual( bestMatch2.totalDiscount());
});

test('testMatchGroup',()=>{
    let seatedItems = getSeatedItems();
    //二樓:A:1:1-5
    //rule1 -2000 rule2 -1800 rule1+rule2 -3800 rule3 -4000
    const rule1 = Interpreter.parseString("[#z二樓:A:1:1-二樓:A:1:5].adjacentSeat(2)->y:APackage2:18000");
    rule1.group=0;
    const rule2 = Interpreter.parseString("[#kAPackage2].count(1)->-10%@1");
    const rule3 = Interpreter.parseString("[#k02].count(3)->-4000@1");
    const rules = [rule1,rule2,rule3];

    const crossedGroupMatch = Strategy.bestChoice(rules,seatedItems,
        MatchType.MultiRule,MatchGroup.CrossedMatch);
    expect(crossedGroupMatch.totalDiscount()).toEqual(-3800 -4000);

    const sequentialGroupMatch = Strategy.bestChoice(rules,seatedItems,
        MatchType.MultiRule,MatchGroup.SequentialMatch);
    expect(sequentialGroupMatch.totalDiscount()).toEqual (-3800*2);
    console.log(rule1.toString());
    console.log(rule2.toRuleString());
    console.log(rule3.toString());
});

可以看到MatchGroup.SequntialMatch模式下,先用0組規則儘量找到了2組套票,然後分別為每張套票應用了一個9折的票面優惠;
在MatchGroup.CrossedMatch模式下,通過計算,3張票減4000比兩張票組成1個套票再應用9折減3800要更優惠,所以最終是3張票-4000,再加上兩張票形成一個套票再9折-3800

功能擴充套件

規則引擎的模組非常清楚,面對不同的任務,可以在相對明確的範圍做少量調整,並帶來全域性的收益:

  • Range相關的部分是用來表達規則的匹配範圍,如果有這方面的需求,應該只改動這一部分,比如「ID都太長了,希望相同範圍的子規則可以複用範圍設定,減少規則字串長度」,則我們增加一種Range:SameRange表達即可;
  • Predict謂詞是判斷動作,新增了一種判斷動作,只需要擴充套件這部分程式碼即可;
  • Rule、RuleComponent是規則本身的強型別表達,除了規則資料的表達,它們還承擔:
    • 規則匹配
    • 折扣計算
    • 匹配範圍的票的篩選
  • Strategy和一套Match類是用來做多個規則和多張票的自動優選的,一般不會動到;
  • Interpreter是字串解析器,基本它的流程不會需要改動,對規則組合的分解,對單一規則的解釋;
  • Builder是一套強型別鏈式建立各種規則的輔助體系。

擴充套件oneSKU謂詞

我們看一下如何擴充套件一個oneSKU謂詞來實現至少有一單個SKU必須要達到多少數量的判斷。

java

  • P.java
//predict 判斷動詞
public enum P {
    
    //... ...

    /**
     * 某種SKU的數量
     */
    ONE_SKU;

    @Override
    public String toString() {
        switch (this){
            //... ...
            case ONE_SKU:return "oneSKU";
        }
    }

    public static P parseString(String s){
        switch (s){
            //... ...
            case "oneSKU": return P.ONE_SKU;
        }
    }
}
  • validator.java
public class Validator {
    //@1 有新的玩法只需在這裡加謂詞和對應的含義
    private static HashMap<P, Function<Stream<Ticket>, Integer>> validators
            = new HashMap<P, Function<Stream<Ticket>, Integer>>(){
        {
            // ... ...
            put(P.ONE_SKU,(items) -> {
                return items.collect(
                            Collectors.groupingBy(
                                    t->t.getSKU(),
                                    Collectors.counting()))
                        .values().stream()
                        .max(Long::compare)
                        .orElse(0L).intValue();
            });
        }
    };

javascript

  • enums.js
const P = Object.freeze({
    //... ...
    ONE_SKU: {
        name: "oneSKU",
        handler: function(items){
            if(items.length < 1){
                return 0;
            }
            let map = new Map();
            for (const item of items) {
                let count = map.get(item.SKU);
                if(count){
                    map.set(item.SKU,count + 1);
                }
                else{
                    map.set(item.SKU,1);
                }
            }
            return [...map.values()].sort().reverse()[0];
        },
        toString: function (){
            return this.name;
        }
    },
    parseString: function(s){
        switch (s){
            //... ...
            case this.ONE_SKU.name:
                return this.ONE_SKU;
            //... ...
        }
    }
});
//... ...

用法見單元測試中的strategyTest中的test_oneSKU()

場景擴充套件

不同的場景會有個性化的需求,原始碼中已經實現了對演示場景(票多了座位這一半鍵屬性),可以參考:

  1. Range支援z表示座位
  2. Predict增加adjancetSeat判斷商品組合中票是不是連座的
  3. 用TicketSeatComparator封裝根據座位資訊判斷不同座位位置關係的邏輯

程式碼結構

|- /java -- 後端實現,暫時不考慮翻譯golang/.net語言版本,電商還是java多
|- /java/.../Builder.java -- 表示式構造器入口 !important
|- /java/.../Interpreter.java -- 表示式字串解析器 !important
|- /java/.../Strategy.java -- 計算方法入口 !important
|- /java/.../model -- 規則結構化類體系
|- /java/.../model/builder -- 構造器的處理類
|- /java/.../model/comparator -- Item比較邏輯
|- /java/.../model/strategy -- 規則計算邏輯 !important
|- /java/.../model/validate -- 規則驗證結果類
|- /js -- 前端javascript實現,程式碼結構與功能與後端完全一致,暫時不考慮翻譯成typescript了