重构收益与优惠券逻辑:统一使用 orderAmount,新增优惠券状态校验与过滤

- IPlayOrderInfoService/PlayOrderInfoServiceImpl/ClerkRevenueCalculator 将参数 finalAmount 更名为 orderAmount,避免语义混淆
- 预计收益计算兼容 null 与 0,防止 NPE 并明确边界
- 结算/回填:EarningsServiceImpl、EarningsBackfillServiceImpl 改为使用 orderMoney 兜底;0 金额允许,负数跳过
- 新增枚举:CouponOnlineState、CouponValidityPeriodType 用于券上下架与有效期判定
- IPlayCouponInfoService/Impl 增加 getCouponDetailRestrictionReason,支持已使用/过期/下架等状态校验
- WxCouponController 列表与下单查询增加状态/有效期/库存/白名单过滤逻辑
- OrderLifecycleServiceImpl 下单时校验优惠券状态,预计收益入参从 finalAmount 调整为 orderMoney
- 完善单元测试:订单生命周期、优惠券过滤、收益生成与回填等覆盖
This commit is contained in:
irving
2025-10-30 22:07:37 -04:00
parent e29c5db276
commit 48bdc9af33
15 changed files with 494 additions and 38 deletions

View File

@@ -52,13 +52,13 @@ public interface IPlayOrderInfoService extends IService<PlayOrderInfoEntity> {
* @param croupIds 优惠券ID列表
* @param placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单)
* @param firstOrder 是否是首单【0不是1是】
* @param finalAmount 订单支付金额
* @param orderAmount 订单金额
* @return com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo
* @author admin
* @since 2024/7/18 16:39
**/
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 placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单)
* @param firstOrder 是否是首单【0不是1是】
* @param finalAmount 订单支付金额
* @param orderAmount 订单金额
* @return math.BigDecimal 店员预计收入
* @author admin
* @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

@@ -226,6 +226,13 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
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(),
@@ -617,7 +624,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
context.getPaymentInfo().getCouponIds(),
context.getPlaceType().getCode(),
entity.getFirstOrder(),
context.getPaymentInfo().getFinalAmount());
context.getPaymentInfo().getOrderMoney());
entity.setEstimatedRevenue(estimatedRevenue.getRevenueAmount());
entity.setEstimatedRevenueRatio(estimatedRevenue.getRevenueRatio());
}

View File

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

View File

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

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

@@ -7,7 +7,9 @@ import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.vo.PlayCouponInfoQueryVo;
import com.starry.admin.modules.shop.module.vo.PlayCouponInfoReturnVo;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 优惠券信息Service接口
@@ -35,6 +37,22 @@ public interface IPlayCouponInfoService extends IService<PlayCouponInfoEntity> {
String getCouponReasonForUnavailableUse(PlayCouponInfoEntity couponInfo, String placeType, String commodityId,
Integer commodityQuantity, BigDecimal price);
/**
* 获取优惠券在状态层面的不可用原因(如已使用、已过期等)。
*
* @param couponInfo
* 优惠券信息
* @param useState
* 优惠券详情使用状态
* @param obtainingTime
* 优惠券领取时间
* @return 不可用原因
*/
Optional<String> getCouponDetailRestrictionReason(
PlayCouponInfoEntity couponInfo,
String useState,
LocalDateTime obtainingTime);
/**
* 获取优惠券不可领取的原因
*

View File

@@ -9,15 +9,20 @@ import com.github.yulichang.wrapper.MPJLambdaWrapper;
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.constant.CouponUseState;
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.CouponOnlineState;
import com.starry.admin.modules.shop.module.enums.CouponValidityPeriodType;
import com.starry.admin.modules.shop.module.vo.PlayCouponInfoQueryVo;
import com.starry.admin.modules.shop.module.vo.PlayCouponInfoReturnVo;
import com.starry.admin.modules.shop.service.IPlayCouponInfoService;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -71,6 +76,60 @@ public class PlayCouponInfoServiceImpl extends ServiceImpl<PlayCouponInfoMapper,
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();
}
/**
* 获取优惠券不可领取的原因
*

View File

@@ -10,6 +10,8 @@ import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
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.enums.CouponClaimConditionType;
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;
@@ -26,10 +28,12 @@ import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
@@ -99,11 +103,37 @@ public class WxCouponController {
List<PlayCouponDetailsReturnVo> obtainedCoupons = couponDetailsService
.selectByCustomId(currentCustomId);
List<PlayCouponInfoEntity> couponInfoEntities = couponInfoService.queryAll();
LocalDateTime now = LocalDateTime.now();
List<WxCouponReceiveReturnVo> returnVos = new ArrayList<>(couponInfoEntities.size());
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;
}
if (couponInfoEntity.getRemainingQuantity() != null && couponInfoEntity.getRemainingQuantity() <= 0) {
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())) {
List<String> wl = couponInfoEntity.getCustomWhitelist();
@@ -151,6 +181,14 @@ public class WxCouponController {
try {
PlayCouponInfoEntity couponInfo = couponInfoService.selectPlayCouponInfoById(couponDetails.getCouponId());
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,
vo.getPlaceType(), vo.getCommodityId(), vo.getCommodityQuantity(),
commodityInfo.getCommodityPrice());

View File

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

View File

@@ -26,10 +26,10 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
@Override
public void createFromOrder(PlayOrderInfoEntity orderInfo) {
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()
: (orderInfo.getFinalAmount() != null ? orderInfo.getFinalAmount() : BigDecimal.ZERO);
if (amount.compareTo(BigDecimal.ZERO) <= 0) return;
: (orderInfo.getOrderMoney() != null ? orderInfo.getOrderMoney() : BigDecimal.ZERO);
if (amount.compareTo(BigDecimal.ZERO) < 0) return;
int freezeHours = freezePolicyService
.resolveFreezeHours(orderInfo.getTenantId(), orderInfo.getAcceptBy());