一、简介
1、优惠共享时商品处理
方式1: 折后价 -> A商品用了1号活动后,将以A的优惠后价格去算2号活动。
方式2: 商品互斥 -> A 商品用了1号活动后,将不能用2号活动。
2、优惠策略
策略计算的方法是贪心法,以最小的门槛代价获取最高的优惠。
a、单品折扣
针对指定的商品进行打折促销。
b、订单满减
与其它金额类活动互斥,当订单达到门槛时,可以享用对应优惠,金额需要分摊到全部商品。
c、组合满减
当购买指定的商品组合后,会享用对应优惠。将金额分摊到参与商品上。
c.1 获取满足金额门槛的最优排列
基于动态规划算法,递推关系式为: dp[i][j] = Math.min(dp[i-1][j], dp[i-1][j-p[i].price] + 1);
根据dp结果,反向推出排列;
public List<Product> bestPath(List<Product> products, int totalAmount) {
var size = products.size();
int[][] dp = new int[size][totalAmount + 1];
// init
for (int i = 0; i < size; i++) {
Arrays.fill(dp[i], Integer.MAX_VALUE);
dp[i][0] = 0;
}
// calc
for (int i = 0; i < size; i++) {
var unitPrice = products.get(i).getUnitPrice();
for (int j = 1; j <= Math.min(totalAmount, unitPrice); j++) {
if (j <= unitPrice) {
dp[i][j] = 1;
continue;
}
if (i > 0) {
if (dp[i - 1][j] != Integer.MAX_VALUE) {
dp[i][j] = dp[i - 1][j];
}
if (dp[i - 1][j - unitPrice] != Integer.MAX_VALUE) {
dp[i][j] = Math.min(dp[i][j], dp[i - 1][j - unitPrice] + 1);
}
}
}
}
// 反向推出排列
List<Product> result = new ArrayList<>();
int i = size - 1;
if (dp[i][totalAmount] == Integer.MAX_VALUE) { // 没有满足的方案
return result;
}
int amt = totalAmount;
while (i >= 0) {
if (dp[i][amt] == dp[i - 1][amt]) { //当前product 没有加
i--;
continue;
}
result.add(0, products.get(i)); // 当前product有加
amt -= products.get(i).getUnitPrice();
i--;
}
return result;
}
tips: 这里product不是sku,而是具体每一件。
c.2 每满x元减y元样例
void apply(StrategyContext ctx, List<Product> products) {
var strategy = ctx.getModel().getStrategyList().get(0);
var strategyLocker = ctx.getStrategyLocker();
var tmpLockQueue = new LockerList();
var acc = 0;
for (var p : products) {
// 尝试锁定商品
var lockResult = strategyLocker.tryLockCombo(ctx, p, tmpLockQueue);
if (!lockResult.isSuccess()) {
break;
}
acc += p.unitPrice;
// 加入锁定列表
tmpLockQueue.add(new Locker(p, ctx);
}
var cnt = acc / strategy.getOrderAmount();
// 获取最优排列
var bestProducts = bestPath(tmpLockQueue.getProducts(), cnt * strategy.getOrderAmount());
tmpLockQueue = tmpLockQueue.filter(bestProducts);
if (!tmpLockQueue.isEmpty()){
rowLockerManager.lockProductsShareByAmount( tmpLockQueue, cnt * strategy.getDeductAmount());
}
}
d、赠品活动
赠品活动分为自动推荐赠品和用户手动选择赠品两种形式。
e、换购活动
当推荐换购活动后,用户可以在换购页加购换购商品。
关于换购活动,如果购物车变化时候,这种变化可能会产生以下几种情况:
1、推荐新的换购活动,这个时候需要将购物车中老的换购商品设置为不可选中。
2、换购活动门槛失效,这个时候把之前加购的换购商品设置为不可选中。
3、换购活动门槛又生效,这个时候需要把之前不可选中的换购商品变为选中。
二、技术方案设计
1.商品级互斥
a.Locker
Locker粒度是单个商品的分摊结果。
@Data
public class Locker implements Comparable<Locker>{
/**
* 命中的商品编码
*/
private String skuCode;
/**
* sku的第几个
*/
private Integer index;
/**
* 优惠活动编号
*/
private String promotionId;
/**
* 单价
*/
private Long unitPirce;
/**
* 商品优惠分摊金额
*/
private Long promotionAmoount;
private boolean isSkuLock;
private boolean isSkuDiscountLock;
private boolean isComboLock;
}
b.CartLocker
CartLocker表示整个试算分摊情况。
@Data
public class CartLocker{
/**
* 优惠总金额
*/
private Long totalPromotionAmount;
/**
* 试算分摊结果
*/
private List<Locker> lockers;
}
c.StrategyLocker
StrategyLocker这个类表示某个策略分摊情况。
public class StrategyLocker{
private CartLocker cartLocker;
/**
* 当前活动id
*/
private String promotionId;
/**
* 当前活动命中lockers
*/
private List<Locker> lockers;
/**
* 判断当前商品是否能命中
*/
public boolean tryLock(Product product, List<Product> tmpLockQueue){
String skuCode = product.getSkuCode();
int quantity = product.getQuantity();
// 临时锁定数量
Long tmpLockQuantity = Lists.filter(tmpLockQueue, v -> v.getSkuCode().equals(skuCode)).size();
// 已锁定数量
Long lockQuantity = Lists.filter(lockers, v -> v.getSkuCode().equals(skuCode)).size();
// 获取购物车已锁定数量
Long cartLockerQuantity = Lists.filter(cartLocker.lockers, v -> v.getSkuCode().equals(skuCode)).size();
return tmpLockQuantity + lockQuantity + cartLockerQuantity < quantity;
}
/**
* 锁定临时锁定队列商品
*/
public void lockProductsShareByAmount(List<Product> tmpLockQueue, int promotionAmt){
var totalPrice = Lists.sum(tmpLockQueue, p -> p.getDiscount());
for (Product product:tmpLockQueue){
Locker locker = new Locker(product.getSkuCode(), promotionId, product.getDiscount());
locker.setPromotionAmt(promotionAmt * (product.getDiscount()/totalPrice);
lockers.add(locker);
}
}
}
2.优惠编排
a.单品折扣
上文介绍的Locker 是以策略为粒度的,比方说 A活动 锁定 sku1,sku2, B活动锁定sku1,sku2, 最终最优的是A活动,或者是B活动。。。。。。。而事实上sku1最优的是A活动,sku2最优的是B活动。
所以在计算时候,需要将单品活动根据配置的sku进行拆分,比如A配置了sku1,sku2,那就拆成A1,A2;同理B拆成了B1,B2,呢么最优的是 A1 + B2了。
public List<Promotion> splitSkuPromotion(Promotion promotion){
// 每个sku 对应一个策略
var strategyList = promotion.getStrategyList();
var res = new ArrayList<Promotion>();
for (var strategy:strategyList){
var split = promotion.copy();
split.setStrategyList(List.of(strategy));
res.add(split);
}
return res;
}
由于全排列的影响,分裂单品活动后会导致性能下降,可以在试算时候,根据购物车商品,取出其最优的单品活动,如sku1是A1,sku2是B2, 删除A2和B1
b.组合满减
组合和组合,组合和单品直接计算顺序会影响最终结果,所以采用全排列方式计算。
全排列计算方式
方式1: 在回溯过程中计算。
方式2: 先获取所有排列,然后对每个排列进行异步试算。(!性能更好)
private CartLocker maxCartLocker = null;
/**
* 回溯算法计算优惠全排列
*/
public void dfs(CalculateReq req, CartLocker cartLocker, List<Promotion> pList, int[] visited){
boolean end = true;
for (int i = 0; i < pList.size(); i++){
if (visited[i] == 1){
continue;
}
visited[i] = 1;
end = false;
CartLocker cpCL= cartLocker.copy(); // 这里是copy的,不好回溯。
// 互斥组判断通过
if (StrategyGroup.isNotConflict(cpCl.getHitPromotion(),
pList.get(i)
)){
// 策略计算,结果存在cartLocker中
ApplyResult result = pList.get(i).apply(req, cpCl);
cpCl.addAllLockers(result.getRowLockerManager().getLockers());
}
// 全排列
dfs(req, cpCL, pList, visited);
visited[i] = 0;
}
if (end){
if (!isValid(cartLocker)){return;} // 勾选的优惠券必须使用
if (maxCartLocker == null || maxCartLocker.getPromotionAmt() < cartLocker.getPromotionAmt()){
maxCartLocker = cartLocker;
}
}
}
全排列优化:
1. 如果全是单品活动 -> 降级成线性
2. 如果有1个组合 ,n个单品 -> 降级成组合。
组合 -> x个单品 ,组合 ,n-x个单品。只算x,n-x可以取差集。
tips: 全排列 是 Ann 种, 组合是 Cn0 + Cn 1 + ... C n/2 种
下面是组合的代码示例
private CartLocker maxCartLocker;
private List<Promotion> skuPromotions;
private List<Promotion> comboPromotions;
/**
* 回溯算法计算获取单品的组合
*/
public void dfs(CalculateReq req, CartLocker cartLocker, List<Promotion> tmpSkuPromotions, int start, int size){
if (tmpSkuPromomtions.size() == size){
for (var comboPromotion: comboPromotons){
var left = tmpSkuPromotion;
var middle = comboPromotion;
var right = Lists.filter(skuPromotions, item -> !left.contains(item));
{
var lmr = new ArrayList<>(left);
lmr.add(middle);
lmr.addAll(right);
apply(lmr, req, cartLocker.copy());
// todo setMaxcartlocker
}
{
var rml = new ArrayList<>(right);
lmr.add(middle);
lmr.addAll(left);
apply(rml, req, cartLocker.copy());
// todo setMaxcartLocker
}
}
return;
}
for (int i = start; i < skuPromotions.size(); i++ ){
// add
tmpSkuPromotions.add(skuPromotions.get(i));
dfs(req,cartLocker, tmpSkuPromotions, i + 1 , size);
// not add
tmpSkuPromotions.remove(skuPromotions.get(i));
dfs(req,cartLocker, tmpSkuPromotions, i + 1 , size);
}
}
public void apply(List<Promotion> promotions, CalculateRequest request, CartLocker cartLocker){
for (var promoton:promotions){
promotion.apply(request, cartLocker);
}
if (!isValid(cartLocker)){return;} // 勾选的优惠券必须使用
if (maxCartLocker == null || maxCartLocker.getPromotionAmount() < cartLocker.getPromotionAmount()){
maxCartLocker = cartLocker;
}
}
3.折后价
// note 这里面product 是指同一个skuCode,但是在数量上有区别
Long price = product.getUnitPrice() - cartLocker.getDiscountAmt(product);
4.优惠互斥
通过HitPromotionStack 存储试算过程中已命中的活动,通过CalculateContext中记录互斥配置(用户选了优惠券,会将某些活动互斥)。在判断当前活动是否可用时,判断HitPromotionStack是否有互斥的活动,以及CalculateContext中配置互斥条件。