feat(shop): 添加订单取消和退款时的水票撤销功能
- 在ShopOrderController中注入GltTicketRevokeService服务 - 实现订单状态改为已取消时同步撤销相关水票、释放计划和送水订单 - 实现退款成功后自动撤销水票相关数据的功能 - 新增GltTicketRevokeService服务处理水票撤销逻辑 - 添加批量订单取消时的水票撤销支持 - 实现撤销操作的幂等性确保无副作用 - 添加单元测试验证水票撤销功能的正确性
This commit is contained in:
@@ -6,13 +6,17 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.gxwebsoft.glt.entity.GltTicketOrder;
|
||||
import com.gxwebsoft.glt.entity.GltUserTicket;
|
||||
import com.gxwebsoft.glt.entity.GltUserTicketRelease;
|
||||
import com.gxwebsoft.shop.entity.ShopOrderGoods;
|
||||
import com.gxwebsoft.shop.service.ShopOrderGoodsService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 水票撤销(订单取消/退款成功后的清理):
|
||||
@@ -35,6 +39,7 @@ public class GltTicketRevokeService {
|
||||
private final GltUserTicketService gltUserTicketService;
|
||||
private final GltUserTicketReleaseService gltUserTicketReleaseService;
|
||||
private final GltTicketOrderService gltTicketOrderService;
|
||||
private final ShopOrderGoodsService shopOrderGoodsService;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int revokeByShopOrder(Integer tenantId, Integer shopOrderId, String shopOrderNo, String reason) {
|
||||
@@ -58,6 +63,33 @@ public class GltTicketRevokeService {
|
||||
}
|
||||
|
||||
List<GltUserTicket> tickets = gltUserTicketService.list(qw);
|
||||
|
||||
// 兼容历史数据:部分水票只记录了 orderGoodsId,未记录 orderId/orderNo
|
||||
if ((tickets == null || tickets.isEmpty()) && shopOrderId != null) {
|
||||
try {
|
||||
List<ShopOrderGoods> goodsList = shopOrderGoodsService.list(
|
||||
new LambdaQueryWrapper<ShopOrderGoods>()
|
||||
.select(ShopOrderGoods::getId)
|
||||
.eq(ShopOrderGoods::getTenantId, tenantId)
|
||||
.eq(ShopOrderGoods::getOrderId, shopOrderId)
|
||||
);
|
||||
List<Integer> orderGoodsIds = goodsList == null ? List.of() : goodsList.stream()
|
||||
.map(ShopOrderGoods::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (!orderGoodsIds.isEmpty()) {
|
||||
tickets = gltUserTicketService.list(
|
||||
new LambdaQueryWrapper<GltUserTicket>()
|
||||
.eq(GltUserTicket::getTenantId, tenantId)
|
||||
.eq(GltUserTicket::getDeleted, 0)
|
||||
.in(GltUserTicket::getOrderGoodsId, orderGoodsIds)
|
||||
);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("撤销水票:通过orderGoodsId兜底反查失败 - tenantId={}, shopOrderId={}", tenantId, shopOrderId, e);
|
||||
}
|
||||
}
|
||||
if (tickets == null || tickets.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
@@ -67,10 +99,15 @@ public class GltTicketRevokeService {
|
||||
// 不强制覆盖 comments,避免影响后台人工备注;reason 仅用于日志。
|
||||
String reasonForLog = StrUtil.isBlank(reason) ? "订单取消/退款撤销水票" : reason.trim();
|
||||
|
||||
// 去重(避免 orderId/orderNo 与 orderGoodsId 两种路径重复命中)
|
||||
Set<Integer> seen = new HashSet<>();
|
||||
for (GltUserTicket t : tickets) {
|
||||
if (t == null || t.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
if (!seen.add(t.getId())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Integer userTicketId = t.getId();
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.gxwebsoft.shop.controller;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.shop.dto.RefundedOrderGltRepairRequest;
|
||||
import com.gxwebsoft.shop.service.ShopOrderGltRepairService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 历史订单修复:用于对“已退款的旧订单”补偿撤销水票相关数据。
|
||||
*/
|
||||
@Tag(name = "订单修复")
|
||||
@RestController
|
||||
@RequestMapping("/api/shop/shop-order/repair")
|
||||
public class ShopOrderGltRepairController extends BaseController {
|
||||
|
||||
@Resource
|
||||
private ShopOrderGltRepairService shopOrderGltRepairService;
|
||||
|
||||
@PreAuthorize("hasAuthority('shop:shopOrder:manage')")
|
||||
@Operation(summary = "修复:已退款旧订单补偿撤销水票/释放计划/送水单(支持dryRun预览)")
|
||||
@PostMapping("/revoke-glt-after-refund")
|
||||
public ApiResult<?> revokeGltAfterRefund(@RequestBody RefundedOrderGltRepairRequest req) {
|
||||
if (req == null) {
|
||||
return fail("请求体不能为空");
|
||||
}
|
||||
|
||||
Integer tenantId = ObjectUtil.defaultIfNull(req.getTenantId(), getTenantId());
|
||||
if (tenantId == null) {
|
||||
return fail("tenantId不能为空");
|
||||
}
|
||||
|
||||
boolean dryRun = req.getDryRun() == null || req.getDryRun();
|
||||
|
||||
// 防误操作:既未指定订单,也未指定时间窗口时,只允许 dryRun
|
||||
boolean noTargets = (req.getOrderIds() == null || req.getOrderIds().isEmpty())
|
||||
&& (req.getOrderNos() == null || req.getOrderNos().isEmpty())
|
||||
&& req.getRefundTimeStart() == null
|
||||
&& req.getRefundTimeEnd() == null;
|
||||
if (noTargets && !dryRun) {
|
||||
return fail("请指定 orderIds/orderNos 或 refundTimeStart/refundTimeEnd;否则仅允许 dryRun=true 预览");
|
||||
}
|
||||
|
||||
ShopOrderGltRepairService.RepairResult r = shopOrderGltRepairService.revokeTicketsForRefundedOrders(
|
||||
tenantId,
|
||||
req.getOrderIds(),
|
||||
req.getOrderNos(),
|
||||
req.getRefundTimeStart(),
|
||||
req.getRefundTimeEnd(),
|
||||
req.getBatchSize(),
|
||||
dryRun
|
||||
);
|
||||
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("tenantId", tenantId);
|
||||
data.put("dryRun", r.dryRun());
|
||||
data.put("scannedOrders", r.scannedOrders());
|
||||
data.put("ordersWithTicketsRevoked", r.ordersWithTicketsRevoked());
|
||||
data.put("revokedTicketCount", r.revokedTicketCount());
|
||||
data.put("processedOrderIds", r.processedOrderIds());
|
||||
return success(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.gxwebsoft.shop.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 历史退款订单:补偿撤销水票/释放计划/送水单 的修复请求。
|
||||
*/
|
||||
@Data
|
||||
public class RefundedOrderGltRepairRequest implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "租户ID(不传则使用当前请求租户)")
|
||||
private Integer tenantId;
|
||||
|
||||
@Schema(description = "指定订单ID列表(优先使用;为空则走时间窗口扫描)")
|
||||
private List<Integer> orderIds;
|
||||
|
||||
@Schema(description = "指定订单号列表(可选;为空则走时间窗口扫描)")
|
||||
private List<String> orderNos;
|
||||
|
||||
@Schema(description = "退款时间起(yyyy-MM-dd HH:mm:ss)")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime refundTimeStart;
|
||||
|
||||
@Schema(description = "退款时间止(yyyy-MM-dd HH:mm:ss)")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime refundTimeEnd;
|
||||
|
||||
@Schema(description = "每次处理条数上限(默认200)")
|
||||
private Integer batchSize;
|
||||
|
||||
@Schema(description = "是否仅预览(默认true,仅统计不落库)")
|
||||
private Boolean dryRun;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.gxwebsoft.shop.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.gxwebsoft.glt.service.GltTicketRevokeService;
|
||||
import com.gxwebsoft.shop.entity.ShopOrder;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 历史订单修复:对已退款订单补偿撤销水票相关数据。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ShopOrderGltRepairService {
|
||||
|
||||
/** shop_order.order_status:退款成功 */
|
||||
private static final int ORDER_STATUS_REFUND_SUCCESS = 6;
|
||||
|
||||
private final ShopOrderService shopOrderService;
|
||||
private final GltTicketRevokeService gltTicketRevokeService;
|
||||
|
||||
public RepairResult revokeTicketsForRefundedOrders(Integer tenantId,
|
||||
List<Integer> orderIds,
|
||||
List<String> orderNos,
|
||||
LocalDateTime refundTimeStart,
|
||||
LocalDateTime refundTimeEnd,
|
||||
Integer batchSize,
|
||||
boolean dryRun) {
|
||||
if (tenantId == null) {
|
||||
throw new IllegalArgumentException("tenantId不能为空");
|
||||
}
|
||||
|
||||
int limit = batchSize == null ? 200 : Math.max(1, Math.min(batchSize, 2000));
|
||||
List<ShopOrder> orders = new ArrayList<>();
|
||||
|
||||
// 1) 精准修复:指定 orderIds / orderNos
|
||||
if (orderIds != null && !orderIds.isEmpty()) {
|
||||
orders = shopOrderService.list(new LambdaQueryWrapper<ShopOrder>()
|
||||
.eq(ShopOrder::getTenantId, tenantId)
|
||||
.eq(ShopOrder::getDeleted, 0)
|
||||
.eq(ShopOrder::getOrderStatus, ORDER_STATUS_REFUND_SUCCESS)
|
||||
.in(ShopOrder::getOrderId, orderIds)
|
||||
.last("limit " + limit));
|
||||
} else if (orderNos != null && !orderNos.isEmpty()) {
|
||||
orders = shopOrderService.list(new LambdaQueryWrapper<ShopOrder>()
|
||||
.eq(ShopOrder::getTenantId, tenantId)
|
||||
.eq(ShopOrder::getDeleted, 0)
|
||||
.eq(ShopOrder::getOrderStatus, ORDER_STATUS_REFUND_SUCCESS)
|
||||
.in(ShopOrder::getOrderNo, orderNos)
|
||||
.last("limit " + limit));
|
||||
} else {
|
||||
// 2) 扫描修复:按 refundTime 窗口分页处理
|
||||
LambdaQueryWrapper<ShopOrder> qw = new LambdaQueryWrapper<ShopOrder>()
|
||||
.eq(ShopOrder::getTenantId, tenantId)
|
||||
.eq(ShopOrder::getDeleted, 0)
|
||||
.eq(ShopOrder::getOrderStatus, ORDER_STATUS_REFUND_SUCCESS);
|
||||
if (refundTimeStart != null) {
|
||||
qw.ge(ShopOrder::getRefundTime, refundTimeStart);
|
||||
}
|
||||
if (refundTimeEnd != null) {
|
||||
qw.le(ShopOrder::getRefundTime, refundTimeEnd);
|
||||
}
|
||||
orders = shopOrderService.list(qw.orderByAsc(ShopOrder::getRefundTime).orderByAsc(ShopOrder::getOrderId).last("limit " + limit));
|
||||
}
|
||||
|
||||
int scanned = orders == null ? 0 : orders.size();
|
||||
int revokedTickets = 0;
|
||||
int revokedOrders = 0;
|
||||
List<Integer> processedOrderIds = new ArrayList<>();
|
||||
|
||||
if (orders != null) {
|
||||
for (ShopOrder o : orders) {
|
||||
if (o == null || o.getOrderId() == null) {
|
||||
continue;
|
||||
}
|
||||
processedOrderIds.add(o.getOrderId());
|
||||
if (dryRun) {
|
||||
continue;
|
||||
}
|
||||
int revoked = gltTicketRevokeService.revokeByShopOrder(
|
||||
tenantId,
|
||||
o.getOrderId(),
|
||||
o.getOrderNo(),
|
||||
"历史退款订单补偿撤销水票"
|
||||
);
|
||||
if (revoked > 0) {
|
||||
revokedTickets += revoked;
|
||||
revokedOrders++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new RepairResult(scanned, revokedOrders, revokedTickets, processedOrderIds, dryRun);
|
||||
}
|
||||
|
||||
public record RepairResult(int scannedOrders,
|
||||
int ordersWithTicketsRevoked,
|
||||
int revokedTicketCount,
|
||||
List<Integer> processedOrderIds,
|
||||
boolean dryRun) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ class GltTicketRevokeServiceTest {
|
||||
private GltUserTicketReleaseService gltUserTicketReleaseService;
|
||||
@Mock
|
||||
private GltTicketOrderService gltTicketOrderService;
|
||||
@Mock
|
||||
private com.gxwebsoft.shop.service.ShopOrderGoodsService shopOrderGoodsService;
|
||||
|
||||
@InjectMocks
|
||||
private GltTicketRevokeService gltTicketRevokeService;
|
||||
@@ -31,16 +33,18 @@ class GltTicketRevokeServiceTest {
|
||||
void revokeByShopOrder_noTenant_noop() {
|
||||
int revoked = gltTicketRevokeService.revokeByShopOrder(null, 1, "O1", "r");
|
||||
assertEquals(0, revoked);
|
||||
verifyNoInteractions(gltUserTicketService, gltUserTicketReleaseService, gltTicketOrderService);
|
||||
verifyNoInteractions(gltUserTicketService, gltUserTicketReleaseService, gltTicketOrderService, shopOrderGoodsService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeByShopOrder_noTickets_noop() {
|
||||
when(gltUserTicketService.list(any())).thenReturn(List.of());
|
||||
when(shopOrderGoodsService.list(any())).thenReturn(List.of());
|
||||
int revoked = gltTicketRevokeService.revokeByShopOrder(10584, 1, "O1", "r");
|
||||
assertEquals(0, revoked);
|
||||
verify(gltUserTicketService, times(1)).list(any());
|
||||
verifyNoMoreInteractions(gltUserTicketService);
|
||||
verify(shopOrderGoodsService, times(1)).list(any());
|
||||
verifyNoMoreInteractions(gltUserTicketService, shopOrderGoodsService);
|
||||
verifyNoInteractions(gltUserTicketReleaseService, gltTicketOrderService);
|
||||
}
|
||||
|
||||
@@ -60,6 +64,6 @@ class GltTicketRevokeServiceTest {
|
||||
verify(gltTicketOrderService, times(1)).update(isNull(), any());
|
||||
verify(gltUserTicketReleaseService, times(1)).update(isNull(), any());
|
||||
verify(gltUserTicketService, times(1)).update(isNull(), any());
|
||||
verifyNoInteractions(shopOrderGoodsService);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user