From e616dd6a135e16b026c4706b3b288f38406acd43 Mon Sep 17 00:00:00 2001 From: irving Date: Mon, 10 Nov 2025 23:42:00 -0500 Subject: [PATCH] WIP --- .../controller/PlayOrderInfoController.java | 21 ++++ .../OrderRevocationBalanceListener.java | 54 +++++++++ .../OrderRevocationEarningsListener.java | 56 +++++++++ .../order/module/constant/OrderConstant.java | 3 +- .../module/dto/OrderRevocationContext.java | 56 +++++++++ .../module/event/OrderRevocationEvent.java | 17 +++ .../module/vo/PlayOrderRevocationVo.java | 32 +++++ .../order/service/IOrderLifecycleService.java | 3 + .../order/service/IPlayOrderInfoService.java | 2 + .../impl/OrderLifecycleServiceImpl.java | 114 +++++++++++++++++- .../impl/PlayOrderInfoServiceImpl.java | 8 ++ .../withdraw/service/IEarningsService.java | 6 + .../service/impl/EarningsServiceImpl.java | 48 ++++++++ .../admin/api/WxBlindBoxOrderApiTest.java | 1 + .../OrderRevocationEarningsListenerTest.java | 80 ++++++++++++ .../impl/OrderLifecycleServiceImplTest.java | 105 ++++++++++++++++ .../service/impl/EarningsServiceImplTest.java | 52 ++++++++ 17 files changed, 655 insertions(+), 3 deletions(-) create mode 100644 play-admin/src/main/java/com/starry/admin/modules/order/listener/OrderRevocationBalanceListener.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListener.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderRevocationContext.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/order/module/event/OrderRevocationEvent.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/order/module/vo/PlayOrderRevocationVo.java create mode 100644 play-admin/src/test/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListenerTest.java diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/controller/PlayOrderInfoController.java b/play-admin/src/main/java/com/starry/admin/modules/order/controller/PlayOrderInfoController.java index 48f9279..866d48c 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/controller/PlayOrderInfoController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/controller/PlayOrderInfoController.java @@ -7,6 +7,7 @@ import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource; import com.starry.admin.modules.order.module.dto.OrderRefundContext; +import com.starry.admin.modules.order.module.dto.OrderRevocationContext; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.vo.*; import com.starry.admin.modules.order.service.IOrderLifecycleService; @@ -106,6 +107,26 @@ public class PlayOrderInfoController { return R.ok("退款成功"); } + @ApiOperation(value = "撤销已完成订单", notes = "管理员操作撤销,支持可选退款与收益处理") + @PostMapping("/revokeCompleted") + public R revokeCompleted(@Validated @RequestBody PlayOrderRevocationVo vo) { + OrderRevocationContext context = new OrderRevocationContext(); + context.setOrderId(vo.getOrderId()); + context.setRefundToCustomer(vo.isRefundToCustomer()); + context.setRefundAmount(vo.getRefundAmount()); + context.setRefundReason(vo.getRefundReason()); + OrderRevocationContext.EarningsAdjustStrategy strategy = vo.getEarningsStrategy() != null + ? vo.getEarningsStrategy() + : OrderRevocationContext.EarningsAdjustStrategy.NONE; + context.setEarningsStrategy(strategy); + context.setCounterClerkId(vo.getCounterClerkId()); + context.setOperatorType(OperatorType.ADMIN.getCode()); + context.setOperatorId(SecurityUtils.getUserId()); + context.withTriggerSource(OrderTriggerSource.ADMIN_API); + orderLifecycleService.revokeCompletedOrder(context); + return R.ok("撤销成功"); + } + /** * 管理后台强制取消进行中订单 */ diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/listener/OrderRevocationBalanceListener.java b/play-admin/src/main/java/com/starry/admin/modules/order/listener/OrderRevocationBalanceListener.java new file mode 100644 index 0000000..67670c3 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/listener/OrderRevocationBalanceListener.java @@ -0,0 +1,54 @@ +package com.starry.admin.modules.order.listener; + +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.module.constant.OrderConstant.BalanceOperationType; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.module.event.OrderRevocationEvent; +import java.math.BigDecimal; +import java.util.Optional; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class OrderRevocationBalanceListener { + + private final IPlayCustomUserInfoService customUserInfoService; + + public OrderRevocationBalanceListener(IPlayCustomUserInfoService customUserInfoService) { + this.customUserInfoService = customUserInfoService; + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(OrderRevocationEvent event) { + if (event == null || event.getContext() == null || event.getOrderSnapshot() == null) { + return; + } + if (!event.getContext().isRefundToCustomer()) { + return; + } + + BigDecimal refundAmount = Optional.ofNullable(event.getContext().getRefundAmount()).orElse(BigDecimal.ZERO); + if (refundAmount.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + + PlayOrderInfoEntity order = event.getOrderSnapshot(); + PlayCustomUserInfoEntity customer = customUserInfoService.getById(order.getPurchaserBy()); + if (customer == null) { + throw new CustomException("顾客信息不存在"); + } + BigDecimal currentBalance = Optional.ofNullable(customer.getAccountBalance()).orElse(BigDecimal.ZERO); + customUserInfoService.updateAccountBalanceById( + customer.getId(), + currentBalance, + currentBalance.add(refundAmount), + BalanceOperationType.REFUND.getCode(), + "已完成订单撤销退款", + refundAmount, + BigDecimal.ZERO, + order.getId()); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListener.java b/play-admin/src/main/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListener.java new file mode 100644 index 0000000..277de43 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListener.java @@ -0,0 +1,56 @@ +package com.starry.admin.modules.order.listener; + +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.order.module.dto.OrderRevocationContext; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.module.event.OrderRevocationEvent; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import java.math.BigDecimal; +import java.util.Optional; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class OrderRevocationEarningsListener { + + private final IEarningsService earningsService; + + public OrderRevocationEarningsListener(IEarningsService earningsService) { + this.earningsService = earningsService; + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(OrderRevocationEvent event) { + if (event == null || event.getContext() == null || event.getOrderSnapshot() == null) { + return; + } + OrderRevocationContext context = event.getContext(); + switch (context.getEarningsStrategy()) { + case NONE: + return; + case REVERSE_CLERK: + earningsService.reverseByOrder(event.getOrderSnapshot().getId(), context.getOperatorId()); + return; + case COUNTER_TO_PEIPEI: + createCounterLine(event); + return; + default: + throw new CustomException("未知的收益处理策略"); + } + } + + private void createCounterLine(OrderRevocationEvent event) { + OrderRevocationContext context = event.getContext(); + PlayOrderInfoEntity order = event.getOrderSnapshot(); + String targetClerkId = context.getCounterClerkId(); + if (targetClerkId == null) { + throw new CustomException("需要指定收益冲销目标账号"); + } + BigDecimal amount = context.getRefundAmount(); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + amount = Optional.ofNullable(order.getEstimatedRevenue()).orElse(BigDecimal.ZERO); + } + earningsService.createCounterLine(order.getId(), order.getTenantId(), targetClerkId, amount, context.getOperatorId()); + } +} 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 e2b42f6..e7c5a35 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 @@ -19,7 +19,8 @@ public class OrderConstant { ACCEPTED("1", "已接单(待开始)"), IN_PROGRESS("2", "已开始(服务中)"), COMPLETED("3", "已完成"), - CANCELLED("4", "已取消"); + CANCELLED("4", "已取消"), + REVOKED("5", "已撤销"); private final String code; private final String description; diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderRevocationContext.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderRevocationContext.java new file mode 100644 index 0000000..8c07e42 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderRevocationContext.java @@ -0,0 +1,56 @@ +package com.starry.admin.modules.order.module.dto; + +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource; +import java.math.BigDecimal; +import javax.validation.constraints.NotBlank; +import lombok.Data; +import org.springframework.lang.Nullable; + +@Data +public class OrderRevocationContext { + + @NotBlank + private String orderId; + + @Nullable + private String operatorId; + + @Nullable + private String operatorType; + + @Nullable + private BigDecimal refundAmount; + + @Nullable + private String refundReason; + + private boolean refundToCustomer; + + private EarningsAdjustStrategy earningsStrategy = EarningsAdjustStrategy.NONE; + + @Nullable + private String counterClerkId; + + private OrderTriggerSource triggerSource = OrderTriggerSource.UNKNOWN; + + public OrderRevocationContext withTriggerSource(OrderTriggerSource triggerSource) { + this.triggerSource = triggerSource; + return this; + } + + public enum EarningsAdjustStrategy { + NONE("NO_ADJUST"), + REVERSE_CLERK("REV_CLERK"), + COUNTER_TO_PEIPEI("CTR_PEIPEI"); + + private final String logCode; + + EarningsAdjustStrategy(String logCode) { + this.logCode = logCode; + } + + public String getLogCode() { + return logCode; + } + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/event/OrderRevocationEvent.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/event/OrderRevocationEvent.java new file mode 100644 index 0000000..4851d33 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/event/OrderRevocationEvent.java @@ -0,0 +1,17 @@ +package com.starry.admin.modules.order.module.event; + +import com.starry.admin.modules.order.module.dto.OrderRevocationContext; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import lombok.Getter; + +@Getter +public class OrderRevocationEvent { + + private final OrderRevocationContext context; + private final PlayOrderInfoEntity orderSnapshot; + + public OrderRevocationEvent(OrderRevocationContext context, PlayOrderInfoEntity orderSnapshot) { + this.context = context; + this.orderSnapshot = orderSnapshot; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/vo/PlayOrderRevocationVo.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/vo/PlayOrderRevocationVo.java new file mode 100644 index 0000000..714e091 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/vo/PlayOrderRevocationVo.java @@ -0,0 +1,32 @@ +package com.starry.admin.modules.order.module.vo; + +import com.starry.admin.modules.order.module.dto.OrderRevocationContext.EarningsAdjustStrategy; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import java.math.BigDecimal; +import javax.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@ApiModel(value = "订单撤销参数", description = "撤销已完成订单的请求参数") +public class PlayOrderRevocationVo { + + @NotBlank(message = "订单ID不能为空") + @ApiModelProperty(value = "订单ID", required = true) + private String orderId; + + @ApiModelProperty(value = "是否退还顾客余额") + private boolean refundToCustomer; + + @ApiModelProperty(value = "退款金额,未填写则默认订单实付金额") + private BigDecimal refundAmount; + + @ApiModelProperty(value = "撤销原因") + private String refundReason; + + @ApiModelProperty(value = "收益处理策略:NONE/REVERSE_CLERK/COUNTER_TO_PEIPEI") + private EarningsAdjustStrategy earningsStrategy = EarningsAdjustStrategy.NONE; + + @ApiModelProperty(value = "收益冲销目标账号ID,策略为 COUNTER_TO_PEIPEI 时必填") + private String counterClerkId; +} 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 ecf8b1b..076a536 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 @@ -5,6 +5,7 @@ import com.starry.admin.modules.order.module.dto.OrderCreationContext; import com.starry.admin.modules.order.module.dto.OrderPlacementCommand; import com.starry.admin.modules.order.module.dto.OrderPlacementResult; import com.starry.admin.modules.order.module.dto.OrderRefundContext; +import com.starry.admin.modules.order.module.dto.OrderRevocationContext; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; public interface IOrderLifecycleService { @@ -14,4 +15,6 @@ public interface IOrderLifecycleService { void completeOrder(String orderId, OrderCompletionContext context); void refundOrder(OrderRefundContext context); + + void revokeCompletedOrder(OrderRevocationContext 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 503d25b..7872046 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 @@ -199,6 +199,8 @@ public interface IPlayOrderInfoService extends IService { */ List customSelectOrderInfoByList(String customId); + void revokeCompletedOrder(OrderRevocationContext context); + /** * 修改订单状态为接单 只有管理员或者店员本人才能操作 * diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java index 36165d3..fa329a3 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 @@ -34,10 +34,12 @@ import com.starry.admin.modules.order.module.dto.OrderCreationContext; import com.starry.admin.modules.order.module.dto.OrderPlacementCommand; import com.starry.admin.modules.order.module.dto.OrderPlacementResult; import com.starry.admin.modules.order.module.dto.OrderRefundContext; +import com.starry.admin.modules.order.module.dto.OrderRevocationContext; 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.event.OrderRevocationEvent; 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; @@ -61,9 +63,11 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import javax.annotation.PostConstruct; import javax.annotation.Resource; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -77,7 +81,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { private enum LifecycleOperation { CREATE, COMPLETE, - REFUND + REFUND, + REVOKE_COMPLETED } @Resource @@ -110,6 +115,9 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { @Resource private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService; + @Resource + private ApplicationEventPublisher applicationEventPublisher; + private Map placementStrategies; @PostConstruct @@ -520,7 +528,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { throw new CustomException("每个订单只能退款一次~"); } - if (isBalancePaidOrder(order) && !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) { + if (isBalancePaidOrder(order) + && !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) { throw new CustomException("订单未发生余额扣款,无法退款"); } @@ -603,6 +612,107 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { } } + @Override + @Transactional(rollbackFor = Exception.class) + public void revokeCompletedOrder(OrderRevocationContext context) { + if (context == null || StrUtil.isBlank(context.getOrderId())) { + throw new CustomException("订单ID不能为空"); + } + PlayOrderInfoEntity order = orderInfoMapper.selectById(context.getOrderId()); + if (order == null) { + throw new CustomException("订单不存在"); + } + if (OrderStatus.REVOKED.getCode().equals(order.getOrderStatus())) { + throw new CustomException("订单已撤销"); + } + if (!OrderStatus.COMPLETED.getCode().equals(order.getOrderStatus())) { + throw new CustomException("当前状态无法撤销"); + } + + OrderRevocationContext.EarningsAdjustStrategy strategy = context.getEarningsStrategy() != null + ? context.getEarningsStrategy() + : OrderRevocationContext.EarningsAdjustStrategy.NONE; + if (strategy != OrderRevocationContext.EarningsAdjustStrategy.NONE && earningsService.hasLockedLines(order.getId())) { + throw new CustomException("收益已提现或处理中,无法撤销"); + } + + String operatorType = StrUtil.isNotBlank(context.getOperatorType()) ? context.getOperatorType() : OperatorType.ADMIN.getCode(); + context.setOperatorType(operatorType); + String operatorId = StrUtil.isNotBlank(context.getOperatorId()) ? context.getOperatorId() : SecurityUtils.getUserId(); + context.setOperatorId(operatorId); + + BigDecimal finalAmount = Optional.ofNullable(order.getFinalAmount()).orElse(BigDecimal.ZERO); + BigDecimal refundAmount = context.getRefundAmount(); + if (refundAmount == null) { + refundAmount = context.isRefundToCustomer() ? finalAmount : BigDecimal.ZERO; + } + if (refundAmount.compareTo(BigDecimal.ZERO) < 0) { + throw new CustomException("退款金额不能小于0"); + } + if (refundAmount.compareTo(finalAmount) > 0) { + throw new CustomException("退款金额不能大于支付金额"); + } + context.setRefundAmount(refundAmount); + + if (refundAmount.compareTo(BigDecimal.ZERO) > 0 && context.isRefundToCustomer()) { + if (isBalancePaidOrder(order) + && !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) { + throw new CustomException("订单未发生余额扣款,无法退款"); + } + } + + UpdateWrapper updateWrapper = new UpdateWrapper<>(); + updateWrapper.eq("id", order.getId()) + .eq("order_status", OrderStatus.COMPLETED.getCode()) + .set("order_status", OrderStatus.REVOKED.getCode()) + .set("order_cancel_time", LocalDateTime.now()) + .set("refund_amount", refundAmount) + .set("refund_reason", context.getRefundReason()); + if (refundAmount.compareTo(BigDecimal.ZERO) > 0) { + updateWrapper.set("refund_type", OrderRefundFlag.REFUNDED.getCode()); + } + + boolean updated = orderInfoMapper.update(null, updateWrapper) > 0; + PlayOrderInfoEntity latest = orderInfoMapper.selectById(order.getId()); + if (!updated && (latest == null || !OrderStatus.REVOKED.getCode().equals(latest.getOrderStatus()))) { + throw new CustomException("订单状态已变化,无法撤销"); + } + if (latest == null) { + latest = order; + latest.setOrderStatus(OrderStatus.REVOKED.getCode()); + } + + if (refundAmount.compareTo(BigDecimal.ZERO) > 0 && context.isRefundToCustomer()) { + OrderRefundRecordType recordType = finalAmount.compareTo(refundAmount) == 0 + ? OrderRefundRecordType.FULL + : OrderRefundRecordType.PARTIAL; + orderRefundInfoService.add( + latest.getId(), + latest.getPurchaserBy(), + latest.getAcceptBy(), + latest.getPayMethod(), + recordType.getCode(), + refundAmount, + context.getRefundReason(), + context.getOperatorType(), + context.getOperatorId(), + OrderRefundState.PROCESSING.getCode(), + ReviewRequirement.NOT_REQUIRED.getCode()); + } + + OrderActor actor = resolveCompletionActor(context.getOperatorType()); + String operationType = String.format( + "%s_%s", + LifecycleOperation.REVOKE_COMPLETED.name(), + strategy != null + ? strategy.getLogCode() + : OrderRevocationContext.EarningsAdjustStrategy.NONE.getLogCode()); + recordOrderLog(latest, actor, context.getOperatorId(), LifecycleOperation.REVOKE_COMPLETED, + context.getRefundReason(), operationType); + + applicationEventPublisher.publishEvent(new OrderRevocationEvent(context, latest)); + } + private void validateOrderCreationRequest(OrderCreationContext context) { if (context == null) { throw new CustomException("订单创建请求不能为空"); 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 1a00204..62f0c2d 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 @@ -943,6 +943,14 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl lambdaQueryWrapper = new LambdaQueryWrapper<>(); diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsService.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsService.java index e7f03b8..428980a 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsService.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsService.java @@ -17,4 +17,10 @@ public interface IEarningsService extends IService { LocalDateTime getNextUnlockTime(String clerkId, LocalDateTime now); List findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now); + + void reverseByOrder(String orderId, String operatorId); + + void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId); + + boolean hasLockedLines(String orderId); } diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java index 0caa435..3d622dc 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java @@ -1,6 +1,8 @@ package com.starry.admin.modules.withdraw.service.impl; +import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; @@ -12,6 +14,7 @@ import com.starry.common.utils.IdUtils; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import javax.annotation.Resource; import org.springframework.stereotype.Service; @@ -90,4 +93,49 @@ public class EarningsServiceImpl extends ServiceImpl(); return picked; } + + @Override + public void reverseByOrder(String orderId, String operatorId) { + if (StrUtil.isBlank(orderId)) { + return; + } + this.update(Wrappers.lambdaUpdate(EarningsLineEntity.class) + .eq(EarningsLineEntity::getOrderId, orderId) + .in(EarningsLineEntity::getStatus, Arrays.asList("available", "frozen")) + .set(EarningsLineEntity::getStatus, "reversed")); + } + + @Override + public void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId) { + if (StrUtil.hasBlank(orderId, tenantId, targetClerkId)) { + throw new IllegalArgumentException("创建冲销收益时参数缺失"); + } + BigDecimal normalized = amount == null ? BigDecimal.ZERO : amount.abs(); + if (normalized.compareTo(BigDecimal.ZERO) == 0) { + return; + } + + EarningsLineEntity line = new EarningsLineEntity(); + line.setId(IdUtils.getUuid()); + line.setOrderId(orderId); + line.setTenantId(tenantId); + line.setClerkId(targetClerkId); + line.setAmount(normalized.negate()); + line.setEarningType(EarningsType.ORDER); + line.setStatus("available"); + line.setUnlockTime(LocalDateTime.now()); + this.save(line); + } + + @Override + public boolean hasLockedLines(String orderId) { + if (StrUtil.isBlank(orderId)) { + return false; + } + Long count = this.lambdaQuery() + .eq(EarningsLineEntity::getOrderId, orderId) + .in(EarningsLineEntity::getStatus, Arrays.asList("withdrawing", "withdrawn")) + .count(); + return count != null && count > 0; + } } diff --git a/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxOrderApiTest.java index a6c03e4..719d6eb 100644 --- a/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxOrderApiTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxOrderApiTest.java @@ -33,6 +33,7 @@ import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport { diff --git a/play-admin/src/test/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListenerTest.java b/play-admin/src/test/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListenerTest.java new file mode 100644 index 0000000..df1e693 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/order/listener/OrderRevocationEarningsListenerTest.java @@ -0,0 +1,80 @@ +package com.starry.admin.modules.order.listener; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.starry.admin.modules.order.module.dto.OrderRevocationContext; +import com.starry.admin.modules.order.module.dto.OrderRevocationContext.EarningsAdjustStrategy; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.module.event.OrderRevocationEvent; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import java.math.BigDecimal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class OrderRevocationEarningsListenerTest { + + @Mock + private IEarningsService earningsService; + + private OrderRevocationEarningsListener listener; + + @BeforeEach + void setUp() { + listener = new OrderRevocationEarningsListener(earningsService); + } + + @Test + void handle_counterStrategyFallsBackToEstimatedRevenueWhenRefundAmountZero() { + OrderRevocationContext context = new OrderRevocationContext(); + context.setOrderId("order-counter-1"); + context.setOperatorId("admin-op"); + context.setEarningsStrategy(EarningsAdjustStrategy.COUNTER_TO_PEIPEI); + context.setRefundAmount(BigDecimal.ZERO); + context.setCounterClerkId("ops-clerk"); + + PlayOrderInfoEntity order = new PlayOrderInfoEntity(); + order.setId("order-counter-1"); + order.setTenantId("tenant-77"); + order.setEstimatedRevenue(BigDecimal.valueOf(68)); + + listener.handle(new OrderRevocationEvent(context, order)); + + verify(earningsService) + .createCounterLine(order.getId(), order.getTenantId(), "ops-clerk", BigDecimal.valueOf(68), "admin-op"); + } + + @Test + void handle_reverseStrategyRevertsAvailableLines() { + OrderRevocationContext context = new OrderRevocationContext(); + context.setOrderId("order-reverse-2"); + context.setOperatorId("admin-reviewer"); + context.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK); + + PlayOrderInfoEntity order = new PlayOrderInfoEntity(); + order.setId("order-reverse-2"); + + listener.handle(new OrderRevocationEvent(context, order)); + + verify(earningsService).reverseByOrder("order-reverse-2", "admin-reviewer"); + } + + @Test + void handle_noneStrategyIsNoOp() { + OrderRevocationContext context = new OrderRevocationContext(); + context.setOrderId("order-none-3"); + context.setOperatorId("admin-noop"); + context.setEarningsStrategy(EarningsAdjustStrategy.NONE); + + PlayOrderInfoEntity order = new PlayOrderInfoEntity(); + order.setId("order-none-3"); + + listener.handle(new OrderRevocationEvent(context, order)); + + verifyNoInteractions(earningsService); + } +} 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 9fbafcf..64beb72 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 @@ -40,9 +40,12 @@ import com.starry.admin.modules.order.module.dto.OrderCreationContext; import com.starry.admin.modules.order.module.dto.OrderPlacementCommand; import com.starry.admin.modules.order.module.dto.OrderPlacementResult; import com.starry.admin.modules.order.module.dto.OrderRefundContext; +import com.starry.admin.modules.order.module.dto.OrderRevocationContext; +import com.starry.admin.modules.order.module.dto.OrderRevocationContext.EarningsAdjustStrategy; 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.event.OrderRevocationEvent; 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; @@ -65,10 +68,12 @@ import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; @ExtendWith(MockitoExtension.class) class OrderLifecycleServiceImplTest { @@ -106,11 +111,104 @@ class OrderLifecycleServiceImplTest { @Mock private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService; + @Mock + private ApplicationEventPublisher applicationEventPublisher; + @BeforeEach void initStrategies() { lifecycleService.initPlacementStrategies(); } + @Test + void revokeCompletedOrder_updatesStatusAndPublishesEvent() { + String orderId = UUID.randomUUID().toString(); + PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode()); + completed.setFinalAmount(BigDecimal.valueOf(188)); + completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode()); + PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode()); + revoked.setFinalAmount(BigDecimal.valueOf(188)); + + when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked); + when(orderInfoMapper.update(isNull(), any())).thenReturn(1); + when(earningsService.hasLockedLines(orderId)).thenReturn(false); + PlayCustomUserInfoEntity customer = buildCustomer(completed.getPurchaserBy(), BigDecimal.ZERO); + when(customUserInfoService.getById(completed.getPurchaserBy())).thenReturn(customer); + doNothing().when(customUserInfoService) + .updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString()); + doNothing().when(orderRefundInfoService) + .add(anyString(), anyString(), anyString(), anyString(), anyString(), any(), anyString(), anyString(), anyString(), anyString(), anyString()); + + OrderRevocationContext context = new OrderRevocationContext(); + context.setOrderId(orderId); + context.setOperatorId("admin-8"); + context.setOperatorType(OperatorType.ADMIN.getCode()); + context.setRefundAmount(BigDecimal.valueOf(88)); + context.setRefundReason("客户投诉"); + context.setRefundToCustomer(true); + context.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK); + + lifecycleService.revokeCompletedOrder(context); + + verify(orderInfoMapper).update(isNull(), any()); + verify(orderLogInfoMapper).insert(any()); + ArgumentCaptor captor = ArgumentCaptor.forClass(OrderRevocationEvent.class); + verify(applicationEventPublisher).publishEvent(captor.capture()); + OrderRevocationEvent event = captor.getValue(); + assertEquals(orderId, event.getContext().getOrderId()); + assertEquals(EarningsAdjustStrategy.REVERSE_CLERK, event.getContext().getEarningsStrategy()); + assertEquals(BigDecimal.valueOf(88), event.getContext().getRefundAmount()); + } + + @Test + void revokeCompletedOrder_defersBalanceCreditToListener() { + String orderId = UUID.randomUUID().toString(); + PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode()); + completed.setFinalAmount(BigDecimal.valueOf(208)); + completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode()); + PlayOrderInfoEntity revoked = buildOrder(orderId, OrderStatus.REVOKED.getCode()); + revoked.setFinalAmount(BigDecimal.valueOf(208)); + + when(orderInfoMapper.selectById(orderId)).thenReturn(completed, revoked); + when(orderInfoMapper.update(isNull(), any())).thenReturn(1); + when(earningsService.hasLockedLines(orderId)).thenReturn(false); + when(customUserInfoService.getById(anyString())).thenThrow(new AssertionError("Balance update should be handled by listener")); + + OrderRevocationContext context = new OrderRevocationContext(); + context.setOrderId(orderId); + context.setOperatorId("admin-9"); + context.setOperatorType(OperatorType.ADMIN.getCode()); + context.setRefundAmount(BigDecimal.valueOf(108)); + context.setRefundReason("质量问题"); + context.setRefundToCustomer(true); + context.setEarningsStrategy(EarningsAdjustStrategy.NONE); + + lifecycleService.revokeCompletedOrder(context); + + verify(customUserInfoService, never()).getById(anyString()); + verify(applicationEventPublisher).publishEvent(any(OrderRevocationEvent.class)); + } + + @Test + void revokeCompletedOrder_blocksWhenEarningsLocked() { + String orderId = UUID.randomUUID().toString(); + PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode()); + completed.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode()); + + when(orderInfoMapper.selectById(orderId)).thenReturn(completed); + when(earningsService.hasLockedLines(orderId)).thenReturn(true); + + OrderRevocationContext context = new OrderRevocationContext(); + context.setOrderId(orderId); + context.setOperatorId("admin-locked"); + context.setOperatorType(OperatorType.ADMIN.getCode()); + context.setRefundToCustomer(false); + context.setEarningsStrategy(EarningsAdjustStrategy.REVERSE_CLERK); + + assertThrows(CustomException.class, () -> lifecycleService.revokeCompletedOrder(context)); + verify(orderInfoMapper, never()).update(isNull(), any()); + verify(applicationEventPublisher, never()).publishEvent(any()); + } + @Test void placeOrder_throwsWhenCommandNull() { assertThrows(CustomException.class, () -> lifecycleService.placeOrder(null)); @@ -1223,6 +1321,13 @@ class OrderLifecycleServiceImplTest { return entity; } + private PlayCustomUserInfoEntity buildCustomer(String id, BigDecimal balance) { + PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity(); + entity.setId(id); + entity.setAccountBalance(balance); + return entity; + } + private void stubDefaultPersistence() { lenient().when(orderInfoMapper.selectCount(any())).thenReturn(0L); lenient().when(orderInfoMapper.insert(any())).thenReturn(1); diff --git a/play-admin/src/test/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImplTest.java b/play-admin/src/test/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImplTest.java index e6b45d0..6437cf5 100644 --- a/play-admin/src/test/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImplTest.java +++ b/play-admin/src/test/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImplTest.java @@ -12,6 +12,8 @@ import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper; import com.starry.admin.modules.withdraw.service.IFreezePolicyService; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -68,4 +70,54 @@ class EarningsServiceImplTest { verify(baseMapper, never()).insert(any()); } + + @Test + void createCounterLine_persistsNegativeAvailableLine() { + when(baseMapper.insert(any())).thenReturn(1); + + earningsService.createCounterLine("order-neg", "tenant-t", "clerk-c", BigDecimal.valueOf(88), "admin"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EarningsLineEntity.class); + verify(baseMapper).insert(captor.capture()); + EarningsLineEntity saved = captor.getValue(); + assertEquals(new BigDecimal("-88"), saved.getAmount()); + assertEquals("clerk-c", saved.getClerkId()); + assertEquals("available", saved.getStatus()); + } + + @Test + void findWithdrawable_returnsEmptyWhenCounterLinesCreateDebt() { + LocalDateTime now = LocalDateTime.now(); + List lines = Arrays.asList( + line("neg", new BigDecimal("-60")), + line("pos", new BigDecimal("40"))); + when(baseMapper.selectWithdrawableLines("clerk-001", now)).thenReturn(lines); + + List picked = earningsService.findWithdrawable("clerk-001", BigDecimal.valueOf(30), now); + + assertEquals(0, picked.size(), "净额不足时不应允许提现"); + } + + @Test + void findWithdrawable_allowsWithdrawalAfterPositiveLinesCoverDebt() { + LocalDateTime now = LocalDateTime.now(); + List lines = Arrays.asList( + line("neg", new BigDecimal("-60")), + line("first", new BigDecimal("40")), + line("second", new BigDecimal("150"))); + when(baseMapper.selectWithdrawableLines("clerk-001", now)).thenReturn(lines); + + List picked = earningsService.findWithdrawable("clerk-001", BigDecimal.valueOf(70), now); + + assertEquals(3, picked.size()); + assertEquals("second", picked.get(2).getId()); + } + + private EarningsLineEntity line(String id, BigDecimal amount) { + EarningsLineEntity entity = new EarningsLineEntity(); + entity.setId(id); + entity.setAmount(amount); + entity.setStatus("available"); + return entity; + } }