package cn.iocoder.yudao.module.aftersales.service.returnobj; import cn.hutool.core.collection.CollUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.aftersales.controller.admin.returnobj.vo.AfterSaleReturnPageReqVO; import cn.iocoder.yudao.module.aftersales.controller.admin.returnobj.vo.AfterSaleReturnSaveReqVO; import cn.iocoder.yudao.module.aftersales.dal.dataobject.returnobj.AfterSaleReturnDO; import cn.iocoder.yudao.module.aftersales.dal.dataobject.returnobj.AfterSaleReturnItemDO; import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketDO; import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketItemDO; import cn.iocoder.yudao.module.aftersales.dal.mysql.returnobj.AfterSaleReturnItemMapper; import cn.iocoder.yudao.module.aftersales.dal.mysql.returnobj.AfterSaleReturnMapper; import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketMapper; import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketItemMapper; import cn.iocoder.yudao.module.aftersales.dal.redis.no.AfterSaleNoRedisDAO; import cn.iocoder.yudao.module.aftersales.enums.AfterSaleBizTypeEnum; import cn.iocoder.yudao.module.aftersales.enums.AfterSaleReturnStatusEnum; import cn.iocoder.yudao.module.aftersales.enums.AfterSaleTicketStatusEnum; import cn.iocoder.yudao.module.aftersales.enums.permission.AfterSalePermissionLevelEnum; import cn.iocoder.yudao.module.aftersales.service.permission.AfterSalePermissionService; import cn.iocoder.yudao.module.aftersales.service.permission.bo.AfterSalePermissionCreateReqBO; import cn.iocoder.yudao.module.mes.api.returnsales.MesWmReturnSalesApi; import cn.iocoder.yudao.module.mes.api.returnsales.dto.MesWmReturnSalesCreateReqDTO; import cn.iocoder.yudao.module.mes.api.returnsales.dto.MesWmReturnSalesLineRespDTO; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; import java.util.Objects; import java.util.HashSet; import java.util.function.Function; import java.util.stream.Collectors; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.aftersales.enums.ErrorCodeConstants.*; /** * 售后退货申请 Service 实现类 */ @Service @Validated @Slf4j public class AfterSaleReturnServiceImpl implements AfterSaleReturnService { @Resource private AfterSaleReturnMapper returnMapper; @Resource private AfterSaleReturnItemMapper returnItemMapper; @Resource private AfterSaleTicketMapper ticketMapper; @Resource private AfterSaleTicketItemMapper ticketItemMapper; @Resource private AfterSaleNoRedisDAO noRedisDAO; @Resource private AfterSalePermissionService permissionService; @Resource private MesWmReturnSalesApi mesWmReturnSalesApi; @Override @Transactional(rollbackFor = Exception.class) public Long createReturn(AfterSaleReturnSaveReqVO createReqVO, Long userId) { // 1. 校验工单存在,且必须关联销售订单(退货的订单与物料数据由工单反显带入) AfterSaleTicketDO ticket = ticketMapper.selectById(createReqVO.getTicketId()); if (ticket == null) { throw exception(TICKET_NOT_EXISTS); } if (ticket.getSaleOrderId() == null) { throw exception(RETURN_TICKET_SALE_ORDER_REQUIRED); } if (!AfterSaleTicketStatusEnum.PROCESSING.getStatus().equals(ticket.getStatus()) || !Objects.equals(ticket.getCustomerId(), createReqVO.getCustomerId()) || ticket.getReturnId() != null || returnMapper.existsActiveByTicketId(ticket.getId())) { throw exception(RETURN_TICKET_INVALID); } if (CollUtil.isEmpty(createReqVO.getItems())) { throw exception(RETURN_ITEM_EMPTY); } if (createReqVO.getReturnType() == null || !List.of(1, 2, 3).contains(createReqVO.getReturnType())) { throw exception(RETURN_ITEM_INVALID); } Map ticketItems = ticketItemMapper.selectListByTicketId(ticket.getId()) .stream().collect(Collectors.toMap(AfterSaleTicketItemDO::getId, Function.identity())); Set usedItems = new HashSet<>(); for (AfterSaleReturnSaveReqVO.Item item : createReqVO.getItems()) { AfterSaleTicketItemDO ticketItem = ticketItems.get(item.getTicketItemId()); if (ticketItem == null || !usedItems.add(item.getTicketItemId()) || item.getItemId() == null || !item.getItemId().equals(ticketItem.getItemId()) || (!Integer.valueOf(3).equals(createReqVO.getReturnType()) && !Boolean.TRUE.equals(ticketItem.getNeedReturn())) || (ticketItem.getBatchId() != null && !Objects.equals(item.getBatchId(), ticketItem.getBatchId())) || item.getReturnQuantity() == null || item.getReturnQuantity().signum() <= 0 || ticketItem.getSaleQuantity() == null || item.getReturnQuantity().compareTo(ticketItem.getSaleQuantity()) > 0 || (ticketItem.getReturnQuantity() != null && ticketItem.getReturnQuantity().signum() > 0 && item.getReturnQuantity().compareTo(ticketItem.getReturnQuantity()) > 0) || item.getUnitPrice() == null || item.getUnitPrice().signum() < 0 || item.getReturnPrice() == null || item.getReturnPrice().signum() < 0) { throw exception(RETURN_ITEM_INVALID); } } // 销售订单以工单为准,保证退货单与工单关联同一订单 createReqVO.setSaleOrderId(ticket.getSaleOrderId()); // 2. 生成单号 String no = noRedisDAO.generate(AfterSaleNoRedisDAO.RETURN_NO_PREFIX); if (returnMapper.selectByNo(no) != null) { throw exception(RETURN_NO_EXISTS); } // 3. 计算应退总金额 BigDecimal totalReturnPrice = BigDecimal.ZERO; if (CollUtil.isNotEmpty(createReqVO.getItems())) { totalReturnPrice = createReqVO.getItems().stream() .map(item -> item.getReturnPrice() != null ? item.getReturnPrice() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); } // 4. 插入退货申请 AfterSaleReturnDO returnDO = BeanUtils.toBean(createReqVO, AfterSaleReturnDO.class) .setNo(no) .setTotalReturnPrice(totalReturnPrice) .setStatus(AfterSaleReturnStatusEnum.DRAFT.getStatus()); returnMapper.insert(returnDO); // 5. 插入退货明细行 if (CollUtil.isNotEmpty(createReqVO.getItems())) { createReqVO.getItems().forEach(item -> { AfterSaleReturnItemDO itemDO = BeanUtils.toBean(item, AfterSaleReturnItemDO.class); itemDO.setReturnId(returnDO.getId()); returnItemMapper.insert(itemDO); }); } // 6. 创建数据权限 AfterSalePermissionCreateReqBO permBO = new AfterSalePermissionCreateReqBO(); permBO.setBizType(AfterSaleBizTypeEnum.AFTER_SALE_RETURN.getType()); permBO.setBizId(returnDO.getId()); permBO.setUserId(userId); permBO.setLevel(AfterSalePermissionLevelEnum.OWNER.getLevel()); permissionService.createPermission(permBO); // 7. 更新工单状态为待退货 int ticketUpdated = ticketMapper.update(null, new LambdaUpdateWrapper() .eq(AfterSaleTicketDO::getId, ticket.getId()) .eq(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.PROCESSING.getStatus()) .isNull(AfterSaleTicketDO::getReturnId) .set(AfterSaleTicketDO::getReturnId, returnDO.getId()) .set(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.WAITING_RETURN.getStatus())); if (ticketUpdated != 1) { throw exception(RETURN_TICKET_INVALID); } return returnDO.getId(); } @Override @Transactional(rollbackFor = Exception.class) public void approveReturn(Long id) { // 1. 校验退货申请 AfterSaleReturnDO returnDO = validateReturnExists(id); if (!AfterSaleReturnStatusEnum.DRAFT.getStatus().equals(returnDO.getStatus())) { throw exception(RETURN_APPROVE_FAIL); } // 2. 获取关联工单(用于获取销售订单号等冗余信息) AfterSaleTicketDO ticket = ticketMapper.selectById(returnDO.getTicketId()); // 3. 仅退款不产生实物入库;其余类型将明细完整带入 MES if (Integer.valueOf(3).equals(returnDO.getReturnType())) { returnMapper.updateById(new AfterSaleReturnDO().setId(id) .setStatus(AfterSaleReturnStatusEnum.APPROVED.getStatus()) .setAuditTime(LocalDateTime.now())); if (ticketMapper.update(null, new LambdaUpdateWrapper() .eq(AfterSaleTicketDO::getId, returnDO.getTicketId()) .eq(AfterSaleTicketDO::getReturnId, id) .eq(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.WAITING_RETURN.getStatus()) .set(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.WAITING_REFUND.getStatus())) != 1) { throw exception(RETURN_TICKET_INVALID); } return; } MesWmReturnSalesCreateReqDTO mesReqDTO = new MesWmReturnSalesCreateReqDTO(); mesReqDTO.setCode(returnDO.getNo()); mesReqDTO.setName("售后退货-" + returnDO.getNo()); mesReqDTO.setSaleOrderId(returnDO.getSaleOrderId()); mesReqDTO.setSalesOrderCode(ticket != null ? ticket.getSaleOrderNo() : null); mesReqDTO.setClientId(returnDO.getCustomerId()); mesReqDTO.setReturnDate(LocalDate.now()); mesReqDTO.setReturnReason(returnDO.getReturnReason()); mesReqDTO.setRemark("售后工单退货 - 工单号: " + (ticket != null ? ticket.getNo() : "")); mesReqDTO.setAfterSaleReturnId(returnDO.getId()); mesReqDTO.setItems(returnItemMapper.selectListByReturnId(id).stream().map(item -> { MesWmReturnSalesCreateReqDTO.Item line = new MesWmReturnSalesCreateReqDTO.Item(); line.setItemId(item.getItemId()); line.setQuantity(item.getReturnQuantity()); line.setBatchId(item.getBatchId()); line.setBatchCode(item.getBatchCode()); line.setRemark(item.getRemark()); return line; }).toList()); Long mesReturnId = mesWmReturnSalesApi.createReturnSales(mesReqDTO); // 4. 回写 MES 解析后的批次(原单未记录批次的商品由 MES 自动生成退货批次) syncReturnItemBatch(id, mesReturnId); // 5. 更新退货申请状态 returnMapper.updateById(new AfterSaleReturnDO().setId(id) .setStatus(AfterSaleReturnStatusEnum.APPROVED.getStatus()) .setMesReturnId(mesReturnId) .setMesReturnCode(returnDO.getNo()) .setAuditTime(LocalDateTime.now())); } /** * 将 MES 退货单行上的批次回写到售后明细,便于页面展示与追溯 */ private void syncReturnItemBatch(Long returnId, Long mesReturnId) { List mesLines = mesWmReturnSalesApi.getReturnSalesLineList(mesReturnId); if (CollUtil.isEmpty(mesLines)) { return; } Map lineMap = mesLines.stream() .filter(line -> line.getBatchId() != null) .collect(Collectors.toMap(MesWmReturnSalesLineRespDTO::getItemId, line -> line, (a, b) -> a)); if (lineMap.isEmpty()) { return; } for (AfterSaleReturnItemDO item : returnItemMapper.selectListByReturnId(returnId)) { MesWmReturnSalesLineRespDTO line = lineMap.get(item.getItemId()); if (line == null || item.getBatchId() != null) { continue; } returnItemMapper.updateById(new AfterSaleReturnItemDO().setId(item.getId()) .setBatchId(line.getBatchId()).setBatchCode(line.getBatchCode())); } } @Override @Transactional(rollbackFor = Exception.class) public void cancelReturn(Long id) { AfterSaleReturnDO returnDO = validateReturnExists(id); if (!List.of(AfterSaleReturnStatusEnum.DRAFT.getStatus(), AfterSaleReturnStatusEnum.APPROVED.getStatus(), AfterSaleReturnStatusEnum.INSPECTING.getStatus(), AfterSaleReturnStatusEnum.INSPECTED.getStatus()) .contains(returnDO.getStatus())) { throw exception(RETURN_STATUS_ERROR); } if (returnDO.getMesReturnId() != null) { mesWmReturnSalesApi.cancelReturnSales(returnDO.getMesReturnId()); } else { onMesCanceled(id); } } @Override @Transactional(rollbackFor = Exception.class) public void onMesCanceled(Long returnId) { AfterSaleReturnDO returnDO = validateReturnExists(returnId); if (AfterSaleReturnStatusEnum.CANCELED.getStatus().equals(returnDO.getStatus())) { return; } if (!List.of(AfterSaleReturnStatusEnum.DRAFT.getStatus(), AfterSaleReturnStatusEnum.APPROVED.getStatus(), AfterSaleReturnStatusEnum.INSPECTING.getStatus(), AfterSaleReturnStatusEnum.INSPECTED.getStatus()) .contains(returnDO.getStatus())) { throw exception(RETURN_STATUS_ERROR); } returnMapper.updateById(new AfterSaleReturnDO().setId(returnId) .setStatus(AfterSaleReturnStatusEnum.CANCELED.getStatus())); if (ticketMapper.update(null, new LambdaUpdateWrapper() .eq(AfterSaleTicketDO::getId, returnDO.getTicketId()) .eq(AfterSaleTicketDO::getReturnId, returnId) .set(AfterSaleTicketDO::getReturnId, null) .set(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.PROCESSING.getStatus())) != 1) { throw exception(RETURN_TICKET_INVALID); } } @Override public AfterSaleReturnDO getReturn(Long id) { return validateReturnExists(id); } @Override public List getReturnList(Collection ids) { if (CollUtil.isEmpty(ids)) { return List.of(); } return returnMapper.selectBatchIds(ids); } @Override public PageResult getReturnPage(AfterSaleReturnPageReqVO pageReqVO, Long userId) { return returnMapper.selectPage(pageReqVO, userId, pageReqVO.getSceneType(), pageReqVO.getNo(), pageReqVO.getTicketId(), pageReqVO.getCustomerId(), pageReqVO.getStatus()); } @Override public List getReturnItemListByReturnId(Long returnId) { return returnItemMapper.selectListByReturnId(returnId); } @Override @Transactional(rollbackFor = Exception.class) public void onQualityFinished(Long returnId, String qualityResult, String reportUrl) { AfterSaleReturnDO returnDO = validateReturnExists(returnId); if (List.of(AfterSaleReturnStatusEnum.INSPECTED.getStatus(), AfterSaleReturnStatusEnum.INBOUND.getStatus(), AfterSaleReturnStatusEnum.REFUNDED.getStatus(), AfterSaleReturnStatusEnum.CLOSED.getStatus()) .contains(returnDO.getStatus())) { return; } if (!AfterSaleReturnStatusEnum.APPROVED.getStatus().equals(returnDO.getStatus()) && !AfterSaleReturnStatusEnum.INSPECTING.getStatus().equals(returnDO.getStatus())) { throw exception(RETURN_STATUS_ERROR); } returnMapper.updateById(new AfterSaleReturnDO().setId(returnId) .setQualityResult(qualityResult) .setQualityReportUrl(reportUrl) .setStatus(AfterSaleReturnStatusEnum.INSPECTED.getStatus())); } @Override @Transactional(rollbackFor = Exception.class) public void onInboundFinished(Long returnId) { AfterSaleReturnDO returnDO = validateReturnExists(returnId); if (List.of(AfterSaleReturnStatusEnum.INBOUND.getStatus(), AfterSaleReturnStatusEnum.REFUNDED.getStatus(), AfterSaleReturnStatusEnum.CLOSED.getStatus()).contains(returnDO.getStatus())) { return; } if (!AfterSaleReturnStatusEnum.INSPECTED.getStatus().equals(returnDO.getStatus())) { throw exception(RETURN_STATUS_ERROR); } returnMapper.updateById(new AfterSaleReturnDO().setId(returnId) .setStatus(AfterSaleReturnStatusEnum.INBOUND.getStatus()) .setReturnInboundTime(LocalDateTime.now())); if (ticketMapper.update(null, new LambdaUpdateWrapper() .eq(AfterSaleTicketDO::getId, returnDO.getTicketId()) .eq(AfterSaleTicketDO::getReturnId, returnId) .eq(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.WAITING_RETURN.getStatus()) .set(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.WAITING_REFUND.getStatus())) != 1) { throw exception(RETURN_TICKET_INVALID); } } @Override @Transactional(rollbackFor = Exception.class) public void onRefundCompleted(Long returnId, BigDecimal actualRefundPrice) { completeRefund(returnId, actualRefundPrice, null, false); } @Override @Transactional(rollbackFor = Exception.class) public void confirmRefundCompleted(Long returnId, BigDecimal actualRefundPrice, String voucherNo) { if (StrUtil.isBlank(voucherNo)) { throw exception(RETURN_REFUND_VOUCHER_REQUIRED); } completeRefund(returnId, actualRefundPrice, voucherNo.trim(), true); } private void completeRefund(Long returnId, BigDecimal actualRefundPrice, String voucherNo, boolean manual) { AfterSaleReturnDO returnDO = validateReturnExists(returnId); if (List.of(AfterSaleReturnStatusEnum.REFUNDED.getStatus(), AfterSaleReturnStatusEnum.CLOSED.getStatus()) .contains(returnDO.getStatus())) { return; } boolean refundOnly = Integer.valueOf(3).equals(returnDO.getReturnType()); if (!(refundOnly && AfterSaleReturnStatusEnum.APPROVED.getStatus().equals(returnDO.getStatus())) && !AfterSaleReturnStatusEnum.INBOUND.getStatus().equals(returnDO.getStatus())) { throw exception(RETURN_STATUS_ERROR); } if (actualRefundPrice == null || actualRefundPrice.signum() < 0) { throw exception(RETURN_ITEM_INVALID); } if (manual && returnDO.getTotalReturnPrice() != null && actualRefundPrice.compareTo(returnDO.getTotalReturnPrice()) > 0) { throw exception(RETURN_REFUND_EXCEEDS_TOTAL); } if (manual && !Integer.valueOf(2).equals(returnDO.getReturnType()) && returnDO.getTotalReturnPrice() != null && returnDO.getTotalReturnPrice().signum() > 0 && actualRefundPrice.signum() == 0) { throw exception(RETURN_ITEM_INVALID); } returnMapper.updateById(new AfterSaleReturnDO().setId(returnId) .setActualRefundPrice(actualRefundPrice) .setRefundVoucherNo(voucherNo) .setStatus(AfterSaleReturnStatusEnum.REFUNDED.getStatus()) .setRefundTime(LocalDateTime.now())); // 退款或换货完成后工单才能完结 if (ticketMapper.update(null, new LambdaUpdateWrapper() .eq(AfterSaleTicketDO::getId, returnDO.getTicketId()) .eq(AfterSaleTicketDO::getReturnId, returnId) .eq(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.WAITING_REFUND.getStatus()) .set(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.COMPLETED.getStatus()) .set(AfterSaleTicketDO::getResolvedTime, LocalDateTime.now())) != 1) { throw exception(RETURN_TICKET_INVALID); } } // ========== 内部方法 ========== private AfterSaleReturnDO validateReturnExists(Long id) { AfterSaleReturnDO returnDO = returnMapper.selectById(id); if (returnDO == null) { throw exception(RETURN_NOT_EXISTS); } return returnDO; } }