Compare commits

..

4 Commits

Author SHA1 Message Date
irving
c9439e1021 test: 补充优惠券库存相关单测
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
2025-10-31 00:10:40 -04:00
irving
db6132d7e3 feat: 优化优惠券发放与库存校验流程 2025-10-31 00:10:24 -04:00
irving
48bdc9af33 重构收益与优惠券逻辑:统一使用 orderAmount,新增优惠券状态校验与过滤
- IPlayOrderInfoService/PlayOrderInfoServiceImpl/ClerkRevenueCalculator 将参数 finalAmount 更名为 orderAmount,避免语义混淆
- 预计收益计算兼容 null 与 0,防止 NPE 并明确边界
- 结算/回填:EarningsServiceImpl、EarningsBackfillServiceImpl 改为使用 orderMoney 兜底;0 金额允许,负数跳过
- 新增枚举:CouponOnlineState、CouponValidityPeriodType 用于券上下架与有效期判定
- IPlayCouponInfoService/Impl 增加 getCouponDetailRestrictionReason,支持已使用/过期/下架等状态校验
- WxCouponController 列表与下单查询增加状态/有效期/库存/白名单过滤逻辑
- OrderLifecycleServiceImpl 下单时校验优惠券状态,预计收益入参从 finalAmount 调整为 orderMoney
- 完善单元测试:订单生命周期、优惠券过滤、收益生成与回填等覆盖
2025-10-30 22:07:37 -04:00
irving
e29c5db276 重构订单下单逻辑,引入策略模式和命令模式
- 为OrderTriggerSource枚举添加详细注释说明
- 将IOrderLifecycleService接口的initiateOrder方法重构为placeOrder
- 新增OrderPlacementCommand、OrderPlacementResult、OrderAmountBreakdown等DTO
- 实现订单下单策略模式,支持不同类型订单的差异化处理
- 优化金额计算逻辑,完善优惠券折扣计算
- 改进余额扣减逻辑,增强异常处理
- 更新相关控制器使用新的下单接口
- 完善单元测试覆盖,确保代码质量
2025-10-30 21:12:37 -04:00
36 changed files with 2454 additions and 131 deletions

View File

@@ -418,14 +418,41 @@ public class OrderConstant {
@Getter @Getter
public enum OrderTriggerSource { public enum OrderTriggerSource {
/**
* 未标记来源的兜底,通常用于兼容历史数据
*/
UNKNOWN("unknown"), UNKNOWN("unknown"),
/**
* 运营或客服后台人工处理触发
*/
MANUAL("manual"), MANUAL("manual"),
/**
* 微信顾客端(小程序/公众号)下单触发
*/
WX_CUSTOMER("wx_customer"), WX_CUSTOMER("wx_customer"),
/**
* 微信店员端操作触发
*/
WX_CLERK("wx_clerk"), WX_CLERK("wx_clerk"),
/**
* 管理后台控制台界面发起
*/
ADMIN_CONSOLE("admin_console"), ADMIN_CONSOLE("admin_console"),
/**
* 管理后台开放接口调用
*/
ADMIN_API("admin_api"), ADMIN_API("admin_api"),
/**
* 打赏单自动生成的订单流程
*/
REWARD_ORDER("reward_order"), REWARD_ORDER("reward_order"),
/**
* 定时任务/调度器触发
*/
SCHEDULER("scheduler"), SCHEDULER("scheduler"),
/**
* 平台内部系统逻辑触发
*/
SYSTEM("system"); SYSTEM("system");
private final String code; private final String code;

View File

@@ -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;
}

View File

@@ -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<String> couponIds = Collections.emptyList();
private final String commodityId;
@NonNull
private final OrderConstant.PlaceType placeType;
}
}

View File

@@ -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;
}

View File

