重构订单下单逻辑,引入策略模式和命令模式

- 为OrderTriggerSource枚举添加详细注释说明
- 将IOrderLifecycleService接口的initiateOrder方法重构为placeOrder
- 新增OrderPlacementCommand、OrderPlacementResult、OrderAmountBreakdown等DTO
- 实现订单下单策略模式,支持不同类型订单的差异化处理
- 优化金额计算逻辑,完善优惠券折扣计算
- 改进余额扣减逻辑,增强异常处理
- 更新相关控制器使用新的下单接口
- 完善单元测试覆盖,确保代码质量
This commit is contained in:
irving
2025-10-30 21:12:37 -04:00
parent 67692ff79f
commit e29c5db276
16 changed files with 1609 additions and 80 deletions

View File

@@ -3,6 +3,8 @@ package com.starry.admin.modules.order.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
@@ -10,6 +12,7 @@ import static org.mockito.Mockito.*;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.common.exception.ServiceException;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper;
@@ -33,6 +36,8 @@ import com.starry.admin.modules.order.module.constant.OrderConstant.YesNoFlag;
import com.starry.admin.modules.order.module.dto.CommodityInfo;
import com.starry.admin.modules.order.module.dto.OrderCompletionContext;
import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.OrderRefundContext;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements;
@@ -41,7 +46,10 @@ import com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator;
import com.starry.admin.modules.shop.module.constant.CouponUseState;
import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
import com.starry.admin.modules.shop.service.IPlayCouponInfoService;
import com.starry.admin.modules.weichat.service.WxCustomMpService;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.service.IEarningsService;
@@ -49,7 +57,9 @@ import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -81,14 +91,65 @@ class OrderLifecycleServiceImplTest {
@Mock
private IPlayCouponDetailsService playCouponDetailsService;
@Mock
private IPlayCouponInfoService playCouponInfoService;
@Mock
private ClerkRevenueCalculator clerkRevenueCalculator;
@Mock
private PlayOrderLogInfoMapper orderLogInfoMapper;
private PlayOrderLogInfoMapper orderLogInfoMapper;
@BeforeEach
void initStrategies() {
lifecycleService.initPlacementStrategies();
}
@Test
void initiateOrder_specifiedOrder_persistsAndUpdatesCoupon() {
void placeOrder_throwsWhenCommandNull() {
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(null));
}
@Test
void placeOrder_throwsWhenContextNull() {
OrderPlacementCommand command = mock(OrderPlacementCommand.class);
when(command.getOrderContext()).thenReturn(null);
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
}
@Test
void placeOrder_throwsWhenPaymentInfoMissing() {
OrderCreationContext context = OrderCreationContext.builder()
.orderId("ctx-001")
.orderNo("NO-001")
.orderStatus(OrderStatus.PENDING)
.orderType(OrderType.NORMAL)
.placeType(PlaceType.OTHER)
.rewardType(RewardType.NOT_APPLICABLE)
.isFirstOrder(false)
.commodityInfo(CommodityInfo.builder()
.commodityId("commodity")
.commodityType(CommodityType.SERVICE)
.commodityPrice(BigDecimal.TEN)
.serviceDuration("60")
.commodityName("服务")
.commodityNumber("1")
.build())
.paymentInfo(null)
.purchaserBy("customer-1")
.build();
OrderPlacementCommand command = OrderPlacementCommand.builder()
.orderContext(context)
.deductBalance(false)
.build();
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
}
@Test
void placeOrder_specifiedOrder_persistsAndUpdatesCoupon() {
OrderCreationContext request = OrderCreationContext.builder()
.orderId("order-init-001")
.orderNo("NO20241001")
@@ -118,6 +179,18 @@ class OrderLifecycleServiceImplTest {
.remark("备注")
.build();
OrderPlacementCommand command = OrderPlacementCommand.builder()
.orderContext(request)
.deductBalance(false)
.pricingInput(OrderPlacementCommand.PricingInput.builder()
.unitPrice(BigDecimal.valueOf(199))
.quantity(1)
.couponIds(request.getPaymentInfo().getCouponIds())
.commodityId("commodity-01")
.placeType(PlaceType.SPECIFIED)
.build())
.build();
ClerkEstimatedRevenueVo revenueVo = new ClerkEstimatedRevenueVo();
revenueVo.setRevenueAmount(BigDecimal.valueOf(89.5));
revenueVo.setRevenueRatio(50);
@@ -129,7 +202,23 @@ class OrderLifecycleServiceImplTest {
doNothing().when(customUserInfoService).saveOrderInfo(any());
doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), anyString());
PlayOrderInfoEntity created = lifecycleService.initiateOrder(request);
PlayCouponDetailsEntity couponDetails = new PlayCouponDetailsEntity();
couponDetails.setCouponId("coupon-master");
when(playCouponDetailsService.getById("coupon-1")).thenReturn(couponDetails);
PlayCouponInfoEntity couponInfo = new PlayCouponInfoEntity();
couponInfo.setDiscountType("0");
couponInfo.setDiscountAmount(BigDecimal.valueOf(20));
when(playCouponInfoService.selectPlayCouponInfoById("coupon-master")).thenReturn(couponInfo);
when(playCouponInfoService.getCouponReasonForUnavailableUse(
eq(couponInfo),
eq(PlaceType.SPECIFIED.getCode()),
eq("commodity-01"),
eq(1),
any())).thenReturn("");
OrderPlacementResult result = lifecycleService.placeOrder(command);
PlayOrderInfoEntity created = result.getOrder();
verify(customUserInfoService).saveOrderInfo(created);
verify(orderInfoMapper).insert(created);
@@ -148,6 +237,740 @@ class OrderLifecycleServiceImplTest {
assertEquals(PayMethod.WECHAT.getCode(), created.getPayMethod());
}
@Test
void placeOrder_otherType_skipsPricingAndUsesExistingAmounts() {
PaymentInfo payment = payment(BigDecimal.valueOf(50), BigDecimal.valueOf(50), BigDecimal.ZERO, Collections.emptyList());
OrderCreationContext context = baseContext(
PlaceType.OTHER,
RewardType.NOT_APPLICABLE,
payment,
null,
null);
stubDefaultPersistence();
OrderPlacementResult result = lifecycleService.placeOrder(command(context, null, false, null, null));
assertMoneyEquals("50.00", result.getAmountBreakdown().getGrossAmount());
assertMoneyEquals("0.00", result.getAmountBreakdown().getDiscountAmount());
assertMoneyEquals("50.00", result.getAmountBreakdown().getNetAmount());
verify(playCouponDetailsService, never()).updateCouponUseStateByIds(anyList(), anyString());
}
@Test
void placeOrder_otherType_withCoupons_marksUsageButKeepsAmounts() {
List<String> coupons = Collections.singletonList("coupon-1");
PaymentInfo payment = payment(BigDecimal.valueOf(60), BigDecimal.valueOf(60), BigDecimal.ZERO, coupons);
OrderCreationContext context = baseContext(
PlaceType.OTHER,
RewardType.NOT_APPLICABLE,
payment,
null,
null);
stubDefaultPersistence();
lifecycleService.placeOrder(command(context, null, false, null, null));
verify(playCouponDetailsService).updateCouponUseStateByIds(eq(coupons), eq(CouponUseState.USED.getCode()));
}
@Test
void placeOrder_specifiedWithoutCoupons_calculatesTotallyFromPricing() {
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
null,
"clerk-001");
stubDefaultPersistence();
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(20), 2, Collections.emptyList(), "commodity-x", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("40.00", context.getPaymentInfo().getOrderMoney());
assertMoneyEquals("0.00", context.getPaymentInfo().getDiscountAmount());
assertMoneyEquals("40.00", context.getPaymentInfo().getFinalAmount());
assertMoneyEquals("40.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_specifiedWithFullReductionCouponReducesNetAmount() {
List<String> coupons = Collections.singletonList("coupon-1");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-001");
stubDefaultPersistence();
stubCoupon("coupon-1", "coupon-master", "0", BigDecimal.valueOf(15), "");
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(30), 1, coupons, "commodity-123", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("15.00", result.getAmountBreakdown().getDiscountAmount());
assertMoneyEquals("15.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_specifiedWithMultipleCouponsCapsNetAtZero() {
List<String> coupons = Arrays.asList("coupon-1", "coupon-2");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-001");
stubDefaultPersistence();
PlayCouponDetailsEntity firstDetail = new PlayCouponDetailsEntity();
firstDetail.setCouponId("master-1");
PlayCouponDetailsEntity secondDetail = new PlayCouponDetailsEntity();
secondDetail.setCouponId("master-2");
when(playCouponDetailsService.getById("coupon-1")).thenReturn(firstDetail);
when(playCouponDetailsService.getById("coupon-2")).thenReturn(secondDetail);
PlayCouponInfoEntity firstInfo = new PlayCouponInfoEntity();
firstInfo.setDiscountType("0");
firstInfo.setDiscountAmount(BigDecimal.valueOf(40));
PlayCouponInfoEntity secondInfo = new PlayCouponInfoEntity();
secondInfo.setDiscountType("0");
secondInfo.setDiscountAmount(BigDecimal.valueOf(30));
when(playCouponInfoService.selectPlayCouponInfoById("master-1")).thenReturn(firstInfo);
when(playCouponInfoService.selectPlayCouponInfoById("master-2")).thenReturn(secondInfo);
when(playCouponInfoService.getCouponReasonForUnavailableUse(any(), anyString(), anyString(), anyInt(), any()))
.thenReturn("");
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(60), 1, coupons, "commodity-max", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("0.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_specifiedWithPercentageFold_eightyPercentApplied() {
List<String> coupons = Collections.singletonList("coupon-1");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-001");
stubDefaultPersistence();
stubCoupon("coupon-1", "master-1", "1", BigDecimal.valueOf(8), "");
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(50), 1, coupons, "commodity-perc", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("40.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_specifiedWithPercentageFoldGreaterThanTenTreatsAsFree() {
List<String> coupons = Collections.singletonList("coupon-1");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-001");
stubDefaultPersistence();
stubCoupon("coupon-1", "master-1", "1", BigDecimal.valueOf(20), "");
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(40), 1, coupons, "commodity-perc", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("0.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_specifiedWithNonPositivePercentageIgnoresDiscount() {
List<String> coupons = Collections.singletonList("coupon-1");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-001");
stubDefaultPersistence();
stubCoupon("coupon-1", "master-1", "1", BigDecimal.ZERO, "");
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(25), 1, coupons, "commodity-perc", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("25.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_specifiedThrowsWhenCouponDetailMissing() {
List<String> coupons = Collections.singletonList("coupon-1");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
null);
stubDefaultPersistence();
when(playCouponDetailsService.getById("coupon-1")).thenReturn(null);
OrderPlacementCommand command = command(
context,
pricing(BigDecimal.valueOf(30), 1, coupons, "commodity-missing", PlaceType.SPECIFIED),
false,
null,
null);
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
}
@Test
void placeOrder_specifiedThrowsWhenCouponIneligible() {
List<String> coupons = Collections.singletonList("coupon-1");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
null);
stubDefaultPersistence();
stubCoupon("coupon-1", "master-1", "0", BigDecimal.valueOf(10), "订单类型不符合");
OrderPlacementCommand command = command(
context,
pricing(BigDecimal.valueOf(30), 1, coupons, "commodity-invalid", PlaceType.SPECIFIED),
false,
null,
null);
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
}
@Test
void placeOrder_specifiedSkipsUnsupportedCouponTypeButSucceeds() {
List<String> coupons = Collections.singletonList("coupon-unsupported");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-001");
stubDefaultPersistence();
PlayCouponDetailsEntity detail = new PlayCouponDetailsEntity();
detail.setCouponId("master-unsupported");
when(playCouponDetailsService.getById("coupon-unsupported")).thenReturn(detail);
PlayCouponInfoEntity info = new PlayCouponInfoEntity();
info.setDiscountType("X");
info.setDiscountAmount(BigDecimal.valueOf(999));
when(playCouponInfoService.selectPlayCouponInfoById("master-unsupported")).thenReturn(info);
when(playCouponInfoService.getCouponReasonForUnavailableUse(any(), anyString(), anyString(), anyInt(), any()))
.thenReturn("");
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(45), 1, coupons, "commodity-unsupported", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("45.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_randomWithValidRequirementsSucceeds() {
RandomOrderRequirements requirements = RandomOrderRequirements.builder()
.clerkGender(Gender.MALE)
.clerkLevelId("level-1")
.excludeHistory("0")
.build();
OrderCreationContext context = baseContext(
PlaceType.RANDOM,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
requirements,
null);
stubDefaultPersistence();
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(35), 2, Collections.emptyList(), "commodity-rand", PlaceType.RANDOM),
false,
null,
null));
assertMoneyEquals("70.00", result.getAmountBreakdown().getGrossAmount());
}
@Test
void placeOrder_randomWithoutRequirementsThrows() {
OrderCreationContext context = baseContext(
PlaceType.RANDOM,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
null,
null);
OrderPlacementCommand command = command(
context,
pricing(BigDecimal.valueOf(20), 1, Collections.emptyList(), "commodity-rand", PlaceType.RANDOM),
false,
null,
null);
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
}
@Test
void placeOrder_rewardGiftWithoutAcceptByDoesNotAutoComplete() {
OrderCreationContext context = baseContext(
PlaceType.REWARD,
RewardType.GIFT,
payment(BigDecimal.valueOf(30), BigDecimal.valueOf(30), BigDecimal.ZERO, Collections.emptyList()),
null,
null);
stubDefaultPersistence();
lifecycleService.placeOrder(command(context, null, false, null, null));
verify(orderInfoMapper, never()).selectById(anyString());
verify(wxCustomMpService, never()).sendOrderFinishMessageAsync(any());
verify(earningsService, never()).createFromOrder(any());
}
@Test
void placeOrder_rewardGiftWithAcceptByTriggersAutoComplete() {
PlayOrderInfoEntity inProgress = buildOrder("reward-order", OrderStatus.IN_PROGRESS.getCode());
inProgress.setAcceptBy("clerk-1");
PlayOrderInfoEntity completed = buildOrder("reward-order", OrderStatus.COMPLETED.getCode());
OrderCreationContext context = baseContext(
PlaceType.REWARD,
RewardType.GIFT,
payment(BigDecimal.valueOf(30), BigDecimal.valueOf(30), BigDecimal.ZERO, Collections.emptyList()),
null,
"clerk-1");
stubDefaultPersistence();
when(orderInfoMapper.selectById(anyString())).thenReturn(inProgress, completed);
when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
mockEarningsCounts(0L, 1L);
doNothing().when(customUserInfoService).handleOrderCompletion(any());
doNothing().when(earningsService).createFromOrder(any());
lifecycleService.placeOrder(command(context, null, false, null, null));
verify(orderInfoMapper, atLeastOnce()).selectById(anyString());
verify(customUserInfoService).handleOrderCompletion(any());
verify(earningsService).createFromOrder(completed);
verify(wxCustomMpService).sendOrderFinishMessageAsync(completed);
}
@Test
void placeOrder_rewardTipWithoutAcceptByDoesNotAutoComplete() {
OrderCreationContext context = baseContext(
PlaceType.REWARD,
RewardType.BALANCE,
payment(BigDecimal.valueOf(40), BigDecimal.valueOf(40), BigDecimal.ZERO, Collections.emptyList()),
null,
null);
stubDefaultPersistence();
lifecycleService.placeOrder(command(context, null, false, null, null));
verify(orderInfoMapper, never()).selectById(anyString());
verify(wxCustomMpService, never()).sendOrderFinishMessageAsync(any());
verify(earningsService, never()).createFromOrder(any());
}
@Test
void placeOrder_rewardTipWithAcceptByTriggersAutoComplete() {
PlayOrderInfoEntity inProgress = buildOrder("tip-reward", OrderStatus.IN_PROGRESS.getCode());
inProgress.setAcceptBy("clerk-3");
PlayOrderInfoEntity completed = buildOrder("tip-reward", OrderStatus.COMPLETED.getCode());
OrderCreationContext context = baseContext(
PlaceType.REWARD,
RewardType.BALANCE,
payment(BigDecimal.valueOf(40), BigDecimal.valueOf(40), BigDecimal.ZERO, Collections.emptyList()),
null,
"clerk-3");
stubDefaultPersistence();
when(orderInfoMapper.selectById(anyString())).thenReturn(inProgress, completed);
when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
mockEarningsCounts(0L, 1L);
doNothing().when(customUserInfoService).handleOrderCompletion(any());
doNothing().when(earningsService).createFromOrder(any());
lifecycleService.placeOrder(command(context, null, false, null, null));
verify(orderInfoMapper, atLeastOnce()).selectById(anyString());
verify(customUserInfoService).handleOrderCompletion(any());
verify(earningsService).createFromOrder(completed);
verify(wxCustomMpService).sendOrderFinishMessageAsync(completed);
}
@Test
void placeOrder_withBalanceDeductionAndSufficientFundsUpdatesBalance() {
List<String> coupons = Collections.emptyList();
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-balance");
PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(100));
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());
stubDefaultPersistence();
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(20), 2, coupons, "commodity-balance", PlaceType.SPECIFIED),
true,
null,
null));
assertMoneyEquals("40.00", result.getAmountBreakdown().getNetAmount());
verify(customUserInfoService).updateAccountBalanceById(
eq(context.getPurchaserBy()),
eq(customer.getAccountBalance()),
eq(customer.getAccountBalance().subtract(new BigDecimal("40.00"))),
eq(BalanceOperationType.CONSUME.getCode()),
eq("下单"),
eq(new BigDecimal("40.00")),
eq(BigDecimal.ZERO),
anyString());
}
@Test
void placeOrder_withBalanceDeductionUsesCustomAction() {
List<String> coupons = Collections.emptyList();
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
null);
PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(50));
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());
stubDefaultPersistence();
lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(10), 2, coupons, "commodity-act", PlaceType.SPECIFIED),
true,
"测试扣款",
null));
verify(customUserInfoService).updateAccountBalanceById(
eq(context.getPurchaserBy()),
any(),
any(),
eq(BalanceOperationType.CONSUME.getCode()),
eq("测试扣款"),
eq(new BigDecimal("20.00")),
eq(BigDecimal.ZERO),
anyString());
}
@Test
void placeOrder_withInsufficientBalanceThrows() {
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
null,
null);
PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(10));
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
stubDefaultPersistence();
OrderPlacementCommand command = command(
context,
pricing(BigDecimal.valueOf(20), 1, Collections.emptyList(), "commodity-insufficient", PlaceType.SPECIFIED),
true,
null,
null);
assertThrows(ServiceException.class, () -> lifecycleService.placeOrder(command));
verify(customUserInfoService, never()).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());
}
@Test
void placeOrder_withMissingCustomerThrows() {
OrderCreationContext context = baseContext(
PlaceType.OTHER,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
null,
null);
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(null);
stubDefaultPersistence();
OrderPlacementCommand command = command(
context,
pricing(BigDecimal.valueOf(10), 1, Collections.emptyList(), "commodity-missing", PlaceType.SPECIFIED),
true,
null,
null);
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
}
@Test
void placeOrder_noDeductionHandlesNullFinalAmount() {
PaymentInfo payment = payment(BigDecimal.valueOf(15), null, BigDecimal.ZERO, Collections.emptyList());
OrderCreationContext context = baseContext(
PlaceType.OTHER,
RewardType.NOT_APPLICABLE,
payment,
null,
null);
stubDefaultPersistence();
OrderPlacementResult result = lifecycleService.placeOrder(command(context, null, false, null, null));
assertMoneyEquals("0.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_deductFalseStillMarksCouponUsage() {
List<String> coupons = Collections.singletonList("coupon-flag");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
null);
stubDefaultPersistence();
stubCoupon("coupon-flag", "master-flag", "0", BigDecimal.valueOf(5), "");
lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(25), 1, coupons, "commodity-flag", PlaceType.SPECIFIED),
false,
null,
null));
verify(playCouponDetailsService).updateCouponUseStateByIds(eq(coupons), eq(CouponUseState.USED.getCode()));
}
@Test
void placeOrder_randomWithBalanceDeductionSucceeds() {
RandomOrderRequirements requirements = RandomOrderRequirements.builder()
.clerkGender(Gender.FEMALE)
.clerkLevelId("level-9")
.excludeHistory("1")
.build();
OrderCreationContext context = baseContext(
PlaceType.RANDOM,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
requirements,
null);
PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(300));
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());
stubDefaultPersistence();
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(60), 2, Collections.emptyList(), "commodity-random", PlaceType.RANDOM),
true,
"随机扣款",
null));
assertMoneyEquals("120.00", result.getAmountBreakdown().getNetAmount());
verify(customUserInfoService).updateAccountBalanceById(
eq(context.getPurchaserBy()),
any(),
any(),
eq(BalanceOperationType.CONSUME.getCode()),
eq("随机扣款"),
eq(new BigDecimal("120.00")),
eq(BigDecimal.ZERO),
anyString());
}
@Test
void placeOrder_randomWithDeductionThrowsWhenInsufficientBalance() {
RandomOrderRequirements requirements = RandomOrderRequirements.builder()
.clerkGender(Gender.UNKNOWN)
.clerkLevelId("level-2")
.excludeHistory("0")
.build();
OrderCreationContext context = baseContext(
PlaceType.RANDOM,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
requirements,
null);
PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(10));
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
stubDefaultPersistence();
OrderPlacementCommand command = command(
context,
pricing(BigDecimal.valueOf(30), 1, Collections.emptyList(), "commodity-rand", PlaceType.RANDOM),
true,
null,
null);
assertThrows(ServiceException.class, () -> lifecycleService.placeOrder(command));
}
@Test
void placeOrder_withMixedCouponTypesAppliesValidOnes() {
List<String> coupons = Arrays.asList("coupon-invalid", "coupon-valid");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-1");
stubDefaultPersistence();
PlayCouponDetailsEntity invalidDetail = new PlayCouponDetailsEntity();
invalidDetail.setCouponId("master-invalid");
PlayCouponDetailsEntity validDetail = new PlayCouponDetailsEntity();
validDetail.setCouponId("master-valid");
when(playCouponDetailsService.getById("coupon-invalid")).thenReturn(invalidDetail);
when(playCouponDetailsService.getById("coupon-valid")).thenReturn(validDetail);
PlayCouponInfoEntity invalidInfo = new PlayCouponInfoEntity();
invalidInfo.setDiscountType("X");
invalidInfo.setDiscountAmount(BigDecimal.valueOf(100));
PlayCouponInfoEntity validInfo = new PlayCouponInfoEntity();
validInfo.setDiscountType("0");
validInfo.setDiscountAmount(BigDecimal.valueOf(10));
when(playCouponInfoService.selectPlayCouponInfoById("master-invalid")).thenReturn(invalidInfo);
when(playCouponInfoService.selectPlayCouponInfoById("master-valid")).thenReturn(validInfo);
when(playCouponInfoService.getCouponReasonForUnavailableUse(any(), anyString(), anyString(), anyInt(), any()))
.thenReturn("");
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(35), 1, coupons, "commodity-mixed", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("25.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_withDeductionFetchesCustomerBalance() {
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
null,
null);
PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(70));
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());
stubDefaultPersistence();
lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(15), 2, Collections.emptyList(), "commodity-snap", PlaceType.SPECIFIED),
true,
null,
null));
verify(customUserInfoService).selectById(context.getPurchaserBy());
verify(customUserInfoService).updateAccountBalanceById(
eq(customer.getId()),
eq(customer.getAccountBalance()),
eq(customer.getAccountBalance().subtract(new BigDecimal("30.00"))),
eq(BalanceOperationType.CONSUME.getCode()),
eq("下单"),
eq(new BigDecimal("30.00")),
eq(BigDecimal.ZERO),
anyString());
}
@Test
void placeOrder_withZeroFinalAmountStillUpdatesLedger() {
OrderCreationContext context = baseContext(
PlaceType.OTHER,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
null,
null);
PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(10));
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());
stubDefaultPersistence();
lifecycleService.placeOrder(command(
context,
null,
true,
null,
null));
verify(customUserInfoService).updateAccountBalanceById(
eq(context.getPurchaserBy()),
eq(customer.getAccountBalance()),
eq(customer.getAccountBalance()),
eq(BalanceOperationType.CONSUME.getCode()),
eq("下单"),
argThat(amount -> amount.compareTo(BigDecimal.ZERO) == 0),
argThat(amount -> amount.compareTo(BigDecimal.ZERO) == 0),
anyString());
}
@Test
void completeOrder_inProgress_createsEarningsAndNotifies() {
String orderId = UUID.randomUUID().toString();
@@ -329,6 +1152,103 @@ class OrderLifecycleServiceImplTest {
return entity;
}
private void stubDefaultPersistence() {
lenient().when(orderInfoMapper.selectCount(any())).thenReturn(0L);
lenient().when(orderInfoMapper.insert(any())).thenReturn(1);
lenient().doNothing().when(customUserInfoService).saveOrderInfo(any());
lenient().doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), anyString());
ClerkEstimatedRevenueVo revenueVo = new ClerkEstimatedRevenueVo();
revenueVo.setRevenueAmount(BigDecimal.ZERO);
revenueVo.setRevenueRatio(0);
lenient().when(clerkRevenueCalculator.calculateEstimatedRevenue(
anyString(), anyList(), anyString(), anyString(), any())).thenReturn(revenueVo);
}
private PaymentInfo payment(BigDecimal gross, BigDecimal net, BigDecimal discount, List<String> couponIds) {
return PaymentInfo.builder()
.orderMoney(gross)
.finalAmount(net)
.discountAmount(discount)
.couponIds(couponIds)
.build();
}
private OrderCreationContext baseContext(PlaceType placeType, RewardType rewardType, PaymentInfo paymentInfo,
RandomOrderRequirements randomRequirements, String acceptBy) {
return OrderCreationContext.builder()
.orderId(UUID.randomUUID().toString())
.orderNo("NO-" + System.nanoTime())
.orderStatus(OrderStatus.PENDING)
.orderType(OrderType.NORMAL)
.placeType(placeType)
.rewardType(rewardType)
.isFirstOrder(true)
.commodityInfo(CommodityInfo.builder()
.commodityId("commodity-" + placeType.getCode())
.commodityType(CommodityType.SERVICE)
.commodityPrice(BigDecimal.TEN)
.serviceDuration("60")
.commodityName("服务")
.commodityNumber("1")
.build())
.paymentInfo(paymentInfo)
.purchaserBy("customer-" + placeType.getCode())
.acceptBy(acceptBy)
.randomOrderRequirements(randomRequirements)
.build();
}
private OrderPlacementCommand.PricingInput pricing(BigDecimal unitPrice, int quantity, List<String> couponIds,
String commodityId, PlaceType placeType) {
return OrderPlacementCommand.PricingInput.builder()
.unitPrice(unitPrice)
.quantity(quantity)
.couponIds(couponIds)
.commodityId(commodityId)
.placeType(placeType)
.build();
}
private OrderPlacementCommand command(OrderCreationContext context,
OrderPlacementCommand.PricingInput pricing,
boolean deduct,
String action,
PlayCustomUserInfoEntity snapshot) {
OrderPlacementCommand.OrderPlacementCommandBuilder builder = OrderPlacementCommand.builder()
.orderContext(context)
.deductBalance(deduct)
.balanceOperationAction(action);
if (pricing != null) {
builder.pricingInput(pricing);
}
return builder.build();
}
private void stubCoupon(String detailId, String masterId, String discountType, BigDecimal discountAmount,
String reason) {
PlayCouponDetailsEntity detail = new PlayCouponDetailsEntity();
detail.setCouponId(masterId);
when(playCouponDetailsService.getById(detailId)).thenReturn(detail);
PlayCouponInfoEntity info = new PlayCouponInfoEntity();
info.setDiscountType(discountType);
info.setDiscountAmount(discountAmount);
when(playCouponInfoService.selectPlayCouponInfoById(masterId)).thenReturn(info);
when(playCouponInfoService.getCouponReasonForUnavailableUse(
eq(info), anyString(), anyString(), anyInt(), any())).thenReturn(reason);
}
private PlayCustomUserInfoEntity customer(String id, BigDecimal balance) {
PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
entity.setId(id);
entity.setAccountBalance(balance.setScale(2));
return entity;
}
private void assertMoneyEquals(String expected, BigDecimal actual) {
assertEquals(0, actual.compareTo(new BigDecimal(expected)));
}
private void mockEarningsCounts(long... counts) {
LambdaQueryChainWrapper<EarningsLineEntity> chain = Mockito.mock(LambdaQueryChainWrapper.class);
when(chain.eq(any(), any())).thenReturn(chain);