feat:后台批量退款
This commit is contained in:
1 parent
f4ff6bd3a3
commit
be749076bf
11 files changed
+1119
-2
No files matched your search
+25
@@ -0,0 +1,25 @@
|
||||
package com.bsd.pjcloud.hard.api.dto.order;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 后台批量退款预览请求,只接收列表页已经勾选的订单主键。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "后台批量退款预览请求")
|
||||
public class HardBatchRefundPreviewDto {
|
||||
|
||||
/**
|
||||
* 待预览的订单主键,服务端会去重并重新校验数据权限和退款资格,单次最多 1000 笔。
|
||||
*/
|
||||
@NotEmpty(message = "请选择需要退款的订单")
|
||||
@Size(max = 1000, message = "单次最多选择1000个订单")
|
||||
@Schema(description = "待退款订单主键,单次最多1000笔")
|
||||
private List<@NotNull(message = "订单id不能为空") Long> orderIds;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.bsd.pjcloud.hard.api.dto.order;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 后台批量退款提交请求,在预览订单集合之外补充本批次统一备注。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "后台批量退款提交请求")
|
||||
public class HardBatchRefundSubmitDto extends HardBatchRefundPreviewDto {
|
||||
|
||||
/**
|
||||
* 本批次退款备注,同时写入每笔售后单的申请原因和补充描述,去除首尾空白后不能为空。
|
||||
*/
|
||||
@NotBlank(message = "请输入退款备注")
|
||||
@Size(max = 200, message = "退款备注不能超过200个字符")
|
||||
@Schema(description = "本批次统一退款备注,最多200个字符")
|
||||
private String remark;
|
||||
}
|
||||
+13
@@ -1,8 +1,21 @@
|
||||
package com.bsd.pjcloud.hard.api.vo.order;
|
||||
|
||||
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* App 售后进度记录,在持久化日志之外补充当前节点对应的退款原因。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "App 售后进度记录")
|
||||
public class HardAfterSaleLogVo extends HardAfterSaleLogEntity {
|
||||
|
||||
/**
|
||||
* 当前进度节点对应的退款或拒绝原因;无可展示原因时为空,不写入售后日志表。
|
||||
*/
|
||||
@Schema(description = "当前进度节点对应的退款原因")
|
||||
private String reason;
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package com.bsd.pjcloud.hard.api.vo.order;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 后台批量退款预览结果,按订单展示服务端实时核定的剩余可退商品和金额。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "后台批量退款预览结果")
|
||||
public class HardBatchRefundPreviewVo {
|
||||
|
||||
/**
|
||||
* 当前预览范围的订单、人员、商品和金额汇总,不包含无权限或不存在的订单。
|
||||
*/
|
||||
@Schema(description = "批量退款汇总")
|
||||
private Summary summary;
|
||||
|
||||
/**
|
||||
* 按服务端订单排序返回的预览明细;不可退款订单保留原因但不会进入实际退款任务。
|
||||
*/
|
||||
@Schema(description = "批量退款订单明细")
|
||||
private List<OrderItem> orders;
|
||||
|
||||
/**
|
||||
* 批量退款预览汇总,数量和金额仅累计当前仍可退款的订单。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "批量退款预览汇总")
|
||||
public static class Summary {
|
||||
|
||||
/** 当前请求中通过数据权限校验的订单数量。 */
|
||||
@Schema(description = "预览订单数")
|
||||
private Integer selectedOrderCount;
|
||||
|
||||
/** 当前校验通过并会进入退款任务的订单数量。 */
|
||||
@Schema(description = "可退款订单数")
|
||||
private Integer refundableOrderCount;
|
||||
|
||||
/** 因状态、渠道或剩余金额不满足要求而跳过的订单数量。 */
|
||||
@Schema(description = "不可退款订单数")
|
||||
private Integer unavailableOrderCount;
|
||||
|
||||
/** 可退款订单中的去重订餐人数,未知用户不计入。 */
|
||||
@Schema(description = "订餐人数")
|
||||
private Integer userCount;
|
||||
|
||||
/** 可退款订单中按商品主键去重后的菜品种类数。 */
|
||||
@Schema(description = "菜品种类数")
|
||||
private Integer productKindCount;
|
||||
|
||||
/** 可退款数量商品的剩余总份数,不包含称重商品。 */
|
||||
@Schema(description = "剩余可退商品总份数")
|
||||
private Long productCount;
|
||||
|
||||
/** 可退款称重商品的剩余总重量,单位沿用购物车重量协议。 */
|
||||
@Schema(description = "剩余可退商品总重量")
|
||||
private BigDecimal productWeight;
|
||||
|
||||
/** 可退款订单的剩余可退总金额,单位为分。 */
|
||||
@Schema(description = "剩余可退总金额,单位分")
|
||||
private Long refundPrice;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单笔订单的批量退款预览信息。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "批量退款订单预览明细")
|
||||
public static class OrderItem {
|
||||
|
||||
/** Hardware 订单主键,也是异步任务逐笔退款的稳定业务标识。 */
|
||||
@Schema(description = "订单主键")
|
||||
private Long orderId;
|
||||
|
||||
/** 订单业务编号;历史数据可能为空。 */
|
||||
@Schema(description = "订单业务编号")
|
||||
private String orderNo;
|
||||
|
||||
/** 优先取预约用餐日期,其次取支付日期,最后取订单创建日期。 */
|
||||
@Schema(description = "退款预览业务日期")
|
||||
private LocalDate businessDate;
|
||||
|
||||
/** 用餐餐段字典编码;非餐饮或历史订单可能为空。 */
|
||||
@Schema(description = "用餐餐段字典编码")
|
||||
private String hourType;
|
||||
|
||||
/** 实际订餐用户主键;匿名或历史订单可能为空。 */
|
||||
@Schema(description = "订餐用户主键")
|
||||
private Long userId;
|
||||
|
||||
/** 实际订餐人姓名;无法关联用户资料时为空。 */
|
||||
@Schema(description = "订餐人姓名")
|
||||
private String username;
|
||||
|
||||
/** 订餐人下单时关联部门名称;历史数据可能为空。 */
|
||||
@Schema(description = "订餐人部门名称")
|
||||
private String userDeptName;
|
||||
|
||||
/** 订单所属商户名称;无法关联商户资料时为空。 */
|
||||
@Schema(description = "商户名称")
|
||||
private String merchantName;
|
||||
|
||||
/** 当前已完成退款后剩余可退金额,单位为分。 */
|
||||
@Schema(description = "剩余可退金额,单位分")
|
||||
private Integer refundPrice;
|
||||
|
||||
/** 是否通过当前批量退款资格校验。 */
|
||||
@Schema(description = "是否可以退款")
|
||||
private Boolean refundable;
|
||||
|
||||
/** 不可退款原因;可退款时为空。 */
|
||||
@Schema(description = "不可退款原因")
|
||||
private String unavailableReason;
|
||||
|
||||
/** 当前订单扣除待审核和已完成售后占用后的剩余商品。 */
|
||||
@Schema(description = "剩余可退商品")
|
||||
private List<ProductItem> products;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个商品的剩余可退数量、重量和商品金额。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "批量退款商品预览明细")
|
||||
public static class ProductItem {
|
||||
|
||||
/** 原订单购物车快照中的商品主键。 */
|
||||
@Schema(description = "商品主键")
|
||||
private Long productId;
|
||||
|
||||
/** 原订单购物车快照中的商品名称,不回查可能已改名的商品主数据。 */
|
||||
@Schema(description = "商品名称")
|
||||
private String productName;
|
||||
|
||||
/** 商品性质字典编码,用于区分数量商品和称重商品。 */
|
||||
@Schema(description = "商品性质字典编码")
|
||||
private String productNature;
|
||||
|
||||
/** 数量商品的剩余可退份数;称重商品固定为零。 */
|
||||
@Schema(description = "剩余可退份数")
|
||||
private Integer productCount;
|
||||
|
||||
/** 称重商品的剩余可退重量;数量商品固定为零。 */
|
||||
@Schema(description = "剩余可退重量")
|
||||
private BigDecimal productWeight;
|
||||
|
||||
/** 原购物车商品总价扣除有效售后占用后的剩余可退金额,单位为分。 */
|
||||
@Schema(description = "剩余可退商品金额,单位分")
|
||||
private Long refundPrice;
|
||||
}
|
||||
}
|
||||
+43
-2
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.controller.order;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
|
||||
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
|
||||
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
@@ -19,9 +20,12 @@ import com.bsd.pjcloud.hard.api.dto.order.*;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto;
|
||||
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
|
||||
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleLogVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardBatchRefundPreviewVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppRefundApplyVo;
|
||||
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
|
||||
import com.bsd.pjcloud.hard.service.HardBatchRefundService;
|
||||
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
|
||||
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
|
||||
@@ -51,6 +55,9 @@ public class HardAfterSaleController {
|
||||
|
||||
private final HardAfterSaleService hardAfterSaleService;
|
||||
|
||||
/** 批量退款服务,负责权限预览和最多 1000 笔订单的异步逐笔退款。 */
|
||||
private final HardBatchRefundService hardBatchRefundService;
|
||||
|
||||
private final RemoteMchService remoteMchService;
|
||||
|
||||
/**
|
||||
@@ -99,12 +106,18 @@ public class HardAfterSaleController {
|
||||
return R.ok(hardAfterSaleService.cancelAfterSale(user.getId(), id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前 App 用户可见的售后进度,并在对应业务节点返回退款原因。
|
||||
*
|
||||
* @param id 售后单 ID
|
||||
* @return 带节点退款原因的售后进度
|
||||
*/
|
||||
@ApiCryptoAes
|
||||
@GetMapping(value = "/app/history")
|
||||
@Operation(summary = "app查询售后进度")
|
||||
public R<List<HardAfterSaleLogEntity>> appHistory(@RequestParam Long id) {
|
||||
public R<List<HardAfterSaleLogVo>> appHistory(@RequestParam Long id) {
|
||||
hardAfterSaleService.validateAppAfterSaleAccess(id, SecurityUtils.getUser().getId());
|
||||
return R.ok(hardAfterSaleService.getHistory(id));
|
||||
return R.ok(hardAfterSaleService.getAppHistory(id));
|
||||
}
|
||||
|
||||
@ApiCryptoAes
|
||||
@@ -168,6 +181,34 @@ public class HardAfterSaleController {
|
||||
return R.ok(hardAfterSaleService.adminReinforceRefund(dto, user.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前后台用户的数据范围预览勾选订单的日期、餐段、订餐人、剩余商品和退款金额。
|
||||
*
|
||||
* @param dto 列表页跨页勾选的订单主键,单次最多 1000 笔
|
||||
* @return 当前时点服务端重新核定的批量退款预览
|
||||
*/
|
||||
@PostMapping(value = "/batch-refund/preview")
|
||||
@Operation(summary = "后台批量退款预览")
|
||||
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
|
||||
public R<HardBatchRefundPreviewVo> batchRefundPreview(@Valid @RequestBody HardBatchRefundPreviewDto dto) {
|
||||
return R.ok(hardBatchRefundService.preview(dto, SecurityUtils.getUser()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交批量退款异步任务,每笔订单沿用现有整单退款订单锁和独立事务。
|
||||
*
|
||||
* @param dto 订单主键和本批次必填退款备注
|
||||
* @return 可通过 Hardware 通用进度接口轮询的初始任务状态
|
||||
*/
|
||||
@PostMapping(value = "/batch-refund/task")
|
||||
@Operation(summary = "后台批量退款")
|
||||
@SysLog("后台批量退款")
|
||||
@Idempotent(expireTime = 5, info = "批量退款任务正在提交,请勿重复操作")
|
||||
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
|
||||
public R<AsyncTaskProgressVO> submitBatchRefund(@Valid @RequestBody HardBatchRefundSubmitDto dto) {
|
||||
return R.ok(hardBatchRefundService.submit(dto, SecurityUtils.getUser()), "批量退款任务已提交");
|
||||
}
|
||||
|
||||
@GetMapping(value = "/history")
|
||||
@Operation(summary = "查询售后进度")
|
||||
public R<List<HardAfterSaleLogEntity>> history(@RequestParam Long id) {
|
||||
|
||||
+9
@@ -47,6 +47,15 @@ public interface HardAfterSaleCarService extends IService<HardAfterSaleCarEntity
|
||||
*/
|
||||
List<HardAfterSaleCarEntity> getOccupiedByCarParentId(Long carParentId, List<String> statuses);
|
||||
|
||||
/**
|
||||
* 批量查询多个父购物车在指定售后状态下的商品占用明细,避免退款预览逐订单访问数据库。
|
||||
*
|
||||
* @param carParentIds 父购物车主键集合,空集合不发起数据库查询
|
||||
* @param statuses 需要计入占用的售后状态编码集合
|
||||
* @return 父购物车主键到商品聚合明细的映射;无占用的购物车不出现在映射中
|
||||
*/
|
||||
Map<Long, List<HardAfterSaleCarEntity>> getOccupiedByCarParentIds(List<Long> carParentIds, List<String> statuses);
|
||||
|
||||
/**
|
||||
* 按售后状态为订单购物车展示对象补充原购、已退、申请中和剩余份数。
|
||||
*
|
||||
|
||||
+9
@@ -10,6 +10,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardDisagreeDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto;
|
||||
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
|
||||
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleLogVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppRefundApplyVo;
|
||||
@@ -57,6 +58,14 @@ public interface HardAfterSaleService extends IService<HardAfterSaleEntity> {
|
||||
*/
|
||||
List<HardAfterSaleLogEntity> getHistory(Long id);
|
||||
|
||||
/**
|
||||
* 获取 App 售后进度,并仅在申请、拒绝或直接退款节点补充对应原因。
|
||||
*
|
||||
* @param id 售后单 ID
|
||||
* @return App 可见的售后进度记录
|
||||
*/
|
||||
List<HardAfterSaleLogVo> getAppHistory(Long id);
|
||||
|
||||
/**
|
||||
* 退款
|
||||
*/
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.bsd.pjcloud.hard.service;
|
||||
|
||||
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
|
||||
import com.bsd.pjcloud.common.security.service.PjcloudUser;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundPreviewDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundSubmitDto;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardBatchRefundPreviewVo;
|
||||
|
||||
/**
|
||||
* 后台订单批量退款服务,负责权限冻结、只读预览和逐笔独立退款任务。
|
||||
*/
|
||||
public interface HardBatchRefundService {
|
||||
|
||||
/**
|
||||
* 按当前后台用户的数据范围生成批量退款预览。
|
||||
*
|
||||
* @param dto 列表页跨页勾选的订单主键
|
||||
* @param user 当前后台登录用户,用于设备类型和商户数据范围校验
|
||||
* @return 服务端实时计算的订单、菜品、人员和剩余金额明细
|
||||
*/
|
||||
HardBatchRefundPreviewVo preview(HardBatchRefundPreviewDto dto, PjcloudUser user);
|
||||
|
||||
/**
|
||||
* 冻结当前仍有权限访问的订单并提交异步逐笔退款任务。
|
||||
*
|
||||
* @param dto 订单主键和本批次统一退款备注
|
||||
* @param user 当前后台登录用户,用户主键会记录为每笔退款的审核操作人
|
||||
* @return 前端可通过通用进度接口轮询的初始任务状态
|
||||
*/
|
||||
AsyncTaskProgressVO submit(HardBatchRefundSubmitDto dto, PjcloudUser user);
|
||||
}
|
||||
+20
@@ -72,6 +72,26 @@ public class HardAfterSaleCarServiceImpl extends ServiceImpl<HardAfterSaleCarMap
|
||||
return baseMapper.getOccupiedByCarParentId(carParentId, statuses);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次查询全部父购物车的商品占用,再按父购物车主键分组供批量预览复用。
|
||||
*
|
||||
* @param carParentIds 父购物车主键集合
|
||||
* @param statuses 需要计入占用的售后状态编码集合
|
||||
* @return 父购物车主键到商品占用明细的有序映射
|
||||
*/
|
||||
@Override
|
||||
public Map<Long, List<HardAfterSaleCarEntity>> getOccupiedByCarParentIds(List<Long> carParentIds,
|
||||
List<String> statuses) {
|
||||
if (ObjectUtil.isEmpty(carParentIds) || ObjectUtil.isEmpty(statuses)) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, List<HardAfterSaleCarEntity>> result = new LinkedHashMap<>();
|
||||
baseMapper.getOccupiedByCarParentIds(carParentIds.stream().filter(java.util.Objects::nonNull).distinct().toList(),
|
||||
statuses)
|
||||
.forEach(item -> result.computeIfAbsent(item.getParentCarId(), ignored -> new ArrayList<>()).add(item));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已完成退款才减少可履约数量,申请中的份数单独返回供页面提示。
|
||||
*
|
||||
|
||||
+56
@@ -38,6 +38,7 @@ import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundItemDto;
|
||||
import com.bsd.pjcloud.hard.api.entity.order.*;
|
||||
import com.bsd.pjcloud.hard.api.utils.HardUtil;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleLogVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppRefundApplyVo;
|
||||
@@ -509,6 +510,61 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将售后单上的原因投影到 App 时间线的业务节点,避免所有日志重复展示同一退款原因。
|
||||
* 会员申请节点展示申请原因,拒绝节点展示审核原因;后台或系统直接退款没有申请节点,
|
||||
* 因此在退款完成节点展示申请原因,并兼容回退到历史审核原因。
|
||||
*
|
||||
* @param id 售后单 ID
|
||||
* @return 带节点退款原因的 App 售后进度
|
||||
*/
|
||||
@Override
|
||||
public List<HardAfterSaleLogVo> getAppHistory(Long id) {
|
||||
List<HardAfterSaleLogEntity> history = getHistory(id);
|
||||
if (history.isEmpty()) return List.of();
|
||||
HardAfterSaleEntity afterSale = baseMapper.selectById(id);
|
||||
return history.stream().map(log -> {
|
||||
HardAfterSaleLogVo vo = BeanUtils.toBean(log, HardAfterSaleLogVo.class);
|
||||
if (afterSale != null) {
|
||||
vo.setReason(resolveAppHistoryReason(log.getOperateType(), afterSale));
|
||||
}
|
||||
return vo;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按售后操作类型选择当前节点的原因,原因只用于 App 展示且不改变日志持久化内容。
|
||||
*
|
||||
* @param operateType 售后日志操作类型协议编码
|
||||
* @param afterSale 当前售后单
|
||||
* @return 去除首尾空白的节点原因;该节点无需展示时返回 {@code null}
|
||||
*/
|
||||
private String resolveAppHistoryReason(String operateType, HardAfterSaleEntity afterSale) {
|
||||
if (Objects.equals(operateType, HardAfterSaleOperateTypeEnum.MEMBER_CREATE.getType())) {
|
||||
return trimToNull(afterSale.getApplyReason());
|
||||
}
|
||||
if (Objects.equals(operateType, HardAfterSaleOperateTypeEnum.ADMIN_DISAGREE_APPLY.getType())) {
|
||||
return trimToNull(afterSale.getAuditReason());
|
||||
}
|
||||
boolean directRefund = Objects.equals(operateType, HardAfterSaleOperateTypeEnum.ADMIN_REFUND.getType())
|
||||
|| Objects.equals(operateType, HardAfterSaleOperateTypeEnum.SYSTEM_AUTO_REFUND.getType())
|
||||
|| Objects.equals(operateType, HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getType());
|
||||
if (!directRefund) return null;
|
||||
String applyReason = trimToNull(afterSale.getApplyReason());
|
||||
return applyReason == null ? trimToNull(afterSale.getAuditReason()) : applyReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一过滤接口原因中的纯空白值,避免 App 渲染空的信息行。
|
||||
*
|
||||
* @param value 数据库存储的原因文本
|
||||
* @return 去除首尾空白后的文本;空值或纯空白返回 {@code null}
|
||||
*/
|
||||
private String trimToNull(String value) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用默认退款单号前缀执行售后退款。
|
||||
*
|
||||
|
||||
+733
@@ -0,0 +1,733 @@
|
||||
package com.bsd.pjcloud.hard.service.impl.order;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
import com.alibaba.excel.annotation.ExcelProperty;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
|
||||
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
|
||||
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
|
||||
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
|
||||
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
|
||||
import com.bsd.pjcloud.common.security.service.PjcloudUser;
|
||||
import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum;
|
||||
import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardAdminRefundDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundPreviewDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundSubmitDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity;
|
||||
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardBatchRefundPreviewVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
|
||||
import com.bsd.pjcloud.hard.service.HardAfterSaleCarService;
|
||||
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
|
||||
import com.bsd.pjcloud.hard.service.HardBatchRefundService;
|
||||
import com.bsd.pjcloud.hard.service.HardManagerService;
|
||||
import com.bsd.pjcloud.hard.service.HardOrderService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 后台批量退款服务实现,预览阶段批量聚合数据,执行阶段复用既有单笔整单退款事务。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class HardBatchRefundServiceImpl implements HardBatchRefundService {
|
||||
|
||||
/** 批量退款日志标签,用于定位权限校验、逐笔退款和结果文件异常。 */
|
||||
private static final String LOG_TAG = "订单批量退款";
|
||||
|
||||
/** 前后端共同遵守的单批订单上限,防止超大 IN 查询和失控的支付渠道调用。 */
|
||||
private static final int MAX_BATCH_ORDER_COUNT = 1000;
|
||||
|
||||
/** 批量退款异步进度使用的稳定 Redis 业务命名空间。 */
|
||||
private static final String BATCH_REFUND_REDIS_KEY = "hard:order:batch-refund";
|
||||
|
||||
/** 通用异步进度弹窗展示的任务名称。 */
|
||||
private static final String BATCH_REFUND_TASK_NAME = "订单批量退款";
|
||||
|
||||
/** 每笔后台整单退款固定使用的仅退款售后方式协议编码。 */
|
||||
private static final String REFUND_ONLY_WAY = "10";
|
||||
|
||||
/** 结果工作簿名称,包含每笔订单成功、失败或跳过原因。 */
|
||||
private static final String RESULT_SHEET_NAME = "批量退款结果";
|
||||
|
||||
/** 文件中心保存批量退款结果的业务目录。 */
|
||||
private static final String RESULT_FILE_DIR = "hard-batch-refund";
|
||||
|
||||
/** 文件中心区分批量退款结果文件的业务类型编码。 */
|
||||
private static final String RESULT_FILE_TYPE = "HARD_BATCH_REFUND_RESULT";
|
||||
|
||||
/** XLSX 结果文件的标准 MIME 类型。 */
|
||||
private static final String XLSX_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
|
||||
/** 结果文件名时间格式,保证同一操作人连续执行时文件名仍可区分。 */
|
||||
private static final DateTimeFormatter RESULT_FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
|
||||
/** 当前支付退款实现支持的第三方原路退款渠道。 */
|
||||
private static final Set<HardPayTypeEnum> SUPPORTED_THIRD_PARTY_PAY_TYPES = Set.of(
|
||||
HardPayTypeEnum.WECHANT_SELF_QR_CODE_PAY,
|
||||
HardPayTypeEnum.WECHANT_APP_PAY,
|
||||
HardPayTypeEnum.ALI_SELF_QR_CODE_PAY,
|
||||
HardPayTypeEnum.ALI_APP_PAY
|
||||
);
|
||||
|
||||
/** 订单服务,用于按后台数据范围批量读取订单和原购物车快照。 */
|
||||
private final HardOrderService hardOrderService;
|
||||
|
||||
/** 售后服务,复用既有整单退款业务和读取历史售后金额。 */
|
||||
private final HardAfterSaleService hardAfterSaleService;
|
||||
|
||||
/** 售后商品服务,用一次查询取得全部订单的待审核和已退款商品占用。 */
|
||||
private final HardAfterSaleCarService hardAfterSaleCarService;
|
||||
|
||||
/** 设备权限服务,用于冻结当前后台用户允许查看的设备类型范围。 */
|
||||
private final HardManagerService hardManagerService;
|
||||
|
||||
/** 通用异步任务执行器,负责恢复租户、登录和 Feign Token 上下文并保存进度。 */
|
||||
private final AsyncTaskExecutorService asyncTaskExecutorService;
|
||||
|
||||
/** 文件中心服务,用于保存最多 1000 笔订单的退款执行结果工作簿。 */
|
||||
private final RemoteFileService remoteFileService;
|
||||
|
||||
/** Spring 事务管理器,用于让每笔退款在异步任务外层事务中仍以独立事务提交。 */
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
|
||||
/**
|
||||
* 生成只读退款预览,订单、购物车、售后金额和商品占用均按批量方式读取。
|
||||
*
|
||||
* @param dto 列表页勾选的订单主键
|
||||
* @param user 当前后台登录用户
|
||||
* @return 当前时点的批量退款预览
|
||||
*/
|
||||
@Override
|
||||
public HardBatchRefundPreviewVo preview(HardBatchRefundPreviewDto dto, PjcloudUser user) {
|
||||
return buildPreview(normalizeOrderIds(dto == null ? null : dto.getOrderIds()), user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交异步退款任务前同步冻结权限与预览状态,避免任务线程接收未经授权的订单主键。
|
||||
*
|
||||
* @param dto 订单主键和统一退款备注
|
||||
* @param user 当前后台登录用户
|
||||
* @return 通用异步任务初始进度
|
||||
*/
|
||||
@Override
|
||||
public AsyncTaskProgressVO submit(HardBatchRefundSubmitDto dto, PjcloudUser user) {
|
||||
if (dto == null || StrUtil.isBlank(dto.getRemark())) {
|
||||
throw new IllegalArgumentException("请输入退款备注");
|
||||
}
|
||||
String remark = dto.getRemark().trim();
|
||||
HardBatchRefundPreviewVo preview = buildPreview(normalizeOrderIds(dto.getOrderIds()), user);
|
||||
long refundableCount = preview.getOrders().stream().filter(item -> Boolean.TRUE.equals(item.getRefundable())).count();
|
||||
if (refundableCount == 0) {
|
||||
throw new IllegalStateException("所选订单当前均不可退款,请刷新列表后重试");
|
||||
}
|
||||
List<HardBatchRefundPreviewVo.OrderItem> orderSnapshot = List.copyOf(preview.getOrders());
|
||||
Long operatorId = user == null ? null : user.getId();
|
||||
if (operatorId == null) {
|
||||
throw new IllegalStateException("无法取得当前退款操作人");
|
||||
}
|
||||
return asyncTaskExecutorService.submitWithResult(BATCH_REFUND_REDIS_KEY, BATCH_REFUND_TASK_NAME,
|
||||
orderSnapshot.size(), reporter -> executeBatch(orderSnapshot, remark, operatorId, reporter));
|
||||
}
|
||||
|
||||
/**
|
||||
* 去重并保留页面勾选顺序,同时执行服务端数量上限兜底。
|
||||
*
|
||||
* @param orderIds 客户端提交的订单主键
|
||||
* @return 非空、去重且不超过上限的订单主键
|
||||
*/
|
||||
private List<Long> normalizeOrderIds(List<Long> orderIds) {
|
||||
if (orderIds == null || orderIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("请选择需要退款的订单");
|
||||
}
|
||||
List<Long> normalized = orderIds.stream().filter(Objects::nonNull).distinct().toList();
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("请选择需要退款的订单");
|
||||
}
|
||||
if (normalized.size() > MAX_BATCH_ORDER_COUNT) {
|
||||
throw new IllegalArgumentException("单次最多选择" + MAX_BATCH_ORDER_COUNT + "个订单");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按订单主键一次装配订单、用户、商户、购物车及售后占用,再计算每笔剩余退款内容。
|
||||
*
|
||||
* @param orderIds 已规范化的订单主键
|
||||
* @param user 当前后台登录用户
|
||||
* @return 与请求主键顺序一致的退款预览
|
||||
*/
|
||||
private HardBatchRefundPreviewVo buildPreview(List<Long> orderIds, PjcloudUser user) {
|
||||
List<HardOrderVo> scopedOrders = loadScopedOrders(orderIds, user);
|
||||
Map<Long, HardOrderVo> orderMap = scopedOrders.stream().collect(Collectors.toMap(HardOrderVo::getId,
|
||||
item -> item, (first, second) -> first, LinkedHashMap::new));
|
||||
if (orderMap.size() != orderIds.size()) {
|
||||
throw new IllegalStateException("部分订单不存在或已超出当前数据权限,请刷新列表后重试");
|
||||
}
|
||||
|
||||
Map<Long, List<HardAfterSaleEntity>> afterSalesByOrderId = loadEffectiveAfterSales(orderIds);
|
||||
List<Long> parentCarIds = scopedOrders.stream().map(HardOrderVo::getParentCarId)
|
||||
.filter(Objects::nonNull).distinct().toList();
|
||||
Map<Long, List<HardAfterSaleCarEntity>> occupiedCarsByParentId =
|
||||
hardAfterSaleCarService.getOccupiedByCarParentIds(parentCarIds,
|
||||
List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus()));
|
||||
|
||||
List<HardBatchRefundPreviewVo.OrderItem> items = new ArrayList<>(orderIds.size());
|
||||
for (Long orderId : orderIds) {
|
||||
HardOrderVo order = orderMap.get(orderId);
|
||||
List<HardAfterSaleEntity> afterSales = afterSalesByOrderId.getOrDefault(orderId, List.of());
|
||||
List<HardAfterSaleCarEntity> occupiedCars = occupiedCarsByParentId.getOrDefault(order.getParentCarId(), List.of());
|
||||
items.add(buildOrderItem(order, afterSales, occupiedCars));
|
||||
}
|
||||
|
||||
HardBatchRefundPreviewVo result = new HardBatchRefundPreviewVo();
|
||||
result.setOrders(items);
|
||||
result.setSummary(buildSummary(items));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复用订单列表的数据范围、设备类型范围和批量关联装配能力读取所选订单。
|
||||
*
|
||||
* @param orderIds 已规范化的订单主键
|
||||
* @param user 当前后台登录用户
|
||||
* @return 当前用户仍有权限访问的订单及购物车快照
|
||||
*/
|
||||
private List<HardOrderVo> loadScopedOrders(List<Long> orderIds, PjcloudUser user) {
|
||||
if (user == null) {
|
||||
throw new IllegalStateException("无法取得当前登录用户");
|
||||
}
|
||||
HardOrderDto query = new HardOrderDto();
|
||||
query.setOrderIds(orderIds);
|
||||
query.setIsFindCarList(true);
|
||||
CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
|
||||
query.setMchTypeCodes(hardTypeInfo == null || hardTypeInfo.getHardTypeList() == null
|
||||
? List.of() : new ArrayList<>(hardTypeInfo.getHardTypeList()));
|
||||
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
|
||||
dataScope.setScopeDeptName("merchant_dept_id");
|
||||
return hardOrderService.scopeList(query, dataScope);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次读取全部待审核和已完成售后单,取消及拒绝记录不占用本次退款金额。
|
||||
*
|
||||
* @param orderIds 预览订单主键
|
||||
* @return 订单主键到有效售后记录的映射
|
||||
*/
|
||||
private Map<Long, List<HardAfterSaleEntity>> loadEffectiveAfterSales(List<Long> orderIds) {
|
||||
return hardAfterSaleService.list(new LambdaQueryWrapper<HardAfterSaleEntity>()
|
||||
.in(HardAfterSaleEntity::getOrderId, orderIds)
|
||||
.in(HardAfterSaleEntity::getStatus,
|
||||
List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus()))
|
||||
.orderByAsc(HardAfterSaleEntity::getId))
|
||||
.stream().collect(Collectors.groupingBy(HardAfterSaleEntity::getOrderId,
|
||||
LinkedHashMap::new, Collectors.toList()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成单笔订单预览并给出不可退款原因;实际任务仍会在订单锁内重新校验全部条件。
|
||||
*
|
||||
* @param order 当前数据范围内的订单
|
||||
* @param afterSales 待审核和已完成售后记录
|
||||
* @param occupiedCars 待审核和已完成售后占用的商品汇总
|
||||
* @return 单笔订单预览明细
|
||||
*/
|
||||
private HardBatchRefundPreviewVo.OrderItem buildOrderItem(HardOrderVo order,
|
||||
List<HardAfterSaleEntity> afterSales,
|
||||
List<HardAfterSaleCarEntity> occupiedCars) {
|
||||
HardBatchRefundPreviewVo.OrderItem item = new HardBatchRefundPreviewVo.OrderItem();
|
||||
item.setOrderId(order.getId());
|
||||
item.setOrderNo(order.getOrderNo());
|
||||
item.setBusinessDate(resolveBusinessDate(order));
|
||||
item.setHourType(order.getHourType());
|
||||
item.setUserId(order.getUserId());
|
||||
item.setUsername(order.getUsername());
|
||||
item.setUserDeptName(order.getUserDeptName());
|
||||
item.setMerchantName(order.getMerchantName());
|
||||
int completedRefundPrice = sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus());
|
||||
item.setRefundPrice(Math.max(0, safePrice(order.getPayPrice()) - completedRefundPrice));
|
||||
item.setProducts(buildRemainingProducts(order.getCarList(), occupiedCars));
|
||||
String unavailableReason = resolveUnavailableReason(order, afterSales, item.getProducts(), completedRefundPrice);
|
||||
item.setRefundable(unavailableReason == null);
|
||||
item.setUnavailableReason(unavailableReason);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务日期优先体现预约用餐日期,非预约订单再回退到支付或创建日期。
|
||||
*
|
||||
* @param order 当前订单
|
||||
* @return 可用于抽屉按日扫描的业务日期;历史时间均为空时返回 {@code null}
|
||||
*/
|
||||
private LocalDate resolveBusinessDate(HardOrderVo order) {
|
||||
if (order.getApptUseDate() != null) return order.getApptUseDate();
|
||||
if (order.getPayTime() != null) return order.getPayTime().toLocalDate();
|
||||
return order.getCreateTime() == null ? null : order.getCreateTime().toLocalDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按商品主键聚合原购物车重复行,并扣除待审核和已完成售后占用的数量、重量及商品金额。
|
||||
*
|
||||
* @param originalCars 原订单购物车快照
|
||||
* @param occupiedCars 有效售后商品占用汇总
|
||||
* @return 仍有数量或重量可退的商品明细,同时携带对应剩余商品金额
|
||||
*/
|
||||
private List<HardBatchRefundPreviewVo.ProductItem> buildRemainingProducts(List<HardCarVo> originalCars,
|
||||
List<HardAfterSaleCarEntity> occupiedCars) {
|
||||
if (ObjectUtil.isEmpty(originalCars)) return List.of();
|
||||
Map<Long, HardAfterSaleCarEntity> occupiedByProductId = occupiedCars.stream()
|
||||
.filter(item -> item != null && item.getProductId() != null)
|
||||
.collect(Collectors.toMap(HardAfterSaleCarEntity::getProductId, item -> item,
|
||||
(first, second) -> first, LinkedHashMap::new));
|
||||
Map<String, ProductAccumulator> products = new LinkedHashMap<>();
|
||||
for (HardCarVo car : originalCars) {
|
||||
if (car == null) continue;
|
||||
String key = car.getProductId() == null ? "car:" + car.getId() : "product:" + car.getProductId();
|
||||
products.computeIfAbsent(key, ignored -> new ProductAccumulator(car)).add(car);
|
||||
}
|
||||
List<HardBatchRefundPreviewVo.ProductItem> result = new ArrayList<>();
|
||||
for (ProductAccumulator product : products.values()) {
|
||||
HardAfterSaleCarEntity occupied = occupiedByProductId.get(product.getProductId());
|
||||
HardBatchRefundPreviewVo.ProductItem item = product.toRemainingItem(occupied);
|
||||
if (item != null) result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按现有单笔退款能力判断渠道、订单类型、历史退款和剩余购物车是否满足批量执行条件。
|
||||
*
|
||||
* @param order 当前订单
|
||||
* @param afterSales 当前有效售后记录
|
||||
* @param products 扣除有效售后占用后的剩余商品
|
||||
* @param completedRefundPrice 已完成退款金额,单位分
|
||||
* @return 不可退款原因;返回 {@code null} 表示通过预览校验
|
||||
*/
|
||||
private String resolveUnavailableReason(HardOrderVo order, List<HardAfterSaleEntity> afterSales,
|
||||
List<HardBatchRefundPreviewVo.ProductItem> products,
|
||||
int completedRefundPrice) {
|
||||
HardOrderTypeEnum orderType = HardOrderTypeEnum.valueOfByType(order.getOrderType());
|
||||
if (!HardOrderTypeEnum.isRefund(orderType) || ObjectUtil.equals(orderType, HardOrderTypeEnum.ZY)) {
|
||||
return "当前订单类型不支持后台退款";
|
||||
}
|
||||
HardPayTypeEnum payType = HardPayTypeEnum.valueOfByType(order.getPayType());
|
||||
if (payType == null || !isSupportedRefundChannel(orderType, payType)) {
|
||||
return "当前支付方式不支持原路退款";
|
||||
}
|
||||
if (safePrice(order.getPayPrice()) <= 0) {
|
||||
return "订单实付金额异常";
|
||||
}
|
||||
if (afterSales.stream().anyMatch(item -> ObjectUtil.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) {
|
||||
return "已有退款申请待审核";
|
||||
}
|
||||
if (completedRefundPrice >= safePrice(order.getPayPrice())) {
|
||||
return "订单已全部退款";
|
||||
}
|
||||
boolean simplePay = HardPayTypeEnum.isSimplePayList.contains(payType)
|
||||
|| HardOrderTypeEnum.isSimplePayList.contains(orderType);
|
||||
if (!simplePay && products.isEmpty()) {
|
||||
return "没有剩余可退商品";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款渠道严格跟随现有售后服务的分支:钱包支付或四类微信、支付宝原路退款。
|
||||
*
|
||||
* @param orderType 订单类型协议枚举
|
||||
* @param payType 支付方式协议枚举
|
||||
* @return {@code true} 表示现有服务存在对应渠道退款实现
|
||||
*/
|
||||
private boolean isSupportedRefundChannel(HardOrderTypeEnum orderType, HardPayTypeEnum payType) {
|
||||
return hardOrderService.isPay(orderType, payType) || SUPPORTED_THIRD_PARTY_PAY_TYPES.contains(payType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总可退款订单的去重人数、菜品种类、份数、重量和剩余金额。
|
||||
*
|
||||
* @param items 全部预览订单明细
|
||||
* @return 只累计可退款订单的汇总
|
||||
*/
|
||||
private HardBatchRefundPreviewVo.Summary buildSummary(List<HardBatchRefundPreviewVo.OrderItem> items) {
|
||||
HardBatchRefundPreviewVo.Summary summary = new HardBatchRefundPreviewVo.Summary();
|
||||
List<HardBatchRefundPreviewVo.OrderItem> refundableItems = items.stream()
|
||||
.filter(item -> Boolean.TRUE.equals(item.getRefundable())).toList();
|
||||
Set<Long> userIds = refundableItems.stream().map(HardBatchRefundPreviewVo.OrderItem::getUserId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Set<String> productKeys = new LinkedHashSet<>();
|
||||
long productCount = 0L;
|
||||
BigDecimal productWeight = BigDecimal.ZERO;
|
||||
long refundPrice = 0L;
|
||||
for (HardBatchRefundPreviewVo.OrderItem item : refundableItems) {
|
||||
refundPrice += safePrice(item.getRefundPrice());
|
||||
for (HardBatchRefundPreviewVo.ProductItem product : item.getProducts()) {
|
||||
productKeys.add(product.getProductId() == null ? "name:" + product.getProductName()
|
||||
: "id:" + product.getProductId());
|
||||
productCount += product.getProductCount() == null ? 0 : Math.max(0, product.getProductCount());
|
||||
productWeight = productWeight.add(safeWeight(product.getProductWeight()));
|
||||
}
|
||||
}
|
||||
summary.setSelectedOrderCount(items.size());
|
||||
summary.setRefundableOrderCount(refundableItems.size());
|
||||
summary.setUnavailableOrderCount(items.size() - refundableItems.size());
|
||||
summary.setUserCount(userIds.size());
|
||||
summary.setProductKindCount(productKeys.size());
|
||||
summary.setProductCount(productCount);
|
||||
summary.setProductWeight(productWeight);
|
||||
summary.setRefundPrice(refundPrice);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在通用异步线程中逐笔退款;每笔使用独立事务,失败会记录结果并继续后续订单。
|
||||
*
|
||||
* @param orders 提交时冻结的预览订单
|
||||
* @param remark 去除首尾空白后的统一退款备注
|
||||
* @param operatorId 后台退款操作人主键
|
||||
* @param reporter 通用异步任务进度报告器
|
||||
* @return 包含完整逐笔结果工作簿下载地址的任务结果
|
||||
*/
|
||||
private AsyncTaskResult executeBatch(List<HardBatchRefundPreviewVo.OrderItem> orders, String remark,
|
||||
Long operatorId, AsyncTaskProgressReporter reporter) {
|
||||
List<BatchRefundResultExcelRow> results = new ArrayList<>(orders.size());
|
||||
int successCount = 0;
|
||||
int failedCount = 0;
|
||||
for (HardBatchRefundPreviewVo.OrderItem order : orders) {
|
||||
if (!Boolean.TRUE.equals(order.getRefundable())) {
|
||||
failedCount++;
|
||||
results.add(toResultRow(order, "跳过", order.getUnavailableReason()));
|
||||
} else {
|
||||
try {
|
||||
refundOneOrder(order.getOrderId(), remark, operatorId);
|
||||
successCount++;
|
||||
results.add(toResultRow(order, "成功", "退款已完成"));
|
||||
} catch (Exception exception) {
|
||||
failedCount++;
|
||||
String message = safeErrorMessage(exception);
|
||||
log.warn("|{}|单笔退款失败,orderId:{},message:{}", LOG_TAG, order.getOrderId(), message);
|
||||
results.add(toResultRow(order, "失败", message));
|
||||
}
|
||||
}
|
||||
reporter.advance("已处理 " + results.size() + " / " + orders.size() + " 笔,成功 "
|
||||
+ successCount + " 笔,失败或跳过 " + failedCount + " 笔");
|
||||
}
|
||||
return createResultFile(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 REQUIRES_NEW 包裹既有整单退款,使异步任务中的某笔失败不会回滚其他已完成退款。
|
||||
*
|
||||
* @param orderId 当前退款订单主键
|
||||
* @param remark 本批次统一退款备注
|
||||
* @param operatorId 后台退款操作人主键
|
||||
*/
|
||||
private void refundOneOrder(Long orderId, String remark, Long operatorId) {
|
||||
HardAdminRefundDto refund = new HardAdminRefundDto();
|
||||
refund.setOrderId(orderId);
|
||||
refund.setWay(REFUND_ONLY_WAY);
|
||||
refund.setApplyReason(remark);
|
||||
refund.setApplyDescription(remark);
|
||||
refund.setApplyPicUrls("");
|
||||
|
||||
TransactionTemplate transaction = new TransactionTemplate(transactionManager);
|
||||
transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
Boolean result = transaction.execute(status -> hardAfterSaleService.adminRefund(refund, operatorId));
|
||||
if (!Boolean.TRUE.equals(result)) {
|
||||
throw new IllegalStateException("退款服务未返回成功结果");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单笔执行结果转换为结果工作簿行,金额使用提交任务时的预览剩余金额。
|
||||
*
|
||||
* @param order 提交时冻结的订单预览
|
||||
* @param status 执行状态:成功、失败或跳过
|
||||
* @param message 面向后台操作人的业务结果说明
|
||||
* @return 可直接交给 EasyExcel 的结果行
|
||||
*/
|
||||
private BatchRefundResultExcelRow toResultRow(HardBatchRefundPreviewVo.OrderItem order, String status,
|
||||
String message) {
|
||||
BatchRefundResultExcelRow row = new BatchRefundResultExcelRow();
|
||||
row.setOrderId(String.valueOf(order.getOrderId()));
|
||||
row.setOrderNo(order.getOrderNo());
|
||||
row.setBusinessDate(order.getBusinessDate() == null ? null : order.getBusinessDate().toString());
|
||||
row.setUsername(StrUtil.blankToDefault(order.getUsername(), "未知用户"));
|
||||
row.setPreviewRefundPrice(BigDecimal.valueOf(safePrice(order.getRefundPrice()), 2)
|
||||
.setScale(2, RoundingMode.HALF_UP).toPlainString());
|
||||
row.setStatus(status);
|
||||
row.setMessage(message);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成并上传结果工作簿;文件异常不改变已经逐笔提交的真实退款结果。
|
||||
*
|
||||
* @param results 全部订单执行结果
|
||||
* @return 成功上传时携带下载地址,文件异常时返回无附件成功结果
|
||||
*/
|
||||
private AsyncTaskResult createResultFile(List<BatchRefundResultExcelRow> results) {
|
||||
Path outputPath = null;
|
||||
String fileName = "批量退款结果_" + LocalDateTime.now().format(RESULT_FILE_TIME_FORMATTER) + ".xlsx";
|
||||
try {
|
||||
outputPath = Files.createTempFile("hard-batch-refund-", ".xlsx");
|
||||
EasyExcel.write(outputPath.toFile(), BatchRefundResultExcelRow.class)
|
||||
.sheet(RESULT_SHEET_NAME).doWrite(results);
|
||||
return AsyncTaskResult.file(uploadResultFile(outputPath, fileName), fileName);
|
||||
} catch (Exception exception) {
|
||||
log.error("|{}|结果文件生成或上传失败,fileName:{}", LOG_TAG, fileName, exception);
|
||||
return AsyncTaskResult.empty();
|
||||
} finally {
|
||||
deleteTemporaryResultFile(outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传完整结果工作簿并校验文件中心统一业务响应。
|
||||
*
|
||||
* @param file 已关闭并可读取的临时 XLSX 文件
|
||||
* @param fileName 用户下载时看到的文件名
|
||||
* @return 文件中心下载地址
|
||||
* @throws Exception 文件读取或远程上传失败时抛出
|
||||
*/
|
||||
private String uploadResultFile(Path file, String fileName) throws Exception {
|
||||
MultipartFile resultFile = new MockMultipartFile("file", encodeOriginalFilename(fileName),
|
||||
XLSX_CONTENT_TYPE, Files.readAllBytes(file));
|
||||
R<?> uploadResult = remoteFileService.innerUpload(resultFile, RESULT_FILE_DIR, null, RESULT_FILE_TYPE);
|
||||
if (uploadResult == null || !uploadResult.isOk()
|
||||
|| !(uploadResult.getData() instanceof Map<?, ?> resultData)) {
|
||||
throw new IllegalStateException("文件中心未返回有效的批量退款结果");
|
||||
}
|
||||
Object resultUrl = resultData.get("url");
|
||||
if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) {
|
||||
throw new IllegalStateException("文件中心未返回批量退款结果下载地址");
|
||||
}
|
||||
return resultUrl.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按文件服务历史约定转换中文原始文件名编码。
|
||||
*
|
||||
* @param fileName UTF-8 中文业务文件名
|
||||
* @return 文件中心上传接口使用的 ISO-8859-1 原始文件名
|
||||
*/
|
||||
private String encodeOriginalFilename(String fileName) {
|
||||
return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除批量退款临时结果文件,清理失败只记录告警且不覆盖退款结果。
|
||||
*
|
||||
* @param outputPath 待清理的临时路径,允许为空
|
||||
*/
|
||||
private void deleteTemporaryResultFile(Path outputPath) {
|
||||
if (outputPath == null) return;
|
||||
try {
|
||||
Files.deleteIfExists(outputPath);
|
||||
} catch (Exception exception) {
|
||||
log.warn("|{}|清理临时结果文件失败,path:{}", LOG_TAG, outputPath, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取可展示的业务异常信息并限制长度,避免把技术堆栈或超长下游响应写入结果文件。
|
||||
*
|
||||
* @param exception 单笔退款抛出的异常
|
||||
* @return 最多 200 个字符的业务失败说明
|
||||
*/
|
||||
private String safeErrorMessage(Exception exception) {
|
||||
String message = exception == null ? null : exception.getMessage();
|
||||
if (StrUtil.isBlank(message)) return "退款失败,请查看服务日志";
|
||||
return message.length() > 200 ? message.substring(0, 200) : message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总指定状态售后单金额,历史空金额按零处理并限制在整数分范围内。
|
||||
*
|
||||
* @param afterSales 当前订单有效售后记录
|
||||
* @param status 需要汇总的售后状态编码
|
||||
* @return 累计金额,单位分
|
||||
*/
|
||||
private int sumRefundPrice(List<HardAfterSaleEntity> afterSales, String status) {
|
||||
long total = afterSales.stream().filter(item -> ObjectUtil.equals(item.getStatus(), status))
|
||||
.map(HardAfterSaleEntity::getRefundPrice).filter(Objects::nonNull).mapToLong(Integer::longValue).sum();
|
||||
return total > Integer.MAX_VALUE ? Integer.MAX_VALUE : Math.toIntExact(Math.max(0L, total));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将可空金额转换为非负整数分,历史负值按零处理。
|
||||
*
|
||||
* @param price 数据库或预览中的金额
|
||||
* @return 可参与退款计算的非负金额
|
||||
*/
|
||||
private int safePrice(Integer price) {
|
||||
return price == null ? 0 : Math.max(0, price);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将可空重量转换为非负值,避免历史负数扩大剩余可退重量。
|
||||
*
|
||||
* @param weight 购物车或售后商品重量
|
||||
* @return 非负重量
|
||||
*/
|
||||
private static BigDecimal safeWeight(BigDecimal weight) {
|
||||
return weight == null ? BigDecimal.ZERO : weight.max(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将可空商品金额转换为非负整数分,历史负值按零处理。
|
||||
*
|
||||
* @param price 购物车或售后商品总价,单位分
|
||||
* @return 可参与剩余商品金额计算的非负金额
|
||||
*/
|
||||
private static long safeProductPrice(Long price) {
|
||||
return price == null ? 0L : Math.max(0L, price);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一商品多行购物车快照的原始数量、重量和金额累加器。
|
||||
*/
|
||||
@Data
|
||||
private static class ProductAccumulator {
|
||||
|
||||
/** 商品主键;无法识别的历史购物车行允许为空。 */
|
||||
private final Long productId;
|
||||
|
||||
/** 首行购物车快照中的商品名称。 */
|
||||
private final String productName;
|
||||
|
||||
/** 商品性质协议编码,用于选择数量或重量口径。 */
|
||||
private final String productNature;
|
||||
|
||||
/** 同一商品全部原购物车行的数量总和。 */
|
||||
private int originalCount;
|
||||
|
||||
/** 同一商品全部原购物车行的重量总和。 */
|
||||
private BigDecimal originalWeight = BigDecimal.ZERO;
|
||||
|
||||
/** 同一商品全部原购物车行的商品总价,单位为分。 */
|
||||
private long originalRefundPrice;
|
||||
|
||||
/**
|
||||
* 使用首行购物车快照建立商品身份和展示字段。
|
||||
*
|
||||
* @param car 当前商品第一条购物车快照
|
||||
*/
|
||||
private ProductAccumulator(HardCarVo car) {
|
||||
this.productId = car.getProductId();
|
||||
this.productName = car.getProductName();
|
||||
this.productNature = car.getProductNature();
|
||||
}
|
||||
|
||||
/**
|
||||
* 累加同一商品购物车行的非负数量、重量和商品总价。
|
||||
*
|
||||
* @param car 同一商品的购物车快照
|
||||
*/
|
||||
private void add(HardCarVo car) {
|
||||
this.originalCount += car.getProductCount() == null ? 0 : Math.max(0, car.getProductCount());
|
||||
this.originalWeight = this.originalWeight.add(safeWeight(car.getProductWeight()));
|
||||
this.originalRefundPrice = Math.addExact(this.originalRefundPrice, safeProductPrice(car.getTotalPrice()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 扣除有效售后占用并转换为抽屉展示商品;无剩余数量或重量时返回空。
|
||||
*
|
||||
* @param occupied 待审核和已完成售后商品占用汇总
|
||||
* @return 携带剩余数量、重量和商品金额的可退商品,已无剩余时返回 {@code null}
|
||||
*/
|
||||
private HardBatchRefundPreviewVo.ProductItem toRemainingItem(HardAfterSaleCarEntity occupied) {
|
||||
int occupiedCount = occupied == null || occupied.getProductCount() == null
|
||||
? 0 : Math.max(0, occupied.getProductCount());
|
||||
BigDecimal occupiedWeight = occupied == null ? BigDecimal.ZERO : safeWeight(occupied.getProductWeight());
|
||||
int remainingCount = Math.max(0, originalCount - occupiedCount);
|
||||
BigDecimal remainingWeight = originalWeight.subtract(occupiedWeight).max(BigDecimal.ZERO);
|
||||
long occupiedRefundPrice = occupied == null ? 0L : safeProductPrice(occupied.getTotalPrice());
|
||||
long remainingRefundPrice = Math.max(0L, originalRefundPrice - occupiedRefundPrice);
|
||||
boolean countProduct = ObjectUtil.equals(productNature, HardProductNatureEnum.DEFAULT.getType());
|
||||
if ((countProduct && remainingCount <= 0)
|
||||
|| (!countProduct && remainingWeight.compareTo(BigDecimal.ZERO) <= 0)) {
|
||||
return null;
|
||||
}
|
||||
HardBatchRefundPreviewVo.ProductItem item = new HardBatchRefundPreviewVo.ProductItem();
|
||||
item.setProductId(productId);
|
||||
item.setProductName(productName);
|
||||
item.setProductNature(productNature);
|
||||
item.setProductCount(countProduct ? remainingCount : 0);
|
||||
item.setProductWeight(countProduct ? BigDecimal.ZERO : remainingWeight);
|
||||
item.setRefundPrice(remainingRefundPrice);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步批量退款结果工作簿行,保留预览信息和最终逐笔执行状态。
|
||||
*/
|
||||
@Data
|
||||
public static class BatchRefundResultExcelRow {
|
||||
|
||||
/** Hardware 订单主键,文本格式避免 Excel 科学计数法丢失精度。 */
|
||||
@ExcelProperty("订单ID")
|
||||
private String orderId;
|
||||
|
||||
/** 订单业务编号;历史数据可能为空。 */
|
||||
@ExcelProperty("订单编号")
|
||||
private String orderNo;
|
||||
|
||||
/** 提交任务时冻结的预约、支付或创建业务日期。 */
|
||||
@ExcelProperty("业务日期")
|
||||
private String businessDate;
|
||||
|
||||
/** 提交任务时订单关联的订餐人姓名。 */
|
||||
@ExcelProperty("订餐人")
|
||||
private String username;
|
||||
|
||||
/** 提交任务时服务端计算的剩余可退金额,单位元。 */
|
||||
@ExcelProperty("预览可退金额(元)")
|
||||
private String previewRefundPrice;
|
||||
|
||||
/** 本次异步任务的逐笔状态:成功、失败或跳过。 */
|
||||
@ExcelProperty("处理状态")
|
||||
private String status;
|
||||
|
||||
/** 渠道退款结果或不可退款原因,不包含技术堆栈。 */
|
||||
@ExcelProperty("处理说明")
|
||||
private String message;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user