Browse Source

feat(wm-patrol): #77 巡检问题上报 + 工单联动(基于 master 真实表)

用 Java 在 wm-patrol 模块实现,替代原放错技术栈的 Python 实现(src/patrol/*.py)。

核心改动:

- PatrolIssueLinkageService: 问题上报→自动创建维修工单(workOrderId回填) + 状态同步 + 分类统计 + 处理时效分析

- PatrolIssueLinkageController: GET /api/patrol/issue-linkage/{by-work-order,stats/by-type,stats/processing-time}

- PatrolAppIssueService.submit 增强: 上报后自动联动创建工单

- 复用 master 现有 PatrolWorkOrderService/PatrolIssueReportMapper/真实表 pat_issue_report+pat_work_order

- 专项单元测试 PatrolIssueLinkageServiceTest: 联动创建+严重度映射+状态同步+统计+时效分析(覆盖 PM 反复要求的巡检/工单专项测试)

分支重建为基于 master 的干净单提交,清除了原 30b24863(Python) 及 3 个无关 IoT/AI 污染提交。符合设计文档 7.2。
bot_dev3 2 days ago
parent
commit
6328788271

+ 47
- 0
wm-patrol/src/main/java/com/water/patrol/controller/PatrolIssueLinkageController.java View File

@@ -0,0 +1,47 @@
1
+package com.water.patrol.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.patrol.entity.PatrolIssueReport;
5
+import com.water.patrol.entity.PatrolWorkOrder;
6
+import com.water.patrol.service.PatrolIssueLinkageService;
7
+import io.swagger.v3.oas.annotations.Operation;
8
+import io.swagger.v3.oas.annotations.tags.Tag;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.web.bind.annotation.GetMapping;
11
+import org.springframework.web.bind.annotation.PathVariable;
12
+import org.springframework.web.bind.annotation.RequestMapping;
13
+import org.springframework.web.bind.annotation.RestController;
14
+
15
+import java.util.Map;
16
+
17
+/**
18
+ * 巡检问题↔工单联动 API(对应 Issue #77)。
19
+ *
20
+ * 提供按工单查关联问题、问题分类统计、处理时效分析等端点。
21
+ */
22
+@Tag(name = "巡检问题-工单联动")
23
+@RestController
24
+@RequestMapping("/api/patrol/issue-linkage")
25
+@RequiredArgsConstructor
26
+public class PatrolIssueLinkageController {
27
+
28
+    private final PatrolIssueLinkageService linkageService;
29
+
30
+    @Operation(summary = "按工单ID查询关联的巡检问题")
31
+    @GetMapping("/by-work-order/{workOrderId}")
32
+    public R<PatrolIssueReport> byWorkOrder(@PathVariable Long workOrderId) {
33
+        return R.ok(linkageService.findByWorkOrder(workOrderId));
34
+    }
35
+
36
+    @Operation(summary = "问题分类统计")
37
+    @GetMapping("/stats/by-type")
38
+    public R<Map<String, Long>> statsByType() {
39
+        return R.ok(linkageService.countByType());
40
+    }
41
+
42
+    @Operation(summary = "处理时效分析(平均/最大处理时长)")
43
+    @GetMapping("/stats/processing-time")
44
+    public R<Map<String, Object>> processingTimeStats() {
45
+        return R.ok(linkageService.processingTimeStats());
46
+    }
47
+}

+ 9
- 1
wm-patrol/src/main/java/com/water/patrol/service/PatrolAppIssueService.java View File

@@ -7,6 +7,7 @@ import java.math.BigDecimal; import java.util.*;
7 7
 @Slf4j @Service @RequiredArgsConstructor
8 8
 public class PatrolAppIssueService {
9 9
     private final PatrolIssueReportMapper mapper;
10
+    private final PatrolIssueLinkageService linkageService;
10 11
     public PatrolIssueReport submit(Long taskId, Long reporterId, String reporterName,
11 12
             String issueType, String description, String photos, String videos,
12 13
             BigDecimal lng, BigDecimal lat, String address, String severity) {
@@ -18,7 +19,14 @@ public class PatrolAppIssueService {
18 19
         r.setLng(lng); r.setLat(lat); r.setAddress(address);
19 20
         r.setSeverity(severity != null ? severity : "medium");
20 21
         r.setStatus("submitted");
21
-        mapper.insert(r); return r;
22
+        mapper.insert(r);
23
+        // Issue #77:问题上报后自动联动创建维修工单
24
+        try {
25
+            linkageService.linkAndCreateWorkOrder(r);
26
+        } catch (Exception e) {
27
+            log.warn("问题 {} 自动创建工单失败: {}", r.getId(), e.getMessage());
28
+        }
29
+        return r;
22 30
     }
23 31
     public Map<String, Object> myReports(Long reporterId) {
24 32
         Map<String, Object> r = new LinkedHashMap<>();

+ 176
- 0
wm-patrol/src/main/java/com/water/patrol/service/PatrolIssueLinkageService.java View File

@@ -0,0 +1,176 @@
1
+package com.water.patrol.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.patrol.entity.PatrolIssueReport;
5
+import com.water.patrol.entity.PatrolWorkOrder;
6
+import com.water.patrol.mapper.PatrolIssueReportMapper;
7
+import lombok.RequiredArgsConstructor;
8
+import lombok.extern.slf4j.Slf4j;
9
+import org.springframework.stereotype.Service;
10
+
11
+import java.time.Duration;
12
+import java.time.LocalDateTime;
13
+import java.util.LinkedHashMap;
14
+import java.util.List;
15
+import java.util.Map;
16
+
17
+/**
18
+ * 巡检问题上报 ↔ 工单联动服务(对应 Issue #77)。
19
+ *
20
+ * <p>核心职责:
21
+ * <ul>
22
+ *   <li>问题上报时自动创建维修工单(issue_report.work_order_id 回填)</li>
23
+ *   <li>工单状态变更同步回问题状态</li>
24
+ *   <li>问题分类统计 + 处理时效分析</li>
25
+ * </ul>
26
+ *
27
+ * <p>基于 master 真实表 pat_issue_report / pat_work_order,复用已存在的
28
+ * {@link PatrolWorkOrderService} 与 {@link PatrolIssueReportMapper}。
29
+ */
30
+@Slf4j
31
+@Service
32
+@RequiredArgsConstructor
33
+public class PatrolIssueLinkageService {
34
+
35
+    private final PatrolIssueReportMapper issueMapper;
36
+    private final PatrolWorkOrderService workOrderService;
37
+
38
+    /**
39
+     * 为已上报的问题自动创建维修工单,并把工单ID回填到问题记录。
40
+     *
41
+     * @param issue 已插入的问题上报记录
42
+     * @return 关联的工单(issue.workOrderId 已回填)
43
+     */
44
+    public PatrolWorkOrder linkAndCreateWorkOrder(PatrolIssueReport issue) {
45
+        if (issue == null) {
46
+            throw new IllegalArgumentException("问题记录不能为空");
47
+        }
48
+        if (issue.getWorkOrderId() != null) {
49
+            log.info("问题 {} 已关联工单 {},跳过自动创建", issue.getId(), issue.getWorkOrderId());
50
+            return workOrderService.getDetail(issue.getWorkOrderId());
51
+        }
52
+
53
+        // 根据问题级别映射工单严重度
54
+        String severity = mapSeverity(issue.getSeverity());
55
+        PatrolWorkOrder wo = workOrderService.create(
56
+                issue.getTaskId(),
57
+                issue.getIssueType(),
58
+                issue.getDescription(),
59
+                issue.getPhotos(),
60
+                severity);
61
+
62
+        // 回填工单ID,状态流转为处理中
63
+        issue.setWorkOrderId(wo.getId());
64
+        issue.setStatus("processing");
65
+        issueMapper.updateById(issue);
66
+
67
+        log.info("问题 {} 已自动创建工单 {}", issue.getId(), wo.getOrderNo());
68
+        return wo;
69
+    }
70
+
71
+    /**
72
+     * 工单状态变更后同步问题状态。
73
+     *
74
+     * @param workOrderId 工单ID
75
+     * @param woStatus    工单新状态
76
+     */
77
+    public void syncIssueStatus(Long workOrderId, String woStatus) {
78
+        if (workOrderId == null) {
79
+            return;
80
+        }
81
+        PatrolIssueReport issue = findByWorkOrder(workOrderId);
82
+        if (issue == null) {
83
+            return;
84
+        }
85
+        String issueStatus = mapWorkOrderStatusToIssue(woStatus);
86
+        if (issueStatus != null && !issueStatus.equals(issue.getStatus())) {
87
+            issue.setStatus(issueStatus);
88
+            issueMapper.updateById(issue);
89
+            log.info("工单 {} 状态 {} 已同步至问题 {}", workOrderId, woStatus, issue.getId());
90
+        }
91
+    }
92
+
93
+    /**
94
+     * 根据工单ID查询关联的问题。
95
+     */
96
+    public PatrolIssueReport findByWorkOrder(Long workOrderId) {
97
+        LambdaQueryWrapper<PatrolIssueReport> w = new LambdaQueryWrapper<>();
98
+        w.eq(PatrolIssueReport::getWorkOrderId, workOrderId).last("LIMIT 1");
99
+        return issueMapper.selectOne(w);
100
+    }
101
+
102
+    /**
103
+     * 问题分类统计(按 issueType 分组计数)。
104
+     */
105
+    public Map<String, Long> countByType() {
106
+        Map<String, Long> result = new LinkedHashMap<>();
107
+        List<PatrolIssueReport> all = issueMapper.selectList(new LambdaQueryWrapper<>());
108
+        for (PatrolIssueReport issue : all) {
109
+            String type = issue.getIssueType() != null ? issue.getIssueType() : "unknown";
110
+            result.merge(type, 1L, Long::sum);
111
+        }
112
+        return result;
113
+    }
114
+
115
+    /**
116
+     * 处理时效分析:已关联工单且工单已解决的问题,统计平均处理时长(小时)。
117
+     *
118
+     * @return {count, avgHours, maxHours}
119
+     */
120
+    public Map<String, Object> processingTimeStats() {
121
+        LambdaQueryWrapper<PatrolIssueReport> w = new LambdaQueryWrapper<>();
122
+        w.isNotNull(PatrolIssueReport::getWorkOrderId);
123
+        List<PatrolIssueReport> linked = issueMapper.selectList(w);
124
+
125
+        long count = 0;
126
+        long totalSeconds = 0;
127
+        long maxSeconds = 0;
128
+        for (PatrolIssueReport issue : linked) {
129
+            PatrolWorkOrder wo = workOrderService.getDetail(issue.getWorkOrderId());
130
+            if (wo == null || wo.getResolvedAt() == null || issue.getCreatedAt() == null) {
131
+                continue;
132
+            }
133
+            long seconds = Duration.between(issue.getCreatedAt(), wo.getResolvedAt()).getSeconds();
134
+            totalSeconds += seconds;
135
+            maxSeconds = Math.max(maxSeconds, seconds);
136
+            count++;
137
+        }
138
+
139
+        Map<String, Object> stats = new LinkedHashMap<>();
140
+        stats.put("resolvedCount", count);
141
+        stats.put("avgHours", count == 0 ? 0.0 : totalSeconds / 3600.0 / count);
142
+        stats.put("maxHours", maxSeconds / 3600.0);
143
+        return stats;
144
+    }
145
+
146
+    /** 问题严重度 → 工单严重度映射(统一为中/英小写口径) */
147
+    private String mapSeverity(String issueSeverity) {
148
+        if (issueSeverity == null) {
149
+            return "medium";
150
+        }
151
+        return switch (issueSeverity.toLowerCase()) {
152
+            case "low" -> "low";
153
+            case "high", "urgent" -> "high";
154
+            default -> "medium";
155
+        };
156
+    }
157
+
158
+    /** 工单状态 → 问题状态映射 */
159
+    private String mapWorkOrderStatusToIssue(String woStatus) {
160
+        if (woStatus == null) {
161
+            return null;
162
+        }
163
+        return switch (woStatus) {
164
+            case "pending", "assigned" -> "processing";
165
+            case "in_progress" -> "processing";
166
+            case "resolved" -> "resolved";
167
+            case "closed" -> "closed";
168
+            default -> null;
169
+        };
170
+    }
171
+
172
+    // 提供给测试使用的当前时间点(便于扩展,此处直接用实体上的 createdAt)
173
+    LocalDateTime nowForTest() {
174
+        return LocalDateTime.now();
175
+    }
176
+}

+ 190
- 0
wm-patrol/src/test/java/com/water/patrol/service/PatrolIssueLinkageServiceTest.java View File

@@ -0,0 +1,190 @@
1
+package com.water.patrol.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
4
+import com.water.patrol.entity.PatrolIssueReport;
5
+import com.water.patrol.entity.PatrolWorkOrder;
6
+import com.water.patrol.mapper.PatrolIssueReportMapper;
7
+import org.junit.jupiter.api.Test;
8
+import org.junit.jupiter.api.extension.ExtendWith;
9
+import org.mockito.InjectMocks;
10
+import org.mockito.Mock;
11
+import org.mockito.junit.jupiter.MockitoExtension;
12
+
13
+import java.time.LocalDateTime;
14
+import java.util.List;
15
+import java.util.Map;
16
+
17
+import static org.junit.jupiter.api.Assertions.*;
18
+import static org.mockito.ArgumentMatchers.any;
19
+import static org.mockito.ArgumentMatchers.eq;
20
+import static org.mockito.Mockito.*;
21
+
22
+/**
23
+ * PatrolIssueLinkageService 专项测试(对应 Issue #77,PM 反复要求的巡检/工单专项测试)。
24
+ *
25
+ * 覆盖:联动创建工单 + workOrderId 回填 + 状态同步 + 分类统计 + 时效分析。
26
+ */
27
+@ExtendWith(MockitoExtension.class)
28
+class PatrolIssueLinkageServiceTest {
29
+
30
+    @Mock PatrolIssueReportMapper issueMapper;
31
+    @Mock PatrolWorkOrderService workOrderService;
32
+
33
+    @InjectMocks PatrolIssueLinkageService service;
34
+
35
+    private PatrolIssueReport issue(Long id, String severity, Long workOrderId) {
36
+        PatrolIssueReport i = new PatrolIssueReport();
37
+        i.setId(id);
38
+        i.setTaskId(10L);
39
+        i.setIssueType("water_leak");
40
+        i.setDescription("管道漏水");
41
+        i.setSeverity(severity);
42
+        i.setWorkOrderId(workOrderId);
43
+        i.setStatus("submitted");
44
+        i.setCreatedAt(LocalDateTime.now().minusHours(2));
45
+        return i;
46
+    }
47
+
48
+    private PatrolWorkOrder wo(Long id, String status, LocalDateTime resolvedAt) {
49
+        PatrolWorkOrder w = new PatrolWorkOrder();
50
+        w.setId(id);
51
+        w.setOrderNo("PWO-" + id);
52
+        w.setStatus(status);
53
+        w.setResolvedAt(resolvedAt);
54
+        return w;
55
+    }
56
+
57
+    // ---------- 联动创建工单 ----------
58
+
59
+    @Test
60
+    void linkAndCreateWorkOrder_createsOrderAndBackfillsId() {
61
+        PatrolIssueReport i = issue(1L, "high", null);
62
+        PatrolWorkOrder created = wo(500L, "pending", null);
63
+        when(workOrderService.create(eq(10L), eq("water_leak"), eq("管道漏水"), isNull(), eq("high")))
64
+                .thenReturn(created);
65
+
66
+        PatrolWorkOrder result = service.linkAndCreateWorkOrder(i);
67
+
68
+        assertSame(created, result);
69
+        // workOrderId 被回填
70
+        assertEquals(500L, i.getWorkOrderId());
71
+        // 状态流转为处理中
72
+        assertEquals("processing", i.getStatus());
73
+        // 工单创建 + 问题更新均被调用
74
+        verify(workOrderService).create(any(), any(), any(), any(), any());
75
+        verify(issueMapper).updateById(i);
76
+    }
77
+
78
+    @Test
79
+    void linkAndCreateWorkOrder_severityMapping_urgentToHigh() {
80
+        PatrolIssueReport i = issue(2L, "urgent", null);
81
+        when(workOrderService.create(any(), any(), any(), any(), eq("high"))).thenReturn(wo(1L, "pending", null));
82
+
83
+        service.linkAndCreateWorkOrder(i);
84
+        // urgent 映射为 high
85
+        verify(workOrderService).create(any(), any(), any(), any(), eq("high"));
86
+    }
87
+
88
+    @Test
89
+    void linkAndCreateWorkOrder_severityMapping_lowStaysLow() {
90
+        PatrolIssueReport i = issue(3L, "low", null);
91
+        when(workOrderService.create(any(), any(), any(), any(), eq("low"))).thenReturn(wo(1L, "pending", null));
92
+
93
+        service.linkAndCreateWorkOrder(i);
94
+        verify(workOrderService).create(any(), any(), any(), any(), eq("low"));
95
+    }
96
+
97
+    @Test
98
+    void linkAndCreateWorkOrder_skipsIfAlreadyLinked() {
99
+        PatrolIssueReport i = issue(4L, "high", 999L); // 已有工单
100
+        when(workOrderService.getDetail(999L)).thenReturn(wo(999L, "assigned", null));
101
+
102
+        PatrolWorkOrder result = service.linkAndCreateWorkOrder(i);
103
+
104
+        assertEquals(999L, result.getId());
105
+        // 不应再创建新工单
106
+        verify(workOrderService, never()).create(any(), any(), any(), any(), any());
107
+        verify(issueMapper, never()).updateById(any());
108
+    }
109
+
110
+    @Test
111
+    void linkAndCreateWorkOrder_nullIssueThrows() {
112
+        assertThrows(IllegalArgumentException.class, () -> service.linkAndCreateWorkOrder(null));
113
+    }
114
+
115
+    // ---------- 状态同步 ----------
116
+
117
+    @Test
118
+    void syncIssueStatus_updatesIssueWhenWorkOrderResolved() {
119
+        PatrolIssueReport i = issue(5L, "high", 50L);
120
+        i.setStatus("processing");
121
+        when(issueMapper.selectOne(any(Wrapper.class))).thenReturn(i);
122
+
123
+        service.syncIssueStatus(50L, "resolved");
124
+
125
+        assertEquals("resolved", i.getStatus());
126
+        verify(issueMapper).updateById(i);
127
+    }
128
+
129
+    @Test
130
+    void syncIssueStatus_noopWhenNoLinkedIssue() {
131
+        when(issueMapper.selectOne(any(Wrapper.class))).thenReturn(null);
132
+        service.syncIssueStatus(999L, "resolved");
133
+        verify(issueMapper, never()).updateById(any());
134
+    }
135
+
136
+    // ---------- 分类统计 ----------
137
+
138
+    @Test
139
+    @SuppressWarnings("unchecked")
140
+    void countByType_groupsByIssueType() {
141
+        PatrolIssueReport a = issue(1L, "high", null); a.setIssueType("water_leak");
142
+        PatrolIssueReport b = issue(2L, "low", null); b.setIssueType("water_leak");
143
+        PatrolIssueReport c = issue(3L, "medium", null); c.setIssueType("equipment_failure");
144
+        when(issueMapper.selectList(any(Wrapper.class))).thenReturn(List.of(a, b, c));
145
+
146
+        Map<String, Long> stats = service.countByType();
147
+
148
+        assertEquals(2L, stats.get("water_leak"));
149
+        assertEquals(1L, stats.get("equipment_failure"));
150
+    }
151
+
152
+    // ---------- 时效分析 ----------
153
+
154
+    @Test
155
+    @SuppressWarnings("unchecked")
156
+    void processingTimeStats_computesAvgAndMax() {
157
+        LocalDateTime base = LocalDateTime.now().minusHours(5);
158
+        PatrolIssueReport i1 = issue(1L, "high", 100L);
159
+        i1.setCreatedAt(base);
160
+        PatrolIssueReport i2 = issue(2L, "low", 200L);
161
+        i2.setCreatedAt(base);
162
+
163
+        when(issueMapper.selectList(any(Wrapper.class))).thenReturn(List.of(i1, i2));
164
+        // 工单1:创建后2小时解决;工单2:创建后4小时解决
165
+        when(workOrderService.getDetail(100L)).thenReturn(wo(100L, "resolved", base.plusHours(2)));
166
+        when(workOrderService.getDetail(200L)).thenReturn(wo(200L, "resolved", base.plusHours(4)));
167
+
168
+        Map<String, Object> stats = service.processingTimeStats();
169
+
170
+        assertEquals(2L, stats.get("resolvedCount"));
171
+        // 平均 = (2+4)/2 = 3 小时
172
+        assertEquals(3.0, (double) stats.get("avgHours"), 0.01);
173
+        // 最大 = 4 小时
174
+        assertEquals(4.0, (double) stats.get("maxHours"), 0.01);
175
+    }
176
+
177
+    @Test
178
+    @SuppressWarnings("unchecked")
179
+    void processingTimeStats_skipsUnresolved() {
180
+        PatrolIssueReport i = issue(1L, "high", 100L);
181
+        when(issueMapper.selectList(any(Wrapper.class))).thenReturn(List.of(i));
182
+        // 工单未解决(resolvedAt=null)
183
+        when(workOrderService.getDetail(100L)).thenReturn(wo(100L, "in_progress", null));
184
+
185
+        Map<String, Object> stats = service.processingTimeStats();
186
+
187
+        assertEquals(0L, stats.get("resolvedCount"));
188
+        assertEquals(0.0, (double) stats.get("avgHours"), 0.01);
189
+    }
190
+}