|
|
@@ -69,6 +69,9 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
@Autowired
|
|
|
private PosOrderMapper posOrderMapper;
|
|
|
|
|
|
+ @Autowired
|
|
|
+ private PromotionDiscountLimitChecker discountLimitChecker;
|
|
|
+
|
|
|
@Override
|
|
|
public PromotionCalcResponse calculate(PromotionCalcRequest request, Long userId)
|
|
|
{
|
|
|
@@ -171,6 +174,27 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
BigDecimal afterPromotion = "A".equals(optimalPath) ? subtotalA : subtotalB;
|
|
|
BigDecimal promotionReduce = originalAmount.subtract(afterPromotion);
|
|
|
|
|
|
+ // ---- 5.5 优惠幅度预算(spec FR-025):预算 = 比例 × originalAmount,按 促销→新客→券 层层封顶 ----
|
|
|
+ // budgetLimit=null(开关关闭)即预算无限:下面所有封顶分支全部跳过,行为与现状完全一致
|
|
|
+ BigDecimal budgetLimit = discountLimitChecker.isEnabled()
|
|
|
+ ? originalAmount.multiply(discountLimitChecker.getRatioPercent().movePointLeft(2))
|
|
|
+ .setScale(0, RoundingMode.HALF_UP)
|
|
|
+ : null;
|
|
|
+ // 促销+新客的已占预算(券之前);候选券列表也复用它算"剩余预算",与当前选中哪张券无关
|
|
|
+ BigDecimal budgetUsedBeforeCoupon = BigDecimal.ZERO;
|
|
|
+ boolean promotionCapped = false;
|
|
|
+ // 半价胜出豁免:第二杯半价结构性封顶该商品小计的25%,不占预算也不封顶(spec Session 2026-09-02)
|
|
|
+ boolean halfPriceExempt = "A".equals(optimalPath)
|
|
|
+ && Integer.valueOf(3).equals(pathAResult.get("appliedType"));
|
|
|
+ if (budgetLimit != null && !halfPriceExempt && promotionReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
+ {
|
|
|
+ BigDecimal capped = promotionReduce.min(budgetLimit);
|
|
|
+ promotionCapped = capped.compareTo(promotionReduce) < 0;
|
|
|
+ budgetUsedBeforeCoupon = budgetUsedBeforeCoupon.add(capped);
|
|
|
+ promotionReduce = capped;
|
|
|
+ afterPromotion = originalAmount.subtract(promotionReduce);
|
|
|
+ }
|
|
|
+
|
|
|
// ---- 6. 新客立减(type=4) ----
|
|
|
BigDecimal newCustomerReduce = BigDecimal.ZERO;
|
|
|
if (userId != null)
|
|
|
@@ -202,8 +226,20 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // 新客立减同样封顶:实减 = min(设置额, 剩余预算)。创建侧无门槛无分母,统一由预算兜底
|
|
|
+ boolean newCustomerCapped = false;
|
|
|
+ if (budgetLimit != null && newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
+ {
|
|
|
+ BigDecimal remaining = budgetLimit.subtract(budgetUsedBeforeCoupon).max(BigDecimal.ZERO);
|
|
|
+ BigDecimal capped = newCustomerReduce.min(remaining);
|
|
|
+ newCustomerCapped = capped.compareTo(newCustomerReduce) < 0;
|
|
|
+ budgetUsedBeforeCoupon = budgetUsedBeforeCoupon.add(capped);
|
|
|
+ newCustomerReduce = capped;
|
|
|
+ }
|
|
|
+
|
|
|
// ---- 7. 优惠券 ----
|
|
|
BigDecimal couponReduce = BigDecimal.ZERO;
|
|
|
+ boolean couponCapped = false;
|
|
|
String couponName = null;
|
|
|
Boolean couponConflict = null;
|
|
|
String conflictNote = null;
|
|
|
@@ -238,6 +274,13 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
CouponCalcResult mutexResult = calcCouponReduce(couponRule, batch, itemsWithPrice, originalAmount, priceMap, nameMap);
|
|
|
couponReduce = mutexResult.reduce;
|
|
|
couponProductItems = mutexResult.items;
|
|
|
+ // 互斥券路径(spec FR-025):促销/新客清零,券自身用全额预算封顶
|
|
|
+ if (budgetLimit != null && couponReduce.compareTo(budgetLimit) > 0)
|
|
|
+ {
|
|
|
+ couponReduce = budgetLimit;
|
|
|
+ couponCapped = true;
|
|
|
+ applyCappedCouponReduce(couponProductItems, couponReduce);
|
|
|
+ }
|
|
|
afterPromotion = originalAmount;
|
|
|
promotionReduce = BigDecimal.ZERO;
|
|
|
newCustomerReduce = BigDecimal.ZERO;
|
|
|
@@ -275,6 +318,18 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
baseForCoupon, priceMap, nameMap);
|
|
|
couponReduce = normalResult.reduce;
|
|
|
couponProductItems = normalResult.items;
|
|
|
+ // 普通券路径:实减 = min(可抵金额, 促销+新客占用后的剩余预算),部分抵扣
|
|
|
+ if (budgetLimit != null && couponReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
+ {
|
|
|
+ BigDecimal remaining = budgetLimit.subtract(budgetUsedBeforeCoupon)
|
|
|
+ .max(BigDecimal.ZERO);
|
|
|
+ if (couponReduce.compareTo(remaining) > 0)
|
|
|
+ {
|
|
|
+ couponReduce = remaining;
|
|
|
+ couponCapped = true;
|
|
|
+ applyCappedCouponReduce(couponProductItems, couponReduce);
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -365,6 +420,10 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
}
|
|
|
}
|
|
|
detail.setReduce(promotionReduce);
|
|
|
+ if (promotionCapped)
|
|
|
+ {
|
|
|
+ detail.setLimitCapped(true);
|
|
|
+ }
|
|
|
details.add(detail);
|
|
|
}
|
|
|
if (newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
@@ -374,6 +433,10 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
detail.setSubType(4);
|
|
|
detail.setName("新客立减");
|
|
|
detail.setReduce(newCustomerReduce);
|
|
|
+ if (newCustomerCapped)
|
|
|
+ {
|
|
|
+ detail.setLimitCapped(true);
|
|
|
+ }
|
|
|
// 找到新客立减活动ID
|
|
|
for (PromotionActivity act : activeActivities)
|
|
|
{
|
|
|
@@ -392,6 +455,10 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
detail.setSubType(0);
|
|
|
detail.setName(couponName);
|
|
|
detail.setReduce(couponReduce);
|
|
|
+ if (couponCapped)
|
|
|
+ {
|
|
|
+ detail.setLimitCapped(true);
|
|
|
+ }
|
|
|
// 优惠券的refId设为券批次ID
|
|
|
if (batch != null)
|
|
|
{
|
|
|
@@ -404,7 +471,8 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
// ---- 11. 可用优惠券列表 ----
|
|
|
List<Map<String, Object>> availableCouponMaps = buildAvailableCoupons(storeId, userId,
|
|
|
originalAmount, afterPromotion, promotionReduce, itemsWithPrice,
|
|
|
- rulesByProduct, activityMap, priceMap, nameMap, optimalPath);
|
|
|
+ rulesByProduct, activityMap, priceMap, nameMap, optimalPath,
|
|
|
+ budgetLimit, budgetUsedBeforeCoupon);
|
|
|
response.setAvailableCoupons(availableCouponMaps.stream()
|
|
|
.map(this::toAvailableCoupon)
|
|
|
.collect(Collectors.toList()));
|
|
|
@@ -476,6 +544,7 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
ac.setAmount((BigDecimal) couponMap.get("amount"));
|
|
|
ac.setDiscountRate((BigDecimal) couponMap.get("discountRate"));
|
|
|
ac.setUsable((Boolean) couponMap.get("usable"));
|
|
|
+ ac.setUnusableReason((String) couponMap.get("unusableReason"));
|
|
|
ac.setConflictWithPromotion((Boolean) couponMap.get("conflictWithPromotion"));
|
|
|
ac.setCouponPreviewReduce((BigDecimal) couponMap.get("couponPreviewReduce"));
|
|
|
@SuppressWarnings("unchecked")
|
|
|
@@ -839,8 +908,28 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 券被预算封顶后,同步把商品券行预览的优惠后价改为按封顶实减重算
|
|
|
+ * (originalLineTotal − 封顶后实减),保证返回/快照金额与实减一致
|
|
|
+ */
|
|
|
+ private void applyCappedCouponReduce(List<Map<String, Object>> couponItems, BigDecimal cappedReduce)
|
|
|
+ {
|
|
|
+ for (Map<String, Object> lineItem : couponItems)
|
|
|
+ {
|
|
|
+ BigDecimal original = (BigDecimal) lineItem.get("originalLineTotal");
|
|
|
+ if (original != null)
|
|
|
+ {
|
|
|
+ lineItem.put("finalLineTotal", original.subtract(cappedReduce).max(BigDecimal.ZERO)
|
|
|
+ .setScale(0, RoundingMode.HALF_UP));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 构建用户可用优惠券列表
|
|
|
+ *
|
|
|
+ * budgetLimit 为 null(限制开关关闭)时不算预算,行为与现状完全一致;
|
|
|
+ * budgetUsedBeforeCoupon 为促销+新客已占预算(候选券与当前选中券互为替代,故只看券前占用)
|
|
|
*/
|
|
|
private List<Map<String, Object>> buildAvailableCoupons(Long storeId, Long userId,
|
|
|
BigDecimal originalAmount,
|
|
|
@@ -851,7 +940,9 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
Map<Long, PromotionActivity> activityMap,
|
|
|
Map<Long, BigDecimal> priceMap,
|
|
|
Map<Long, String> nameMap,
|
|
|
- String optimalPath)
|
|
|
+ String optimalPath,
|
|
|
+ BigDecimal budgetLimit,
|
|
|
+ BigDecimal budgetUsedBeforeCoupon)
|
|
|
{
|
|
|
List<Map<String, Object>> available = new ArrayList<>();
|
|
|
if (userId == null)
|
|
|
@@ -944,6 +1035,38 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // 预算封顶(spec FR-026):候选实抵 = min(原可抵金额, 剩余预算);互斥券选中会清掉促销,用全额预算
|
|
|
+ // 放在商品券预览之后,封顶后的实抵值覆盖未封顶预览
|
|
|
+ if (budgetLimit != null)
|
|
|
+ {
|
|
|
+ // 互斥券只有在实际会取消促销时(促销>0,与 calculate 互斥分支进入条件一致)才享受全额预算;
|
|
|
+ // 无促销时互斥券走普通分支,按促销+新客占用后的剩余预算封顶
|
|
|
+ boolean mutexActive = isMutex == 1 && promotionReduce.compareTo(BigDecimal.ZERO) > 0;
|
|
|
+ BigDecimal candidateBudget = mutexActive ? budgetLimit
|
|
|
+ : budgetLimit.subtract(budgetUsedBeforeCoupon).max(BigDecimal.ZERO);
|
|
|
+ BigDecimal baseForCalc = mutexActive ? originalAmount : afterPromotion;
|
|
|
+ CouponCalcResult budgetCalc = calcCouponReduce(rule, batch, itemsWithPrice,
|
|
|
+ baseForCalc, priceMap, nameMap);
|
|
|
+ BigDecimal cappedReduce = budgetCalc.reduce.min(candidateBudget);
|
|
|
+ if (budgetCalc.reduce.compareTo(BigDecimal.ZERO) > 0
|
|
|
+ && cappedReduce.compareTo(BigDecimal.ZERO) <= 0)
|
|
|
+ {
|
|
|
+ usable = false;
|
|
|
+ couponInfo.put("unusableReason", "本单优惠已达优惠上限");
|
|
|
+ couponInfo.put("couponPreviewReduce", BigDecimal.ZERO);
|
|
|
+ }
|
|
|
+ else if (cappedReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
+ {
|
|
|
+ couponInfo.put("couponPreviewReduce", cappedReduce);
|
|
|
+ if (cappedReduce.compareTo(budgetCalc.reduce) < 0)
|
|
|
+ {
|
|
|
+ // 商品券预览行同步按封顶实减重算,避免预览行金额与 couponPreviewReduce 矛盾
|
|
|
+ applyCappedCouponReduce(budgetCalc.items, cappedReduce);
|
|
|
+ couponInfo.put("productPreview", budgetCalc.items);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
couponInfo.put("usable", usable);
|
|
|
available.add(couponInfo);
|
|
|
}
|