diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java index fe94f3a..5cc1e0f 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java @@ -418,14 +418,41 @@ public class OrderConstant { @Getter public enum OrderTriggerSource { + /** + * 未标记来源的兜底,通常用于兼容历史数据 + */ UNKNOWN("unknown"), + /** + * 运营或客服后台人工处理触发 + */ MANUAL("manual"), + /** + * 微信顾客端(小程序/公众号)下单触发 + */ WX_CUSTOMER("wx_customer"), + /** + * 微信店员端操作触发 + */ WX_CLERK("wx_clerk"), + /** + * 管理后台控制台界面发起 + */ ADMIN_CONSOLE("admin_console"), + /** + * 管理后台开放接口调用 + */ ADMIN_API("admin_api"), + /** + * 打赏单自动生成的订单流程 + */ REWARD_ORDER("reward_order"), + /** + * 定时任务/调度器触发 + */ SCHEDULER("scheduler"), + /** + * 平台内部系统逻辑触发 + */ SYSTEM("system"); private final String code; diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderAmountBreakdown.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderAmountBreakdown.java new file mode 100644 index 0000000..bd76241 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderAmountBreakdown.java @@ -0,0 +1,17 @@ +package com.starry.admin.modules.order.module.dto; + +import java.math.BigDecimal; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 订单金额拆分结果:原价、优惠、实付。 + */ +@Getter +@AllArgsConstructor(staticName = "of") +public class OrderAmountBreakdown { + + private final BigDecimal grossAmount; + private final BigDecimal discountAmount; + private final BigDecimal netAmount; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderPlacementCommand.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderPlacementCommand.java new file mode 100644 index 0000000..f292095 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderPlacementCommand.java @@ -0,0 +1,48 @@ +package com.starry.admin.modules.order.module.dto; + +import com.starry.admin.modules.order.module.constant.OrderConstant; +import java.math.BigDecimal; +import java.util.Collections; +import java.util.List; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +/** + * 下单指令,封装不同业务场景需要的参数。 + */ +@Data +@Builder +public class OrderPlacementCommand { + + @NonNull + private final OrderCreationContext orderContext; + + private final String balanceOperationAction; + + @Builder.Default + private final boolean deductBalance = true; + + private final PricingInput pricingInput; + + /** + * 计价所需的输入参数。 + */ + @Data + @Builder + public static class PricingInput { + + @NonNull + private final BigDecimal unitPrice; + + private final int quantity; + + @Builder.Default + private final List couponIds = Collections.emptyList(); + + private final String commodityId; + + @NonNull + private final OrderConstant.PlaceType placeType; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderPlacementResult.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderPlacementResult.java new file mode 100644 index 0000000..4ebd22a --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderPlacementResult.java @@ -0,0 +1,16 @@ +package com.starry.admin.modules.order.module.dto; + +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 订单下单结果,包含订单实体和金额拆分。 + */ +@Getter +@AllArgsConstructor(staticName = "of") +public class OrderPlacementResult { + + private final PlayOrderInfoEntity order; + private final OrderAmountBreakdown amountBreakdown; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/IOrderLifecycleService.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/IOrderLifecycleService.java index f4288f4..ecf8b1b 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/service/IOrderLifecycleService.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/IOrderLifecycleService.java @@ -2,12 +2,14 @@ package com.starry.admin.modules.order.service; 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.entity.PlayOrderInfoEntity; public interface IOrderLifecycleService { - PlayOrderInfoEntity initiateOrder(OrderCreationContext context); + OrderPlacementResult placeOrder(OrderPlacementCommand command); void completeOrder(String orderId, OrderCompletionContext context); diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/AbstractOrderPlacementStrategy.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/AbstractOrderPlacementStrategy.java new file mode 100644 index 0000000..b907209 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/AbstractOrderPlacementStrategy.java @@ -0,0 +1,45 @@ +package com.starry.admin.modules.order.service.impl; + +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.order.module.dto.OrderAmountBreakdown; +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.PaymentInfo; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; + +abstract class AbstractOrderPlacementStrategy implements OrderPlacementStrategy { + + protected final OrderLifecycleServiceImpl service; + + AbstractOrderPlacementStrategy(OrderLifecycleServiceImpl service) { + this.service = service; + } + + @Override + public OrderPlacementResult place(OrderPlacementCommand command) { + OrderCreationContext context = command.getOrderContext(); + OrderAmountBreakdown breakdown = handlePricing(command, context); + + PaymentInfo paymentInfo = context.getPaymentInfo(); + if (paymentInfo == null) { + throw new CustomException("支付信息不能为空"); + } + + PlayOrderInfoEntity order = service.createOrderRecord(context); + + if (command.isDeductBalance()) { + service.deductCustomerBalance( + context.getPurchaserBy(), + service.normalizeMoney(paymentInfo.getFinalAmount()), + command.getBalanceOperationAction(), + order.getId()); + } + + OrderAmountBreakdown amountBreakdown = + breakdown != null ? breakdown : service.fallbackBreakdown(paymentInfo); + return OrderPlacementResult.of(order, amountBreakdown); + } + + protected abstract OrderAmountBreakdown handlePricing(OrderPlacementCommand command, OrderCreationContext context); +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java index 9f36889..860f215 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java @@ -6,10 +6,12 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; 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; import com.starry.admin.modules.order.mapper.PlayOrderLogInfoMapper; +import com.starry.admin.modules.order.module.constant.OrderConstant; import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType; import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor; @@ -25,8 +27,11 @@ import com.starry.admin.modules.order.module.constant.OrderConstant.PlaceType; import com.starry.admin.modules.order.module.constant.OrderConstant.ReviewRequirement; 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.OrderAmountBreakdown; 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; @@ -37,15 +42,24 @@ import com.starry.admin.modules.order.service.IOrderLifecycleService; 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.module.enums.CouponDiscountType; 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; import com.starry.admin.utils.SecurityUtils; import com.starry.common.utils.IdUtils; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDateTime; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; +import javax.annotation.PostConstruct; import javax.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -56,6 +70,7 @@ import org.springframework.transaction.annotation.Transactional; public class OrderLifecycleServiceImpl implements IOrderLifecycleService { private static final LifecycleToken LIFECYCLE_TOKEN = new LifecycleToken(); + private static final int MONEY_SCALE = 2; private enum LifecycleOperation { CREATE, @@ -81,21 +96,91 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { @Resource private IPlayCouponDetailsService playCouponDetailsService; + @Resource + private IPlayCouponInfoService playCouponInfoService; + @Resource private ClerkRevenueCalculator clerkRevenueCalculator; @Resource private PlayOrderLogInfoMapper orderLogInfoMapper; + private Map placementStrategies; + + @PostConstruct + void initPlacementStrategies() { + Map strategies = new HashMap<>(); + + OrderPlacementStrategy specifiedStrategy = new ServiceOrderPlacementStrategy(this, OrderConstant.PlaceType.SPECIFIED); + registerStrategy(strategies, OrderConstant.PlaceType.SPECIFIED, OrderConstant.RewardType.NOT_APPLICABLE, specifiedStrategy); + + OrderPlacementStrategy randomStrategy = new ServiceOrderPlacementStrategy(this, OrderConstant.PlaceType.RANDOM); + registerStrategy(strategies, OrderConstant.PlaceType.RANDOM, OrderConstant.RewardType.NOT_APPLICABLE, randomStrategy); + + OrderPlacementStrategy otherStrategy = new OtherOrderPlacementStrategy(this); + registerStrategy(strategies, OrderConstant.PlaceType.OTHER, OrderConstant.RewardType.NOT_APPLICABLE, otherStrategy); + + OrderPlacementStrategy rewardGiftStrategy = new RewardGiftOrderPlacementStrategy(this); + registerStrategy(strategies, OrderConstant.PlaceType.REWARD, OrderConstant.RewardType.GIFT, rewardGiftStrategy); + + OrderPlacementStrategy rewardTipStrategy = new RewardTipOrderPlacementStrategy(this); + registerStrategy(strategies, OrderConstant.PlaceType.REWARD, OrderConstant.RewardType.BALANCE, rewardTipStrategy); + registerStrategy(strategies, OrderConstant.PlaceType.REWARD, OrderConstant.RewardType.NOT_APPLICABLE, rewardTipStrategy); + + this.placementStrategies = strategies; + } + + private void registerStrategy( + Map strategies, + OrderConstant.PlaceType placeType, + OrderConstant.RewardType rewardType, + OrderPlacementStrategy strategy) { + StrategyKey key = StrategyKey.of(placeType, rewardType); + if (strategies.containsKey(key)) { + throw new IllegalStateException(String.format("Duplicate order placement strategy for %s/%s", placeType, rewardType)); + } + strategies.put(key, strategy); + } + + private boolean isRewardScenario(OrderCreationContext context) { + return context.getPlaceType() == OrderConstant.PlaceType.REWARD; + } + @Override @Transactional(rollbackFor = Exception.class) - public PlayOrderInfoEntity initiateOrder(OrderCreationContext context) { + public OrderPlacementResult placeOrder(OrderPlacementCommand command) { + if (command == null || command.getOrderContext() == null) { + throw new CustomException("下单参数不能为空"); + } + + OrderCreationContext context = command.getOrderContext(); + OrderConstant.PlaceType placeType = context.getPlaceType(); + log.info("placeOrder placeType={} rewardFlag? {}", placeType, isRewardScenario(context)); + if (placeType == null) { + throw new CustomException("下单类型不能为空"); + } + + OrderConstant.RewardType rewardType = context.getRewardType() != null + ? context.getRewardType() + : OrderConstant.RewardType.NOT_APPLICABLE; + OrderPlacementStrategy strategy = placementStrategies.get(StrategyKey.of(placeType, rewardType)); + if (strategy == null && rewardType != OrderConstant.RewardType.NOT_APPLICABLE) { + strategy = placementStrategies.get(StrategyKey.of(placeType, OrderConstant.RewardType.NOT_APPLICABLE)); + } + if (strategy == null) { + throw new CustomException("暂不支持该类型的下单逻辑"); + } + return strategy.place(command); + } + + PlayOrderInfoEntity createOrderRecord(OrderCreationContext context) { validateOrderCreationRequest(context); PlayOrderInfoEntity entity = buildOrderEntity(context); applyRandomOrderRequirements(entity, context.getRandomOrderRequirements()); applyAcceptByInfo(entity, context); - if (context.isRewardOrder()) { + boolean rewardScenario = isRewardScenario(context); + if (rewardScenario) { applyRewardOrderDefaults(entity); } @@ -111,7 +196,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { creationOperationType); updateCouponUsage(context.getPaymentInfo().getCouponIds()); - if (context.isRewardOrder() && StrUtil.isNotBlank(context.getAcceptBy())) { + if (rewardScenario && StrUtil.isNotBlank(context.getAcceptBy())) { completeOrder( entity.getId(), OrderCompletionContext.of(OrderActor.SYSTEM, null, OrderTriggerSource.REWARD_ORDER) @@ -121,6 +206,162 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { return entity; } + OrderAmountBreakdown calculateOrderAmounts(OrderPlacementCommand.PricingInput pricingInput) { + int quantity = pricingInput.getQuantity(); + if (quantity <= 0) { + throw new CustomException("数量不能为空"); + } + BigDecimal normalizedUnitPrice = normalizeMoney(pricingInput.getUnitPrice()); + BigDecimal grossAmount = normalizeMoney(normalizedUnitPrice.multiply(BigDecimal.valueOf(quantity))); + + List couponIds = pricingInput.getCouponIds(); + if (CollectionUtil.isEmpty(couponIds)) { + return OrderAmountBreakdown.of(grossAmount, BigDecimal.ZERO, grossAmount); + } + + BigDecimal discountTotal = BigDecimal.ZERO; + for (String couponId : couponIds) { + PlayCouponDetailsEntity couponDetails = playCouponDetailsService.getById(couponId); + if (couponDetails == null) { + throw new CustomException("优惠券不存在"); + } + PlayCouponInfoEntity couponInfo = playCouponInfoService.selectPlayCouponInfoById(couponDetails.getCouponId()); + String reason = playCouponInfoService.getCouponReasonForUnavailableUse( + couponInfo, + pricingInput.getPlaceType().getCode(), + pricingInput.getCommodityId(), + quantity, + normalizedUnitPrice); + if (StrUtil.isNotBlank(reason)) { + throw new CustomException("优惠券不可用 - " + reason); + } + + CouponDiscountType discountType; + try { + discountType = CouponDiscountType.fromCode(couponInfo.getDiscountType()); + } catch (CustomException ex) { + log.warn("Unsupported coupon discount type {}, couponId={}", couponInfo.getDiscountType(), couponInfo.getId()); + continue; + } + + switch (discountType) { + case FULL_REDUCTION: + discountTotal = discountTotal.add(normalizeMoney(couponInfo.getDiscountAmount())); + break; + case PERCENTAGE: + discountTotal = discountTotal.add(calculatePercentageDiscount(grossAmount, couponInfo.getDiscountAmount(), couponInfo.getId())); + break; + default: + break; + } + } + + BigDecimal discountAmount = normalizeMoney(discountTotal.min(grossAmount)); + BigDecimal netAmount = normalizeMoney(grossAmount.subtract(discountAmount)); + return OrderAmountBreakdown.of(grossAmount, discountAmount, netAmount); + } + + void deductCustomerBalance( + String customerId, + BigDecimal netAmount, + String operationAction, + String orderId) { + PlayCustomUserInfoEntity customer = customUserInfoService.selectById(customerId); + if (customer == null) { + throw new CustomException("顾客不存在"); + } + BigDecimal before = normalizeMoney(customer.getAccountBalance()); + if (netAmount.compareTo(before) > 0) { + throw new ServiceException("余额不足", 998); + } + BigDecimal after = normalizeMoney(before.subtract(netAmount)); + String action = StrUtil.isNotBlank(operationAction) ? operationAction : "下单"; + customUserInfoService.updateAccountBalanceById( + customerId, + before, + after, + BalanceOperationType.CONSUME.getCode(), + action, + netAmount, + BigDecimal.ZERO, + orderId); + } + + OrderAmountBreakdown fallbackBreakdown(PaymentInfo paymentInfo) { + return OrderAmountBreakdown.of( + normalizeMoney(paymentInfo.getOrderMoney()), + normalizeMoney(paymentInfo.getDiscountAmount()), + normalizeMoney(paymentInfo.getFinalAmount())); + } + + private static final class StrategyKey { + + private final OrderConstant.PlaceType placeType; + private final OrderConstant.RewardType rewardType; + + private StrategyKey(OrderConstant.PlaceType placeType, OrderConstant.RewardType rewardType) { + this.placeType = placeType; + this.rewardType = rewardType; + } + + static StrategyKey of(OrderConstant.PlaceType placeType, OrderConstant.RewardType rewardType) { + return new StrategyKey(placeType, rewardType); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof StrategyKey)) { + return false; + } + StrategyKey that = (StrategyKey) o; + return placeType == that.placeType && rewardType == that.rewardType; + } + + @Override + public int hashCode() { + return Objects.hash(placeType, rewardType); + } + } + + BigDecimal calculatePercentageDiscount(BigDecimal grossAmount, BigDecimal discountFold, String couponId) { + if (discountFold == null) { + log.warn("Percentage coupon has no discount amount configured, couponId={}", couponId); + return BigDecimal.ZERO; + } + + BigDecimal normalizedFold = discountFold.abs(); + // REVIEW THIS: WHY NORMALIZE this if < 10? + if (normalizedFold.compareTo(BigDecimal.TEN) > 0) { + return normalizeMoney(grossAmount); + } + BigDecimal rate; + if (normalizedFold.compareTo(BigDecimal.ONE) <= 0) { + rate = normalizedFold; + } else if (normalizedFold.compareTo(BigDecimal.TEN) <= 0) { + rate = normalizedFold.divide(BigDecimal.TEN, MONEY_SCALE + 2, RoundingMode.HALF_UP); + } else if (normalizedFold.compareTo(BigDecimal.valueOf(100)) <= 0) { + rate = normalizedFold.divide(BigDecimal.valueOf(100), MONEY_SCALE + 2, RoundingMode.HALF_UP); + } else { + log.warn("Percentage coupon rate out of range, couponId={}, value={}", couponId, discountFold); + return BigDecimal.ZERO; + } + + if (rate.compareTo(BigDecimal.ZERO) <= 0) { + log.warn("Percentage coupon rate <= 0, couponId={}", couponId); + return BigDecimal.ZERO; + } + if (rate.compareTo(BigDecimal.ONE) > 0) { + log.warn("Percentage coupon rate > 1 after normalisation, couponId={}", couponId); + rate = BigDecimal.ONE; + } + + BigDecimal discount = grossAmount.multiply(BigDecimal.ONE.subtract(rate)); + return normalizeMoney(discount.max(BigDecimal.ZERO)); + } + @Override @Transactional(rollbackFor = Exception.class) public void completeOrder(String orderId, OrderCompletionContext context) { @@ -395,6 +636,13 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { playCouponDetailsService.updateCouponUseStateByIds(couponIds, CouponUseState.USED.getCode()); } + BigDecimal normalizeMoney(BigDecimal amount) { + if (amount == null) { + return BigDecimal.ZERO.setScale(MONEY_SCALE, RoundingMode.HALF_UP); + } + return amount.setScale(MONEY_SCALE, RoundingMode.HALF_UP); + } + private String resolvePayMethod(String payMethodCode) { if (StrUtil.isBlank(payMethodCode)) { return PayMethod.BALANCE.getCode(); diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderPlacementStrategy.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderPlacementStrategy.java new file mode 100644 index 0000000..968c5d9 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderPlacementStrategy.java @@ -0,0 +1,9 @@ +package com.starry.admin.modules.order.service.impl; + +import com.starry.admin.modules.order.module.dto.OrderPlacementCommand; +import com.starry.admin.modules.order.module.dto.OrderPlacementResult; + +interface OrderPlacementStrategy { + + OrderPlacementResult place(OrderPlacementCommand command); +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OtherOrderPlacementStrategy.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OtherOrderPlacementStrategy.java new file mode 100644 index 0000000..2015653 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OtherOrderPlacementStrategy.java @@ -0,0 +1,30 @@ +package com.starry.admin.modules.order.service.impl; + +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.order.module.dto.OrderAmountBreakdown; +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.PaymentInfo; + +class OtherOrderPlacementStrategy extends AbstractOrderPlacementStrategy { + + OtherOrderPlacementStrategy(OrderLifecycleServiceImpl service) { + super(service); + } + + @Override + protected OrderAmountBreakdown handlePricing(OrderPlacementCommand command, OrderCreationContext context) { + PaymentInfo info = context.getPaymentInfo(); + if (info == null) { + throw new CustomException("支付信息不能为空"); + } + context.setPaymentInfo(PaymentInfo.builder() + .orderMoney(service.normalizeMoney(info.getOrderMoney())) + .discountAmount(service.normalizeMoney(info.getDiscountAmount())) + .finalAmount(service.normalizeMoney(info.getFinalAmount())) + .couponIds(info.getCouponIds()) + .payMethod(info.getPayMethod()) + .build()); + return null; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/RewardGiftOrderPlacementStrategy.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/RewardGiftOrderPlacementStrategy.java new file mode 100644 index 0000000..bacd398 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/RewardGiftOrderPlacementStrategy.java @@ -0,0 +1,41 @@ +package com.starry.admin.modules.order.service.impl; + +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.order.module.constant.OrderConstant; +import com.starry.admin.modules.order.module.dto.OrderAmountBreakdown; +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.PaymentInfo; + +class RewardGiftOrderPlacementStrategy extends AbstractOrderPlacementStrategy { + + RewardGiftOrderPlacementStrategy(OrderLifecycleServiceImpl service) { + super(service); + } + + @Override + protected OrderAmountBreakdown handlePricing(OrderPlacementCommand command, OrderCreationContext context) { + if (context.getRewardType() != OrderConstant.RewardType.GIFT) { + throw new CustomException("礼物订单类型不匹配"); + } + + PaymentInfo normalized = ensurePaymentInfoPresent(context); + return service.fallbackBreakdown(normalized); + } + + private PaymentInfo ensurePaymentInfoPresent(OrderCreationContext context) { + PaymentInfo info = context.getPaymentInfo(); + if (info == null) { + throw new CustomException("支付信息不能为空"); + } + PaymentInfo normalized = PaymentInfo.builder() + .orderMoney(service.normalizeMoney(info.getOrderMoney())) + .discountAmount(service.normalizeMoney(info.getDiscountAmount())) + .finalAmount(service.normalizeMoney(info.getFinalAmount())) + .couponIds(info.getCouponIds()) + .payMethod(info.getPayMethod()) + .build(); + context.setPaymentInfo(normalized); + return normalized; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/RewardTipOrderPlacementStrategy.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/RewardTipOrderPlacementStrategy.java new file mode 100644 index 0000000..32e7bb5 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/RewardTipOrderPlacementStrategy.java @@ -0,0 +1,40 @@ +package com.starry.admin.modules.order.service.impl; + +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.order.module.constant.OrderConstant; +import com.starry.admin.modules.order.module.dto.OrderAmountBreakdown; +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.PaymentInfo; + +class RewardTipOrderPlacementStrategy extends AbstractOrderPlacementStrategy { + + RewardTipOrderPlacementStrategy(OrderLifecycleServiceImpl service) { + super(service); + } + + @Override + protected OrderAmountBreakdown handlePricing(OrderPlacementCommand command, OrderCreationContext context) { + if (context.getRewardType() == OrderConstant.RewardType.GIFT) { + throw new CustomException("礼物订单应使用礼物策略"); + } + PaymentInfo normalized = ensurePaymentInfoPresent(context); + return service.fallbackBreakdown(normalized); + } + + private PaymentInfo ensurePaymentInfoPresent(OrderCreationContext context) { + PaymentInfo info = context.getPaymentInfo(); + if (info == null) { + throw new CustomException("支付信息不能为空"); + } + PaymentInfo normalized = PaymentInfo.builder() + .orderMoney(service.normalizeMoney(info.getOrderMoney())) + .discountAmount(service.normalizeMoney(info.getDiscountAmount())) + .finalAmount(service.normalizeMoney(info.getFinalAmount())) + .couponIds(info.getCouponIds()) + .payMethod(info.getPayMethod()) + .build(); + context.setPaymentInfo(normalized); + return normalized; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/ServiceOrderPlacementStrategy.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/ServiceOrderPlacementStrategy.java new file mode 100644 index 0000000..cd72411 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/ServiceOrderPlacementStrategy.java @@ -0,0 +1,57 @@ +package com.starry.admin.modules.order.service.impl; + +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.order.module.constant.OrderConstant; +import com.starry.admin.modules.order.module.dto.OrderAmountBreakdown; +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.PaymentInfo; + +class ServiceOrderPlacementStrategy extends AbstractOrderPlacementStrategy { + + private final OrderConstant.PlaceType placeType; + + ServiceOrderPlacementStrategy(OrderLifecycleServiceImpl service, OrderConstant.PlaceType placeType) { + super(service); + this.placeType = placeType; + } + + @Override + protected OrderAmountBreakdown handlePricing(OrderPlacementCommand command, OrderCreationContext context) { + if (context.getPlaceType() != placeType) { + throw new CustomException("下单类型与服务计价策略不匹配"); + } + + OrderPlacementCommand.PricingInput pricingInput = command.getPricingInput(); + if (pricingInput == null) { + PaymentInfo info = context.getPaymentInfo(); + if (info == null) { + throw new CustomException("支付信息不能为空"); + } + PaymentInfo normalized = PaymentInfo.builder() + .orderMoney(service.normalizeMoney(info.getOrderMoney())) + .discountAmount(service.normalizeMoney(info.getDiscountAmount())) + .finalAmount(service.normalizeMoney(info.getFinalAmount())) + .couponIds(info.getCouponIds()) + .payMethod(info.getPayMethod()) + .build(); + context.setPaymentInfo(normalized); + return service.fallbackBreakdown(normalized); + } + if (pricingInput.getPlaceType() != placeType) { + throw new CustomException("计价参数的下单类型不匹配"); + } + + OrderAmountBreakdown breakdown = service.calculateOrderAmounts(pricingInput); + PaymentInfo currentInfo = context.getPaymentInfo(); + PaymentInfo updated = PaymentInfo.builder() + .orderMoney(breakdown.getGrossAmount()) + .discountAmount(breakdown.getDiscountAmount()) + .finalAmount(breakdown.getNetAmount()) + .couponIds(pricingInput.getCouponIds()) + .payMethod(currentInfo != null ? currentInfo.getPayMethod() : null) + .build(); + context.setPaymentInfo(updated); + return breakdown; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/shop/module/enums/CouponDiscountType.java b/play-admin/src/main/java/com/starry/admin/modules/shop/module/enums/CouponDiscountType.java new file mode 100644 index 0000000..a20b90b --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/shop/module/enums/CouponDiscountType.java @@ -0,0 +1,36 @@ +package com.starry.admin.modules.shop.module.enums; + +import com.starry.admin.common.exception.CustomException; +import lombok.Getter; + +/** + * 优惠券折扣类型枚举。 + */ +@Getter +public enum CouponDiscountType { + + /** + * 满减券(固定金额抵扣)。 + */ + FULL_REDUCTION("0"), + + /** + * 折扣券(按比例折扣)。暂未支持具体计算逻辑。 + */ + PERCENTAGE("1"); + + private final String code; + + CouponDiscountType(String code) { + this.code = code; + } + + public static CouponDiscountType fromCode(String code) { + for (CouponDiscountType value : values()) { + if (value.code.equals(code)) { + return value; + } + } + throw new CustomException("不支持的优惠券折扣类型: " + code); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java index 63ced0b..b562889 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java @@ -7,7 +7,6 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.starry.admin.common.aspect.CustomUserLogin; import com.starry.admin.common.conf.ThreadLocalRequestDetail; import com.starry.admin.common.exception.CustomException; -import com.starry.admin.common.exception.ServiceException; import com.starry.admin.common.task.OverdueOrderHandlerTask; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService; @@ -23,6 +22,8 @@ import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor; import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType; import com.starry.admin.modules.order.module.dto.CommodityInfo; 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.PaymentInfo; import com.starry.admin.modules.order.module.dto.RandomOrderRequirements; import com.starry.admin.modules.order.module.entity.PlayOrderComplaintInfoEntity; @@ -32,8 +33,6 @@ import com.starry.admin.modules.order.service.IOrderLifecycleService; import com.starry.admin.modules.order.service.IPlayOrderComplaintInfoService; import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService; -import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity; -import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity; import com.starry.admin.modules.shop.module.vo.PlayCommodityInfoVo; import com.starry.admin.modules.shop.service.*; import com.starry.admin.modules.weichat.entity.*; @@ -63,12 +62,13 @@ import io.swagger.annotations.ApiResponse; import io.swagger.annotations.ApiResponses; import java.math.BigDecimal; import java.util.ArrayList; +import java.util.Collections; import java.util.Date; import java.util.List; -import java.util.Objects; import javax.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; +import org.springframework.util.CollectionUtils; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -230,10 +230,6 @@ public class WxCustomController { public R rewardToOrder(@ApiParam(value = "打赏信息", required = true) @Validated @RequestBody PlayOrderInfoRewardAdd vo) { MoneyUtils.verificationTypeIsNormal(vo.getMoney()); String userId = ThreadLocalRequestDetail.getCustomUserInfo().getId(); - PlayCustomUserInfoEntity customUserInfo = customUserInfoService.selectById(userId); - if (new BigDecimal(vo.getMoney()).compareTo(customUserInfo.getAccountBalance()) > 0) { - throw new ServiceException("余额不足", 998); - } String orderId = IdUtils.getUuid(); // 记录订单信息 OrderCreationContext orderRequest = OrderCreationContext.builder() @@ -265,9 +261,10 @@ public class WxCustomController { .weiChatCode(vo.getWeiChatCode()) .remark(vo.getRemark()) .build(); - orderLifecycleService.initiateOrder(orderRequest); - // 顾客减少余额 - customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(new BigDecimal(vo.getMoney())), "1", "打赏", new BigDecimal(vo.getMoney()), BigDecimal.ZERO, orderId); + orderLifecycleService.placeOrder(OrderPlacementCommand.builder() + .orderContext(orderRequest) + .balanceOperationAction("打赏") + .build()); return R.ok("成功"); } @@ -301,34 +298,6 @@ public class WxCustomController { PlayCommodityInfoVo commodityInfo = playCommodityInfoService.queryCommodityInfo(vo.getCommodityId(), clerkUserInfo.getLevelId()); - BigDecimal couponMoney = BigDecimal.ZERO; - for (String couponId : vo.getCouponIds()) { - PlayCouponDetailsEntity couponDetailsEntity = couponDetailsService.getById(couponId); - if (Objects.isNull(couponDetailsEntity)) { - throw new CustomException("优惠券不存在"); - } - PlayCouponInfoEntity couponInfo = playCouponInfoService.selectPlayCouponInfoById(couponDetailsEntity.getCouponId()); - String couponReasonForUnavailableUse = playCouponInfoService.getCouponReasonForUnavailableUse(couponInfo, "0", vo.getCommodityId(), vo.getCommodityQuantity(), commodityInfo.getCommodityPrice()); - if (StrUtil.isNotBlank(couponReasonForUnavailableUse)) { - throw new CustomException("优惠券不可用"); - } - if (couponInfo.getDiscountType().equals("0")) { - couponMoney = couponMoney.add(couponInfo.getDiscountAmount()); - } - } - - PlayCustomUserInfoEntity customUserInfo = customUserInfoService.selectById(customId); - - BigDecimal money = commodityInfo.getCommodityPrice().multiply(new BigDecimal(vo.getCommodityQuantity())); - - money = money.subtract(couponMoney); - if (money.compareTo(BigDecimal.ZERO) < 1) { - money = BigDecimal.ZERO; - } - - if (money.compareTo(customUserInfo.getAccountBalance()) > 0) { - throw new ServiceException("余额不足", 998); - } String orderId = IdUtils.getUuid(); String orderNo = playOrderInfoService.getOrderNo(); // 记录订单信息 @@ -351,21 +320,39 @@ public class WxCustomController { .commodityNumber(String.valueOf(vo.getCommodityQuantity())) .build()) .paymentInfo(PaymentInfo.builder() - .orderMoney(money) - .finalAmount(money) + .orderMoney(BigDecimal.ZERO) + .finalAmount(BigDecimal.ZERO) .discountAmount(BigDecimal.ZERO) - .couponIds(vo.getCouponIds()) + .couponIds(Collections.emptyList()) .build()) .purchaserBy(customId) .acceptBy(clerkUserInfo.getId()) .weiChatCode(vo.getWeiChatCode()) .remark(vo.getRemark()) .build(); - orderLifecycleService.initiateOrder(orderRequest); - // 顾客减少余额 - customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(money), "1", "下单-指定单", money, BigDecimal.ZERO, orderId); + List couponIds = CollectionUtils.isEmpty(vo.getCouponIds()) ? Collections.emptyList() : vo.getCouponIds(); + OrderPlacementCommand command = OrderPlacementCommand.builder() + .orderContext(orderRequest) + .balanceOperationAction("下单-指定单") + .pricingInput(OrderPlacementCommand.PricingInput.builder() + .unitPrice(commodityInfo.getCommodityPrice()) + .quantity(vo.getCommodityQuantity()) + .couponIds(couponIds) + .commodityId(commodityInfo.getCommodityId()) + .placeType(OrderConstant.PlaceType.SPECIFIED) + .build()) + .build(); + OrderPlacementResult result = orderLifecycleService.placeOrder(command); + PlayOrderInfoEntity order = result.getOrder(); + BigDecimal netAmount = result.getAmountBreakdown().getNetAmount(); // 发送通知给店员 - wxCustomMpService.sendCreateOrderMessage(clerkUserInfo.getTenantId(), clerkUserInfo.getOpenid(), orderNo, money.toString(), commodityInfo.getCommodityName(),orderId ); + wxCustomMpService.sendCreateOrderMessage( + clerkUserInfo.getTenantId(), + clerkUserInfo.getOpenid(), + orderNo, + netAmount.toString(), + commodityInfo.getCommodityName(), + order.getId()); return R.ok("成功"); } @@ -380,12 +367,7 @@ public class WxCustomController { public R randomToOrdder(@Validated @RequestBody PlayOrderInfoRandomAdd vo) { // 验证当前顾客余额是否充足 String customId = ThreadLocalRequestDetail.getCustomUserInfo().getId(); - PlayCustomUserInfoEntity customUserInfo = customUserInfoService.selectById(customId); PlayCommodityInfoVo commodityInfo = playCommodityInfoService.queryCommodityInfo(vo.getCommodityId(), vo.getLevelId()); - BigDecimal money = commodityInfo.getCommodityPrice().multiply(new BigDecimal(vo.getCommodityQuantity())); - if (money.compareTo(customUserInfo.getAccountBalance()) > 0) { - throw new ServiceException("余额不足", 998); - } String orderId = IdUtils.getUuid(); String orderNo = playOrderInfoService.getOrderNo(); // 记录订单信息 @@ -408,10 +390,10 @@ public class WxCustomController { .commodityNumber(String.valueOf(vo.getCommodityQuantity())) .build()) .paymentInfo(PaymentInfo.builder() - .orderMoney(money) - .finalAmount(money) + .orderMoney(BigDecimal.ZERO) + .finalAmount(BigDecimal.ZERO) .discountAmount(BigDecimal.ZERO) - .couponIds(vo.getCouponIds()) + .couponIds(Collections.emptyList()) .build()) .purchaserBy(customId) .weiChatCode(vo.getWeiChatCode()) @@ -422,13 +404,28 @@ public class WxCustomController { .excludeHistory(vo.getExcludeHistory()) .build()) .build(); - orderLifecycleService.initiateOrder(orderRequest); - // 顾客减少余额 - customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(money), "1", "下单-随机单", money, BigDecimal.ZERO, orderId); + List couponIds = CollectionUtils.isEmpty(vo.getCouponIds()) ? Collections.emptyList() : vo.getCouponIds(); + OrderPlacementCommand command = OrderPlacementCommand.builder() + .orderContext(orderRequest) + .balanceOperationAction("下单-随机单") + .pricingInput(OrderPlacementCommand.PricingInput.builder() + .unitPrice(commodityInfo.getCommodityPrice()) + .quantity(vo.getCommodityQuantity()) + .couponIds(couponIds) + .commodityId(commodityInfo.getCommodityId()) + .placeType(OrderConstant.PlaceType.RANDOM) + .build()) + .build(); + OrderPlacementResult result = orderLifecycleService.placeOrder(command); + PlayOrderInfoEntity order = result.getOrder(); + BigDecimal netAmount = result.getAmountBreakdown().getNetAmount(); // 给全部店员发送通知 - List clerkList = clerkUserInfoService.list(Wrappers.lambdaQuery(PlayClerkUserInfoEntity.class).isNotNull(PlayClerkUserInfoEntity::getOpenid).eq(PlayClerkUserInfoEntity::getClerkState, "1") - .eq(PlayClerkUserInfoEntity::getOnlineState, "1").eq(PlayClerkUserInfoEntity::getSex, vo.getSex())); - wxCustomMpService.sendCreateOrderMessageBatch(clerkList, orderNo, money.toString(), commodityInfo.getCommodityName(),orderId); + List clerkList = clerkUserInfoService.list(Wrappers.lambdaQuery(PlayClerkUserInfoEntity.class) + .isNotNull(PlayClerkUserInfoEntity::getOpenid) + .eq(PlayClerkUserInfoEntity::getClerkState, "1") + .eq(PlayClerkUserInfoEntity::getOnlineState, "1") + .eq(PlayClerkUserInfoEntity::getSex, vo.getSex())); + wxCustomMpService.sendCreateOrderMessageBatch(clerkList, orderNo, netAmount.toString(), commodityInfo.getCommodityName(),order.getId()); // 记录订单,指定指定未接单后,进行退款处理 overdueOrderHandlerTask.enqueue(orderId + "_" + SecurityUtils.getTenantId()); // 下单成功后,先根据用户条件进行随机分配 diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxGiftOrderService.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxGiftOrderService.java index b16f21b..bc01853 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxGiftOrderService.java +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxGiftOrderService.java @@ -2,7 +2,6 @@ package com.starry.admin.modules.weichat.service; import com.starry.admin.common.conf.ThreadLocalRequestDetail; 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.IPlayCustomGiftInfoService; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; @@ -11,7 +10,10 @@ import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor; import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType; import com.starry.admin.modules.order.module.dto.CommodityInfo; 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.PaymentInfo; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.service.IOrderLifecycleService; import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity; @@ -79,27 +81,21 @@ public class WxGiftOrderService { throw new CustomException("礼物金额必须大于0"); } - BigDecimal currentBalance = customUserInfo.getAccountBalance() == null - ? BigDecimal.ZERO : customUserInfo.getAccountBalance(); - if (totalAmount.compareTo(currentBalance) > 0) { - throw new ServiceException("余额不足", 998); - } - String orderId = IdUtils.getUuid(); OrderCreationContext orderRequest = buildOrderCreationContext(orderId, request, sessionUser.getId(), giftInfo, totalAmount); - orderLifecycleService.initiateOrder(orderRequest); - - BigDecimal newBalance = currentBalance.subtract(totalAmount); - customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), currentBalance, newBalance, "1", - "赠送礼物", totalAmount, BigDecimal.ZERO, orderId); + OrderPlacementResult result = orderLifecycleService.placeOrder(OrderPlacementCommand.builder() + .orderContext(orderRequest) + .balanceOperationAction("赠送礼物") + .build()); + PlayOrderInfoEntity order = result.getOrder(); String tenantId = customUserInfo.getTenantId(); long delta = giftQuantity; playCustomGiftInfoService.incrementGiftCount(customUserInfo.getId(), request.getGiftId(), tenantId, delta); playClerkGiftInfoService.incrementGiftCount(request.getClerkId(), request.getGiftId(), tenantId, delta); - return orderId; + return order.getId(); } private OrderCreationContext buildOrderCreationContext(String orderId, PlayOrderInfoGiftAdd request, diff --git a/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java b/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java index 0295e04..86c7a95 100644 --- a/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java +++ b/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java @@ -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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 chain = Mockito.mock(LambdaQueryChainWrapper.class); when(chain.eq(any(), any())).thenReturn(chain);