gaoluyang
7 天以前 87d725aa16dcdcdd19f28cd3f3f162e950fd1d2f
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java
@@ -1,17 +1,27 @@
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;
@@ -19,15 +29,21 @@
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.*;
@@ -44,9 +60,14 @@
    private AfterSaleTicketMapper ticketMapper;
    @Resource
    private AfterSaleTicketItemMapper ticketItemMapper;
    @Resource
    private AfterSaleTicketLogMapper ticketLogMapper;
    @Resource
    private AfterSaleNoRedisDAO noRedisDAO;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private AfterSalePermissionService permissionService;
@@ -93,6 +114,10 @@
        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();
    }
@@ -162,25 +187,29 @@
    @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);
@@ -189,11 +218,12 @@
                .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);
@@ -209,19 +239,241 @@
                .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) {