From 87d725aa16dcdcdd19f28cd3f3f162e950fd1d2f Mon Sep 17 00:00:00 2001
From: gaoluyang <2820782392@qq.com>
Date: 星期二, 03 十一月 2026 09:13:20 +0800
Subject: [PATCH] feat(aftersales): 工单助手 App 闭环(签到/转派/升级/处理记录/进度/统计)

---
 yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java |  266 +++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 259 insertions(+), 7 deletions(-)

diff --git a/yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java b/yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java
index bc01293..5a24cc7 100644
--- a/yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java
+++ b/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) {

--
Gitblit v1.9.3