gaoluyang
5 天以前 87d725aa16dcdcdd19f28cd3f3f162e950fd1d2f
feat(aftersales): 工单助手 App 闭环(签到/转派/升级/处理记录/进度/统计)

- after_sale_ticket 增加现场签到时间与地址
- 新增 after_sale_ticket_log 操作记录表,覆盖上报、派单、签到、判定、转派、升级、处理记录、完结、回访、归档、取消
- 新增签到、转派、升级、处理记录、进度列表、统计接口
- 回访登记同步写进度记录
- 补诚发五金本地库脚本:表结构 + 隐藏菜单/按钮权限 + 角色授权

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加11个文件
已修改7个文件
1145 ■■■■■ 文件已修改
docs/aftersales_ticket_helper_app_integration.md 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/cf_aftersales_helper_20261103.sql 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/AfterSaleTicketController.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketEscalateReqVO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketHandleReqVO.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketLogRespVO.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketRespVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketSignInReqVO.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketStatisticsRespVO.java 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketTransferReqVO.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/ticket/AfterSaleTicketDO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/ticket/AfterSaleTicketLogDO.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/ticket/AfterSaleTicketLogMapper.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/AfterSaleTicketLogActionEnum.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/ErrorCodeConstants.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketService.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java 266 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/visit/AfterSaleVisitServiceImpl.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/aftersales_ticket_helper_app_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,128 @@
# å·¥å•助手(App)接口联调方案
## æ¶‰åŠé¡µé¢
- App å·¥ä½œå°ã€Œå·¥å•助手」分组
- App å·¥å•上报(新增 / ç¼–辑售后工单)
- App å·¥å•受理(售后工单列表)
- App å”®åŽå·¥å•详情(进度、签到、转派、升级、处理记录)
- App å·¥å•分析(统计看板)
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. å·¥å•上报 â†’ å·¥å•受理:上报后工单状态为「处理中」,自动写入一条 `CREATE` è¿›åº¦è®°å½•。
2. å·¥å•受理 â†’ å”®åŽå·¥å•详情:列表点入详情,按 `ticketId` æ‹‰å–进度记录、回访记录与附件。
3. è¯¦æƒ… â†’ çŽ°åœºç­¾åˆ°ï¼šApp å–当前定位(经度 / çº¬åº¦ï¼‰å¹¶å¡«å†™çŽ°åœºåœ°å€ï¼Œé¦–æ¬¡ç­¾åˆ°å›žå†™å·¥å• `signInTime` / `signInAddress`。
4. è¯¦æƒ… â†’ è½¬æ´¾ / å‡çº§ / å¤„理记录:均写入 `after_sale_ticket_log`,进度时间线实时刷新。
5. è¯¦æƒ… â†’ å®Œç»“ â†’ å›žè®¿ï¼šå›žè®¿ç™»è®°æˆåŠŸåŽå†™ `VISIT` è®°å½•;不满意或复发会退回「处理中」。
6. å·¥å•分析:按数据权限场景(我负责的 / æˆ‘参与的 / ä¸‹å±žè´Ÿè´£çš„ / å…¨éƒ¨ï¼‰æ±‡æ€»å·¥å•状态、问题类型、处理人工作量。
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /aftersales/ticket/sign-in | çŽ°åœºç­¾åˆ° |
| POST | /aftersales/ticket/transfer | è½¬æ´¾å·¥å• |
| POST | /aftersales/ticket/escalate | å‡çº§å·¥å• |
| POST | /aftersales/ticket/handle | æ–°å¢žå¤„理记录 |
| GET | /aftersales/ticket/log-list | å·¥å•操作 / è¿›åº¦è®°å½•列表 |
| GET | /aftersales/ticket/statistics | å·¥å•助手统计 |
权限码:签到、转派、升级、处理记录统一使用 `aftersales:ticket:update`;进度记录与统计使用登录态,无额外按钮权限。
**请求参数:**
签到 `POST /aftersales/ticket/sign-in`
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | æ˜¯ | å·¥å•编号 |
| longitude | BigDecimal | å¦ | ç»åº¦ï¼ŒApp å®šä½å¤±è´¥æ—¶å…è®¸ä¸ºç©º |
| latitude | BigDecimal | å¦ | çº¬åº¦ |
| address | String | å¦ | çŽ°åœºåœ°å€ï¼ˆå»ºè®®å®¢æˆ·ç«¯å¿…å¡«ï¼Œä¾¿äºŽè¿½æº¯ï¼‰ |
| content | String | å¦ | ç­¾åˆ°è¯´æ˜Ž |
转派 `POST /aftersales/ticket/transfer`
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | æ˜¯ | å·¥å•编号 |
| handlerUserId | Long | æ˜¯ | æ–°å¤„理人,不能与当前处理人相同 |
| reason | String | å¦ | è½¬æ´¾åŽŸå›  |
升级 `POST /aftersales/ticket/escalate`
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | æ˜¯ | å·¥å•编号 |
| severityLevel | Integer | å¦ | å‡çº§åŽçš„严重程度(0 è½»å¾® / 1 ä¸€èˆ¬ / 2 ä¸¥é‡ / 3 è‡´å‘½ï¼‰ï¼Œä¸ä¼ åˆ™è‡ªåЍ +1,最高 3 |
| reason | String | å¦ | å‡çº§åŽŸå›  |
处理记录 `POST /aftersales/ticket/handle`
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | æ˜¯ | å·¥å•编号 |
| content | String | æ˜¯ | å¤„理动作与结果 |
进度记录 `GET /aftersales/ticket/log-list?ticketId=1`
统计 `GET /aftersales/ticket/statistics?sceneType=4`
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| sceneType | Integer | å¦ | 1 æˆ‘负责的 / 2 æˆ‘参与的 / 3 ä¸‹å±žè´Ÿè´£çš„ / 4 å…¨éƒ¨ï¼Œé»˜è®¤ 4 |
**响应字段:**
进度记录项:
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| actionType | Integer | 1 ä¸ŠæŠ¥ / 2 æ´¾å• / 3 ç­¾åˆ° / 4 é—®é¢˜åˆ¤å®š / 5 è½¬æ´¾ / 6 å‡çº§ / 7 å¤„理记录 / 8 å®Œç»“ / 9 å›žè®¿ / 10 å½’æ¡£ / 11 å–消 |
| actionName | String | æ“ä½œåç§°ï¼ˆåŽç«¯å·²ç¿»è¯‘) |
| content | String | æ“ä½œå†…容 |
| operatorUserName | String | æ“ä½œäººæ˜µç§° |
| longitude / latitude | BigDecimal | ç­¾åˆ°åæ ‡ |
| address | String | ç­¾åˆ°åœ°å€ |
| createTime | LocalDateTime | æ“ä½œæ—¶é—´ |
统计响应:
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| totalCount / processingCount / waitingReturnCount / waitingRefundCount | Long | æ€»é‡ä¸Žå„阶段数量 |
| completedCount / closedCount / canceledCount | Long | å·²å®Œç»“ / å·²å…³é—­ / å·²å–消 |
| todayCreateCount / todayResolveCount | Long | ä»Šæ—¥ä¸ŠæŠ¥ / ä»Šæ—¥å®Œç»“ |
| signInCount / escalatedCount | Long | ç­¾åˆ°æ¬¡æ•° / å‡çº§è¿‡çš„工单数 |
| avgResolveHours | BigDecimal | å¹³å‡å¤„理时长(小时) |
| satisfactionRate | BigDecimal | å›žè®¿æ»¡æ„åº¦ï¼ˆ%) |
| statusCounts | Array | `{ status, statusName, count }` |
| issueTypeCounts | Array | `{ issueType, issueTypeName, count }` |
| handlerWorkloads | Array | `{ handlerUserId, handlerUserName, processingCount, totalCount }` |
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| signInTime / signInAddress | å·¥å•详情 | æ˜¾ç¤ºã€Œæœªç­¾åˆ°ã€æ—¶æä¾›ä¸€ä¸ªçŽ°åœºç­¾åˆ°æŒ‰é’® |
| è¿›åº¦è®°å½• | å·¥å•详情「处理进度」 | æŒ‰æ—¶é—´æ­£åºå±•示,签到记录附带定位 |
| actionName | è¿›åº¦æ—¶é—´çº¿æ ‡é¢˜ | ç›´æŽ¥ä½¿ç”¨åŽç«¯è¿”回值,不在前端重复维护枚举 |
| ç»Ÿè®¡çœ‹æ¿ | å·¥å•分析 | å¡ç‰‡åˆ»åº¦ + æ¨ªå‘占比条,处理人工作量按处理中 / ç´¯è®¡å±•示 |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| çŽ°åœºç­¾åˆ° | ä»…「处理中」工单可签到;首次签到回写工单,后续签到仅追加记录 |
| è½¬æ´¾ | ä»…「处理中」可转派;新处理人与当前处理人相同会返回明确错误 |
| å‡çº§ | ä»…「处理中」工单可升级;严重程度只升不降 |
| å¤„理记录 | å¤„理中、待退货、待退款状态可追加 |
| ç¦»çº¿ä¸ŠæŠ¥ | æ— ç½‘络时工单暂存本地,进入工单列表且有网时自动补传;业务校验失败的离线单会被跳过 |
## æ³¨æ„äº‹é¡¹
- ç­¾åˆ°å®šä½å¤±è´¥ä¸é˜»æ–­ç­¾åˆ°ï¼šåœ°å€ä»å»ºè®®å¿…填,便于追溯。
- è¿›åº¦è®°å½•依赖登录用户,操作人由后端从上下文获取,前端不要传 `operatorUserId`。
- ç»Ÿè®¡æŽ¥å£æŒ‰æ•°æ®æƒé™åœºæ™¯è¿‡æ»¤ï¼Œ`sceneType` ä¸Žå·¥å•列表保持一致。
- ç¦»çº¿é˜Ÿåˆ—补传使用创建时的 token;token å¤±æ•ˆæ—¶éœ€ç”¨æˆ·é‡æ–°ç™»å½•后再补传。
sql/mysql/cf_aftersales_helper_20261103.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,147 @@
-- =====================================================================
-- è¯šå‘五金 Â· å·¥å•助手(App)后端配套脚本
-- ä¾æ®ã€Šé™„件一:技术方案-诚发五金(2026)》第(八)章「工单助手」
-- å†…容:
--   1. after_sale_ticket å¢žåŠ çŽ°åœºç­¾åˆ°ä½ï¼ˆsign_in_time / sign_in_address)
--   2. æ–°å»º after_sale_ticket_log å·¥å•操作/进度记录表(进度跟踪、全生命周期追溯)
--   3. è¡¥ã€Œå·¥å•助手」菜单与按钮权限(菜单 visible=0:仅 App ä½¿ç”¨ï¼Œä¸æ”¹å˜ Web ä¾§è¾¹æ ï¼‰
--   4. ç»™é”€å”®/生产管理员/经理角色授权
-- é€‚用库:诚发五金本地库 CFWJ(单租户,脚本不含 tenant_id)
-- å¹‚等:可重复执行
-- =====================================================================
SET NAMES utf8mb4;
-- ==================== 1. after_sale_ticket çŽ°åœºç­¾åˆ°ä½ ====================
SET @exists_sign_in_time = (SELECT COUNT(*) FROM information_schema.columns
    WHERE table_schema = DATABASE() AND table_name = 'after_sale_ticket' AND column_name = 'sign_in_time');
SET @sql = IF(@exists_sign_in_time = 0,
    'ALTER TABLE `after_sale_ticket` ADD COLUMN `sign_in_time` datetime DEFAULT NULL COMMENT ''首次现场签到时间'' AFTER `repair_id`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists_sign_in_address = (SELECT COUNT(*) FROM information_schema.columns
    WHERE table_schema = DATABASE() AND table_name = 'after_sale_ticket' AND column_name = 'sign_in_address');
SET @sql = IF(@exists_sign_in_address = 0,
    'ALTER TABLE `after_sale_ticket` ADD COLUMN `sign_in_address` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT ''首次现场签到地址'' AFTER `sign_in_time`',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ==================== 2. å·¥å•操作/进度记录表 ====================
CREATE TABLE IF NOT EXISTS `after_sale_ticket_log` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '记录编号',
  `ticket_id` bigint NOT NULL COMMENT '工单编号,关联 after_sale_ticket.id',
  `action_type` tinyint NOT NULL COMMENT '操作类型: 1-上报 2-派单 3-签到 4-问题判定 5-转派 6-升级 7-处理记录 8-完结 9-回访 10-归档 11-取消',
  `content` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作内容/说明',
  `operator_user_id` bigint DEFAULT NULL COMMENT '操作人用户编号',
  `longitude` decimal(12,8) DEFAULT NULL COMMENT '经度(现场签到时上报)',
  `latitude` decimal(12,8) DEFAULT NULL COMMENT '纬度(现场签到时上报)',
  `address` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '定位地址(现场签到时上报)',
  `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  PRIMARY KEY (`id`) USING BTREE,
  KEY `idx_ticket_id` (`ticket_id`) USING BTREE,
  KEY `idx_action_type` (`action_type`) USING BTREE,
  KEY `idx_create_time` (`create_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='售后工单操作/进度记录表';
-- ==================== 3. å·¥å•助手菜单(App ä½¿ç”¨ï¼ŒWeb ä¾§ä¸æ˜¾ç¤ºï¼‰ ====================
START TRANSACTION;
-- 3.1 ä¸€çº§ç›®å½•
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
                           `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '工单助手', '', 1, 20, 0, 'aftersales-helper', 'ep:headset', NULL, NULL,
       0, b'0', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `system_menu` WHERE `deleted` = b'0' AND `parent_id` = 0 AND `name` = '工单助手');
SET @helper_id = (SELECT id FROM `system_menu` WHERE `deleted` = b'0' AND `parent_id` = 0 AND `name` = '工单助手' ORDER BY id LIMIT 1);
-- 3.2 å·¥å•受理页面
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
                           `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '工单受理', '', 2, 1, @helper_id, 'ticket', 'ep:document', 'aftersales/ticket/index', NULL,
       0, b'0', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `system_menu` WHERE `deleted` = b'0' AND `parent_id` = @helper_id AND `component` = 'aftersales/ticket/index');
SET @ticket_menu_id = (SELECT id FROM `system_menu` WHERE `deleted` = b'0' AND `parent_id` = @helper_id AND `component` = 'aftersales/ticket/index' ORDER BY id LIMIT 1);
-- 3.3 é€€è´§ç”³è¯·é¡µé¢
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
                           `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '退货申请', '', 2, 2, @helper_id, 'return', 'ep:refresh-left', 'aftersales/return/index', NULL,
       0, b'0', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `system_menu` WHERE `deleted` = b'0' AND `parent_id` = @helper_id AND `component` = 'aftersales/return/index');
SET @return_menu_id = (SELECT id FROM `system_menu` WHERE `deleted` = b'0' AND `parent_id` = @helper_id AND `component` = 'aftersales/return/index' ORDER BY id LIMIT 1);
-- 3.4 é—®é¢˜åº“页面
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
                           `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT '问题库', '', 2, 3, @helper_id, 'problem', 'ep:notebook', 'aftersales/problem/index', NULL,
       0, b'0', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `system_menu` WHERE `deleted` = b'0' AND `parent_id` = @helper_id AND `component` = 'aftersales/problem/index');
SET @problem_menu_id = (SELECT id FROM `system_menu` WHERE `deleted` = b'0' AND `parent_id` = @helper_id AND `component` = 'aftersales/problem/index' ORDER BY id LIMIT 1);
-- 3.5 æŒ‰é’®æƒé™ï¼ˆæŒ‰ permission å¹‚等)
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
                           `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT t.`name`, t.`permission`, 3, t.`sort`, t.`parent_id`, '', '', NULL, NULL, 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'
FROM (
    SELECT '工单查询' AS `name`, 'aftersales:ticket:query' AS `permission`, 1 AS `sort`, @ticket_menu_id AS `parent_id`
    UNION ALL SELECT '工单上报', 'aftersales:ticket:create', 2, @ticket_menu_id
    UNION ALL SELECT '工单处理', 'aftersales:ticket:update', 3, @ticket_menu_id
    UNION ALL SELECT '工单删除', 'aftersales:ticket:delete', 4, @ticket_menu_id
    UNION ALL SELECT '工单回访', 'aftersales:visit:create', 5, @ticket_menu_id
    UNION ALL SELECT '退货查询', 'aftersales:return:query', 1, @return_menu_id
    UNION ALL SELECT '退货申请', 'aftersales:return:create', 2, @return_menu_id
    UNION ALL SELECT '退货审核', 'aftersales:return:update', 3, @return_menu_id
    UNION ALL SELECT '问题查询', 'aftersales:problem:query', 1, @problem_menu_id
    UNION ALL SELECT '问题新增', 'aftersales:problem:create', 2, @problem_menu_id
    UNION ALL SELECT '问题修改', 'aftersales:problem:update', 3, @problem_menu_id
    UNION ALL SELECT '问题删除', 'aftersales:problem:delete', 4, @problem_menu_id
    UNION ALL SELECT '问题发布', 'aftersales:problem:publish', 5, @problem_menu_id
    UNION ALL SELECT '问题停用', 'aftersales:problem:disable', 6, @problem_menu_id
) t
WHERE NOT EXISTS (SELECT 1 FROM `system_menu` m WHERE m.`deleted` = b'0' AND m.`permission` = t.`permission`);
-- 3.6 è§’色授权:销售、生产管理员、经理(超级管理员由代码直通,无需行)
CREATE TEMPORARY TABLE IF NOT EXISTS `tmp_helper_role` (`role_id` bigint NOT NULL PRIMARY KEY);
TRUNCATE TABLE `tmp_helper_role`;
INSERT INTO `tmp_helper_role` (`role_id`) VALUES (165), (166), (168);
INSERT INTO `system_role_menu` (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT r.`role_id`, m.`id`, '1', NOW(), '1', NOW(), b'0'
FROM `tmp_helper_role` r
JOIN `system_menu` m ON m.`deleted` = b'0' AND (
    m.`id` IN (@helper_id, @ticket_menu_id, @return_menu_id, @problem_menu_id)
    OR m.`permission` LIKE 'aftersales:%'
)
WHERE NOT EXISTS (
    SELECT 1 FROM `system_role_menu` rm
    WHERE rm.`role_id` = r.`role_id` AND rm.`menu_id` = m.`id` AND rm.`deleted` = b'0'
);
DROP TEMPORARY TABLE IF EXISTS `tmp_helper_role`;
COMMIT;
-- ==================== 4. éªŒè¯ ====================
-- 1) å·¥å•助手菜单树
SELECT id, name, permission, type, parent_id, visible, component
FROM `system_menu`
WHERE `deleted` = b'0' AND (name = '工单助手' OR parent_id = @helper_id
    OR permission LIKE 'aftersales:%')
ORDER BY parent_id, type, sort;
-- 2) è§’色授权数量
SELECT rm.role_id, COUNT(*) AS menu_count
FROM `system_role_menu` rm
JOIN `system_menu` m ON m.id = rm.menu_id AND m.deleted = b'0'
WHERE rm.deleted = b'0' AND (m.name = '工单助手' OR m.permission LIKE 'aftersales:%')
GROUP BY rm.role_id;
-- 3) è¿›åº¦è®°å½•表
SELECT COUNT(*) AS log_count FROM `after_sale_ticket_log`;
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/AfterSaleTicketController.java
@@ -5,11 +5,19 @@
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
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.AfterSaleTicketLogRespVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketPageReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketRespVO;
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.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.enums.AfterSaleTicketLogActionEnum;
import cn.iocoder.yudao.module.aftersales.service.ticket.AfterSaleTicketService;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
@@ -97,7 +105,7 @@
    @PreAuthorize("@ss.hasPermission('aftersales:ticket:update')")
    public CommonResult<Boolean> assignTicket(@RequestParam("id") Long id,
                                              @RequestParam("handlerUserId") Long handlerUserId) {
        ticketService.assignTicket(id, handlerUserId);
        ticketService.assignTicket(id, handlerUserId, getLoginUserId());
        return success(true);
    }
@@ -108,7 +116,7 @@
                                                @RequestParam("issueType") Integer issueType,
                                                @RequestParam(value = "issueCategory", required = false) String issueCategory,
                                                @RequestParam(value = "severityLevel", required = false) Integer severityLevel) {
        ticketService.classifyTicket(id, issueType, issueCategory, severityLevel);
        ticketService.classifyTicket(id, issueType, issueCategory, severityLevel, getLoginUserId());
        return success(true);
    }
@@ -116,7 +124,7 @@
    @Operation(summary = "完结工单")
    @PreAuthorize("@ss.hasPermission('aftersales:ticket:update')")
    public CommonResult<Boolean> resolveTicket(@RequestParam("id") Long id) {
        ticketService.resolveTicket(id);
        ticketService.resolveTicket(id, getLoginUserId());
        return success(true);
    }
@@ -124,7 +132,7 @@
    @Operation(summary = "关闭工单")
    @PreAuthorize("@ss.hasPermission('aftersales:ticket:update')")
    public CommonResult<Boolean> closeTicket(@RequestParam("id") Long id) {
        ticketService.closeTicket(id);
        ticketService.closeTicket(id, getLoginUserId());
        return success(true);
    }
@@ -132,10 +140,74 @@
    @Operation(summary = "取消工单")
    @PreAuthorize("@ss.hasPermission('aftersales:ticket:update')")
    public CommonResult<Boolean> cancelTicket(@RequestParam("id") Long id) {
        ticketService.cancelTicket(id);
        ticketService.cancelTicket(id, getLoginUserId());
        return success(true);
    }
    @PostMapping("/sign-in")
    @Operation(summary = "现场签到")
    @PreAuthorize("@ss.hasPermission('aftersales:ticket:update')")
    public CommonResult<Boolean> signInTicket(@Valid @RequestBody AfterSaleTicketSignInReqVO reqVO) {
        ticketService.signInTicket(reqVO, getLoginUserId());
        return success(true);
    }
    @PostMapping("/transfer")
    @Operation(summary = "转派工单")
    @PreAuthorize("@ss.hasPermission('aftersales:ticket:update')")
    public CommonResult<Boolean> transferTicket(@Valid @RequestBody AfterSaleTicketTransferReqVO reqVO) {
        ticketService.transferTicket(reqVO, getLoginUserId());
        return success(true);
    }
    @PostMapping("/escalate")
    @Operation(summary = "升级工单")
    @PreAuthorize("@ss.hasPermission('aftersales:ticket:update')")
    public CommonResult<Boolean> escalateTicket(@Valid @RequestBody AfterSaleTicketEscalateReqVO reqVO) {
        ticketService.escalateTicket(reqVO, getLoginUserId());
        return success(true);
    }
    @PostMapping("/handle")
    @Operation(summary = "新增处理记录")
    @PreAuthorize("@ss.hasPermission('aftersales:ticket:update')")
    public CommonResult<Boolean> addHandleRecord(@Valid @RequestBody AfterSaleTicketHandleReqVO reqVO) {
        ticketService.addHandleRecord(reqVO, getLoginUserId());
        return success(true);
    }
    @GetMapping("/log-list")
    @Operation(summary = "获得工单操作/进度记录列表")
    @Parameter(name = "ticketId", description = "工单编号", required = true, example = "1")
    public CommonResult<List<AfterSaleTicketLogRespVO>> getTicketLogList(@RequestParam("ticketId") Long ticketId) {
        return success(buildLogRespList(ticketService.getTicketLogList(ticketId)));
    }
    @GetMapping("/statistics")
    @Operation(summary = "获得工单助手统计")
    @Parameter(name = "sceneType", description = "场景类型: 1-我负责的 2-我参与的 3-下属负责的 4-全部", example = "4")
    public CommonResult<AfterSaleTicketStatisticsRespVO> getTicketStatistics(
            @RequestParam(value = "sceneType", required = false) Integer sceneType) {
        return success(ticketService.getTicketStatistics(sceneType, getLoginUserId()));
    }
    // ========== å†…部方法:拼装进度记录 ==========
    private List<AfterSaleTicketLogRespVO> buildLogRespList(List<AfterSaleTicketLogDO> logList) {
        if (CollUtil.isEmpty(logList)) {
            return Collections.emptyList();
        }
        Set<Long> userIds = convertSet(logList, AfterSaleTicketLogDO::getOperatorUserId);
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(userIds);
        return BeanUtils.toBean(logList, AfterSaleTicketLogRespVO.class, vo -> {
            vo.setActionName(AfterSaleTicketLogActionEnum.getName(vo.getActionType()));
            AdminUserRespDTO user = userMap.get(vo.getOperatorUserId());
            if (user != null) {
                vo.setOperatorUserName(user.getNickname());
            }
        });
    }
    // ========== å†…部方法:批量拼装关联名称 ==========
    private List<AfterSaleTicketRespVO> buildTicketDetailList(List<AfterSaleTicketDO> ticketList) {
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketEscalateReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
 * å”®åŽå·¥å• - å‡çº§ Request VO
 */
@Schema(description = "售后工单 - å‡çº§ Request VO")
@Data
public class AfterSaleTicketEscalateReqVO {
    @Schema(description = "工单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "工单编号不能为空")
    private Long id;
    @Schema(description = "升级后严重程度: 0-轻微 1-一般 2-严重 3-致命", example = "2")
    private Integer severityLevel;
    @Schema(description = "升级原因", example = "现场排查发现主板烧毁,影响整条产线")
    private String reason;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketHandleReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
 * å”®åŽå·¥å• - å¤„理记录 Request VO
 */
@Schema(description = "售后工单 - å¤„理记录 Request VO")
@Data
public class AfterSaleTicketHandleReqVO {
    @Schema(description = "工单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "工单编号不能为空")
    private Long id;
    @Schema(description = "处理内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "已更换损坏的轴承并完成试机")
    @NotBlank(message = "处理内容不能为空")
    private String content;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketLogRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * å”®åŽå·¥å•操作/进度记录 Response VO
 */
@Schema(description = "售后工单 - æ“ä½œ/进度记录 Response VO")
@Data
public class AfterSaleTicketLogRespVO {
    @Schema(description = "记录编号", example = "1")
    private Long id;
    @Schema(description = "工单编号", example = "1")
    private Long ticketId;
    @Schema(description = "操作类型: 1-上报 2-派单 3-签到 4-问题判定 5-转派 6-升级 7-处理记录 8-完结 9-回访 10-归档 11-取消", example = "3")
    private Integer actionType;
    @Schema(description = "操作类型名称", example = "现场签到")
    private String actionName;
    @Schema(description = "操作内容", example = "已到达客户现场,设备运行异响")
    private String content;
    @Schema(description = "操作人用户编号", example = "1")
    private Long operatorUserId;
    @Schema(description = "操作人名称", example = "å¼ å·¥")
    private String operatorUserName;
    @Schema(description = "经度", example = "118.180000")
    private BigDecimal longitude;
    @Schema(description = "纬度", example = "39.630000")
    private BigDecimal latitude;
    @Schema(description = "定位地址", example = "河北省唐山市滦南县XXè·¯XX号")
    private String address;
    @Schema(description = "操作时间")
    private LocalDateTime createTime;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketRespVO.java
@@ -81,6 +81,12 @@
    @Schema(description = "关联维修记录编号", example = "1")
    private Long repairId;
    @Schema(description = "首次现场签到时间")
    private LocalDateTime signInTime;
    @Schema(description = "首次现场签到地址")
    private String signInAddress;
    @Schema(description = "解决时间")
    private LocalDateTime resolvedTime;
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketSignInReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
/**
 * å”®åŽå·¥å• - çŽ°åœºç­¾åˆ° Request VO
 */
@Schema(description = "售后工单 - çŽ°åœºç­¾åˆ° Request VO")
@Data
public class AfterSaleTicketSignInReqVO {
    @Schema(description = "工单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "工单编号不能为空")
    private Long id;
    @Schema(description = "经度", example = "118.180000")
    private BigDecimal longitude;
    @Schema(description = "纬度", example = "39.630000")
    private BigDecimal latitude;
    @Schema(description = "定位地址", example = "河北省唐山市滦南县XXè·¯XX号")
    private String address;
    @Schema(description = "签到说明", example = "已到达客户现场")
    private String content;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketStatisticsRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,112 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
 * å”®åŽå·¥å• - å·¥å•助手统计 Response VO
 */
@Schema(description = "售后工单 - å·¥å•助手统计 Response VO")
@Data
public class AfterSaleTicketStatisticsRespVO {
    @Schema(description = "工单总数", example = "42")
    private Long totalCount;
    @Schema(description = "处理中数量", example = "8")
    private Long processingCount;
    @Schema(description = "待退货数量", example = "2")
    private Long waitingReturnCount;
    @Schema(description = "待退款/换货数量", example = "1")
    private Long waitingRefundCount;
    @Schema(description = "已完结(待回访)数量", example = "5")
    private Long completedCount;
    @Schema(description = "已关闭(归档)数量", example = "20")
    private Long closedCount;
    @Schema(description = "已取消数量", example = "1")
    private Long canceledCount;
    @Schema(description = "今日新增数量", example = "3")
    private Long todayCreateCount;
    @Schema(description = "今日完结数量", example = "2")
    private Long todayResolveCount;
    @Schema(description = "已现场签到工单数量", example = "15")
    private Long signInCount;
    @Schema(description = "被升级过的工单数量", example = "4")
    private Long escalatedCount;
    @Schema(description = "平均完结耗时(小时)", example = "6.5")
    private BigDecimal avgResolveHours;
    @Schema(description = "回访满意率(百分比,0-100)", example = "96.7")
    private BigDecimal satisfactionRate;
    @Schema(description = "按状态统计")
    private List<StatusCount> statusCounts;
    @Schema(description = "按问题类型统计")
    private List<IssueTypeCount> issueTypeCounts;
    @Schema(description = "按处理人统计工作量")
    private List<HandlerWorkload> handlerWorkloads;
    @Schema(description = "状态统计")
    @Data
    public static class StatusCount {
        @Schema(description = "状态", example = "10")
        private Integer status;
        @Schema(description = "状态名称", example = "处理中")
        private String statusName;
        @Schema(description = "数量", example = "8")
        private Long count;
    }
    @Schema(description = "问题类型统计")
    @Data
    public static class IssueTypeCount {
        @Schema(description = "问题类型", example = "2")
        private Integer issueType;
        @Schema(description = "问题类型名称", example = "维修问题")
        private String issueTypeName;
        @Schema(description = "数量", example = "12")
        private Long count;
    }
    @Schema(description = "处理人工作量")
    @Data
    public static class HandlerWorkload {
        @Schema(description = "处理人用户编号", example = "2")
        private Long handlerUserId;
        @Schema(description = "处理人名称", example = "å¼ å·¥")
        private String handlerUserName;
        @Schema(description = "处理中数量", example = "3")
        private Long processingCount;
        @Schema(description = "累计处理数量", example = "12")
        private Long totalCount;
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketTransferReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
 * å”®åŽå·¥å• - è½¬æ´¾ Request VO
 */
@Schema(description = "售后工单 - è½¬æ´¾ Request VO")
@Data
public class AfterSaleTicketTransferReqVO {
    @Schema(description = "工单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "工单编号不能为空")
    private Long id;
    @Schema(description = "新处理人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
    @NotNull(message = "新处理人不能为空")
    private Long handlerUserId;
    @Schema(description = "转派原因", example = "该问题需要电气工程师处理")
    private String reason;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/ticket/AfterSaleTicketDO.java
@@ -101,6 +101,14 @@
     */
    private Long repairId;
    /**
     * é¦–次现场签到时间
     */
    private LocalDateTime signInTime;
    /**
     * é¦–次现场签到地址
     */
    private String signInAddress;
    /**
     * è§£å†³æ—¶é—´
     */
    private LocalDateTime resolvedTime;
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/ticket/AfterSaleTicketLogDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
package cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleTicketLogActionEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.math.BigDecimal;
/**
 * å”®åŽå·¥å•操作/进度记录 DO
 * <p>
 * å·¥å•助手 App çš„「进度跟踪」数据来源,完整记录工单全生命周期操作
 * ï¼ˆæ“ä½œæ—¶é—´ã€æ“ä½œäººã€åŠ¨ä½œã€çŽ°åœºä½ç½®ï¼‰ï¼Œä¿è¯å”®åŽè¿‡ç¨‹å¯è¿½æº¯ã€‚
 */
@TableName("after_sale_ticket_log")
@KeySequence("after_sale_ticket_log_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AfterSaleTicketLogDO extends BaseDO {
    /**
     * è®°å½•编号
     */
    @TableId
    private Long id;
    /**
     * å·¥å•编号,关联 after_sale_ticket.id
     */
    private Long ticketId;
    /**
     * æ“ä½œç±»åž‹
     *
     * æžšä¸¾ {@link AfterSaleTicketLogActionEnum}
     */
    private Integer actionType;
    /**
     * æ“ä½œå†…容/说明
     */
    private String content;
    /**
     * æ“ä½œäººç”¨æˆ·ç¼–号
     */
    private Long operatorUserId;
    /**
     * ç»åº¦ï¼ˆçŽ°åœºç­¾åˆ°æ—¶ä¸ŠæŠ¥ï¼‰
     */
    private BigDecimal longitude;
    /**
     * çº¬åº¦ï¼ˆçŽ°åœºç­¾åˆ°æ—¶ä¸ŠæŠ¥ï¼‰
     */
    private BigDecimal latitude;
    /**
     * å®šä½åœ°å€ï¼ˆçŽ°åœºç­¾åˆ°æ—¶ä¸ŠæŠ¥ï¼‰
     */
    private String address;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/ticket/AfterSaleTicketLogMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.aftersales.dal.mysql.ticket;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketLogDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
/**
 * å”®åŽå·¥å•操作/进度记录 Mapper
 */
@Mapper
public interface AfterSaleTicketLogMapper extends BaseMapperX<AfterSaleTicketLogDO> {
    default List<AfterSaleTicketLogDO> selectListByTicketId(Long ticketId) {
        return selectList(new LambdaQueryWrapperX<AfterSaleTicketLogDO>()
                .eq(AfterSaleTicketLogDO::getTicketId, ticketId)
                .orderByAsc(AfterSaleTicketLogDO::getId));
    }
    default List<AfterSaleTicketLogDO> selectListByTicketIds(Collection<Long> ticketIds) {
        if (ticketIds == null || ticketIds.isEmpty()) {
            return List.of();
        }
        return selectList(new LambdaQueryWrapperX<AfterSaleTicketLogDO>()
                .in(AfterSaleTicketLogDO::getTicketId, ticketIds)
                .orderByAsc(AfterSaleTicketLogDO::getId));
    }
    default Long selectCountByTicketIdAndActionType(Long ticketId, Integer actionType) {
        return selectCount(new LambdaQueryWrapperX<AfterSaleTicketLogDO>()
                .eq(AfterSaleTicketLogDO::getTicketId, ticketId)
                .eq(AfterSaleTicketLogDO::getActionType, actionType));
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/AfterSaleTicketLogActionEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
package cn.iocoder.yudao.module.aftersales.enums;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/**
 * å”®åŽå·¥å•操作/进度记录动作枚举
 * <p>
 * ç”¨äºŽã€Œå·¥å•助手」App çš„工单全生命周期追溯:每一次上报、派单、签到、处理、转派、
 * å‡çº§ã€å®Œç»“、回访、归档、取消都会写入一条 after_sale_ticket_log è®°å½•。
 */
@RequiredArgsConstructor
@Getter
public enum AfterSaleTicketLogActionEnum implements ArrayValuable<Integer> {
    CREATE(1, "工单上报"),
    ASSIGN(2, "派单"),
    SIGN_IN(3, "现场签到"),
    CLASSIFY(4, "问题判定"),
    TRANSFER(5, "转派"),
    ESCALATE(6, "升级"),
    HANDLE(7, "处理记录"),
    RESOLVE(8, "完结工单"),
    VISIT(9, "客户回访"),
    CLOSE(10, "归档"),
    CANCEL(11, "取消工单");
    public static final Integer[] ARRAYS = Arrays.stream(values())
            .map(AfterSaleTicketLogActionEnum::getType).toArray(Integer[]::new);
    private final Integer type;
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static String getName(Integer type) {
        return Arrays.stream(values())
                .filter(item -> item.getType().equals(type))
                .map(AfterSaleTicketLogActionEnum::getName)
                .findFirst()
                .orElse("未知操作");
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/ErrorCodeConstants.java
@@ -20,6 +20,7 @@
    ErrorCode TICKET_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_022_000_007, "售后工单审批流程不存在或未激活,请检查 BPM é…ç½®");
    ErrorCode TICKET_STATUS_ERROR = new ErrorCode(1_022_000_008, "工单当前状态不允许此操作");
    ErrorCode TICKET_ARCHIVE_FAIL_NOT_VISITED = new ErrorCode(1_022_000_009, "归档失败,原因:工单尚未回访通过,请先登记满意的回访记录");
    ErrorCode TICKET_TRANSFER_FAIL_SAME_HANDLER = new ErrorCode(1_022_000_010, "转派失败,原因:新处理人与当前处理人相同,请选择其他处理人");
    // ========== å”®åŽå·¥å•明细行 1-022-001-000 ==========
    ErrorCode TICKET_ITEM_NOT_EXISTS = new ErrorCode(1_022_001_000, "工单明细行不存在");
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketService.java
@@ -1,10 +1,16 @@
package cn.iocoder.yudao.module.aftersales.service.ticket;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
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.ticket.AfterSaleTicketDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketItemDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketLogDO;
import jakarta.validation.Valid;
import java.util.Collection;
@@ -74,10 +80,11 @@
    /**
     * åˆ†é…å¤„理人
     *
     * @param id              å·¥å•编号
     * @param handlerUserId   å¤„理人用户编号
     * @param id            å·¥å•编号
     * @param handlerUserId å¤„理人用户编号
     * @param operatorUserId æ“ä½œäººç”¨æˆ·ç¼–号
     */
    void assignTicket(Long id, Long handlerUserId);
    void assignTicket(Long id, Long handlerUserId, Long operatorUserId);
    /**
     * é—®é¢˜åˆ¤å®šï¼ˆç¡®å®šé—®é¢˜ç±»åž‹ã€åˆ†ç±»ã€ä¸¥é‡ç¨‹åº¦ï¼‰
@@ -86,28 +93,81 @@
     * @param issueType     é—®é¢˜ç±»åž‹
     * @param issueCategory é—®é¢˜åˆ†ç±»
     * @param severityLevel ä¸¥é‡ç¨‹åº¦
     * @param operatorUserId æ“ä½œäººç”¨æˆ·ç¼–号
     */
    void classifyTicket(Long id, Integer issueType, String issueCategory, Integer severityLevel);
    void classifyTicket(Long id, Integer issueType, String issueCategory, Integer severityLevel, Long operatorUserId);
    /**
     * å®Œç»“工单
     *
     * @param id å·¥å•编号
     * @param operatorUserId æ“ä½œäººç”¨æˆ·ç¼–号
     */
    void resolveTicket(Long id);
    void resolveTicket(Long id, Long operatorUserId);
    /**
     * å…³é—­å·¥å•
     *
     * @param id å·¥å•编号
     * @param operatorUserId æ“ä½œäººç”¨æˆ·ç¼–号
     */
    void closeTicket(Long id);
    void closeTicket(Long id, Long operatorUserId);
    /**
     * å–消工单
     *
     * @param id å·¥å•编号
     * @param operatorUserId æ“ä½œäººç”¨æˆ·ç¼–号
     */
    void cancelTicket(Long id);
    void cancelTicket(Long id, Long operatorUserId);
    /**
     * çŽ°åœºç­¾åˆ°ï¼ˆè®°å½•ä½ç½®ä¿¡æ¯ï¼Œä¿éšœæœåŠ¡çœŸå®žæ€§ï¼‰
     *
     * @param reqVO  ç­¾åˆ°ä¿¡æ¯
     * @param userId æ“ä½œäººç”¨æˆ·ç¼–号
     */
    void signInTicket(@Valid AfterSaleTicketSignInReqVO reqVO, Long userId);
    /**
     * è½¬æ´¾å·¥å•
     *
     * @param reqVO  è½¬æ´¾ä¿¡æ¯
     * @param userId æ“ä½œäººç”¨æˆ·ç¼–号
     */
    void transferTicket(@Valid AfterSaleTicketTransferReqVO reqVO, Long userId);
    /**
     * å‡çº§å·¥å•
     *
     * @param reqVO  å‡çº§ä¿¡æ¯
     * @param userId æ“ä½œäººç”¨æˆ·ç¼–号
     */
    void escalateTicket(@Valid AfterSaleTicketEscalateReqVO reqVO, Long userId);
    /**
     * æ–°å¢žå¤„理记录
     *
     * @param reqVO  å¤„理记录
     * @param userId æ“ä½œäººç”¨æˆ·ç¼–号
     */
    void addHandleRecord(@Valid AfterSaleTicketHandleReqVO reqVO, Long userId);
    /**
     * èŽ·å¾—å·¥å•æ“ä½œ/进度记录列表
     *
     * @param ticketId å·¥å•编号
     * @return è®°å½•列表
     */
    List<AfterSaleTicketLogDO> getTicketLogList(Long ticketId);
    /**
     * èŽ·å¾—å·¥å•åŠ©æ‰‹ç»Ÿè®¡
     *
     * @param sceneType åœºæ™¯ç±»åž‹
     * @param userId    ç”¨æˆ·ç¼–号
     * @return ç»Ÿè®¡ç»“æžœ
     */
    AfterSaleTicketStatisticsRespVO getTicketStatistics(Integer sceneType, Long userId);
}
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) {
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/visit/AfterSaleVisitServiceImpl.java
@@ -6,9 +6,12 @@
import cn.iocoder.yudao.module.aftersales.controller.admin.visit.vo.AfterSaleVisitCreateReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.visit.vo.AfterSaleVisitPageReqVO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketLogDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.visit.AfterSaleVisitDO;
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.mysql.visit.AfterSaleVisitMapper;
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.system.api.storage.StorageAttachmentApi;
@@ -38,6 +41,8 @@
    private AfterSaleVisitMapper visitMapper;
    @Resource
    private AfterSaleTicketMapper ticketMapper;
    @Resource
    private AfterSaleTicketLogMapper ticketLogMapper;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
@@ -80,6 +85,16 @@
                    .setVisitStatus(AfterSaleVisitStatusEnum.WAITING.getStatus());
        }
        ticketMapper.updateById(update);
        // 5. è®°å½•回访进度(工单助手进度跟踪)
        ticketLogMapper.insert(AfterSaleTicketLogDO.builder()
                .ticketId(ticket.getId())
                .actionType(AfterSaleTicketLogActionEnum.VISIT.getType())
                .content("客户回访:" + (satisfied ? "满意" : "不满意")
                        + (recurred ? ",问题复发" : ",未复发")
                        + (createReqVO.getContent() != null ? ";" + createReqVO.getContent() : ""))
                .operatorUserId(userId)
                .build());
        return visitDO.getId();
    }