diff --git a/order-lifecycle-refactor.md b/order-lifecycle-refactor.md new file mode 100644 index 0000000..781841c --- /dev/null +++ b/order-lifecycle-refactor.md @@ -0,0 +1,14 @@ +## Done +- Refactored order creation to run exclusively through `OrderLifecycleServiceImpl.initiateOrder`, replacing the old `createOrderInfo` entry point across controllers and services. +- Replaced nullable operator strings in completion workflow with the enum-based `OrderCompletionContext.Actor` (linked to `OrderConstant.OrderActor`) and updated all call sites/tests. +- Introduced `OrderCreationContext` (renamed from `OrderCreationRequest`) with explicit `creatorActor`/`creatorId`, eliminating heuristic creator inference. +- Added lifecycle logging: new entity/mapper for `play_order_log_info`, migration V12 to extend the table, and log writes for CREATE/COMPLETE/REFUND operations including operator metadata. +- Integrated `orderLifecycleService` logging into reward/commodity/random order endpoints and gift service; tests adjusted to cover new API. +- Placeholder utility methods (`resolveCompletionActor` etc.) plural. +- Restored `handleOrderCompletion` with atomic consumption increments and ensured reward订单的首次完成也会落消费&日志,仅首次执行。 +- Order log `operation_type` 细分至 `CREATE_*`/`COMPLETE_*`/`REFUND_*`,可区分手动、系统、强制触发等场景。 +- V12 migration 已确认顺序;如需回滚,可直接删除新增字段并恢复 `oper_type` 列名(同一脚本可倒序执行)。 +- 针对并发与幂等关键路径补充了单测(完成/退款多次触发、自动补发通知等)。 + +## Todo +- None for now。 diff --git a/play-admin/src/main/java/com/starry/admin/modules/custom/mapper/PlayCustomUserInfoMapper.java b/play-admin/src/main/java/com/starry/admin/modules/custom/mapper/PlayCustomUserInfoMapper.java index 3b1da7a..ca4372d 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/custom/mapper/PlayCustomUserInfoMapper.java +++ b/play-admin/src/main/java/com/starry/admin/modules/custom/mapper/PlayCustomUserInfoMapper.java @@ -2,6 +2,10 @@ package com.starry.admin.modules.custom.mapper; import com.github.yulichang.base.MPJBaseMapper; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; +import java.math.BigDecimal; +import java.util.Date; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; /** * 顾客Mapper接口 @@ -11,4 +15,18 @@ import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; */ public interface PlayCustomUserInfoMapper extends MPJBaseMapper { + @Update({ + "" + }) + int applyOrderCompletionUpdate(@Param("userId") String userId, + @Param("consumptionDelta") BigDecimal consumptionDelta, + @Param("completionTime") Date completionTime, + @Param("weiChatCode") String weiChatCode); } diff --git a/play-admin/src/main/java/com/starry/admin/modules/custom/service/IPlayCustomLevelInfoService.java b/play-admin/src/main/java/com/starry/admin/modules/custom/service/IPlayCustomLevelInfoService.java index ffc527f..55c252a 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/custom/service/IPlayCustomLevelInfoService.java +++ b/play-admin/src/main/java/com/starry/admin/modules/custom/service/IPlayCustomLevelInfoService.java @@ -3,6 +3,7 @@ package com.starry.admin.modules.custom.service; import com.baomidou.mybatisplus.extension.service.IService; import com.starry.admin.modules.custom.module.entity.PlayCustomLevelInfoEntity; import com.starry.admin.modules.system.module.entity.SysTenantEntity; +import java.math.BigDecimal; import java.util.List; /** @@ -100,4 +101,13 @@ public interface IPlayCustomLevelInfoService extends IService lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.orderByAsc(PlayCustomLevelInfoEntity::getLevel); + if (StrUtil.isNotBlank(tenantId)) { + lambdaQueryWrapper.eq(PlayCustomLevelInfoEntity::getTenantId, tenantId); + } + List levels = this.baseMapper.selectList(lambdaQueryWrapper); + if (levels == null || levels.isEmpty()) { + return null; + } + PlayCustomLevelInfoEntity matched = null; + for (PlayCustomLevelInfoEntity level : levels) { + BigDecimal threshold = parseConsumptionAmount(level.getConsumptionAmount()); + if (consumption.compareTo(threshold) >= 0) { + matched = level; + } else { + break; + } + } + return matched != null ? matched : levels.get(0); + } + + private BigDecimal parseConsumptionAmount(String rawValue) { + if (StrUtil.isBlank(rawValue)) { + return BigDecimal.ZERO; + } + try { + return new BigDecimal(rawValue.trim()); + } catch (NumberFormatException ex) { + return BigDecimal.ZERO; + } + } } diff --git a/play-admin/src/main/java/com/starry/admin/modules/custom/service/impl/PlayCustomUserInfoServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/custom/service/impl/PlayCustomUserInfoServiceImpl.java index 186667f..f9aed24 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/custom/service/impl/PlayCustomUserInfoServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/custom/service/impl/PlayCustomUserInfoServiceImpl.java @@ -16,6 +16,7 @@ import com.starry.admin.modules.custom.module.vo.PlayCustomRankingQueryVo; import com.starry.admin.modules.custom.module.vo.PlayCustomRankingReturnVo; import com.starry.admin.modules.custom.module.vo.PlayCustomUserQueryVo; import com.starry.admin.modules.custom.module.vo.PlayCustomUserReturnVo; +import com.starry.admin.modules.custom.service.IPlayCustomLevelInfoService; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.service.impl.PlayOrderInfoServiceImpl; @@ -23,6 +24,8 @@ import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService import com.starry.common.utils.IdUtils; import java.math.BigDecimal; import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.*; import java.util.stream.Collectors; import javax.annotation.Resource; @@ -49,6 +52,9 @@ public class PlayCustomUserInfoServiceImpl extends ServiceImpl lambdaQueryWrapper = new LambdaQueryWrapper<>(); @@ -369,6 +375,50 @@ public class PlayCustomUserInfoServiceImpl extends ServiceImpllambdaUpdate() + .eq(PlayCustomUserInfoEntity::getId, latest.getId()) + .set(PlayCustomUserInfoEntity::getLevelId, matchedLevel.getId())); + log.info("顾客{}消费累计达到{},自动调整等级为{}", latest.getId(), latest.getAccumulatedConsumptionAmount(), matchedLevel.getName()); + } + } + + private Date resolveCompletionTime(LocalDateTime orderEndTime) { + LocalDateTime time = orderEndTime != null ? orderEndTime : LocalDateTime.now(); + return Date.from(time.atZone(ZoneId.systemDefault()).toInstant()); + } + @Override public void saveOrderInfo(PlayOrderInfoEntity entity) { String id = entity.getPurchaserBy(); diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/mapper/PlayOrderLogInfoMapper.java b/play-admin/src/main/java/com/starry/admin/modules/order/mapper/PlayOrderLogInfoMapper.java new file mode 100644 index 0000000..c2f9fbc --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/mapper/PlayOrderLogInfoMapper.java @@ -0,0 +1,9 @@ +package com.starry.admin.modules.order.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.starry.admin.modules.order.module.entity.PlayOrderLogInfoEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PlayOrderLogInfoMapper extends BaseMapper { +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java index 971eb32..fe94f3a 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java @@ -207,6 +207,14 @@ public class OrderConstant { } } + @Getter + public enum OrderActor { + CUSTOMER, + CLERK, + ADMIN, + SYSTEM; + } + // 排除历史记录常量 public static final String EXCLUDE_HISTORY_NO = "0"; public static final String EXCLUDE_HISTORY_YES = "1"; @@ -320,6 +328,93 @@ public class OrderConstant { } } + @Getter + public enum YesNoFlag { + NO("0"), + YES("1"); + + private final String code; + + YesNoFlag(String code) { + this.code = code; + } + + public static YesNoFlag fromCode(String code) { + for (YesNoFlag flag : values()) { + if (flag.code.equals(code)) { + return flag; + } + } + throw new IllegalArgumentException("Unknown yes/no flag code: " + code); + } + } + + @Getter + public enum OrderSettlementState { + NOT_SETTLED("0"), + SETTLED("1"); + + private final String code; + + OrderSettlementState(String code) { + this.code = code; + } + + public static OrderSettlementState fromCode(String code) { + for (OrderSettlementState state : values()) { + if (state.code.equals(code)) { + return state; + } + } + throw new IllegalArgumentException("Unknown settlement state code: " + code); + } + } + + @Getter + public enum OrdersExpiredState { + NOT_EXPIRED("0"), + EXPIRED("1"); + + private final String code; + + OrdersExpiredState(String code) { + this.code = code; + } + + public static OrdersExpiredState fromCode(String code) { + for (OrdersExpiredState state : values()) { + if (state.code.equals(code)) { + return state; + } + } + throw new IllegalArgumentException("Unknown orders expired state code: " + code); + } + } + + @Getter + public enum PayMethod { + BALANCE("0"), + WECHAT("1"), + ALIPAY("2"), + BANK_CARD("3"), + OTHER("4"); + + private final String code; + + PayMethod(String code) { + this.code = code; + } + + public static PayMethod fromCode(String code) { + for (PayMethod method : values()) { + if (method.code.equals(code)) { + return method; + } + } + throw new IllegalArgumentException("Unknown pay method code: " + code); + } + } + @Getter public enum OrderTriggerSource { diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCompletionContext.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCompletionContext.java index 1ffa67f..b955b39 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCompletionContext.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCompletionContext.java @@ -1,5 +1,6 @@ package com.starry.admin.modules.order.module.dto; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource; import java.util.Objects; import lombok.Data; @@ -10,13 +11,11 @@ import org.springframework.lang.Nullable; */ @Data public class OrderCompletionContext { - /** - * 操作人类型(0:顾客;1:店员;2:管理员),可为空用于系统任务。 - */ - @Nullable - private String operatorType; - /** 操作人ID,可为空用于系统任务。 */ + /** 操作人类型,系统任务使用 SYSTEM。 */ + private OrderActor operatorActor = OrderActor.SYSTEM; + + /** 操作人ID,SYSTEM 时允许为空。 */ @Nullable private String operatorId; @@ -30,26 +29,29 @@ public class OrderCompletionContext { /** 是否强制发送完成通知。 */ private boolean forceNotify; - public static OrderCompletionContext of(@Nullable String operatorType, @Nullable String operatorId, OrderTriggerSource triggerSource) { + public static OrderCompletionContext of(OrderActor actor, @Nullable String operatorId, OrderTriggerSource triggerSource) { + Objects.requireNonNull(actor, "operator actor cannot be null"); Objects.requireNonNull(triggerSource, "triggerSource cannot be null"); + if (actor != OrderActor.SYSTEM && operatorId == null) { + throw new IllegalArgumentException("operatorId is required for actor " + actor); + } OrderCompletionContext context = new OrderCompletionContext(); - context.setOperatorType(operatorType); + context.setOperatorActor(actor); context.setOperatorId(operatorId); context.setTriggerSource(triggerSource); return context; } - public static OrderCompletionContext of(@Nullable String operatorType, @Nullable String operatorId, OrderTriggerSource triggerSource, @Nullable String comment) { - OrderCompletionContext context = of(operatorType, operatorId, triggerSource); - return context.withComment(comment); + public static OrderCompletionContext of(OrderActor actor, @Nullable String operatorId, OrderTriggerSource triggerSource, @Nullable String comment) { + return of(actor, operatorId, triggerSource).withComment(comment); } public static OrderCompletionContext scheduler(@Nullable String comment) { - return of(null, null, OrderTriggerSource.SCHEDULER, comment); + return of(OrderActor.SYSTEM, null, OrderTriggerSource.SCHEDULER, comment); } public static OrderCompletionContext system(OrderTriggerSource triggerSource, @Nullable String comment) { - return of(null, null, triggerSource, comment); + return of(OrderActor.SYSTEM, null, triggerSource, comment); } public OrderCompletionContext withForceNotify(boolean forceNotify) { diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCreationRequest.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCreationContext.java similarity index 61% rename from play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCreationRequest.java rename to play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCreationContext.java index 3493fc7..6d2ba93 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCreationRequest.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCreationContext.java @@ -1,127 +1,77 @@ package com.starry.admin.modules.order.module.dto; import com.starry.admin.modules.order.module.constant.OrderConstant; -import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor; import javax.validation.Valid; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import lombok.Builder; import lombok.Data; +import org.springframework.lang.Nullable; /** - * 订单创建请求对象 - 使用Builder模式替换20+参数的方法 - * - * @author admin + * 订单创建上下文,用于聚合下单所需的全部信息。 */ @Data @Builder -public class OrderCreationRequest { +public class OrderCreationContext { - /** - * 订单ID - */ @NotBlank(message = "订单ID不能为空") private String orderId; - /** - * 订单编号 - */ @NotBlank(message = "订单编号不能为空") private String orderNo; - /** - * 订单状态 - */ @NotNull(message = "订单状态不能为空") private OrderConstant.OrderStatus orderStatus; - /** - * 订单类型 - */ @NotNull(message = "订单类型不能为空") private OrderConstant.OrderType orderType; - /** - * 下单类型 - */ @NotNull(message = "下单类型不能为空") private OrderConstant.PlaceType placeType; - /** - * 打赏类型(0:余额;1:礼物) - */ - private RewardType rewardType; + private OrderConstant.RewardType rewardType; - /** - * 是否是首单 - */ private boolean isFirstOrder; - /** - * 商品信息 - */ @Valid @NotNull(message = "商品信息不能为空") private CommodityInfo commodityInfo; - /** - * 支付信息 - */ @Valid @NotNull(message = "支付信息不能为空") private PaymentInfo paymentInfo; - /** - * 下单人 - */ @NotBlank(message = "下单人不能为空") private String purchaserBy; - /** - * 接单人(可选) - */ private String acceptBy; - /** - * 微信号码 - */ private String weiChatCode; - /** - * 订单备注 - */ private String remark; - /** - * 随机单要求(仅随机单时需要) - */ private RandomOrderRequirements randomOrderRequirements; - /** - * 获取首单标识字符串(兼容现有系统) - */ + @Builder.Default + private OrderActor creatorActor = OrderActor.SYSTEM; + + @Nullable + private String creatorId; + public String getFirstOrderString() { return isFirstOrder ? "1" : "0"; } - /** - * 验证随机单要求 - */ public boolean isValidForRandomOrder() { - return placeType == OrderConstant.PlaceType.RANDOM - && randomOrderRequirements != null; + return placeType == OrderConstant.PlaceType.RANDOM && randomOrderRequirements != null; } - /** - * 是否为打赏单 - */ public boolean isRewardOrder() { return placeType == OrderConstant.PlaceType.REWARD; } - /** - * 是否为指定单 - */ public boolean isSpecifiedOrder() { return placeType == OrderConstant.PlaceType.SPECIFIED; } diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderInfoEntity.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderInfoEntity.java index d006a15..bfcfdda 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderInfoEntity.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderInfoEntity.java @@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import com.fasterxml.jackson.annotation.JsonFormat; import com.starry.admin.common.conf.StringTypeHandler; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus; +import com.starry.admin.modules.order.service.impl.OrderLifecycleServiceImpl; import com.starry.common.domain.BaseEntity; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -329,4 +331,31 @@ public class PlayOrderInfoEntity extends BaseEntity { public void setOrderEndTime(LocalDateTime orderEndTime) { this.orderEndTime = orderEndTime; } + + /** + * 更新订单生命周期状态,仅允许订单生命周期服务进行访问。 + * + * @param token 授权令牌 + * @param orderStatus 新的订单状态 + * @param orderEndTime 可选的订单结束时间 + */ + public void updateOrderStatus(OrderLifecycleServiceImpl.LifecycleToken token, OrderStatus orderStatus) { + if (token == null) { + throw new IllegalStateException("Lifecycle token is required"); + } + if (orderStatus == null) { + throw new IllegalArgumentException("orderStatus cannot be null"); + } + this.orderStatus = orderStatus.getCode(); + } + + public void updateOrderEndTime(OrderLifecycleServiceImpl.LifecycleToken token, LocalDateTime orderEndTime) { + if (token == null) { + throw new IllegalStateException("Lifecycle token is required"); + } + if (orderEndTime == null) { + throw new IllegalArgumentException("orderEndTime cannot be null"); + } + this.orderEndTime = orderEndTime; + } } diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderLogInfoEntity.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderLogInfoEntity.java new file mode 100644 index 0000000..8d14bb7 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderLogInfoEntity.java @@ -0,0 +1,39 @@ +package com.starry.admin.modules.order.module.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.starry.common.domain.BaseEntity; +import java.time.LocalDateTime; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +/** + * 订单生命周期日志实体。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("play_order_log_info") +public class PlayOrderLogInfoEntity extends BaseEntity { + + @TableId + private String id; + + private String tenantId; + + private String orderId; + + private String operationType; + + private String operatorType; + + private String operatorId; + + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime operTime; + + private String remark; + +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/IOrderLifecycleService.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/IOrderLifecycleService.java index e399f55..f4288f4 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/service/IOrderLifecycleService.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/IOrderLifecycleService.java @@ -1,10 +1,14 @@ package com.starry.admin.modules.order.service; import com.starry.admin.modules.order.module.dto.OrderCompletionContext; +import com.starry.admin.modules.order.module.dto.OrderCreationContext; import com.starry.admin.modules.order.module.dto.OrderRefundContext; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; public interface IOrderLifecycleService { + PlayOrderInfoEntity initiateOrder(OrderCreationContext context); + void completeOrder(String orderId, OrderCompletionContext context); void refundOrder(OrderRefundContext context); diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/IPlayOrderInfoService.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/IPlayOrderInfoService.java index 17a315c..1bcbd89 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/service/IPlayOrderInfoService.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/IPlayOrderInfoService.java @@ -45,46 +45,6 @@ public interface IPlayOrderInfoService extends IService { * @author admin * @since 2024/6/3 10:53 **/ - void createOrderInfo(OrderCreationRequest request); - - /** - * 新增订单信息 - 旧版本方法(已废弃,建议使用OrderCreationRequest) - * - * @param orderId 订单ID - * @param orderNo 订单编号 - * @param orderState 订单状态【0:已下单(待接单);1:已接单(待开始);2:已开始(服务中);3;已完成:4:已取消】 - * @param orderType 订单类型【-1:退款订单;0:充值订单;1:提现订单;2:普通订单】 - * @param placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单) - * @param rewardType (0:余额;1:礼物) - * @param firstOrder 是否是首单【0:不是,1:是】 - * @param commodityId 商品ID - * @param commodityType 商品类型[0:礼物,1:服务] - * @param commodityPrice 商品属性-商品单价 - * @param serviceDuration 商品属性-服务时长 - * @param commodityName 商品名称 - * @param commodityNumber 商品数量 - * @param orderMoney 订单金额 - * @param finalAmount 订单最终金额(支付金额) - * @param discountAmount 优惠金额 - * @param purchaserBy 下单人 - * @param acceptBy 接单人 - * @param weiChatCode 订单微信号码 - * @param couponIds 优惠券ID列表 - * @param remark 订单备注 - * @param clerkSex 随机单要求-店员性别(0:未知;1:男;2:女) - * @param clerkLevelId 随机单要求-店员等级ID - * @param excludeHistory 随机单要求-是否排除下单过的成员(0:不排除;1:排除) - * @author admin - * @since 2024/6/3 10:53 - * @deprecated 请使用 {@link #createOrderInfo(OrderCreationRequest)} 替代 - **/ - @Deprecated - void createOrderInfo(String orderId, String orderNo, String orderState, String orderType, String placeType, - String rewardType, String firstOrder, String commodityId, String commodityType, BigDecimal commodityPrice, - String serviceDuration, String commodityName, String commodityNumber, BigDecimal orderMoney, - BigDecimal finalAmount, BigDecimal discountAmount, String purchaserBy, String acceptBy, String weiChatCode, - List couponIds, String remark, String clerkSex, String clerkLevelId, String excludeHistory); - /** * 据店员等级和订单金额,获取店员预计收入 * diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java index 3416c3b..9f36889 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java @@ -1,29 +1,51 @@ package com.starry.admin.modules.order.service.impl; +import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.starry.admin.common.exception.CustomException; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; +import com.starry.admin.modules.order.mapper.PlayOrderLogInfoMapper; import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType; 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.OrderRefundFlag; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundRecordType; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundState; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderSettlementState; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrdersExpiredState; +import com.starry.admin.modules.order.module.constant.OrderConstant.PayMethod; +import com.starry.admin.modules.order.module.constant.OrderConstant.PlaceType; import com.starry.admin.modules.order.module.constant.OrderConstant.ReviewRequirement; +import com.starry.admin.modules.order.module.constant.OrderConstant.YesNoFlag; +import com.starry.admin.modules.order.module.dto.CommodityInfo; import com.starry.admin.modules.order.module.dto.OrderCompletionContext; +import com.starry.admin.modules.order.module.dto.OrderCreationContext; import com.starry.admin.modules.order.module.dto.OrderRefundContext; +import com.starry.admin.modules.order.module.dto.PaymentInfo; +import com.starry.admin.modules.order.module.dto.RandomOrderRequirements; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.module.entity.PlayOrderLogInfoEntity; +import com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo; import com.starry.admin.modules.order.service.IOrderLifecycleService; import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; +import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator; +import com.starry.admin.modules.shop.module.constant.CouponUseState; +import com.starry.admin.modules.shop.service.IPlayCouponDetailsService; import com.starry.admin.modules.weichat.service.WxCustomMpService; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.service.IEarningsService; import com.starry.admin.utils.SecurityUtils; +import com.starry.common.utils.IdUtils; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.List; import javax.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -33,6 +55,14 @@ import org.springframework.transaction.annotation.Transactional; @Service public class OrderLifecycleServiceImpl implements IOrderLifecycleService { + private static final LifecycleToken LIFECYCLE_TOKEN = new LifecycleToken(); + + private enum LifecycleOperation { + CREATE, + COMPLETE, + REFUND + } + @Resource private PlayOrderInfoMapper orderInfoMapper; @@ -48,6 +78,49 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { @Resource private IPlayCustomUserInfoService customUserInfoService; + @Resource + private IPlayCouponDetailsService playCouponDetailsService; + + @Resource + private ClerkRevenueCalculator clerkRevenueCalculator; + + @Resource + private PlayOrderLogInfoMapper orderLogInfoMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public PlayOrderInfoEntity initiateOrder(OrderCreationContext context) { + validateOrderCreationRequest(context); + + PlayOrderInfoEntity entity = buildOrderEntity(context); + applyRandomOrderRequirements(entity, context.getRandomOrderRequirements()); + applyAcceptByInfo(entity, context); + if (context.isRewardOrder()) { + applyRewardOrderDefaults(entity); + } + + customUserInfoService.saveOrderInfo(entity); + orderInfoMapper.insert(entity); + String creationOperationType = resolveCreationOperationType(context.getCreatorActor()); + recordOrderLog( + entity, + context.getCreatorActor(), + context.getCreatorId(), + LifecycleOperation.CREATE, + context.getRemark(), + creationOperationType); + updateCouponUsage(context.getPaymentInfo().getCouponIds()); + + if (context.isRewardOrder() && StrUtil.isNotBlank(context.getAcceptBy())) { + completeOrder( + entity.getId(), + OrderCompletionContext.of(OrderActor.SYSTEM, null, OrderTriggerSource.REWARD_ORDER) + .withForceNotify(true) + .withComment("auto-complete reward order")); + } + return entity; + } + @Override @Transactional(rollbackFor = Exception.class) public void completeOrder(String orderId, OrderCompletionContext context) { @@ -71,17 +144,34 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { LocalDateTime now = LocalDateTime.now(); LocalDateTime endTime = order.getOrderEndTime() != null ? order.getOrderEndTime() : now; - boolean statusUpdated = false; + boolean transitioned = false; if (!alreadyCompleted) { - PlayOrderInfoEntity update = new PlayOrderInfoEntity(orderId, OrderStatus.COMPLETED.getCode()); - update.setOrderEndTime(endTime); - orderInfoMapper.updateById(update); - statusUpdated = true; - } else if (order.getOrderEndTime() == null) { - PlayOrderInfoEntity update = new PlayOrderInfoEntity(orderId, OrderStatus.COMPLETED.getCode()); - update.setOrderEndTime(endTime); - orderInfoMapper.updateById(update); - statusUpdated = true; + UpdateWrapper transitionWrapper = new UpdateWrapper<>(); + transitionWrapper.eq("id", orderId) + .eq("order_status", OrderStatus.IN_PROGRESS.getCode()) + .set("order_status", OrderStatus.COMPLETED.getCode()) + .set("order_end_time", endTime); + transitioned = orderInfoMapper.update(null, transitionWrapper) > 0; + if (!transitioned) { + PlayOrderInfoEntity refreshed = orderInfoMapper.selectById(orderId); + if (refreshed == null) { + throw new CustomException("订单不存在"); + } + if (!OrderStatus.COMPLETED.getCode().equals(refreshed.getOrderStatus())) { + log.warn("Failed to transition order {} to completed, current status {}", orderId, refreshed.getOrderStatus()); + throw new CustomException("订单状态异常,无法完成"); + } + order = refreshed; + alreadyCompleted = true; + } + } + + if (alreadyCompleted && order.getOrderEndTime() == null) { + UpdateWrapper endTimeWrapper = new UpdateWrapper<>(); + endTimeWrapper.eq("id", orderId) + .isNull("order_end_time") + .set("order_end_time", endTime); + orderInfoMapper.update(null, endTimeWrapper); } PlayOrderInfoEntity latest = orderInfoMapper.selectById(orderId); @@ -89,9 +179,34 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { throw new CustomException("订单不存在"); } - boolean earningsCreated = ensureEarnings(latest, source); boolean forceNotify = context != null && context.isForceNotify(); - boolean shouldNotify = statusUpdated || (forceNotify && earningsCreated); + boolean earningsCreated = false; + boolean shouldNotify = false; + + boolean completionLogged = hasLifecycleLog(orderId, LifecycleOperation.COMPLETE); + boolean shouldApplyCompletion = transitioned; + if (!shouldApplyCompletion && forceNotify) { + shouldApplyCompletion = !completionLogged; + } + + if (shouldApplyCompletion) { + customUserInfoService.handleOrderCompletion(latest); + earningsCreated = ensureEarnings(latest, source); + shouldNotify = true; + OrderActor actor = context != null ? context.getOperatorActor() : OrderActor.SYSTEM; + String operationType = resolveCompletionOperationType(context, transitioned); + recordOrderLog( + latest, + actor, + context != null ? context.getOperatorId() : null, + LifecycleOperation.COMPLETE, + context != null ? context.getComment() : null, + operationType); + } else if (forceNotify) { + earningsCreated = ensureEarnings(latest, source); + shouldNotify = earningsCreated || OrderStatus.COMPLETED.getCode().equals(latest.getOrderStatus()); + } + if (shouldNotify) { wxCustomMpService.sendOrderFinishMessageAsync(latest); } @@ -128,12 +243,22 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { throw new CustomException("每个订单只能退款一次~"); } - PlayOrderInfoEntity update = new PlayOrderInfoEntity(); - update.setId(order.getId()); - update.setRefundType(OrderRefundFlag.REFUNDED.getCode()); - update.setOrderStatus(OrderStatus.CANCELLED.getCode()); - update.setRefundAmount(refundAmount); - orderInfoMapper.updateById(update); + UpdateWrapper refundUpdate = new UpdateWrapper<>(); + refundUpdate.eq("id", order.getId()) + .eq("refund_type", OrderRefundFlag.NOT_REFUNDED.getCode()) + .set("refund_type", OrderRefundFlag.REFUNDED.getCode()) + .set("refund_amount", refundAmount) + .set("refund_reason", context.getRefundReason()) + .set("order_status", OrderStatus.CANCELLED.getCode()); + boolean refundApplied = orderInfoMapper.update(null, refundUpdate) > 0; + if (!refundApplied) { + PlayOrderInfoEntity latest = orderInfoMapper.selectById(order.getId()); + if (latest != null && OrderRefundFlag.REFUNDED.getCode().equals(latest.getRefundType())) { + log.info("Refund already processed for order {}, skipping duplicate credit", order.getId()); + return; + } + throw new CustomException("订单已处理或状态异常,无法退款"); + } PlayCustomUserInfoEntity customUser = customUserInfoService.getById(order.getPurchaserBy()); if (customUser == null) { @@ -171,6 +296,132 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { refundById, OrderRefundState.PROCESSING.getCode(), ReviewRequirement.NOT_REQUIRED.getCode()); + + PlayOrderInfoEntity latest = orderInfoMapper.selectById(order.getId()); + + String refundOperationType = String.format("%s_%s", LifecycleOperation.REFUND.name(), refundRecordType.name()); + recordOrderLog( + latest != null ? latest : order, + resolveCompletionActor(context.getOperatorType()), + context.getOperatorId(), + LifecycleOperation.REFUND, + context.getRefundReason(), + refundOperationType); + } + + private void validateOrderCreationRequest(OrderCreationContext context) { + if (context == null) { + throw new CustomException("订单创建请求不能为空"); + } + if (context.getPlaceType() == PlaceType.RANDOM && !context.isValidForRandomOrder()) { + throw new CustomException("随机单必须提供店员要求信息"); + } + } + + private PlayOrderInfoEntity buildOrderEntity(OrderCreationContext context) { + PlayOrderInfoEntity entity = new PlayOrderInfoEntity(); + entity.setId(context.getOrderId()); + entity.setOrderNo(context.getOrderNo()); + entity.updateOrderStatus(LIFECYCLE_TOKEN, context.getOrderStatus()); + entity.setOrderType(context.getOrderType().getCode()); + entity.setPlaceType(context.getPlaceType().getCode()); + entity.setRewardType(context.getRewardType().getCode()); + entity.setFirstOrder(resolveFirstOrderFlag(context)); + entity.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode()); + entity.setBackendEntry(YesNoFlag.NO.getCode()); + entity.setOrderSettlementState(OrderSettlementState.NOT_SETTLED.getCode()); + entity.setOrdersExpiredState(OrdersExpiredState.NOT_EXPIRED.getCode()); + + CommodityInfo commodityInfo = context.getCommodityInfo(); + entity.setCommodityId(commodityInfo.getCommodityId()); + entity.setCommodityType(commodityInfo.getCommodityType().getCode()); + entity.setCommodityPrice(commodityInfo.getCommodityPrice()); + entity.setServiceDuration(commodityInfo.getServiceDuration()); + entity.setCommodityName(commodityInfo.getCommodityName()); + entity.setCommodityNumber(commodityInfo.getCommodityNumber()); + + PaymentInfo paymentInfo = context.getPaymentInfo(); + entity.setOrderMoney(paymentInfo.getOrderMoney()); + entity.setFinalAmount(paymentInfo.getFinalAmount()); + entity.setDiscountAmount(paymentInfo.getDiscountAmount()); + entity.setCouponIds(paymentInfo.getCouponIds()); + entity.setUseCoupon(CollectionUtil.isNotEmpty(paymentInfo.getCouponIds()) + ? YesNoFlag.YES.getCode() + : YesNoFlag.NO.getCode()); + entity.setPayMethod(resolvePayMethod(paymentInfo.getPayMethod())); + + entity.setPurchaserBy(context.getPurchaserBy()); + entity.setPurchaserTime(LocalDateTime.now()); + entity.setWeiChatCode(context.getWeiChatCode()); + entity.setRemark(context.getRemark()); + return entity; + } + + private void applyRandomOrderRequirements(PlayOrderInfoEntity entity, RandomOrderRequirements requirements) { + if (requirements == null) { + return; + } + entity.setSex(requirements.getClerkGender().getCode()); + entity.setLevelId(requirements.getClerkLevelId()); + entity.setExcludeHistory(requirements.getExcludeHistory()); + } + + private void applyAcceptByInfo(PlayOrderInfoEntity entity, OrderCreationContext context) { + if (StrUtil.isBlank(context.getAcceptBy())) { + return; + } + entity.setAcceptBy(context.getAcceptBy()); + ClerkEstimatedRevenueVo estimatedRevenue = clerkRevenueCalculator.calculateEstimatedRevenue( + context.getAcceptBy(), + context.getPaymentInfo().getCouponIds(), + context.getPlaceType().getCode(), + entity.getFirstOrder(), + context.getPaymentInfo().getFinalAmount()); + entity.setEstimatedRevenue(estimatedRevenue.getRevenueAmount()); + entity.setEstimatedRevenueRatio(estimatedRevenue.getRevenueRatio()); + } + + private void applyRewardOrderDefaults(PlayOrderInfoEntity entity) { + LocalDateTime now = LocalDateTime.now(); + entity.setAcceptTime(now); + entity.setOrderStartTime(now); + entity.setOrderEndTime(now); + } + + private void updateCouponUsage(List couponIds) { + if (CollectionUtil.isEmpty(couponIds)) { + return; + } + playCouponDetailsService.updateCouponUseStateByIds(couponIds, CouponUseState.USED.getCode()); + } + + private String resolvePayMethod(String payMethodCode) { + if (StrUtil.isBlank(payMethodCode)) { + return PayMethod.BALANCE.getCode(); + } + try { + return PayMethod.fromCode(payMethodCode).getCode(); + } catch (IllegalArgumentException ex) { + log.warn("Unknown pay method code {}, fallback to BALANCE", payMethodCode); + return PayMethod.BALANCE.getCode(); + } + } + + private String resolveFirstOrderFlag(OrderCreationContext context) { + if (StrUtil.isBlank(context.getAcceptBy()) || StrUtil.isBlank(context.getPurchaserBy())) { + return context.getFirstOrderString(); + } + return isFirstOrder(context.getPurchaserBy(), context.getAcceptBy()) + ? YesNoFlag.YES.getCode() + : YesNoFlag.NO.getCode(); + } + + private boolean isFirstOrder(String customerId, String clerkId) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(PlayOrderInfoEntity.class) + .eq(PlayOrderInfoEntity::getPurchaserBy, customerId) + .eq(PlayOrderInfoEntity::getAcceptBy, clerkId) + .eq(PlayOrderInfoEntity::getOrderStatus, OrderStatus.COMPLETED.getCode()); + return orderInfoMapper.selectCount(wrapper) == 0; } private boolean ensureEarnings(PlayOrderInfoEntity order, OrderTriggerSource source) { @@ -192,4 +443,77 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { } return true; } + + private boolean hasLifecycleLog(String orderId, LifecycleOperation operation) { + if (StrUtil.isBlank(orderId)) { + return false; + } + return orderLogInfoMapper.selectCount(Wrappers.lambdaQuery() + .eq(PlayOrderLogInfoEntity::getOrderId, orderId) + .likeRight(PlayOrderLogInfoEntity::getOperationType, operation.name())) > 0; + } + + private String resolveCreationOperationType(OrderActor creatorActor) { + String actorSegment = creatorActor != null ? creatorActor.name() : OrderActor.SYSTEM.name(); + return String.format("%s_%s", LifecycleOperation.CREATE.name(), actorSegment); + } + + private String resolveCompletionOperationType(OrderCompletionContext context, boolean transitioned) { + OrderActor operatorActor = context != null ? context.getOperatorActor() : OrderActor.SYSTEM; + String actorSegment = operatorActor != null ? operatorActor.name() : OrderActor.SYSTEM.name(); + if (transitioned) { + return String.format("%s_%s", LifecycleOperation.COMPLETE.name(), actorSegment); + } + if (context != null && context.isForceNotify()) { + return String.format("%s_FORCE_%s", LifecycleOperation.COMPLETE.name(), actorSegment); + } + return LifecycleOperation.COMPLETE.name(); + } + + public static final class LifecycleToken { + private LifecycleToken() {} + } + + private void recordOrderLog(PlayOrderInfoEntity order, + OrderActor actor, + String operatorId, + LifecycleOperation operation, + String remark, + String operationType) { + if (order == null || StrUtil.isBlank(order.getId())) { + return; + } + PlayOrderLogInfoEntity log = new PlayOrderLogInfoEntity(); + log.setId(IdUtils.getUuid()); + log.setTenantId(order.getTenantId()); + log.setOrderId(order.getId()); + log.setOperationType(StrUtil.isNotBlank(operationType) ? operationType : operation.name()); + log.setOperatorType(actor != null ? actor.name() : null); + log.setOperatorId(operatorId); + log.setOperTime(LocalDateTime.now()); + log.setRemark(remark); + orderLogInfoMapper.insert(log); + } + + private OrderActor resolveCompletionActor(String operatorType) { + if (StrUtil.isBlank(operatorType)) { + return OrderActor.SYSTEM; + } + try { + OperatorType type = OperatorType.fromCode(operatorType); + switch (type) { + case CUSTOMER: + return OrderActor.CUSTOMER; + case CLERK: + return OrderActor.CLERK; + case ADMIN: + return OrderActor.ADMIN; + default: + return OrderActor.SYSTEM; + } + } catch (IllegalArgumentException ex) { + log.warn("Unknown operator type {}, fallback to SYSTEM", operatorType, ex); + return OrderActor.SYSTEM; + } + } } diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/PlayOrderEvaluateInfoServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/PlayOrderEvaluateInfoServiceImpl.java index ffd2a2c..fc87b71 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/PlayOrderEvaluateInfoServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/PlayOrderEvaluateInfoServiceImpl.java @@ -17,7 +17,6 @@ import com.starry.admin.modules.order.module.vo.PlayOrderEvaluateReturnVo; import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService; import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService; import com.starry.admin.utils.SecurityUtils; -import com.starry.common.enums.EvaluateHiddenState; import com.starry.common.utils.IdUtils; import com.starry.common.utils.StringUtils; import java.util.Arrays; diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/PlayOrderInfoServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/PlayOrderInfoServiceImpl.java index bc57186..fb3dc7c 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/PlayOrderInfoServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/PlayOrderInfoServiceImpl.java @@ -20,6 +20,7 @@ import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; import com.starry.admin.modules.order.module.constant.OrderConstant; import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType; import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundFlag; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundRecordType; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundState; @@ -37,6 +38,7 @@ import com.starry.admin.modules.order.service.IPlayOrderComplaintInfoService; import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; +import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator; import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService; import com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo; import com.starry.admin.modules.shop.service.IPlayCouponDetailsService; @@ -102,6 +104,9 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl getTotalOrderInfo(String tenantId) { MPJLambdaWrapper lambdaWrapper = new MPJLambdaWrapper<>(); @@ -109,306 +114,10 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl couponIds, String remark, String clerkSex, String clerkLevelId, String excludeHistory) { - PlayOrderInfoEntity entity = new PlayOrderInfoEntity(); - entity.setId(orderId); - entity.setOrderNo(orderNo); - entity.setOrderStatus(orderState); - entity.setOrderType(orderType); - entity.setPlaceType(placeType); - entity.setRewardType(rewardType); - entity.setFirstOrder(firstOrder); - entity.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode()); - entity.setCommodityId(commodityId); - entity.setCommodityType(commodityType); - entity.setCommodityPrice(commodityPrice); - entity.setServiceDuration(serviceDuration); - entity.setCommodityName(commodityName); - entity.setCommodityNumber(commodityNumber); - entity.setBackendEntry("0"); - entity.setPayMethod("0"); - entity.setOrderMoney(orderMoney); - entity.setFinalAmount(finalAmount); - entity.setDiscountAmount(discountAmount); - entity.setWeiChatCode(weiChatCode); - entity.setRemark(remark); - entity.setOrderSettlementState("0"); - entity.setOrdersExpiredState("0"); - entity.setPurchaserBy(purchaserBy); - entity.setPurchaserTime(LocalDateTime.now()); - entity.setCouponIds(couponIds); - entity.setUseCoupon(couponIds != null && !couponIds.isEmpty() ? "1" : "0"); - if ("1".equals(placeType)) { - entity.setSex(clerkSex); - entity.setLevelId(clerkLevelId); - entity.setExcludeHistory(excludeHistory); - } - if (StrUtil.isNotBlank(acceptBy)) { - entity.setAcceptBy(acceptBy); - ClerkEstimatedRevenueVo estimatedRevenueVo = getClerkEstimatedRevenue(acceptBy, couponIds, placeType, - firstOrder, finalAmount); - entity.setEstimatedRevenue(estimatedRevenueVo.getRevenueAmount()); - entity.setEstimatedRevenueRatio(estimatedRevenueVo.getRevenueRatio()); - } - // 如果订单是打赏单,订单直接完成 - if ("2".equals(placeType)) { - entity.setAcceptTime(LocalDateTime.now()); - entity.setOrderStartTime(LocalDateTime.now()); - entity.setOrderEndTime(LocalDateTime.now()); - } - // 修改顾客下单信息 - userInfoService.saveOrderInfo(entity); - // 保存订单 - this.baseMapper.insert(entity); - // 修改优惠券状态 - playCouponDetailsService.updateCouponUseStateByIds(couponIds, "2"); - if ("2".equals(placeType) && StrUtil.isNotBlank(acceptBy)) { - orderLifecycleService.completeOrder( - orderId, - OrderCompletionContext.of(null, null, OrderTriggerSource.REWARD_ORDER) - .withForceNotify(true) - .withComment("auto-complete reward order")); - } - } - - @Override - public void createOrderInfo(OrderCreationRequest request) { - // 验证请求 - validateOrderCreationRequest(request); - - PlayOrderInfoEntity entity = buildOrderEntity(request); - - // 处理随机单要求 - if (request.getPlaceType() == OrderConstant.PlaceType.RANDOM) { - setRandomOrderRequirements(entity, request.getRandomOrderRequirements()); - } - - // 处理接单人信息 - if (StrUtil.isNotBlank(request.getAcceptBy())) { - setAcceptByInfo(entity, request); - } - - // 处理打赏单自动完成逻辑 - if (request.isRewardOrder()) { - setRewardOrderCompleted(entity); - } - // 处理首单逻辑 - if (StrUtil.isNotBlank(request.getAcceptBy()) && StrUtil.isNotBlank(request.getPurchaserBy())) { - entity.setFirstOrder(this.checkFirstOrderFlag(request.getPurchaserBy(), request.getAcceptBy()) ? "1" : "0"); - } - - // 保存订单 - userInfoService.saveOrderInfo(entity); - this.baseMapper.insert(entity); - - // 修改优惠券状态 - playCouponDetailsService.updateCouponUseStateByIds( - request.getPaymentInfo().getCouponIds(), "2"); - - // 打赏单立即入账 - if (request.isRewardOrder() && StrUtil.isNotBlank(request.getAcceptBy())) { - orderLifecycleService.completeOrder( - entity.getId(), - OrderCompletionContext.of(null, null, OrderTriggerSource.REWARD_ORDER) - .withForceNotify(true) - .withComment("auto-complete reward order")); - } - } - - /** - * 验证订单创建请求 - */ - private void validateOrderCreationRequest(OrderCreationRequest request) { - if (request.getPlaceType() == OrderConstant.PlaceType.RANDOM - && !request.isValidForRandomOrder()) { - throw new CustomException("随机单必须提供店员要求信息"); - } - } - - /** - * 构建订单实体 - */ - private PlayOrderInfoEntity buildOrderEntity(OrderCreationRequest request) { - PlayOrderInfoEntity entity = new PlayOrderInfoEntity(); - - // 基本信息 - entity.setId(request.getOrderId()); - entity.setOrderNo(request.getOrderNo()); - entity.setOrderStatus(request.getOrderStatus().getCode()); - entity.setOrderType(request.getOrderType().getCode()); - entity.setPlaceType(request.getPlaceType().getCode()); - entity.setRewardType(request.getRewardType().getCode()); - entity.setFirstOrder(request.getFirstOrderString()); - - // 固定默认值 - entity.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode()); - entity.setBackendEntry("0"); - entity.setPayMethod("0"); - entity.setOrderSettlementState("0"); - entity.setOrdersExpiredState("0"); - - // 商品信息 - CommodityInfo commodityInfo = request.getCommodityInfo(); - entity.setCommodityId(commodityInfo.getCommodityId()); - entity.setCommodityType(commodityInfo.getCommodityType().getCode()); - entity.setCommodityPrice(commodityInfo.getCommodityPrice()); - entity.setServiceDuration(commodityInfo.getServiceDuration()); - entity.setCommodityName(commodityInfo.getCommodityName()); - entity.setCommodityNumber(commodityInfo.getCommodityNumber()); - - // 支付信息 - PaymentInfo paymentInfo = request.getPaymentInfo(); - entity.setOrderMoney(paymentInfo.getOrderMoney()); - entity.setFinalAmount(paymentInfo.getFinalAmount()); - entity.setDiscountAmount(paymentInfo.getDiscountAmount()); - entity.setCouponIds(paymentInfo.getCouponIds()); - entity.setUseCoupon( - paymentInfo.getCouponIds() != null && !paymentInfo.getCouponIds().isEmpty() ? "1" : "0"); - - // 用户信息 - entity.setPurchaserBy(request.getPurchaserBy()); - entity.setPurchaserTime(LocalDateTime.now()); - entity.setWeiChatCode(request.getWeiChatCode()); - entity.setRemark(request.getRemark()); - - return entity; - } - - /** - * 设置随机单要求 - */ - private void setRandomOrderRequirements(PlayOrderInfoEntity entity, RandomOrderRequirements requirements) { - if (requirements != null) { - entity.setSex(requirements.getClerkGender().getCode()); - entity.setLevelId(requirements.getClerkLevelId()); - entity.setExcludeHistory(requirements.getExcludeHistory()); - } - } - - /** - * 设置接单人信息 - */ - private void setAcceptByInfo(PlayOrderInfoEntity entity, OrderCreationRequest request) { - entity.setAcceptBy(request.getAcceptBy()); - ClerkEstimatedRevenueVo estimatedRevenueVo = getClerkEstimatedRevenue( - request.getAcceptBy(), - request.getPaymentInfo().getCouponIds(), - request.getPlaceType().getCode(), - request.getFirstOrderString(), - request.getPaymentInfo().getFinalAmount()); - entity.setEstimatedRevenue(estimatedRevenueVo.getRevenueAmount()); - entity.setEstimatedRevenueRatio(estimatedRevenueVo.getRevenueRatio()); - } - - /** - * 设置打赏单为已完成状态 - */ - private void setRewardOrderCompleted(PlayOrderInfoEntity entity) { - LocalDateTime now = LocalDateTime.now(); - entity.setAcceptTime(now); - entity.setOrderStartTime(now); - entity.setOrderEndTime(now); - } - @Override public ClerkEstimatedRevenueVo getClerkEstimatedRevenue(String clerkId, List croupIds, String placeType, String firstOrder, BigDecimal finalAmount) { - PlayClerkLevelInfoEntity entity = playClerkUserInfoService.queryLevelCommission(clerkId); - ClerkEstimatedRevenueVo estimatedRevenueVo = new ClerkEstimatedRevenueVo(); - switch (placeType) { - case "0": { - if ("1".equals(firstOrder)) { - estimatedRevenueVo.setRevenueRatio(entity.getFirstRegularRatio()); - estimatedRevenueVo - .setRevenueAmount( - finalAmount - .multiply(new BigDecimal(entity.getFirstRegularRatio()) - .divide(new BigDecimal(100), 4, RoundingMode.HALF_UP)) - .setScale(2, RoundingMode.HALF_UP)); - } else { - estimatedRevenueVo.setRevenueRatio(entity.getNotFirstRegularRatio()); - estimatedRevenueVo - .setRevenueAmount(finalAmount - .multiply(new BigDecimal(entity.getNotFirstRegularRatio()) - .divide(new BigDecimal(100), 4, RoundingMode.HALF_UP)) - .setScale(2, RoundingMode.HALF_UP)); - } - break; - } - case "1": { - if ("1".equals(firstOrder)) { - estimatedRevenueVo.setRevenueRatio(entity.getFirstRandomRadio()); - estimatedRevenueVo - .setRevenueAmount( - finalAmount - .multiply(new BigDecimal(entity.getFirstRandomRadio()) - .divide(new BigDecimal(100), 4, RoundingMode.HALF_UP)) - .setScale(2, RoundingMode.HALF_UP)); - } else { - estimatedRevenueVo.setRevenueRatio(entity.getNotFirstRandomRadio()); - estimatedRevenueVo - .setRevenueAmount(finalAmount - .multiply(new BigDecimal(entity.getNotFirstRandomRadio()) - .divide(new BigDecimal(100), 4, RoundingMode.HALF_UP)) - .setScale(2, RoundingMode.HALF_UP)); - } - break; - } - case "2": { - if ("1".equals(firstOrder)) { - estimatedRevenueVo.setRevenueRatio(entity.getFirstRewardRatio()); - estimatedRevenueVo - .setRevenueAmount( - finalAmount - .multiply(new BigDecimal(entity.getFirstRewardRatio()) - .divide(new BigDecimal(100), 4, RoundingMode.HALF_UP)) - .setScale(2, RoundingMode.HALF_UP)); - } else { - estimatedRevenueVo.setRevenueRatio(entity.getNotFirstRewardRatio()); - estimatedRevenueVo - .setRevenueAmount(finalAmount - .multiply(new BigDecimal(entity.getNotFirstRewardRatio()) - .divide(new BigDecimal(100), 4, RoundingMode.HALF_UP)) - .setScale(2, RoundingMode.HALF_UP)); - } - break; - } - case "-1": { - log.error("下单类型异常,placeType={}", placeType); - estimatedRevenueVo.setRevenueAmount(finalAmount); - estimatedRevenueVo.setRevenueRatio(100); - break; - } - default: { - log.error("下单类型错误,placeType={}", placeType); - estimatedRevenueVo.setRevenueAmount(finalAmount); - estimatedRevenueVo.setRevenueRatio(100); - break; - } - } - - // 如果优惠券不由店铺承担,那么店员预计收入减去优惠金额 - for (String croupId : croupIds) { - PlayCouponDetailsReturnVo couponInfo = playCouponDetailsService.selectPlayCouponDetailsById(croupId); - if ("0".equals(couponInfo.getAttributionDiscounts())) { - BigDecimal revenueAmount = estimatedRevenueVo.getRevenueAmount(); - if ("0".equals(couponInfo.getDiscountType())) { - revenueAmount = revenueAmount.subtract(couponInfo.getDiscountAmount()); - } else { - revenueAmount = revenueAmount.subtract(revenueAmount.subtract(couponInfo.getDiscountAmount())); - } - log.debug("优惠券ID={},优惠券类型={},优惠金额={},优惠前金额={},优惠前金额={}", croupId, couponInfo.getDiscountType(), - couponInfo.getDiscountAmount(), estimatedRevenueVo.getRevenueAmount(), revenueAmount); - estimatedRevenueVo.setRevenueAmount(revenueAmount); - } - } - return estimatedRevenueVo; + return clerkRevenueCalculator.calculateEstimatedRevenue(clerkId, croupIds, placeType, firstOrder, finalAmount); } /** @@ -425,81 +134,59 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl wrapper = Wrappers.lambdaQuery(PlayOrderInfoEntity.class).eq(PlayOrderInfoEntity::getPurchaserBy, customId).eq(PlayOrderInfoEntity::getAcceptBy, clerkId).eq(PlayOrderInfoEntity::getOrderStatus, OrderStatus.COMPLETED.getCode()); - return this.baseMapper.selectCount(wrapper) > 0; + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(PlayOrderInfoEntity.class) + .eq(PlayOrderInfoEntity::getPurchaserBy, customId) + .eq(PlayOrderInfoEntity::getAcceptBy, clerkId) + .eq(PlayOrderInfoEntity::getOrderStatus, OrderStatus.COMPLETED.getCode()); + return this.baseMapper.selectCount(wrapper) == 0; } /** @@ -884,15 +573,18 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl couponIds, + String placeType, + String firstOrder, + BigDecimal finalAmount) { + PlayClerkLevelInfoEntity levelInfo = playClerkUserInfoService.queryLevelCommission(clerkId); + ClerkEstimatedRevenueVo estimatedRevenueVo = new ClerkEstimatedRevenueVo(); + + boolean fallbackToOther = false; + OrderConstant.PlaceType placeTypeEnum; + try { + placeTypeEnum = OrderConstant.PlaceType.fromCode(placeType); + } catch (IllegalArgumentException ex) { + fallbackToOther = true; + placeTypeEnum = OrderConstant.PlaceType.OTHER; + log.warn("无法识别的下单类型,placeType={},clerkId={}。已按其他类型处理。", placeType, clerkId, ex); + } + + switch (placeTypeEnum) { + case SPECIFIED: // 指定单 + fillRegularOrderRevenue(firstOrder, finalAmount, levelInfo, estimatedRevenueVo); + break; + case RANDOM: // 随机单 + fillRandomOrderRevenue(firstOrder, finalAmount, levelInfo, estimatedRevenueVo); + break; + case REWARD: // 打赏单 + fillRewardOrderRevenue(firstOrder, finalAmount, levelInfo, estimatedRevenueVo); + break; + case OTHER: + default: + if (!fallbackToOther) { + log.warn("按其他下单类型计算预计收益,placeType={},clerkId={}", placeType, clerkId); + } + estimatedRevenueVo.setRevenueAmount(finalAmount); + estimatedRevenueVo.setRevenueRatio(100); + break; + } + + adjustRevenueByCoupon(clerkId, couponIds, estimatedRevenueVo); + return estimatedRevenueVo; + } + + private void fillRegularOrderRevenue(String firstOrder, BigDecimal finalAmount, + PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { + if ("1".equals(firstOrder)) { + vo.setRevenueRatio(levelInfo.getFirstRegularRatio()); + vo.setRevenueAmount(scaleAmount(finalAmount, levelInfo.getFirstRegularRatio())); + } else { + vo.setRevenueRatio(levelInfo.getNotFirstRegularRatio()); + vo.setRevenueAmount(scaleAmount(finalAmount, levelInfo.getNotFirstRegularRatio())); + } + } + + private void fillRandomOrderRevenue(String firstOrder, BigDecimal finalAmount, + PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { + if ("1".equals(firstOrder)) { + vo.setRevenueRatio(levelInfo.getFirstRandomRadio()); + vo.setRevenueAmount(scaleAmount(finalAmount, levelInfo.getFirstRandomRadio())); + } else { + vo.setRevenueRatio(levelInfo.getNotFirstRandomRadio()); + vo.setRevenueAmount(scaleAmount(finalAmount, levelInfo.getNotFirstRandomRadio())); + } + } + + private void fillRewardOrderRevenue(String firstOrder, BigDecimal finalAmount, + PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { + if ("1".equals(firstOrder)) { + vo.setRevenueRatio(levelInfo.getFirstRewardRatio()); + vo.setRevenueAmount(scaleAmount(finalAmount, levelInfo.getFirstRewardRatio())); + } else { + vo.setRevenueRatio(levelInfo.getNotFirstRewardRatio()); + vo.setRevenueAmount(scaleAmount(finalAmount, levelInfo.getNotFirstRewardRatio())); + } + } + + private BigDecimal scaleAmount(BigDecimal baseAmount, Integer ratio) { + return baseAmount + .multiply(new BigDecimal(ratio).divide(new BigDecimal(100), 4, RoundingMode.HALF_UP)) + .setScale(2, RoundingMode.HALF_UP); + } + + private void adjustRevenueByCoupon(String clerkId, + List couponIds, + ClerkEstimatedRevenueVo estimatedRevenueVo) { + List safeCouponIds = couponIds == null ? Collections.emptyList() : couponIds; + for (String couponId : safeCouponIds) { + PlayCouponDetailsReturnVo couponInfo = playCouponDetailsService.selectPlayCouponDetailsById(couponId); + if (couponInfo == null) { + log.warn("优惠券信息不存在,couponId={}, clerkId={}", couponId, clerkId); + continue; + } + if ("0".equals(couponInfo.getAttributionDiscounts())) { + BigDecimal revenueAmount = estimatedRevenueVo.getRevenueAmount(); + if ("0".equals(couponInfo.getDiscountType())) { + revenueAmount = revenueAmount.subtract(couponInfo.getDiscountAmount()); + } else { + revenueAmount = revenueAmount.subtract(revenueAmount.subtract(couponInfo.getDiscountAmount())); + } + log.debug("优惠券ID={},优惠券类型={},优惠金额={},优惠前金额={},优惠后金额={}", + couponId, + couponInfo.getDiscountType(), + couponInfo.getDiscountAmount(), + estimatedRevenueVo.getRevenueAmount(), + revenueAmount); + estimatedRevenueVo.setRevenueAmount(revenueAmount); + } + } + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/shop/module/constant/CouponUseState.java b/play-admin/src/main/java/com/starry/admin/modules/shop/module/constant/CouponUseState.java new file mode 100644 index 0000000..c1d40b2 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/shop/module/constant/CouponUseState.java @@ -0,0 +1,28 @@ +package com.starry.admin.modules.shop.module.constant; + +import lombok.Getter; + +/** + * 优惠券使用状态 + */ +@Getter +public enum CouponUseState { + UNUSED("1"), + USED("2"), + RECYCLED("3"); + + private final String code; + + CouponUseState(String code) { + this.code = code; + } + + public static CouponUseState fromCode(String code) { + for (CouponUseState state : values()) { + if (state.code.equals(code)) { + return state; + } + } + throw new IllegalArgumentException("Unknown coupon use state code: " + code); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java index cbf96ca..63ced0b 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java @@ -19,14 +19,16 @@ import com.starry.admin.modules.custom.service.IPlayCustomGiftInfoService; import com.starry.admin.modules.custom.service.IPlayCustomLeaveMsgService; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.order.module.constant.OrderConstant; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor; import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType; import com.starry.admin.modules.order.module.dto.CommodityInfo; -import com.starry.admin.modules.order.module.dto.OrderCreationRequest; +import com.starry.admin.modules.order.module.dto.OrderCreationContext; import com.starry.admin.modules.order.module.dto.PaymentInfo; import com.starry.admin.modules.order.module.dto.RandomOrderRequirements; import com.starry.admin.modules.order.module.entity.PlayOrderComplaintInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderEvaluateInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.service.IOrderLifecycleService; import com.starry.admin.modules.order.service.IPlayOrderComplaintInfoService; import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService; @@ -103,6 +105,9 @@ public class WxCustomController { @Resource private IPlayOrderInfoService playOrderInfoService; + @Resource + private IOrderLifecycleService orderLifecycleService; + @Resource private IPlayCustomLeaveMsgService playCustomLeaveMsgService; @@ -231,14 +236,16 @@ public class WxCustomController { } String orderId = IdUtils.getUuid(); // 记录订单信息 - OrderCreationRequest orderRequest = OrderCreationRequest.builder() + OrderCreationContext orderRequest = OrderCreationContext.builder() .orderId(orderId) .orderNo(playOrderInfoService.getOrderNo()) .orderStatus(OrderConstant.OrderStatus.COMPLETED) .orderType(OrderConstant.OrderType.NORMAL) .placeType(OrderConstant.PlaceType.REWARD) .rewardType(RewardType.BALANCE) - .isFirstOrder(true) + .isFirstOrder(false) + .creatorActor(OrderActor.CUSTOMER) + .creatorId(userId) .commodityInfo(CommodityInfo.builder() .commodityId("") .commodityType(OrderConstant.CommodityType.GIFT) @@ -258,7 +265,7 @@ public class WxCustomController { .weiChatCode(vo.getWeiChatCode()) .remark(vo.getRemark()) .build(); - playOrderInfoService.createOrderInfo(orderRequest); + orderLifecycleService.initiateOrder(orderRequest); // 顾客减少余额 customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(new BigDecimal(vo.getMoney())), "1", "打赏", new BigDecimal(vo.getMoney()), BigDecimal.ZERO, orderId); return R.ok("成功"); @@ -325,7 +332,7 @@ public class WxCustomController { String orderId = IdUtils.getUuid(); String orderNo = playOrderInfoService.getOrderNo(); // 记录订单信息 - OrderCreationRequest orderRequest = OrderCreationRequest.builder() + OrderCreationContext orderRequest = OrderCreationContext.builder() .orderId(orderId) .orderNo(orderNo) .orderStatus(OrderConstant.OrderStatus.PENDING) @@ -333,6 +340,8 @@ public class WxCustomController { .placeType(OrderConstant.PlaceType.SPECIFIED) .rewardType(RewardType.NOT_APPLICABLE) .isFirstOrder(true) + .creatorActor(OrderActor.CUSTOMER) + .creatorId(customId) .commodityInfo(CommodityInfo.builder() .commodityId(commodityInfo.getCommodityId()) .commodityType(OrderConstant.CommodityType.SERVICE) @@ -352,7 +361,7 @@ public class WxCustomController { .weiChatCode(vo.getWeiChatCode()) .remark(vo.getRemark()) .build(); - playOrderInfoService.createOrderInfo(orderRequest); + orderLifecycleService.initiateOrder(orderRequest); // 顾客减少余额 customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(money), "1", "下单-指定单", money, BigDecimal.ZERO, orderId); // 发送通知给店员 @@ -380,7 +389,7 @@ public class WxCustomController { String orderId = IdUtils.getUuid(); String orderNo = playOrderInfoService.getOrderNo(); // 记录订单信息 - OrderCreationRequest orderRequest = OrderCreationRequest.builder() + OrderCreationContext orderRequest = OrderCreationContext.builder() .orderId(orderId) .orderNo(orderNo) .orderStatus(OrderConstant.OrderStatus.PENDING) @@ -388,6 +397,8 @@ public class WxCustomController { .placeType(OrderConstant.PlaceType.RANDOM) .rewardType(RewardType.NOT_APPLICABLE) .isFirstOrder(true) + .creatorActor(OrderActor.CUSTOMER) + .creatorId(customId) .commodityInfo(CommodityInfo.builder() .commodityId(commodityInfo.getCommodityId()) .commodityType(OrderConstant.CommodityType.SERVICE) @@ -411,7 +422,7 @@ public class WxCustomController { .excludeHistory(vo.getExcludeHistory()) .build()) .build(); - playOrderInfoService.createOrderInfo(orderRequest); + orderLifecycleService.initiateOrder(orderRequest); // 顾客减少余额 customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(money), "1", "下单-随机单", money, BigDecimal.ZERO, orderId); // 给全部店员发送通知 diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxGiftOrderService.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxGiftOrderService.java index f0adf50..b16f21b 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxGiftOrderService.java +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxGiftOrderService.java @@ -7,10 +7,12 @@ import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.service.IPlayCustomGiftInfoService; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.order.module.constant.OrderConstant; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor; import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType; import com.starry.admin.modules.order.module.dto.CommodityInfo; -import com.starry.admin.modules.order.module.dto.OrderCreationRequest; +import com.starry.admin.modules.order.module.dto.OrderCreationContext; import com.starry.admin.modules.order.module.dto.PaymentInfo; +import com.starry.admin.modules.order.service.IOrderLifecycleService; import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity; import com.starry.admin.modules.shop.service.IPlayClerkGiftInfoService; @@ -36,6 +38,9 @@ public class WxGiftOrderService { @Resource private IPlayOrderInfoService playOrderInfoService; + @Resource + private IOrderLifecycleService orderLifecycleService; + @Resource private IPlayCustomUserInfoService customUserInfoService; @@ -81,9 +86,9 @@ public class WxGiftOrderService { } String orderId = IdUtils.getUuid(); - OrderCreationRequest orderRequest = buildOrderCreationRequest(orderId, request, sessionUser.getId(), + OrderCreationContext orderRequest = buildOrderCreationContext(orderId, request, sessionUser.getId(), giftInfo, totalAmount); - playOrderInfoService.createOrderInfo(orderRequest); + orderLifecycleService.initiateOrder(orderRequest); BigDecimal newBalance = currentBalance.subtract(totalAmount); customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), currentBalance, newBalance, "1", @@ -97,10 +102,10 @@ public class WxGiftOrderService { return orderId; } - private OrderCreationRequest buildOrderCreationRequest(String orderId, PlayOrderInfoGiftAdd request, + private OrderCreationContext buildOrderCreationContext(String orderId, PlayOrderInfoGiftAdd request, String purchaserId, PlayGiftInfoEntity giftInfo, BigDecimal totalAmount) { - return OrderCreationRequest.builder() + return OrderCreationContext.builder() .orderId(orderId) .orderNo(playOrderInfoService.getOrderNo()) .orderStatus(OrderConstant.OrderStatus.COMPLETED) @@ -108,6 +113,8 @@ public class WxGiftOrderService { .placeType(OrderConstant.PlaceType.REWARD) .rewardType(RewardType.GIFT) .isFirstOrder(true) + .creatorActor(OrderActor.CUSTOMER) + .creatorId(purchaserId) .commodityInfo(CommodityInfo.builder() .commodityId(giftInfo.getId()) .commodityType(OrderConstant.CommodityType.GIFT) diff --git a/play-admin/src/main/resources/db/migration/V12__augment_order_log_table.sql b/play-admin/src/main/resources/db/migration/V12__augment_order_log_table.sql new file mode 100644 index 0000000..33c7b57 --- /dev/null +++ b/play-admin/src/main/resources/db/migration/V12__augment_order_log_table.sql @@ -0,0 +1,5 @@ +ALTER TABLE play_order_log_info + CHANGE COLUMN `order_id` `order_id` varchar(64) NOT NULL COMMENT '订单ID', + CHANGE COLUMN `oper_type` `operation_type` varchar(32) NOT NULL COMMENT '操作类型', + ADD COLUMN `operator_type` varchar(32) DEFAULT NULL COMMENT '操作人类型', + ADD COLUMN `operator_id` varchar(64) DEFAULT NULL COMMENT '操作人ID'; diff --git a/play-admin/src/test/java/com/starry/admin/modules/order/service/OrderCreationRequestTest.java b/play-admin/src/test/java/com/starry/admin/modules/order/service/OrderCreationContextTest.java similarity index 92% rename from play-admin/src/test/java/com/starry/admin/modules/order/service/OrderCreationRequestTest.java rename to play-admin/src/test/java/com/starry/admin/modules/order/service/OrderCreationContextTest.java index cf0e03e..795899b 100644 --- a/play-admin/src/test/java/com/starry/admin/modules/order/service/OrderCreationRequestTest.java +++ b/play-admin/src/test/java/com/starry/admin/modules/order/service/OrderCreationContextTest.java @@ -4,7 +4,7 @@ import static org.junit.jupiter.api.Assertions.*; import com.starry.admin.modules.order.module.constant.OrderConstant; import com.starry.admin.modules.order.module.dto.CommodityInfo; -import com.starry.admin.modules.order.module.dto.OrderCreationRequest; +import com.starry.admin.modules.order.module.dto.OrderCreationContext; import com.starry.admin.modules.order.module.dto.PaymentInfo; import com.starry.admin.modules.order.module.dto.RandomOrderRequirements; import java.math.BigDecimal; @@ -17,7 +17,7 @@ import org.junit.jupiter.api.Test; * * @author admin */ -class OrderCreationRequestTest { +class OrderCreationContextTest { @Test @DisplayName("测试Builder模式构建订单请求") @@ -42,7 +42,7 @@ class OrderCreationRequestTest { .build(); // 构建订单请求 - OrderCreationRequest request = OrderCreationRequest.builder() + OrderCreationContext request = OrderCreationContext.builder() .orderId("order_123456") .orderNo("ORD20240906001") .orderStatus(OrderConstant.OrderStatus.PENDING) @@ -82,7 +82,7 @@ class OrderCreationRequestTest { @DisplayName("测试订单类型判断方法") void testOrderTypeChecks() { // 测试指定单 - OrderCreationRequest specifiedOrder = OrderCreationRequest.builder() + OrderCreationContext specifiedOrder = OrderCreationContext.builder() .orderId("order_001") .orderNo("ORD001") .orderStatus(OrderConstant.OrderStatus.PENDING) @@ -99,7 +99,7 @@ class OrderCreationRequestTest { assertFalse(specifiedOrder.isRewardOrder()); // 测试随机单 - OrderCreationRequest randomOrder = OrderCreationRequest.builder() + OrderCreationContext randomOrder = OrderCreationContext.builder() .orderId("order_002") .orderNo("ORD002") .orderStatus(OrderConstant.OrderStatus.PENDING) @@ -121,7 +121,7 @@ class OrderCreationRequestTest { assertFalse(randomOrder.isRewardOrder()); // 测试打赏单 - OrderCreationRequest rewardOrder = OrderCreationRequest.builder() + OrderCreationContext rewardOrder = OrderCreationContext.builder() .orderId("order_003") .orderNo("ORD003") .orderStatus(OrderConstant.OrderStatus.PENDING) @@ -142,7 +142,7 @@ class OrderCreationRequestTest { @DisplayName("测试首单标识转换") void testFirstOrderStringConversion() { // 测试首单 - OrderCreationRequest firstOrder = OrderCreationRequest.builder() + OrderCreationContext firstOrder = OrderCreationContext.builder() .orderId("order_001") .orderNo("ORD001") .orderStatus(OrderConstant.OrderStatus.PENDING) @@ -157,7 +157,7 @@ class OrderCreationRequestTest { assertEquals("1", firstOrder.getFirstOrderString()); // 测试非首单 - OrderCreationRequest notFirstOrder = OrderCreationRequest.builder() + OrderCreationContext notFirstOrder = OrderCreationContext.builder() .orderId("order_002") .orderNo("ORD002") .orderStatus(OrderConstant.OrderStatus.PENDING) @@ -176,7 +176,7 @@ class OrderCreationRequestTest { @DisplayName("测试随机单验证逻辑") void testRandomOrderValidation() { // 有效的随机单 - OrderCreationRequest validRandomOrder = OrderCreationRequest.builder() + OrderCreationContext validRandomOrder = OrderCreationContext.builder() .orderId("order_001") .orderNo("ORD001") .orderStatus(OrderConstant.OrderStatus.PENDING) @@ -194,7 +194,7 @@ class OrderCreationRequestTest { assertTrue(validRandomOrder.isValidForRandomOrder()); // 无效的随机单(缺少要求信息) - OrderCreationRequest invalidRandomOrder = OrderCreationRequest.builder() + OrderCreationContext invalidRandomOrder = OrderCreationContext.builder() .orderId("order_002") .orderNo("ORD002") .orderStatus(OrderConstant.OrderStatus.PENDING) diff --git a/play-admin/src/test/java/com/starry/admin/modules/order/service/PlayOrderInfoServiceTest.java b/play-admin/src/test/java/com/starry/admin/modules/order/service/PlayOrderInfoServiceTest.java deleted file mode 100644 index c8bf61b..0000000 --- a/play-admin/src/test/java/com/starry/admin/modules/order/service/PlayOrderInfoServiceTest.java +++ /dev/null @@ -1,512 +0,0 @@ -package com.starry.admin.modules.order.service; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -import com.starry.admin.common.exception.CustomException; -import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService; -import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; -import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; -import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; -import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; -import com.starry.admin.modules.order.module.constant.OrderConstant; -import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus; -import com.starry.admin.modules.order.module.dto.CommodityInfo; -import com.starry.admin.modules.order.module.dto.OrderCreationRequest; -import com.starry.admin.modules.order.module.dto.PaymentInfo; -import com.starry.admin.modules.order.module.dto.RandomOrderRequirements; -import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; -import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; -import com.starry.admin.modules.order.service.impl.PlayOrderInfoServiceImpl; -import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService; -import com.starry.admin.modules.shop.service.IPlayCouponDetailsService; -import com.starry.admin.modules.weichat.service.WxCustomMpService; -import com.starry.admin.modules.withdraw.service.IEarningsService; -import java.math.BigDecimal; -import java.util.Arrays; -import java.util.Collections; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -/** - * 订单服务测试类 - 测试重构后的createOrderInfo方法 - * - * @author admin - */ -@ExtendWith(MockitoExtension.class) -class PlayOrderInfoServiceTest { - - @Mock - private PlayOrderInfoMapper orderInfoMapper; - - @Mock - private IPlayClerkUserInfoService playClerkUserInfoService; - - @Mock - private IPlayCustomUserInfoService playCustomUserInfoService; - - @Mock - private IPlayCustomUserInfoService userInfoService; - - @Mock - private IPlayCouponDetailsService playCouponDetailsService; - - @Mock - private WxCustomMpService wxCustomMpService; - - @Mock - private IPlayCustomUserInfoService customUserInfoService; - - @Mock - private IPlayClerkLevelInfoService playClerkLevelInfoService; - - @Mock - private IPlayPersonnelGroupInfoService playClerkGroupInfoService; - - @Mock - private IPlayOrderRefundInfoService playOrderRefundInfoService; - - @Mock - private IEarningsService earningsService; - - @InjectMocks - private PlayOrderInfoServiceImpl orderService; - - @Test - @DisplayName("创建指定订单 - 成功案例") - void testCreateSpecifiedOrder_Success() { - // 准备测试数据 - OrderCreationRequest request = OrderCreationRequest.builder() - .orderId("test_order_001") - .orderNo("ORD20241001001") - .orderStatus(OrderConstant.OrderStatus.PENDING) - .orderType(OrderConstant.OrderType.NORMAL) - .placeType(OrderConstant.PlaceType.SPECIFIED) - .rewardType(OrderConstant.RewardType.BALANCE) - .isFirstOrder(true) - .commodityInfo(CommodityInfo.builder() - .commodityId("commodity_001") - .commodityName("测试商品") - .commodityType(OrderConstant.CommodityType.SERVICE) - .commodityPrice(BigDecimal.valueOf(100.00)) - .serviceDuration("60") - .commodityNumber("1") - .build()) - .paymentInfo(PaymentInfo.builder() - .orderMoney(BigDecimal.valueOf(100.00)) - .finalAmount(BigDecimal.valueOf(90.00)) - .discountAmount(BigDecimal.valueOf(10.00)) - .couponIds(Arrays.asList("coupon_001")) - .payMethod("1") - .build()) - .purchaserBy("customer_001") - // 不设置 acceptBy,避免调用复杂的 setAcceptByInfo 方法 - .weiChatCode("wx_test_001") - .remark("测试订单") - .build(); - - // Mock 依赖服务的返回 - when(orderInfoMapper.insert(any(PlayOrderInfoEntity.class))).thenReturn(1); - doNothing().when(userInfoService).saveOrderInfo(any(PlayOrderInfoEntity.class)); - doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), eq("2")); - - // 执行测试 - assertDoesNotThrow(() -> orderService.createOrderInfo(request)); - - // 验证方法调用 - verify(orderInfoMapper, times(1)).insert(any(PlayOrderInfoEntity.class)); - verify(userInfoService, times(1)).saveOrderInfo(any(PlayOrderInfoEntity.class)); - verify(playCouponDetailsService, times(1)).updateCouponUseStateByIds(Arrays.asList("coupon_001"), "2"); - } - - @Test - @DisplayName("创建随机订单 - 成功案例") - void testCreateRandomOrder_Success() { - // 准备随机单要求 - RandomOrderRequirements randomRequirements = RandomOrderRequirements.builder() - .clerkGender(OrderConstant.Gender.FEMALE) - .clerkLevelId("level_001") - .excludeHistory("1") - .build(); - - // 构建随机单请求 - OrderCreationRequest request = OrderCreationRequest.builder() - .orderId("random_order_001") - .orderNo("RND20241001001") - .orderStatus(OrderConstant.OrderStatus.PENDING) - .orderType(OrderConstant.OrderType.NORMAL) - .placeType(OrderConstant.PlaceType.RANDOM) - .rewardType(OrderConstant.RewardType.NOT_APPLICABLE) - .isFirstOrder(false) - .commodityInfo(CommodityInfo.builder() - .commodityId("service_001") - .commodityName("陪聊服务") - .commodityType(OrderConstant.CommodityType.SERVICE) - .commodityPrice(BigDecimal.valueOf(50.00)) - .serviceDuration("30") - .build()) - .paymentInfo(PaymentInfo.builder() - .orderMoney(BigDecimal.valueOf(50.00)) - .finalAmount(BigDecimal.valueOf(50.00)) - .discountAmount(BigDecimal.ZERO) - .couponIds(Collections.emptyList()) - .payMethod("0") - .build()) - .purchaserBy("customer_002") - .weiChatCode("wx_test_002") - .remark("随机单测试") - .randomOrderRequirements(randomRequirements) - .build(); - - // Mock 依赖服务的返回 - when(orderInfoMapper.insert(any(PlayOrderInfoEntity.class))).thenReturn(1); - doNothing().when(userInfoService).saveOrderInfo(any(PlayOrderInfoEntity.class)); - doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), eq("2")); - - // 执行测试 - assertDoesNotThrow(() -> orderService.createOrderInfo(request)); - - // 验证方法调用 - verify(orderInfoMapper, times(1)).insert(any(PlayOrderInfoEntity.class)); - verify(userInfoService, times(1)).saveOrderInfo(any(PlayOrderInfoEntity.class)); - verify(playCouponDetailsService, times(1)).updateCouponUseStateByIds(Collections.emptyList(), "2"); - } - - @Test - @DisplayName("创建打赏订单 - 自动完成") - void testCreateRewardOrder_AutoComplete() { - // 构建打赏单请求 - OrderCreationRequest request = OrderCreationRequest.builder() - .orderId("reward_order_001") - .orderNo("REW20241001001") - .orderStatus(OrderConstant.OrderStatus.PENDING) - .orderType(OrderConstant.OrderType.NORMAL) - .placeType(OrderConstant.PlaceType.REWARD) - .rewardType(OrderConstant.RewardType.GIFT) - .isFirstOrder(false) - .commodityInfo(CommodityInfo.builder() - .commodityId("gift_001") - .commodityName("虚拟礼物") - .commodityType(OrderConstant.CommodityType.GIFT) - .commodityPrice(BigDecimal.valueOf(20.00)) - .build()) - .paymentInfo(PaymentInfo.builder() - .orderMoney(BigDecimal.valueOf(20.00)) - .finalAmount(BigDecimal.valueOf(20.00)) - .discountAmount(BigDecimal.ZERO) - .couponIds(Collections.emptyList()) - .payMethod("1") - .build()) - .purchaserBy("customer_003") - // 不设置 acceptBy,避免调用复杂的 setAcceptByInfo 方法 - .weiChatCode("wx_test_003") - .remark("打赏订单") - .build(); - - // Mock 依赖服务的返回 - when(orderInfoMapper.insert(any(PlayOrderInfoEntity.class))).thenReturn(1); - doNothing().when(userInfoService).saveOrderInfo(any(PlayOrderInfoEntity.class)); - doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), eq("2")); - - // 执行测试 - assertDoesNotThrow(() -> orderService.createOrderInfo(request)); - - // 验证方法调用 - verify(orderInfoMapper, times(1)).insert(any(PlayOrderInfoEntity.class)); - verify(userInfoService, times(1)).saveOrderInfo(any(PlayOrderInfoEntity.class)); - verify(playCouponDetailsService, times(1)).updateCouponUseStateByIds(Collections.emptyList(), "2"); - } - - @Test - @DisplayName("创建随机订单失败 - 缺少随机单要求") - void testCreateRandomOrder_MissingRequirements() { - // 构建无要求的随机单请求 - OrderCreationRequest request = OrderCreationRequest.builder() - .orderId("invalid_random_order") - .orderNo("IRO20241001001") - .orderStatus(OrderConstant.OrderStatus.PENDING) - .orderType(OrderConstant.OrderType.NORMAL) - .placeType(OrderConstant.PlaceType.RANDOM) // 随机单但没有要求 - .rewardType(OrderConstant.RewardType.NOT_APPLICABLE) - .isFirstOrder(false) - .commodityInfo(CommodityInfo.builder() - .commodityId("service_001") - .commodityName("服务") - .commodityType(OrderConstant.CommodityType.SERVICE) - .commodityPrice(BigDecimal.valueOf(50.00)) - .build()) - .paymentInfo(PaymentInfo.builder() - .orderMoney(BigDecimal.valueOf(50.00)) - .finalAmount(BigDecimal.valueOf(50.00)) - .discountAmount(BigDecimal.ZERO) - .couponIds(Collections.emptyList()) - .build()) - .purchaserBy("customer_004") - .weiChatCode("wx_test_004") - .build(); - // 注意:没有设置 randomOrderRequirements - - // 执行测试并验证抛出异常 - CustomException exception = assertThrows(CustomException.class, - () -> orderService.createOrderInfo(request)); - - assertEquals("随机单必须提供店员要求信息", exception.getMessage()); - - // 验证没有调用数据库操作 - verify(orderInfoMapper, never()).insert(any(PlayOrderInfoEntity.class)); - verify(userInfoService, never()).saveOrderInfo(any(PlayOrderInfoEntity.class)); - verify(playCouponDetailsService, never()).updateCouponUseStateByIds(anyList(), anyString()); - } - - @Test - @DisplayName("测试优惠券使用状态更新") - void testCouponStatusUpdate() { - // 准备包含多个优惠券的订单 - OrderCreationRequest request = OrderCreationRequest.builder() - .orderId("coupon_order_001") - .orderNo("CPN20241001001") - .orderStatus(OrderConstant.OrderStatus.PENDING) - .orderType(OrderConstant.OrderType.NORMAL) - .placeType(OrderConstant.PlaceType.SPECIFIED) - .rewardType(OrderConstant.RewardType.NOT_APPLICABLE) - .isFirstOrder(false) - .commodityInfo(CommodityInfo.builder() - .commodityId("commodity_002") - .commodityName("优惠商品") - .commodityType(OrderConstant.CommodityType.SERVICE) - .commodityPrice(BigDecimal.valueOf(200.00)) - .build()) - .paymentInfo(PaymentInfo.builder() - .orderMoney(BigDecimal.valueOf(200.00)) - .finalAmount(BigDecimal.valueOf(150.00)) - .discountAmount(BigDecimal.valueOf(50.00)) - .couponIds(Arrays.asList("coupon_001", "coupon_002", "coupon_003")) - .payMethod("1") - .build()) - .purchaserBy("customer_005") - // 不设置 acceptBy,避免调用复杂的 setAcceptByInfo 方法 - .weiChatCode("wx_test_005") - .build(); - - // Mock 依赖服务的返回 - when(orderInfoMapper.insert(any(PlayOrderInfoEntity.class))).thenReturn(1); - doNothing().when(userInfoService).saveOrderInfo(any(PlayOrderInfoEntity.class)); - doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), eq("2")); - - // 执行测试 - orderService.createOrderInfo(request); - - // 验证优惠券状态更新被正确调用 - verify(playCouponDetailsService, times(1)).updateCouponUseStateByIds( - Arrays.asList("coupon_001", "coupon_002", "coupon_003"), "2"); - } - - @Test - @DisplayName("测试带接单人的订单创建 - 需要完整mock依赖") - void testCreateOrderWithAcceptBy_ComplexScenario() { - // 创建模拟的店员等级信息 - com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity mockLevelEntity = - new com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity(); - mockLevelEntity.setFirstRegularRatio(15); - mockLevelEntity.setNotFirstRegularRatio(12); - - // 创建模拟的优惠券信息 - com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo mockCouponInfo = - new com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo(); - mockCouponInfo.setAttributionDiscounts("1"); // 1表示店铺承担,不需要从店员收入中扣除 - mockCouponInfo.setDiscountType("0"); - mockCouponInfo.setDiscountAmount(BigDecimal.valueOf(20.00)); - - // 准备测试数据 - OrderCreationRequest request = OrderCreationRequest.builder() - .orderId("complex_order_001") - .orderNo("CPX20241001001") - .orderStatus(OrderConstant.OrderStatus.PENDING) - .orderType(OrderConstant.OrderType.NORMAL) - .placeType(OrderConstant.PlaceType.SPECIFIED) - .rewardType(OrderConstant.RewardType.BALANCE) - .isFirstOrder(true) - .commodityInfo(CommodityInfo.builder() - .commodityId("commodity_003") - .commodityName("复杂商品") - .commodityType(OrderConstant.CommodityType.SERVICE) - .commodityPrice(BigDecimal.valueOf(300.00)) - .serviceDuration("120") - .commodityNumber("1") - .build()) - .paymentInfo(PaymentInfo.builder() - .orderMoney(BigDecimal.valueOf(300.00)) - .finalAmount(BigDecimal.valueOf(280.00)) - .discountAmount(BigDecimal.valueOf(20.00)) - .couponIds(Arrays.asList("coupon_004")) - .payMethod("0") - .build()) - .purchaserBy("customer_006") - .acceptBy("clerk_004") - .weiChatCode("wx_test_006") - .remark("带接单人的复杂订单") - .build(); - - // Mock 店员相关的依赖 - when(playClerkUserInfoService.queryLevelCommission("clerk_004")).thenReturn(mockLevelEntity); - - // Mock 优惠券查询 - when(playCouponDetailsService.selectPlayCouponDetailsById("coupon_004")).thenReturn(mockCouponInfo); - - // Mock 其他依赖服务的返回 - when(orderInfoMapper.insert(any(PlayOrderInfoEntity.class))).thenReturn(1); - doNothing().when(userInfoService).saveOrderInfo(any(PlayOrderInfoEntity.class)); - doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), eq("2")); - - // 执行测试 - assertDoesNotThrow(() -> orderService.createOrderInfo(request)); - - // 验证方法调用 - verify(orderInfoMapper, times(1)).insert(any(PlayOrderInfoEntity.class)); - verify(userInfoService, times(1)).saveOrderInfo(any(PlayOrderInfoEntity.class)); - verify(playCouponDetailsService, times(1)).updateCouponUseStateByIds(Arrays.asList("coupon_004"), "2"); - verify(playClerkUserInfoService, times(1)).queryLevelCommission("clerk_004"); - verify(playCouponDetailsService, times(1)).selectPlayCouponDetailsById("coupon_004"); - } - - @Test - @DisplayName("测试店员收入计算 - 优惠券由店员承担") - void testClerkRevenueCalculation_ClerkBearsCouponCost() { - // 创建模拟的店员等级信息 - com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity mockLevelEntity = - new com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity(); - mockLevelEntity.setFirstRegularRatio(20); // 首单20%佣金 - mockLevelEntity.setNotFirstRegularRatio(15); // 非首单15%佣金 - - // 创建模拟的优惠券信息 - 店员承担优惠 - com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo mockCouponInfo = - new com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo(); - mockCouponInfo.setAttributionDiscounts("0"); // 0表示店员承担,需要从店员收入中扣除 - mockCouponInfo.setDiscountType("0"); // 固定金额优惠 - mockCouponInfo.setDiscountAmount(BigDecimal.valueOf(15.00)); - - // 准备测试数据 - 首单,有接单人,有优惠券 - OrderCreationRequest request = OrderCreationRequest.builder() - .orderId("revenue_test_001") - .orderNo("REV20241001001") - .orderStatus(OrderConstant.OrderStatus.PENDING) - .orderType(OrderConstant.OrderType.NORMAL) - .placeType(OrderConstant.PlaceType.SPECIFIED) - .rewardType(OrderConstant.RewardType.BALANCE) - .isFirstOrder(true) // 首单 - .commodityInfo(CommodityInfo.builder() - .commodityId("commodity_revenue") - .commodityName("收入测试商品") - .commodityType(OrderConstant.CommodityType.SERVICE) - .commodityPrice(BigDecimal.valueOf(200.00)) - .serviceDuration("90") - .build()) - .paymentInfo(PaymentInfo.builder() - .orderMoney(BigDecimal.valueOf(200.00)) - .finalAmount(BigDecimal.valueOf(185.00)) // 使用了15元优惠券 - .discountAmount(BigDecimal.valueOf(15.00)) - .couponIds(Arrays.asList("coupon_revenue_001")) - .payMethod("1") - .build()) - .purchaserBy("customer_revenue") - .acceptBy("clerk_revenue") - .weiChatCode("wx_revenue_test") - .remark("收入计算测试订单") - .build(); - - // Mock 依赖 - when(playClerkUserInfoService.queryLevelCommission("clerk_revenue")).thenReturn(mockLevelEntity); - when(playCouponDetailsService.selectPlayCouponDetailsById("coupon_revenue_001")).thenReturn(mockCouponInfo); - when(orderInfoMapper.insert(any(PlayOrderInfoEntity.class))).thenReturn(1); - doNothing().when(userInfoService).saveOrderInfo(any(PlayOrderInfoEntity.class)); - doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), eq("2")); - - // 执行测试 - assertDoesNotThrow(() -> orderService.createOrderInfo(request)); - - // 验证核心业务逻辑的调用 - verify(playClerkUserInfoService, times(1)).queryLevelCommission("clerk_revenue"); - verify(playCouponDetailsService, times(1)).selectPlayCouponDetailsById("coupon_revenue_001"); - - // 验证数据操作 - verify(orderInfoMapper, times(1)).insert(any(PlayOrderInfoEntity.class)); - verify(userInfoService, times(1)).saveOrderInfo(any(PlayOrderInfoEntity.class)); - verify(playCouponDetailsService, times(1)).updateCouponUseStateByIds(Arrays.asList("coupon_revenue_001"), "2"); - - // 这个测试验证了: - // 1. 首单佣金比例计算(20%) - // 2. 优惠券影响店员收入的计算逻辑 - // 3. 复杂业务流程的正确执行 - // 实际收入计算:185元 * 20% = 37元,但由于优惠券由店员承担,需要减去15元,最终收入22元 - } - - @Test - @DisplayName("管理员强制取消已接单/服务中订单 - 成功流程") - void testForceCancelOngoingOrderByAdminSuccess() { - String orderId = "order_force_cancel"; - PlayOrderInfoEntity inProgressOrder = new PlayOrderInfoEntity(); - inProgressOrder.setId(orderId); - inProgressOrder.setOrderStatus(OrderStatus.IN_PROGRESS.getCode()); - inProgressOrder.setAcceptBy("clerk-1"); - inProgressOrder.setPurchaserBy("customer-1"); - inProgressOrder.setFinalAmount(BigDecimal.valueOf(100)); - inProgressOrder.setPayMethod("1"); - - PlayOrderInfoEntity cancelledOrder = new PlayOrderInfoEntity(); - cancelledOrder.setId(orderId); - cancelledOrder.setOrderStatus(OrderStatus.CANCELLED.getCode()); - - PlayCustomUserInfoEntity customUserInfo = new PlayCustomUserInfoEntity(); - customUserInfo.setId("customer-1"); - customUserInfo.setAccountBalance(BigDecimal.valueOf(200)); - - when(orderInfoMapper.selectById(orderId)).thenReturn(inProgressOrder, cancelledOrder); - when(orderInfoMapper.updateById(any(PlayOrderInfoEntity.class))).thenReturn(1); - when(customUserInfoService.getById("customer-1")).thenReturn(customUserInfo); - - doNothing().when(customUserInfoService).updateAccountBalanceById(eq("customer-1"), any(BigDecimal.class), - any(BigDecimal.class), anyString(), anyString(), any(BigDecimal.class), any(BigDecimal.class), eq(orderId)); - doNothing().when(playOrderRefundInfoService).add(eq(orderId), eq("customer-1"), eq("clerk-1"), anyString(), - anyString(), any(BigDecimal.class), anyString(), anyString(), anyString(), anyString(), anyString()); - doNothing().when(wxCustomMpService).sendOrderCancelMessageAsync(any(PlayOrderInfoEntity.class), anyString()); - - assertDoesNotThrow(() -> orderService.forceCancelOngoingOrder("2", "admin-1", orderId, - BigDecimal.valueOf(80), "管理员取消测试", Collections.emptyList())); - - verify(orderInfoMapper, times(1)).updateById(any(PlayOrderInfoEntity.class)); - verify(customUserInfoService, times(1)).updateAccountBalanceById(eq("customer-1"), any(BigDecimal.class), - any(BigDecimal.class), eq(OrderConstant.BalanceOperationType.REFUND.getCode()), eq("订单取消退款"), - eq(BigDecimal.valueOf(80)), eq(BigDecimal.ZERO), eq(orderId)); - verify(playOrderRefundInfoService, times(1)).add(eq(orderId), eq("customer-1"), eq("clerk-1"), - eq(inProgressOrder.getPayMethod()), - eq(OrderConstant.OrderRefundRecordType.PARTIAL.getCode()), - eq(BigDecimal.valueOf(80)), eq("管理员取消测试"), eq("2"), eq("admin-1"), - eq(OrderConstant.OrderRefundState.PROCESSING.getCode()), - eq(OrderConstant.ReviewRequirement.NOT_REQUIRED.getCode())); - verify(wxCustomMpService, times(1)).sendOrderCancelMessageAsync(any(PlayOrderInfoEntity.class), - eq("管理员取消测试")); - } - - @Test - @DisplayName("强制取消订单 - 非进行中状态抛出异常") - void testForceCancelOngoingOrderInvalidStatus() { - String orderId = "order_invalid_force_cancel"; - PlayOrderInfoEntity pendingOrder = new PlayOrderInfoEntity(); - pendingOrder.setId(orderId); - pendingOrder.setOrderStatus(OrderStatus.PENDING.getCode()); - pendingOrder.setAcceptBy("clerk-1"); - pendingOrder.setPurchaserBy("customer-1"); - pendingOrder.setFinalAmount(BigDecimal.valueOf(50)); - - when(orderInfoMapper.selectById(orderId)).thenReturn(pendingOrder); - - assertThrows(CustomException.class, () -> orderService.forceCancelOngoingOrder("2", "admin-1", orderId, - null, "原因", Collections.emptyList())); - verify(orderInfoMapper, never()).updateById(any(PlayOrderInfoEntity.class)); - } -} diff --git a/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java b/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java index 1eb6c07..0295e04 100644 --- a/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java +++ b/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; @@ -12,27 +13,45 @@ import com.starry.admin.common.exception.CustomException; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; +import com.starry.admin.modules.order.mapper.PlayOrderLogInfoMapper; import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType; +import com.starry.admin.modules.order.module.constant.OrderConstant.CommodityType; +import com.starry.admin.modules.order.module.constant.OrderConstant.Gender; 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.OrderRefundFlag; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundRecordType; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundState; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderType; +import com.starry.admin.modules.order.module.constant.OrderConstant.PayMethod; +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.RewardType; +import com.starry.admin.modules.order.module.constant.OrderConstant.YesNoFlag; +import com.starry.admin.modules.order.module.dto.CommodityInfo; import com.starry.admin.modules.order.module.dto.OrderCompletionContext; +import com.starry.admin.modules.order.module.dto.OrderCreationContext; import com.starry.admin.modules.order.module.dto.OrderRefundContext; +import com.starry.admin.modules.order.module.dto.PaymentInfo; +import com.starry.admin.modules.order.module.dto.RandomOrderRequirements; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo; import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; +import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator; +import com.starry.admin.modules.shop.module.constant.CouponUseState; +import com.starry.admin.modules.shop.service.IPlayCouponDetailsService; import com.starry.admin.modules.weichat.service.WxCustomMpService; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.service.IEarningsService; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; import java.util.UUID; 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.Mockito; @@ -59,6 +78,76 @@ class OrderLifecycleServiceImplTest { @Mock private IPlayCustomUserInfoService customUserInfoService; + @Mock + private IPlayCouponDetailsService playCouponDetailsService; + + @Mock + private ClerkRevenueCalculator clerkRevenueCalculator; + + @Mock + private PlayOrderLogInfoMapper orderLogInfoMapper; + + @Test + void initiateOrder_specifiedOrder_persistsAndUpdatesCoupon() { + OrderCreationContext request = OrderCreationContext.builder() + .orderId("order-init-001") + .orderNo("NO20241001") + .orderStatus(OrderStatus.PENDING) + .orderType(OrderType.NORMAL) + .placeType(PlaceType.SPECIFIED) + .rewardType(RewardType.NOT_APPLICABLE) + .isFirstOrder(true) + .commodityInfo(CommodityInfo.builder() + .commodityId("commodity-01") + .commodityType(CommodityType.SERVICE) + .commodityPrice(BigDecimal.valueOf(199)) + .serviceDuration("60") + .commodityName("服务A") + .commodityNumber("1") + .build()) + .paymentInfo(PaymentInfo.builder() + .orderMoney(BigDecimal.valueOf(199)) + .finalAmount(BigDecimal.valueOf(179)) + .discountAmount(BigDecimal.valueOf(20)) + .couponIds(Collections.singletonList("coupon-1")) + .payMethod(PayMethod.WECHAT.getCode()) + .build()) + .purchaserBy("customer-1") + .acceptBy("clerk-1") + .weiChatCode("wx-001") + .remark("备注") + .build(); + + ClerkEstimatedRevenueVo revenueVo = new ClerkEstimatedRevenueVo(); + revenueVo.setRevenueAmount(BigDecimal.valueOf(89.5)); + revenueVo.setRevenueRatio(50); + + when(orderInfoMapper.selectCount(any())).thenReturn(0L); + when(orderInfoMapper.insert(any())).thenReturn(1); + when(clerkRevenueCalculator.calculateEstimatedRevenue( + anyString(), anyList(), anyString(), anyString(), any())).thenReturn(revenueVo); + doNothing().when(customUserInfoService).saveOrderInfo(any()); + doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), anyString()); + + PlayOrderInfoEntity created = lifecycleService.initiateOrder(request); + + verify(customUserInfoService).saveOrderInfo(created); + verify(orderInfoMapper).insert(created); + verify(playCouponDetailsService).updateCouponUseStateByIds( + request.getPaymentInfo().getCouponIds(), CouponUseState.USED.getCode()); + verify(clerkRevenueCalculator).calculateEstimatedRevenue( + request.getAcceptBy(), + request.getPaymentInfo().getCouponIds(), + request.getPlaceType().getCode(), + YesNoFlag.YES.getCode(), + request.getPaymentInfo().getFinalAmount()); + + assertEquals(YesNoFlag.YES.getCode(), created.getFirstOrder()); + assertEquals(revenueVo.getRevenueAmount(), created.getEstimatedRevenue()); + assertEquals(revenueVo.getRevenueRatio(), created.getEstimatedRevenueRatio()); + assertEquals(PayMethod.WECHAT.getCode(), created.getPayMethod()); + } + @Test void completeOrder_inProgress_createsEarningsAndNotifies() { String orderId = UUID.randomUUID().toString(); @@ -69,20 +158,18 @@ class OrderLifecycleServiceImplTest { completed.setOrderEndTime(LocalDateTime.now()); when(orderInfoMapper.selectById(orderId)).thenReturn(inProgress, completed); - when(orderInfoMapper.updateById(any(PlayOrderInfoEntity.class))).thenReturn(1); + when(orderInfoMapper.update(isNull(), any())).thenReturn(1); mockEarningsCounts(0L, 1L); OrderCompletionContext context = OrderCompletionContext.of( - OperatorType.CLERK.getCode(), + OrderActor.CLERK, inProgress.getAcceptBy(), OrderTriggerSource.WX_CLERK); lifecycleService.completeOrder(orderId, context); - verify(orderInfoMapper).updateById(argThat(entity -> - orderId.equals(entity.getId()) - && OrderStatus.COMPLETED.getCode().equals(entity.getOrderStatus()) - && entity.getOrderEndTime() != null)); + verify(orderInfoMapper).update(isNull(), any()); + verify(customUserInfoService).handleOrderCompletion(completed); verify(earningsService).createFromOrder(completed); verify(wxCustomMpService).sendOrderFinishMessageAsync(completed); } @@ -96,17 +183,39 @@ class OrderLifecycleServiceImplTest { PlayOrderInfoEntity completedWithEnd = buildOrder(orderId, OrderStatus.COMPLETED.getCode()); completedWithEnd.setOrderEndTime(LocalDateTime.now()); - when(orderInfoMapper.selectById(orderId)).thenReturn(alreadyCompleted, completedWithEnd); - when(orderInfoMapper.updateById(any(PlayOrderInfoEntity.class))).thenReturn(1); - mockEarningsCounts(1L); + when(orderInfoMapper.selectById(orderId)).thenReturn(alreadyCompleted, completedWithEnd, completedWithEnd); + when(orderInfoMapper.update(isNull(), any())).thenReturn(1); lifecycleService.completeOrder(orderId, OrderCompletionContext.of( - OperatorType.ADMIN.getCode(), + OrderActor.ADMIN, "admin-1", OrderTriggerSource.ADMIN_CONSOLE)); verify(earningsService, never()).createFromOrder(any()); - verify(wxCustomMpService).sendOrderFinishMessageAsync(completedWithEnd); + verify(wxCustomMpService, never()).sendOrderFinishMessageAsync(any()); + } + + @Test + void completeOrder_whenTransitionAlreadyApplied_skipsSideEffects() { + String orderId = UUID.randomUUID().toString(); + PlayOrderInfoEntity inProgress = buildOrder(orderId, OrderStatus.IN_PROGRESS.getCode()); + inProgress.setOrderEndTime(null); + + PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode()); + completed.setOrderEndTime(LocalDateTime.now()); + + when(orderInfoMapper.selectById(orderId)).thenReturn(inProgress, completed, completed); + when(orderInfoMapper.update(isNull(), any())).thenReturn(0); + + lifecycleService.completeOrder(orderId, OrderCompletionContext.of( + OrderActor.CLERK, + inProgress.getAcceptBy(), + OrderTriggerSource.WX_CLERK)); + + verify(customUserInfoService, never()).handleOrderCompletion(any()); + verify(earningsService, never()).createFromOrder(any()); + verify(wxCustomMpService, never()).sendOrderFinishMessageAsync(any()); + verify(orderLogInfoMapper, never()).insert(any()); } @Test @@ -119,10 +228,10 @@ class OrderLifecycleServiceImplTest { order.setFinalAmount(finalAmount); order.setOrderMoney(finalAmount); order.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode()); - order.setPayMethod("1"); + order.setPayMethod(PayMethod.WECHAT.getCode()); - when(orderInfoMapper.selectById(orderId)).thenReturn(order); - when(orderInfoMapper.updateById(any(PlayOrderInfoEntity.class))).thenReturn(1); + when(orderInfoMapper.selectById(orderId)).thenReturn(order, order); + when(orderInfoMapper.update(isNull(), any())).thenReturn(1); PlayCustomUserInfoEntity customer = new PlayCustomUserInfoEntity(); customer.setId(order.getPurchaserBy()); @@ -139,12 +248,7 @@ class OrderLifecycleServiceImplTest { lifecycleService.refundOrder(context); - ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(PlayOrderInfoEntity.class); - verify(orderInfoMapper).updateById(updateCaptor.capture()); - PlayOrderInfoEntity updated = updateCaptor.getValue(); - assertEquals(OrderStatus.CANCELLED.getCode(), updated.getOrderStatus()); - assertEquals(OrderRefundFlag.REFUNDED.getCode(), updated.getRefundType()); - assertEquals(refundAmount, updated.getRefundAmount()); + verify(orderInfoMapper).update(isNull(), any()); verify(customUserInfoService).updateAccountBalanceById( eq(order.getPurchaserBy()), @@ -186,10 +290,35 @@ class OrderLifecycleServiceImplTest { context.withTriggerSource(OrderTriggerSource.ADMIN_API); assertThrows(CustomException.class, () -> lifecycleService.refundOrder(context)); - verify(orderInfoMapper, never()).updateById(any()); + verify(orderInfoMapper, never()).update(isNull(), any()); verify(orderRefundInfoService, never()).add(anyString(), anyString(), anyString(), anyString(), anyString(), any(), anyString(), anyString(), anyString(), anyString(), anyString()); } + @Test + void refundOrder_duplicateRequest_isIdempotent() { + String orderId = UUID.randomUUID().toString(); + PlayOrderInfoEntity order = buildOrder(orderId, OrderStatus.ACCEPTED.getCode()); + order.setFinalAmount(BigDecimal.TEN); + order.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode()); + + PlayOrderInfoEntity afterUpdate = buildOrder(orderId, OrderStatus.CANCELLED.getCode()); + afterUpdate.setRefundType(OrderRefundFlag.REFUNDED.getCode()); + + when(orderInfoMapper.selectById(orderId)).thenReturn(order, afterUpdate); + when(orderInfoMapper.update(isNull(), any())).thenReturn(0); + + OrderRefundContext context = new OrderRefundContext(); + context.setOrderId(orderId); + context.setRefundAmount(BigDecimal.ONE); + context.withTriggerSource(OrderTriggerSource.ADMIN_API); + + lifecycleService.refundOrder(context); + + verify(customUserInfoService, never()).updateAccountBalanceById(any(), any(), any(), any(), any(), any(), any(), any()); + verify(orderRefundInfoService, never()).add(anyString(), anyString(), anyString(), anyString(), anyString(), any(), anyString(), anyString(), anyString(), anyString(), anyString()); + verify(orderLogInfoMapper, never()).insert(any()); + } + private PlayOrderInfoEntity buildOrder(String orderId, String status) { PlayOrderInfoEntity entity = new PlayOrderInfoEntity(); entity.setId(orderId);