Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
c2080bb5b4
9 files changed
+981
-13
No files matched your search
+3
@@ -14,6 +14,9 @@ import java.util.List;
|
||||
@Data
|
||||
public class HardAfterSaleDto extends HardAfterSaleEntity implements BaseUserIdentityQueryInterface {
|
||||
|
||||
/** 批量预览指定的售后单主键;仅为查询条件,不对应持久化字段,空集合不限制范围。 */
|
||||
private List<Long> afterSaleIds;
|
||||
|
||||
/** 售后申请用户主键集合;为空时不限制申请人。 */
|
||||
private List<Long> userIds;
|
||||
|
||||
|
||||
+21
@@ -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;
|
||||
}
|
||||
+22
-10
@@ -8,7 +8,7 @@ import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 后台批量退款预览结果,按订单展示服务端实时核定的剩余可退商品和金额。
|
||||
* 后台批量退款预览结果;普通订单展示剩余可退内容,售后模式展示指定申请保存的退款快照。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "后台批量退款预览结果")
|
||||
@@ -27,13 +27,13 @@ public class HardBatchRefundPreviewVo {
|
||||
private List<OrderItem> 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<ProductItem> products;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个商品的剩余可退数量、重量和商品金额。
|
||||
* 本次退款商品的数量、重量和金额;普通模式使用剩余商品,售后模式使用原申请快照。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "批量退款商品预览明细")
|
||||
@@ -149,7 +161,7 @@ public class HardBatchRefundPreviewVo {
|
||||
@Schema(description = "剩余可退重量")
|
||||
private BigDecimal productWeight;
|
||||
|
||||
/** 原购物车商品总价扣除有效售后占用后的剩余可退金额,单位为分。 */
|
||||
/** 本次退款商品分摊金额,单位分;售后模式直接读取原申请保存的总价。 */
|
||||
@Schema(description = "剩余可退商品金额,单位分")
|
||||
private Long refundPrice;
|
||||
}
|
||||
|
||||
+28
@@ -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<HardBatchRefundPreviewVo> 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<AsyncTaskProgressVO> submitBatchAgree(@Valid @RequestBody HardBatchAfterSaleRefundDto dto) {
|
||||
return R.ok(hardBatchRefundService.submitAfterSale(dto, SecurityUtils.getUser()), "售后批量退款任务已提交");
|
||||
}
|
||||
|
||||
@GetMapping(value = "/history")
|
||||
@Operation(summary = "查询售后进度")
|
||||
public R<List<HardAfterSaleLogEntity>> history(@RequestParam Long id) {
|
||||
|
||||
+127
@@ -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<Integer> 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<String, CellStyle> 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);
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -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);
|
||||
}
|
||||
+419
-2
@@ -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<HardBatchRefundPreviewVo.OrderItem> 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<Long> normalizeAfterSaleIds(HardBatchAfterSaleRefundDto dto) {
|
||||
List<Long> 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<Long> ids, PjcloudUser user) {
|
||||
List<HardAfterSaleVo> afterSales = loadScopedAfterSales(ids, user);
|
||||
Map<Long, HardAfterSaleVo> afterSaleMap = afterSales.stream()
|
||||
.collect(Collectors.toMap(HardAfterSaleVo::getId, item -> item));
|
||||
if (afterSaleMap.size() != ids.size()) {
|
||||
throw new IllegalStateException("部分售后申请不存在或已超出当前数据权限,请刷新列表后重试");
|
||||
}
|
||||
List<Long> orderIds = afterSales.stream().map(HardAfterSaleVo::getOrderId).filter(Objects::nonNull).distinct().toList();
|
||||
Map<Long, HardOrderVo> orderMap = orderIds.isEmpty() ? Map.of() : loadScopedOrders(orderIds, user).stream()
|
||||
.collect(Collectors.toMap(HardOrderVo::getId, item -> item));
|
||||
Map<Long, List<HardAfterSaleEntity>> effectiveAfterSales = orderIds.isEmpty() ? Map.of() : loadEffectiveAfterSales(orderIds);
|
||||
Map<Long, List<HardAfterSaleCarEntity>> carsByRefundId = hardAfterSaleCarService
|
||||
.list(new LambdaQueryWrapper<HardAfterSaleCarEntity>().in(HardAfterSaleCarEntity::getRefundId, ids)
|
||||
.orderByAsc(HardAfterSaleCarEntity::getId))
|
||||
.stream().collect(Collectors.groupingBy(HardAfterSaleCarEntity::getRefundId));
|
||||
List<HardBatchRefundPreviewVo.OrderItem> 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<HardAfterSaleVo> loadScopedAfterSales(List<Long> 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<List<MchManageVo>> response = remoteMchService.scopeList(mchQuery);
|
||||
if (response == null || !response.isOk() || response.getData() == null) {
|
||||
throw new IllegalStateException("商户数据权限查询失败,请稍后重试");
|
||||
}
|
||||
List<Long> 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<HardAfterSaleEntity> 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<HardBatchRefundPreviewVo.OrderItem> snapshot, Long operatorId,
|
||||
AsyncTaskProgressReporter reporter) {
|
||||
List<BatchAfterSaleRefundResultExcelRow> 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<BatchAfterSaleRefundResultExcelRow> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步批量退款结果工作簿行,保留预览信息和最终逐笔执行状态。
|
||||
*/
|
||||
|
||||
+6
@@ -61,6 +61,12 @@
|
||||
<if test="dto.id != null">
|
||||
and a.id = #{dto.id}
|
||||
</if>
|
||||
<if test="dto.afterSaleIds != null and dto.afterSaleIds.size() != 0">
|
||||
and a.id in
|
||||
<foreach collection="dto.afterSaleIds" item="id" open="(" close=")" separator=",">
|
||||
#{id}
|
||||
</foreach>
|
||||
</if>
|
||||
<if test="dto.orderId != null">
|
||||
and a.order_id = #{dto.orderId}
|
||||
</if>
|
||||
|
||||
+335
@@ -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<HardAfterSaleDto> 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<String> 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<HardBatchRefundServiceImpl.BatchAfterSaleRefundResultExcelRow> 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<TransactionDefinition> 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<HardAfterSaleVo> applications) {
|
||||
when(user.getId()).thenReturn(7L);
|
||||
MchManageVo merchant = new MchManageVo();
|
||||
merchant.setId(9L);
|
||||
when(remoteMchService.scopeList(any())).thenReturn(R.ok(List.of(merchant)));
|
||||
Page<HardAfterSaleVo> page = new Page<>(1, applications.size());
|
||||
page.setRecords(applications);
|
||||
when(hardAfterSaleService.scopePageList(any(), any())).thenReturn(page);
|
||||
}
|
||||
|
||||
/** 构造支付 100 元的原订单和独立申请快照,确保预览不使用原订单剩余商品。 */
|
||||
private void preparePreview(List<HardAfterSaleVo> applications, List<HardAfterSaleCarEntity> 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<HardAfterSaleVo> 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<AsyncTaskResultAction> 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<HardBatchRefundServiceImpl.BatchAfterSaleRefundResultExcelRow> uploadedRows() throws Exception {
|
||||
ArgumentCaptor<MultipartFile> 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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user