You've already forked FrameTour-BE
fix(pricing): 修复优惠券计算逻辑,防止优惠金额溢出
- 重构优惠券折扣计算方法,确保固定金额优惠券不超过适用商品总价 - 修改百分比优惠券计算逻辑,基于适用商品总价而非购物车总价 - 新增适用商品总价计算方法,支持按商品类型过滤 - 添加防止优惠金额超出适用商品总价的保护逻辑 - 完善无商品类型限制时的全商品适用逻辑 - 增加多种边界情况和多SKU场景的单元测试 - 修复百分比优惠券最大折扣限制的计算顺序问题
This commit is contained in:
@@ -70,17 +70,27 @@ public class CouponServiceImpl implements ICouponService {
|
||||
if (!isCouponApplicable(coupon, products, totalAmount)) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
|
||||
// 计算适用商品的总价
|
||||
BigDecimal applicableProductsTotal = calculateApplicableProductsTotal(coupon, products);
|
||||
|
||||
BigDecimal discount;
|
||||
if (coupon.getCouponType() == CouponType.PERCENTAGE) {
|
||||
discount = totalAmount.multiply(coupon.getDiscountValue().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
|
||||
// 百分比优惠券基于适用商品总价计算,而非购物车总价
|
||||
discount = applicableProductsTotal.multiply(coupon.getDiscountValue().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
|
||||
if (coupon.getMaxDiscount() != null && discount.compareTo(coupon.getMaxDiscount()) > 0) {
|
||||
discount = coupon.getMaxDiscount();
|
||||
}
|
||||
} else {
|
||||
// 固定金额优惠券
|
||||
discount = coupon.getDiscountValue();
|
||||
}
|
||||
|
||||
|
||||
// 限制优惠金额不超过适用商品总价,防止优惠溢出到其他SKU
|
||||
if (discount.compareTo(applicableProductsTotal) > 0) {
|
||||
discount = applicableProductsTotal;
|
||||
}
|
||||
|
||||
return discount.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
@@ -128,7 +138,37 @@ public class CouponServiceImpl implements ICouponService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 计算适用商品的总价
|
||||
* @param coupon 优惠券配置
|
||||
* @param products 商品列表
|
||||
* @return 适用商品的总价
|
||||
*/
|
||||
private BigDecimal calculateApplicableProductsTotal(PriceCouponConfig coupon, List<ProductItem> products) {
|
||||
// 如果优惠券没有商品类型限制,返回所有商品总价
|
||||
if (coupon.getApplicableProducts() == null || coupon.getApplicableProducts().isEmpty()) {
|
||||
return products.stream()
|
||||
.map(ProductItem::getSubtotal)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析适用商品类型列表
|
||||
List<String> applicableProductTypes = objectMapper.readValue(
|
||||
coupon.getApplicableProducts(), new TypeReference<List<String>>() {});
|
||||
|
||||
// 计算适用商品的总价
|
||||
return products.stream()
|
||||
.filter(product -> applicableProductTypes.contains(product.getProductType().getCode()))
|
||||
.map(ProductItem::getSubtotal)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
} catch (Exception e) {
|
||||
log.error("解析适用商品类型失败", e);
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CouponUseResult useCoupon(CouponUseRequest request) {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
package com.ycwl.basic.pricing.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ycwl.basic.pricing.dto.ProductItem;
|
||||
import com.ycwl.basic.pricing.entity.PriceCouponConfig;
|
||||
import com.ycwl.basic.pricing.enums.CouponType;
|
||||
import com.ycwl.basic.pricing.enums.ProductType;
|
||||
import com.ycwl.basic.pricing.service.impl.CouponServiceImpl;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 优惠券溢出问题测试
|
||||
* 测试多SKU场景下优惠券优惠金额不会溢出到不适用的商品
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("优惠券溢出问题测试")
|
||||
class CouponOverflowTest {
|
||||
|
||||
@InjectMocks
|
||||
private CouponServiceImpl couponService;
|
||||
|
||||
@Mock
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 使用真实的ObjectMapper,避免mock导致的问题
|
||||
couponService = new CouponServiceImpl(null, null, new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("固定金额优惠券 - 多SKU场景优惠金额超过适用商品总价应被截断")
|
||||
void testFixedAmountCoupon_MultiSKU_DiscountExceedsApplicableTotal() {
|
||||
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(3元)
|
||||
List<ProductItem> products = Arrays.asList(
|
||||
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
|
||||
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("3.00"))
|
||||
);
|
||||
BigDecimal totalAmount = new BigDecimal("13.00");
|
||||
|
||||
// 优惠券配置: 满0减5元,仅适用于PHOTO_PRINT_MU
|
||||
PriceCouponConfig coupon = createCoupon(
|
||||
CouponType.FIXED_AMOUNT,
|
||||
new BigDecimal("5.00"),
|
||||
BigDecimal.ZERO,
|
||||
"[\"PHOTO_PRINT_MU\"]"
|
||||
);
|
||||
|
||||
// 执行计算
|
||||
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||
|
||||
// 验证: 优惠金额应为3元(适用商品总价),而非5元
|
||||
assertTrue(discount.compareTo(new BigDecimal("3.00")) == 0, "优惠金额应被截断为适用商品总价");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("固定金额优惠券 - 优惠金额小于适用商品总价应正常优惠")
|
||||
void testFixedAmountCoupon_MultiSKU_DiscountWithinApplicableTotal() {
|
||||
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(8元)
|
||||
List<ProductItem> products = Arrays.asList(
|
||||
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
|
||||
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("8.00"))
|
||||
);
|
||||
BigDecimal totalAmount = new BigDecimal("18.00");
|
||||
|
||||
// 优惠券配置: 满0减5元,仅适用于PHOTO_PRINT_MU
|
||||
PriceCouponConfig coupon = createCoupon(
|
||||
CouponType.FIXED_AMOUNT,
|
||||
new BigDecimal("5.00"),
|
||||
BigDecimal.ZERO,
|
||||
"[\"PHOTO_PRINT_MU\"]"
|
||||
);
|
||||
|
||||
// 执行计算
|
||||
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||
|
||||
// 验证: 优惠金额应为5元(配置值)
|
||||
assertTrue(discount.compareTo(new BigDecimal("5.00")) == 0, "优惠金额未超过适用商品总价,应为配置值");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("百分比优惠券 - 应基于适用商品总价计算而非购物车总价")
|
||||
void testPercentageCoupon_MultiSKU_CalculateOnApplicableTotal() {
|
||||
// 准备商品列表: PHOTO_PRINT(100元) + PHOTO_PRINT_MU(50元)
|
||||
List<ProductItem> products = Arrays.asList(
|
||||
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("100.00")),
|
||||
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("50.00"))
|
||||
);
|
||||
BigDecimal totalAmount = new BigDecimal("150.00");
|
||||
|
||||
// 优惠券配置: 9折,仅适用于PHOTO_PRINT_MU
|
||||
PriceCouponConfig coupon = createCoupon(
|
||||
CouponType.PERCENTAGE,
|
||||
new BigDecimal("10.00"), // 10% off = 9折
|
||||
BigDecimal.ZERO,
|
||||
"[\"PHOTO_PRINT_MU\"]"
|
||||
);
|
||||
|
||||
// 执行计算
|
||||
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||
|
||||
// 验证: 优惠金额应为5元(50 * 10% = 5),而非15元(150 * 10% = 15)
|
||||
assertTrue(discount.compareTo(new BigDecimal("5.00")) == 0, "百分比优惠应基于适用商品总价计算");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("百分比优惠券 - 计算结果超过适用商品总价应被截断")
|
||||
void testPercentageCoupon_MultiSKU_DiscountExceedsApplicableTotal() {
|
||||
// 准备商品列表: PHOTO_PRINT(100元) + PHOTO_PRINT_MU(3元)
|
||||
List<ProductItem> products = Arrays.asList(
|
||||
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("100.00")),
|
||||
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("3.00"))
|
||||
);
|
||||
BigDecimal totalAmount = new BigDecimal("103.00");
|
||||
|
||||
// 优惠券配置: 5折,仅适用于PHOTO_PRINT_MU
|
||||
PriceCouponConfig coupon = createCoupon(
|
||||
CouponType.PERCENTAGE,
|
||||
new BigDecimal("50.00"), // 50% off = 5折
|
||||
BigDecimal.ZERO,
|
||||
"[\"PHOTO_PRINT_MU\"]"
|
||||
);
|
||||
|
||||
// 执行计算
|
||||
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||
|
||||
// 验证: 理论优惠1.5元(3 * 50% = 1.5),实际不应超过3元
|
||||
assertTrue(discount.compareTo(new BigDecimal("3.00")) <= 0, "优惠金额不应超过适用商品总价");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无商品类型限制的优惠券 - 应用于所有商品")
|
||||
void testCoupon_NoProductTypeRestriction() {
|
||||
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(8元)
|
||||
List<ProductItem> products = Arrays.asList(
|
||||
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
|
||||
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("8.00"))
|
||||
);
|
||||
BigDecimal totalAmount = new BigDecimal("18.00");
|
||||
|
||||
// 优惠券配置: 满0减5元,无商品类型限制
|
||||
PriceCouponConfig coupon = createCoupon(
|
||||
CouponType.FIXED_AMOUNT,
|
||||
new BigDecimal("5.00"),
|
||||
BigDecimal.ZERO,
|
||||
null // 无商品类型限制
|
||||
);
|
||||
|
||||
// 执行计算
|
||||
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||
|
||||
// 验证: 优惠金额应为5元
|
||||
assertTrue(discount.compareTo(new BigDecimal("5.00")) == 0, "无商品类型限制时应应用于所有商品");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无商品类型限制的优惠券 - 优惠金额超过总价应被截断")
|
||||
void testCoupon_NoProductTypeRestriction_ExceedsTotalPrice() {
|
||||
// 准备商品列表: PHOTO_PRINT(2元) + PHOTO_PRINT_MU(1元)
|
||||
List<ProductItem> products = Arrays.asList(
|
||||
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("2.00")),
|
||||
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("1.00"))
|
||||
);
|
||||
BigDecimal totalAmount = new BigDecimal("3.00");
|
||||
|
||||
// 优惠券配置: 满0减10元,无商品类型限制
|
||||
PriceCouponConfig coupon = createCoupon(
|
||||
CouponType.FIXED_AMOUNT,
|
||||
new BigDecimal("10.00"),
|
||||
BigDecimal.ZERO,
|
||||
null
|
||||
);
|
||||
|
||||
// 执行计算
|
||||
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||
|
||||
// 验证: 优惠金额应被截断为3元
|
||||
assertTrue(discount.compareTo(new BigDecimal("3.00")) == 0, "优惠金额应被截断为商品总价");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多商品类型限制的优惠券 - 计算多个适用商品的总价")
|
||||
void testCoupon_MultipleProductTypeRestriction() {
|
||||
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(8元) + VLOG_VIDEO(20元)
|
||||
List<ProductItem> products = Arrays.asList(
|
||||
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
|
||||
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("8.00")),
|
||||
createProductItem(ProductType.VLOG_VIDEO, new BigDecimal("20.00"))
|
||||
);
|
||||
BigDecimal totalAmount = new BigDecimal("38.00");
|
||||
|
||||
// 优惠券配置: 满0减10元,适用于PHOTO_PRINT和PHOTO_PRINT_MU
|
||||
PriceCouponConfig coupon = createCoupon(
|
||||
CouponType.FIXED_AMOUNT,
|
||||
new BigDecimal("10.00"),
|
||||
BigDecimal.ZERO,
|
||||
"[\"PHOTO_PRINT\",\"PHOTO_PRINT_MU\"]"
|
||||
);
|
||||
|
||||
// 执行计算
|
||||
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||
|
||||
// 验证: 优惠金额应为10元(适用商品总价18元,优惠10元未超限)
|
||||
assertTrue(discount.compareTo(new BigDecimal("10.00")) == 0, "多商品类型限制时应正确计算适用商品总价");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("边界情况 - 适用商品总价为0")
|
||||
void testCoupon_ApplicableTotalIsZero() {
|
||||
// 准备商品列表: 仅有PHOTO_PRINT(10元)
|
||||
List<ProductItem> products = Arrays.asList(
|
||||
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00"))
|
||||
);
|
||||
BigDecimal totalAmount = new BigDecimal("10.00");
|
||||
|
||||
// 优惠券配置: 满0减5元,仅适用于PHOTO_PRINT_MU(购物车中无此商品)
|
||||
PriceCouponConfig coupon = createCoupon(
|
||||
CouponType.FIXED_AMOUNT,
|
||||
new BigDecimal("5.00"),
|
||||
BigDecimal.ZERO,
|
||||
"[\"PHOTO_PRINT_MU\"]"
|
||||
);
|
||||
|
||||
// 执行计算
|
||||
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||
|
||||
// 验证: 优惠金额应为0(适用商品总价为0,自动截断)
|
||||
assertTrue(new BigDecimal("0.00").compareTo(discount) == 0, "适用商品总价为0时优惠金额应为0");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("边界情况 - 优惠金额刚好等于适用商品总价")
|
||||
void testCoupon_DiscountEqualsApplicableTotal() {
|
||||
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(5元)
|
||||
List<ProductItem> products = Arrays.asList(
|
||||
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
|
||||
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("5.00"))
|
||||
);
|
||||
BigDecimal totalAmount = new BigDecimal("15.00");
|
||||
|
||||
// 优惠券配置: 满0减5元,仅适用于PHOTO_PRINT_MU
|
||||
PriceCouponConfig coupon = createCoupon(
|
||||
CouponType.FIXED_AMOUNT,
|
||||
new BigDecimal("5.00"),
|
||||
BigDecimal.ZERO,
|
||||
"[\"PHOTO_PRINT_MU\"]"
|
||||
);
|
||||
|
||||
// 执行计算
|
||||
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||
|
||||
// 验证: 优惠金额应为5元
|
||||
assertTrue(discount.compareTo(new BigDecimal("5.00")) == 0, "优惠金额刚好等于适用商品总价时应正常优惠");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("百分比优惠券 - 带最大优惠金额限制且超过适用商品总价")
|
||||
void testPercentageCoupon_WithMaxDiscount_ExceedsApplicableTotal() {
|
||||
// 准备商品列表: PHOTO_PRINT(100元) + PHOTO_PRINT_MU(3元)
|
||||
List<ProductItem> products = Arrays.asList(
|
||||
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("100.00")),
|
||||
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("3.00"))
|
||||
);
|
||||
BigDecimal totalAmount = new BigDecimal("103.00");
|
||||
|
||||
// 优惠券配置: 5折,最大优惠10元,仅适用于PHOTO_PRINT_MU
|
||||
PriceCouponConfig coupon = createCoupon(
|
||||
CouponType.PERCENTAGE,
|
||||
new BigDecimal("50.00"),
|
||||
BigDecimal.ZERO,
|
||||
"[\"PHOTO_PRINT_MU\"]"
|
||||
);
|
||||
coupon.setMaxDiscount(new BigDecimal("10.00"));
|
||||
|
||||
// 执行计算
|
||||
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||
|
||||
// 验证: 理论优惠1.5元(3 * 50%),不受最大优惠10元限制,但仍不超过适用商品总价3元
|
||||
assertTrue(discount.compareTo(new BigDecimal("3.00")) <= 0, "优惠金额不应超过适用商品总价");
|
||||
assertTrue(discount.compareTo(new BigDecimal("1.50")) >= 0, "优惠金额应为计算值1.5元");
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
||||
/**
|
||||
* 创建商品项
|
||||
*/
|
||||
private ProductItem createProductItem(ProductType productType, BigDecimal subtotal) {
|
||||
ProductItem item = new ProductItem();
|
||||
item.setProductType(productType);
|
||||
item.setSubtotal(subtotal);
|
||||
item.setQuantity(1);
|
||||
item.setUnitPrice(subtotal);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建优惠券配置
|
||||
*/
|
||||
private PriceCouponConfig createCoupon(CouponType type, BigDecimal value,
|
||||
BigDecimal minAmount, String applicableProducts) {
|
||||
PriceCouponConfig coupon = new PriceCouponConfig();
|
||||
coupon.setId(1L);
|
||||
coupon.setCouponName("测试优惠券");
|
||||
coupon.setCouponType(type);
|
||||
coupon.setDiscountValue(value);
|
||||
coupon.setMinAmount(minAmount);
|
||||
coupon.setApplicableProducts(applicableProducts);
|
||||
coupon.setIsActive(true);
|
||||
return coupon;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user