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了