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

来源:https://www.cnblogs.com/think/archive/2023/10/31/17801097.html
-Advertisement-
Play Games

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


超過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了


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 背景 1、在ArkTS的架構中,沒有明確的可管理的載入請求狀態的腳手架,在進行網路請求過程中,無法簡單的進行交互響應。 2、參考Android中的LoadState寫了一個簡單的腳手架,以便在日常開發過程中,管理載入請求狀態和UI交互。 腳手架說明與源碼 1、狀態機LoadState 使用一個狀態機 ...
  • 這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 一,問題起因 最新在開發小程式的時候,調用微信小程式來獲取用戶信息的時候經常報錯一個問題 fail api scope is not declared in the privacy agreement,api 更具公告,是微信更新對應的隱 ...
  • 在建設博客的初期,我採用GitBook構建了編碼專家的專欄系統。GitBook是基於Node.js的靜態頁構建組件,可以將Markdown文檔渲染為靜態頁,支持插件擴展,使用非常簡單。由於它不支持深度的定製,使用了一段時間後,無法滿足我的要求了。有一天我看到某博客採用VuePress,簡潔美觀、功能... ...
  • 我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。 本文作者:修能 朝聞道,夕死可矣 何為 Molecule? 輕量級的 Web IDE UI 框架——Molecule 我們開源了一個輕量的 Web IDE UI 框架 ...
  • 環境說明 macOS 13.5 % sw_vers ProductName: macOS ProductVersion: 13.5 BuildVersion: 22G74 Brew % brew --version Homebrew 4.1.2-33-gc531a35 安裝 安裝 nvm 使用 br ...
  • 我的小冊 《CSS 技術揭秘與實戰通關》上線了,想瞭解更多有趣、進階、系統化的 CSS 內容,可以猛擊 - LINK。 在 CSS 還原拉斯維加斯球數字動畫 - 掘金 一文中,我們利用純 CSS,實現了一個非常 Amazing 的動畫效果: 其中一個核心點就是,我們利用瞭如下的代碼,在一個 DIV ...
  • 在這個三年的時間點上,也就是1100天,我打算繼續出來和大家嘮嘮,這一年我又做了些什麼事,或者說,如何把一款好的後臺框架變得通用? ...
  • 一、算數運算符 算術運算符(+,-, *,/,%【重要】,++【重要】,--) 其中 + 的左邊和右邊有 " ", 表示拼接 i++,表示先賦值後+1 ++i ,表示先+1後賦值 同理減法也是如此 算數運算中,+ 可以進行隱式迭代,將字元串數字轉化為Number類型 alert(typeof(+'5 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...