From be749076bffd4d3ce9e9ab198971dd1ca75d13ae Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 27 Aug 2026 17:49:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=90=8E=E5=8F=B0=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E9=80=80=E6=AC=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/order/HardBatchRefundPreviewDto.java | 25 + .../dto/order/HardBatchRefundSubmitDto.java | 24 + .../hard/api/vo/order/HardAfterSaleLogVo.java | 13 + .../vo/order/HardBatchRefundPreviewVo.java | 156 ++++ .../order/HardAfterSaleController.java | 45 +- .../hard/service/HardAfterSaleCarService.java | 9 + .../hard/service/HardAfterSaleService.java | 9 + .../hard/service/HardBatchRefundService.java | 31 + .../order/HardAfterSaleCarServiceImpl.java | 20 + .../impl/order/HardAfterSaleServiceImpl.java | 56 ++ .../order/HardBatchRefundServiceImpl.java | 733 ++++++++++++++++++ 11 files changed, 1119 insertions(+), 2 deletions(-) create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundPreviewDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundSubmitDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardBatchRefundPreviewVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBatchRefundService.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchRefundServiceImpl.java diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundPreviewDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundPreviewDto.java new file mode 100644 index 000000000..a0e120b22 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundPreviewDto.java @@ -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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundSubmitDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundSubmitDto.java new file mode 100644 index 000000000..bac72a757 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundSubmitDto.java @@ -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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java index c977c2123..d1dc72869 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java @@ -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; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardBatchRefundPreviewVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardBatchRefundPreviewVo.java new file mode 100644 index 000000000..3ff8bac2d --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardBatchRefundPreviewVo.java @@ -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 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 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; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java index 833c0015e..9f8b3a75e 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java @@ -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> appHistory(@RequestParam Long id) { + public R> 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 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 submitBatchRefund(@Valid @RequestBody HardBatchRefundSubmitDto dto) { + return R.ok(hardBatchRefundService.submit(dto, SecurityUtils.getUser()), "批量退款任务已提交"); + } + @GetMapping(value = "/history") @Operation(summary = "查询售后进度") public R> history(@RequestParam Long id) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java index 77f024c45..17e8913e1 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java @@ -47,6 +47,15 @@ public interface HardAfterSaleCarService extends IService getOccupiedByCarParentId(Long carParentId, List statuses); + /** + * 批量查询多个父购物车在指定售后状态下的商品占用明细,避免退款预览逐订单访问数据库。 + * + * @param carParentIds 父购物车主键集合,空集合不发起数据库查询 + * @param statuses 需要计入占用的售后状态编码集合 + * @return 父购物车主键到商品聚合明细的映射;无占用的购物车不出现在映射中 + */ + Map> getOccupiedByCarParentIds(List carParentIds, List statuses); + /** * 按售后状态为订单购物车展示对象补充原购、已退、申请中和剩余份数。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java index 156b251b3..5913bdd38 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java @@ -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 { */ List getHistory(Long id); + /** + * 获取 App 售后进度,并仅在申请、拒绝或直接退款节点补充对应原因。 + * + * @param id 售后单 ID + * @return App 可见的售后进度记录 + */ + List getAppHistory(Long id); + /** * 退款 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBatchRefundService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBatchRefundService.java new file mode 100644 index 000000000..e863919f2 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBatchRefundService.java @@ -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); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java index 539dc118f..b634f089b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java @@ -72,6 +72,26 @@ public class HardAfterSaleCarServiceImpl extends ServiceImpl> getOccupiedByCarParentIds(List carParentIds, + List statuses) { + if (ObjectUtil.isEmpty(carParentIds) || ObjectUtil.isEmpty(statuses)) { + return Map.of(); + } + Map> 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; + } + /** * 已完成退款才减少可履约数量,申请中的份数单独返回供页面提示。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java index e1c1136bb..a5765cbf9 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java @@ -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 getAppHistory(Long id) { + List 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(); + } + /** * 使用默认退款单号前缀执行售后退款。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchRefundServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchRefundServiceImpl.java new file mode 100644 index 000000000..9254e9d8e --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchRefundServiceImpl.java @@ -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 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 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 normalizeOrderIds(List orderIds) { + if (orderIds == null || orderIds.isEmpty()) { + throw new IllegalArgumentException("请选择需要退款的订单"); + } + List 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 orderIds, PjcloudUser user) { + List scopedOrders = loadScopedOrders(orderIds, user); + Map orderMap = scopedOrders.stream().collect(Collectors.toMap(HardOrderVo::getId, + item -> item, (first, second) -> first, LinkedHashMap::new)); + if (orderMap.size() != orderIds.size()) { + throw new IllegalStateException("部分订单不存在或已超出当前数据权限,请刷新列表后重试"); + } + + Map> afterSalesByOrderId = loadEffectiveAfterSales(orderIds); + List parentCarIds = scopedOrders.stream().map(HardOrderVo::getParentCarId) + .filter(Objects::nonNull).distinct().toList(); + Map> occupiedCarsByParentId = + hardAfterSaleCarService.getOccupiedByCarParentIds(parentCarIds, + List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())); + + List items = new ArrayList<>(orderIds.size()); + for (Long orderId : orderIds) { + HardOrderVo order = orderMap.get(orderId); + List afterSales = afterSalesByOrderId.getOrDefault(orderId, List.of()); + List 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 loadScopedOrders(List 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> loadEffectiveAfterSales(List orderIds) { + return hardAfterSaleService.list(new LambdaQueryWrapper() + .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 afterSales, + List 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 buildRemainingProducts(List originalCars, + List occupiedCars) { + if (ObjectUtil.isEmpty(originalCars)) return List.of(); + Map occupiedByProductId = occupiedCars.stream() + .filter(item -> item != null && item.getProductId() != null) + .collect(Collectors.toMap(HardAfterSaleCarEntity::getProductId, item -> item, + (first, second) -> first, LinkedHashMap::new)); + Map 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 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 afterSales, + List 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 items) { + HardBatchRefundPreviewVo.Summary summary = new HardBatchRefundPreviewVo.Summary(); + List refundableItems = items.stream() + .filter(item -> Boolean.TRUE.equals(item.getRefundable())).toList(); + Set userIds = refundableItems.stream().map(HardBatchRefundPreviewVo.OrderItem::getUserId) + .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + Set 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 orders, String remark, + Long operatorId, AsyncTaskProgressReporter reporter) { + List 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 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 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; + } +}