diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java index dd7e7063..01497d1a 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java @@ -14,6 +14,9 @@ import java.util.List; @Data public class HardAfterSaleDto extends HardAfterSaleEntity implements BaseUserIdentityQueryInterface { + /** 批量预览指定的售后单主键;仅为查询条件,不对应持久化字段,空集合不限制范围。 */ + private List afterSaleIds; + /** 售后申请用户主键集合;为空时不限制申请人。 */ private List userIds; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchAfterSaleRefundDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchAfterSaleRefundDto.java new file mode 100644 index 00000000..2c6c2ae3 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchAfterSaleRefundDto.java @@ -0,0 +1,21 @@ +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 HardBatchAfterSaleRefundDto { + + /** 售后单主键集合;保留勾选顺序,服务端去重并校验当前账号的数据范围,单次最多 1000 条。 */ + @NotEmpty(message = "请选择需要退款的售后申请") + @Size(max = 1000, message = "单次最多选择1000条售后申请") + @Schema(description = "待审核售后单主键,单次最多1000条") + private List<@NotNull(message = "售后单id不能为空") Long> afterSaleIds; +} 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 index 3ff8bac2..a8bc8d73 100644 --- 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 @@ -8,7 +8,7 @@ import java.time.LocalDate; import java.util.List; /** - * 后台批量退款预览结果,按订单展示服务端实时核定的剩余可退商品和金额。 + * 后台批量退款预览结果;普通订单展示剩余可退内容,售后模式展示指定申请保存的退款快照。 */ @Data @Schema(description = "后台批量退款预览结果") @@ -27,13 +27,13 @@ public class HardBatchRefundPreviewVo { private List orders; /** - * 批量退款预览汇总,数量和金额仅累计当前仍可退款的订单。 + * 批量退款预览汇总,数量和金额仅累计可退款条目;普通模式按订单,售后模式按申请计数。 */ @Data @Schema(description = "批量退款预览汇总") public static class Summary { - /** 当前请求中通过数据权限校验的订单数量。 */ + /** 当前请求中通过数据权限校验的预览条目数;售后模式同一原订单的多条申请分别计数。 */ @Schema(description = "预览订单数") private Integer selectedOrderCount; @@ -53,15 +53,15 @@ public class HardBatchRefundPreviewVo { @Schema(description = "菜品种类数") private Integer productKindCount; - /** 可退款数量商品的剩余总份数,不包含称重商品。 */ + /** 本次可退款数量商品的总份数,不包含称重商品;售后模式取原申请快照。 */ @Schema(description = "剩余可退商品总份数") private Long productCount; - /** 可退款称重商品的剩余总重量,单位沿用购物车重量协议。 */ + /** 本次可退款称重商品的总重量,单位沿用购物车重量协议;售后模式取原申请快照。 */ @Schema(description = "剩余可退商品总重量") private BigDecimal productWeight; - /** 可退款订单的剩余可退总金额,单位为分。 */ + /** 本次可退款总金额,单位分;普通模式累计剩余金额,售后模式累计原申请金额。 */ @Schema(description = "剩余可退总金额,单位分") private Long refundPrice; } @@ -73,6 +73,18 @@ public class HardBatchRefundPreviewVo { @Schema(description = "批量退款订单预览明细") public static class OrderItem { + /** 已有售后申请的主键;仅售后批量审核模式返回,用于区分同一原订单的不同申请。 */ + @Schema(description = "售后申请主键,普通订单退款为空") + private Long afterSaleId; + + /** 申请创建时保存的售后单号;仅售后模式返回,历史数据可能为空。 */ + @Schema(description = "售后单号") + private String afterSaleNo; + + /** 原售后申请原因,仅供审核展示;批量确认不会覆盖申请原因。 */ + @Schema(description = "原申请退款原因") + private String applyReason; + /** Hardware 订单主键,也是异步任务逐笔退款的稳定业务标识。 */ @Schema(description = "订单主键") private Long orderId; @@ -105,7 +117,7 @@ public class HardBatchRefundPreviewVo { @Schema(description = "商户名称") private String merchantName; - /** 当前已完成退款后剩余可退金额,单位为分。 */ + /** 本次预计退款金额,单位为分;普通订单取剩余金额,售后模式取原申请核定金额。 */ @Schema(description = "剩余可退金额,单位分") private Integer refundPrice; @@ -117,13 +129,13 @@ public class HardBatchRefundPreviewVo { @Schema(description = "不可退款原因") private String unavailableReason; - /** 当前订单扣除待审核和已完成售后占用后的剩余商品。 */ + /** 本次退款商品;普通订单取剩余商品,售后模式仅取该售后申请保存的商品快照。 */ @Schema(description = "剩余可退商品") private List products; } /** - * 单个商品的剩余可退数量、重量和商品金额。 + * 本次退款商品的数量、重量和金额;普通模式使用剩余商品,售后模式使用原申请快照。 */ @Data @Schema(description = "批量退款商品预览明细") @@ -149,7 +161,7 @@ public class HardBatchRefundPreviewVo { @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 14e99112..e22def08 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 @@ -232,6 +232,34 @@ public class HardAfterSaleController { return R.ok(hardBatchRefundService.submit(dto, SecurityUtils.getUser()), "批量退款任务已提交"); } + /** + * 预览已有退款申请,只读取原申请商品和金额,并复核当前账号的数据范围。 + * + * @param dto 跨页勾选的售后单主键 + * @return 与勾选申请逐条对应的退款预览 + */ + @PostMapping("/batch-agree/preview") + @Operation(summary = "售后批量退款预览") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_change')") + public R batchAgreePreview(@Valid @RequestBody HardBatchAfterSaleRefundDto dto) { + return R.ok(hardBatchRefundService.previewAfterSale(dto, SecurityUtils.getUser())); + } + + /** + * 提交已有售后申请的批量审核任务,每条申请沿用单笔审核的订单锁与独立事务。 + * + * @param dto 跨页勾选的售后单主键,不接收金额或申请原因修改 + * @return 通用异步任务进度及后续退款结果文件信息 + */ + @PostMapping("/batch-agree/task") + @Operation(summary = "售后批量退款") + @SysLog("售后批量退款") + @Idempotent(expireTime = 5, info = "售后批量退款任务正在提交,请勿重复操作") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_change')") + public R submitBatchAgree(@Valid @RequestBody HardBatchAfterSaleRefundDto dto) { + return R.ok(hardBatchRefundService.submitAfterSale(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/excel/HardAfterSaleRefundExportStyleHandler.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardAfterSaleRefundExportStyleHandler.java new file mode 100644 index 00000000..99dfcd37 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardAfterSaleRefundExportStyleHandler.java @@ -0,0 +1,127 @@ +package com.bsd.pjcloud.hard.excel; + +import com.alibaba.excel.write.handler.RowWriteHandler; +import com.alibaba.excel.write.handler.SheetWriteHandler; +import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; +import com.alibaba.excel.write.metadata.holder.WriteTableHolder; +import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.util.CellRangeAddress; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** 售后退款信息导出布局,配合导出行样式注解处理冻结、筛选及长文本行高。 */ +public class HardAfterSaleRefundExportStyleHandler implements SheetWriteHandler, RowWriteHandler { + + /** 当前售后退款信息固定包含十列,筛选范围与导出行字段顺序一致。 */ + private static final int COLUMN_COUNT = 10; + + /** 前四列为长 ID 或业务单号,统一使用 Excel 内置文本格式,避免科学计数法和精度损失。 */ + private static final short TEXT_FORMAT = 49; + + /** 日期、订餐人和处理状态列采用居中对齐,索引与导出行字段顺序一致。 */ + private static final Set CENTERED_COLUMNS = Set.of(4, 5, 8); + + /** 申请退款金额位于第八列,采用右对齐且保留原有两位小数数值格式。 */ + private static final int AMOUNT_COLUMN = 7; + + /** 常规数据行最小高度,单位磅,给 11 磅字体保留上下留白。 */ + private static final float MIN_ROW_HEIGHT = 26; + + /** 自动换行的单行文字高度,单位磅。 */ + private static final float TEXT_LINE_HEIGHT = 16; + + /** 数据行上下留白合计,单位磅。 */ + private static final float ROW_PADDING = 10; + + /** Excel 允许的最大行高,单位磅,避免异常长文本产生无效工作簿尺寸。 */ + private static final float MAX_ROW_HEIGHT = 409; + + /** 本次导出的明细条数,用于将筛选器限定在表头和真实数据范围内。 */ + private final int dataRowCount; + + /** 原样式、对齐方式和文本格式到微调样式的缓存,每次导出独立复用,避免逐格创建样式。 */ + private final Map columnStyles = new HashMap<>(); + + /** + * 每次导出创建独立布局处理器,避免不同退款任务共享工作表状态。 + * + * @param dataRowCount 当前结果明细条数,不包含表头 + */ + public HardAfterSaleRefundExportStyleHandler(int dataRowCount) { + this.dataRowCount = Math.max(0, dataRowCount); + } + + /** + * 隐藏工作区网格、冻结首行并开放十列筛选,明细边框由导出行注解统一控制。 + * + * @param workbookHolder 当前工作簿持有者 + * @param sheetHolder 售后退款信息工作表持有者 + */ + @Override + public void afterSheetCreate(WriteWorkbookHolder workbookHolder, WriteSheetHolder sheetHolder) { + Sheet sheet = sheetHolder.getSheet(); + sheet.setDisplayGridlines(false); + sheet.setDefaultRowHeightInPoints(MIN_ROW_HEIGHT); + sheet.createFreezePane(0, 1); + sheet.setAutoFilter(new CellRangeAddress(0, dataRowCount, 0, COLUMN_COUNT - 1)); + } + + /** + * 在数据行仍处于流式窗口时按列宽估算换行高度,避免长退款原因或失败说明被固定行高裁切。 + * + * @param sheetHolder 当前工作表持有者 + * @param tableHolder 独立表格持有者,本导出未使用独立表格 + * @param row 刚写出的数据行 + * @param relativeRowIndex 当前写入批次的相对行号 + * @param isHead 是否为表头;表头高度由导出行的注解控制 + */ + @Override + public void afterRowDispose(WriteSheetHolder sheetHolder, WriteTableHolder tableHolder, Row row, Integer relativeRowIndex, Boolean isHead) { + if (row == null || Boolean.TRUE.equals(isHead)) return; + int lineCount = 1; + for (Cell cell : row) { + applyColumnStyle(cell); + if (cell.getCellType() != CellType.STRING) continue; + // 列宽以西文字符计量,中文通常占两个字符;减去留白以保守估算实际可用宽度。 + double availableWidth = Math.max(1, sheetHolder.getSheet().getColumnWidth(cell.getColumnIndex()) / 256.0 - 2); + int cellLines = 0; + for (String line : cell.getStringCellValue().split("\\R", -1)) { + int textWidth = line.codePoints().map(codePoint -> codePoint > 0xFF ? 2 : 1).sum(); + cellLines += Math.max(1, (int) Math.ceil(textWidth / availableWidth)); + } + lineCount = Math.max(lineCount, cellLines); + } + row.setHeightInPoints(Math.min(MAX_ROW_HEIGHT, Math.max(MIN_ROW_HEIGHT, lineCount * TEXT_LINE_HEIGHT + ROW_PADDING))); + } + + /** + * 从完整正文样式复制后微调列对齐和文本格式,保留字体、细边框、换行与金额数字格式。 + * Easy Excel 的字段级 ContentStyle 会替代类级设置,因此在行写出后统一微调,避免部分列丢失边框。 + * + * @param cell 已完成写入并应用类级样式的正文单元格 + */ + private void applyColumnStyle(Cell cell) { + int column = cell.getColumnIndex(); + CellStyle originalStyle = cell.getCellStyle(); + HorizontalAlignment alignment = column == AMOUNT_COLUMN ? HorizontalAlignment.RIGHT + : CENTERED_COLUMNS.contains(column) ? HorizontalAlignment.CENTER : HorizontalAlignment.LEFT; + boolean textColumn = column < 4; + String key = originalStyle.getIndex() + ":" + alignment + ":" + textColumn; + CellStyle style = columnStyles.computeIfAbsent(key, ignored -> { + CellStyle copied = cell.getSheet().getWorkbook().createCellStyle(); + copied.cloneStyleFrom(originalStyle); + copied.setAlignment(alignment); + if (textColumn) copied.setDataFormat(TEXT_FORMAT); + return copied; + }); + cell.setCellStyle(style); + } +} 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 index e863919f..13674d29 100644 --- 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 @@ -2,12 +2,13 @@ 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.HardBatchAfterSaleRefundDto; 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 { @@ -28,4 +29,22 @@ public interface HardBatchRefundService { * @return 前端可通过通用进度接口轮询的初始任务状态 */ AsyncTaskProgressVO submit(HardBatchRefundSubmitDto dto, PjcloudUser user); + + /** + * 按售后列表数据范围预览已有申请,金额与商品沿用申请快照。 + * + * @param dto 待审核售后单主键,最多 1000 条 + * @param user 当前审核用户,用于商户与订单数据范围校验 + * @return 逐条申请的可退款状态、原申请内容及汇总 + */ + HardBatchRefundPreviewVo previewAfterSale(HardBatchAfterSaleRefundDto dto, PjcloudUser user); + + /** + * 冻结已有售后申请并异步逐条执行同意审核,不新建申请或覆盖原申请信息。 + * + * @param dto 待审核售后单主键 + * @param user 当前审核用户,其主键写入既有审核日志 + * @return 可由通用进度组件轮询及下载结果的初始任务状态 + */ + AsyncTaskProgressVO submitAfterSale(HardBatchAfterSaleRefundDto dto, PjcloudUser user); } 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 index 9254e9d8..772e25d7 100644 --- 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 @@ -4,13 +4,27 @@ 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.alibaba.excel.annotation.format.NumberFormat; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import com.alibaba.excel.annotation.write.style.ContentFontStyle; +import com.alibaba.excel.annotation.write.style.ContentStyle; +import com.alibaba.excel.annotation.write.style.HeadFontStyle; +import com.alibaba.excel.annotation.write.style.HeadRowHeight; +import com.alibaba.excel.annotation.write.style.HeadStyle; +import com.alibaba.excel.enums.BooleanEnum; +import com.alibaba.excel.enums.poi.BorderStyleEnum; +import com.alibaba.excel.enums.poi.FillPatternTypeEnum; +import com.alibaba.excel.enums.poi.HorizontalAlignmentEnum; +import com.alibaba.excel.enums.poi.VerticalAlignmentEnum; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; 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.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum; import com.bsd.pjcloud.common.security.service.PjcloudUser; @@ -20,19 +34,27 @@ 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.HardAfterSaleDto; +import com.bsd.pjcloud.hard.api.dto.order.HardBatchAfterSaleRefundDto; 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.HardAfterSaleVo; 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.HardAutoRefundTaskService; import com.bsd.pjcloud.hard.service.HardBatchRefundService; import com.bsd.pjcloud.hard.service.HardManagerService; import com.bsd.pjcloud.hard.service.HardOrderService; +import com.bsd.pjcloud.hard.excel.HardAfterSaleRefundExportStyleHandler; +import com.bsd.pjcloud.wallet.api.dto.MchManageDto; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -61,7 +83,7 @@ import java.util.Set; import java.util.stream.Collectors; /** - * 后台批量退款服务实现,预览阶段批量聚合数据,执行阶段复用既有单笔整单退款事务。 + * 后台批量退款服务,批量聚合预览数据;普通订单复用整单退款,已有申请复用原人工审核事务。 */ @Slf4j @Service @@ -80,6 +102,24 @@ public class HardBatchRefundServiceImpl implements HardBatchRefundService { /** 通用异步进度弹窗展示的任务名称。 */ private static final String BATCH_REFUND_TASK_NAME = "订单批量退款"; + /** 已有售后申请批量审核的进度命名空间,与普通订单新建退款任务隔离,不拼接租户编号。 */ + private static final String BATCH_AFTER_SALE_REDIS_KEY = "hard:after-sale:batch-refund"; + + /** 已有售后申请批量审核的任务名称,供通用进度弹窗展示。 */ + private static final String BATCH_AFTER_SALE_TASK_NAME = "售后批量退款"; + + /** 售后退款信息表头使用 Excel 标准调色板深蓝色,对应 POI IndexedColors.DARK_BLUE。 */ + private static final short AFTER_SALE_EXPORT_HEAD_COLOR = 18; + + /** 售后退款信息表头白色文字,对应 POI IndexedColors.WHITE。 */ + private static final short AFTER_SALE_EXPORT_HEAD_TEXT_COLOR = 9; + + /** 售后退款明细细边框采用浅灰色,对应 POI IndexedColors.GREY_25_PERCENT。 */ + private static final short AFTER_SALE_EXPORT_BORDER_COLOR = 22; + + /** 售后退款表头和明细统一使用的中文无衬线字体,便于管理端人员核对。 */ + private static final String AFTER_SALE_EXPORT_FONT = "微软雅黑"; + /** 每笔后台整单退款固定使用的仅退款售后方式协议编码。 */ private static final String REFUND_ONLY_WAY = "10"; @@ -113,6 +153,12 @@ public class HardBatchRefundServiceImpl implements HardBatchRefundService { /** 售后服务,复用既有整单退款业务和读取历史售后金额。 */ private final HardAfterSaleService hardAfterSaleService; + /** 自动退款任务服务,执行前再次确认售后单未被任何自动任务永久接管。 */ + private final HardAutoRefundTaskService hardAutoRefundTaskService; + + /** 商户范围查询能力,预览沿用售后列表限定的食堂、助农商户范围。 */ + private final RemoteMchService remoteMchService; + /** 售后商品服务,用一次查询取得全部订单的待审核和已退款商品占用。 */ private final HardAfterSaleCarService hardAfterSaleCarService; @@ -125,7 +171,7 @@ public class HardBatchRefundServiceImpl implements HardBatchRefundService { /** 文件中心服务,用于保存最多 1000 笔订单的退款执行结果工作簿。 */ private final RemoteFileService remoteFileService; - /** Spring 事务管理器,用于让每笔退款在异步任务外层事务中仍以独立事务提交。 */ + /** Spring 事务管理器,用于普通订单独立事务,以及售后审核时挂起通用任务的外层事务。 */ private final PlatformTransactionManager transactionManager; /** @@ -167,6 +213,312 @@ public class HardBatchRefundServiceImpl implements HardBatchRefundService { orderSnapshot.size(), reporter -> executeBatch(orderSnapshot, remark, operatorId, reporter)); } + /** + * 预览已有售后申请,不使用普通订单“扣除申请占用后的剩余商品”口径。 + * + * @param dto 勾选的售后单主键 + * @param user 当前后台审核用户 + * @return 按勾选顺序返回的原申请快照与资格结果 + */ + @Override + public HardBatchRefundPreviewVo previewAfterSale(HardBatchAfterSaleRefundDto dto, PjcloudUser user) { + return buildAfterSalePreview(normalizeAfterSaleIds(dto), user); + } + + /** + * 重新核定权限及申请状态后冻结异步任务;只提交售后 ID,不新建或修改申请内容。 + * + * @param dto 勾选的售后单主键 + * @param user 当前后台审核用户 + * @return 通用异步任务初始状态 + */ + @Override + public AsyncTaskProgressVO submitAfterSale(HardBatchAfterSaleRefundDto dto, PjcloudUser user) { + HardBatchRefundPreviewVo preview = buildAfterSalePreview(normalizeAfterSaleIds(dto), user); + if (preview.getSummary().getRefundableOrderCount() == 0) { + throw new IllegalStateException("所选售后申请当前均不可退款,请刷新列表后重试"); + } + List snapshot = List.copyOf(preview.getOrders()); + Long operatorId = user.getId(); + return asyncTaskExecutorService.submitWithResult(BATCH_AFTER_SALE_REDIS_KEY, BATCH_AFTER_SALE_TASK_NAME, + snapshot.size(), reporter -> executeAfterSaleBatch(snapshot, operatorId, reporter)); + } + + /** + * 去重并保留售后勾选顺序,接口注解之外再次限制请求数量。 + * + * @param dto 待审核申请集合,允许为空以便返回明确的业务提示 + * @return 非空且最多 1000 条的售后主键 + */ + private List normalizeAfterSaleIds(HardBatchAfterSaleRefundDto dto) { + List ids = dto == null ? null : dto.getAfterSaleIds(); + if (ids == null || ids.isEmpty() || ids.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("请选择需要退款的有效售后申请"); + } + if (ids.size() > MAX_BATCH_ORDER_COUNT) { + throw new IllegalArgumentException("单次最多选择1000条售后申请"); + } + return ids.stream().distinct().toList(); + } + + /** + * 通过售后列表查询校验全部申请的数据权限,再批量关联原订单、申请商品和已退款金额。 + * + * @param ids 已去重的售后单主键 + * @param user 当前后台审核用户 + * @return 每条申请的服务端预览;不存在或越权的申请使整个请求失败 + */ + private HardBatchRefundPreviewVo buildAfterSalePreview(List ids, PjcloudUser user) { + List afterSales = loadScopedAfterSales(ids, user); + Map afterSaleMap = afterSales.stream() + .collect(Collectors.toMap(HardAfterSaleVo::getId, item -> item)); + if (afterSaleMap.size() != ids.size()) { + throw new IllegalStateException("部分售后申请不存在或已超出当前数据权限,请刷新列表后重试"); + } + List orderIds = afterSales.stream().map(HardAfterSaleVo::getOrderId).filter(Objects::nonNull).distinct().toList(); + Map orderMap = orderIds.isEmpty() ? Map.of() : loadScopedOrders(orderIds, user).stream() + .collect(Collectors.toMap(HardOrderVo::getId, item -> item)); + Map> effectiveAfterSales = orderIds.isEmpty() ? Map.of() : loadEffectiveAfterSales(orderIds); + Map> carsByRefundId = hardAfterSaleCarService + .list(new LambdaQueryWrapper().in(HardAfterSaleCarEntity::getRefundId, ids) + .orderByAsc(HardAfterSaleCarEntity::getId)) + .stream().collect(Collectors.groupingBy(HardAfterSaleCarEntity::getRefundId)); + List items = new ArrayList<>(ids.size()); + for (Long id : ids) { + HardAfterSaleVo afterSale = afterSaleMap.get(id); + HardOrderVo order = afterSale.getOrderId() == null ? null : orderMap.get(afterSale.getOrderId()); + HardBatchRefundPreviewVo.OrderItem item = new HardBatchRefundPreviewVo.OrderItem(); + item.setAfterSaleId(id); + item.setAfterSaleNo(afterSale.getNo()); + item.setApplyReason(afterSale.getApplyReason()); + item.setOrderId(afterSale.getOrderId()); + item.setOrderNo(order == null ? afterSale.getOrderNo() : order.getOrderNo()); + if (order != null) { + item.setBusinessDate(resolveBusinessDate(order)); + item.setHourType(order.getHourType()); + item.setUserId(order.getUserId()); + item.setUsername(order.getUsername()); + item.setUserDeptName(order.getUserDeptName()); + item.setMerchantName(order.getMerchantName()); + } + // 历史空申请金额沿用单笔审核的整单金额兼容规则;有金额时绝不替换为订单剩余额度。 + item.setRefundPrice(afterSale.getRefundPrice() == null && order != null + ? safePrice(order.getPayPrice()) : safePrice(afterSale.getRefundPrice())); + item.setProducts(carsByRefundId.getOrDefault(id, List.of()).stream().map(this::toAfterSaleProduct).toList()); + String unavailableReason = resolveAfterSaleUnavailableReason(afterSale, order, item.getRefundPrice(), + afterSale.getOrderId() == null ? List.of() : effectiveAfterSales.getOrDefault(afterSale.getOrderId(), List.of())); + item.setRefundable(unavailableReason == null); + item.setUnavailableReason(unavailableReason); + items.add(item); + } + HardBatchRefundPreviewVo result = new HardBatchRefundPreviewVo(); + result.setOrders(items); + result.setSummary(buildSummary(items)); + return result; + } + + /** + * 复用售后列表的数据范围及商户类型限制,商户服务失败或无可见商户时拒绝执行。 + * + * @param ids 售后单主键,单次不超过分页拦截器的 1000 条上限 + * @param user 当前后台审核用户 + * @return 当前账号可见的售后申请及人工/自动审核标识 + */ + private List loadScopedAfterSales(List ids, PjcloudUser user) { + if (user == null || user.getId() == null) { + throw new IllegalStateException("无法取得当前退款操作人"); + } + MchManageDto mchQuery = new MchManageDto(); + mchQuery.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType(), MerchantTypeEnum.FARMER.getType())); + R> response = remoteMchService.scopeList(mchQuery); + if (response == null || !response.isOk() || response.getData() == null) { + throw new IllegalStateException("商户数据权限查询失败,请稍后重试"); + } + List mchIds = response.getData().stream().map(MchManageVo::getId).filter(Objects::nonNull).distinct().toList(); + if (mchIds.isEmpty()) { + throw new IllegalStateException("当前账号没有可审核的商户"); + } + HardAfterSaleDto query = new HardAfterSaleDto(); + query.setAfterSaleIds(ids); + query.setMchIds(mchIds); + return hardAfterSaleService.scopePageList(new Page<>(1, ids.size(), false), query).getRecords(); + } + + /** + * 将原申请保存的商品快照映射为共享抽屉行,不扣减其他申请或重新计算商品分摊。 + * + * @param car 当前售后申请的商品快照 + * @return 沿用名称、数量、重量及分摊金额的预览商品 + */ + private HardBatchRefundPreviewVo.ProductItem toAfterSaleProduct(HardAfterSaleCarEntity car) { + HardBatchRefundPreviewVo.ProductItem item = new HardBatchRefundPreviewVo.ProductItem(); + item.setProductId(car.getProductId()); + item.setProductName(car.getProductName()); + item.setProductNature(car.getProductNature()); + item.setProductCount(car.getProductCount() == null ? 0 : Math.max(0, car.getProductCount())); + item.setProductWeight(safeWeight(car.getProductWeight())); + item.setRefundPrice(safeProductPrice(car.getTotalPrice())); + return item; + } + + /** + * 按既有人工审核条件校验申请、支付渠道和累计金额,不把当前待审核申请当成退款占用扣除。 + * + * @param afterSale 当前申请与自动退款标识 + * @param order 当前数据范围内的原订单,不存在或无权限时为空 + * @param refundPrice 本条原申请的核定金额,单位分 + * @param effectiveAfterSales 同一原订单的申请中及已完成记录 + * @return 不可退款原因;为空表示可以提交,执行时仍由订单锁内再次校验 + */ + private String resolveAfterSaleUnavailableReason(HardAfterSaleVo afterSale, HardOrderVo order, int refundPrice, + List effectiveAfterSales) { + if (!Objects.equals(afterSale.getStatus(), HardSaleStatusEnum.APPLY.getStatus())) return "售后申请已不处于申请退款状态"; + if (StrUtil.isNotBlank(afterSale.getAutoRefundTaskStatus()) || StrUtil.isNotBlank(afterSale.getAutoRefundTaskType())) { + return "售后申请已由自动退款任务接管"; + } + if (order == null) return "关联订单不存在或已超出当前数据权限"; + HardOrderTypeEnum orderType = HardOrderTypeEnum.valueOfByType(order.getOrderType()); + if (orderType == null || !HardOrderTypeEnum.isRefund(orderType)) return "当前订单类型不支持退款"; + HardPayTypeEnum payType = HardPayTypeEnum.valueOfByType(order.getPayType()); + if (payType == null || !isSupportedRefundChannel(orderType, payType)) return "当前支付方式不支持原路退款"; + if (refundPrice <= 0) return "申请退款金额异常"; + long completedPrice = effectiveAfterSales.stream() + .filter(item -> !Objects.equals(item.getId(), afterSale.getId())) + .filter(item -> Objects.equals(item.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())) + .map(HardAfterSaleEntity::getRefundPrice).filter(Objects::nonNull).mapToLong(Integer::longValue).sum(); + return completedPrice + refundPrice > safePrice(order.getPayPrice()) ? "申请金额超过订单剩余可退金额" : null; + } + + /** + * 逐条执行已有申请并记录完整结果,单笔审核提交后才继续下一条。 + * + * @param snapshot 提交时重新核定的售后预览快照 + * @param operatorId 后台审核用户主键 + * @param reporter 通用任务进度报告器 + * @return 包含每条申请处理结果的 Excel 下载信息;文件失败返回无附件结果,由页面明确提示 + */ + private AsyncTaskResult executeAfterSaleBatch(List snapshot, Long operatorId, + AsyncTaskProgressReporter reporter) { + List results = new ArrayList<>(snapshot.size()); + TransactionTemplate singleApproval = new TransactionTemplate(transactionManager); + // 通用执行器会包裹整批事务。先挂起它,让原审核服务自行开启、提交单笔事务后再释放订单锁, + // 避免锁已释放但外层事务尚未提交,也避免某笔失败回滚此前已经完成的审核。 + singleApproval.setPropagationBehavior(TransactionDefinition.PROPAGATION_NOT_SUPPORTED); + int successCount = 0; + int failedCount = 0; + int skippedCount = 0; + for (HardBatchRefundPreviewVo.OrderItem item : snapshot) { + BatchAfterSaleRefundResultExcelRow result; + if (!Boolean.TRUE.equals(item.getRefundable())) { + result = toAfterSaleResultRow(item, "跳过", item.getUnavailableReason()); + } else { + try { + result = singleApproval.execute(status -> approveAfterSale(item, operatorId)); + if (result == null) throw new IllegalStateException("单笔售后审核未返回处理结果"); + } catch (Exception exception) { + log.warn("|{}|售后批量审核异常,afterSaleId:{}", LOG_TAG, item.getAfterSaleId(), exception); + result = toAfterSaleResultRow(item, "失败", safeErrorMessage(exception)); + } + } + results.add(result); + if ("成功".equals(result.getStatus())) successCount++; + else if ("跳过".equals(result.getStatus())) skippedCount++; + else failedCount++; + reporter.advance("已处理 " + results.size() + " / " + snapshot.size() + " 条,成功 " + successCount + + " 条,失败 " + failedCount + " 条,跳过 " + skippedCount + " 条"); + } + return createAfterSaleResultFile(results); + } + + /** + * 在通用任务事务之外复用单笔审核;并发审核或自动任务接管时归为跳过,渠道异常归为失败。 + * + * @param item 本次处理的申请快照 + * @param operatorId 当前审核用户主键 + * @return 当前申请的成功、失败或跳过记录 + */ + private BatchAfterSaleRefundResultExcelRow approveAfterSale(HardBatchRefundPreviewVo.OrderItem item, Long operatorId) { + try { + String skipReason = resolveAfterSaleExecutionSkipReason(item.getAfterSaleId()); + if (skipReason != null) return toAfterSaleResultRow(item, "跳过", skipReason); + if (!Boolean.TRUE.equals(hardAfterSaleService.agreeAfterSale(operatorId, item.getAfterSaleId()))) { + throw new IllegalStateException("售后审核未成功,请核对当前售后状态"); + } + return toAfterSaleResultRow(item, "成功", "原售后申请已审核通过并执行退款"); + } catch (Exception exception) { + String skipReason = null; + try { + // 预检查后仍可能等待其他审核持有的订单锁;失败后读取已提交状态以识别并发跳过。 + skipReason = resolveAfterSaleExecutionSkipReason(item.getAfterSaleId()); + } catch (Exception queryException) { + log.warn("|{}|复核售后状态失败,afterSaleId:{}", LOG_TAG, item.getAfterSaleId(), queryException); + } + log.warn("|{}|售后批量退款未执行成功,afterSaleId:{},message:{}", LOG_TAG, + item.getAfterSaleId(), safeErrorMessage(exception)); + return toAfterSaleResultRow(item, skipReason == null ? "失败" : "跳过", + skipReason == null ? safeErrorMessage(exception) : skipReason); + } + } + + /** + * 读取最新提交的人工可审核状态;最终并发保护仍由原审核服务的订单锁负责。 + * + * @param afterSaleId 本次任务指定的售后单主键 + * @return 已删除、状态变化或自动接管的跳过原因;仍可审核时为空 + */ + private String resolveAfterSaleExecutionSkipReason(Long afterSaleId) { + HardAfterSaleEntity current = hardAfterSaleService.getById(afterSaleId); + if (current == null) return "售后申请已不存在"; + if (!Objects.equals(current.getStatus(), HardSaleStatusEnum.APPLY.getStatus())) return "售后申请状态已变化,已跳过"; + return hardAutoRefundTaskService.existsByAfterSaleId(afterSaleId) ? "售后申请已由自动退款任务接管" : null; + } + + /** + * 生成售后结果行,长主键保存为文本,金额以原申请核定的分转换为元。 + * + * @param item 原申请预览快照 + * @param status 成功、失败或跳过 + * @param message 面向审核人员的处理说明 + * @return 对应一条所选售后申请的 Excel 行 + */ + private BatchAfterSaleRefundResultExcelRow toAfterSaleResultRow(HardBatchRefundPreviewVo.OrderItem item, String status, String message) { + BatchAfterSaleRefundResultExcelRow result = new BatchAfterSaleRefundResultExcelRow(); + result.setAfterSaleId(String.valueOf(item.getAfterSaleId())); + result.setAfterSaleNo(item.getAfterSaleNo()); + result.setOrderId(item.getOrderId() == null ? null : String.valueOf(item.getOrderId())); + result.setOrderNo(item.getOrderNo()); + result.setBusinessDate(item.getBusinessDate() == null ? null : item.getBusinessDate().toString()); + result.setUsername(StrUtil.blankToDefault(item.getUsername(), "未知用户")); + result.setApplyReason(item.getApplyReason()); + result.setRefundPrice(BigDecimal.valueOf(safePrice(item.getRefundPrice()), 2).setScale(2, RoundingMode.HALF_UP)); + result.setStatus(status); + result.setMessage(message); + return result; + } + + /** + * 生成并上传售后退款信息;文件异常不能撤销已经独立提交的退款,页面以无文件成功状态明确提示。 + * + * @param results 每条所选申请的最终处理结果 + * @return 可下载结果文件,生成或上传失败时返回无附件结果 + */ + private AsyncTaskResult createAfterSaleResultFile(List results) { + Path outputPath = null; + String fileName = "售后批量退款信息_" + LocalDateTime.now().format(RESULT_FILE_TIME_FORMATTER) + ".xlsx"; + try { + outputPath = Files.createTempFile("hard-after-sale-batch-refund-", ".xlsx"); + EasyExcel.write(outputPath.toFile(), BatchAfterSaleRefundResultExcelRow.class) + .registerWriteHandler(new HardAfterSaleRefundExportStyleHandler(results.size())) + .sheet("售后退款信息").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); + } + } + /** * 去重并保留页面勾选顺序,同时执行服务端数量上限兜底。 * @@ -696,6 +1048,71 @@ public class HardBatchRefundServiceImpl implements HardBatchRefundService { } } + /** 售后批量审核结果行;与普通订单退款工作簿独立,保留每条原申请及处理说明。 */ + @Data + @ColumnWidth(24) + @HeadRowHeight(30) + @HeadFontStyle(fontName = AFTER_SALE_EXPORT_FONT, fontHeightInPoints = 11, color = AFTER_SALE_EXPORT_HEAD_TEXT_COLOR, bold = BooleanEnum.TRUE) + @ContentFontStyle(fontName = AFTER_SALE_EXPORT_FONT, fontHeightInPoints = 11) + @HeadStyle(fillPatternType = FillPatternTypeEnum.SOLID_FOREGROUND, fillForegroundColor = AFTER_SALE_EXPORT_HEAD_COLOR, + horizontalAlignment = HorizontalAlignmentEnum.CENTER, verticalAlignment = VerticalAlignmentEnum.CENTER, wrapped = BooleanEnum.FALSE, + borderLeft = BorderStyleEnum.NONE, borderRight = BorderStyleEnum.NONE, borderTop = BorderStyleEnum.NONE, borderBottom = BorderStyleEnum.NONE) + @ContentStyle(horizontalAlignment = HorizontalAlignmentEnum.LEFT, verticalAlignment = VerticalAlignmentEnum.CENTER, wrapped = BooleanEnum.TRUE, + borderLeft = BorderStyleEnum.THIN, borderRight = BorderStyleEnum.THIN, borderTop = BorderStyleEnum.THIN, borderBottom = BorderStyleEnum.THIN, + leftBorderColor = AFTER_SALE_EXPORT_BORDER_COLOR, rightBorderColor = AFTER_SALE_EXPORT_BORDER_COLOR, + topBorderColor = AFTER_SALE_EXPORT_BORDER_COLOR, bottomBorderColor = AFTER_SALE_EXPORT_BORDER_COLOR) + public static class BatchAfterSaleRefundResultExcelRow { + + /** 售后单主键,使用文本避免 Excel 科学计数法丢失精度。 */ + @ExcelProperty("售后ID") + private String afterSaleId; + + /** 原申请创建时保存的售后单号;历史记录可能为空。 */ + @ExcelProperty("售后单号") + @ColumnWidth(44) + private String afterSaleNo; + + /** 原订单主键,使用文本避免长整型精度丢失。 */ + @ExcelProperty("订单ID") + private String orderId; + + /** 原订单业务编号,历史记录可能为空。 */ + @ExcelProperty("订单编号") + @ColumnWidth(32) + private String orderNo; + + /** 原订单预约用餐日期,非预约单取支付或创建日期。 */ + @ExcelProperty("业务日期") + @ColumnWidth(14) + private String businessDate; + + /** 原订单的实际订餐用户姓名,无法关联时展示未知用户。 */ + @ExcelProperty("订餐人") + @ColumnWidth(14) + private String username; + + /** 原售后申请填写的退款原因,批量审核不会覆盖。 */ + @ExcelProperty("申请退款原因") + @ColumnWidth(30) + private String applyReason; + + /** 本条原申请的预计退款金额,单位元;以数值单元格输出,保留两位小数并支持求和。 */ + @ExcelProperty("申请退款金额(元)") + @ColumnWidth(22) + @NumberFormat("#,##0.00") + private BigDecimal refundPrice; + + /** 本次任务对该申请的最终处理状态:成功、失败或跳过。 */ + @ExcelProperty("处理状态") + @ColumnWidth(12) + private String status; + + /** 审核结果、渠道失败或并发状态变化等可读原因,不包含技术堆栈。 */ + @ExcelProperty("处理说明") + @ColumnWidth(60) + private String message; + } + /** * 异步批量退款结果工作簿行,保留预览信息和最终逐笔执行状态。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml index 165aea28..c534b5b3 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml @@ -61,6 +61,12 @@ and a.id = #{dto.id} + + and a.id in + + #{id} + + and a.order_id = #{dto.orderId} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchRefundServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchRefundServiceImplTest.java new file mode 100644 index 00000000..141ae195 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchRefundServiceImplTest.java @@ -0,0 +1,335 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +import com.alibaba.excel.EasyExcel; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResultAction; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.hard.api.dto.order.HardAfterSaleDto; +import com.bsd.pjcloud.hard.api.dto.order.HardBatchAfterSaleRefundDto; +import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity; +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.HardOrderVo; +import com.bsd.pjcloud.hard.service.HardAfterSaleCarService; +import com.bsd.pjcloud.hard.service.HardAfterSaleService; +import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService; +import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.HardOrderService; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.LongStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** 售后批量退款回归用例:原申请快照、权限校验、独立审核、并发跳过及完整结果文件。 */ +@ExtendWith(MockitoExtension.class) +class HardBatchRefundServiceImplTest { + + /** 原订单展示信息及渠道判断依赖。 */ + @Mock + private HardOrderService hardOrderService; + /** 已有售后申请查询与单笔审核入口。 */ + @Mock + private HardAfterSaleService hardAfterSaleService; + /** 自动退款接管状态查询依赖。 */ + @Mock + private HardAutoRefundTaskService hardAutoRefundTaskService; + /** 当前账号商户范围依赖,不访问真实商户服务。 */ + @Mock + private RemoteMchService remoteMchService; + /** 原申请商品快照查询依赖。 */ + @Mock + private HardAfterSaleCarService hardAfterSaleCarService; + /** 订单设备范围查询依赖。 */ + @Mock + private HardManagerService hardManagerService; + /** 捕获后台任务动作,测试不启动线程或访问 Redis。 */ + @Mock + private AsyncTaskExecutorService asyncTaskExecutorService; + /** 捕获结果工作簿,测试不上传至真实文件中心。 */ + @Mock + private RemoteFileService remoteFileService; + /** 检查逐笔审核挂起通用任务外层事务的配置。 */ + @Mock + private PlatformTransactionManager transactionManager; + /** 当前后台审核用户。 */ + @Mock + private PjcloudUser user; + /** 被测共享批量退款服务。 */ + private HardBatchRefundServiceImpl service; + + /** 为每个用例创建独立服务及 Mock 依赖。 */ + @BeforeEach + void setUp() { + service = new HardBatchRefundServiceImpl(hardOrderService, hardAfterSaleService, hardAutoRefundTaskService, + remoteMchService, hardAfterSaleCarService, hardManagerService, asyncTaskExecutorService, + remoteFileService, transactionManager); + } + + /** 同一订单多条申请按售后 ID 去重及排序,申请 30 元不能变成原订单支付的 100 元。 */ + @Test + void previewShouldUseSelectedApplicationSnapshots() { + HardAfterSaleVo first = application(101L, 3000); + HardAfterSaleVo second = application(102L, 1500); + preparePreview(List.of(first, second), List.of(product(101L, "原申请菜品", 1, 3000L), product(102L, "第二条申请菜品", 2, 1500L))); + + HardBatchRefundPreviewVo preview = service.previewAfterSale(request(102L, 101L, 102L), user); + + assertEquals(List.of(102L, 101L), preview.getOrders().stream().map(HardBatchRefundPreviewVo.OrderItem::getAfterSaleId).toList()); + assertEquals(3000, preview.getOrders().get(1).getRefundPrice()); + assertEquals(4500L, preview.getSummary().getRefundPrice()); + assertEquals(1, preview.getSummary().getUserCount()); + assertEquals("原申请菜品", preview.getOrders().get(1).getProducts().get(0).getProductName()); + assertEquals(1, preview.getOrders().get(1).getProducts().get(0).getProductCount()); + ArgumentCaptor query = ArgumentCaptor.forClass(HardAfterSaleDto.class); + verify(hardAfterSaleService).scopePageList(any(), query.capture()); + assertEquals(List.of(102L, 101L), query.getValue().getAfterSaleIds()); + assertEquals(List.of(9L), query.getValue().getMchIds()); + verify(hardAfterSaleService, never()).agreeAfterSale(any(), any()); + } + + /** 非申请状态和自动任务即使出现在客户端选择中,也不能进入人工批量退款。 */ + @Test + void previewShouldExcludeCompletedAndAutomaticApplications() { + HardAfterSaleVo pending = application(101L, 3000); + HardAfterSaleVo completed = application(102L, 1000); + completed.setStatus("50"); + HardAfterSaleVo automatic = application(103L, 1000); + automatic.setAutoRefundTaskType("10"); + preparePreview(List.of(pending, completed, automatic), List.of()); + + HardBatchRefundPreviewVo preview = service.previewAfterSale(request(101L, 102L, 103L), user); + + assertEquals(1, preview.getSummary().getRefundableOrderCount()); + assertEquals(2, preview.getSummary().getUnavailableOrderCount()); + assertFalse(preview.getOrders().get(1).getRefundable()); + assertTrue(preview.getOrders().get(2).getUnavailableReason().contains("自动退款")); + assertEquals(3000L, preview.getSummary().getRefundPrice()); + } + + /** 缺失或越权申请必须使整个预览失败,不能静默缩小用户确认的范围。 */ + @Test + void previewShouldRejectMissingOrUnauthorizedApplications() { + prepareScope(List.of(application(101L, 3000))); + + assertThrows(IllegalStateException.class, () -> service.previewAfterSale(request(101L, 102L), user)); + + verify(hardOrderService, never()).scopeList(any(), any()); + verify(asyncTaskExecutorService, never()).submitWithResult(anyString(), anyString(), anyInt(), any()); + } + + /** 商户范围为空时拒绝执行,避免空 IN 条件被解释成不限制商户。 */ + @Test + void previewShouldFailClosedWithoutVisibleMerchants() { + when(user.getId()).thenReturn(7L); + when(remoteMchService.scopeList(any())).thenReturn(R.ok(List.of())); + + assertThrows(IllegalStateException.class, () -> service.previewAfterSale(request(101L), user)); + + verify(hardAfterSaleService, never()).scopePageList(any(), any()); + } + + /** 服务层独立兜底空请求及 1001 条选择,不能只依赖 Controller 参数注解。 */ + @Test + void previewShouldRejectEmptyAndOversizedSelections() { + assertThrows(IllegalArgumentException.class, () -> service.previewAfterSale(request(), user)); + HardBatchAfterSaleRefundDto oversized = new HardBatchAfterSaleRefundDto(); + oversized.setAfterSaleIds(LongStream.rangeClosed(1, 1001).boxed().toList()); + assertThrows(IllegalArgumentException.class, () -> service.previewAfterSale(oversized, user)); + verify(remoteMchService, never()).scopeList(any()); + } + + /** 一条渠道失败不阻止下一条;执行前状态变化归为跳过,结果文件包含全部所选申请。 */ + @Test + void taskShouldKeepAllResultsAndContinueAfterFailure() throws Exception { + AsyncTaskResultAction action = prepareTask(List.of(application(101L, 3000), application(102L, 1500), application(103L, 1000))); + when(hardAfterSaleService.getById(101L)).thenReturn(application(101L, 3000)); + when(hardAfterSaleService.getById(102L)).thenReturn(application(102L, 1500)); + HardAfterSaleVo changed = application(103L, 1000); + changed.setStatus("50"); + when(hardAfterSaleService.getById(103L)).thenReturn(changed); + when(hardAfterSaleService.agreeAfterSale(7L, 101L)).thenReturn(true); + when(hardAfterSaleService.agreeAfterSale(7L, 102L)).thenThrow(new IllegalStateException("渠道退款失败")); + prepareUpload(); + List progress = new ArrayList<>(); + + AsyncTaskResult result = action.execute(progress::add); + + assertEquals("/files/refund-result.xlsx", result.fileUrl()); + assertEquals(3, progress.size()); + verify(hardAfterSaleService, never()).agreeAfterSale(7L, 103L); + verify(hardAfterSaleService, never()).adminRefund(any(), any()); + List rows = uploadedRows(); + assertEquals(List.of("成功", "失败", "跳过"), rows.stream().map(HardBatchRefundServiceImpl.BatchAfterSaleRefundResultExcelRow::getStatus).toList()); + assertEquals(0, new BigDecimal("30.00").compareTo(rows.get(0).getRefundPrice())); + assertEquals("101", rows.get(0).getAfterSaleId()); + assertEquals("9007199254740993", rows.get(0).getOrderId()); + assertEquals("原申请原因", rows.get(0).getApplyReason()); + ArgumentCaptor definition = ArgumentCaptor.forClass(TransactionDefinition.class); + verify(transactionManager, times(3)).getTransaction(definition.capture()); + assertTrue(definition.getAllValues().stream().allMatch(item -> item.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NOT_SUPPORTED)); + } + + /** 预检查后被其他审核完成的申请,锁内拒绝后归为并发跳过而非再次发起退款。 */ + @Test + void taskShouldSkipAnApplicationApprovedConcurrently() throws Exception { + AsyncTaskResultAction action = prepareTask(List.of(application(101L, 3000))); + HardAfterSaleVo changed = application(101L, 3000); + changed.setStatus("50"); + when(hardAfterSaleService.getById(101L)).thenReturn(application(101L, 3000), changed); + when(hardAfterSaleService.agreeAfterSale(7L, 101L)).thenThrow(new IllegalStateException("申请状态已变化")); + prepareUpload(); + + action.execute(message -> { }); + + assertEquals("跳过", uploadedRows().get(0).getStatus()); + verify(hardAfterSaleService).agreeAfterSale(7L, 101L); + } + + /** 提交后被自动任务接管的申请必须跳过,不能从批量入口绕过人工审核限制。 */ + @Test + void taskShouldSkipAutomaticTakeoverAfterSubmission() throws Exception { + AsyncTaskResultAction action = prepareTask(List.of(application(101L, 3000))); + when(hardAfterSaleService.getById(101L)).thenReturn(application(101L, 3000)); + when(hardAutoRefundTaskService.existsByAfterSaleId(101L)).thenReturn(true); + prepareUpload(); + + action.execute(message -> { }); + + assertEquals("跳过", uploadedRows().get(0).getStatus()); + verify(hardAfterSaleService, never()).agreeAfterSale(any(), any()); + } + + /** 文件中心失败不能重新审核已处理申请,返回无附件结果供前端明确提示。 */ + @Test + void fileFailureShouldNotRepeatCompletedApprovals() { + AsyncTaskResultAction action = prepareTask(List.of(application(101L, 3000))); + when(hardAfterSaleService.getById(101L)).thenReturn(application(101L, 3000)); + when(hardAfterSaleService.agreeAfterSale(7L, 101L)).thenReturn(true); + when(remoteFileService.innerUpload(any(), anyString(), isNull(), anyString())).thenReturn(R.failed("文件中心不可用")); + + AsyncTaskResult result = action.execute(message -> { }); + + assertNull(result.fileUrl()); + verify(hardAfterSaleService).agreeAfterSale(7L, 101L); + } + + /** 提供售后列表相同的数据范围,测试不访问真实数据库。 */ + private void prepareScope(List applications) { + when(user.getId()).thenReturn(7L); + MchManageVo merchant = new MchManageVo(); + merchant.setId(9L); + when(remoteMchService.scopeList(any())).thenReturn(R.ok(List.of(merchant))); + Page page = new Page<>(1, applications.size()); + page.setRecords(applications); + when(hardAfterSaleService.scopePageList(any(), any())).thenReturn(page); + } + + /** 构造支付 100 元的原订单和独立申请快照,确保预览不使用原订单剩余商品。 */ + private void preparePreview(List applications, List products) { + prepareScope(applications); + HardOrderVo order = new HardOrderVo(); + order.setId(9007199254740993L); + order.setOrderType("2"); + order.setPayType("10"); + order.setPayPrice(10000); + order.setUserId(8L); + order.setUsername("订餐人"); + order.setApptUseDate(LocalDate.of(2026, 10, 30)); + when(hardOrderService.scopeList(any(), any())).thenReturn(List.of(order)); + when(hardAfterSaleService.list(any(Wrapper.class))).thenReturn(List.of()); + when(hardAfterSaleCarService.list(any(Wrapper.class))).thenReturn(products); + } + + /** 捕获待执行动作,并设置独立审核事务模板所需的 Mock 状态。 */ + private AsyncTaskResultAction prepareTask(List applications) { + preparePreview(applications, List.of()); + when(asyncTaskExecutorService.submitWithResult(anyString(), anyString(), anyInt(), any())).thenReturn(new AsyncTaskProgressVO()); + HardBatchAfterSaleRefundDto dto = new HardBatchAfterSaleRefundDto(); + dto.setAfterSaleIds(applications.stream().map(HardAfterSaleVo::getId).toList()); + service.submitAfterSale(dto, user); + ArgumentCaptor action = ArgumentCaptor.forClass(AsyncTaskResultAction.class); + verify(asyncTaskExecutorService).submitWithResult(eq("hard:after-sale:batch-refund"), eq("售后批量退款"), eq(applications.size()), action.capture()); + when(transactionManager.getTransaction(any())).thenAnswer(invocation -> new SimpleTransactionStatus()); + return action.getValue(); + } + + /** 为结果文件提供受控下载地址,上传的字节可由测试重新解析。 */ + private void prepareUpload() { + when(remoteFileService.innerUpload(any(), anyString(), isNull(), anyString())).thenReturn(R.ok(Map.of("url", "/files/refund-result.xlsx"))); + } + + /** 读取实际生成的工作簿,核对金额、长 ID 及逐条执行状态。 */ + private List uploadedRows() throws Exception { + ArgumentCaptor file = ArgumentCaptor.forClass(MultipartFile.class); + verify(remoteFileService).innerUpload(file.capture(), anyString(), isNull(), anyString()); + try (var input = file.getValue().getInputStream()) { + return EasyExcel.read(input).head(HardBatchRefundServiceImpl.BatchAfterSaleRefundResultExcelRow.class).sheet().doReadSync(); + } + } + + /** 构造已保存的人工售后申请,所有用例共用同一原订单以验证按申请而非订单处理。 */ + private HardAfterSaleVo application(Long id, int price) { + HardAfterSaleVo application = new HardAfterSaleVo(); + application.setId(id); + application.setOrderId(9007199254740993L); + application.setStatus("10"); + application.setNo("AFTER_" + id); + application.setRefundPrice(price); + application.setApplyReason("原申请原因"); + return application; + } + + /** 构造按售后单绑定的商品快照,名称和分摊金额不依赖当前商品资料。 */ + private HardAfterSaleCarEntity product(Long refundId, String name, int count, Long price) { + HardAfterSaleCarEntity product = new HardAfterSaleCarEntity(); + product.setRefundId(refundId); + product.setProductId(refundId); + product.setProductName(name); + product.setProductCount(count); + product.setTotalPrice(price); + return product; + } + + /** 构造接口接收的售后主键集合,保留重复项以覆盖服务端去重。 */ + private HardBatchAfterSaleRefundDto request(Long... ids) { + HardBatchAfterSaleRefundDto request = new HardBatchAfterSaleRefundDto(); + request.setAfterSaleIds(List.of(ids)); + return request; + } +}