Преглед изворни кода

feat(wm-patrol): #77 问题上报与工单联动

bot_dev2 пре 5 дана
родитељ
комит
c29b2ceb61
20 измењених фајлова са 1698 додато и 0 уклоњено
  1. 196
    0
      wm-patrol/src/main/java/com/water/patrol/controller/IssueController.java
  2. 47
    0
      wm-patrol/src/main/java/com/water/patrol/entity/IssueCategory.java
  3. 84
    0
      wm-patrol/src/main/java/com/water/patrol/entity/IssueWorkOrder.java
  4. 25
    0
      wm-patrol/src/main/java/com/water/patrol/entity/PatrolIssue.java
  5. 62
    0
      wm-patrol/src/main/java/com/water/patrol/entity/dto/IssueReportRequest.java
  6. 42
    0
      wm-patrol/src/main/java/com/water/patrol/entity/dto/IssueReportVO.java
  7. 5
    0
      wm-patrol/src/main/java/com/water/patrol/entity/dto/PatrolIssueRequest.java
  8. 27
    0
      wm-patrol/src/main/java/com/water/patrol/entity/dto/WorkOrderCompleteRequest.java
  9. 52
    0
      wm-patrol/src/main/java/com/water/patrol/entity/dto/WorkOrderCreateRequest.java
  10. 19
    0
      wm-patrol/src/main/java/com/water/patrol/mapper/IssueCategoryMapper.java
  11. 25
    0
      wm-patrol/src/main/java/com/water/patrol/mapper/IssueWorkOrderMapper.java
  12. 23
    0
      wm-patrol/src/main/java/com/water/patrol/mapper/PatrolIssueMapper.java
  13. 247
    0
      wm-patrol/src/main/java/com/water/patrol/service/IssueReportService.java
  14. 1
    0
      wm-patrol/src/main/java/com/water/patrol/service/IssueService.java
  15. 63
    0
      wm-patrol/src/main/java/com/water/patrol/service/IssueStatsService.java
  16. 254
    0
      wm-patrol/src/main/java/com/water/patrol/service/IssueTrackingService.java
  17. 71
    0
      wm-patrol/src/main/resources/db/V3__patrol_issue.sql
  18. 181
    0
      wm-patrol/src/test/java/com/water/patrol/service/IssueReportServiceTest.java
  19. 77
    0
      wm-patrol/src/test/java/com/water/patrol/service/IssueStatsServiceTest.java
  20. 197
    0
      wm-patrol/src/test/java/com/water/patrol/service/IssueTrackingServiceTest.java

+ 196
- 0
wm-patrol/src/main/java/com/water/patrol/controller/IssueController.java Прегледај датотеку

@@ -0,0 +1,196 @@
1
+package com.water.patrol.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.patrol.entity.IssueCategory;
6
+import com.water.patrol.entity.IssueWorkOrder;
7
+import com.water.patrol.entity.PatrolIssue;
8
+import com.water.patrol.entity.dto.*;
9
+import com.water.patrol.service.IssueReportService;
10
+import com.water.patrol.service.IssueStatsService;
11
+import com.water.patrol.service.IssueTrackingService;
12
+import io.swagger.v3.oas.annotations.Operation;
13
+import io.swagger.v3.oas.annotations.tags.Tag;
14
+import lombok.RequiredArgsConstructor;
15
+import org.springframework.web.bind.annotation.*;
16
+
17
+import java.util.List;
18
+import java.util.Map;
19
+
20
+/**
21
+ * 问题上报 + 工单联动 REST API
22
+ */
23
+@Tag(name = "问题上报与工单联动")
24
+@RestController
25
+@RequestMapping("/api/patrol/issue")
26
+@RequiredArgsConstructor
27
+public class IssueController {
28
+
29
+    private final IssueReportService issueReportService;
30
+    private final IssueTrackingService issueTrackingService;
31
+    private final IssueStatsService issueStatsService;
32
+
33
+    // ==================== 问题上报 ====================
34
+
35
+    @Operation(summary = "上报问题")
36
+    @PostMapping("/report")
37
+    public R<PatrolIssue> reportIssue(@RequestBody IssueReportRequest request) {
38
+        return R.ok(issueReportService.reportIssue(request));
39
+    }
40
+
41
+    @Operation(summary = "获取问题详情(含工单)")
42
+    @GetMapping("/{issueId}")
43
+    public R<IssueReportVO> getIssueDetail(@PathVariable Long issueId) {
44
+        return R.ok(issueReportService.getIssueDetail(issueId));
45
+    }
46
+
47
+    @Operation(summary = "分页查询问题列表")
48
+    @GetMapping("/page")
49
+    public R<Page<PatrolIssue>> pageIssues(
50
+            @RequestParam(defaultValue = "1") int current,
51
+            @RequestParam(defaultValue = "10") int size,
52
+            @RequestParam(required = false) String issueType,
53
+            @RequestParam(required = false) String handleStatus,
54
+            @RequestParam(required = false) String severity,
55
+            @RequestParam(required = false) Long categoryId,
56
+            @RequestParam(required = false) Long executionId) {
57
+        return R.ok(issueReportService.pageIssues(current, size, issueType, handleStatus,
58
+                severity, categoryId, executionId));
59
+    }
60
+
61
+    @Operation(summary = "关闭问题")
62
+    @PutMapping("/{issueId}/close")
63
+    public R<PatrolIssue> closeIssue(@PathVariable Long issueId,
64
+                                      @RequestParam(required = false) String remark) {
65
+        return R.ok(issueReportService.closeIssue(issueId, remark));
66
+    }
67
+
68
+    // ==================== 工单联动 ====================
69
+
70
+    @Operation(summary = "创建工单")
71
+    @PostMapping("/work-order")
72
+    public R<IssueWorkOrder> createWorkOrder(@RequestBody WorkOrderCreateRequest request) {
73
+        return R.ok(issueTrackingService.createWorkOrder(request));
74
+    }
75
+
76
+    @Operation(summary = "问题自动生成工单")
77
+    @PostMapping("/{issueId}/auto-work-order")
78
+    public R<IssueWorkOrder> autoCreateWorkOrder(@PathVariable Long issueId) {
79
+        return R.ok(issueTrackingService.autoCreateWorkOrder(issueId));
80
+    }
81
+
82
+    @Operation(summary = "获取工单详情")
83
+    @GetMapping("/work-order/{workOrderId}")
84
+    public R<IssueWorkOrder> getWorkOrder(@PathVariable Long workOrderId) {
85
+        return R.ok(issueTrackingService.getWorkOrder(workOrderId));
86
+    }
87
+
88
+    @Operation(summary = "派单(指派处理人)")
89
+    @PutMapping("/work-order/{workOrderId}/dispatch")
90
+    public R<IssueWorkOrder> dispatchWorkOrder(@PathVariable Long workOrderId,
91
+                                                @RequestParam Long assigneeId,
92
+                                                @RequestParam String assigneeName) {
93
+        return R.ok(issueTrackingService.dispatchWorkOrder(workOrderId, assigneeId, assigneeName));
94
+    }
95
+
96
+    @Operation(summary = "更新工单状态")
97
+    @PutMapping("/work-order/{workOrderId}/status")
98
+    public R<IssueWorkOrder> updateWorkOrderStatus(@PathVariable Long workOrderId,
99
+                                                    @RequestParam String status) {
100
+        return R.ok(issueTrackingService.updateWorkOrderStatus(workOrderId, status));
101
+    }
102
+
103
+    @Operation(summary = "工单完成回执")
104
+    @PutMapping("/work-order/{workOrderId}/complete")
105
+    public R<IssueWorkOrder> completeWorkOrder(@PathVariable Long workOrderId,
106
+                                                @RequestBody WorkOrderCompleteRequest request) {
107
+        return R.ok(issueTrackingService.completeWorkOrder(workOrderId, request));
108
+    }
109
+
110
+    @Operation(summary = "取消工单")
111
+    @PutMapping("/work-order/{workOrderId}/cancel")
112
+    public R<IssueWorkOrder> cancelWorkOrder(@PathVariable Long workOrderId,
113
+                                              @RequestParam(required = false) String reason) {
114
+        return R.ok(issueTrackingService.cancelWorkOrder(workOrderId, reason));
115
+    }
116
+
117
+    @Operation(summary = "获取问题的所有工单")
118
+    @GetMapping("/{issueId}/work-orders")
119
+    public R<List<IssueWorkOrder>> getWorkOrdersByIssue(@PathVariable Long issueId) {
120
+        return R.ok(issueTrackingService.getWorkOrdersByIssueId(issueId));
121
+    }
122
+
123
+    @Operation(summary = "分页查询工单列表")
124
+    @GetMapping("/work-order/page")
125
+    public R<Page<IssueWorkOrder>> pageWorkOrders(
126
+            @RequestParam(defaultValue = "1") int current,
127
+            @RequestParam(defaultValue = "10") int size,
128
+            @RequestParam(required = false) String status,
129
+            @RequestParam(required = false) Long assigneeId) {
130
+        return R.ok(issueTrackingService.pageWorkOrders(current, size, status, assigneeId));
131
+    }
132
+
133
+    // ==================== 分类管理 ====================
134
+
135
+    @Operation(summary = "获取分类树")
136
+    @GetMapping("/category/tree")
137
+    public R<List<IssueCategory>> getCategoryTree() {
138
+        return R.ok(issueReportService.getCategoryTree());
139
+    }
140
+
141
+    @Operation(summary = "获取子分类")
142
+    @GetMapping("/category/{parentId}/children")
143
+    public R<List<IssueCategory>> getSubCategories(@PathVariable Long parentId) {
144
+        return R.ok(issueReportService.getSubCategories(parentId));
145
+    }
146
+
147
+    @Operation(summary = "创建分类")
148
+    @PostMapping("/category")
149
+    public R<IssueCategory> createCategory(@RequestBody IssueCategory category) {
150
+        return R.ok(issueReportService.createCategory(category));
151
+    }
152
+
153
+    @Operation(summary = "更新分类")
154
+    @PutMapping("/category/{id}")
155
+    public R<String> updateCategory(@PathVariable Long id, @RequestBody IssueCategory category) {
156
+        return issueReportService.updateCategory(id, category) ? R.ok("更新成功") : R.fail("更新失败");
157
+    }
158
+
159
+    @Operation(summary = "删除分类")
160
+    @DeleteMapping("/category/{id}")
161
+    public R<String> deleteCategory(@PathVariable Long id) {
162
+        return issueReportService.deleteCategory(id) ? R.ok("删除成功") : R.fail("删除失败");
163
+    }
164
+
165
+    // ==================== 统计分析 ====================
166
+
167
+    @Operation(summary = "问题分类分布")
168
+    @GetMapping("/stats/category-distribution")
169
+    public R<List<Map<String, Object>>> getCategoryDistribution(
170
+            @RequestParam(required = false) String startDate,
171
+            @RequestParam(required = false) String endDate) {
172
+        return R.ok(issueStatsService.getCategoryDistribution(startDate, endDate));
173
+    }
174
+
175
+    @Operation(summary = "问题趋势(按日)")
176
+    @GetMapping("/stats/trend")
177
+    public R<List<Map<String, Object>>> getDailyTrend(
178
+            @RequestParam String startDate,
179
+            @RequestParam String endDate) {
180
+        return R.ok(issueStatsService.getDailyTrend(startDate, endDate));
181
+    }
182
+
183
+    @Operation(summary = "严重程度分布")
184
+    @GetMapping("/stats/severity-distribution")
185
+    public R<List<Map<String, Object>>> getSeverityDistribution() {
186
+        return R.ok(issueStatsService.getSeverityDistribution());
187
+    }
188
+
189
+    @Operation(summary = "综合统计看板")
190
+    @GetMapping("/stats/dashboard")
191
+    public R<Map<String, Object>> getDashboard(
192
+            @RequestParam(required = false) String startDate,
193
+            @RequestParam(required = false) String endDate) {
194
+        return R.ok(issueStatsService.getDashboard(startDate, endDate));
195
+    }
196
+}