@@ -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.OrderCompletionContext;
import com.starry.admin.modules.order.module.dto.OrderCreationContext; 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.OrderRefundContext;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
public interface IOrderLifecycleService { public interface IOrderLifecycleService {
PlayOrderInfoEntity initiateOrder(OrderCreationContext context); OrderPlacementResult placeOrder(OrderPlacementCommand command);
void completeOrder(String orderId, OrderCompletionContext context); void completeOrder(String orderId, OrderCompletionContext context);

View File

@@ -52,13 +52,13 @@ public interface IPlayOrderInfoService extends IService<PlayOrderInfoEntity> {
* @param croupIds 优惠券ID列表 * @param croupIds 优惠券ID列表
* @param placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单) * @param placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单)
* @param firstOrder 是否是首单【0不是1是】 * @param firstOrder 是否是首单【0不是1是】
* @param finalAmount 订单支付金额 * @param orderAmount 订单金额
* @return com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo * @return com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo
* @author admin * @author admin
* @since 2024/7/18 16:39 * @since 2024/7/18 16:39
**/ **/
ClerkEstimatedRevenueVo getClerkEstimatedRevenue(String clerkId, List<String> croupIds, String placeType, ClerkEstimatedRevenueVo getClerkEstimatedRevenue(String clerkId, List<String> croupIds, String placeType,
String firstOrder, BigDecimal finalAmount); String firstOrder, BigDecimal orderAmount);
/** /**
* 根据店员等级和订单金额,获取店员预计收入 * 根据店员等级和订单金额,获取店员预计收入
@@ -66,12 +66,12 @@ public interface IPlayOrderInfoService extends IService<PlayOrderInfoEntity> {
* @param clerkId 店员ID * @param clerkId 店员ID
* @param placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单) * @param placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单)
* @param firstOrder 是否是首单【0不是1是】 * @param firstOrder 是否是首单【0不是1是】
* @param finalAmount 订单支付金额 * @param orderAmount 订单金额
* @return math.BigDecimal 店员预计收入 * @return math.BigDecimal 店员预计收入
* @author admin * @author admin
* @since 2024/6/3 11:12 * @since 2024/6/3 11:12
**/ **/
BigDecimal getEstimatedRevenue(String clerkId, String placeType, String firstOrder, BigDecimal finalAmount); BigDecimal getEstimatedRevenue(String clerkId, String placeType, String firstOrder, BigDecimal orderAmount);
/** /**
* 根据店员等级,获取店员提成比例 * 根据店员等级,获取店员提成比例

View File

@@ -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);
}

View File

@@ -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.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.starry.admin.common.exception.CustomException; 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.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper;
import com.starry.admin.modules.order.mapper.PlayOrderLogInfoMapper; 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.BalanceOperationType;
import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType; import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor; 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.ReviewRequirement;
import com.starry.admin.modules.order.module.constant.OrderConstant.YesNoFlag; 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.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.OrderCompletionContext;
import com.starry.admin.modules.order.module.dto.OrderCreationContext; 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.OrderRefundContext;
import com.starry.admin.modules.order.module.dto.PaymentInfo; 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.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.IPlayOrderRefundInfoService;
import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator; 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.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.IPlayCouponDetailsService;
import com.starry.admin.modules.shop.service.IPlayCouponInfoService;
import com.starry.admin.modules.weichat.service.WxCustomMpService; import com.starry.admin.modules.weichat.service.WxCustomMpService;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.service.IEarningsService; import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects;
import javax.annotation.PostConstruct;
import javax.annotation.Resource; import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -56,6 +70,7 @@ import org.springframework.transaction.annotation.Transactional;
public class OrderLifecycleServiceImpl implements IOrderLifecycleService { public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
private static final LifecycleToken LIFECYCLE_TOKEN = new LifecycleToken(); private static final LifecycleToken LIFECYCLE_TOKEN = new LifecycleToken();
private static final int MONEY_SCALE = 2;
private enum LifecycleOperation { private enum LifecycleOperation {
CREATE, CREATE,
@@ -81,21 +96,91 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
@Resource @Resource
private IPlayCouponDetailsService playCouponDetailsService; private IPlayCouponDetailsService playCouponDetailsService;
@Resource
private IPlayCouponInfoService playCouponInfoService;
@Resource @Resource
private ClerkRevenueCalculator clerkRevenueCalculator; private ClerkRevenueCalculator clerkRevenueCalculator;
@Resource @Resource
private PlayOrderLogInfoMapper orderLogInfoMapper; private PlayOrderLogInfoMapper orderLogInfoMapper;
private Map<StrategyKey, OrderPlacementStrategy> placementStrategies;
@PostConstruct
void initPlacementStrategies() {
Map<StrategyKey, OrderPlacementStrategy> 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<StrategyKey, OrderPlacementStrategy> 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 @Override
@Transactional(rollbackFor = Exception.class) @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); validateOrderCreationRequest(context);
PlayOrderInfoEntity entity = buildOrderEntity(context); PlayOrderInfoEntity entity = buildOrderEntity(context);
applyRandomOrderRequirements(entity, context.getRandomOrderRequirements()); applyRandomOrderRequirements(entity, context.getRandomOrderRequirements());
applyAcceptByInfo(entity, context); applyAcceptByInfo(entity, context);
if (context.isRewardOrder()) { boolean rewardScenario = isRewardScenario(context);
if (rewardScenario) {
applyRewardOrderDefaults(entity); applyRewardOrderDefaults(entity);
} }
@@ -111,7 +196,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
creationOperationType); creationOperationType);
updateCouponUsage(context.getPaymentInfo().getCouponIds()); updateCouponUsage(context.getPaymentInfo().getCouponIds());
if (context.isRewardOrder() && StrUtil.isNotBlank(context.getAcceptBy())) { if (rewardScenario && StrUtil.isNotBlank(context.getAcceptBy())) {
completeOrder( completeOrder(
entity.getId(), entity.getId(),
OrderCompletionContext.of(OrderActor.SYSTEM, null, OrderTriggerSource.REWARD_ORDER) OrderCompletionContext.of(OrderActor.SYSTEM, null, OrderTriggerSource.REWARD_ORDER)
@@ -121,6 +206,169 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
return entity; 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<String> 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());
playCouponInfoService.getCouponDetailRestrictionReason(
couponInfo,
couponDetails.getUseState(),
couponDetails.getObtainingTime())
.ifPresent(reason -> {
throw new CustomException("优惠券不可用 - " + reason);
});
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 @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void completeOrder(String orderId, OrderCompletionContext context) { public void completeOrder(String orderId, OrderCompletionContext context) {
@@ -376,7 +624,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
context.getPaymentInfo().getCouponIds(), context.getPaymentInfo().getCouponIds(),
context.getPlaceType().getCode(), context.getPlaceType().getCode(),
entity.getFirstOrder(), entity.getFirstOrder(),
context.getPaymentInfo().getFinalAmount()); context.getPaymentInfo().getOrderMoney());
entity.setEstimatedRevenue(estimatedRevenue.getRevenueAmount()); entity.setEstimatedRevenue(estimatedRevenue.getRevenueAmount());
entity.setEstimatedRevenueRatio(estimatedRevenue.getRevenueRatio()); entity.setEstimatedRevenueRatio(estimatedRevenue.getRevenueRatio());
} }
@@ -395,6 +643,13 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
playCouponDetailsService.updateCouponUseStateByIds(couponIds, CouponUseState.USED.getCode()); 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) { private String resolvePayMethod(String payMethodCode) {
if (StrUtil.isBlank(payMethodCode)) { if (StrUtil.isBlank(payMethodCode)) {
return PayMethod.BALANCE.getCode(); return PayMethod.BALANCE.getCode();

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -116,8 +116,8 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
@Override @Override
public ClerkEstimatedRevenueVo getClerkEstimatedRevenue(String clerkId, List<String> croupIds, String placeType, public ClerkEstimatedRevenueVo getClerkEstimatedRevenue(String clerkId, List<String> croupIds, String placeType,
String firstOrder, BigDecimal finalAmount) { String firstOrder, BigDecimal orderAmount) {
return clerkRevenueCalculator.calculateEstimatedRevenue(clerkId, croupIds, placeType, firstOrder, finalAmount); return clerkRevenueCalculator.calculateEstimatedRevenue(clerkId, croupIds, placeType, firstOrder, orderAmount);
} }
/** /**
@@ -126,35 +126,36 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
* @param clerkId 店员ID * @param clerkId 店员ID
* @param placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单) * @param placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单)
* @param firstOrder 是否是首单【0不是1是】 * @param firstOrder 是否是首单【0不是1是】
* @param finalAmount 订单支付金额 * @param orderAmount 订单金额
* @return math.BigDecimal 店员预计收入 * @return math.BigDecimal 店员预计收入
* @author admin * @author admin
* @since 2024/6/3 11:12 * @since 2024/6/3 11:12
**/ **/
@Override @Override
public BigDecimal getEstimatedRevenue(String clerkId, String placeType, String firstOrder, BigDecimal finalAmount) { public BigDecimal getEstimatedRevenue(String clerkId, String placeType, String firstOrder, BigDecimal orderAmount) {
PlayClerkLevelInfoEntity entity = playClerkUserInfoService.queryLevelCommission(clerkId); PlayClerkLevelInfoEntity entity = playClerkUserInfoService.queryLevelCommission(clerkId);
boolean isFirst = OrderConstant.YesNoFlag.YES.getCode().equals(firstOrder); boolean isFirst = OrderConstant.YesNoFlag.YES.getCode().equals(firstOrder);
BigDecimal safeOrderAmount = orderAmount == null ? BigDecimal.ZERO : orderAmount;
try { try {
OrderConstant.PlaceType place = OrderConstant.PlaceType.fromCode(placeType); OrderConstant.PlaceType place = OrderConstant.PlaceType.fromCode(placeType);
switch (place) { switch (place) {
case SPECIFIED: case SPECIFIED:
return calculateRevenue(finalAmount, return calculateRevenue(safeOrderAmount,
isFirst ? entity.getFirstRegularRatio() : entity.getNotFirstRegularRatio()); isFirst ? entity.getFirstRegularRatio() : entity.getNotFirstRegularRatio());
case RANDOM: case RANDOM:
return calculateRevenue(finalAmount, return calculateRevenue(safeOrderAmount,
isFirst ? entity.getFirstRandomRadio() : entity.getNotFirstRandomRadio()); isFirst ? entity.getFirstRandomRadio() : entity.getNotFirstRandomRadio());
case REWARD: case REWARD:
return calculateRevenue(finalAmount, return calculateRevenue(safeOrderAmount,
isFirst ? entity.getFirstRewardRatio() : entity.getNotFirstRewardRatio()); isFirst ? entity.getFirstRewardRatio() : entity.getNotFirstRewardRatio());
case OTHER: case OTHER:
default: default:
log.error("下单类型异常placeType={}", placeType); log.error("下单类型异常placeType={}", placeType);
return finalAmount; return safeOrderAmount;
} }
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
log.error("下单类型错误placeType={}", placeType, ex); log.error("下单类型错误placeType={}", placeType, ex);
return finalAmount; return safeOrderAmount;
} }
} }
@@ -445,7 +446,9 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
returnVo.setRefundById(orderRefundInfoEntity.getRefundById()); returnVo.setRefundById(orderRefundInfoEntity.getRefundById());
returnVo.setRefundReason(orderRefundInfoEntity.getRefundReason()); returnVo.setRefundReason(orderRefundInfoEntity.getRefundReason());
} }
returnVo.setEstimatedRevenue(returnVo.getFinalAmount()); if (returnVo.getEstimatedRevenue() == null) {
returnVo.setEstimatedRevenue(BigDecimal.ZERO);
}
return returnVo; return returnVo;
} }
@@ -585,7 +588,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
orderInfo.getCouponIds(), orderInfo.getCouponIds(),
orderInfo.getPlaceType(), orderInfo.getPlaceType(),
firstOrderFlag, firstOrderFlag,
orderInfo.getFinalAmount()); orderInfo.getOrderMoney());
BigDecimal revenueAmount = estimatedRevenueVo.getRevenueAmount(); BigDecimal revenueAmount = estimatedRevenueVo.getRevenueAmount();
entity.setEstimatedRevenue(revenueAmount); entity.setEstimatedRevenue(revenueAmount);
entity.setEstimatedRevenueRatio(estimatedRevenueVo.getRevenueRatio()); entity.setEstimatedRevenueRatio(estimatedRevenueVo.getRevenueRatio());

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -32,8 +32,9 @@ public class ClerkRevenueCalculator {
List<String> couponIds, List<String> couponIds,
String placeType, String placeType,
String firstOrder, String firstOrder,
BigDecimal finalAmount) { BigDecimal orderAmount) {
PlayClerkLevelInfoEntity levelInfo = playClerkUserInfoService.queryLevelCommission(clerkId); PlayClerkLevelInfoEntity levelInfo = playClerkUserInfoService.queryLevelCommission(clerkId);
BigDecimal baseAmount = orderAmount == null ? BigDecimal.ZERO : orderAmount;
ClerkEstimatedRevenueVo estimatedRevenueVo = new ClerkEstimatedRevenueVo(); ClerkEstimatedRevenueVo estimatedRevenueVo = new ClerkEstimatedRevenueVo();
boolean fallbackToOther = false; boolean fallbackToOther = false;
@@ -48,20 +49,20 @@ public class ClerkRevenueCalculator {
switch (placeTypeEnum) { switch (placeTypeEnum) {
case SPECIFIED: // 指定单 case SPECIFIED: // 指定单
fillRegularOrderRevenue(firstOrder, finalAmount, levelInfo, estimatedRevenueVo); fillRegularOrderRevenue(firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
break; break;
case RANDOM: // 随机单 case RANDOM: // 随机单
fillRandomOrderRevenue(firstOrder, finalAmount, levelInfo, estimatedRevenueVo); fillRandomOrderRevenue(firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
break; break;
case REWARD: // 打赏单 case REWARD: // 打赏单
fillRewardOrderRevenue(firstOrder, finalAmount, levelInfo, estimatedRevenueVo); fillRewardOrderRevenue(firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
break; break;
case OTHER: case OTHER:
default: default:
if (!fallbackToOther) { if (!fallbackToOther) {
log.warn("按其他下单类型计算预计收益placeType={}clerkId={}", placeType, clerkId); log.warn("按其他下单类型计算预计收益placeType={}clerkId={}", placeType, clerkId);
} }
estimatedRevenueVo.setRevenueAmount(finalAmount); estimatedRevenueVo.setRevenueAmount(baseAmount);
estimatedRevenueVo.setRevenueRatio(100); estimatedRevenueVo.setRevenueRatio(100);
break; break;
} }
@@ -70,36 +71,36 @@ public class ClerkRevenueCalculator {
return estimatedRevenueVo; return estimatedRevenueVo;
} }
private void fillRegularOrderRevenue(String firstOrder, BigDecimal finalAmount, private void fillRegularOrderRevenue(String firstOrder, BigDecimal orderAmount,
PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) {
if ("1".equals(firstOrder)) { if ("1".equals(firstOrder)) {
vo.setRevenueRatio(levelInfo.getFirstRegularRatio()); vo.setRevenueRatio(levelInfo.getFirstRegularRatio());
vo.setRevenueAmount(scaleAmount(finalAmount, levelInfo.getFirstRegularRatio())); vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getFirstRegularRatio()));
} else { } else {
vo.setRevenueRatio(levelInfo.getNotFirstRegularRatio()); vo.setRevenueRatio(levelInfo.getNotFirstRegularRatio());
vo.setRevenueAmount(scaleAmount(finalAmount, levelInfo.getNotFirstRegularRatio())); vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getNotFirstRegularRatio()));
} }
} }
private void fillRandomOrderRevenue(String firstOrder, BigDecimal finalAmount, private void fillRandomOrderRevenue(String firstOrder, BigDecimal orderAmount,
PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) {
if ("1".equals(firstOrder)) { if ("1".equals(firstOrder)) {
vo.setRevenueRatio(levelInfo.getFirstRandomRadio()); vo.setRevenueRatio(levelInfo.getFirstRandomRadio());
vo.setRevenueAmount(scaleAmount(finalAmount, levelInfo.getFirstRandomRadio())); vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getFirstRandomRadio()));
} else { } else {
vo.setRevenueRatio(levelInfo.getNotFirstRandomRadio()); vo.setRevenueRatio(levelInfo.getNotFirstRandomRadio());
vo.setRevenueAmount(scaleAmount(finalAmount, levelInfo.getNotFirstRandomRadio())); vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getNotFirstRandomRadio()));
} }
} }
private void fillRewardOrderRevenue(String firstOrder, BigDecimal finalAmount, private void fillRewardOrderRevenue(String firstOrder, BigDecimal orderAmount,
PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) {
if ("1".equals(firstOrder)) { if ("1".equals(firstOrder)) {
vo.setRevenueRatio(levelInfo.getFirstRewardRatio()); vo.setRevenueRatio(levelInfo.getFirstRewardRatio());
vo.setRevenueAmount(scaleAmount(finalAmount, levelInfo.getFirstRewardRatio())); vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getFirstRewardRatio()));
} else { } else {
vo.setRevenueRatio(levelInfo.getNotFirstRewardRatio()); vo.setRevenueRatio(levelInfo.getNotFirstRewardRatio());
vo.setRevenueAmount(scaleAmount(finalAmount, levelInfo.getNotFirstRewardRatio())); vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getNotFirstRewardRatio()));
} }
} }

