為了廣泛支持營銷活動的複雜與靈活,Wonder8.promotion(旺德發.營銷)引擎使用專門設計的表達式高度提煉信息,可以輕鬆表達營銷活動與用戶選取的商品組合之間匹配範圍、要求、折扣方式,可以設定多條營銷規則的邏輯聯合、分組、優先順序,並且支持多種為用戶計算最優折扣的策略。 本引擎功能細節較多,建... ...
超過10年沒有更新過內容了,不知道現在園子的氛圍這類文章還適不適合放首頁
想著整點內容,也是支持園子!
概述
為了廣泛支持營銷活動的複雜與靈活,Wonder8.promotion(旺德發.營銷)引擎使用專門設計的表達式高度提煉信息,可以輕鬆表達營銷活動與用戶選取的商品組合之間匹配範圍、要求、折扣方式,可以設定多條營銷規則的邏輯聯合、分組、優先順序,並且支持多種為用戶計算最優折扣的策略。
本引擎功能細節較多,建議用以下步驟來學習和應用:
- 先通過簡單需求場景來熟悉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元,以及計算出應用折扣後如果還有餘出的物品,用戶如何拼單獲得更多的折扣。
- 嘗試編寫複雜的規則組合熟悉分組、商品組合、多種計算策略和計算範圍等概念。
- 嘗試為自己的業務場景擴展引擎功能,本引擎有清晰的結構,各部分相互獨立,往往能添加10來行代碼即擴展新的功能。
功能特性:
- 支持針對用戶選定的一批商品,從一堆營銷規則中自動應用最大的優惠;
- 可以同時應用多個規則,規則之間可以是與和或的關係,可以限定規則組合的優先順序;
- 可以對規則分組,限定先應用一組,再應用另一組;
- 可以限定必須應用完一組優惠才能計算下一組優惠;
- 也可以把各組優惠方式交叉對比最優組合;
- 多種規則匹配方式求最佳優惠:
- 最優的只匹配一次規則;
- 最優的單規則多次匹配;
- 最優的多規則多次匹配;
- 可以支持類似於買12瓶水可以合成兩箱水(另一個SKU),而兩箱水又可以應用另一種規則;
- 可以計算推薦用戶再添加什麼商品可以獲得下一個優惠;
- 同時提供服務端Java實現和客戶端JS實現,便於下放優惠規則後,客戶端實時得出優惠結果;
- 基於專門設計的字元串表達式,各種變態組合玩法可以靈活直觀表達,並且提供Builder和Interpreter為字元串和結構化對象間轉換;
- 代碼結構清晰,進行功能擴展和各類規則組合場景擴展比較方便;
功能說明
所有想法源自於一個營銷折扣的規則可以抽象成三個部分:
- 規則適用的範圍(Range)
- 我們暫且將範圍表達成三層:類目、SPU、SKU,不同場景可以擴展,由於字元串可以自由串接,一般情況也不需要擴展,比如:大類目-小類目就相當於擴展了一層;
- 一個規則可以有適用多個範圍,即範圍可以是一個組合;
- 規則的要求(Predict & Validate)
- 規則的要求可以抽象出來,主要是:要求有多少個,要求達到多少總價值,要求含有多少種,必須搭售某個商品等;
- 計算方式(Predict)可以擴展;
- 優惠方案(Promotion)
- 優惠方案往往是:固定減多少錢,每滿多少錢減多少錢,按比例折扣,直接減到一個固定值(一口價)
所以一條營銷規則就是:[range].predict(expectedValue)。
- 優惠方案往往是:固定減多少錢,每滿多少錢減多少錢,按比例折扣,直接減到一個固定值(一口價)
表達式語法
Wonder8.promotion使用表達式來表達和組合營銷規則,如表示當一組商品中包括食品-水果、食品-肉類、食品-蔬菜三大類中至少兩個時,優惠10%:"[#cFOOD-FRUIT#cFOOD-MEAT#cFOOD-VEGETABLE].countCate(2)->-10%":
- 一條營銷規則由三部分組成,規則適用的範圍,規則計算的方法,規則應用的優惠結果:
- [range].predict(expectedVaue)是一條規則的格式
- 用戶當前選擇了10個物品,但是並不是每一個物品都符合這條規則的範圍,則它不應計算在內。所以適用範圍是首要設置的:
- [#cCate1#cCate2#……]表示法中,#是一個範圍對象的表達開始,c是類型(可選c-類目,p-SPU,k-SKU,可以擴展),後面是ID,[]內可以放>=1個對象;
- $表示全部:$.sum(20000);
- ~表示覆用上一條規則的範圍:[#ccate01#ccate02#ccate03].countCate(2) & ~.countSPU(5) & $.countSKU(5) & ~.sum(10000)),意味著在類目cate01,cate02,cate03這個範圍內,物品組合需要滿足類目涵蓋2個,SPU涵蓋5個,SKU涵蓋5個,總價達到10000。
- .predict()表示計算的方法,當前支持countCategory()計算範圍內含多少個類目,countSPU()計算範圍內含多少個SPU,countSKU()計算範圍內含多少個SKU,count()計算多少個物品,oneSKU()計算某種SKU含多少個,sum()計算價格的合計。
- expectedValue是一個int數字,表示計算結果要>=這個數, 才能通過。
- 規則可以聯合,用&表示並且,用|表示或者,規則可以分組,用(),比如(rule1&rule2&rule3)|rule4,表示,1、2、3都要達成或者4達成,均可通過規則:
"([#pp01#pp02#pp03].countCate(2) & \$.countSPU(3) & \$.count(5) & \$.sum(10000)) | \$.sum(50000)"
- 每條規則由計算部分和一個規則優惠部分組成,中間用->連接;
- 優惠部分的語法是:
- -1000 表示固定優惠10塊錢(所以錢相關的計算單位是分)
- -1000/10000 表示每100塊錢優惠10塊錢
- -10% 表示優惠10%,即打9折,添加了小數點支持比如-0.5%表示優惠95.5%
- 8000 表示一口價,80塊錢
- -0表示優惠0元,0表示優惠到0元
規則對象
對應表達式,有一系統的結構化對象:
- Rule -- 對應一條完整的營銷規則,主要屬性是condition 表示條件規則,promotion表示優惠規則
- 實際使用過程中,因為要對用戶提示,顯示標簽等,所以需要擴展Rule類,提供更多與計算無關的附加屬性,參見測試用例中的RuleImpl類。
- SimplexRule -- 對應一條條件規則,主要屬性是range表示條件計算範圍,predict表示計算方法,expcteted表示達標的值。
- SameRangeRule -- 與前一條條件規則範圍相同的規則,用~復用前述規則的範圍表達式。
- AndCompositeRule -- 表示and邏輯的條件規則組,主要屬性是保存子規則集合的components,可以addRule()添加子規則,子規則可以是Simplex/SameRange,也可以是AndComposite/OrComposite。
- OrCompositeRule -- 表示or邏輯的條件規則組,其它同AndComposite
- Rule的condition可以是Simplex/AndComposite/OrComposite,不能是SameRange,不然SameRange去哪裡復用範圍規則
- Rule/Simplex/SameRange/AndComposite/OrComposite都有對應的builder,通過Builder.rule()/simplex()/and()/or()可以找到builder的快捷入口。見[規則的創建]
- 條件可以通過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個,並且兩種都要有:
- Strategy.bestMatch()的策略是求最低成本下達成最多優惠,如果是比率折扣,它會取高價票,否則取低價票,上例結果是計算1張01和5張02;
- 如果規則A的promotion是滿折滿減(%,/),則會同時計算將更多票匹配到A是否會帶來更多的優惠
- Strategy.bestOfOnlyOnceDiscount()的策略是只允許使用一次優惠規則,所以計算達成規則所需的最少張數,但是是最高價格的票,上例結果是計算1張01和5張02;
- 如果規則A的promotion是滿折滿減(%,/),則會同時計算將更多票匹配到A是否會帶來更多的優惠
- Rule.discount(),會對所有票應用優惠,上例結果是計算所有9張票;
- 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的規則可以疊加在組別為0的規則應用的結果上,依此類推
- 各組規則可以按組依次計算、疊加,再取最優,即MatchGroup.SequentialMath
- 各組規則可以交織在一起計算、疊加,取所有可能的最優,即MatchGroup.CrossedMatch
- 規則字元串後加@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()
場景擴展
不同的場景會有個性化的需求,源碼中已經實現了對演示場景(票多了座位這一半鍵屬性),可以參考:
- Range支持z表示座位
- Predict增加adjancetSeat判斷商品組合中票是不是連座的
- 用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了