+ 47
- 0
wm-patrol/src/main/java/com/water/patrol/entity/IssueCategory.java Прегледај датотеку

@@ -0,0 +1,47 @@
1
+package com.water.patrol.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+@Data
9
+@EqualsAndHashCode(callSuper = true)
10
+@TableName("issue_category")
11
+public class IssueCategory extends BaseEntity {
12
+
13
+    /**
14
+     * 分类名称
15
+     */
16
+    private String name;
17
+
18
+    /**
19
+     * 分类编码
20
+     */
21
+    private String code;
22
+
23
+    /**
24
+     * 父分类ID (0=顶级分类)
25
+     */
26
+    private Long parentId;
27
+
28
+    /**
29
+     * 图标标识
30
+     */
31
+    private String icon;
32
+
33
+    /**
34
+     * 分类描述
35
+     */
36
+    private String description;
37
+
38
+    /**
39
+     * 排序号
40
+     */
41
+    private Integer sort;
42
+
43
+    /**
44
+     * 状态: 1=启用 0=停用
45
+     */
46
+    private Integer status;
47
+}

+ 84
- 0
wm-patrol/src/main/java/com/water/patrol/entity/IssueWorkOrder.java Прегледај датотеку

@@ -0,0 +1,84 @@
1
+package com.water.patrol.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+import java.time.LocalDateTime;
9
+
10
+@Data
11
+@EqualsAndHashCode(callSuper = true)
12
+@TableName("issue_work_order")
13
+public class IssueWorkOrder extends BaseEntity {
14
+
15
+    /**
16
+     * 关联问题ID
17
+     */
18
+    private Long issueId;
19
+
20
+    /**
21
+     * 工单编号
22
+     */
23
+    private String orderNo;
24
+
25
+    /**
26
+     * 工单标题
27
+     */
28
+    private String title;
29
+
30
+    /**
31
+     * 工单描述
32
+     */
33
+    private String description;
34
+
35
+    /**
36
+     * 问题分类ID
37
+     */
38
+    private Long categoryId;
39
+
40
+    /**
41
+     * 优先级: low/medium/high/urgent
42
+     */
43
+    private String priority;
44
+
45
+    /**
46
+     * 工单状态: pending/dispatched/in_progress/completed/cancelled
47
+     */
48
+    private String status;
49
+
50
+    /**
51
+     * 处理人ID
52
+     */
53
+    private Long assigneeId;
54
+
55
+    /**
56
+     * 处理人姓名
57
+     */
58
+    private String assigneeName;
59
+
60
+    /**
61
+     * 期望完成时间
62
+     */
63
+    private LocalDateTime expectedCompleteTime;
64
+
65
+    /**
66
+     * 实际完成时间
67
+     */
68
+    private LocalDateTime actualCompleteTime;
69
+
70
+    /**
71
+     * 处理结果
72
+     */
73
+    private String result;
74
+
75
+    /**
76
+     * 处理结果照片(JSON数组)
77
+     */
78
+    private String resultPhotos;
79
+
80
+    /**
81
+     * 备注
82
+     */
83
+    private String remark;
84
+}

+ 25
- 0
wm-patrol/src/main/java/com/water/patrol/entity/PatrolIssue.java Прегледај датотеку

@@ -81,4 +81,29 @@ public class PatrolIssue extends BaseEntity {
81 81
      * 上报时间
82 82
      */
83 83
     private LocalDateTime reportTime;
84
+
85
+    /**
86
+     * 问题分类ID
87
+     */
88
+    private Long categoryId;
89
+
90
+    /**
91
+     * 处理备注
92
+     */
93
+    private String handleRemark;
94
+
95
+    /**
96
+     * 处理完成时间
97
+     */
98
+    private LocalDateTime handleTime;
99
+
100
+    /**
101
+     * 处理人ID
102
+     */
103
+    private Long handlerId;
104
+
105
+    /**
106
+     * 处理人姓名
107
+     */
108
+    private String handlerName;
84 109
 }

+ 62
- 0
wm-patrol/src/main/java/com/water/patrol/entity/dto/IssueReportRequest.java Прегледај датотеку

@@ -0,0 +1,62 @@
1
+package com.water.patrol.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.util.List;
6
+
7
+/**
8
+ * 问题上报请求 DTO
9
+ */
10
+@Data
11
+public class IssueReportRequest {
12
+
13
+    /**
14
+     * 关联执行ID
15
+     */
16
+    private Long executionId;
17
+
18
+    /**
19
+     * 关联检查项记录ID(可选)
20
+     */
21
+    private Long checkRecordId;
22
+
23
+    /**
24
+     * 问题分类ID
25
+     */
26
+    private Long categoryId;
27
+
28
+    /**
29
+     * 问题类型: leak/damage/pollution/illegal/other
30
+     */
31
+    private String issueType;
32
+
33
+    /**
34
+     * 严重程度: low/medium/high/critical
35
+     */
36
+    private String severity;
37
+
38
+    /**
39
+     * 问题描述
40
+     */
41
+    private String description;
42
+
43
+    /**
44
+     * 照片URL列表
45
+     */
46
+    private List<String> photoUrls;
47
+
48
+    /**
49
+     * 经度
50
+     */
51
+    private Double lng;
52
+
53
+    /**
54
+     * 纬度
55
+     */
56
+    private Double lat;
57
+
58
+    /**
59
+     * 地址描述
60
+     */
61
+    private String address;
62
+}

+ 42
- 0
wm-patrol/src/main/java/com/water/patrol/entity/dto/IssueReportVO.java Прегледај датотеку

@@ -0,0 +1,42 @@
1
+package com.water.patrol.entity.dto;
2
+
3
+import com.water.patrol.entity.IssueWorkOrder;
4
+import com.water.patrol.entity.PatrolIssue;
5
+import lombok.Data;
6
+
7
+import java.util.List;
8
+
9
+/**
10
+ * 问题上报详情 VO (包含工单联动信息)
11
+ */
12
+@Data
13
+public class IssueReportVO {
14
+
15
+    /**
16
+     * 问题信息
17
+     */
18
+    private PatrolIssue issue;
19
+
20
+    /**
21
+     * 分类名称
22
+     */
23
+    private String categoryName;
24
+
25
+    /**
26
+     * 关联工单
27
+     */
28
+    private IssueWorkOrder workOrder;
29
+
30
+    /**
31
+     * 处理进度时间线
32
+     */
33
+    private List<IssueTimelineItem> timeline;
34
+
35
+    @Data
36
+    public static class IssueTimelineItem {
37
+        private String time;
38
+        private String action;
39
+        private String operator;
40
+        private String remark;
41
+    }
42
+}

+ 5
- 0
wm-patrol/src/main/java/com/water/patrol/entity/dto/PatrolIssueRequest.java Прегледај датотеку