View File

@@ -5,8 +5,8 @@ import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.enums.CouponObtainChannel;
import com.starry.admin.modules.shop.module.vo.*; import com.starry.admin.modules.shop.module.vo.*;
import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
import com.starry.admin.modules.shop.service.IPlayCouponInfoService; import com.starry.admin.modules.shop.service.IPlayCouponInfoService;
import com.starry.common.annotation.Log; import com.starry.common.annotation.Log;
import com.starry.common.enums.BusinessType; import com.starry.common.enums.BusinessType;
@@ -38,9 +38,6 @@ public class PlayCouponInfoController {
@Resource @Resource
private IPlayCouponInfoService playCouponInfoService; private IPlayCouponInfoService playCouponInfoService;
@Resource
private IPlayCouponDetailsService playCouponDetailsService;
@Resource @Resource
private IPlayCustomUserInfoService playCustomUserInfoService; private IPlayCustomUserInfoService playCustomUserInfoService;
@@ -85,9 +82,9 @@ public class PlayCouponInfoController {
@PostMapping("/sendCoupon") @PostMapping("/sendCoupon")
public R sendCoupon(@ApiParam(value = "优惠券发放信息", required = true) @Validated @RequestBody PlayCouponInfoSendVo vo) { public R sendCoupon(@ApiParam(value = "优惠券发放信息", required = true) @Validated @RequestBody PlayCouponInfoSendVo vo) {
PlayCustomUserInfoEntity customUserInfo = playCustomUserInfoService.selectById(vo.getCustomId()); PlayCustomUserInfoEntity customUserInfo = playCustomUserInfoService.selectById(vo.getCustomId());
for (Integer i = 0; i < vo.getSendNumber(); i++) { for (int i = 0; i < vo.getSendNumber(); i++) {
playCouponDetailsService.create(customUserInfo.getId(), customUserInfo.getNickname(), playCouponInfoService.claimCouponForCustom(
customUserInfo.getLevelId(), vo.getId(), "2", "1"); vo.getId(), customUserInfo, CouponObtainChannel.BACKEND_GRANT);
} }
return R.ok(); return R.ok();
} }
@@ -131,6 +128,31 @@ public class PlayCouponInfoController {
if ("2".equals(vo.getValidityPeriodType()) && vo.getEffectiveDay() <= 0) { if ("2".equals(vo.getValidityPeriodType()) && vo.getEffectiveDay() <= 0) {
throw new CustomException("用券时间输入错误有效天数不能为0"); throw new CustomException("用券时间输入错误有效天数不能为0");
} }
boolean unlimitedInventory = "1".equals(vo.getCouponQuantityType());
if (unlimitedInventory) {
entity.setCouponQuantity(null);
entity.setRemainingQuantity(null);
} else {
Integer totalQuantity = vo.getCouponQuantity();
if (totalQuantity == null || totalQuantity <= 0) {
throw new CustomException("优惠券总数必须大于0");
}
entity.setCouponQuantity(totalQuantity);
entity.setRemainingQuantity(totalQuantity);
}
boolean unlimitedPerUser = "1".equals(vo.getClerkObtainedMaxQuantityType());
if (unlimitedPerUser) {
entity.setClerkObtainedMaxQuantity(null);
} else {
Integer perUserLimit = vo.getClerkObtainedMaxQuantity();
if (perUserLimit == null || perUserLimit <= 0) {
throw new CustomException("每人限领数量必须大于0");
}
entity.setClerkObtainedMaxQuantity(perUserLimit);
}
String discountContent = ""; String discountContent = "";
if (BigDecimal.ZERO.compareTo(vo.getUseMinAmount()) == 0) { if (BigDecimal.ZERO.compareTo(vo.getUseMinAmount()) == 0) {
discountContent = "无门槛"; discountContent = "无门槛";

View File

@@ -2,13 +2,33 @@ package com.starry.admin.modules.shop.mapper;
import com.github.yulichang.base.MPJBaseMapper; import com.github.yulichang.base.MPJBaseMapper;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/** /**
* 优惠券信息Mapper接口 * 优惠券信息Mapper接口
*
* @author admin
* @since 2024-07-04
*/ */
@Mapper
public interface PlayCouponInfoMapper extends MPJBaseMapper<PlayCouponInfoEntity> { public interface PlayCouponInfoMapper extends MPJBaseMapper<PlayCouponInfoEntity> {
@Update({
"UPDATE play_coupon_info",
"SET issued_quantity = IFNULL(issued_quantity, 0) + 1,",
" remaining_quantity = CASE",
" WHEN remaining_quantity IS NULL THEN NULL",
" WHEN remaining_quantity < 0 THEN remaining_quantity",
" ELSE remaining_quantity - 1",
" END",
"WHERE id = #{id}",
" AND (remaining_quantity IS NULL OR remaining_quantity <> 0)"
})
int increaseIssuedAndConsumeRemaining(String id);
@Select({
"SELECT * FROM play_coupon_info",
"WHERE id = #{id}",
"FOR UPDATE"
})
PlayCouponInfoEntity selectByIdForUpdate(String id);
} }

View File

@@ -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);
}
}

View File

@@ -0,0 +1,27 @@
package com.starry.admin.modules.shop.module.enums;
import java.util.Arrays;
import java.util.Optional;
import lombok.Getter;
/**
* 优惠券领取渠道
*/
@Getter
public enum CouponObtainChannel {
SELF_SERVICE("1"),
BACKEND_GRANT("2"),
REFUND_RETURN("3"),
SHARE("4"),
LOTTERY("5");
private final String code;
CouponObtainChannel(String code) {
this.code = code;
}
public static Optional<CouponObtainChannel> fromCode(String code) {
return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst();
}
}

View File

@@ -0,0 +1,24 @@
package com.starry.admin.modules.shop.module.enums;
import java.util.Arrays;
import java.util.Optional;
import lombok.Getter;
/**
* 优惠券上下架状态
*/
@Getter
public enum CouponOnlineState {
OFFLINE("0"),
ONLINE("1");
private final String code;
CouponOnlineState(String code) {
this.code = code;
}
public static Optional<CouponOnlineState> fromCode(String code) {
return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst();
}
}

View File

@@ -0,0 +1,25 @@
package com.starry.admin.modules.shop.module.enums;
import java.util.Arrays;
import java.util.Optional;
import lombok.Getter;
/**
* 优惠券有效期类型
*/
@Getter
public enum CouponValidityPeriodType {
PERMANENT("0"),
FIXED_RANGE("1"),
RELATIVE_DAYS("2");
private final String code;
CouponValidityPeriodType(String code) {
this.code = code;
}
public static Optional<CouponValidityPeriodType> fromCode(String code) {
return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst();
}
}

View File

@@ -0,0 +1,38 @@
package com.starry.admin.modules.shop.module.support;
/**
* Encapsulates how coupon inventory should behave (finite vs. unlimited).
*/
public enum CouponInventoryPolicy {
UNLIMITED,
FINITE;
public boolean isUnlimited() {
return this == UNLIMITED;
}
public boolean consumesStock() {
return this == FINITE;
}
public boolean hasStock(Integer remainingQuantity) {
if (isUnlimited()) {
return true;
}
return remainingQuantity == null || remainingQuantity > 0;
}
public static CouponInventoryPolicy resolve(Integer totalQuantity, Integer remainingQuantity) {
if (isUnlimitedSentinel(totalQuantity) || isUnlimitedSentinel(remainingQuantity)) {
return UNLIMITED;
}
if (totalQuantity == null && remainingQuantity == null) {
return UNLIMITED;
}
return FINITE;
}
private static boolean isUnlimitedSentinel(Integer value) {
return value != null && value < 0;
}
}

View File

@@ -82,6 +82,11 @@ public interface IPlayCouponDetailsService extends IService<PlayCouponDetailsEnt
*/ */
boolean create(PlayCouponDetailsEntity playCouponDetails); boolean create(PlayCouponDetailsEntity playCouponDetails);
/**
* 统计指定用户的有效优惠券数量(排除已回收)。
*/
long countActiveByCustomAndCoupon(String couponId, String customId);
/** /**
* 修改优惠券详情 * 修改优惠券详情
* *

View File

@@ -4,10 +4,13 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.enums.CouponObtainChannel;
import com.starry.admin.modules.shop.module.vo.PlayCouponInfoQueryVo; import com.starry.admin.modules.shop.module.vo.PlayCouponInfoQueryVo;
import com.starry.admin.modules.shop.module.vo.PlayCouponInfoReturnVo; import com.starry.admin.modules.shop.module.vo.PlayCouponInfoReturnVo;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional;
/** /**
* 优惠券信息Service接口 * 优惠券信息Service接口
@@ -35,6 +38,27 @@ public interface IPlayCouponInfoService extends IService<PlayCouponInfoEntity> {
String getCouponReasonForUnavailableUse(PlayCouponInfoEntity couponInfo, String placeType, String commodityId, String getCouponReasonForUnavailableUse(PlayCouponInfoEntity couponInfo, String placeType, String commodityId,
Integer commodityQuantity, BigDecimal price); Integer commodityQuantity, BigDecimal price);
/**
* 获取优惠券在状态层面的不可用原因(如已使用、已过期等)。
*
* @param couponInfo
* 优惠券信息
* @param useState
* 优惠券详情使用状态
* @param obtainingTime
* 优惠券领取时间
* @return 不可用原因
*/
Optional<String> getCouponDetailRestrictionReason(
PlayCouponInfoEntity couponInfo,
String useState,
LocalDateTime obtainingTime);
/**
* 顾客领取优惠券,校验库存与限领并生成明细。
*/
void claimCouponForCustom(String couponId, PlayCustomUserInfoEntity customUserInfo, CouponObtainChannel obtainChannel);
/** /**
* 获取优惠券不可领取的原因 * 获取优惠券不可领取的原因
* *

View File

@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.yulichang.wrapper.MPJLambdaWrapper; import com.github.yulichang.wrapper.MPJLambdaWrapper;
import com.starry.admin.modules.shop.mapper.PlayCouponDetailsMapper; import com.starry.admin.modules.shop.mapper.PlayCouponDetailsMapper;
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.PlayCouponDetailsEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.vo.PlayCouponDetailsQueryVo; import com.starry.admin.modules.shop.module.vo.PlayCouponDetailsQueryVo;
@@ -123,6 +124,15 @@ public class PlayCouponDetailsServiceImpl extends ServiceImpl<PlayCouponDetailsM
return save(playCouponDetails); return save(playCouponDetails);
} }
@Override
public long countActiveByCustomAndCoupon(String couponId, String customId) {
return lambdaQuery()
.eq(PlayCouponDetailsEntity::getCouponId, couponId)
.eq(PlayCouponDetailsEntity::getCustomId, customId)
.eq(PlayCouponDetailsEntity::getUseState, CouponUseState.UNUSED.getCode())
.count();
}
@Override @Override
public List<PlayCouponDetailsReturnVo> selectByCustomId(String customId) { public List<PlayCouponDetailsReturnVo> selectByCustomId(String customId) {
MPJLambdaWrapper<PlayCouponDetailsEntity> lambdaWrapper = new MPJLambdaWrapper<>(); MPJLambdaWrapper<PlayCouponDetailsEntity> lambdaWrapper = new MPJLambdaWrapper<>();

View File

@@ -9,17 +9,26 @@ import com.github.yulichang.wrapper.MPJLambdaWrapper;
import com.starry.admin.common.exception.CustomException; import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.shop.mapper.PlayCouponInfoMapper; import com.starry.admin.modules.shop.mapper.PlayCouponInfoMapper;
import com.starry.admin.modules.shop.module.constant.CouponUseState;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.enums.CouponClaimConditionType; import com.starry.admin.modules.shop.module.enums.CouponClaimConditionType;
import com.starry.admin.modules.shop.module.enums.CouponObtainChannel;
import com.starry.admin.modules.shop.module.enums.CouponOnlineState;
import com.starry.admin.modules.shop.module.enums.CouponValidityPeriodType;
import com.starry.admin.modules.shop.module.support.CouponInventoryPolicy;
import com.starry.admin.modules.shop.module.vo.PlayCouponInfoQueryVo; import com.starry.admin.modules.shop.module.vo.PlayCouponInfoQueryVo;
import com.starry.admin.modules.shop.module.vo.PlayCouponInfoReturnVo; import com.starry.admin.modules.shop.module.vo.PlayCouponInfoReturnVo;
import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
import com.starry.admin.modules.shop.service.IPlayCouponInfoService; import com.starry.admin.modules.shop.service.IPlayCouponInfoService;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Optional;
import javax.annotation.Resource; import javax.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** /**
* 优惠券信息Service业务层处理 * 优惠券信息Service业务层处理
@@ -34,6 +43,9 @@ public class PlayCouponInfoServiceImpl extends ServiceImpl<PlayCouponInfoMapper,
@Resource @Resource
private PlayCouponInfoMapper playCouponInfoMapper; private PlayCouponInfoMapper playCouponInfoMapper;
@Resource
private IPlayCouponDetailsService playCouponDetailsService;
/** /**
* 获取优惠券不可用的原因 * 获取优惠券不可用的原因
* *
@@ -71,6 +83,108 @@ public class PlayCouponInfoServiceImpl extends ServiceImpl<PlayCouponInfoMapper,
return ""; return "";
} }
@Override
public Optional<String> getCouponDetailRestrictionReason(
PlayCouponInfoEntity couponInfo,
String useState,
LocalDateTime obtainingTime) {
if (couponInfo == null) {
return Optional.of("优惠券不存在");
}
if (StrUtil.isBlank(useState)) {
return Optional.of("优惠券状态异常");
}
CouponUseState state;
try {
state = CouponUseState.fromCode(useState);
} catch (IllegalArgumentException ex) {
return Optional.of("优惠券状态异常");
}
if (state != CouponUseState.UNUSED) {
return Optional.of("优惠券已使用");
}
Optional<CouponOnlineState> onlineStateOpt = CouponOnlineState.fromCode(couponInfo.getCouponOnLineState());
if (onlineStateOpt.isEmpty()) {
return Optional.of("优惠券状态异常");
}
if (onlineStateOpt.get() != CouponOnlineState.ONLINE) {
return Optional.of("优惠券已下架");
}
LocalDateTime now = LocalDateTime.now();
CouponValidityPeriodType validityType = CouponValidityPeriodType
.fromCode(couponInfo.getValidityPeriodType())
.orElse(CouponValidityPeriodType.PERMANENT);
if (validityType == CouponValidityPeriodType.FIXED_RANGE) {
if (couponInfo.getProductiveTime() != null && now.isBefore(couponInfo.getProductiveTime())) {
return Optional.of("优惠券未生效");
}
if (couponInfo.getExpirationTime() != null && now.isAfter(couponInfo.getExpirationTime())) {
return Optional.of("优惠券已过期");
}
} else if (validityType == CouponValidityPeriodType.RELATIVE_DAYS) {
Integer effectiveDay = couponInfo.getEffectiveDay();
if (obtainingTime == null || effectiveDay == null || effectiveDay <= 0) {
return Optional.of("优惠券有效期未配置");
}
LocalDateTime expiration = obtainingTime.plusDays(effectiveDay);
if (now.isAfter(expiration)) {
return Optional.of("优惠券已过期");
}
}
return Optional.empty();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void claimCouponForCustom(
String couponId,
PlayCustomUserInfoEntity customUserInfo,
CouponObtainChannel obtainChannel) {
if (customUserInfo == null) {
throw new CustomException("顾客信息不存在");
}
CouponObtainChannel channel = obtainChannel != null ? obtainChannel : CouponObtainChannel.SELF_SERVICE;
PlayCouponInfoEntity couponInfo = playCouponInfoMapper.selectByIdForUpdate(couponId);
if (couponInfo == null) {
throw new CustomException("优惠券不存在");
}
Optional<CouponOnlineState> onlineState = CouponOnlineState.fromCode(couponInfo.getCouponOnLineState());
if (onlineState.isPresent() && onlineState.get() != CouponOnlineState.ONLINE) {
throw new CustomException("优惠券已下架");
}
CouponInventoryPolicy inventoryPolicy = CouponInventoryPolicy
.resolve(couponInfo.getCouponQuantity(), couponInfo.getRemainingQuantity());
if (!inventoryPolicy.hasStock(couponInfo.getRemainingQuantity())) {
throw new CustomException("优惠券已领完");
}
Integer maxQuantityPerUser = couponInfo.getClerkObtainedMaxQuantity();
if (maxQuantityPerUser != null && maxQuantityPerUser > 0) {
long claimed = playCouponDetailsService.countActiveByCustomAndCoupon(couponId, customUserInfo.getId());
if (claimed >= maxQuantityPerUser) {
throw new CustomException("优惠券已达到领取上限");
}
}
int affected = playCouponInfoMapper.increaseIssuedAndConsumeRemaining(couponId);
if (inventoryPolicy.consumesStock() && affected == 0) {
throw new CustomException("优惠券已领完");
}
playCouponDetailsService.create(
customUserInfo.getId(),
customUserInfo.getNickname(),
customUserInfo.getLevelId(),
couponId,
channel.getCode(),
CouponUseState.UNUSED.getCode());
}
/** /**
* 获取优惠券不可领取的原因 * 获取优惠券不可领取的原因
* *
@@ -85,6 +199,22 @@ public class PlayCouponInfoServiceImpl extends ServiceImpl<PlayCouponInfoMapper,
@Override @Override
public String getReasonForNotObtainingCoupons(PlayCouponInfoEntity entity, public String getReasonForNotObtainingCoupons(PlayCouponInfoEntity entity,
PlayCustomUserInfoEntity customUserInfo) { PlayCustomUserInfoEntity customUserInfo) {
CouponInventoryPolicy inventoryPolicy = CouponInventoryPolicy
.resolve(entity.getCouponQuantity(), entity.getRemainingQuantity());
if (!inventoryPolicy.hasStock(entity.getRemainingQuantity())) {
return "优惠券已领完";
}
if (customUserInfo != null && playCouponDetailsService != null) {
Integer perUserLimit = entity.getClerkObtainedMaxQuantity();
if (perUserLimit != null && perUserLimit > 0) {
long claimed = playCouponDetailsService.countActiveByCustomAndCoupon(
entity.getId(),
customUserInfo.getId());
if (claimed >= perUserLimit) {
return "优惠券已达到领取上限";
}
}
}
CouponClaimConditionType type = CouponClaimConditionType.of(entity.getClaimConditionType()); CouponClaimConditionType type = CouponClaimConditionType.of(entity.getClaimConditionType());
switch (type) { switch (type) {
case ALL: case ALL:

View File

@@ -10,6 +10,10 @@ import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.enums.CouponClaimConditionType; import com.starry.admin.modules.shop.module.enums.CouponClaimConditionType;
import com.starry.admin.modules.shop.module.enums.CouponObtainChannel;
import com.starry.admin.modules.shop.module.enums.CouponOnlineState;
import com.starry.admin.modules.shop.module.enums.CouponValidityPeriodType;
import com.starry.admin.modules.shop.module.support.CouponInventoryPolicy;
import com.starry.admin.modules.shop.module.vo.PlayCommodityInfoVo; import com.starry.admin.modules.shop.module.vo.PlayCommodityInfoVo;
import com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo; import com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo;
import com.starry.admin.modules.shop.service.IPlayCommodityInfoService; import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
@@ -26,10 +30,12 @@ import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam; import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse; import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses; import io.swagger.annotations.ApiResponses;
import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import javax.annotation.Resource; import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
@@ -71,10 +77,13 @@ public class WxCouponController {
PlayCouponInfoEntity entity = couponInfoService.selectPlayCouponInfoById(id); PlayCouponInfoEntity entity = couponInfoService.selectPlayCouponInfoById(id);
String msg = couponInfoService.getReasonForNotObtainingCoupons(entity, customUserInfo); String msg = couponInfoService.getReasonForNotObtainingCoupons(entity, customUserInfo);
boolean success = StrUtil.isBlank(msg); boolean success = StrUtil.isBlank(msg);
// 优惠券领取验证通过,发放优惠券
if (success) { if (success) {
couponDetailsService.create(customUserInfo.getId(), customUserInfo.getNickname(), try {
customUserInfo.getLevelId(), entity.getId(), "1", "1"); couponInfoService.claimCouponForCustom(entity.getId(), customUserInfo, CouponObtainChannel.SELF_SERVICE);
} catch (CustomException ex) {
success = false;
msg = ex.getMessage();
}
} }
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("success", success); result.put("success", success);
@@ -99,11 +108,39 @@ public class WxCouponController {
List<PlayCouponDetailsReturnVo> obtainedCoupons = couponDetailsService List<PlayCouponDetailsReturnVo> obtainedCoupons = couponDetailsService
.selectByCustomId(currentCustomId); .selectByCustomId(currentCustomId);
List<PlayCouponInfoEntity> couponInfoEntities = couponInfoService.queryAll(); List<PlayCouponInfoEntity> couponInfoEntities = couponInfoService.queryAll();
LocalDateTime now = LocalDateTime.now();
List<WxCouponReceiveReturnVo> returnVos = new ArrayList<>(couponInfoEntities.size()); List<WxCouponReceiveReturnVo> returnVos = new ArrayList<>(couponInfoEntities.size());
for (PlayCouponInfoEntity couponInfoEntity : couponInfoEntities) { for (PlayCouponInfoEntity couponInfoEntity : couponInfoEntities) {
if ("0".equals(couponInfoEntity.getCouponOnLineState())) { boolean online = CouponOnlineState.fromCode(couponInfoEntity.getCouponOnLineState())
.map(state -> state == CouponOnlineState.ONLINE)
.orElse(false);
if (!online) {
continue; continue;
} }
CouponInventoryPolicy inventoryPolicy = CouponInventoryPolicy
.resolve(couponInfoEntity.getCouponQuantity(), couponInfoEntity.getRemainingQuantity());
if (!inventoryPolicy.hasStock(couponInfoEntity.getRemainingQuantity())) {
continue;
}
CouponValidityPeriodType validityType = CouponValidityPeriodType
.fromCode(couponInfoEntity.getValidityPeriodType())
.orElse(CouponValidityPeriodType.PERMANENT);
if (validityType == CouponValidityPeriodType.FIXED_RANGE) {
if (couponInfoEntity.getProductiveTime() != null
&& now.isBefore(couponInfoEntity.getProductiveTime())) {
continue;
}
if (couponInfoEntity.getExpirationTime() != null
&& now.isAfter(couponInfoEntity.getExpirationTime())) {
continue;
}
} else if (validityType == CouponValidityPeriodType.RELATIVE_DAYS) {
Integer effectiveDay = couponInfoEntity.getEffectiveDay();
if (effectiveDay == null || effectiveDay <= 0) {
continue;
}
// RELATIVE_DAYS 按领取时间计算,这里仅进行配置有效性校验,实际过期由详情校验处理
}
// 领取白名单:非白名单用户不可见 // 领取白名单:非白名单用户不可见
if (CouponClaimConditionType.WHITELIST.code().equals(couponInfoEntity.getClaimConditionType())) { if (CouponClaimConditionType.WHITELIST.code().equals(couponInfoEntity.getClaimConditionType())) {
List<String> wl = couponInfoEntity.getCustomWhitelist(); List<String> wl = couponInfoEntity.getCustomWhitelist();
@@ -151,6 +188,14 @@ public class WxCouponController {
try { try {
PlayCouponInfoEntity couponInfo = couponInfoService.selectPlayCouponInfoById(couponDetails.getCouponId()); PlayCouponInfoEntity couponInfo = couponInfoService.selectPlayCouponInfoById(couponDetails.getCouponId());
WxCouponOrderReturnVo wxCouponReturnVo = ConvertUtil.entityToVo(couponDetails, WxCouponOrderReturnVo.class); WxCouponOrderReturnVo wxCouponReturnVo = ConvertUtil.entityToVo(couponDetails, WxCouponOrderReturnVo.class);
Optional<String> statusReason = couponInfoService.getCouponDetailRestrictionReason(
couponInfo,
couponDetails.getUseState(),
couponDetails.getObtainingTime());
if (statusReason.isPresent()) {
// 已使用、过期、下架等状态的优惠券不展示
continue;
}
String couponReasonForUnavailableUse = couponInfoService.getCouponReasonForUnavailableUse(couponInfo, String couponReasonForUnavailableUse = couponInfoService.getCouponReasonForUnavailableUse(couponInfo,
vo.getPlaceType(), vo.getCommodityId(), vo.getCommodityQuantity(), vo.getPlaceType(), vo.getCommodityId(), vo.getCommodityQuantity(),
commodityInfo.getCommodityPrice()); commodityInfo.getCommodityPrice());

View File

@@ -7,7 +7,6 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.starry.admin.common.aspect.CustomUserLogin; import com.starry.admin.common.aspect.CustomUserLogin;
import com.starry.admin.common.conf.ThreadLocalRequestDetail; import com.starry.admin.common.conf.ThreadLocalRequestDetail;
import com.starry.admin.common.exception.CustomException; 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.common.task.OverdueOrderHandlerTask;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService; 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.constant.OrderConstant.RewardType;
import com.starry.admin.modules.order.module.dto.CommodityInfo; 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.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.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements; import com.starry.admin.modules.order.module.dto.RandomOrderRequirements;
import com.starry.admin.modules.order.module.entity.PlayOrderComplaintInfoEntity; 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.IPlayOrderComplaintInfoService;
import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService; import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; 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.module.vo.PlayCommodityInfoVo;
import com.starry.admin.modules.shop.service.*; import com.starry.admin.modules.shop.service.*;
import com.starry.admin.modules.weichat.entity.*; import com.starry.admin.modules.weichat.entity.*;
@@ -63,12 +62,13 @@ import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses; import io.swagger.annotations.ApiResponses;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Objects;
import javax.annotation.Resource; import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -230,10 +230,6 @@ public class WxCustomController {
public R rewardToOrder(@ApiParam(value = "打赏信息", required = true) @Validated @RequestBody PlayOrderInfoRewardAdd vo) { public R rewardToOrder(@ApiParam(value = "打赏信息", required = true) @Validated @RequestBody PlayOrderInfoRewardAdd vo) {
MoneyUtils.verificationTypeIsNormal(vo.getMoney()); MoneyUtils.verificationTypeIsNormal(vo.getMoney());
String userId = ThreadLocalRequestDetail.getCustomUserInfo().getId(); 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(); String orderId = IdUtils.getUuid();
// 记录订单信息 // 记录订单信息
OrderCreationContext orderRequest = OrderCreationContext.builder() OrderCreationContext orderRequest = OrderCreationContext.builder()
@@ -265,9 +261,10 @@ public class WxCustomController {
.weiChatCode(vo.getWeiChatCode()) .weiChatCode(vo.getWeiChatCode())
.remark(vo.getRemark()) .remark(vo.getRemark())
.build(); .build();
orderLifecycleService.initiateOrder(orderRequest); orderLifecycleService.placeOrder(OrderPlacementCommand.builder()
// 顾客减少余额 .orderContext(orderRequest)
customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(new BigDecimal(vo.getMoney())), "1", "打赏", new BigDecimal(vo.getMoney()), BigDecimal.ZERO, orderId); .balanceOperationAction("打赏")
.build());
return R.ok("成功"); return R.ok("成功");
} }
@@ -301,34 +298,6 @@ public class WxCustomController {
PlayCommodityInfoVo commodityInfo = playCommodityInfoService.queryCommodityInfo(vo.getCommodityId(), clerkUserInfo.getLevelId()); 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 orderId = IdUtils.getUuid();
String orderNo = playOrderInfoService.getOrderNo(); String orderNo = playOrderInfoService.getOrderNo();
// 记录订单信息 // 记录订单信息
@@ -351,21 +320,39 @@ public class WxCustomController {
.commodityNumber(String.valueOf(vo.getCommodityQuantity())) .commodityNumber(String.valueOf(vo.getCommodityQuantity()))
.build()) .build())
.paymentInfo(PaymentInfo.builder() .paymentInfo(PaymentInfo.builder()
.orderMoney(money) .orderMoney(BigDecimal.ZERO)
.finalAmount(money) .finalAmount(BigDecimal.ZERO)
.discountAmount(BigDecimal.ZERO) .discountAmount(BigDecimal.ZERO)
.couponIds(vo.getCouponIds()) .couponIds(Collections.emptyList())
.build()) .build())
.purchaserBy(customId) .purchaserBy(customId)
.acceptBy(clerkUserInfo.getId()) .acceptBy(clerkUserInfo.getId())
.weiChatCode(vo.getWeiChatCode()) .weiChatCode(vo.getWeiChatCode())
.remark(vo.getRemark()) .remark(vo.getRemark())
.build(); .build();
orderLifecycleService.initiateOrder(orderRequest); List<String> couponIds = CollectionUtils.isEmpty(vo.getCouponIds()) ? Collections.emptyList() : vo.getCouponIds();
// 顾客减少余额 OrderPlacementCommand command = OrderPlacementCommand.builder()
customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(money), "1", "下单-指定单", money, BigDecimal.ZERO, orderId); .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("成功"); return R.ok("成功");
} }
@@ -380,12 +367,7 @@ public class WxCustomController {
public R randomToOrdder(@Validated @RequestBody PlayOrderInfoRandomAdd vo) { public R randomToOrdder(@Validated @RequestBody PlayOrderInfoRandomAdd vo) {
// 验证当前顾客余额是否充足 // 验证当前顾客余额是否充足
String customId = ThreadLocalRequestDetail.getCustomUserInfo().getId(); String customId = ThreadLocalRequestDetail.getCustomUserInfo().getId();
PlayCustomUserInfoEntity customUserInfo = customUserInfoService.selectById(customId);
PlayCommodityInfoVo commodityInfo = playCommodityInfoService.queryCommodityInfo(vo.getCommodityId(), vo.getLevelId()); 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 orderId = IdUtils.getUuid();
String orderNo = playOrderInfoService.getOrderNo(); String orderNo = playOrderInfoService.getOrderNo();
// 记录订单信息 // 记录订单信息
@@ -408,10 +390,10 @@ public class WxCustomController {
.commodityNumber(String.valueOf(vo.getCommodityQuantity())) .commodityNumber(String.valueOf(vo.getCommodityQuantity()))
.build()) .build())
.paymentInfo(PaymentInfo.builder() .paymentInfo(PaymentInfo.builder()
.orderMoney(money) .orderMoney(BigDecimal.ZERO)
.finalAmount(money) .finalAmount(BigDecimal.ZERO)
.discountAmount(BigDecimal.ZERO) .discountAmount(BigDecimal.ZERO)
.couponIds(vo.getCouponIds()) .couponIds(Collections.emptyList())
.build()) .build())
.purchaserBy(customId) .purchaserBy(customId)
.weiChatCode(vo.getWeiChatCode()) .weiChatCode(vo.getWeiChatCode())
@@ -422,13 +404,28 @@ public class WxCustomController {
.excludeHistory(vo.getExcludeHistory()) .excludeHistory(vo.getExcludeHistory())
.build()) .build())
.build(); .build();
orderLifecycleService.initiateOrder(orderRequest); List<String> couponIds = CollectionUtils.isEmpty(vo.getCouponIds()) ? Collections.emptyList() : vo.getCouponIds();
// 顾客减少余额 OrderPlacementCommand command = OrderPlacementCommand.builder()
customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(money), "1", "下单-随机单", money, BigDecimal.ZERO, orderId); .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<PlayClerkUserInfoEntity> clerkList = clerkUserInfoService.list(Wrappers.lambdaQuery(PlayClerkUserInfoEntity.class).isNotNull(PlayClerkUserInfoEntity::getOpenid).eq(PlayClerkUserInfoEntity::getClerkState, "1") List<PlayClerkUserInfoEntity> clerkList = clerkUserInfoService.list(Wrappers.lambdaQuery(PlayClerkUserInfoEntity.class)
.eq(PlayClerkUserInfoEntity::getOnlineState, "1").eq(PlayClerkUserInfoEntity::getSex, vo.getSex())); .isNotNull(PlayClerkUserInfoEntity::getOpenid)
wxCustomMpService.sendCreateOrderMessageBatch(clerkList, orderNo, money.toString(), commodityInfo.getCommodityName(),orderId); .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()); overdueOrderHandlerTask.enqueue(orderId + "_" + SecurityUtils.getTenantId());
// 下单成功后,先根据用户条件进行随机分配 // 下单成功后,先根据用户条件进行随机分配

View File

@@ -2,7 +2,6 @@ package com.starry.admin.modules.weichat.service;
import com.starry.admin.common.conf.ThreadLocalRequestDetail; import com.starry.admin.common.conf.ThreadLocalRequestDetail;
import com.starry.admin.common.exception.CustomException; 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.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomGiftInfoService; import com.starry.admin.modules.custom.service.IPlayCustomGiftInfoService;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; 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.constant.OrderConstant.RewardType;
import com.starry.admin.modules.order.module.dto.CommodityInfo; 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.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.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.IOrderLifecycleService;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
@@ -79,27 +81,21 @@ public class WxGiftOrderService {
throw new CustomException("礼物金额必须大于0"); 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(); String orderId = IdUtils.getUuid();
OrderCreationContext orderRequest = buildOrderCreationContext(orderId, request, sessionUser.getId(), OrderCreationContext orderRequest = buildOrderCreationContext(orderId, request, sessionUser.getId(),
giftInfo, totalAmount); giftInfo, totalAmount);
orderLifecycleService.initiateOrder(orderRequest); OrderPlacementResult result = orderLifecycleService.placeOrder(OrderPlacementCommand.builder()
.orderContext(orderRequest)
BigDecimal newBalance = currentBalance.subtract(totalAmount); .balanceOperationAction("赠送礼物")
customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), currentBalance, newBalance, "1", .build());
"赠送礼物", totalAmount, BigDecimal.ZERO, orderId); PlayOrderInfoEntity order = result.getOrder();
String tenantId = customUserInfo.getTenantId(); String tenantId = customUserInfo.getTenantId();
long delta = giftQuantity; long delta = giftQuantity;
playCustomGiftInfoService.incrementGiftCount(customUserInfo.getId(), request.getGiftId(), tenantId, delta); playCustomGiftInfoService.incrementGiftCount(customUserInfo.getId(), request.getGiftId(), tenantId, delta);
playClerkGiftInfoService.incrementGiftCount(request.getClerkId(), request.getGiftId(), tenantId, delta); playClerkGiftInfoService.incrementGiftCount(request.getClerkId(), request.getGiftId(), tenantId, delta);
return orderId; return order.getId();
} }
private OrderCreationContext buildOrderCreationContext(String orderId, PlayOrderInfoGiftAdd request, private OrderCreationContext buildOrderCreationContext(String orderId, PlayOrderInfoGiftAdd request,

View File

@@ -162,9 +162,9 @@ public class EarningsBackfillServiceImpl implements IEarningsBackfillService {
return null; return null;
} }
BigDecimal amount = order.getEstimatedRevenue() != null ? order.getEstimatedRevenue() BigDecimal amount = order.getEstimatedRevenue() != null ? order.getEstimatedRevenue()
: (order.getFinalAmount() != null ? order.getFinalAmount() : BigDecimal.ZERO); : (order.getOrderMoney() != null ? order.getOrderMoney() : BigDecimal.ZERO);
if (amount.compareTo(BigDecimal.ZERO) <= 0) { if (amount.compareTo(BigDecimal.ZERO) < 0) {
addWarning(warnings, "订单 " + order.getId() + " 收益金额为0,跳过"); addWarning(warnings, "订单 " + order.getId() + " 收益金额为负数,跳过");
return null; return null;
} }
if (order.getOrderEndTime() == null) { if (order.getOrderEndTime() == null) {

View File

@@ -26,10 +26,10 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
@Override @Override
public void createFromOrder(PlayOrderInfoEntity orderInfo) { public void createFromOrder(PlayOrderInfoEntity orderInfo) {
if (orderInfo == null || orderInfo.getAcceptBy() == null) return; if (orderInfo == null || orderInfo.getAcceptBy() == null) return;
// amount from estimatedRevenue; fallback to finalAmount if null // amount from estimatedRevenue; fallback to orderMoney if null
BigDecimal amount = orderInfo.getEstimatedRevenue() != null ? orderInfo.getEstimatedRevenue() BigDecimal amount = orderInfo.getEstimatedRevenue() != null ? orderInfo.getEstimatedRevenue()
: (orderInfo.getFinalAmount() != null ? orderInfo.getFinalAmount() : BigDecimal.ZERO); : (orderInfo.getOrderMoney() != null ? orderInfo.getOrderMoney() : BigDecimal.ZERO);
if (amount.compareTo(BigDecimal.ZERO) <= 0) return; if (amount.compareTo(BigDecimal.ZERO) < 0) return;
int freezeHours = freezePolicyService int freezeHours = freezePolicyService
.resolveFreezeHours(orderInfo.getTenantId(), orderInfo.getAcceptBy()); .resolveFreezeHours(orderInfo.getTenantId(), orderInfo.getAcceptBy());

View File

@@ -1,22 +1,33 @@
package com.starry.admin.modules.shop.service; package com.starry.admin.modules.shop.service;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import com.starry.admin.common.conf.ThreadLocalRequestDetail; import com.starry.admin.common.conf.ThreadLocalRequestDetail;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.shop.module.constant.CouponUseState;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.enums.CouponOnlineState;
import com.starry.admin.modules.shop.module.enums.CouponValidityPeriodType;
import com.starry.admin.modules.shop.module.vo.PlayCommodityInfoVo;
import com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo;
import com.starry.admin.modules.shop.service.IPlayCommodityInfoService; import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
import com.starry.admin.modules.shop.service.IPlayCouponDetailsService; import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
import com.starry.admin.modules.shop.service.IPlayCouponInfoService; import com.starry.admin.modules.shop.service.IPlayCouponInfoService;
import com.starry.admin.modules.shop.service.impl.PlayCouponInfoServiceImpl; import com.starry.admin.modules.shop.service.impl.PlayCouponInfoServiceImpl;
import com.starry.admin.modules.weichat.controller.WxCouponController; import com.starry.admin.modules.weichat.controller.WxCouponController;
import com.starry.admin.modules.weichat.entity.WxCouponOrderQueryVo;
import com.starry.admin.modules.weichat.entity.WxCouponOrderReturnVo;
import com.starry.admin.modules.weichat.entity.WxCouponReceiveReturnVo; import com.starry.admin.modules.weichat.entity.WxCouponReceiveReturnVo;
import com.starry.common.result.R; import com.starry.common.result.R;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -97,4 +108,131 @@ public class CouponWhitelistTest {
assertEquals(1, list.size(), "非白名单券应被过滤不可见"); assertEquals(1, list.size(), "非白名单券应被过滤不可见");
assertEquals("c1", list.get(0).getId()); assertEquals("c1", list.get(0).getId());
} }
@Test
@DisplayName("列表过滤-状态:下架或过期优惠券不可见")
void testQueryAllFiltersInvalidStatus() {
PlayCustomUserInfoEntity current = new PlayCustomUserInfoEntity();
current.setId("uid-2");
ThreadLocalRequestDetail.setRequestDetail(current);
PlayCouponInfoEntity offline = new PlayCouponInfoEntity();
offline.setId("offline");
offline.setCouponOnLineState(CouponOnlineState.OFFLINE.getCode());
PlayCouponInfoEntity expired = new PlayCouponInfoEntity();
expired.setId("expired");
expired.setCouponOnLineState(CouponOnlineState.ONLINE.getCode());
expired.setValidityPeriodType(CouponValidityPeriodType.FIXED_RANGE.getCode());
expired.setProductiveTime(LocalDateTime.now().minusDays(5));
expired.setExpirationTime(LocalDateTime.now().minusDays(1));
expired.setRemainingQuantity(5);
PlayCouponInfoEntity active = new PlayCouponInfoEntity();
active.setId("active");
active.setCouponOnLineState(CouponOnlineState.ONLINE.getCode());
active.setValidityPeriodType(CouponValidityPeriodType.PERMANENT.getCode());
active.setRemainingQuantity(10);
when(couponDetailsService.selectByCustomId("uid-2")).thenReturn(Collections.emptyList());
when(couponInfoService.queryAll()).thenReturn(Arrays.asList(offline, expired, active));
R resp = wxCouponController.queryAll();
@SuppressWarnings("unchecked")
List<WxCouponReceiveReturnVo> list = (List<WxCouponReceiveReturnVo>) resp.getData();
assertNotNull(list);
assertEquals(1, list.size(), "仅应展示在线有效的优惠券");
assertEquals("active", list.get(0).getId());
}
@Test
@DisplayName("列表过滤-库存:无限库存的优惠券可见")
void testQueryAllShowsUnlimitedInventory() {
PlayCustomUserInfoEntity current = new PlayCustomUserInfoEntity();
current.setId("uid-4");
ThreadLocalRequestDetail.setRequestDetail(current);
PlayCouponInfoEntity unlimited = new PlayCouponInfoEntity();
unlimited.setId("coupon-unlimited");
unlimited.setCouponOnLineState(CouponOnlineState.ONLINE.getCode());
unlimited.setValidityPeriodType(CouponValidityPeriodType.PERMANENT.getCode());
unlimited.setRemainingQuantity(-1);
unlimited.setCouponQuantity(-1);
when(couponDetailsService.selectByCustomId("uid-4")).thenReturn(Collections.emptyList());
when(couponInfoService.queryAll()).thenReturn(Collections.singletonList(unlimited));
R resp = wxCouponController.queryAll();
@SuppressWarnings("unchecked")
List<WxCouponReceiveReturnVo> list = (List<WxCouponReceiveReturnVo>) resp.getData();
assertNotNull(list);
assertEquals(1, list.size(), "无限库存优惠券不应被过滤");
assertEquals("coupon-unlimited", list.get(0).getId());
}
@Test
@DisplayName("下单查询:存在限制原因的优惠券被过滤")
void testQueryByOrderFiltersRestrictedCoupons() {
PlayCustomUserInfoEntity current = new PlayCustomUserInfoEntity();
current.setId("uid-3");
ThreadLocalRequestDetail.setRequestDetail(current);
PlayCouponDetailsReturnVo restricted = new PlayCouponDetailsReturnVo();
restricted.setId("detail-1");
restricted.setCouponId("coupon-restrict");
restricted.setUseState(CouponUseState.USED.getCode());
restricted.setObtainingTime(LocalDateTime.now().minusDays(3));
PlayCouponDetailsReturnVo usable = new PlayCouponDetailsReturnVo();
usable.setId("detail-2");
usable.setCouponId("coupon-usable");
usable.setUseState(CouponUseState.UNUSED.getCode());
usable.setObtainingTime(LocalDateTime.now().minusDays(1));
when(couponDetailsService.selectByCustomId("uid-3"))
.thenReturn(Arrays.asList(restricted, usable));
PlayCouponInfoEntity restrictedInfo = new PlayCouponInfoEntity();
restrictedInfo.setId("coupon-restrict");
restrictedInfo.setCouponOnLineState(CouponOnlineState.ONLINE.getCode());
restrictedInfo.setValidityPeriodType(CouponValidityPeriodType.PERMANENT.getCode());
PlayCouponInfoEntity usableInfo = new PlayCouponInfoEntity();
usableInfo.setId("coupon-usable");
usableInfo.setCouponOnLineState(CouponOnlineState.ONLINE.getCode());
usableInfo.setValidityPeriodType(CouponValidityPeriodType.PERMANENT.getCode());
when(couponInfoService.selectPlayCouponInfoById("coupon-restrict")).thenReturn(restrictedInfo);
when(couponInfoService.selectPlayCouponInfoById("coupon-usable")).thenReturn(usableInfo);
when(couponInfoService.getCouponDetailRestrictionReason(eq(restrictedInfo), eq(restricted.getUseState()), any(LocalDateTime.class)))
.thenReturn(Optional.of("优惠券已使用"));
when(couponInfoService.getCouponDetailRestrictionReason(eq(usableInfo), eq(usable.getUseState()), any(LocalDateTime.class)))
.thenReturn(Optional.empty());
when(couponInfoService.getCouponReasonForUnavailableUse(eq(usableInfo), anyString(), anyString(), anyInt(), any()))
.thenReturn("");
PlayCommodityInfoVo commodityInfo = new PlayCommodityInfoVo();
commodityInfo.setCommodityId("cmd-1");
commodityInfo.setCommodityPrice(BigDecimal.valueOf(50));
when(commodityInfoService.queryCommodityInfo("cmd-1", "level-1")).thenReturn(commodityInfo);
WxCouponOrderQueryVo vo = new WxCouponOrderQueryVo();
vo.setCommodityId("cmd-1");
vo.setLevelId("level-1");
vo.setClerkId("");
vo.setPlaceType("0");
vo.setCommodityQuantity(1);
R resp = wxCouponController.queryByOrder(vo);
@SuppressWarnings("unchecked")
List<WxCouponOrderReturnVo> list = (List<WxCouponOrderReturnVo>) resp.getData();
assertNotNull(list);
assertEquals(1, list.size(), "受限优惠券应被过滤");
assertEquals("detail-2", list.get(0).getId());
assertEquals("1", list.get(0).getAvailable());
verify(couponInfoService).getCouponDetailRestrictionReason(eq(restrictedInfo), eq(restricted.getUseState()), any(LocalDateTime.class));
}
} }

View File

@@ -0,0 +1,106 @@
package com.starry.admin.modules.shop.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.shop.mapper.PlayCouponInfoMapper;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.enums.CouponObtainChannel;
import com.starry.admin.modules.shop.module.enums.CouponOnlineState;
import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class PlayCouponInfoServiceImplTest {
@Mock
private PlayCouponInfoMapper playCouponInfoMapper;
@Mock
private IPlayCouponDetailsService playCouponDetailsService;
@InjectMocks
private PlayCouponInfoServiceImpl service;
private PlayCouponInfoEntity couponInfo;
private PlayCustomUserInfoEntity customer;
@BeforeEach
void setUp() {
couponInfo = new PlayCouponInfoEntity();
couponInfo.setId("coupon-1");
couponInfo.setCouponOnLineState(CouponOnlineState.ONLINE.getCode());
couponInfo.setCouponQuantity(5);
couponInfo.setRemainingQuantity(5);
customer = new PlayCustomUserInfoEntity();
customer.setId("custom-1");
customer.setNickname("nick");
customer.setLevelId("level-1");
}
@Test
void claimCouponForCustom_consumesInventoryAndCreatesDetail() {
couponInfo.setClerkObtainedMaxQuantity(2);
when(playCouponInfoMapper.selectByIdForUpdate("coupon-1")).thenReturn(couponInfo);
when(playCouponDetailsService.countActiveByCustomAndCoupon("coupon-1", "custom-1"))
.thenReturn(0L);
when(playCouponInfoMapper.increaseIssuedAndConsumeRemaining("coupon-1")).thenReturn(1);
service.claimCouponForCustom("coupon-1", customer, CouponObtainChannel.SELF_SERVICE);
verify(playCouponInfoMapper).increaseIssuedAndConsumeRemaining("coupon-1");
verify(playCouponDetailsService)
.create(eq("custom-1"), eq(customer.getNickname()), eq(customer.getLevelId()),
eq("coupon-1"), eq(CouponObtainChannel.SELF_SERVICE.getCode()), anyString());
}
@Test
void claimCouponForCustom_throwsWhenLimitReached() {
couponInfo.setClerkObtainedMaxQuantity(1);
when(playCouponInfoMapper.selectByIdForUpdate("coupon-1")).thenReturn(couponInfo);
when(playCouponDetailsService.countActiveByCustomAndCoupon("coupon-1", "custom-1"))
.thenReturn(1L);
CustomException ex = assertThrows(CustomException.class, () ->
service.claimCouponForCustom("coupon-1", customer, CouponObtainChannel.SELF_SERVICE));
assertEquals("优惠券已达到领取上限", ex.getMessage());
}
@Test
void claimCouponForCustom_throwsWhenDepleted() {
couponInfo.setRemainingQuantity(0);
when(playCouponInfoMapper.selectByIdForUpdate("coupon-1")).thenReturn(couponInfo);
CustomException ex = assertThrows(CustomException.class, () ->
service.claimCouponForCustom("coupon-1", customer, CouponObtainChannel.SELF_SERVICE));
assertEquals("优惠券已领完", ex.getMessage());
}
@Test
void claimCouponForCustom_allowsUnlimitedInventory() {
couponInfo.setRemainingQuantity(-1);
couponInfo.setCouponQuantity(-1);
when(playCouponInfoMapper.selectByIdForUpdate("coupon-1")).thenReturn(couponInfo);
when(playCouponInfoMapper.increaseIssuedAndConsumeRemaining("coupon-1")).thenReturn(1);
service.claimCouponForCustom("coupon-1", customer, CouponObtainChannel.BACKEND_GRANT);
verify(playCouponInfoMapper).increaseIssuedAndConsumeRemaining("coupon-1");
verify(playCouponDetailsService)
.create(eq("custom-1"), eq(customer.getNickname()), eq(customer.getLevelId()),
eq("coupon-1"), eq(CouponObtainChannel.BACKEND_GRANT.getCode()), anyString());
}
}

View File

@@ -0,0 +1,57 @@
package com.starry.admin.modules.withdraw.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class EarningsBackfillServiceImplTest {
private final EarningsBackfillServiceImpl service = new EarningsBackfillServiceImpl();
@SuppressWarnings("unchecked")
private BigDecimal invokeResolveAmount(PlayOrderInfoEntity order, List<String> warnings) throws Exception {
Method method = EarningsBackfillServiceImpl.class.getDeclaredMethod("resolveAmount", PlayOrderInfoEntity.class,
List.class);
method.setAccessible(true);
return (BigDecimal) method.invoke(service, order, warnings);
}
@Test
void resolveAmount_acceptsZeroAmountWithoutWarnings() throws Exception {
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-zero");
order.setAcceptBy("clerk-001");
order.setOrderEndTime(LocalDateTime.now());
order.setOrderMoney(BigDecimal.ZERO);
List<String> warnings = new ArrayList<>();
BigDecimal amount = invokeResolveAmount(order, warnings);
assertEquals(BigDecimal.ZERO, amount);
assertTrue(warnings.isEmpty());
}
@Test
void resolveAmount_rejectsNegativeAmount() throws Exception {
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-negative");
order.setAcceptBy("clerk-001");
order.setOrderEndTime(LocalDateTime.now());
order.setEstimatedRevenue(BigDecimal.valueOf(-5));
List<String> warnings = new ArrayList<>();
BigDecimal amount = invokeResolveAmount(order, warnings);
assertNull(amount);
assertEquals(1, warnings.size());
assertTrue(warnings.get(0).contains("order-negative"));
}
}

View File

@@ -0,0 +1,71 @@
package com.starry.admin.modules.withdraw.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
import com.starry.admin.modules.withdraw.service.IFreezePolicyService;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class EarningsServiceImplTest {
@InjectMocks
private EarningsServiceImpl earningsService;
@Mock
private EarningsLineMapper baseMapper;
@Mock
private IFreezePolicyService freezePolicyService;
private PlayOrderInfoEntity baselineOrder() {
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-001");
order.setTenantId("tenant-001");
order.setAcceptBy("clerk-001");
order.setOrderEndTime(LocalDateTime.now());
order.setOrderSettlementState("0");
return order;
}
@Test
void createFromOrder_usesOrderAmountWhenEstimatedMissing_evenIfZero() {
PlayOrderInfoEntity order = baselineOrder();
order.setEstimatedRevenue(null);
order.setOrderMoney(BigDecimal.ZERO);
when(freezePolicyService.resolveFreezeHours("tenant-001", "clerk-001")).thenReturn(0);
when(baseMapper.insert(any())).thenReturn(1);
earningsService.createFromOrder(order);
ArgumentCaptor<EarningsLineEntity> captor = ArgumentCaptor.forClass(EarningsLineEntity.class);
verify(baseMapper).insert(captor.capture());
assertEquals(BigDecimal.ZERO, captor.getValue().getAmount());
}
@Test
void createFromOrder_skipsWhenAmountNegative() {
PlayOrderInfoEntity order = baselineOrder();
order.setEstimatedRevenue(BigDecimal.valueOf(-10));
order.setOrderMoney(BigDecimal.valueOf(20));
earningsService.createFromOrder(order);
verify(baseMapper, never()).insert(any());
}
}