| | |
| | | package cn.iocoder.yudao.module.aftersales.service.ticket; |
| | | |
| | | import cn.hutool.core.collection.CollUtil; |
| | | import cn.hutool.core.util.StrUtil; |
| | | 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.ticket.vo.ticket.AfterSaleTicketEscalateReqVO; |
| | | import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketHandleReqVO; |
| | | import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketPageReqVO; |
| | | import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketSaveReqVO; |
| | | import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketSignInReqVO; |
| | | import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketStatisticsRespVO; |
| | | import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketTransferReqVO; |
| | | import cn.iocoder.yudao.module.aftersales.dal.dataobject.problem.AfterSaleProblemDO; |
| | | 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.dataobject.ticket.AfterSaleTicketLogDO; |
| | | import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketItemMapper; |
| | | import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketLogMapper; |
| | | 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.AfterSaleTicketLogActionEnum; |
| | | 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 org.springframework.transaction.annotation.Transactional; |
| | | import org.springframework.validation.annotation.Validated; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.time.Duration; |
| | | import java.time.LocalDate; |
| | | import java.time.LocalDateTime; |
| | | import java.util.Collection; |
| | | import java.util.List; |
| | | import java.util.*; |
| | | import java.util.stream.Collectors; |
| | | |
| | | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| | | import static cn.iocoder.yudao.module.aftersales.enums.ErrorCodeConstants.*; |
| | |
| | | private AfterSaleTicketMapper ticketMapper; |
| | | @Resource |
| | | private AfterSaleTicketItemMapper ticketItemMapper; |
| | | @Resource |
| | | private AfterSaleTicketLogMapper ticketLogMapper; |
| | | |
| | | @Resource |
| | | private AfterSaleNoRedisDAO noRedisDAO; |
| | | |
| | | @Resource |
| | | private AdminUserApi adminUserApi; |
| | | |
| | | @Resource |
| | | private AfterSalePermissionService permissionService; |
| | |
| | | permBO.setUserId(userId); |
| | | permBO.setLevel(AfterSalePermissionLevelEnum.OWNER.getLevel()); |
| | | permissionService.createPermission(permBO); |
| | | |
| | | // 6. 记录上报进度 |
| | | addLog(ticket.getId(), AfterSaleTicketLogActionEnum.CREATE, |
| | | StrUtil.blankToDefault(createReqVO.getIssueDescription(), "工单上报:" + createReqVO.getName()), userId); |
| | | |
| | | return ticket.getId(); |
| | | } |
| | |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void assignTicket(Long id, Long handlerUserId) { |
| | | public void assignTicket(Long id, Long handlerUserId, Long operatorUserId) { |
| | | AfterSaleTicketDO ticket = validateTicketExists(id); |
| | | validateTicketStatus(ticket, AfterSaleTicketStatusEnum.DRAFT, AfterSaleTicketStatusEnum.PROCESSING); |
| | | ticketMapper.updateById(new AfterSaleTicketDO().setId(id).setHandlerUserId(handlerUserId) |
| | | .setStatus(AfterSaleTicketStatusEnum.PROCESSING.getStatus())); |
| | | addLog(id, AfterSaleTicketLogActionEnum.ASSIGN, |
| | | "派单给处理人:" + getUserName(handlerUserId), operatorUserId); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void classifyTicket(Long id, Integer issueType, String issueCategory, Integer severityLevel) { |
| | | public void classifyTicket(Long id, Integer issueType, String issueCategory, Integer severityLevel, Long operatorUserId) { |
| | | AfterSaleTicketDO ticket = validateTicketExists(id); |
| | | validateTicketStatus(ticket, AfterSaleTicketStatusEnum.PROCESSING); |
| | | ticketMapper.updateById(new AfterSaleTicketDO().setId(id) |
| | | .setIssueType(issueType).setIssueCategory(issueCategory).setSeverityLevel(severityLevel)); |
| | | addLog(id, AfterSaleTicketLogActionEnum.CLASSIFY, |
| | | buildClassifyContent(issueType, issueCategory, severityLevel), operatorUserId); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void resolveTicket(Long id) { |
| | | public void resolveTicket(Long id, Long operatorUserId) { |
| | | AfterSaleTicketDO ticket = validateTicketExists(id); |
| | | validateTicketStatus(ticket, AfterSaleTicketStatusEnum.PROCESSING, |
| | | AfterSaleTicketStatusEnum.WAITING_RETURN, AfterSaleTicketStatusEnum.WAITING_REFUND); |
| | |
| | | .setStatus(AfterSaleTicketStatusEnum.COMPLETED.getStatus()) |
| | | .setResolvedTime(LocalDateTime.now()) |
| | | .setVisitStatus(AfterSaleVisitStatusEnum.WAITING.getStatus())); |
| | | addLog(id, AfterSaleTicketLogActionEnum.RESOLVE, "工单处理完成,等待客户回访", operatorUserId); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void closeTicket(Long id) { |
| | | public void closeTicket(Long id, Long operatorUserId) { |
| | | // 1. 校验工单存在且处于已完结状态 |
| | | AfterSaleTicketDO ticket = validateTicketExists(id); |
| | | validateTicketStatus(ticket, AfterSaleTicketStatusEnum.COMPLETED); |
| | |
| | | .setClosedTime(LocalDateTime.now()) |
| | | .setProblemId(problem.getId()) |
| | | .setProblemNo(problem.getNo())); |
| | | addLog(id, AfterSaleTicketLogActionEnum.CLOSE, "工单归档,沉淀问题:" + problem.getNo(), operatorUserId); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void cancelTicket(Long id) { |
| | | public void cancelTicket(Long id, Long operatorUserId) { |
| | | AfterSaleTicketDO ticket = validateTicketExists(id); |
| | | validateTicketStatus(ticket, AfterSaleTicketStatusEnum.DRAFT, AfterSaleTicketStatusEnum.PROCESSING); |
| | | ticketMapper.updateById(new AfterSaleTicketDO().setId(id) |
| | | .setStatus(AfterSaleTicketStatusEnum.CANCELED.getStatus())); |
| | | addLog(id, AfterSaleTicketLogActionEnum.CANCEL, "取消工单", operatorUserId); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void signInTicket(AfterSaleTicketSignInReqVO reqVO, Long userId) { |
| | | AfterSaleTicketDO ticket = validateTicketExists(reqVO.getId()); |
| | | validateTicketStatus(ticket, AfterSaleTicketStatusEnum.PROCESSING); |
| | | // 1. 写入签到进度(含位置信息) |
| | | LocalDateTime now = LocalDateTime.now(); |
| | | AfterSaleTicketLogDO log = AfterSaleTicketLogDO.builder() |
| | | .ticketId(ticket.getId()) |
| | | .actionType(AfterSaleTicketLogActionEnum.SIGN_IN.getType()) |
| | | .content(StrUtil.blankToDefault(reqVO.getContent(), "已到达客户现场签到")) |
| | | .operatorUserId(userId) |
| | | .longitude(reqVO.getLongitude()) |
| | | .latitude(reqVO.getLatitude()) |
| | | .address(StrUtil.maxLength(reqVO.getAddress(), 255)) |
| | | .build(); |
| | | ticketLogMapper.insert(log); |
| | | // 2. 首次签到回写到工单,便于列表/详情快速识别是否已到现场 |
| | | if (ticket.getSignInTime() == null) { |
| | | ticketMapper.updateById(new AfterSaleTicketDO().setId(ticket.getId()) |
| | | .setSignInTime(now) |
| | | .setSignInAddress(StrUtil.maxLength(reqVO.getAddress(), 255))); |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void transferTicket(AfterSaleTicketTransferReqVO reqVO, Long userId) { |
| | | AfterSaleTicketDO ticket = validateTicketExists(reqVO.getId()); |
| | | validateTicketStatus(ticket, AfterSaleTicketStatusEnum.PROCESSING); |
| | | if (Objects.equals(ticket.getHandlerUserId(), reqVO.getHandlerUserId())) { |
| | | throw exception(TICKET_TRANSFER_FAIL_SAME_HANDLER); |
| | | } |
| | | ticketMapper.updateById(new AfterSaleTicketDO().setId(ticket.getId()) |
| | | .setHandlerUserId(reqVO.getHandlerUserId())); |
| | | String content = "转派给:" + getUserName(reqVO.getHandlerUserId()); |
| | | if (StrUtil.isNotBlank(reqVO.getReason())) { |
| | | content = content + ",原因:" + reqVO.getReason(); |
| | | } |
| | | addLog(ticket.getId(), AfterSaleTicketLogActionEnum.TRANSFER, content, userId); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void escalateTicket(AfterSaleTicketEscalateReqVO reqVO, Long userId) { |
| | | AfterSaleTicketDO ticket = validateTicketExists(reqVO.getId()); |
| | | validateTicketStatus(ticket, AfterSaleTicketStatusEnum.PROCESSING); |
| | | int currentLevel = ticket.getSeverityLevel() == null ? 0 : ticket.getSeverityLevel(); |
| | | int targetLevel = reqVO.getSeverityLevel() == null ? Math.min(currentLevel + 1, 3) |
| | | : Math.max(currentLevel, reqVO.getSeverityLevel()); |
| | | ticketMapper.updateById(new AfterSaleTicketDO().setId(ticket.getId()).setSeverityLevel(targetLevel)); |
| | | addLog(ticket.getId(), AfterSaleTicketLogActionEnum.ESCALATE, |
| | | "严重程度升级为:" + getSeverityName(targetLevel) |
| | | + (StrUtil.isNotBlank(reqVO.getReason()) ? ",原因:" + reqVO.getReason() : ""), |
| | | userId); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void addHandleRecord(AfterSaleTicketHandleReqVO reqVO, Long userId) { |
| | | AfterSaleTicketDO ticket = validateTicketExists(reqVO.getId()); |
| | | validateTicketStatus(ticket, AfterSaleTicketStatusEnum.PROCESSING, |
| | | AfterSaleTicketStatusEnum.WAITING_RETURN, AfterSaleTicketStatusEnum.WAITING_REFUND); |
| | | addLog(ticket.getId(), AfterSaleTicketLogActionEnum.HANDLE, reqVO.getContent(), userId); |
| | | } |
| | | |
| | | @Override |
| | | public List<AfterSaleTicketLogDO> getTicketLogList(Long ticketId) { |
| | | validateTicketExists(ticketId); |
| | | return ticketLogMapper.selectListByTicketId(ticketId); |
| | | } |
| | | |
| | | @Override |
| | | public AfterSaleTicketStatisticsRespVO getTicketStatistics(Integer sceneType, Long userId) { |
| | | // 1. 按当前场景的数据权限查询工单(工单助手面向移动端小数据量场景) |
| | | AfterSaleTicketPageReqVO pageReqVO = new AfterSaleTicketPageReqVO(); |
| | | pageReqVO.setPageNo(1); |
| | | pageReqVO.setPageSize(10000); |
| | | pageReqVO.setSceneType(sceneType == null ? 4 : sceneType); |
| | | List<AfterSaleTicketDO> tickets = getTicketPage(pageReqVO, userId).getList(); |
| | | |
| | | // 2. 基础计数 |
| | | AfterSaleTicketStatisticsRespVO respVO = new AfterSaleTicketStatisticsRespVO(); |
| | | respVO.setTotalCount((long) tickets.size()); |
| | | respVO.setProcessingCount(countStatus(tickets, AfterSaleTicketStatusEnum.PROCESSING)); |
| | | respVO.setWaitingReturnCount(countStatus(tickets, AfterSaleTicketStatusEnum.WAITING_RETURN)); |
| | | respVO.setWaitingRefundCount(countStatus(tickets, AfterSaleTicketStatusEnum.WAITING_REFUND)); |
| | | respVO.setCompletedCount(countStatus(tickets, AfterSaleTicketStatusEnum.COMPLETED)); |
| | | respVO.setClosedCount(countStatus(tickets, AfterSaleTicketStatusEnum.CLOSED)); |
| | | respVO.setCanceledCount(countStatus(tickets, AfterSaleTicketStatusEnum.CANCELED)); |
| | | |
| | | LocalDateTime todayStart = LocalDate.now().atStartOfDay(); |
| | | respVO.setTodayCreateCount(tickets.stream() |
| | | .filter(t -> t.getCreateTime() != null && !t.getCreateTime().isBefore(todayStart)).count()); |
| | | respVO.setTodayResolveCount(tickets.stream() |
| | | .filter(t -> t.getResolvedTime() != null && !t.getResolvedTime().isBefore(todayStart)).count()); |
| | | respVO.setSignInCount(tickets.stream().filter(t -> t.getSignInTime() != null).count()); |
| | | |
| | | // 3. 升级次数(来自进度记录) |
| | | Set<Long> ticketIds = tickets.stream().map(AfterSaleTicketDO::getId).collect(Collectors.toSet()); |
| | | List<AfterSaleTicketLogDO> logs = ticketLogMapper.selectListByTicketIds(ticketIds); |
| | | respVO.setEscalatedCount(logs.stream() |
| | | .filter(log -> AfterSaleTicketLogActionEnum.ESCALATE.getType().equals(log.getActionType())) |
| | | .map(AfterSaleTicketLogDO::getTicketId).distinct().count()); |
| | | |
| | | // 4. 平均完结耗时 |
| | | List<AfterSaleTicketDO> resolvedTickets = tickets.stream() |
| | | .filter(t -> t.getResolvedTime() != null && t.getCreateTime() != null) |
| | | .toList(); |
| | | if (!resolvedTickets.isEmpty()) { |
| | | long totalMinutes = resolvedTickets.stream() |
| | | .mapToLong(t -> Math.max(Duration.between(t.getCreateTime(), t.getResolvedTime()).toMinutes(), 0)) |
| | | .sum(); |
| | | respVO.setAvgResolveHours(BigDecimal.valueOf(totalMinutes) |
| | | .divide(BigDecimal.valueOf(resolvedTickets.size() * 60L), 1, RoundingMode.HALF_UP)); |
| | | } else { |
| | | respVO.setAvgResolveHours(BigDecimal.ZERO); |
| | | } |
| | | |
| | | // 5. 回访满意率(仅统计已产生回访状态的工单) |
| | | List<AfterSaleTicketDO> visitedTickets = tickets.stream() |
| | | .filter(t -> t.getVisitStatus() != null).toList(); |
| | | if (!visitedTickets.isEmpty()) { |
| | | long passed = visitedTickets.stream() |
| | | .filter(t -> AfterSaleVisitStatusEnum.PASSED.getStatus().equals(t.getVisitStatus())).count(); |
| | | respVO.setSatisfactionRate(BigDecimal.valueOf(passed * 100L) |
| | | .divide(BigDecimal.valueOf(visitedTickets.size()), 1, RoundingMode.HALF_UP)); |
| | | } else { |
| | | respVO.setSatisfactionRate(BigDecimal.ZERO); |
| | | } |
| | | |
| | | // 6. 按状态、问题类型统计 |
| | | respVO.setStatusCounts(Arrays.stream(AfterSaleTicketStatusEnum.values()) |
| | | .map(status -> { |
| | | AfterSaleTicketStatisticsRespVO.StatusCount item = new AfterSaleTicketStatisticsRespVO.StatusCount(); |
| | | item.setStatus(status.getStatus()); |
| | | item.setStatusName(status.getName()); |
| | | item.setCount(tickets.stream().filter(t -> status.getStatus().equals(t.getStatus())).count()); |
| | | return item; |
| | | }).toList()); |
| | | respVO.setIssueTypeCounts(Arrays.stream(AfterSaleIssueTypeEnum.values()) |
| | | .map(issueType -> { |
| | | AfterSaleTicketStatisticsRespVO.IssueTypeCount item = new AfterSaleTicketStatisticsRespVO.IssueTypeCount(); |
| | | item.setIssueType(issueType.getType()); |
| | | item.setIssueTypeName(issueType.getName()); |
| | | item.setCount(tickets.stream() |
| | | .filter(t -> Objects.equals(issueType.getType(), |
| | | t.getIssueType() == null ? AfterSaleIssueTypeEnum.UNCLASSIFIED.getType() : t.getIssueType())) |
| | | .count()); |
| | | return item; |
| | | }).toList()); |
| | | |
| | | // 7. 按处理人统计工作量 |
| | | Map<Long, List<AfterSaleTicketDO>> handlerMap = tickets.stream() |
| | | .filter(t -> t.getHandlerUserId() != null) |
| | | .collect(Collectors.groupingBy(AfterSaleTicketDO::getHandlerUserId)); |
| | | Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(handlerMap.keySet()); |
| | | respVO.setHandlerWorkloads(handlerMap.entrySet().stream().map(entry -> { |
| | | AfterSaleTicketStatisticsRespVO.HandlerWorkload item = new AfterSaleTicketStatisticsRespVO.HandlerWorkload(); |
| | | item.setHandlerUserId(entry.getKey()); |
| | | AdminUserRespDTO user = userMap.get(entry.getKey()); |
| | | item.setHandlerUserName(user != null ? user.getNickname() : "用户" + entry.getKey()); |
| | | item.setTotalCount((long) entry.getValue().size()); |
| | | item.setProcessingCount(entry.getValue().stream() |
| | | .filter(t -> AfterSaleTicketStatusEnum.PROCESSING.getStatus().equals(t.getStatus())).count()); |
| | | return item; |
| | | }).sorted(Comparator.comparing(AfterSaleTicketStatisticsRespVO.HandlerWorkload::getTotalCount).reversed()).toList()); |
| | | return respVO; |
| | | } |
| | | |
| | | // ========== 内部方法 ========== |
| | | |
| | | private Long countStatus(List<AfterSaleTicketDO> tickets, AfterSaleTicketStatusEnum status) { |
| | | return tickets.stream().filter(t -> status.getStatus().equals(t.getStatus())).count(); |
| | | } |
| | | |
| | | private void addLog(Long ticketId, AfterSaleTicketLogActionEnum action, String content, Long operatorUserId) { |
| | | ticketLogMapper.insert(AfterSaleTicketLogDO.builder() |
| | | .ticketId(ticketId) |
| | | .actionType(action.getType()) |
| | | .content(content) |
| | | .operatorUserId(operatorUserId) |
| | | .build()); |
| | | } |
| | | |
| | | private String getUserName(Long userId) { |
| | | if (userId == null) { |
| | | return "未指定"; |
| | | } |
| | | AdminUserRespDTO user = adminUserApi.getUserMap(Set.of(userId)).get(userId); |
| | | return user != null && StrUtil.isNotBlank(user.getNickname()) ? user.getNickname() : "用户" + userId; |
| | | } |
| | | |
| | | private String buildClassifyContent(Integer issueType, String issueCategory, Integer severityLevel) { |
| | | StringBuilder content = new StringBuilder("问题判定:").append(getIssueTypeName(issueType)); |
| | | if (StrUtil.isNotBlank(issueCategory)) { |
| | | content.append(",分类:").append(issueCategory); |
| | | } |
| | | if (severityLevel != null) { |
| | | content.append(",严重程度:").append(getSeverityName(severityLevel)); |
| | | } |
| | | return content.toString(); |
| | | } |
| | | |
| | | private String getIssueTypeName(Integer issueType) { |
| | | return Arrays.stream(AfterSaleIssueTypeEnum.values()) |
| | | .filter(item -> item.getType().equals(issueType)) |
| | | .map(AfterSaleIssueTypeEnum::getName) |
| | | .findFirst() |
| | | .orElse("未判定"); |
| | | } |
| | | |
| | | private String getSeverityName(Integer severityLevel) { |
| | | return switch (severityLevel == null ? 0 : severityLevel) { |
| | | case 0 -> "轻微"; |
| | | case 1 -> "一般"; |
| | | case 2 -> "严重"; |
| | | case 3 -> "致命"; |
| | | default -> "未知"; |
| | | }; |
| | | } |
| | | |
| | | private AfterSaleTicketDO validateTicketExists(Long id) { |
| | | AfterSaleTicketDO ticket = ticketMapper.selectById(id); |
| | | if (ticket == null) { |