超過10年沒有更新過內容了,不知道現在園子的氛圍這類文章還適不適合放首頁
想著整點內容,也是支援園子!
為了廣泛支援行銷活動的複雜與靈活,Wonder8.promotion(旺德發.行銷)引擎使用專門設計的表示式高度提煉資訊,可以輕鬆表達行銷活動與使用者選取的商品組合之間匹配範圍、要求、折扣方式,可以設定多條行銷規則的邏輯聯合、分組、優先順序,並且支援多種為使用者計算最優折扣的策略。
本引擎功能細節較多,建議用以下步驟來學習和應用:
所有想法源自於一個行銷折扣的規則可以抽象成三個部分:
Wonder8.promotion使用表示式來表達和組合行銷規則,如表示當一組商品中包括食品-水果、食品-肉類、食品-蔬菜三大類中至少兩個時,優惠10%:"[#cFOOD-FRUIT#cFOOD-MEAT#cFOOD-VEGETABLE].countCate(2)->-10%":
"([#pp01#pp02#pp03].countCate(2) & \$.countSPU(3) & \$.count(5) & \$.sum(10000)) | \$.sum(50000)"
對應表示式,有一系統的結構化物件:
/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個,並且兩種都要有:
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
//以下例子應用了擴充套件場景-劇院座位,多了一個座位的屬性,多張鄰座票可以組合成一個聯票,形成聯票後又可以應用聯票的優惠規則
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
規則引擎的模組非常清楚,面對不同的任務,可以在相對明確的範圍做少量調整,並帶來全域性的收益:
我們看一下如何擴充套件一個oneSKU謂詞來實現至少有一單個SKU必須要達到多少數量的判斷。
//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;
}
}
}
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();
});
}
};
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()
不同的場景會有個性化的需求,原始碼中已經實現了對演示場景(票多了座位這一半鍵屬性),可以參考:
|- /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了