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