|
@@ -14,6 +14,8 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
|
|
+import com.ruoyi.common.exception.ServiceException;
|
|
|
|
|
+import com.ruoyi.common.utils.MessageUtils;
|
|
|
import com.ruoyi.system.domain.PosFood;
|
|
import com.ruoyi.system.domain.PosFood;
|
|
|
import com.ruoyi.system.domain.PosOrder;
|
|
import com.ruoyi.system.domain.PosOrder;
|
|
|
import com.ruoyi.system.domain.PromotionActivity;
|
|
import com.ruoyi.system.domain.PromotionActivity;
|
|
@@ -23,6 +25,7 @@ import com.ruoyi.system.domain.PromotionCouponRule;
|
|
|
import com.ruoyi.system.domain.PromotionUserCoupon;
|
|
import com.ruoyi.system.domain.PromotionUserCoupon;
|
|
|
import com.ruoyi.system.dto.PromotionCalcRequest;
|
|
import com.ruoyi.system.dto.PromotionCalcRequest;
|
|
|
import com.ruoyi.system.dto.PromotionCalcResponse;
|
|
import com.ruoyi.system.dto.PromotionCalcResponse;
|
|
|
|
|
+import com.ruoyi.system.dto.PromotionDeliveryContext;
|
|
|
import com.ruoyi.system.dto.PromotionCalcResponse.AvailableCoupon;
|
|
import com.ruoyi.system.dto.PromotionCalcResponse.AvailableCoupon;
|
|
|
import com.ruoyi.system.dto.PromotionCalcResponse.LineItem;
|
|
import com.ruoyi.system.dto.PromotionCalcResponse.LineItem;
|
|
|
import com.ruoyi.system.dto.PromotionCalcResponse.MatchedRule;
|
|
import com.ruoyi.system.dto.PromotionCalcResponse.MatchedRule;
|
|
@@ -79,83 +82,17 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
Long couponId = request.getCouponId();
|
|
Long couponId = request.getCouponId();
|
|
|
String forcePath = request.getForcePath();
|
|
String forcePath = request.getForcePath();
|
|
|
|
|
|
|
|
- // 将 DTO items 转为内部 Map 格式
|
|
|
|
|
- List<Map<String, Object>> items = new ArrayList<>();
|
|
|
|
|
- if (request.getItems() != null)
|
|
|
|
|
- {
|
|
|
|
|
- for (PromotionCalcRequest.CartItem ci : request.getItems())
|
|
|
|
|
- {
|
|
|
|
|
- Map<String, Object> m = new LinkedHashMap<>();
|
|
|
|
|
- m.put("productId", ci.getProductId());
|
|
|
|
|
- m.put("quantity", ci.getQuantity());
|
|
|
|
|
- m.put("specPrice", ci.getSpecPrice());
|
|
|
|
|
- items.add(m);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ---- 1. 获取商品真实价格,计算 originalAmount ----
|
|
|
|
|
- Map<Long, BigDecimal> priceMap = new HashMap<>();
|
|
|
|
|
- Map<Long, String> nameMap = new HashMap<>();
|
|
|
|
|
- for (Map<String, Object> item : items)
|
|
|
|
|
- {
|
|
|
|
|
- Long productId = toLong(item.get("productId"));
|
|
|
|
|
- if (productId != null && !priceMap.containsKey(productId))
|
|
|
|
|
- {
|
|
|
|
|
- PosFood food = posFoodMapper.selectById(productId);
|
|
|
|
|
- if (food != null)
|
|
|
|
|
- {
|
|
|
|
|
- priceMap.put(productId, food.getPrice());
|
|
|
|
|
- nameMap.put(productId, food.getName());
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- BigDecimal originalAmount = BigDecimal.ZERO;
|
|
|
|
|
- List<Map<String, Object>> itemsWithPrice = new ArrayList<>();
|
|
|
|
|
- for (Map<String, Object> item : items)
|
|
|
|
|
- {
|
|
|
|
|
- Long productId = toLong(item.get("productId"));
|
|
|
|
|
- int quantity = toInt(item.get("quantity"));
|
|
|
|
|
- // 实付单价 = 商品价格 + 规格价格(规格价可为0/不传), 按行计算以支持同商品多规格
|
|
|
|
|
- BigDecimal basePrice = priceMap.getOrDefault(productId, BigDecimal.ZERO);
|
|
|
|
|
- BigDecimal specPrice = (BigDecimal) item.get("specPrice");
|
|
|
|
|
- if (specPrice == null)
|
|
|
|
|
- {
|
|
|
|
|
- specPrice = BigDecimal.ZERO;
|
|
|
|
|
- }
|
|
|
|
|
- BigDecimal unitPrice = basePrice.add(specPrice);
|
|
|
|
|
- originalAmount = originalAmount.add(unitPrice.multiply(BigDecimal.valueOf(quantity)));
|
|
|
|
|
-
|
|
|
|
|
- Map<String, Object> ip = new LinkedHashMap<>();
|
|
|
|
|
- ip.put("productId", productId);
|
|
|
|
|
- ip.put("quantity", quantity);
|
|
|
|
|
- ip.put("unitPrice", unitPrice);
|
|
|
|
|
- ip.put("name", nameMap.getOrDefault(productId, ""));
|
|
|
|
|
- itemsWithPrice.add(ip);
|
|
|
|
|
- }
|
|
|
|
|
- originalAmount = originalAmount.setScale(0, RoundingMode.HALF_UP);
|
|
|
|
|
-
|
|
|
|
|
- // ---- 2. 查询门店生效的促销活动 ----
|
|
|
|
|
- List<PromotionActivity> activeActivities = activityMapper.selectActiveByStoreId(storeId);
|
|
|
|
|
- List<PromotionActivityRule> activeRules = activityRuleMapper.selectActiveRulesByStoreId(storeId);
|
|
|
|
|
-
|
|
|
|
|
- Map<Long, PromotionActivity> activityMap = activeActivities.stream()
|
|
|
|
|
- .collect(Collectors.toMap(PromotionActivity::getId, a -> a, (a, b) -> a));
|
|
|
|
|
-
|
|
|
|
|
- Map<Long, List<PromotionActivityRule>> rulesByActivity = activeRules.stream()
|
|
|
|
|
- .collect(Collectors.groupingBy(PromotionActivityRule::getActivityId));
|
|
|
|
|
-
|
|
|
|
|
- Map<Long, List<PromotionActivityRule>> rulesByProduct = activeRules.stream()
|
|
|
|
|
- .filter(r -> r.getProductId() != null)
|
|
|
|
|
- .collect(Collectors.groupingBy(PromotionActivityRule::getProductId));
|
|
|
|
|
-
|
|
|
|
|
- // ---- 3. PATH A: 折扣(type=2) / 第二份半价(type=3) ----
|
|
|
|
|
- Map<String, Object> pathAResult = calculatePathA(itemsWithPrice, activeActivities,
|
|
|
|
|
- rulesByActivity, rulesByProduct, activityMap);
|
|
|
|
|
-
|
|
|
|
|
- // ---- 4. PATH B: 满减(type=1) ----
|
|
|
|
|
- Map<String, Object> pathBResult = calculatePathB(itemsWithPrice, originalAmount,
|
|
|
|
|
- activeActivities, rulesByActivity, activityMap);
|
|
|
|
|
|
|
+ CalculationBase base = loadCalculationBase(request);
|
|
|
|
|
+ List<Map<String, Object>> itemsWithPrice = base.itemsWithPrice;
|
|
|
|
|
+ BigDecimal originalAmount = base.originalAmount;
|
|
|
|
|
+ List<PromotionActivity> activeActivities = base.activeActivities;
|
|
|
|
|
+ Map<Long, List<PromotionActivityRule>> rulesByActivity = base.rulesByActivity;
|
|
|
|
|
+ Map<Long, List<PromotionActivityRule>> rulesByProduct = base.rulesByProduct;
|
|
|
|
|
+ Map<Long, PromotionActivity> activityMap = base.activityMap;
|
|
|
|
|
+ Map<String, Object> pathAResult = base.pathAResult;
|
|
|
|
|
+ Map<String, Object> pathBResult = base.pathBResult;
|
|
|
|
|
+ Map<Long, BigDecimal> priceMap = base.priceMap;
|
|
|
|
|
+ Map<Long, String> nameMap = base.nameMap;
|
|
|
|
|
|
|
|
// ---- 5. 比较两条路径 ----
|
|
// ---- 5. 比较两条路径 ----
|
|
|
BigDecimal subtotalA = (BigDecimal) pathAResult.get("subtotal");
|
|
BigDecimal subtotalA = (BigDecimal) pathAResult.get("subtotal");
|
|
@@ -480,6 +417,640 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
return response;
|
|
return response;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 完整配送算价(pricingVersion=2,2026-10-19 满额免运,spec FR-028~FR-036)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 与旧 calculate 的差异仅在于配送上下文存在时的叠加规则:
|
|
|
|
|
+ * - 满额免运按商品原价 O 判断门槛,命中即全额预留 F=R(允许 F>B,绝不部分免运);
|
|
|
|
|
+ * - 受限优惠(促销/新客/券)的可用预算为 max(0, B−F),互斥券路径同样不释放 F;
|
|
|
|
|
+ * - 路径选择比较应用预算与指定券后的整单应付 A=G+D,取低者,相等选 B;forcePath 尊重用户选择;
|
|
|
|
|
+ * - 命中免运时免配送费券不可用、不核销;appliedCouponId 仅在券实减>0时返回;
|
|
|
|
|
+ * - 第二份半价胜出的预算豁免保留(不占预算、不封顶)。
|
|
|
|
|
+ * finalAmount 语义与旧接口一致:商品应付 G,不含配送费。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public PromotionCalcResponse calculate(PromotionCalcRequest request, Long userId, PromotionDeliveryContext deliveryContext)
|
|
|
|
|
+ {
|
|
|
|
|
+ Long storeId = request.getStoreId();
|
|
|
|
|
+ Long couponId = request.getCouponId();
|
|
|
|
|
+ String forcePath = request.getForcePath();
|
|
|
|
|
+
|
|
|
|
|
+ CalculationBase base = loadCalculationBase(request);
|
|
|
|
|
+ List<Map<String, Object>> itemsWithPrice = base.itemsWithPrice;
|
|
|
|
|
+ BigDecimal originalAmount = base.originalAmount;
|
|
|
|
|
+ List<PromotionActivity> activeActivities = base.activeActivities;
|
|
|
|
|
+ Map<Long, List<PromotionActivityRule>> rulesByActivity = base.rulesByActivity;
|
|
|
|
|
+ Map<Long, List<PromotionActivityRule>> rulesByProduct = base.rulesByProduct;
|
|
|
|
|
+ Map<Long, PromotionActivity> activityMap = base.activityMap;
|
|
|
|
|
+ Map<String, Object> pathAResult = base.pathAResult;
|
|
|
|
|
+ Map<String, Object> pathBResult = base.pathBResult;
|
|
|
|
|
+ Map<Long, BigDecimal> priceMap = base.priceMap;
|
|
|
|
|
+ Map<Long, String> nameMap = base.nameMap;
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 1. 满额免运判定(最高优先级,按商品原价 O 判断,不含配送费/促销/券) ----
|
|
|
|
|
+ BigDecimal deliveryFee = deliveryContext == null || deliveryContext.getDeliveryFee() == null
|
|
|
|
|
+ ? BigDecimal.ZERO : deliveryContext.getDeliveryFee();
|
|
|
|
|
+ boolean deliveryEligible = deliveryContext == null || deliveryContext.isEligible();
|
|
|
|
|
+ PromotionActivity freeDeliveryActivity = null;
|
|
|
|
|
+ BigDecimal freeDeliveryThreshold = null;
|
|
|
|
|
+ if (deliveryEligible && deliveryFee.compareTo(BigDecimal.ZERO) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ List<PromotionActivity> fdActivities = activityMapper.selectActiveFreeDeliveryByStoreId(storeId, new Date());
|
|
|
|
|
+ if (fdActivities != null && fdActivities.size() > 1)
|
|
|
|
|
+ {
|
|
|
|
|
+ throw new ServiceException(MessageUtils.message("promotion.freeDelivery.configConflict"));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (fdActivities != null && fdActivities.size() == 1)
|
|
|
|
|
+ {
|
|
|
|
|
+ PromotionActivity candidate = fdActivities.get(0);
|
|
|
|
|
+ List<PromotionActivityRule> fdRules = activityRuleMapper.selectRulesByActivityId(candidate.getId());
|
|
|
|
|
+ BigDecimal threshold = fdRules == null || fdRules.isEmpty() ? null : fdRules.get(0).getThreshold();
|
|
|
|
|
+ if (threshold == null || threshold.compareTo(BigDecimal.ZERO) <= 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ throw new ServiceException(MessageUtils.message("promotion.freeDelivery.configConflict"));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (originalAmount.compareTo(threshold) >= 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ freeDeliveryActivity = candidate;
|
|
|
|
|
+ freeDeliveryThreshold = threshold;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 命中:F=R、D=0;未命中:F=0、D=R
|
|
|
|
|
+ BigDecimal freeDeliveryReduce = freeDeliveryActivity == null ? BigDecimal.ZERO : deliveryFee;
|
|
|
|
|
+ BigDecimal customerDeliveryFee = deliveryFee.subtract(freeDeliveryReduce);
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 2. 总优惠预算 B 与受限优惠可用预算 max(0, B−F) ----
|
|
|
|
|
+ BigDecimal budgetLimit = discountLimitChecker.isEnabled()
|
|
|
|
|
+ ? originalAmount.multiply(discountLimitChecker.getRatioPercent().movePointLeft(2))
|
|
|
|
|
+ .setScale(0, RoundingMode.HALF_UP)
|
|
|
|
|
+ : null;
|
|
|
|
|
+ BigDecimal goodsBudget = budgetLimit == null
|
|
|
|
|
+ ? null : budgetLimit.subtract(freeDeliveryReduce).max(BigDecimal.ZERO);
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 3. 预加载指定券(两条路径共用,避免重复查询) ----
|
|
|
|
|
+ SelectedCoupon selectedCoupon = loadSelectedCoupon(couponId, userId);
|
|
|
|
|
+ // 命中满额免运时免配送费券不生效、不核销(spec FR-036);couponId 仍保留用户选择意图
|
|
|
|
|
+ if (selectedCoupon != null && freeDeliveryReduce.compareTo(BigDecimal.ZERO) > 0
|
|
|
|
|
+ && selectedCoupon.batch.getCouponType() != null
|
|
|
|
|
+ && selectedCoupon.batch.getCouponType() == 3)
|
|
|
|
|
+ {
|
|
|
|
|
+ selectedCoupon = null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 4. 逐路径按预算+指定券计算,比较整单应付 A=G+D 取低者(相等选B) ----
|
|
|
|
|
+ PathEvaluation evalA = evaluatePath("A", pathAResult, originalAmount, storeId, activeActivities,
|
|
|
|
|
+ rulesByActivity, rulesByProduct, activityMap, itemsWithPrice, priceMap, nameMap,
|
|
|
|
|
+ userId, goodsBudget, selectedCoupon);
|
|
|
|
|
+ PathEvaluation evalB = evaluatePath("B", pathBResult, originalAmount, storeId, activeActivities,
|
|
|
|
|
+ rulesByActivity, rulesByProduct, activityMap, itemsWithPrice, priceMap, nameMap,
|
|
|
|
|
+ userId, goodsBudget, selectedCoupon);
|
|
|
|
|
+
|
|
|
|
|
+ String optimalPath;
|
|
|
|
|
+ if (forcePath != null && ("A".equalsIgnoreCase(forcePath) || "B".equalsIgnoreCase(forcePath)))
|
|
|
|
|
+ {
|
|
|
|
|
+ optimalPath = forcePath.toUpperCase();
|
|
|
|
|
+ }
|
|
|
|
|
+ else if (evalA.finalAmount.add(customerDeliveryFee)
|
|
|
|
|
+ .compareTo(evalB.finalAmount.add(customerDeliveryFee)) < 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ optimalPath = "A";
|
|
|
|
|
+ }
|
|
|
|
|
+ else
|
|
|
|
|
+ {
|
|
|
|
|
+ optimalPath = "B";
|
|
|
|
|
+ }
|
|
|
|
|
+ PathEvaluation winner = "A".equals(optimalPath) ? evalA : evalB;
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 5. 响应基础字段(finalAmount=G 保持旧含义,不含配送费) ----
|
|
|
|
|
+ PromotionCalcResponse response = new PromotionCalcResponse();
|
|
|
|
|
+ response.setPricingVersion(2);
|
|
|
|
|
+ response.setOriginalAmount(originalAmount);
|
|
|
|
|
+ response.setPathA(toPathResult(pathAResult));
|
|
|
|
|
+ response.setPathB(toPathResult(pathBResult));
|
|
|
|
|
+ response.setOptimalPath(optimalPath);
|
|
|
|
|
+ response.setFinalAmount(winner.finalAmount);
|
|
|
|
|
+ response.setDeliveryFee(deliveryFee);
|
|
|
|
|
+ response.setDeliveryDiscountAmount(freeDeliveryReduce);
|
|
|
|
|
+ response.setCustomerDeliveryFee(customerDeliveryFee);
|
|
|
|
|
+ response.setPayableAmount(winner.finalAmount.add(customerDeliveryFee));
|
|
|
|
|
+ if (freeDeliveryActivity != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ response.setFreeDeliveryActivityId(freeDeliveryActivity.getId());
|
|
|
|
|
+ response.setFreeDeliveryThreshold(freeDeliveryThreshold);
|
|
|
|
|
+ }
|
|
|
|
|
+ response.setDiscountBudget(budgetLimit);
|
|
|
|
|
+ if (budgetLimit != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ // 剩余预算 = max(0, B − F − 受限优惠已占用);半价豁免部分不占预算不计入
|
|
|
|
|
+ response.setRemainingDiscountBudget(budgetLimit
|
|
|
|
|
+ .subtract(freeDeliveryReduce)
|
|
|
|
|
+ .subtract(winner.budgetUsedTotal)
|
|
|
|
|
+ .max(BigDecimal.ZERO));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (winner.newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ response.setNewCustomerReduce(winner.newCustomerReduce);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (couponId != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ response.setCouponId(couponId);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (winner.couponName != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ response.setCouponName(winner.couponName);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (winner.couponReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ response.setCouponReduce(winner.couponReduce);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (winner.couponConflict != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ response.setCouponConflict(winner.couponConflict);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (winner.conflictNote != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ response.setConflictNote(winner.conflictNote);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (selectedCoupon != null && selectedCoupon.batch != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ response.setCouponBatchId(selectedCoupon.batch.getId());
|
|
|
|
|
+ }
|
|
|
|
|
+ // 仅实际生效且实减>0的券返回 appliedCouponId,核销以此为准
|
|
|
|
|
+ if (couponId != null && winner.couponReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ response.setAppliedCouponId(couponId);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (winner.couponProductItems != null && !winner.couponProductItems.isEmpty())
|
|
|
|
|
+ {
|
|
|
|
|
+ response.setCouponProductItems(winner.couponProductItems.stream()
|
|
|
|
|
+ .map(this::toLineItem).collect(Collectors.toList()));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 6. 明细列表(type=5 满额免运独立于商品促销汇总,最先列出) ----
|
|
|
|
|
+ List<PromotionDetail> details = new ArrayList<>();
|
|
|
|
|
+ if (freeDeliveryReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ PromotionDetail fdDetail = new PromotionDetail();
|
|
|
|
|
+ fdDetail.setType("promotion");
|
|
|
|
|
+ fdDetail.setSubType(5);
|
|
|
|
|
+ fdDetail.setName(freeDeliveryActivity.getName());
|
|
|
|
|
+ fdDetail.setRefId(freeDeliveryActivity.getId());
|
|
|
|
|
+ fdDetail.setReduce(freeDeliveryReduce);
|
|
|
|
|
+ fdDetail.setOriginalAmount(originalAmount);
|
|
|
|
|
+ fdDetail.setDeliveryFee(deliveryFee);
|
|
|
|
|
+ fdDetail.setCustomerDeliveryFee(customerDeliveryFee);
|
|
|
|
|
+ fdDetail.setThreshold(freeDeliveryThreshold);
|
|
|
|
|
+ details.add(fdDetail);
|
|
|
|
|
+ }
|
|
|
|
|
+ appendGoodsDetails(details, winner, optimalPath, activeActivities, pathAResult, pathBResult, selectedCoupon);
|
|
|
|
|
+ response.setDetails(details);
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 7. 可用优惠券列表(与选中路径同一剩余预算;命中免运时免配送费券不可用) ----
|
|
|
|
|
+ List<Map<String, Object>> availableCouponMaps = buildAvailableCoupons(storeId, userId,
|
|
|
|
|
+ originalAmount, winner.afterPromotion, winner.promotionReduce, itemsWithPrice,
|
|
|
|
|
+ rulesByProduct, activityMap, priceMap, nameMap, optimalPath,
|
|
|
|
|
+ goodsBudget, winner.budgetUsedBeforeCoupon, freeDeliveryReduce);
|
|
|
|
|
+ response.setAvailableCoupons(availableCouponMaps.stream()
|
|
|
|
|
+ .map(this::toAvailableCoupon)
|
|
|
|
|
+ .collect(Collectors.toList()));
|
|
|
|
|
+
|
|
|
|
|
+ return response;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 共享装载与完整配送内部方法 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /** 旧/新算价共用的计算基底(纯抽取自原 calculate 前四步,行为不变) */
|
|
|
|
|
+ private CalculationBase loadCalculationBase(PromotionCalcRequest request)
|
|
|
|
|
+ {
|
|
|
|
|
+ Long storeId = request.getStoreId();
|
|
|
|
|
+
|
|
|
|
|
+ // 将 DTO items 转为内部 Map 格式
|
|
|
|
|
+ List<Map<String, Object>> items = new ArrayList<>();
|
|
|
|
|
+ if (request.getItems() != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ for (PromotionCalcRequest.CartItem ci : request.getItems())
|
|
|
|
|
+ {
|
|
|
|
|
+ Map<String, Object> m = new LinkedHashMap<>();
|
|
|
|
|
+ m.put("productId", ci.getProductId());
|
|
|
|
|
+ m.put("quantity", ci.getQuantity());
|
|
|
|
|
+ m.put("specPrice", ci.getSpecPrice());
|
|
|
|
|
+ items.add(m);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 1. 获取商品真实价格,计算 originalAmount ----
|
|
|
|
|
+ Map<Long, BigDecimal> priceMap = new HashMap<>();
|
|
|
|
|
+ Map<Long, String> nameMap = new HashMap<>();
|
|
|
|
|
+ for (Map<String, Object> item : items)
|
|
|
|
|
+ {
|
|
|
|
|
+ Long productId = toLong(item.get("productId"));
|
|
|
|
|
+ if (productId != null && !priceMap.containsKey(productId))
|
|
|
|
|
+ {
|
|
|
|
|
+ PosFood food = posFoodMapper.selectById(productId);
|
|
|
|
|
+ if (food != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ priceMap.put(productId, food.getPrice());
|
|
|
|
|
+ nameMap.put(productId, food.getName());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ BigDecimal originalAmount = BigDecimal.ZERO;
|
|
|
|
|
+ List<Map<String, Object>> itemsWithPrice = new ArrayList<>();
|
|
|
|
|
+ for (Map<String, Object> item : items)
|
|
|
|
|
+ {
|
|
|
|
|
+ Long productId = toLong(item.get("productId"));
|
|
|
|
|
+ int quantity = toInt(item.get("quantity"));
|
|
|
|
|
+ // 实付单价 = 商品价格 + 规格价格(规格价可为0/不传), 按行计算以支持同商品多规格
|
|
|
|
|
+ BigDecimal basePrice = priceMap.getOrDefault(productId, BigDecimal.ZERO);
|
|
|
|
|
+ BigDecimal specPrice = (BigDecimal) item.get("specPrice");
|
|
|
|
|
+ if (specPrice == null)
|
|
|
|
|
+ {
|
|
|
|
|
+ specPrice = BigDecimal.ZERO;
|
|
|
|
|
+ }
|
|
|
|
|
+ BigDecimal unitPrice = basePrice.add(specPrice);
|
|
|
|
|
+ originalAmount = originalAmount.add(unitPrice.multiply(BigDecimal.valueOf(quantity)));
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> ip = new LinkedHashMap<>();
|
|
|
|
|
+ ip.put("productId", productId);
|
|
|
|
|
+ ip.put("quantity", quantity);
|
|
|
|
|
+ ip.put("unitPrice", unitPrice);
|
|
|
|
|
+ ip.put("name", nameMap.getOrDefault(productId, ""));
|
|
|
|
|
+ itemsWithPrice.add(ip);
|
|
|
|
|
+ }
|
|
|
|
|
+ originalAmount = originalAmount.setScale(0, RoundingMode.HALF_UP);
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 2. 查询门店生效的促销活动 ----
|
|
|
|
|
+ List<PromotionActivity> activeActivities = activityMapper.selectActiveByStoreId(storeId);
|
|
|
|
|
+ List<PromotionActivityRule> activeRules = activityRuleMapper.selectActiveRulesByStoreId(storeId);
|
|
|
|
|
+
|
|
|
|
|
+ Map<Long, PromotionActivity> activityMap = activeActivities.stream()
|
|
|
|
|
+ .collect(Collectors.toMap(PromotionActivity::getId, a -> a, (a, b) -> a));
|
|
|
|
|
+
|
|
|
|
|
+ Map<Long, List<PromotionActivityRule>> rulesByActivity = activeRules.stream()
|
|
|
|
|
+ .collect(Collectors.groupingBy(PromotionActivityRule::getActivityId));
|
|
|
|
|
+
|
|
|
|
|
+ Map<Long, List<PromotionActivityRule>> rulesByProduct = activeRules.stream()
|
|
|
|
|
+ .filter(r -> r.getProductId() != null)
|
|
|
|
|
+ .collect(Collectors.groupingBy(PromotionActivityRule::getProductId));
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 3. PATH A: 折扣(type=2) / 第二份半价(type=3) ----
|
|
|
|
|
+ Map<String, Object> pathAResult = calculatePathA(itemsWithPrice, activeActivities,
|
|
|
|
|
+ rulesByActivity, rulesByProduct, activityMap);
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 4. PATH B: 满减(type=1) ----
|
|
|
|
|
+ Map<String, Object> pathBResult = calculatePathB(itemsWithPrice, originalAmount,
|
|
|
|
|
+ activeActivities, rulesByActivity, activityMap);
|
|
|
|
|
+
|
|
|
|
|
+ CalculationBase base = new CalculationBase();
|
|
|
|
|
+ base.itemsWithPrice = itemsWithPrice;
|
|
|
|
|
+ base.originalAmount = originalAmount;
|
|
|
|
|
+ base.activeActivities = activeActivities;
|
|
|
|
|
+ base.rulesByActivity = rulesByActivity;
|
|
|
|
|
+ base.rulesByProduct = rulesByProduct;
|
|
|
|
|
+ base.activityMap = activityMap;
|
|
|
|
|
+ base.pathAResult = pathAResult;
|
|
|
|
|
+ base.pathBResult = pathBResult;
|
|
|
|
|
+ base.priceMap = priceMap;
|
|
|
|
|
+ base.nameMap = nameMap;
|
|
|
|
|
+ return base;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 计算基底(loadCalculationBase 的产物,新旧算价共用) */
|
|
|
|
|
+ private static class CalculationBase
|
|
|
|
|
+ {
|
|
|
|
|
+ List<Map<String, Object>> itemsWithPrice;
|
|
|
|
|
+ BigDecimal originalAmount;
|
|
|
|
|
+ List<PromotionActivity> activeActivities;
|
|
|
|
|
+ Map<Long, List<PromotionActivityRule>> rulesByActivity;
|
|
|
|
|
+ Map<Long, List<PromotionActivityRule>> rulesByProduct;
|
|
|
|
|
+ Map<Long, PromotionActivity> activityMap;
|
|
|
|
|
+ Map<String, Object> pathAResult;
|
|
|
|
|
+ Map<String, Object> pathBResult;
|
|
|
|
|
+ Map<Long, BigDecimal> priceMap;
|
|
|
|
|
+ Map<Long, String> nameMap;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 指定券预加载结果(null 表示无有效券) */
|
|
|
|
|
+ private static class SelectedCoupon
|
|
|
|
|
+ {
|
|
|
|
|
+ PromotionUserCoupon userCoupon;
|
|
|
|
|
+ PromotionCouponBatch batch;
|
|
|
|
|
+ PromotionCouponRule rule;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private SelectedCoupon loadSelectedCoupon(Long couponId, Long userId)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (couponId == null)
|
|
|
|
|
+ {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ PromotionUserCoupon userCoupon = userCouponMapper.selectById(couponId);
|
|
|
|
|
+ if (userCoupon == null || !userCoupon.getUserId().equals(userId)
|
|
|
|
|
+ || userCoupon.getStatus() == null || userCoupon.getStatus() != 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (userCoupon.getExpireTime() != null && userCoupon.getExpireTime().before(new Date()))
|
|
|
|
|
+ {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ PromotionCouponBatch batch = couponBatchMapper.selectById(userCoupon.getBatchId());
|
|
|
|
|
+ PromotionCouponRule rule = couponRuleMapper.selectRuleByBatchId(userCoupon.getBatchId());
|
|
|
|
|
+ if (batch == null || rule == null)
|
|
|
|
|
+ {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ SelectedCoupon selected = new SelectedCoupon();
|
|
|
|
|
+ selected.userCoupon = userCoupon;
|
|
|
|
|
+ selected.batch = batch;
|
|
|
|
|
+ selected.rule = rule;
|
|
|
|
|
+ return selected;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 单条路径在预算+指定券下的完整评估结果 */
|
|
|
|
|
+ private static class PathEvaluation
|
|
|
|
|
+ {
|
|
|
|
|
+ String path;
|
|
|
|
|
+ BigDecimal afterPromotion = BigDecimal.ZERO;
|
|
|
|
|
+ BigDecimal promotionReduce = BigDecimal.ZERO;
|
|
|
|
|
+ boolean promotionCapped = false;
|
|
|
|
|
+ BigDecimal newCustomerReduce = BigDecimal.ZERO;
|
|
|
|
|
+ boolean newCustomerCapped = false;
|
|
|
|
|
+ BigDecimal couponReduce = BigDecimal.ZERO;
|
|
|
|
|
+ boolean couponCapped = false;
|
|
|
|
|
+ String couponName;
|
|
|
|
|
+ Boolean couponConflict;
|
|
|
|
|
+ String conflictNote;
|
|
|
|
|
+ List<Map<String, Object>> couponProductItems;
|
|
|
|
|
+ BigDecimal budgetUsedBeforeCoupon = BigDecimal.ZERO;
|
|
|
|
|
+ BigDecimal budgetUsedTotal = BigDecimal.ZERO;
|
|
|
|
|
+ BigDecimal finalAmount = BigDecimal.ZERO;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 按路径应用预算与指定券:
|
|
|
|
|
+ * 促销(三选一)→ 新客立减 → 券,层层封顶于 goodsBudget=max(0,B−F);
|
|
|
|
|
+ * 半价胜出豁免;互斥券清促销/新客但不释放免运预留。
|
|
|
|
|
+ */
|
|
|
|
|
+ private PathEvaluation evaluatePath(String path, Map<String, Object> pathResult,
|
|
|
|
|
+ BigDecimal originalAmount, Long storeId,
|
|
|
|
|
+ List<PromotionActivity> activeActivities,
|
|
|
|
|
+ Map<Long, List<PromotionActivityRule>> rulesByActivity,
|
|
|
|
|
+ Map<Long, List<PromotionActivityRule>> rulesByProduct,
|
|
|
|
|
+ Map<Long, PromotionActivity> activityMap,
|
|
|
|
|
+ List<Map<String, Object>> itemsWithPrice,
|
|
|
|
|
+ Map<Long, BigDecimal> priceMap, Map<Long, String> nameMap,
|
|
|
|
|
+ Long userId, BigDecimal goodsBudget,
|
|
|
|
|
+ SelectedCoupon selectedCoupon)
|
|
|
|
|
+ {
|
|
|
|
|
+ PathEvaluation eval = new PathEvaluation();
|
|
|
|
|
+ eval.path = path;
|
|
|
|
|
+
|
|
|
|
|
+ BigDecimal subtotal = (BigDecimal) pathResult.get("subtotal");
|
|
|
|
|
+ BigDecimal promotionReduce = originalAmount.subtract(subtotal);
|
|
|
|
|
+ BigDecimal afterPromotion = subtotal;
|
|
|
|
|
+ boolean halfPriceExempt = "A".equals(path)
|
|
|
|
|
+ && Integer.valueOf(3).equals(pathResult.get("appliedType"));
|
|
|
|
|
+
|
|
|
|
|
+ // 促销层:非半价豁免时受预算封顶
|
|
|
|
|
+ BigDecimal promotionUsed = BigDecimal.ZERO;
|
|
|
|
|
+ if (goodsBudget != null && !halfPriceExempt && promotionReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ BigDecimal capped = promotionReduce.min(goodsBudget);
|
|
|
|
|
+ eval.promotionCapped = capped.compareTo(promotionReduce) < 0;
|
|
|
|
|
+ promotionUsed = capped;
|
|
|
|
|
+ promotionReduce = capped;
|
|
|
|
|
+ afterPromotion = originalAmount.subtract(promotionReduce);
|
|
|
|
|
+ }
|
|
|
|
|
+ eval.promotionReduce = promotionReduce;
|
|
|
|
|
+ eval.afterPromotion = afterPromotion;
|
|
|
|
|
+ eval.budgetUsedBeforeCoupon = eval.budgetUsedBeforeCoupon.add(promotionUsed);
|
|
|
|
|
+
|
|
|
|
|
+ // 新客立减层(该门店无已支付订单则为新客;实减 = min(设置额, 剩余预算))
|
|
|
|
|
+ BigDecimal newCustomerReduce = BigDecimal.ZERO;
|
|
|
|
|
+ if (userId != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ LambdaQueryWrapper<PosOrder> orderWrapper = new LambdaQueryWrapper<>();
|
|
|
|
|
+ orderWrapper.eq(PosOrder::getUserId, userId)
|
|
|
|
|
+ .eq(PosOrder::getMdId, storeId)
|
|
|
|
|
+ .eq(PosOrder::getPayStatus, 1L);
|
|
|
|
|
+ Long paidCount = posOrderMapper.selectCount(orderWrapper);
|
|
|
|
|
+ if (paidCount == 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ for (PromotionActivity act : activeActivities)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (act.getType() != null && act.getType() == 4)
|
|
|
|
|
+ {
|
|
|
|
|
+ List<PromotionActivityRule> ncRules = rulesByActivity.get(act.getId());
|
|
|
|
|
+ if (ncRules != null && !ncRules.isEmpty())
|
|
|
|
|
+ {
|
|
|
|
|
+ BigDecimal reduce = ncRules.get(0).getReduceAmount();
|
|
|
|
|
+ if (reduce != null && reduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ newCustomerReduce = reduce;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (goodsBudget != null && newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ BigDecimal remaining = goodsBudget.subtract(eval.budgetUsedBeforeCoupon).max(BigDecimal.ZERO);
|
|
|
|
|
+ BigDecimal capped = newCustomerReduce.min(remaining);
|
|
|
|
|
+ eval.newCustomerCapped = capped.compareTo(newCustomerReduce) < 0;
|
|
|
|
|
+ eval.budgetUsedBeforeCoupon = eval.budgetUsedBeforeCoupon.add(capped);
|
|
|
|
|
+ newCustomerReduce = capped;
|
|
|
|
|
+ }
|
|
|
|
|
+ else if (newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ // 开关关闭:不封顶,但保持占用计数与旧逻辑口径一致(不进入剩余预算计算)
|
|
|
|
|
+ eval.budgetUsedBeforeCoupon = eval.budgetUsedBeforeCoupon.add(newCustomerReduce);
|
|
|
|
|
+ }
|
|
|
|
|
+ eval.newCustomerReduce = newCustomerReduce;
|
|
|
|
|
+
|
|
|
|
|
+ // 券层
|
|
|
|
|
+ if (selectedCoupon != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ PromotionCouponRule couponRule = selectedCoupon.rule;
|
|
|
|
|
+ PromotionCouponBatch batch = selectedCoupon.batch;
|
|
|
|
|
+ eval.couponName = batch.getName();
|
|
|
|
|
+ int isMutex = couponRule.getIsMutex() != null ? couponRule.getIsMutex() : 0;
|
|
|
|
|
+
|
|
|
|
|
+ if (isMutex == 1 && promotionReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ eval.couponConflict = true;
|
|
|
|
|
+ eval.conflictNote = "互斥券不可与满减/折扣叠加,选择此券将取消促销优惠";
|
|
|
|
|
+ CouponCalcResult mutexResult = calcCouponReduce(couponRule, batch, itemsWithPrice, originalAmount, priceMap, nameMap);
|
|
|
|
|
+ eval.couponReduce = mutexResult.reduce;
|
|
|
|
|
+ eval.couponProductItems = mutexResult.items;
|
|
|
|
|
+ // 互斥券路径:促销/新客清零,券自身以 goodsBudget 封顶(免运预留 F 不释放,spec FR-030)
|
|
|
|
|
+ if (goodsBudget != null && eval.couponReduce.compareTo(goodsBudget) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ eval.couponReduce = goodsBudget;
|
|
|
|
|
+ eval.couponCapped = true;
|
|
|
|
|
+ applyCappedCouponReduce(eval.couponProductItems, eval.couponReduce);
|
|
|
|
|
+ }
|
|
|
|
|
+ eval.budgetUsedBeforeCoupon = BigDecimal.ZERO;
|
|
|
|
|
+ promotionReduce = BigDecimal.ZERO;
|
|
|
|
|
+ afterPromotion = originalAmount;
|
|
|
|
|
+ newCustomerReduce = BigDecimal.ZERO;
|
|
|
|
|
+ eval.promotionCapped = false;
|
|
|
|
|
+ eval.newCustomerCapped = false;
|
|
|
|
|
+ eval.promotionReduce = BigDecimal.ZERO;
|
|
|
|
|
+ eval.afterPromotion = afterPromotion;
|
|
|
|
|
+ eval.newCustomerReduce = BigDecimal.ZERO;
|
|
|
|
|
+ }
|
|
|
|
|
+ else
|
|
|
|
|
+ {
|
|
|
|
|
+ BigDecimal baseForCoupon = afterPromotion.subtract(newCustomerReduce);
|
|
|
|
|
+ baseForCoupon = baseForCoupon.max(BigDecimal.ZERO);
|
|
|
|
|
+
|
|
|
|
|
+ if (couponRule.getThreshold() == null
|
|
|
|
|
+ || baseForCoupon.compareTo(couponRule.getThreshold()) >= 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (batch.getCouponType() != null && batch.getCouponType() == 2
|
|
|
|
|
+ && couponRule.getProductId() != null && "A".equals(path))
|
|
|
|
|
+ {
|
|
|
|
|
+ List<PromotionActivityRule> productRules = rulesByProduct.get(couponRule.getProductId());
|
|
|
|
|
+ if (productRules != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ boolean inDiscount = productRules.stream().anyMatch(r ->
|
|
|
|
|
+ {
|
|
|
|
|
+ PromotionActivity act = activityMap.get(r.getActivityId());
|
|
|
|
|
+ return act != null && (act.getType() == 2 || act.getType() == 3);
|
|
|
|
|
+ });
|
|
|
|
|
+ if (inDiscount)
|
|
|
|
|
+ {
|
|
|
|
|
+ eval.couponConflict = true;
|
|
|
|
|
+ eval.conflictNote = "商品券对应的商品在折扣活动中,不可与Path A叠加";
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (eval.couponConflict == null)
|
|
|
|
|
+ {
|
|
|
|
|
+ CouponCalcResult normalResult = calcCouponReduce(couponRule, batch, itemsWithPrice,
|
|
|
|
|
+ baseForCoupon, priceMap, nameMap);
|
|
|
|
|
+ eval.couponReduce = normalResult.reduce;
|
|
|
|
|
+ eval.couponProductItems = normalResult.items;
|
|
|
|
|
+ // 普通券路径:实减 = min(可抵金额, 券前占用后的剩余预算),部分抵扣
|
|
|
|
|
+ if (goodsBudget != null && eval.couponReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ BigDecimal remaining = goodsBudget.subtract(eval.budgetUsedBeforeCoupon)
|
|
|
|
|
+ .max(BigDecimal.ZERO);
|
|
|
|
|
+ if (eval.couponReduce.compareTo(remaining) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ eval.couponReduce = remaining;
|
|
|
|
|
+ eval.couponCapped = true;
|
|
|
|
|
+ applyCappedCouponReduce(eval.couponProductItems, eval.couponReduce);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 总占用(仅预算开启时有意义):券前占用 + 券实减;半价豁免部分不占预算不计入
|
|
|
|
|
+ eval.budgetUsedTotal = eval.budgetUsedBeforeCoupon
|
|
|
|
|
+ .add(eval.couponReduce.max(BigDecimal.ZERO))
|
|
|
|
|
+ .max(BigDecimal.ZERO);
|
|
|
|
|
+
|
|
|
|
|
+ // G = O − 促销 − 新客 − 券,最低兜底与取整口径与旧逻辑一致
|
|
|
|
|
+ eval.finalAmount = originalAmount
|
|
|
|
|
+ .subtract(eval.promotionReduce)
|
|
|
|
|
+ .subtract(eval.newCustomerReduce)
|
|
|
|
|
+ .subtract(eval.couponReduce)
|
|
|
|
|
+ .max(MIN_AMOUNT)
|
|
|
|
|
+ .setScale(0, RoundingMode.HALF_UP);
|
|
|
|
|
+ return eval;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 追加商品侧优惠明细(促销/新客/券;type=5 满额免运单独快照,不在此汇总) */
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ private void appendGoodsDetails(List<PromotionDetail> details, PathEvaluation eval, String optimalPath,
|
|
|
|
|
+ List<PromotionActivity> activeActivities,
|
|
|
|
|
+ Map<String, Object> pathAResult, Map<String, Object> pathBResult,
|
|
|
|
|
+ SelectedCoupon selectedCoupon)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (eval.promotionReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ PromotionDetail detail = new PromotionDetail();
|
|
|
|
|
+ detail.setType("promotion");
|
|
|
|
|
+ if ("A".equals(optimalPath))
|
|
|
|
|
+ {
|
|
|
|
|
+ detail.setSubType((Integer) pathAResult.get("appliedType"));
|
|
|
|
|
+ detail.setName((String) pathAResult.get("label"));
|
|
|
|
|
+ Integer appliedType = (Integer) pathAResult.get("appliedType");
|
|
|
|
|
+ for (PromotionActivity act : activeActivities)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (act.getType() != null && act.getType().equals(appliedType))
|
|
|
|
|
+ {
|
|
|
|
|
+ detail.setRefId(act.getId());
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ else
|
|
|
|
|
+ {
|
|
|
|
|
+ detail.setSubType(1);
|
|
|
|
|
+ detail.setName((String) pathBResult.get("label"));
|
|
|
|
|
+ Map<String, Object> matchedRule = (Map<String, Object>) pathBResult.get("matchedRule");
|
|
|
|
|
+ if (matchedRule != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ detail.setRefId(toLong(matchedRule.get("activityId")));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ detail.setReduce(eval.promotionReduce);
|
|
|
|
|
+ if (eval.promotionCapped)
|
|
|
|
|
+ {
|
|
|
|
|
+ detail.setLimitCapped(true);
|
|
|
|
|
+ }
|
|
|
|
|
+ details.add(detail);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (eval.newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ PromotionDetail detail = new PromotionDetail();
|
|
|
|
|
+ detail.setType("promotion");
|
|
|
|
|
+ detail.setSubType(4);
|
|
|
|
|
+ detail.setName("新客立减");
|
|
|
|
|
+ detail.setReduce(eval.newCustomerReduce);
|
|
|
|
|
+ if (eval.newCustomerCapped)
|
|
|
|
|
+ {
|
|
|
|
|
+ detail.setLimitCapped(true);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (PromotionActivity act : activeActivities)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (act.getType() != null && act.getType() == 4)
|
|
|
|
|
+ {
|
|
|
|
|
+ detail.setRefId(act.getId());
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ details.add(detail);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (eval.couponReduce.compareTo(BigDecimal.ZERO) > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ PromotionDetail detail = new PromotionDetail();
|
|
|
|
|
+ detail.setType("coupon");
|
|
|
|
|
+ detail.setSubType(0);
|
|
|
|
|
+ detail.setName(eval.couponName);
|
|
|
|
|
+ detail.setReduce(eval.couponReduce);
|
|
|
|
|
+ if (eval.couponCapped)
|
|
|
|
|
+ {
|
|
|
|
|
+ detail.setLimitCapped(true);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (selectedCoupon != null && selectedCoupon.batch != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ detail.setRefId(selectedCoupon.batch.getId());
|
|
|
|
|
+ }
|
|
|
|
|
+ details.add(detail);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// ==================== DTO转换方法 ====================
|
|
// ==================== DTO转换方法 ====================
|
|
|
|
|
|
|
|
private PathResult toPathResult(Map<String, Object> pathMap)
|
|
private PathResult toPathResult(Map<String, Object> pathMap)
|
|
@@ -926,7 +1497,7 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 构建用户可用优惠券列表
|
|
|
|
|
|
|
+ * 构建用户可用优惠券列表(旧契约入口:无满额免运上下文)
|
|
|
*
|
|
*
|
|
|
* budgetLimit 为 null(限制开关关闭)时不算预算,行为与现状完全一致;
|
|
* budgetLimit 为 null(限制开关关闭)时不算预算,行为与现状完全一致;
|
|
|
* budgetUsedBeforeCoupon 为促销+新客已占预算(候选券与当前选中券互为替代,故只看券前占用)
|
|
* budgetUsedBeforeCoupon 为促销+新客已占预算(候选券与当前选中券互为替代,故只看券前占用)
|
|
@@ -943,6 +1514,29 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
String optimalPath,
|
|
String optimalPath,
|
|
|
BigDecimal budgetLimit,
|
|
BigDecimal budgetLimit,
|
|
|
BigDecimal budgetUsedBeforeCoupon)
|
|
BigDecimal budgetUsedBeforeCoupon)
|
|
|
|
|
+ {
|
|
|
|
|
+ return buildAvailableCoupons(storeId, userId, originalAmount, afterPromotion, promotionReduce,
|
|
|
|
|
+ itemsWithPrice, rulesByProduct, activityMap, priceMap, nameMap, optimalPath,
|
|
|
|
|
+ budgetLimit, budgetUsedBeforeCoupon, BigDecimal.ZERO);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 构建用户可用优惠券列表(完整配送报价:budgetBase 为免运预留后的受限预算 max(0,B−F);
|
|
|
|
|
+ * freeDeliveryReduce>0 即本单已享满额免运,免配送费券不可用也不核销,spec FR-036)
|
|
|
|
|
+ */
|
|
|
|
|
+ private List<Map<String, Object>> buildAvailableCoupons(Long storeId, Long userId,
|
|
|
|
|
+ BigDecimal originalAmount,
|
|
|
|
|
+ BigDecimal afterPromotion,
|
|
|
|
|
+ BigDecimal promotionReduce,
|
|
|
|
|
+ List<Map<String, Object>> itemsWithPrice,
|
|
|
|
|
+ Map<Long, List<PromotionActivityRule>> rulesByProduct,
|
|
|
|
|
+ Map<Long, PromotionActivity> activityMap,
|
|
|
|
|
+ Map<Long, BigDecimal> priceMap,
|
|
|
|
|
+ Map<Long, String> nameMap,
|
|
|
|
|
+ String optimalPath,
|
|
|
|
|
+ BigDecimal budgetBase,
|
|
|
|
|
+ BigDecimal budgetUsedBeforeCoupon,
|
|
|
|
|
+ BigDecimal freeDeliveryReduce)
|
|
|
{
|
|
{
|
|
|
List<Map<String, Object>> available = new ArrayList<>();
|
|
List<Map<String, Object>> available = new ArrayList<>();
|
|
|
if (userId == null)
|
|
if (userId == null)
|
|
@@ -980,6 +1574,16 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
couponInfo.put("discountRate", rule.getDiscountRate());
|
|
couponInfo.put("discountRate", rule.getDiscountRate());
|
|
|
couponInfo.put("expireTime", uc.getExpireTime());
|
|
couponInfo.put("expireTime", uc.getExpireTime());
|
|
|
|
|
|
|
|
|
|
+ // 命中满额免运时免配送费券不可用、不核销(spec FR-036),直接短路后续判断
|
|
|
|
|
+ if (freeDeliveryReduce != null && freeDeliveryReduce.compareTo(BigDecimal.ZERO) > 0
|
|
|
|
|
+ && batch.getCouponType() != null && batch.getCouponType() == 3)
|
|
|
|
|
+ {
|
|
|
|
|
+ couponInfo.put("usable", false);
|
|
|
|
|
+ couponInfo.put("unusableReason", MessageUtils.message("promotion.freeDelivery.alreadyApplied"));
|
|
|
|
|
+ available.add(couponInfo);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
boolean usable = true;
|
|
boolean usable = true;
|
|
|
int isMutex = rule.getIsMutex() != null ? rule.getIsMutex() : 0;
|
|
int isMutex = rule.getIsMutex() != null ? rule.getIsMutex() : 0;
|
|
|
|
|
|
|
@@ -1036,14 +1640,15 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 预算封顶(spec FR-026):候选实抵 = min(原可抵金额, 剩余预算);互斥券选中会清掉促销,用全额预算
|
|
// 预算封顶(spec FR-026):候选实抵 = min(原可抵金额, 剩余预算);互斥券选中会清掉促销,用全额预算
|
|
|
|
|
+ // 完整配送报价时 budgetBase 已是免运预留后的受限预算 max(0,B−F),互斥券同样不释放免运预留
|
|
|
// 放在商品券预览之后,封顶后的实抵值覆盖未封顶预览
|
|
// 放在商品券预览之后,封顶后的实抵值覆盖未封顶预览
|
|
|
- if (budgetLimit != null)
|
|
|
|
|
|
|
+ if (budgetBase != null)
|
|
|
{
|
|
{
|
|
|
// 互斥券只有在实际会取消促销时(促销>0,与 calculate 互斥分支进入条件一致)才享受全额预算;
|
|
// 互斥券只有在实际会取消促销时(促销>0,与 calculate 互斥分支进入条件一致)才享受全额预算;
|
|
|
// 无促销时互斥券走普通分支,按促销+新客占用后的剩余预算封顶
|
|
// 无促销时互斥券走普通分支,按促销+新客占用后的剩余预算封顶
|
|
|
boolean mutexActive = isMutex == 1 && promotionReduce.compareTo(BigDecimal.ZERO) > 0;
|
|
boolean mutexActive = isMutex == 1 && promotionReduce.compareTo(BigDecimal.ZERO) > 0;
|
|
|
- BigDecimal candidateBudget = mutexActive ? budgetLimit
|
|
|
|
|
- : budgetLimit.subtract(budgetUsedBeforeCoupon).max(BigDecimal.ZERO);
|
|
|
|
|
|
|
+ BigDecimal candidateBudget = mutexActive ? budgetBase
|
|
|
|
|
+ : budgetBase.subtract(budgetUsedBeforeCoupon).max(BigDecimal.ZERO);
|
|
|
BigDecimal baseForCalc = mutexActive ? originalAmount : afterPromotion;
|
|
BigDecimal baseForCalc = mutexActive ? originalAmount : afterPromotion;
|
|
|
CouponCalcResult budgetCalc = calcCouponReduce(rule, batch, itemsWithPrice,
|
|
CouponCalcResult budgetCalc = calcCouponReduce(rule, batch, itemsWithPrice,
|
|
|
baseForCalc, priceMap, nameMap);
|
|
baseForCalc, priceMap, nameMap);
|