| | |
| | | import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketMapper; |
| | | 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.AfterSaleIssueTypeEnum; |
| | | import cn.iocoder.yudao.module.aftersales.enums.AfterSaleTicketStatusEnum; |
| | | import cn.iocoder.yudao.module.aftersales.enums.AfterSaleVisitStatusEnum; |
| | | import cn.iocoder.yudao.module.aftersales.enums.permission.AfterSalePermissionLevelEnum; |
| | |
| | | import cn.iocoder.yudao.module.aftersales.service.permission.bo.AfterSalePermissionCreateReqBO; |
| | | import cn.iocoder.yudao.module.aftersales.service.problem.AfterSaleProblemService; |
| | | import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi; |
| | | import cn.iocoder.yudao.module.system.api.user.AdminUserApi; |
| | | import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; |
| | | import jakarta.annotation.Resource; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.stereotype.Service; |
| | |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.util.Collection; |
| | | import java.util.Comparator; |
| | | import java.util.List; |
| | | |
| | | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| | |
| | | @Validated |
| | | @Slf4j |
| | | public class AfterSaleTicketServiceImpl implements AfterSaleTicketService { |
| | | |
| | | /** 客户诉求附件记录类型 */ |
| | | private static final String FEEDBACK_RECORD_TYPE = "aftersales_after_sale_ticket_feedback"; |
| | | /** 处理结果附件记录类型 */ |
| | | private static final String RESULT_RECORD_TYPE = "aftersales_after_sale_ticket_result"; |
| | | /** 通用附件记录类型(历史数据) */ |
| | | private static final String LEGACY_RECORD_TYPE = "aftersales_after_sale_ticket"; |
| | | |
| | | @Resource |
| | | private AfterSaleTicketMapper ticketMapper; |
| | |
| | | @Resource |
| | | private StorageAttachmentApi storageAttachmentApi; |
| | | |
| | | @Resource |
| | | private AfterSaleTicketFlowService ticketFlowService; |
| | | |
| | | @Resource |
| | | private AdminUserApi adminUserApi; |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public Long createTicket(AfterSaleTicketSaveReqVO createReqVO, Long userId) { |
| | | // 1. 生成单号 |
| | | // 1. 校验处理人:新增即分配,不再提供单独的分配处理人动作 |
| | | if (createReqVO.getHandlerUserId() == null) { |
| | | throw exception(TICKET_HANDLER_REQUIRED); |
| | | } |
| | | |
| | | // 2. 生成单号 |
| | | String no = noRedisDAO.generate(AfterSaleNoRedisDAO.TICKET_NO_PREFIX); |
| | | if (ticketMapper.selectByNo(no) != null) { |
| | | throw exception(TICKET_NO_EXISTS); |
| | | } |
| | | |
| | | // 2. 插入工单 |
| | | // 3. 插入工单:新增时已指定处理人,直接进入处理中 |
| | | AfterSaleTicketDO ticket = BeanUtils.toBean(createReqVO, AfterSaleTicketDO.class) |
| | | .setNo(no) |
| | | .setStatus(AfterSaleTicketStatusEnum.DRAFT.getStatus()) |
| | | .setStatus(AfterSaleTicketStatusEnum.PROCESSING.getStatus()) |
| | | .setOwnerUserId(userId); |
| | | ticketMapper.insert(ticket); |
| | | |
| | | // 3. 插入明细行 |
| | | // 4. 插入明细行 |
| | | if (CollUtil.isNotEmpty(createReqVO.getItems())) { |
| | | createReqVO.getItems().forEach(item -> { |
| | | AfterSaleTicketItemDO itemDO = BeanUtils.toBean(item, AfterSaleTicketItemDO.class); |
| | |
| | | }); |
| | | } |
| | | |
| | | // 4. 绑定附件 |
| | | storageAttachmentApi.bindAttachments("file", "aftersales_after_sale_ticket", |
| | | // 5. 绑定附件:客户诉求附件、处理结果附件分开存储;历史通用附件字段继续兼容 |
| | | storageAttachmentApi.bindAttachments("file", FEEDBACK_RECORD_TYPE, |
| | | ticket.getId(), createReqVO.getFeedbackBlobIds()); |
| | | storageAttachmentApi.bindAttachments("file", RESULT_RECORD_TYPE, |
| | | ticket.getId(), createReqVO.getResultBlobIds()); |
| | | storageAttachmentApi.bindAttachments("file", LEGACY_RECORD_TYPE, |
| | | ticket.getId(), createReqVO.getBlobIds()); |
| | | |
| | | // 5. 创建数据权限(负责人) |
| | | // 6. 记录流转 |
| | | String createContent = "创建售后工单【" + no + "】,处理人:" + getHandlerName(createReqVO.getHandlerUserId()); |
| | | ticketFlowService.record(ticket.getId(), AfterSaleTicketFlowService.ACTION_CREATE, "创建工单", |
| | | null, ticket.getStatus(), createContent); |
| | | |
| | | // 7. 创建数据权限(负责人) |
| | | AfterSalePermissionCreateReqBO permBO = new AfterSalePermissionCreateReqBO(); |
| | | permBO.setBizType(AfterSaleBizTypeEnum.AFTER_SALE_TICKET.getType()); |
| | | permBO.setBizId(ticket.getId()); |
| | |
| | | public void updateTicket(AfterSaleTicketSaveReqVO updateReqVO) { |
| | | // 1. 校验存在 |
| | | AfterSaleTicketDO ticket = validateTicketExists(updateReqVO.getId()); |
| | | validateTicketStatus(ticket, AfterSaleTicketStatusEnum.DRAFT); |
| | | validateTicketStatus(ticket, AfterSaleTicketStatusEnum.DRAFT, AfterSaleTicketStatusEnum.PROCESSING); |
| | | |
| | | // 2. 更新工单 |
| | | // 2. 更新工单:历史草稿补录处理人后直接进入处理中,避免因取消分配节点而卡在草稿 |
| | | AfterSaleTicketDO updateObj = BeanUtils.toBean(updateReqVO, AfterSaleTicketDO.class); |
| | | Integer targetStatus = ticket.getStatus(); |
| | | if (AfterSaleTicketStatusEnum.DRAFT.getStatus().equals(ticket.getStatus()) |
| | | && updateReqVO.getHandlerUserId() != null) { |
| | | targetStatus = AfterSaleTicketStatusEnum.PROCESSING.getStatus(); |
| | | updateObj.setStatus(targetStatus); |
| | | } |
| | | ticketMapper.updateById(updateObj); |
| | | |
| | | // 3. 更新明细行(先删后增) |
| | |
| | | }); |
| | | } |
| | | |
| | | // 4. 更新附件 |
| | | storageAttachmentApi.updateAttachments("file", "aftersales_after_sale_ticket", updateReqVO.getId(), updateReqVO.getBlobIds()); |
| | | // 4. 更新附件:客户诉求、处理结果分别更新;历史通用附件保留原逻辑 |
| | | storageAttachmentApi.updateAttachments("file", FEEDBACK_RECORD_TYPE, updateReqVO.getId(), |
| | | updateReqVO.getFeedbackBlobIds()); |
| | | storageAttachmentApi.updateAttachments("file", RESULT_RECORD_TYPE, updateReqVO.getId(), |
| | | updateReqVO.getResultBlobIds()); |
| | | storageAttachmentApi.updateAttachments("file", LEGACY_RECORD_TYPE, updateReqVO.getId(), |
| | | updateReqVO.getBlobIds()); |
| | | |
| | | // 5. 记录流转 |
| | | ticketFlowService.record(ticket.getId(), AfterSaleTicketFlowService.ACTION_UPDATE, "更新工单", |
| | | ticket.getStatus(), targetStatus, buildUpdateFlowContent(updateReqVO, ticket.getHandlerUserId())); |
| | | } |
| | | |
| | | @Override |
| | |
| | | // 软删除 |
| | | ticketMapper.deleteById(id); |
| | | ticketItemMapper.deleteByTicketId(id); |
| | | storageAttachmentApi.deleteAttachmentsByRecord("aftersales_after_sale_ticket", id); |
| | | storageAttachmentApi.deleteAttachmentsByRecord(FEEDBACK_RECORD_TYPE, id); |
| | | storageAttachmentApi.deleteAttachmentsByRecord(RESULT_RECORD_TYPE, id); |
| | | storageAttachmentApi.deleteAttachmentsByRecord(LEGACY_RECORD_TYPE, id); |
| | | permissionService.deletePermission(AfterSaleBizTypeEnum.AFTER_SALE_TICKET.getType(), id); |
| | | } |
| | | |
| | |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void assignTicket(Long id, Long handlerUserId) { |
| | | AfterSaleTicketDO ticket = validateTicketExists(id); |
| | | validateTicketStatus(ticket, AfterSaleTicketStatusEnum.DRAFT, AfterSaleTicketStatusEnum.PROCESSING); |
| | | ticketMapper.updateById(new AfterSaleTicketDO().setId(id).setHandlerUserId(handlerUserId) |
| | | .setStatus(AfterSaleTicketStatusEnum.PROCESSING.getStatus())); |
| | | public void onWarehouseReturnCreated(Long saleOrderId, Long returnSalesId, |
| | | String returnSalesCode, String returnReason) { |
| | | if (saleOrderId == null) { |
| | | return; |
| | | } |
| | | // 1. 找到该销售订单下最近一张未关闭、未取消的工单 |
| | | List<AfterSaleTicketDO> tickets = ticketMapper.selectListBySaleOrderId(saleOrderId); |
| | | AfterSaleTicketDO ticket = tickets.stream() |
| | | .filter(item -> !AfterSaleTicketStatusEnum.CLOSED.getStatus().equals(item.getStatus()) |
| | | && !AfterSaleTicketStatusEnum.CANCELED.getStatus().equals(item.getStatus())) |
| | | .max(Comparator.comparing(AfterSaleTicketDO::getId)) |
| | | .orElse(null); |
| | | if (ticket == null) { |
| | | return; |
| | | } |
| | | // 2. 同一退货单只记录一次,避免回调重试产生重复记录 |
| | | String returnSalesKey = returnSalesCode != null && !returnSalesCode.isBlank() |
| | | ? returnSalesCode : "ID=" + returnSalesId; |
| | | boolean recorded = ticketFlowService.getFlowList(ticket.getId()).stream() |
| | | .anyMatch(flow -> AfterSaleTicketFlowService.ACTION_RETURN.equals(flow.getAction()) |
| | | && flow.getContent() != null |
| | | && flow.getContent().contains("【" + returnSalesKey + "】")); |
| | | if (recorded) { |
| | | return; |
| | | } |
| | | // 3. 草稿/处理中的工单进入待退货;其他状态只留痕,不改状态 |
| | | Integer targetStatus = ticket.getStatus(); |
| | | if (AfterSaleTicketStatusEnum.DRAFT.getStatus().equals(targetStatus) |
| | | || AfterSaleTicketStatusEnum.PROCESSING.getStatus().equals(targetStatus)) { |
| | | targetStatus = AfterSaleTicketStatusEnum.WAITING_RETURN.getStatus(); |
| | | ticketMapper.updateById(new AfterSaleTicketDO().setId(ticket.getId()).setStatus(targetStatus)); |
| | | } |
| | | // 4. 写入流转记录 |
| | | String content = "仓储物流创建销售退货单【" + returnSalesKey + "】" |
| | | + (returnReason != null && !returnReason.isBlank() ? ",退货原因:" + returnReason : ""); |
| | | ticketFlowService.record(ticket.getId(), AfterSaleTicketFlowService.ACTION_RETURN, "仓储退货", |
| | | ticket.getStatus(), targetStatus, content); |
| | | } |
| | | |
| | | private String getHandlerName(Long handlerUserId) { |
| | | AdminUserRespDTO handler = adminUserApi.getUser(handlerUserId); |
| | | return handler != null ? handler.getNickname() : "用户(ID=" + handlerUserId + ")"; |
| | | } |
| | | |
| | | @Override |
| | |
| | | validateTicketStatus(ticket, AfterSaleTicketStatusEnum.PROCESSING); |
| | | ticketMapper.updateById(new AfterSaleTicketDO().setId(id) |
| | | .setIssueType(issueType).setIssueCategory(issueCategory).setSeverityLevel(severityLevel)); |
| | | // 记录流转 |
| | | ticketFlowService.record(id, AfterSaleTicketFlowService.ACTION_CLASSIFY, "问题判定", |
| | | ticket.getStatus(), ticket.getStatus(), |
| | | "问题判定:类型=" + getIssueTypeName(issueType) |
| | | + ",分类=" + (issueCategory != null && !issueCategory.isBlank() ? issueCategory : "未填写") |
| | | + ",严重程度=" + getSeverityName(severityLevel)); |
| | | } |
| | | |
| | | @Override |
| | |
| | | .setStatus(AfterSaleTicketStatusEnum.COMPLETED.getStatus()) |
| | | .setResolvedTime(LocalDateTime.now()) |
| | | .setVisitStatus(AfterSaleVisitStatusEnum.WAITING.getStatus())); |
| | | // 记录流转 |
| | | ticketFlowService.record(id, AfterSaleTicketFlowService.ACTION_RESOLVE, "完结工单", |
| | | ticket.getStatus(), AfterSaleTicketStatusEnum.COMPLETED.getStatus(), |
| | | "工单处理完成,进入待回访"); |
| | | } |
| | | |
| | | @Override |
| | |
| | | .setClosedTime(LocalDateTime.now()) |
| | | .setProblemId(problem.getId()) |
| | | .setProblemNo(problem.getNo())); |
| | | // 记录流转 |
| | | ticketFlowService.record(id, AfterSaleTicketFlowService.ACTION_ARCHIVE, "归档工单", |
| | | AfterSaleTicketStatusEnum.COMPLETED.getStatus(), AfterSaleTicketStatusEnum.CLOSED.getStatus(), |
| | | "归档工单,沉淀问题【" + problem.getNo() + "】"); |
| | | } |
| | | |
| | | @Override |
| | |
| | | validateTicketStatus(ticket, AfterSaleTicketStatusEnum.DRAFT, AfterSaleTicketStatusEnum.PROCESSING); |
| | | ticketMapper.updateById(new AfterSaleTicketDO().setId(id) |
| | | .setStatus(AfterSaleTicketStatusEnum.CANCELED.getStatus())); |
| | | // 记录流转 |
| | | ticketFlowService.record(id, AfterSaleTicketFlowService.ACTION_CANCEL, "取消工单", |
| | | ticket.getStatus(), AfterSaleTicketStatusEnum.CANCELED.getStatus(), "取消工单"); |
| | | } |
| | | |
| | | // ========== 内部方法 ========== |
| | | |
| | | private String buildUpdateFlowContent(AfterSaleTicketSaveReqVO reqVO, Long originalHandlerUserId) { |
| | | StringBuilder content = new StringBuilder("更新工单信息"); |
| | | if (reqVO.getHandlerUserId() != null |
| | | && !reqVO.getHandlerUserId().equals(originalHandlerUserId)) { |
| | | content.append(",处理人变更为:").append(getHandlerName(reqVO.getHandlerUserId())); |
| | | } |
| | | if (reqVO.getFeedbackDescription() != null && !reqVO.getFeedbackDescription().isBlank()) { |
| | | content.append(",客户诉求反馈已更新"); |
| | | } |
| | | if (CollUtil.isNotEmpty(reqVO.getFeedbackBlobIds())) { |
| | | content.append(",客户诉求附件 ").append(reqVO.getFeedbackBlobIds().size()).append(" 个"); |
| | | } |
| | | if (reqVO.getResultDescription() != null && !reqVO.getResultDescription().isBlank()) { |
| | | content.append(",处理结果已更新"); |
| | | } |
| | | if (CollUtil.isNotEmpty(reqVO.getResultBlobIds())) { |
| | | content.append(",处理结果附件 ").append(reqVO.getResultBlobIds().size()).append(" 个"); |
| | | } |
| | | return content.toString(); |
| | | } |
| | | |
| | | private String getIssueTypeName(Integer issueType) { |
| | | if (issueType == null) { |
| | | return AfterSaleIssueTypeEnum.UNCLASSIFIED.getName(); |
| | | } |
| | | for (AfterSaleIssueTypeEnum item : AfterSaleIssueTypeEnum.values()) { |
| | | if (item.getType().equals(issueType)) { |
| | | return item.getName(); |
| | | } |
| | | } |
| | | return "未知类型(ID=" + issueType + ")"; |
| | | } |
| | | |
| | | private String getSeverityName(Integer severityLevel) { |
| | | if (severityLevel == null) { |
| | | return "未填写"; |
| | | } |
| | | return switch (severityLevel) { |
| | | case 0 -> "轻微"; |
| | | case 1 -> "一般"; |
| | | case 2 -> "严重"; |
| | | case 3 -> "致命"; |
| | | default -> "未知(ID=" + severityLevel + ")"; |
| | | }; |
| | | } |
| | | |
| | | private AfterSaleTicketDO validateTicketExists(Long id) { |
| | | AfterSaleTicketDO ticket = ticketMapper.selectById(id); |
| | | if (ticket == null) { |