Преглед на файлове

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 преди 5 дни
родител
ревизия
6328788271

+ 47
- 0
wm-patrol/src/main/java/com/water/patrol/controller/PatrolIssueLinkageController.java Целия файл

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 Целия файл

7
 @Slf4j @Service @RequiredArgsConstructor
7
 @Slf4j @Service @RequiredArgsConstructor
8
 public class PatrolAppIssueService {
8
 public class PatrolAppIssueService {
9
     private final PatrolIssueReportMapper mapper;
9
     private final PatrolIssueReportMapper mapper;
10
+    private final PatrolIssueLinkageService linkageService;
10
     public PatrolIssueReport submit(Long taskId, Long reporterId, String reporterName,
11
     public PatrolIssueReport submit(Long taskId, Long reporterId, String reporterName,
11
             String issueType, String description, String photos, String videos,
12
             String issueType, String description, String photos, String videos,
12
             BigDecimal lng, BigDecimal lat, String address, String severity) {
13
             BigDecimal lng, BigDecimal lat, String address, String severity) {
18
         r.setLng(lng); r.setLat(lat); r.setAddress(address);
19
         r.setLng(lng); r.setLat(lat); r.setAddress(address);
19
         r.setSeverity(severity != null ? severity : "medium");
20
         r.setSeverity(severity != null ? severity : "medium");
20
         r.setStatus("submitted");
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
     public Map<String, Object> myReports(Long reporterId) {
31
     public Map<String, Object> myReports(Long reporterId) {
24
         Map<String, Object> r = new LinkedHashMap<>();
32
         Map<String, Object> r = new LinkedHashMap<>();

+ 176
- 0
wm-patrol/src/main/java/com/water/patrol/service/PatrolIssueLinkageService.java Целия файл

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 Целия файл

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
+}