Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-10-30 15:37:02 +08:00
commit c2080bb5b4
9 files changed
+981 -13

No files matched your search

@@ -14,6 +14,9 @@ import java.util.List;
@Data
public class HardAfterSaleDto extends HardAfterSaleEntity implements BaseUserIdentityQueryInterface {
/** 批量预览指定的售后单主键;仅为查询条件,不对应持久化字段,空集合不限制范围。 */
private List<Long> afterSaleIds;
/** 售后申请用户主键集合;为空时不限制申请人。 */
private List<Long> userIds;
@@ -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;
}
@@ -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;
}
@@ -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) {
@@ -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);
}
}
@@ -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);
}
@@ -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;
}
/**
* 异步批量退款结果工作簿行,保留预览信息和最终逐笔执行状态。
*/
@@ -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>
@@ -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;
}
}