@@ -12,6 +12,11 @@ public class PatrolIssueRequest {
12 12
      */
13 13
     private Long checkRecordId;
14 14
 
15
+    /**
16
+     * 问题分类ID
17
+     */
18
+    private Long categoryId;
19
+
15 20
     /**
16 21
      * 问题类型: leak/damage/pollution/illegal/other
17 22
      */

+ 27
- 0
wm-patrol/src/main/java/com/water/patrol/entity/dto/WorkOrderCompleteRequest.java Прегледај датотеку

@@ -0,0 +1,27 @@
1
+package com.water.patrol.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.util.List;
6
+
7
+/**
8
+ * 工单完成回执请求 DTO
9
+ */
10
+@Data
11
+public class WorkOrderCompleteRequest {
12
+
13
+    /**
14
+     * 处理结果描述
15
+     */
16
+    private String result;
17
+
18
+    /**
19
+     * 处理结果照片URL列表
20
+     */
21
+    private List<String> resultPhotos;
22
+
23
+    /**
24
+     * 备注
25
+     */
26
+    private String remark;
27
+}

+ 52
- 0
wm-patrol/src/main/java/com/water/patrol/entity/dto/WorkOrderCreateRequest.java Прегледај датотеку

@@ -0,0 +1,52 @@
1
+package com.water.patrol.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 工单创建请求 DTO
9
+ */
10
+@Data
11
+public class WorkOrderCreateRequest {
12
+
13
+    /**
14
+     * 关联问题ID
15
+     */
16
+    private Long issueId;
17
+
18
+    /**
19
+     * 工单标题
20
+     */
21
+    private String title;
22
+
23
+    /**
24
+     * 工单描述
25
+     */
26
+    private String description;
27
+
28
+    /**
29
+     * 优先级: low/medium/high/urgent
30
+     */
31
+    private String priority;
32
+
33
+    /**
34
+     * 处理人ID
35
+     */
36
+    private Long assigneeId;
37
+
38
+    /**
39
+     * 处理人姓名
40
+     */
41
+    private String assigneeName;
42
+
43
+    /**
44
+     * 期望完成时间
45
+     */
46
+    private LocalDateTime expectedCompleteTime;
47
+
48
+    /**
49
+     * 备注
50
+     */
51
+    private String remark;
52
+}

+ 19
- 0
wm-patrol/src/main/java/com/water/patrol/mapper/IssueCategoryMapper.java Прегледај датотеку

@@ -0,0 +1,19 @@
1
+package com.water.patrol.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.patrol.entity.IssueCategory;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+
11
+@Mapper
12
+public interface IssueCategoryMapper extends BaseMapper<IssueCategory> {
13
+
14
+    @Select("SELECT * FROM issue_category WHERE parent_id = #{parentId} AND deleted = 0 ORDER BY sort ASC")
15
+    List<IssueCategory> selectByParentId(@Param("parentId") Long parentId);
16
+
17
+    @Select("SELECT * FROM issue_category WHERE deleted = 0 ORDER BY sort ASC")
18
+    List<IssueCategory> selectAllActive();
19
+}

+ 25
- 0
wm-patrol/src/main/java/com/water/patrol/mapper/IssueWorkOrderMapper.java Прегледај датотеку

@@ -0,0 +1,25 @@
1
+package com.water.patrol.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.patrol.entity.IssueWorkOrder;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+import java.util.Map;
11
+
12
+@Mapper
13
+public interface IssueWorkOrderMapper extends BaseMapper<IssueWorkOrder> {
14
+
15
+    @Select("SELECT * FROM issue_work_order WHERE issue_id = #{issueId} AND deleted = 0 ORDER BY created_at DESC")
16
+    List<IssueWorkOrder> selectByIssueId(@Param("issueId") Long issueId);
17
+
18
+    @Select("SELECT status, COUNT(*) as count FROM issue_work_order " +
19
+            "WHERE deleted = 0 GROUP BY status")
20
+    List<Map<String, Object>> selectStatusDistribution();
21
+
22
+    @Select("SELECT COUNT(*) FROM issue_work_order WHERE assignee_id = #{assigneeId} " +
23
+            "AND status IN ('pending', 'dispatched', 'in_progress') AND deleted = 0")
24
+    int countActiveByAssignee(@Param("assigneeId") Long assigneeId);
25
+}

+ 23
- 0
wm-patrol/src/main/java/com/water/patrol/mapper/PatrolIssueMapper.java Прегледај датотеку

@@ -15,4 +15,27 @@ public interface PatrolIssueMapper extends BaseMapper<PatrolIssue> {
15 15
     @Select("SELECT issue_type, severity, COUNT(*) as count FROM patrol_issue " +
16 16
             "WHERE execution_id = #{executionId} AND deleted = 0 GROUP BY issue_type, severity")
17 17
     List<Map<String, Object>> selectIssueSummary(@Param("executionId") Long executionId);
18
+
19
+    @Select("SELECT COALESCE(c.name, '未分类') as category, COUNT(*) as count " +
20
+            "FROM patrol_issue i LEFT JOIN issue_category c ON i.category_id = c.id " +
21
+            "WHERE i.deleted = 0 AND i.report_time >= #{startDate} AND i.report_time <= #{endDate} " +
22
+            "GROUP BY c.name ORDER BY count DESC")
23
+    List<Map<String, Object>> selectCategoryDistribution(@Param("startDate") String startDate,
24
+                                                          @Param("endDate") String endDate);
25
+
26
+    @Select("SELECT COALESCE(c.name, '未分类') as category, COUNT(*) as count " +
27
+            "FROM patrol_issue i LEFT JOIN issue_category c ON i.category_id = c.id " +
28
+            "WHERE i.deleted = 0 GROUP BY c.name ORDER BY count DESC")
29
+    List<Map<String, Object>> selectCategoryDistributionAll();
30
+
31
+    @Select("SELECT DATE_TRUNC('day', report_time) as date, COUNT(*) as count " +
32
+            "FROM patrol_issue WHERE deleted = 0 " +
33
+            "AND report_time >= #{startDate} AND report_time <= #{endDate} " +
34
+            "GROUP BY DATE_TRUNC('day', report_time) ORDER BY date")
35
+    List<Map<String, Object>> selectDailyTrend(@Param("startDate") String startDate,
36
+                                                @Param("endDate") String endDate);
37
+
38
+    @Select("SELECT severity, COUNT(*) as count FROM patrol_issue " +
39
+            "WHERE deleted = 0 GROUP BY severity")
40
+    List<Map<String, Object>> selectSeverityDistribution();
18 41
 }

+ 247
- 0
wm-patrol/src/main/java/com/water/patrol/service/IssueReportService.java Прегледај датотеку

@@ -0,0 +1,247 @@
1
+package com.water.patrol.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.patrol.entity.IssueCategory;
6
+import com.water.patrol.entity.IssueWorkOrder;
7
+import com.water.patrol.entity.PatrolExecution;
8
+import com.water.patrol.entity.PatrolIssue;
9
+import com.water.patrol.entity.dto.IssueReportRequest;
10
+import com.water.patrol.entity.dto.IssueReportVO;
11
+import com.water.patrol.mapper.IssueCategoryMapper;
12
+import com.water.patrol.mapper.IssueWorkOrderMapper;
13
+import com.water.patrol.mapper.PatrolIssueMapper;
14
+import lombok.RequiredArgsConstructor;
15
+import lombok.extern.slf4j.Slf4j;
16
+import org.springframework.stereotype.Service;
17
+import org.springframework.transaction.annotation.Transactional;
18
+
19
+import java.time.LocalDateTime;
20
+import java.util.ArrayList;
21
+import java.util.List;
22
+
23
+@Slf4j
24
+@Service
25
+@RequiredArgsConstructor
26
+public class IssueReportService {
27
+
28
+    private final PatrolIssueMapper issueMapper;
29
+    private final IssueCategoryMapper categoryMapper;
30
+    private final IssueWorkOrderMapper workOrderMapper;
31
+    private final ExecutionService executionService;
32
+
33
+    /**
34
+     * 上报问题(增强版,支持分类)
35
+     */
36
+    @Transactional
37
+    public PatrolIssue reportIssue(IssueReportRequest request) {
38
+        Long executionId = request.getExecutionId();
39
+        if (executionId == null) {
40
+            throw new RuntimeException("执行ID不能为空");
41
+        }
42
+
43
+        PatrolExecution execution = executionService.getExecution(executionId);
44
+        if (!"in_progress".equals(execution.getStatus())) {
45
+            throw new RuntimeException("巡检执行不在进行中状态,无法上报问题");
46
+        }
47
+
48
+        PatrolIssue issue = new PatrolIssue();
49
+        issue.setExecutionId(executionId);
50
+        issue.setCheckRecordId(request.getCheckRecordId());
51
+        issue.setCategoryId(request.getCategoryId());
52
+        issue.setIssueType(request.getIssueType());
53
+        issue.setSeverity(request.getSeverity());
54
+        issue.setDescription(request.getDescription());
55
+        issue.setLng(request.getLng());
56
+        issue.setLat(request.getLat());
57
+        issue.setAddress(request.getAddress());
58
+        issue.setHandleStatus("pending");
59
+        issue.setReporterId(execution.getInspectorId());
60
+        issue.setReporterName(execution.getInspectorName());
61
+        issue.setReportTime(LocalDateTime.now());
62
+
63
+        if (request.getPhotoUrls() != null && !request.getPhotoUrls().isEmpty()) {
64
+            issue.setPhotoUrls(String.join(",", request.getPhotoUrls()));
65
+        }
66
+
67
+        issueMapper.insert(issue);
68
+        executionService.incrementIssueCount(executionId);
69
+
70
+        log.info("Issue reported: executionId={}, type={}, severity={}, categoryId={}",
71
+                executionId, request.getIssueType(), request.getSeverity(), request.getCategoryId());
72
+        return issue;
73
+    }
74
+
75
+    /**
76
+     * 获取问题详情(含工单信息)
77
+     */
78
+    public IssueReportVO getIssueDetail(Long issueId) {
79
+        PatrolIssue issue = issueMapper.selectById(issueId);
80
+        if (issue == null) {
81
+            throw new RuntimeException("问题不存在: " + issueId);
82
+        }
83
+
84
+        IssueReportVO vo = new IssueReportVO();
85
+        vo.setIssue(issue);
86
+
87
+        // 填充分类名称
88
+        if (issue.getCategoryId() != null) {
89
+            IssueCategory category = categoryMapper.selectById(issue.getCategoryId());
90
+            if (category != null) {
91
+                vo.setCategoryName(category.getName());
92
+            }
93
+        }
94
+
95
+        // 填充关联工单
96
+        List<IssueWorkOrder> workOrders = workOrderMapper.selectByIssueId(issueId);
97
+        if (!workOrders.isEmpty()) {
98
+            vo.setWorkOrder(workOrders.get(0)); // 取最新的工单
99
+        }
100
+
101
+        // 构建时间线
102
+        vo.setTimeline(buildTimeline(issue, workOrders));
103
+
104
+        return vo;
105
+    }
106
+
107
+    /**
108
+     * 分页查询问题列表
109
+     */
110
+    public Page<PatrolIssue> pageIssues(int current, int size, String issueType,
111
+                                         String handleStatus, String severity,
112
+                                         Long categoryId, Long executionId) {
113
+        LambdaQueryWrapper<PatrolIssue> wrapper = new LambdaQueryWrapper<>();
114
+        if (issueType != null && !issueType.isEmpty()) {
115
+            wrapper.eq(PatrolIssue::getIssueType, issueType);
116
+        }
117
+        if (handleStatus != null && !handleStatus.isEmpty()) {
118
+            wrapper.eq(PatrolIssue::getHandleStatus, handleStatus);
119
+        }
120
+        if (severity != null && !severity.isEmpty()) {
121
+            wrapper.eq(PatrolIssue::getSeverity, severity);
122
+        }
123
+        if (categoryId != null) {
124
+            wrapper.eq(PatrolIssue::getCategoryId, categoryId);
125
+        }
126
+        if (executionId != null) {
127
+            wrapper.eq(PatrolIssue::getExecutionId, executionId);
128
+        }
129
+        wrapper.orderByDesc(PatrolIssue::getReportTime);
130
+        return issueMapper.selectPage(new Page<>(current, size), wrapper);
131
+    }
132
+
133
+    /**
134
+     * 关闭问题
135
+     */
136
+    @Transactional
137
+    public PatrolIssue closeIssue(Long issueId, String remark) {
138
+        PatrolIssue issue = issueMapper.selectById(issueId);
139
+        if (issue == null) {
140
+            throw new RuntimeException("问题不存在: " + issueId);
141
+        }
142
+        issue.setHandleStatus("closed");
143
+        issue.setHandleRemark(remark);
144
+        issue.setHandleTime(LocalDateTime.now());
145
+        issueMapper.updateById(issue);
146
+        log.info("Issue closed: id={}, remark={}", issueId, remark);
147
+        return issue;
148
+    }
149
+
150
+    // ==================== 分类管理 ====================
151
+
152
+    /**
153
+     * 获取分类树
154
+     */
155
+    public List<IssueCategory> getCategoryTree() {
156
+        return categoryMapper.selectAllActive();
157
+    }
158
+
159
+    /**
160
+     * 获取子分类
161
+     */
162
+    public List<IssueCategory> getSubCategories(Long parentId) {
163
+        return categoryMapper.selectByParentId(parentId);
164
+    }
165
+
166
+    /**
167
+     * 创建分类
168
+     */
169
+    public IssueCategory createCategory(IssueCategory category) {
170
+        categoryMapper.insert(category);
171
+        log.info("Issue category created: name={}, code={}", category.getName(), category.getCode());
172
+        return category;
173
+    }
174
+
175
+    /**
176
+     * 更新分类
177
+     */
178
+    public boolean updateCategory(Long id, IssueCategory category) {
179
+        category.setId(id);
180
+        return categoryMapper.updateById(category) > 0;
181
+    }
182
+
183
+    /**
184
+     * 删除分类(逻辑删除)
185
+     */
186
+    public boolean deleteCategory(Long id) {
187
+        return categoryMapper.deleteById(id) > 0;
188
+    }
189
+
190
+    // ==================== 内部方法 ====================
191
+
192
+    private List<IssueReportVO.IssueTimelineItem> buildTimeline(PatrolIssue issue, List<IssueWorkOrder> workOrders) {
193
+        List<IssueReportVO.IssueTimelineItem> timeline = new ArrayList<>();
194
+
195
+        // 上报
196
+        IssueReportVO.IssueTimelineItem reportItem = new IssueReportVO.IssueTimelineItem();
197
+        reportItem.setTime(issue.getReportTime() != null ? issue.getReportTime().toString() : "");
198
+        reportItem.setAction("上报问题");
199
+        reportItem.setOperator(issue.getReporterName());
200
+        reportItem.setRemark(issue.getDescription());
201
+        timeline.add(reportItem);
202
+
203
+        // 工单创建
204
+        if (!workOrders.isEmpty()) {
205
+            IssueWorkOrder wo = workOrders.get(0);
206
+
207
+            IssueReportVO.IssueTimelineItem createItem = new IssueReportVO.IssueTimelineItem();
208
+            createItem.setTime(wo.getCreatedAt() != null ? wo.getCreatedAt().toString() : "");
209
+            createItem.setAction("创建工单");
210
+            createItem.setOperator("系统");
211
+            createItem.setRemark("工单编号: " + wo.getOrderNo());
212
+            timeline.add(createItem);
213
+
214
+            // 派单
215
+            if (wo.getAssigneeName() != null && !"pending".equals(wo.getStatus())) {
216
+                IssueReportVO.IssueTimelineItem dispatchItem = new IssueReportVO.IssueTimelineItem();
217
+                dispatchItem.setTime(wo.getCreatedAt() != null ? wo.getCreatedAt().toString() : "");
218
+                dispatchItem.setAction("派单");
219
+                dispatchItem.setOperator("系统");
220
+                dispatchItem.setRemark("处理人: " + wo.getAssigneeName());
221
+                timeline.add(dispatchItem);
222
+            }
223
+
224
+            // 完成
225
+            if ("completed".equals(wo.getStatus()) && wo.getActualCompleteTime() != null) {
226
+                IssueReportVO.IssueTimelineItem completeItem = new IssueReportVO.IssueTimelineItem();
227
+                completeItem.setTime(wo.getActualCompleteTime().toString());
228
+                completeItem.setAction("工单完成");
229
+                completeItem.setOperator(wo.getAssigneeName());
230
+                completeItem.setRemark(wo.getResult());
231
+                timeline.add(completeItem);
232
+            }
233
+        }
234
+
235
+        // 问题关闭
236
+        if ("closed".equals(issue.getHandleStatus()) && issue.getHandleTime() != null) {
237
+            IssueReportVO.IssueTimelineItem closeItem = new IssueReportVO.IssueTimelineItem();
238
+            closeItem.setTime(issue.getHandleTime().toString());
239
+            closeItem.setAction("关闭问题");
240
+            closeItem.setOperator(issue.getHandlerName());
241
+            closeItem.setRemark(issue.getHandleRemark());
242
+            timeline.add(closeItem);
243
+        }
244
+
245
+        return timeline;
246
+    }
247
+}

+ 1
- 0
wm-patrol/src/main/java/com/water/patrol/service/IssueService.java Прегледај датотеку

@@ -36,6 +36,7 @@ public class IssueService {
36 36
         PatrolIssue issue = new PatrolIssue();
37 37
         issue.setExecutionId(executionId);
38 38
         issue.setCheckRecordId(request.getCheckRecordId());
39
+        issue.setCategoryId(request.getCategoryId());
39 40
         issue.setIssueType(request.getIssueType());
40 41
         issue.setSeverity(request.getSeverity());
41 42
         issue.setDescription(request.getDescription());

+ 63
- 0
wm-patrol/src/main/java/com/water/patrol/service/IssueStatsService.java Прегледај датотеку

@@ -0,0 +1,63 @@
1
+package com.water.patrol.service;
2
+
3
+import com.water.patrol.mapper.IssueWorkOrderMapper;
4
+import com.water.patrol.mapper.PatrolIssueMapper;
5
+import lombok.RequiredArgsConstructor;
6
+import lombok.extern.slf4j.Slf4j;
7
+import org.springframework.stereotype.Service;
8
+
9
+import java.util.HashMap;
10
+import java.util.List;
11
+import java.util.Map;
12
+
13
+@Slf4j
14
+@Service
15
+@RequiredArgsConstructor
16
+public class IssueStatsService {
17
+
18
+    private final PatrolIssueMapper issueMapper;
19
+    private final IssueWorkOrderMapper workOrderMapper;
20
+
21
+    /**
22
+     * 问题分类分布统计
23
+     */
24
+    public List<Map<String, Object>> getCategoryDistribution(String startDate, String endDate) {
25
+        if (startDate != null && endDate != null) {
26
+            return issueMapper.selectCategoryDistribution(startDate, endDate);
27
+        }
28
+        return issueMapper.selectCategoryDistributionAll();
29
+    }
30
+
31
+    /**
32
+     * 问题趋势(按日)
33
+     */
34
+    public List<Map<String, Object>> getDailyTrend(String startDate, String endDate) {
35
+        return issueMapper.selectDailyTrend(startDate, endDate);
36
+    }
37
+
38
+    /**
39
+     * 问题严重程度分布
40
+     */
41
+    public List<Map<String, Object>> getSeverityDistribution() {
42
+        return issueMapper.selectSeverityDistribution();
43
+    }
44
+
45
+    /**
46
+     * 工单状态分布
47
+     */
48
+    public List<Map<String, Object>> getWorkOrderStatusDistribution() {
49
+        return workOrderMapper.selectStatusDistribution();
50
+    }
51
+
52
+    /**
53
+     * 综合统计看板
54
+     */
55
+    public Map<String, Object> getDashboard(String startDate, String endDate) {
56
+        Map<String, Object> dashboard = new HashMap<>();
57
+        dashboard.put("categoryDistribution", getCategoryDistribution(startDate, endDate));
58
+        dashboard.put("dailyTrend", getDailyTrend(startDate, endDate));
59
+        dashboard.put("severityDistribution", getSeverityDistribution());
60
+        dashboard.put("workOrderStatusDistribution", getWorkOrderStatusDistribution());
61
+        return dashboard;
62
+    }
63
+}

+ 254
- 0
wm-patrol/src/main/java/com/water/patrol/service/IssueTrackingService.java Прегледај датотеку

@@ -0,0 +1,254 @@
1
+package com.water.patrol.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.patrol.entity.IssueWorkOrder;
6
+import com.water.patrol.entity.PatrolIssue;
7
+import com.water.patrol.entity.dto.WorkOrderCompleteRequest;
8
+import com.water.patrol.entity.dto.WorkOrderCreateRequest;
9
+import com.water.patrol.mapper.IssueWorkOrderMapper;
10
+import com.water.patrol.mapper.PatrolIssueMapper;
11
+import lombok.RequiredArgsConstructor;
12
+import lombok.extern.slf4j.Slf4j;
13
+import org.springframework.stereotype.Service;
14
+import org.springframework.transaction.annotation.Transactional;
15
+
16
+import java.time.LocalDateTime;
17
+import java.time.format.DateTimeFormatter;
18
+import java.util.List;
19
+
20
+@Slf4j
21
+@Service
22
+@RequiredArgsConstructor
23
+public class IssueTrackingService {
24
+
25
+    private final IssueWorkOrderMapper workOrderMapper;
26
+    private final PatrolIssueMapper issueMapper;
27
+
28
+    /**
29
+     * 问题自动生成工单
30
+     */
31
+    @Transactional
32
+    public IssueWorkOrder createWorkOrder(WorkOrderCreateRequest request) {
33
+        Long issueId = request.getIssueId();
34
+        PatrolIssue issue = issueMapper.selectById(issueId);
35
+        if (issue == null) {
36
+            throw new RuntimeException("问题不存在: " + issueId);
37
+        }
38
+
39
+        // 检查是否已有活跃工单
40
+        List<IssueWorkOrder> existingOrders = workOrderMapper.selectByIssueId(issueId);
41
+        boolean hasActiveOrder = existingOrders.stream()
42
+                .anyMatch(wo -> !"completed".equals(wo.getStatus()) && !"cancelled".equals(wo.getStatus()));
43
+        if (hasActiveOrder) {
44
+            throw new RuntimeException("该问题已有进行中的工单,无法重复创建");
45
+        }
46
+
47
+        IssueWorkOrder workOrder = new IssueWorkOrder();
48
+        workOrder.setIssueId(issueId);
49
+        workOrder.setOrderNo(generateOrderNo());
50
+        workOrder.setTitle(request.getTitle() != null ? request.getTitle() :
51
+                "巡检问题处理 - " + issue.getIssueType());
52
+        workOrder.setDescription(request.getDescription() != null ? request.getDescription() :
53
+                issue.getDescription());
54
+        workOrder.setCategoryId(issue.getCategoryId());
55
+        workOrder.setPriority(request.getPriority() != null ? request.getPriority() :
56
+                mapSeverityToPriority(issue.getSeverity()));
57
+        workOrder.setStatus("pending");
58
+        workOrder.setAssigneeId(request.getAssigneeId());
59
+        workOrder.setAssigneeName(request.getAssigneeName());
60
+        workOrder.setExpectedCompleteTime(request.getExpectedCompleteTime());
61
+        workOrder.setRemark(request.getRemark());
62
+        workOrderMapper.insert(workOrder);
63
+
64
+        // 同步问题状态为处理中
65
+        issue.setHandleStatus("processing");
66
+        issue.setWorkOrderId(workOrder.getId());
67
+        issueMapper.updateById(issue);
68
+
69
+        log.info("Work order created: orderNo={}, issueId={}, priority={}",
70
+                workOrder.getOrderNo(), issueId, workOrder.getPriority());
71
+        return workOrder;
72
+    }
73
+
74
+    /**
75
+     * 自动根据问题创建工单(问题上报后自动触发)
76
+     */
77
+    @Transactional
78
+    public IssueWorkOrder autoCreateWorkOrder(Long issueId) {
79
+        PatrolIssue issue = issueMapper.selectById(issueId);
80
+        if (issue == null) {
81
+            throw new RuntimeException("问题不存在: " + issueId);
82
+        }
83
+
84
+        WorkOrderCreateRequest request = new WorkOrderCreateRequest();
85
+        request.setIssueId(issueId);
86
+        request.setTitle("巡检问题自动工单 - " + issue.getIssueType());
87
+        request.setDescription(issue.getDescription());
88
+        request.setPriority(mapSeverityToPriority(issue.getSeverity()));
89
+        return createWorkOrder(request);
90
+    }
91
+
92
+    /**
93
+     * 派单(指派处理人)
94
+     */
95
+    @Transactional
96
+    public IssueWorkOrder dispatchWorkOrder(Long workOrderId, Long assigneeId, String assigneeName) {
97
+        IssueWorkOrder workOrder = getWorkOrder(workOrderId);
98
+        if (!"pending".equals(workOrder.getStatus())) {
99
+            throw new RuntimeException("只有待处理工单才能派单");
100
+        }
101
+        workOrder.setAssigneeId(assigneeId);
102
+        workOrder.setAssigneeName(assigneeName);
103
+        workOrder.setStatus("dispatched");
104
+        workOrderMapper.updateById(workOrder);
105
+
106
+        log.info("Work order dispatched: id={}, assignee={}", workOrderId, assigneeName);
107
+        return workOrder;
108
+    }
109
+
110
+    /**
111
+     * 更新工单状态
112
+     */
113
+    @Transactional
114
+    public IssueWorkOrder updateWorkOrderStatus(Long workOrderId, String status) {
115
+        IssueWorkOrder workOrder = getWorkOrder(workOrderId);
116
+        workOrder.setStatus(status);
117
+        workOrderMapper.updateById(workOrder);
118
+
119
+        // 同步问题状态
120
+        syncIssueStatus(workOrder.getIssueId(), status);
121
+
122
+        log.info("Work order status updated: id={}, status={}", workOrderId, status);
123
+        return workOrder;
124
+    }
125
+
126
+    /**
127
+     * 工单完成回执
128
+     */
129
+    @Transactional
130
+    public IssueWorkOrder completeWorkOrder(Long workOrderId, WorkOrderCompleteRequest request) {
131
+        IssueWorkOrder workOrder = getWorkOrder(workOrderId);
132
+        if ("completed".equals(workOrder.getStatus())) {
133
+            throw new RuntimeException("工单已完成,无需重复操作");
134
+        }
135
+        if ("cancelled".equals(workOrder.getStatus())) {
136
+            throw new RuntimeException("工单已取消,无法完成");
137
+        }
138
+
139
+        workOrder.setStatus("completed");
140
+        workOrder.setActualCompleteTime(LocalDateTime.now());
141
+        workOrder.setResult(request.getResult());
142
+        workOrder.setRemark(request.getRemark());
143
+
144
+        if (request.getResultPhotos() != null && !request.getResultPhotos().isEmpty()) {
145
+            workOrder.setResultPhotos(String.join(",", request.getResultPhotos()));
146
+        }
147
+
148
+        workOrderMapper.updateById(workOrder);
149
+
150
+        // 同步问题状态为已解决
151
+        syncIssueStatus(workOrder.getIssueId(), "completed");
152
+
153
+        log.info("Work order completed: id={}, result={}", workOrderId, request.getResult());
154
+        return workOrder;
155
+    }
156
+
157
+    /**
158
+     * 取消工单
159
+     */
160
+    @Transactional
161
+    public IssueWorkOrder cancelWorkOrder(Long workOrderId, String reason) {
162
+        IssueWorkOrder workOrder = getWorkOrder(workOrderId);
163
+        if ("completed".equals(workOrder.getStatus())) {
164
+            throw new RuntimeException("已完成的工单无法取消");
165
+        }
166
+        workOrder.setStatus("cancelled");
167
+        workOrder.setRemark(reason);
168
+        workOrderMapper.updateById(workOrder);
169
+        log.info("Work order cancelled: id={}, reason={}", workOrderId, reason);
170
+        return workOrder;
171
+    }
172
+
173
+    /**
174
+     * 获取工单详情
175
+     */
176
+    public IssueWorkOrder getWorkOrder(Long workOrderId) {
177
+        IssueWorkOrder workOrder = workOrderMapper.selectById(workOrderId);
178
+        if (workOrder == null) {
179
+            throw new RuntimeException("工单不存在: " + workOrderId);
180
+        }
181
+        return workOrder;
182
+    }
183
+
184
+    /**
185
+     * 获取问题的所有工单
186
+     */
187
+    public List<IssueWorkOrder> getWorkOrdersByIssueId(Long issueId) {
188
+        return workOrderMapper.selectByIssueId(issueId);
189
+    }
190
+
191
+    /**
192
+     * 分页查询工单列表
193
+     */
194
+    public Page<IssueWorkOrder> pageWorkOrders(int current, int size, String status, Long assigneeId) {
195
+        LambdaQueryWrapper<IssueWorkOrder> wrapper = new LambdaQueryWrapper<>();
196
+        if (status != null && !status.isEmpty()) {
197
+            wrapper.eq(IssueWorkOrder::getStatus, status);
198
+        }
199
+        if (assigneeId != null) {
200
+            wrapper.eq(IssueWorkOrder::getAssigneeId, assigneeId);
201
+        }
202
+        wrapper.orderByDesc(IssueWorkOrder::getCreatedAt);
203
+        return workOrderMapper.selectPage(new Page<>(current, size), wrapper);
204
+    }
205
+
206
+    /**
207
+     * 获取处理人活跃工单数
208
+     */
209
+    public int getActiveWorkOrderCount(Long assigneeId) {
210
+        return workOrderMapper.countActiveByAssignee(assigneeId);
211
+    }
212
+
213
+    // ==================== 内部方法 ====================
214
+
215
+    private String generateOrderNo() {
216
+        String prefix = "WO";
217
+        String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
218
+        String random = String.valueOf((int) (Math.random() * 10000));
219
+        return prefix + timestamp + random;
220
+    }
221
+
222
+    private String mapSeverityToPriority(String severity) {
223
+        if (severity == null) return "medium";
224
+        switch (severity) {
225
+            case "critical": return "urgent";
226
+            case "high": return "high";
227
+            case "medium": return "medium";
228
+            case "low": return "low";
229
+            default: return "medium";
230
+        }
231
+    }
232
+
233
+    private void syncIssueStatus(Long issueId, String workOrderStatus) {
234
+        PatrolIssue issue = issueMapper.selectById(issueId);
235
+        if (issue == null) return;
236
+
237
+        switch (workOrderStatus) {
238
+            case "dispatched":
239
+            case "in_progress":
240
+                issue.setHandleStatus("processing");
241
+                break;
242
+            case "completed":
243
+                issue.setHandleStatus("resolved");
244
+                issue.setHandleTime(LocalDateTime.now());
245
+                break;
246
+            case "cancelled":
247
+                issue.setHandleStatus("pending");
248
+                break;
249
+            default:
250
+                break;
251
+        }
252
+        issueMapper.updateById(issue);
253
+    }
254
+}

+ 71
- 0
wm-patrol/src/main/resources/db/V3__patrol_issue.sql Прегледај датотеку

@@ -0,0 +1,71 @@
1
+-- ============================================================
2
+-- 问题上报 + 工单联动 DDL (V3)
3
+-- ============================================================
4
+
5
+-- 问题分类表
6
+CREATE TABLE IF NOT EXISTS issue_category (
7
+    id          BIGSERIAL PRIMARY KEY,
8
+    name        VARCHAR(100) NOT NULL,
9
+    code        VARCHAR(50)  NOT NULL,
10
+    parent_id   BIGINT       DEFAULT 0,
11
+    icon        VARCHAR(255),
12
+    description TEXT,
13
+    sort        INT          DEFAULT 0,
14
+    status      INT          DEFAULT 1,        -- 1=启用 0=停用
15
+    created_at  TIMESTAMP    DEFAULT NOW(),
16
+    updated_at  TIMESTAMP    DEFAULT NOW(),
17
+    deleted     INT          DEFAULT 0
18
+);
19
+CREATE UNIQUE INDEX IF NOT EXISTS idx_category_code ON issue_category(code) WHERE deleted = 0;
20
+
21
+-- 初始分类数据
22
+INSERT INTO issue_category (name, code, parent_id, icon, description, sort, status) VALUES
23
+    ('管道问题', 'PIPE',      0, 'pipe',      '管道相关问题', 1, 1),
24
+    ('漏水',     'LEAK',      1, 'leak',      '管道漏水/渗水', 1, 1),
25
+    ('爆管',     'BURST',     1, 'burst',     '管道爆裂', 2, 1),
26
+    ('堵塞',     'BLOCKAGE',  1, 'blockage',  '管道堵塞', 3, 1),
27
+    ('设施损坏', 'FACILITY',  0, 'facility',  '设施设备损坏', 2, 1),
28
+    ('阀门故障', 'VALVE',     5, 'valve',     '阀门损坏或故障', 1, 1),
29
+    ('水表故障', 'METER',     5, 'meter',     '水表损坏或读数异常', 2, 1),
30
+    ('环境问题', 'ENV',       0, 'env',       '环境相关问题', 3, 1),
31
+    ('水质污染', 'POLLUTION', 8, 'pollution', '水质异常或污染', 1, 1),
32
+    ('违规排放', 'ILLEGAL',   8, 'illegal',   '违规排水/排污', 2, 1),
33
+    ('其他问题', 'OTHER',     0, 'other',     '其他未分类问题', 99, 1)
34
+ON CONFLICT DO NOTHING;
35
+
36
+-- 工单关联表
37
+CREATE TABLE IF NOT EXISTS issue_work_order (
38
+    id                    BIGSERIAL PRIMARY KEY,
39
+    issue_id              BIGINT       NOT NULL REFERENCES patrol_issue(id),
40
+    order_no              VARCHAR(50)  NOT NULL,
41
+    title                 VARCHAR(200) NOT NULL,
42
+    description           TEXT,
43
+    category_id           BIGINT,
44
+    priority              VARCHAR(20)  DEFAULT 'medium',   -- low/medium/high/urgent
45
+    status                VARCHAR(20)  DEFAULT 'pending',  -- pending/dispatched/in_progress/completed/cancelled
46
+    assignee_id           BIGINT,
47
+    assignee_name         VARCHAR(50),
48
+    expected_complete_time TIMESTAMP,
49
+    actual_complete_time  TIMESTAMP,
50
+    result                TEXT,                            -- 处理结果描述
51
+    result_photos         TEXT,                            -- JSON数组: 处理结果照片
52
+    remark                TEXT,
53
+    created_at            TIMESTAMP    DEFAULT NOW(),
54
+    updated_at            TIMESTAMP    DEFAULT NOW(),
55
+    deleted               INT          DEFAULT 0
56
+);
57
+CREATE UNIQUE INDEX IF NOT EXISTS idx_work_order_no ON issue_work_order(order_no) WHERE deleted = 0;
58
+CREATE INDEX IF NOT EXISTS idx_work_order_issue ON issue_work_order(issue_id);
59
+CREATE INDEX IF NOT EXISTS idx_work_order_status ON issue_work_order(status);
60
+CREATE INDEX IF NOT EXISTS idx_work_order_assignee ON issue_work_order(assignee_id);
61
+
62
+-- 给 patrol_issue 表增强字段
63
+ALTER TABLE patrol_issue ADD COLUMN IF NOT EXISTS category_id    BIGINT;
64
+ALTER TABLE patrol_issue ADD COLUMN IF NOT EXISTS handle_remark  TEXT;
65
+ALTER TABLE patrol_issue ADD COLUMN IF NOT EXISTS handle_time    TIMESTAMP;
66
+ALTER TABLE patrol_issue ADD COLUMN IF NOT EXISTS handler_id     BIGINT;
67
+ALTER TABLE patrol_issue ADD COLUMN IF NOT EXISTS handler_name   VARCHAR(50);
68
+
69
+CREATE INDEX IF NOT EXISTS idx_issue_category ON patrol_issue(category_id);
70
+CREATE INDEX IF NOT EXISTS idx_issue_report_time ON patrol_issue(report_time);
71
+CREATE INDEX IF NOT EXISTS idx_issue_reporter ON patrol_issue(reporter_id);

+ 181
- 0
wm-patrol/src/test/java/com/water/patrol/service/IssueReportServiceTest.java Прегледај датотеку

@@ -0,0 +1,181 @@
1
+package com.water.patrol.service;
2
+
3
+import com.water.patrol.entity.IssueCategory;
4
+import com.water.patrol.entity.IssueWorkOrder;
5
+import com.water.patrol.entity.PatrolExecution;
6
+import com.water.patrol.entity.PatrolIssue;
7
+import com.water.patrol.entity.dto.IssueReportRequest;
8
+import com.water.patrol.entity.dto.IssueReportVO;
9
+import com.water.patrol.mapper.IssueCategoryMapper;
10
+import com.water.patrol.mapper.IssueWorkOrderMapper;
11
+import com.water.patrol.mapper.PatrolIssueMapper;
12
+import org.junit.jupiter.api.DisplayName;
13
+import org.junit.jupiter.api.Test;
14
+import org.junit.jupiter.api.extension.ExtendWith;
15
+import org.mockito.InjectMocks;
16
+import org.mockito.Mock;
17
+import org.mockito.junit.jupiter.MockitoExtension;
18
+
19
+import java.math.BigDecimal;
20
+import java.time.LocalDateTime;
21
+import java.util.Arrays;
22
+import java.util.Collections;
23
+import java.util.List;
24
+
25
+import static org.junit.jupiter.api.Assertions.*;
26
+import static org.mockito.ArgumentMatchers.any;
27
+import static org.mockito.Mockito.*;
28
+
29
+@ExtendWith(MockitoExtension.class)
30
+class IssueReportServiceTest {
31
+
32
+    @Mock
33
+    private PatrolIssueMapper issueMapper;
34
+
35
+    @Mock
36
+    private IssueCategoryMapper categoryMapper;
37
+
38
+    @Mock
39
+    private IssueWorkOrderMapper workOrderMapper;
40
+
41
+    @Mock
42
+    private ExecutionService executionService;
43
+
44
+    @InjectMocks
45
+    private IssueReportService issueReportService;
46
+
47
+    @Test
48
+    @DisplayName("上报问题 - 带分类的正常上报")
49
+    void reportIssue_withCategory_success() {
50
+        PatrolExecution execution = buildInProgressExecution();
51
+        when(executionService.getExecution(1L)).thenReturn(execution);
52
+        when(issueMapper.insert(any(PatrolIssue.class))).thenReturn(1);
53
+
54
+        IssueReportRequest request = new IssueReportRequest();
55
+        request.setExecutionId(1L);
56
+        request.setCategoryId(2L);
57
+        request.setIssueType("leak");
58
+        request.setSeverity("high");
59
+        request.setDescription("发现管道漏水");
60
+        request.setLng(86.6135);
61
+        request.setLat(44.8257);
62
+        request.setAddress("XX路");
63
+        request.setPhotoUrls(Arrays.asList("http://img.example.com/1.jpg"));
64
+
65
+        PatrolIssue result = issueReportService.reportIssue(request);
66
+
67
+        assertNotNull(result);
68
+        assertEquals(2L, result.getCategoryId());
69
+        assertEquals("leak", result.getIssueType());
70
+        assertEquals("high", result.getSeverity());
71
+        assertEquals("pending", result.getHandleStatus());
72
+        verify(executionService).incrementIssueCount(1L);
73
+    }
74
+
75
+    @Test
76
+    @DisplayName("上报问题 - 执行ID为空应抛异常")
77
+    void reportIssue_nullExecutionId_throwsException() {
78
+        IssueReportRequest request = new IssueReportRequest();
79
+        request.setIssueType("leak");
80
+
81
+        RuntimeException ex = assertThrows(RuntimeException.class,
82
+                () -> issueReportService.reportIssue(request));
83
+        assertTrue(ex.getMessage().contains("执行ID不能为空"));
84
+    }
85
+
86
+    @Test
87
+    @DisplayName("获取问题详情 - 含分类和工单信息")
88
+    void getIssueDetail_withCategoryAndWorkOrder() {
89
+        PatrolIssue issue = buildSampleIssue();
90
+        when(issueMapper.selectById(1L)).thenReturn(issue);
91
+
92
+        IssueCategory category = new IssueCategory();
93
+        category.setId(2L);
94
+        category.setName("漏水");
95
+        when(categoryMapper.selectById(2L)).thenReturn(category);
96
+
97
+        IssueWorkOrder workOrder = new IssueWorkOrder();
98
+        workOrder.setId(1L);
99
+        workOrder.setIssueId(1L);
100
+        workOrder.setOrderNo("WO202606140001");
101
+        workOrder.setStatus("in_progress");
102
+        when(workOrderMapper.selectByIssueId(1L)).thenReturn(Collections.singletonList(workOrder));
103
+
104
+        IssueReportVO vo = issueReportService.getIssueDetail(1L);
105
+
106
+        assertNotNull(vo);
107
+        assertEquals(issue, vo.getIssue());
108
+        assertEquals("漏水", vo.getCategoryName());
109
+        assertNotNull(vo.getWorkOrder());
110
+        assertEquals("WO202606140001", vo.getWorkOrder().getOrderNo());
111
+        assertNotNull(vo.getTimeline());
112
+        assertTrue(vo.getTimeline().size() >= 2);
113
+    }
114
+
115
+    @Test
116
+    @DisplayName("关闭问题 - 状态更新为closed")
117
+    void closeIssue_success() {
118
+        PatrolIssue issue = buildSampleIssue();
119
+        issue.setHandleStatus("resolved");
120
+        when(issueMapper.selectById(1L)).thenReturn(issue);
121
+        when(issueMapper.updateById(any(PatrolIssue.class))).thenReturn(1);
122
+
123
+        PatrolIssue result = issueReportService.closeIssue(1L, "已处理完毕");
124
+
125
+        assertEquals("closed", result.getHandleStatus());
126
+        assertEquals("已处理完毕", result.getHandleRemark());
127
+        assertNotNull(result.getHandleTime());
128
+    }
129
+
130
+    @Test
131
+    @DisplayName("获取分类树 - 返回所有启用的分类")
132
+    void getCategoryTree_success() {
133
+        IssueCategory cat1 = new IssueCategory();
134
+        cat1.setId(1L);
135
+        cat1.setName("管道问题");
136
+        cat1.setParentId(0L);
137
+
138
+        IssueCategory cat2 = new IssueCategory();
139
+        cat2.setId(2L);
140
+        cat2.setName("漏水");
141
+        cat2.setParentId(1L);
142
+
143
+        when(categoryMapper.selectAllActive()).thenReturn(Arrays.asList(cat1, cat2));
144
+
145
+        List<IssueCategory> tree = issueReportService.getCategoryTree();
146
+
147
+        assertNotNull(tree);
148
+        assertEquals(2, tree.size());
149
+        assertEquals("管道问题", tree.get(0).getName());
150
+    }
151
+
152
+    private PatrolExecution buildInProgressExecution() {
153
+        PatrolExecution execution = new PatrolExecution();
154
+        execution.setId(1L);
155
+        execution.setTaskId(1L);
156
+        execution.setInspectorId(100L);
157
+        execution.setInspectorName("张三");
158
+        execution.setStatus("in_progress");
159
+        execution.setStartTime(LocalDateTime.now());
160
+        execution.setTotalCheckItems(10);
161
+        execution.setCompletedCheckItems(0);
162
+        execution.setIssueCount(0);
163
+        execution.setTotalDistance(BigDecimal.ZERO);
164
+        return execution;
165
+    }
166
+
167
+    private PatrolIssue buildSampleIssue() {
168
+        PatrolIssue issue = new PatrolIssue();
169
+        issue.setId(1L);
170
+        issue.setExecutionId(1L);
171
+        issue.setCategoryId(2L);
172
+        issue.setIssueType("leak");
173
+        issue.setSeverity("high");
174
+        issue.setDescription("发现管道漏水");
175
+        issue.setHandleStatus("processing");
176
+        issue.setReporterId(100L);
177
+        issue.setReporterName("张三");
178
+        issue.setReportTime(LocalDateTime.now());
179
+        return issue;
180
+    }
181
+}

+ 77
- 0
wm-patrol/src/test/java/com/water/patrol/service/IssueStatsServiceTest.java Прегледај датотеку

@@ -0,0 +1,77 @@
1
+package com.water.patrol.service;
2
+
3
+import com.water.patrol.mapper.IssueWorkOrderMapper;
4
+import com.water.patrol.mapper.PatrolIssueMapper;
5
+import org.junit.jupiter.api.DisplayName;
6
+import org.junit.jupiter.api.Test;
7
+import org.junit.jupiter.api.extension.ExtendWith;
8
+import org.mockito.InjectMocks;
9
+import org.mockito.Mock;
10
+import org.mockito.junit.jupiter.MockitoExtension;
11
+
12
+import java.util.*;
13
+
14
+import static org.junit.jupiter.api.Assertions.*;
15
+import static org.mockito.Mockito.*;
16
+
17
+@ExtendWith(MockitoExtension.class)
18
+class IssueStatsServiceTest {
19
+
20
+    @Mock
21
+    private PatrolIssueMapper issueMapper;
22
+
23
+    @Mock
24
+    private IssueWorkOrderMapper workOrderMapper;
25
+
26
+    @InjectMocks
27
+    private IssueStatsService issueStatsService;
28
+
29
+    @Test
30
+    @DisplayName("分类分布 - 带日期范围")
31
+    void getCategoryDistribution_withDateRange() {
32
+        List<Map<String, Object>> mockData = new ArrayList<>();
33
+        Map<String, Object> item = new HashMap<>();
34
+        item.put("category", "管道问题");
35
+        item.put("count", 15L);
36
+        mockData.add(item);
37
+
38
+        when(issueMapper.selectCategoryDistribution("2026-01-01", "2026-06-30"))
39
+                .thenReturn(mockData);
40
+
41
+        List<Map<String, Object>> result = issueStatsService.getCategoryDistribution("2026-01-01", "2026-06-30");
42
+
43
+        assertNotNull(result);
44
+        assertEquals(1, result.size());
45
+        assertEquals("管道问题", result.get(0).get("category"));
46
+        assertEquals(15L, result.get(0).get("count"));
47
+    }
48
+
49
+    @Test
50
+    @DisplayName("分类分布 - 无日期范围查全部")
51
+    void getCategoryDistribution_withoutDateRange() {
52
+        List<Map<String, Object>> mockData = new ArrayList<>();
53
+        when(issueMapper.selectCategoryDistributionAll()).thenReturn(mockData);
54
+
55
+        List<Map<String, Object>> result = issueStatsService.getCategoryDistribution(null, null);
56
+
57
+        assertNotNull(result);
58
+        verify(issueMapper).selectCategoryDistributionAll();
59
+    }
60
+
61
+    @Test
62
+    @DisplayName("综合看板 - 包含所有统计维度")
63
+    void getDashboard_containsAllDimensions() {
64
+        when(issueMapper.selectCategoryDistributionAll()).thenReturn(Collections.emptyList());
65
+        when(issueMapper.selectDailyTrend(anyString(), anyString())).thenReturn(Collections.emptyList());
66
+        when(issueMapper.selectSeverityDistribution()).thenReturn(Collections.emptyList());
67
+        when(workOrderMapper.selectStatusDistribution()).thenReturn(Collections.emptyList());
68
+
69
+        Map<String, Object> dashboard = issueStatsService.getDashboard(null, null);
70
+
71
+        assertNotNull(dashboard);
72
+        assertTrue(dashboard.containsKey("categoryDistribution"));
73
+        assertTrue(dashboard.containsKey("dailyTrend"));
74
+        assertTrue(dashboard.containsKey("severityDistribution"));
75
+        assertTrue(dashboard.containsKey("workOrderStatusDistribution"));
76
+    }
77
+}

+ 197
- 0
wm-patrol/src/test/java/com/water/patrol/service/IssueTrackingServiceTest.java Прегледај датотеку

@@ -0,0 +1,197 @@
1
+package com.water.patrol.service;
2
+
3
+import com.water.patrol.entity.IssueWorkOrder;
4
+import com.water.patrol.entity.PatrolIssue;
5
+import com.water.patrol.entity.dto.WorkOrderCompleteRequest;
6
+import com.water.patrol.entity.dto.WorkOrderCreateRequest;
7
+import com.water.patrol.mapper.IssueWorkOrderMapper;
8
+import com.water.patrol.mapper.PatrolIssueMapper;
9
+import org.junit.jupiter.api.DisplayName;
10
+import org.junit.jupiter.api.Test;
11
+import org.junit.jupiter.api.extension.ExtendWith;
12
+import org.mockito.ArgumentCaptor;
13
+import org.mockito.InjectMocks;
14
+import org.mockito.Mock;
15
+import org.mockito.junit.jupiter.MockitoExtension;
16
+
17
+import java.time.LocalDateTime;
18
+import java.util.Arrays;
19
+import java.util.Collections;
20
+import java.util.List;
21
+
22
+import static org.junit.jupiter.api.Assertions.*;
23
+import static org.mockito.ArgumentMatchers.any;
24
+import static org.mockito.Mockito.*;
25
+
26
+@ExtendWith(MockitoExtension.class)
27
+class IssueTrackingServiceTest {
28
+
29
+    @Mock
30
+    private IssueWorkOrderMapper workOrderMapper;
31
+
32
+    @Mock
33
+    private PatrolIssueMapper issueMapper;
34
+
35
+    @InjectMocks
36
+    private IssueTrackingService issueTrackingService;
37
+
38
+    @Test
39
+    @DisplayName("创建工单 - 正常创建并关联问题")
40
+    void createWorkOrder_success() {
41
+        PatrolIssue issue = buildSampleIssue();
42
+        when(issueMapper.selectById(1L)).thenReturn(issue);
43
+        when(workOrderMapper.selectByIssueId(1L)).thenReturn(Collections.emptyList());
44
+        when(workOrderMapper.insert(any(IssueWorkOrder.class))).thenReturn(1);
45
+        when(issueMapper.updateById(any(PatrolIssue.class))).thenReturn(1);
46
+
47
+        WorkOrderCreateRequest request = new WorkOrderCreateRequest();
48
+        request.setIssueId(1L);
49
+        request.setTitle("管道漏水维修");
50
+        request.setDescription("XX路管道漏水");
51
+        request.setPriority("high");
52
+        request.setAssigneeId(200L);
53
+        request.setAssigneeName("李四");
54
+
55
+        IssueWorkOrder result = issueTrackingService.createWorkOrder(request);
56
+
57
+        assertNotNull(result);
58
+        assertEquals(1L, result.getIssueId());
59
+        assertEquals("管道漏水维修", result.getTitle());
60
+        assertEquals("high", result.getPriority());
61
+        assertEquals("pending", result.getStatus());
62
+        assertNotNull(result.getOrderNo());
63
+        assertTrue(result.getOrderNo().startsWith("WO"));
64
+
65
+        // 验证问题状态已同步
66
+        ArgumentCaptor<PatrolIssue> issueCaptor = ArgumentCaptor.forClass(PatrolIssue.class);
67
+        verify(issueMapper).updateById(issueCaptor.capture());
68
+        assertEquals("processing", issueCaptor.getValue().getHandleStatus());
69
+    }
70
+
71
+    @Test
72
+    @DisplayName("创建工单 - 已有活跃工单应拒绝")
73
+    void createWorkOrder_hasActiveOrder_throwsException() {
74
+        PatrolIssue issue = buildSampleIssue();
75
+        when(issueMapper.selectById(1L)).thenReturn(issue);
76
+
77
+        IssueWorkOrder existingOrder = new IssueWorkOrder();
78
+        existingOrder.setId(1L);
79
+        existingOrder.setIssueId(1L);
80
+        existingOrder.setStatus("in_progress");
81
+        when(workOrderMapper.selectByIssueId(1L)).thenReturn(Collections.singletonList(existingOrder));
82
+
83
+        WorkOrderCreateRequest request = new WorkOrderCreateRequest();
84
+        request.setIssueId(1L);
85
+
86
+        RuntimeException ex = assertThrows(RuntimeException.class,
87
+                () -> issueTrackingService.createWorkOrder(request));
88
+        assertTrue(ex.getMessage().contains("已有进行中的工单"));
89
+    }
90
+
91
+    @Test
92
+    @DisplayName("派单 - 状态更新为dispatched")
93
+    void dispatchWorkOrder_success() {
94
+        IssueWorkOrder workOrder = buildPendingWorkOrder();
95
+        when(workOrderMapper.selectById(1L)).thenReturn(workOrder);
96
+        when(workOrderMapper.updateById(any(IssueWorkOrder.class))).thenReturn(1);
97
+
98
+        IssueWorkOrder result = issueTrackingService.dispatchWorkOrder(1L, 200L, "李四");
99
+
100
+        assertEquals("dispatched", result.getStatus());
101
+        assertEquals(200L, result.getAssigneeId());
102
+        assertEquals("李四", result.getAssigneeName());
103
+    }
104
+
105
+    @Test
106
+    @DisplayName("派单 - 非待处理工单应拒绝")
107
+    void dispatchWorkOrder_notPending_throwsException() {
108
+        IssueWorkOrder workOrder = buildPendingWorkOrder();
109
+        workOrder.setStatus("in_progress");
110
+        when(workOrderMapper.selectById(1L)).thenReturn(workOrder);
111
+
112
+        RuntimeException ex = assertThrows(RuntimeException.class,
113
+                () -> issueTrackingService.dispatchWorkOrder(1L, 200L, "李四"));
114
+        assertTrue(ex.getMessage().contains("只有待处理工单才能派单"));
115
+    }
116
+
117
+    @Test
118
+    @DisplayName("工单完成回执 - 状态同步到问题")
119
+    void completeWorkOrder_syncsIssueStatus() {
120
+        IssueWorkOrder workOrder = buildPendingWorkOrder();
121
+        workOrder.setStatus("in_progress");
122
+        when(workOrderMapper.selectById(1L)).thenReturn(workOrder);
123
+        when(workOrderMapper.updateById(any(IssueWorkOrder.class))).thenReturn(1);
124
+
125
+        PatrolIssue issue = buildSampleIssue();
126
+        when(issueMapper.selectById(1L)).thenReturn(issue);
127
+        when(issueMapper.updateById(any(PatrolIssue.class))).thenReturn(1);
128
+
129
+        WorkOrderCompleteRequest request = new WorkOrderCompleteRequest();
130
+        request.setResult("已修复漏水管道");
131
+        request.setResultPhotos(Arrays.asList("http://img.example.com/fixed.jpg"));
132
+
133
+        IssueWorkOrder result = issueTrackingService.completeWorkOrder(1L, request);
134
+
135
+        assertEquals("completed", result.getStatus());
136
+        assertEquals("已修复漏水管道", result.getResult());
137
+        assertNotNull(result.getActualCompleteTime());
138
+
139
+        // 验证问题状态同步为resolved
140
+        ArgumentCaptor<PatrolIssue> issueCaptor = ArgumentCaptor.forClass(PatrolIssue.class);
141
+        verify(issueMapper).updateById(issueCaptor.capture());
142
+        assertEquals("resolved", issueCaptor.getValue().getHandleStatus());
143
+    }
144
+
145
+    @Test
146
+    @DisplayName("取消工单 - 已完成工单不可取消")
147
+    void cancelWorkOrder_completed_throwsException() {
148
+        IssueWorkOrder workOrder = buildPendingWorkOrder();
149
+        workOrder.setStatus("completed");
150
+        when(workOrderMapper.selectById(1L)).thenReturn(workOrder);
151
+
152
+        RuntimeException ex = assertThrows(RuntimeException.class,
153
+                () -> issueTrackingService.cancelWorkOrder(1L, "不需要了"));
154
+        assertTrue(ex.getMessage().contains("已完成的工单无法取消"));
155
+    }
156
+
157
+    @Test
158
+    @DisplayName("自动创建工单 - 根据问题severity映射priority")
159
+    void autoCreateWorkOrder_mapsPriority() {
160
+        PatrolIssue issue = buildSampleIssue();
161
+        issue.setSeverity("critical");
162
+        when(issueMapper.selectById(1L)).thenReturn(issue);
163
+        when(workOrderMapper.selectByIssueId(1L)).thenReturn(Collections.emptyList());
164
+        when(workOrderMapper.insert(any(IssueWorkOrder.class))).thenReturn(1);
165
+        when(issueMapper.updateById(any(PatrolIssue.class))).thenReturn(1);
166
+
167
+        IssueWorkOrder result = issueTrackingService.autoCreateWorkOrder(1L);
168
+
169
+        assertNotNull(result);
170
+        assertEquals("urgent", result.getPriority());
171
+    }
172
+
173
+    private PatrolIssue buildSampleIssue() {
174
+        PatrolIssue issue = new PatrolIssue();
175
+        issue.setId(1L);
176
+        issue.setExecutionId(1L);
177
+        issue.setIssueType("leak");
178
+        issue.setSeverity("high");
179
+        issue.setDescription("发现管道漏水");
180
+        issue.setHandleStatus("pending");
181
+        issue.setReporterId(100L);
182
+        issue.setReporterName("张三");
183
+        issue.setReportTime(LocalDateTime.now());
184
+        return issue;
185
+    }
186
+
187
+    private IssueWorkOrder buildPendingWorkOrder() {
188
+        IssueWorkOrder workOrder = new IssueWorkOrder();
189
+        workOrder.setId(1L);
190
+        workOrder.setIssueId(1L);
191
+        workOrder.setOrderNo("WO202606140001");
192
+        workOrder.setTitle("管道漏水维修");
193
+        workOrder.setStatus("pending");
194
+        workOrder.setPriority("high");
195
+        return workOrder;
196
+    }
197
+}