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

feat(wm-patrol): #78 统计分析(执行率/里程/问题分类)

bot_dev2 пре 5 дана
родитељ
комит
6f3899a0c2

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

@@ -0,0 +1,176 @@
1
+package com.water.patrol.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.patrol.entity.dto.RankResult;
5
+import com.water.patrol.entity.dto.StatsQueryRequest;
6
+import com.water.patrol.entity.dto.StatsSummary;
7
+import com.water.patrol.service.PatrolRankService;
8
+import com.water.patrol.service.PatrolStatsService;
9
+import io.swagger.v3.oas.annotations.Operation;
10
+import io.swagger.v3.oas.annotations.tags.Tag;
11
+import lombok.RequiredArgsConstructor;
12
+import org.springframework.format.annotation.DateTimeFormat;
13
+import org.springframework.web.bind.annotation.*;
14
+
15
+import java.time.LocalDate;
16
+import java.util.List;
17
+import java.util.Map;
18
+
19
+/**
20
+ * 巡检统计分析 REST API
21
+ */
22
+@Tag(name = "巡检统计分析")
23
+@RestController
24
+@RequestMapping("/api/patrol/stats")
25
+@RequiredArgsConstructor
26
+public class StatsController {
27
+
28
+    private final PatrolStatsService statsService;
29
+    private final PatrolRankService rankService;
30
+
31
+    // ==================== 综合看板 ====================
32
+
33
+    @Operation(summary = "综合看板概览")
34
+    @GetMapping("/summary")
35
+    public R<StatsSummary> summary(
36
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
37
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
38
+        StatsQueryRequest request = new StatsQueryRequest();
39
+        request.setStartDate(startDate);
40
+        request.setEndDate(endDate);
41
+        return R.ok(statsService.dashboardSummary(request));
42
+    }
43
+
44
+    // ==================== 执行率统计 ====================
45
+
46
+    @Operation(summary = "执行率统计 - 按巡检员")
47
+    @GetMapping("/execution-rate/by-inspector")
48
+    public R<List<Map<String, Object>>> executionRateByInspector(
49
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
50
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
51
+        StatsQueryRequest request = new StatsQueryRequest();
52
+        request.setStartDate(startDate);
53
+        request.setEndDate(endDate);
54
+        return R.ok(statsService.executionRateByInspector(request));
55
+    }
56
+
57
+    @Operation(summary = "执行率统计 - 按路线")
58
+    @GetMapping("/execution-rate/by-route")
59
+    public R<List<Map<String, Object>>> executionRateByRoute(
60
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
61
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
62
+        StatsQueryRequest request = new StatsQueryRequest();
63
+        request.setStartDate(startDate);
64
+        request.setEndDate(endDate);
65
+        return R.ok(statsService.executionRateByRoute(request));
66
+    }
67
+
68
+    @Operation(summary = "执行率统计 - 按时间段")
69
+    @GetMapping("/execution-rate/by-period")
70
+    public R<List<Map<String, Object>>> executionRateByPeriod(
71
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
72
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
73
+        StatsQueryRequest request = new StatsQueryRequest();
74
+        request.setStartDate(startDate);
75
+        request.setEndDate(endDate);
76
+        return R.ok(statsService.executionRateByPeriod(request));
77
+    }
78
+
79
+    // ==================== 里程统计 ====================
80
+
81
+    @Operation(summary = "里程统计概览")
82
+    @GetMapping("/mileage/summary")
83
+    public R<Map<String, Object>> mileageSummary(
84
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
85
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
86
+        StatsQueryRequest request = new StatsQueryRequest();
87
+        request.setStartDate(startDate);
88
+        request.setEndDate(endDate);
89
+        return R.ok(statsService.mileageSummary(request));
90
+    }
91
+
92
+    @Operation(summary = "里程统计 - 按巡检员")
93
+    @GetMapping("/mileage/by-inspector")
94
+    public R<List<Map<String, Object>>> mileageByInspector(
95
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
96
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
97
+        StatsQueryRequest request = new StatsQueryRequest();
98
+        request.setStartDate(startDate);
99
+        request.setEndDate(endDate);
100
+        return R.ok(statsService.mileageByInspector(request));
101
+    }
102
+
103
+    @Operation(summary = "里程趋势(按天)")
104
+    @GetMapping("/mileage/trend")
105
+    public R<List<Map<String, Object>>> mileageTrend(
106
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
107
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
108
+        StatsQueryRequest request = new StatsQueryRequest();
109
+        request.setStartDate(startDate);
110
+        request.setEndDate(endDate);
111
+        return R.ok(statsService.mileageTrend(request));
112
+    }
113
+
114
+    // ==================== 问题分类统计 ====================
115
+
116
+    @Operation(summary = "问题类型分布")
117
+    @GetMapping("/issues/type-distribution")
118
+    public R<List<Map<String, Object>>> issueTypeDistribution(
119
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
120
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
121
+        StatsQueryRequest request = new StatsQueryRequest();
122
+        request.setStartDate(startDate);
123
+        request.setEndDate(endDate);
124
+        return R.ok(statsService.issueTypeDistribution(request));
125
+    }
126
+
127
+    @Operation(summary = "问题严重度分布")
128
+    @GetMapping("/issues/severity-distribution")
129
+    public R<List<Map<String, Object>>> issueSeverityDistribution(
130
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
131
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
132
+        StatsQueryRequest request = new StatsQueryRequest();
133
+        request.setStartDate(startDate);
134
+        request.setEndDate(endDate);
135
+        return R.ok(statsService.issueSeverityDistribution(request));
136
+    }
137
+
138
+    @Operation(summary = "问题解决率统计")
139
+    @GetMapping("/issues/resolution-rate")
140
+    public R<Map<String, Object>> issueResolutionRate(
141
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
142
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
143
+        StatsQueryRequest request = new StatsQueryRequest();
144
+        request.setStartDate(startDate);
145
+        request.setEndDate(endDate);
146
+        return R.ok(statsService.issueResolutionStats(request));
147
+    }
148
+
149
+    // ==================== 排名 ====================
150
+
151
+    @Operation(summary = "巡检员排名")
152
+    @GetMapping("/rank/inspectors")
153
+    public R<List<RankResult>> inspectorRank(
154
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
155
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
156
+            @RequestParam(required = false, defaultValue = "10") Integer topN) {
157
+        StatsQueryRequest request = new StatsQueryRequest();
158
+        request.setStartDate(startDate);
159
+        request.setEndDate(endDate);
160
+        request.setTopN(topN);
161
+        return R.ok(rankService.inspectorRank(request));
162
+    }
163
+
164
+    @Operation(summary = "路线排名")
165
+    @GetMapping("/rank/routes")
166
+    public R<List<RankResult>> routeRank(
167
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
168
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
169
+            @RequestParam(required = false, defaultValue = "10") Integer topN) {
170
+        StatsQueryRequest request = new StatsQueryRequest();
171
+        request.setStartDate(startDate);
172
+        request.setEndDate(endDate);
173
+        request.setTopN(topN);
174
+        return R.ok(rankService.routeRank(request));
175
+    }
176
+}

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

@@ -0,0 +1,62 @@
1
+package com.water.patrol.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.math.BigDecimal;
6
+
7
+/**
8
+ * 排名结果 DTO
9
+ */
10
+@Data
11
+public class RankResult {
12
+
13
+    /**
14
+     * 排名
15
+     */
16
+    private Integer rank;
17
+
18
+    /**
19
+     * ID(巡检员ID或路线ID)
20
+     */
21
+    private Long id;
22
+
23
+    /**
24
+     * 名称(巡检员姓名或路线名称)
25
+     */
26
+    private String name;
27
+
28
+    /**
29
+     * 总任务数
30
+     */
31
+    private Integer totalTasks;
32
+
33
+    /**
34
+     * 已完成数
35
+     */
36
+    private Integer completedTasks;
37
+
38
+    /**
39
+     * 完成率(百分比)
40
+     */
41
+    private BigDecimal completionRate;
42
+
43
+    /**
44
+     * 总里程(公里)
45
+     */
46
+    private BigDecimal totalDistance;
47
+
48
+    /**
49
+     * 综合评分
50
+     */
51
+    private BigDecimal score;
52
+
53
+    /**
54
+     * 发现问题数
55
+     */
56
+    private Integer issueCount;
57
+
58
+    /**
59
+     * 额外指标(如准时率等)
60
+     */
61
+    private String extraMetric;
62
+}

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

@@ -0,0 +1,60 @@
1
+package com.water.patrol.entity.dto;
2
+
3
+import lombok.Data;
4
+import org.springframework.format.annotation.DateTimeFormat;
5
+
6
+import java.time.LocalDate;
7
+
8
+/**
9
+ * 统计查询请求 DTO
10
+ */
11
+@Data
12
+public class StatsQueryRequest {
13
+
14
+    /**
15
+     * 开始日期
16
+     */
17
+    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
18
+    private LocalDate startDate;
19
+
20
+    /**
21
+     * 结束日期
22
+     */
23
+    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
24
+    private LocalDate endDate;
25
+
26
+    /**
27
+     * 巡检员ID(可选,按人员筛选)
28
+     */
29
+    private Long inspectorId;
30
+
31
+    /**
32
+     * 路线ID(可选,按路线筛选)
33
+     */
34
+    private Long routeId;
35
+
36
+    /**
37
+     * 部门(可选)
38
+     */
39
+    private String dept;
40
+
41
+    /**
42
+     * 分组维度: day/week/month(用于趋势分析)
43
+     */
44
+    private String groupBy;
45
+
46
+    /**
47
+     * 分页:当前页
48
+     */
49
+    private Integer pageNum = 1;
50
+
51
+    /**
52
+     * 分页:每页大小
53
+     */
54
+    private Integer pageSize = 10;
55
+
56
+    /**
57
+     * 排名数量限制
58
+     */
59
+    private Integer topN = 10;
60
+}

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

@@ -0,0 +1,77 @@
1
+package com.water.patrol.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.math.BigDecimal;
6
+
7
+/**
8
+ * 统计概览 DTO
9
+ */
10
+@Data
11
+public class StatsSummary {
12
+
13
+    /**
14
+     * 总任务数
15
+     */
16
+    private Integer totalTasks;
17
+
18
+    /**
19
+     * 已完成任务数
20
+     */
21
+    private Integer completedTasks;
22
+
23
+    /**
24
+     * 执行率(百分比)
25
+     */
26
+    private BigDecimal executionRate;
27
+
28
+    /**
29
+     * 完成率(百分比)
30
+     */
31
+    private BigDecimal completionRate;
32
+
33
+    /**
34
+     * 总里程(公里)
35
+     */
36
+    private BigDecimal totalDistance;
37
+
38
+    /**
39
+     * 日均里程(公里)
40
+     */
41
+    private BigDecimal avgDailyDistance;
42
+
43
+    /**
44
+     * 总问题数
45
+     */
46
+    private Integer totalIssues;
47
+
48
+    /**
49
+     * 已解决问题数
50
+     */
51
+    private Integer resolvedIssues;
52
+
53
+    /**
54
+     * 问题解决率(百分比)
55
+     */
56
+    private BigDecimal resolutionRate;
57
+
58
+    /**
59
+     * 严重问题数
60
+     */
61
+    private Integer criticalIssues;
62
+
63
+    /**
64
+     * 巡检员数量
65
+     */
66
+    private Integer inspectorCount;
67
+
68
+    /**
69
+     * 活跃路线数
70
+     */
71
+    private Integer activeRoutes;
72
+
73
+    /**
74
+     * 平均效率(公里/小时)
75
+     */
76
+    private BigDecimal avgEfficiency;
77
+}

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

@@ -0,0 +1,205 @@
1
+package com.water.patrol.mapper;
2
+
3
+import org.apache.ibatis.annotations.Mapper;
4
+import org.apache.ibatis.annotations.Param;
5
+import org.apache.ibatis.annotations.Select;
6
+
7
+import java.time.LocalDate;
8
+import java.util.List;
9
+import java.util.Map;
10
+
11
+/**
12
+ * 统计查询 Mapper
13
+ */
14
+@Mapper
15
+public interface StatsMapper {
16
+
17
+    // ==================== 执行率统计 ====================
18
+
19
+    /**
20
+     * 按巡检员统计执行率
21
+     */
22
+    @Select("SELECT " +
23
+            "e.inspector_id as inspector_id, " +
24
+            "e.inspector_name as inspector_name, " +
25
+            "COUNT(*) as total_executions, " +
26
+            "COUNT(CASE WHEN e.status = 'completed' THEN 1 END) as completed_count, " +
27
+            "COUNT(CASE WHEN e.status = 'in_progress' THEN 1 END) as in_progress_count, " +
28
+            "COUNT(CASE WHEN e.status = 'aborted' THEN 1 END) as aborted_count, " +
29
+            "ROUND(COUNT(CASE WHEN e.status = 'completed' THEN 1 END) * 100.0 / NULLIF(COUNT(*), 0), 2) as completion_rate " +
30
+            "FROM patrol_execution e " +
31
+            "WHERE e.deleted = 0 " +
32
+            "AND e.start_time >= #{startDate}::timestamp " +
33
+            "AND e.start_time < (#{endDate}::date + interval '1 day')::timestamp " +
34
+            "GROUP BY e.inspector_id, e.inspector_name " +
35
+            "ORDER BY completion_rate DESC")
36
+    List<Map<String, Object>> executionRateByInspector(
37
+            @Param("startDate") LocalDate startDate,
38
+            @Param("endDate") LocalDate endDate);
39
+
40
+    /**
41
+     * 按路线统计执行率
42
+     */
43
+    @Select("SELECT " +
44
+            "t.route_id as route_id, " +
45
+            "r.route_name as route_name, " +
46
+            "COUNT(t.id) as total_tasks, " +
47
+            "COUNT(CASE WHEN t.status = 'completed' THEN 1 END) as completed_count, " +
48
+            "ROUND(COUNT(CASE WHEN t.status = 'completed' THEN 1 END) * 100.0 / NULLIF(COUNT(t.id), 0), 2) as completion_rate " +
49
+            "FROM patrol_task t " +
50
+            "LEFT JOIN patrol_route r ON t.route_id = r.id AND r.deleted = 0 " +
51
+            "WHERE t.deleted = 0 " +
52
+            "AND t.task_date BETWEEN #{startDate} AND #{endDate} " +
53
+            "GROUP BY t.route_id, r.route_name " +
54
+            "ORDER BY completion_rate DESC")
55
+    List<Map<String, Object>> executionRateByRoute(
56
+            @Param("startDate") LocalDate startDate,
57
+            @Param("endDate") LocalDate endDate);
58
+
59
+    /**
60
+     * 按时间段统计执行率(按天分组)
61
+     */
62
+    @Select("SELECT " +
63
+            "DATE(e.start_time) as period, " +
64
+            "COUNT(*) as total_executions, " +
65
+            "COUNT(CASE WHEN e.status = 'completed' THEN 1 END) as completed_count, " +
66
+            "ROUND(COUNT(CASE WHEN e.status = 'completed' THEN 1 END) * 100.0 / NULLIF(COUNT(*), 0), 2) as completion_rate " +
67
+            "FROM patrol_execution e " +
68
+            "WHERE e.deleted = 0 " +
69
+            "AND e.start_time >= #{startDate}::timestamp " +
70
+            "AND e.start_time < (#{endDate}::date + interval '1 day')::timestamp " +
71
+            "GROUP BY DATE(e.start_time) " +
72
+            "ORDER BY period")
73
+    List<Map<String, Object>> executionRateByPeriod(
74
+            @Param("startDate") LocalDate startDate,
75
+            @Param("endDate") LocalDate endDate);
76
+
77
+    // ==================== 里程统计 ====================
78
+
79
+    /**
80
+     * 里程统计概览
81
+     */
82
+    @Select("SELECT " +
83
+            "COALESCE(SUM(total_distance), 0) as total_distance, " +
84
+            "COALESCE(AVG(total_distance), 0) as avg_distance, " +
85
+            "COUNT(*) as execution_count, " +
86
+            "COALESCE(MAX(total_distance), 0) as max_distance, " +
87
+            "COALESCE(MIN(total_distance), 0) as min_distance " +
88
+            "FROM patrol_execution " +
89
+            "WHERE deleted = 0 AND status = 'completed' " +
90
+            "AND start_time >= #{startDate}::timestamp " +
91
+            "AND start_time < (#{endDate}::date + interval '1 day')::timestamp")
92
+    Map<String, Object> mileageSummary(
93
+            @Param("startDate") LocalDate startDate,
94
+            @Param("endDate") LocalDate endDate);
95
+
96
+    /**
97
+     * 按巡检员统计里程
98
+     */
99
+    @Select("SELECT " +
100
+            "e.inspector_id as inspector_id, " +
101
+            "e.inspector_name as inspector_name, " +
102
+            "COALESCE(SUM(e.total_distance), 0) as total_distance, " +
103
+            "COALESCE(AVG(e.total_distance), 0) as avg_distance, " +
104
+            "COUNT(*) as execution_count " +
105
+            "FROM patrol_execution e " +
106
+            "WHERE e.deleted = 0 AND e.status = 'completed' " +
107
+            "AND e.start_time >= #{startDate}::timestamp " +
108
+            "AND e.start_time < (#{endDate}::date + interval '1 day')::timestamp " +
109
+            "GROUP BY e.inspector_id, e.inspector_name " +
110
+            "ORDER BY total_distance DESC")
111
+    List<Map<String, Object>> mileageByInspector(
112
+            @Param("startDate") LocalDate startDate,
113
+            @Param("endDate") LocalDate endDate);
114
+
115
+    /**
116
+     * 里程趋势(按天)
117
+     */
118
+    @Select("SELECT " +
119
+            "DATE(start_time) as date, " +
120
+            "COALESCE(SUM(total_distance), 0) as daily_distance, " +
121
+            "COUNT(*) as execution_count " +
122
+            "FROM patrol_execution " +
123
+            "WHERE deleted = 0 AND status = 'completed' " +
124
+            "AND start_time >= #{startDate}::timestamp " +
125
+            "AND start_time < (#{endDate}::date + interval '1 day')::timestamp " +
126
+            "GROUP BY DATE(start_time) " +
127
+            "ORDER BY date")
128
+    List<Map<String, Object>> mileageTrend(
129
+            @Param("startDate") LocalDate startDate,
130
+            @Param("endDate") LocalDate endDate);
131
+
132
+    // ==================== 问题分类统计 ====================
133
+
134
+    /**
135
+     * 问题类型分布
136
+     */
137
+    @Select("SELECT " +
138
+            "i.issue_type as issue_type, " +
139
+            "COUNT(*) as count " +
140
+            "FROM patrol_issue i " +
141
+            "INNER JOIN patrol_execution e ON i.execution_id = e.id AND e.deleted = 0 " +
142
+            "WHERE i.deleted = 0 " +
143
+            "AND i.report_time >= #{startDate}::timestamp " +
144
+            "AND i.report_time < (#{endDate}::date + interval '1 day')::timestamp " +
145
+            "GROUP BY i.issue_type " +
146
+            "ORDER BY count DESC")
147
+    List<Map<String, Object>> issueTypeDistribution(
148
+            @Param("startDate") LocalDate startDate,
149
+            @Param("endDate") LocalDate endDate);
150
+
151
+    /**
152
+     * 问题严重度分布
153
+     */
154
+    @Select("SELECT " +
155
+            "i.severity as severity, " +
156
+            "COUNT(*) as count " +
157
+            "FROM patrol_issue i " +
158
+            "WHERE i.deleted = 0 " +
159
+            "AND i.report_time >= #{startDate}::timestamp " +
160
+            "AND i.report_time < (#{endDate}::date + interval '1 day')::timestamp " +
161
+            "GROUP BY i.severity " +
162
+            "ORDER BY count DESC")
163
+    List<Map<String, Object>> issueSeverityDistribution(
164
+            @Param("startDate") LocalDate startDate,
165
+            @Param("endDate") LocalDate endDate);
166
+
167
+    /**
168
+     * 问题解决率统计
169
+     */
170
+    @Select("SELECT " +
171
+            "COUNT(*) as total_issues, " +
172
+            "COUNT(CASE WHEN i.handle_status = 'resolved' OR i.handle_status = 'closed' THEN 1 END) as resolved_count, " +
173
+            "COUNT(CASE WHEN i.handle_status = 'pending' THEN 1 END) as pending_count, " +
174
+            "COUNT(CASE WHEN i.handle_status = 'processing' THEN 1 END) as processing_count, " +
175
+            "ROUND((COUNT(CASE WHEN i.handle_status = 'resolved' OR i.handle_status = 'closed' THEN 1 END)) * 100.0 " +
176
+            "/ NULLIF(COUNT(*), 0), 2) as resolution_rate " +
177
+            "FROM patrol_issue i " +
178
+            "WHERE i.deleted = 0 " +
179
+            "AND i.report_time >= #{startDate}::timestamp " +
180
+            "AND i.report_time < (#{endDate}::date + interval '1 day')::timestamp")
181
+    Map<String, Object> issueResolutionStats(
182
+            @Param("startDate") LocalDate startDate,
183
+            @Param("endDate") LocalDate endDate);
184
+
185
+    // ==================== 综合看板 ====================
186
+
187
+    /**
188
+     * 综合统计概览
189
+     */
190
+    @Select("SELECT " +
191
+            "(SELECT COUNT(*) FROM patrol_task WHERE task_date BETWEEN #{startDate} AND #{endDate} AND deleted = 0) as total_tasks, " +
192
+            "(SELECT COUNT(*) FROM patrol_task WHERE task_date BETWEEN #{startDate} AND #{endDate} AND deleted = 0 AND status = 'completed') as completed_tasks, " +
193
+            "(SELECT COALESCE(SUM(total_distance), 0) FROM patrol_execution WHERE deleted = 0 AND status = 'completed' " +
194
+            "AND start_time >= #{startDate}::timestamp AND start_time < (#{endDate}::date + interval '1 day')::timestamp) as total_distance, " +
195
+            "(SELECT COUNT(*) FROM patrol_issue WHERE deleted = 0 " +
196
+            "AND report_time >= #{startDate}::timestamp AND report_time < (#{endDate}::date + interval '1 day')::timestamp) as total_issues, " +
197
+            "(SELECT COUNT(*) FROM patrol_issue WHERE deleted = 0 AND (handle_status = 'resolved' OR handle_status = 'closed') " +
198
+            "AND report_time >= #{startDate}::timestamp AND report_time < (#{endDate}::date + interval '1 day')::timestamp) as resolved_issues, " +
199
+            "(SELECT COUNT(DISTINCT inspector_id) FROM patrol_execution WHERE deleted = 0 " +
200
+            "AND start_time >= #{startDate}::timestamp AND start_time < (#{endDate}::date + interval '1 day')::timestamp) as inspector_count, " +
201
+            "(SELECT COUNT(DISTINCT route_id) FROM patrol_task WHERE task_date BETWEEN #{startDate} AND #{endDate} AND deleted = 0) as active_routes")
202
+    Map<String, Object> dashboardSummary(
203
+            @Param("startDate") LocalDate startDate,
204
+            @Param("endDate") LocalDate endDate);
205
+}

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

@@ -0,0 +1,163 @@
1
+package com.water.patrol.service;
2
+
3
+import com.water.patrol.entity.dto.RankResult;
4
+import com.water.patrol.entity.dto.StatsQueryRequest;
5
+import com.water.patrol.mapper.PatrolTaskMapper;
6
+import com.water.patrol.mapper.StatsMapper;
7
+import lombok.RequiredArgsConstructor;
8
+import lombok.extern.slf4j.Slf4j;
9
+import org.springframework.stereotype.Service;
10
+
11
+import java.math.BigDecimal;
12
+import java.math.RoundingMode;
13
+import java.time.LocalDate;
14
+import java.util.ArrayList;
15
+import java.util.Comparator;
16
+import java.util.List;
17
+import java.util.Map;
18
+import java.util.stream.Collectors;
19
+
20
+/**
21
+ * 巡检排名服务
22
+ */
23
+@Slf4j
24
+@Service
25
+@RequiredArgsConstructor
26
+public class PatrolRankService {
27
+
28
+    private final StatsMapper statsMapper;
29
+
30
+    /**
31
+     * 巡检员排名(按综合评分)
32
+     * 评分规则:完成率 * 0.4 + 里程 * 0.3 + 问题发现 * 0.3
33
+     */
34
+    public List<RankResult> inspectorRank(StatsQueryRequest request) {
35
+        LocalDate start = request.getStartDate() != null ? request.getStartDate() : LocalDate.now().minusDays(30);
36
+        LocalDate end = request.getEndDate() != null ? request.getEndDate() : LocalDate.now();
37
+        int topN = request.getTopN() != null ? request.getTopN() : 10;
38
+
39
+        log.info("巡检员排名: {} ~ {}, topN={}", start, end, topN);
40
+
41
+        // 获取执行率数据
42
+        List<Map<String, Object>> executionStats = statsMapper.executionRateByInspector(start, end);
43
+        // 获取里程数据
44
+        List<Map<String, Object>> mileageStats = statsMapper.mileageByInspector(start, end);
45
+
46
+        // 合并数据
47
+        Map<String, Map<String, Object>> mileageMap = mileageStats.stream()
48
+                .collect(Collectors.toMap(
49
+                        m -> String.valueOf(m.get("inspector_id")),
50
+                        m -> m,
51
+                        (a, b) -> a
52
+                ));
53
+
54
+        List<RankResult> results = new ArrayList<>();
55
+        for (Map<String, Object> exec : executionStats) {
56
+            RankResult rank = new RankResult();
57
+            rank.setId(toLong(exec.get("inspector_id")));
58
+            rank.setName(String.valueOf(exec.get("inspector_name")));
59
+            rank.setTotalTasks(toInt(exec.get("total_executions")));
60
+            rank.setCompletedTasks(toInt(exec.get("completed_count")));
61
+
62
+            BigDecimal completionRate = exec.get("completion_rate") != null
63
+                    ? new BigDecimal(exec.get("completion_rate").toString())
64
+                    : BigDecimal.ZERO;
65
+            rank.setCompletionRate(completionRate);
66
+
67
+            // 合并里程
68
+            String key = String.valueOf(exec.get("inspector_id"));
69
+            Map<String, Object> mileage = mileageMap.get(key);
70
+            if (mileage != null) {
71
+                BigDecimal totalDist = mileage.get("total_distance") != null
72
+                        ? new BigDecimal(mileage.get("total_distance").toString())
73
+                        : BigDecimal.ZERO;
74
+                rank.setTotalDistance(totalDist);
75
+            } else {
76
+                rank.setTotalDistance(BigDecimal.ZERO);
77
+            }
78
+
79
+            // 计算综合评分
80
+            BigDecimal score = calculateScore(completionRate, rank.getTotalDistance(), BigDecimal.ZERO);
81
+            rank.setScore(score);
82
+
83
+            results.add(rank);
84
+        }
85
+
86
+        // 按评分排序
87
+        results.sort(Comparator.comparing(RankResult::getScore).reversed());
88
+
89
+        // 设置排名
90
+        for (int i = 0; i < results.size(); i++) {
91
+            results.get(i).setRank(i + 1);
92
+        }
93
+
94
+        return results.stream().limit(topN).collect(Collectors.toList());
95
+    }
96
+
97
+    /**
98
+     * 路线排名(按完成率)
99
+     */
100
+    public List<RankResult> routeRank(StatsQueryRequest request) {
101
+        LocalDate start = request.getStartDate() != null ? request.getStartDate() : LocalDate.now().minusDays(30);
102
+        LocalDate end = request.getEndDate() != null ? request.getEndDate() : LocalDate.now();
103
+        int topN = request.getTopN() != null ? request.getTopN() : 10;
104
+
105
+        log.info("路线排名: {} ~ {}, topN={}", start, end, topN);
106
+
107
+        List<Map<String, Object>> routeStats = statsMapper.executionRateByRoute(start, end);
108
+
109
+        List<RankResult> results = new ArrayList<>();
110
+        for (Map<String, Object> route : routeStats) {
111
+            RankResult rank = new RankResult();
112
+            rank.setId(toLong(route.get("route_id")));
113
+            rank.setName(String.valueOf(route.get("route_name")));
114
+            rank.setTotalTasks(toInt(route.get("total_tasks")));
115
+            rank.setCompletedTasks(toInt(route.get("completed_count")));
116
+
117
+            BigDecimal completionRate = route.get("completion_rate") != null
118
+                    ? new BigDecimal(route.get("completion_rate").toString())
119
+                    : BigDecimal.ZERO;
120
+            rank.setCompletionRate(completionRate);
121
+            rank.setScore(completionRate); // 路线排名直接用完成率作为评分
122
+
123
+            results.add(rank);
124
+        }
125
+
126
+        // 按评分排序
127
+        results.sort(Comparator.comparing(RankResult::getScore).reversed());
128
+
129
+        // 设置排名
130
+        for (int i = 0; i < results.size(); i++) {
131
+            results.get(i).setRank(i + 1);
132
+        }
133
+
134
+        return results.stream().limit(topN).collect(Collectors.toList());
135
+    }
136
+
137
+    /**
138
+     * 计算综合评分
139
+     * 完成率权重 0.4,里程权重 0.3,问题发现权重 0.3
140
+     */
141
+    private BigDecimal calculateScore(BigDecimal completionRate, BigDecimal totalDistance, BigDecimal issueScore) {
142
+        BigDecimal rateScore = completionRate.multiply(BigDecimal.valueOf(0.4));
143
+        // 里程分数:每10公里算1分,封顶100
144
+        BigDecimal distanceScore = totalDistance.divide(BigDecimal.valueOf(10), 2, RoundingMode.HALF_UP)
145
+                .min(BigDecimal.valueOf(100))
146
+                .multiply(BigDecimal.valueOf(0.3));
147
+        BigDecimal issuePart = issueScore.multiply(BigDecimal.valueOf(0.3));
148
+
149
+        return rateScore.add(distanceScore).add(issuePart).setScale(2, RoundingMode.HALF_UP);
150
+    }
151
+
152
+    private int toInt(Object obj) {
153
+        if (obj == null) return 0;
154
+        if (obj instanceof Number) return ((Number) obj).intValue();
155
+        return Integer.parseInt(obj.toString());
156
+    }
157
+
158
+    private Long toLong(Object obj) {
159
+        if (obj == null) return null;
160
+        if (obj instanceof Number) return ((Number) obj).longValue();
161
+        return Long.parseLong(obj.toString());
162
+    }
163
+}

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

@@ -0,0 +1,205 @@
1
+package com.water.patrol.service;
2
+
3
+import com.water.patrol.entity.dto.StatsQueryRequest;
4
+import com.water.patrol.entity.dto.StatsSummary;
5
+import com.water.patrol.mapper.StatsMapper;
6
+import lombok.RequiredArgsConstructor;
7
+import lombok.extern.slf4j.Slf4j;
8
+import org.springframework.stereotype.Service;
9
+
10
+import java.math.BigDecimal;
11
+import java.math.RoundingMode;
12
+import java.time.LocalDate;
13
+import java.time.temporal.ChronoUnit;
14
+import java.util.HashMap;
15
+import java.util.List;
16
+import java.util.Map;
17
+
18
+/**
19
+ * 巡检统计服务
20
+ */
21
+@Slf4j
22
+@Service
23
+@RequiredArgsConstructor
24
+public class PatrolStatsService {
25
+
26
+    private final StatsMapper statsMapper;
27
+
28
+    // ==================== 执行率统计 ====================
29
+
30
+    /**
31
+     * 按巡检员统计执行率
32
+     */
33
+    public List<Map<String, Object>> executionRateByInspector(StatsQueryRequest request) {
34
+        LocalDate start = request.getStartDate() != null ? request.getStartDate() : LocalDate.now().minusDays(30);
35
+        LocalDate end = request.getEndDate() != null ? request.getEndDate() : LocalDate.now();
36
+        log.info("查询执行率(按巡检员): {} ~ {}", start, end);
37
+        return statsMapper.executionRateByInspector(start, end);
38
+    }
39
+
40
+    /**
41
+     * 按路线统计执行率
42
+     */
43
+    public List<Map<String, Object>> executionRateByRoute(StatsQueryRequest request) {
44
+        LocalDate start = request.getStartDate() != null ? request.getStartDate() : LocalDate.now().minusDays(30);
45
+        LocalDate end = request.getEndDate() != null ? request.getEndDate() : LocalDate.now();
46
+        log.info("查询执行率(按路线): {} ~ {}", start, end);
47
+        return statsMapper.executionRateByRoute(start, end);
48
+    }
49
+
50
+    /**
51
+     * 按时间段统计执行率
52
+     */
53
+    public List<Map<String, Object>> executionRateByPeriod(StatsQueryRequest request) {
54
+        LocalDate start = request.getStartDate() != null ? request.getStartDate() : LocalDate.now().minusDays(30);
55
+        LocalDate end = request.getEndDate() != null ? request.getEndDate() : LocalDate.now();
56
+        log.info("查询执行率(按时间段): {} ~ {}", start, end);
57
+        return statsMapper.executionRateByPeriod(start, end);
58
+    }
59
+
60
+    // ==================== 里程统计 ====================
61
+
62
+    /**
63
+     * 里程统计概览
64
+     */
65
+    public Map<String, Object> mileageSummary(StatsQueryRequest request) {
66
+        LocalDate start = request.getStartDate() != null ? request.getStartDate() : LocalDate.now().minusDays(30);
67
+        LocalDate end = request.getEndDate() != null ? request.getEndDate() : LocalDate.now();
68
+        log.info("查询里程统计概览: {} ~ {}", start, end);
69
+
70
+        Map<String, Object> raw = statsMapper.mileageSummary(start, end);
71
+        if (raw == null) {
72
+            raw = new HashMap<>();
73
+        }
74
+
75
+        // 计算日均里程
76
+        long days = ChronoUnit.DAYS.between(start, end) + 1;
77
+        Object totalObj = raw.get("total_distance");
78
+        BigDecimal total = totalObj != null ? new BigDecimal(totalObj.toString()) : BigDecimal.ZERO;
79
+        BigDecimal avgDaily = total.divide(BigDecimal.valueOf(days), 2, RoundingMode.HALF_UP);
80
+        raw.put("avg_daily_distance", avgDaily);
81
+        raw.put("days", days);
82
+
83
+        return raw;
84
+    }
85
+
86
+    /**
87
+     * 按巡检员统计里程
88
+     */
89
+    public List<Map<String, Object>> mileageByInspector(StatsQueryRequest request) {
90
+        LocalDate start = request.getStartDate() != null ? request.getStartDate() : LocalDate.now().minusDays(30);
91
+        LocalDate end = request.getEndDate() != null ? request.getEndDate() : LocalDate.now();
92
+        log.info("查询里程(按巡检员): {} ~ {}", start, end);
93
+        return statsMapper.mileageByInspector(start, end);
94
+    }
95
+
96
+    /**
97
+     * 里程趋势(按天)
98
+     */
99
+    public List<Map<String, Object>> mileageTrend(StatsQueryRequest request) {
100
+        LocalDate start = request.getStartDate() != null ? request.getStartDate() : LocalDate.now().minusDays(30);
101
+        LocalDate end = request.getEndDate() != null ? request.getEndDate() : LocalDate.now();
102
+        log.info("查询里程趋势: {} ~ {}", start, end);
103
+        return statsMapper.mileageTrend(start, end);
104
+    }
105
+
106
+    // ==================== 问题分类统计 ====================
107
+
108
+    /**
109
+     * 问题类型分布
110
+     */
111
+    public List<Map<String, Object>> issueTypeDistribution(StatsQueryRequest request) {
112
+        LocalDate start = request.getStartDate() != null ? request.getStartDate() : LocalDate.now().minusDays(30);
113
+        LocalDate end = request.getEndDate() != null ? request.getEndDate() : LocalDate.now();
114
+        log.info("查询问题类型分布: {} ~ {}", start, end);
115
+        return statsMapper.issueTypeDistribution(start, end);
116
+    }
117
+
118
+    /**
119
+     * 问题严重度分布
120
+     */
121
+    public List<Map<String, Object>> issueSeverityDistribution(StatsQueryRequest request) {
122
+        LocalDate start = request.getStartDate() != null ? request.getStartDate() : LocalDate.now().minusDays(30);
123
+        LocalDate end = request.getEndDate() != null ? request.getEndDate() : LocalDate.now();
124
+        log.info("查询问题严重度分布: {} ~ {}", start, end);
125
+        return statsMapper.issueSeverityDistribution(start, end);
126
+    }
127
+
128
+    /**
129
+     * 问题解决率统计
130
+     */
131
+    public Map<String, Object> issueResolutionStats(StatsQueryRequest request) {
132
+        LocalDate start = request.getStartDate() != null ? request.getStartDate() : LocalDate.now().minusDays(30);
133
+        LocalDate end = request.getEndDate() != null ? request.getEndDate() : LocalDate.now();
134
+        log.info("查询问题解决率: {} ~ {}", start, end);
135
+        Map<String, Object> result = statsMapper.issueResolutionStats(start, end);
136
+        return result != null ? result : new HashMap<>();
137
+    }
138
+
139
+    // ==================== 综合看板 ====================
140
+
141
+    /**
142
+     * 综合统计概览
143
+     */
144
+    public StatsSummary dashboardSummary(StatsQueryRequest request) {
145
+        LocalDate start = request.getStartDate() != null ? request.getStartDate() : LocalDate.now().minusDays(30);
146
+        LocalDate end = request.getEndDate() != null ? request.getEndDate() : LocalDate.now();
147
+        log.info("查询综合看板: {} ~ {}", start, end);
148
+
149
+        Map<String, Object> raw = statsMapper.dashboardSummary(start, end);
150
+
151
+        StatsSummary summary = new StatsSummary();
152
+        if (raw == null) {
153
+            return summary;
154
+        }
155
+
156
+        int totalTasks = toInt(raw.get("total_tasks"));
157
+        int completedTasks = toInt(raw.get("completed_tasks"));
158
+        summary.setTotalTasks(totalTasks);
159
+        summary.setCompletedTasks(completedTasks);
160
+
161
+        // 执行率
162
+        if (totalTasks > 0) {
163
+            summary.setExecutionRate(BigDecimal.valueOf(completedTasks * 100.0 / totalTasks)
164
+                    .setScale(2, RoundingMode.HALF_UP));
165
+            summary.setCompletionRate(summary.getExecutionRate());
166
+        } else {
167
+            summary.setExecutionRate(BigDecimal.ZERO);
168
+            summary.setCompletionRate(BigDecimal.ZERO);
169
+        }
170
+
171
+        // 里程
172
+        BigDecimal totalDistance = raw.get("total_distance") != null
173
+                ? new BigDecimal(raw.get("total_distance").toString())
174
+                : BigDecimal.ZERO;
175
+        summary.setTotalDistance(totalDistance);
176
+
177
+        long days = ChronoUnit.DAYS.between(start, end) + 1;
178
+        summary.setAvgDailyDistance(totalDistance.divide(BigDecimal.valueOf(days), 2, RoundingMode.HALF_UP));
179
+
180
+        // 问题
181
+        int totalIssues = toInt(raw.get("total_issues"));
182
+        int resolvedIssues = toInt(raw.get("resolved_issues"));
183
+        summary.setTotalIssues(totalIssues);
184
+        summary.setResolvedIssues(resolvedIssues);
185
+
186
+        if (totalIssues > 0) {
187
+            summary.setResolutionRate(BigDecimal.valueOf(resolvedIssues * 100.0 / totalIssues)
188
+                    .setScale(2, RoundingMode.HALF_UP));
189
+        } else {
190
+            summary.setResolutionRate(BigDecimal.ZERO);
191
+        }
192
+
193
+        // 人员与路线
194
+        summary.setInspectorCount(toInt(raw.get("inspector_count")));
195
+        summary.setActiveRoutes(toInt(raw.get("active_routes")));
196
+
197
+        return summary;
198
+    }
199
+
200
+    private int toInt(Object obj) {
201
+        if (obj == null) return 0;
202
+        if (obj instanceof Number) return ((Number) obj).intValue();
203
+        return Integer.parseInt(obj.toString());
204
+    }
205
+}

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

@@ -0,0 +1,176 @@
1
+package com.water.patrol.service;
2
+
3
+import com.water.patrol.entity.dto.RankResult;
4
+import com.water.patrol.entity.dto.StatsQueryRequest;
5
+import com.water.patrol.mapper.StatsMapper;
6
+import org.junit.jupiter.api.BeforeEach;
7
+import org.junit.jupiter.api.DisplayName;
8
+import org.junit.jupiter.api.Test;
9
+import org.junit.jupiter.api.extension.ExtendWith;
10
+import org.mockito.InjectMocks;
11
+import org.mockito.Mock;
12
+import org.mockito.junit.jupiter.MockitoExtension;
13
+
14
+import java.math.BigDecimal;
15
+import java.time.LocalDate;
16
+import java.util.*;
17
+
18
+import static org.junit.jupiter.api.Assertions.*;
19
+import static org.mockito.ArgumentMatchers.any;
20
+import static org.mockito.Mockito.*;
21
+
22
+@ExtendWith(MockitoExtension.class)
23
+class PatrolRankServiceTest {
24
+
25
+    @Mock
26
+    private StatsMapper statsMapper;
27
+
28
+    @InjectMocks
29
+    private PatrolRankService rankService;
30
+
31
+    private StatsQueryRequest defaultRequest;
32
+
33
+    @BeforeEach
34
+    void setUp() {
35
+        defaultRequest = new StatsQueryRequest();
36
+        defaultRequest.setStartDate(LocalDate.of(2026, 1, 1));
37
+        defaultRequest.setEndDate(LocalDate.of(2026, 1, 31));
38
+        defaultRequest.setTopN(10);
39
+    }
40
+
41
+    @Test
42
+    @DisplayName("巡检员排名 - 按综合评分排序")
43
+    void inspectorRank_sortedByScore() {
44
+        // 模拟执行率数据
45
+        List<Map<String, Object>> execStats = new ArrayList<>();
46
+        execStats.add(buildExecStat(1L, "张三", 20, 18, "90.00"));
47
+        execStats.add(buildExecStat(2L, "李四", 15, 10, "66.67"));
48
+        execStats.add(buildExecStat(3L, "王五", 25, 25, "100.00"));
49
+
50
+        when(statsMapper.executionRateByInspector(any(), any())).thenReturn(execStats);
51
+
52
+        // 模拟里程数据
53
+        List<Map<String, Object>> mileageStats = new ArrayList<>();
54
+        mileageStats.add(buildMileageStat(1L, "张三", "50.00"));
55
+        mileageStats.add(buildMileageStat(2L, "李四", "30.00"));
56
+        mileageStats.add(buildMileageStat(3L, "王五", "80.00"));
57
+
58
+        when(statsMapper.mileageByInspector(any(), any())).thenReturn(mileageStats);
59
+
60
+        List<RankResult> results = rankService.inspectorRank(defaultRequest);
61
+
62
+        assertNotNull(results);
63
+        assertEquals(3, results.size());
64
+
65
+        // 王五完成率100%+里程80km,应该排第一
66
+        assertEquals(1, results.get(0).getRank());
67
+        assertEquals("王五", results.get(0).getName());
68
+
69
+        // 张三完成率90%+里程50km,应该排第二
70
+        assertEquals(2, results.get(1).getRank());
71
+        assertEquals("张三", results.get(1).getName());
72
+
73
+        verify(statsMapper).executionRateByInspector(any(), any());
74
+        verify(statsMapper).mileageByInspector(any(), any());
75
+    }
76
+
77
+    @Test
78
+    @DisplayName("巡检员排名 - topN限制")
79
+    void inspectorRank_respectsTopN() {
80
+        List<Map<String, Object>> execStats = new ArrayList<>();
81
+        for (long i = 1; i <= 15; i++) {
82
+            execStats.add(buildExecStat(i, "巡检员" + i, 10, 8, "80.00"));
83
+        }
84
+        when(statsMapper.executionRateByInspector(any(), any())).thenReturn(execStats);
85
+        when(statsMapper.mileageByInspector(any(), any())).thenReturn(new ArrayList<>());
86
+
87
+        defaultRequest.setTopN(5);
88
+        List<RankResult> results = rankService.inspectorRank(defaultRequest);
89
+
90
+        assertNotNull(results);
91
+        assertEquals(5, results.size());
92
+    }
93
+
94
+    @Test
95
+    @DisplayName("路线排名 - 按完成率排序")
96
+    void routeRank_sortedByCompletionRate() {
97
+        List<Map<String, Object>> routeStats = new ArrayList<>();
98
+        routeStats.add(buildRouteStat(1L, "东线", 30, 28, "93.33"));
99
+        routeStats.add(buildRouteStat(2L, "西线", 20, 10, "50.00"));
100
+        routeStats.add(buildRouteStat(3L, "南线", 25, 25, "100.00"));
101
+
102
+        when(statsMapper.executionRateByRoute(any(), any())).thenReturn(routeStats);
103
+
104
+        List<RankResult> results = rankService.routeRank(defaultRequest);
105
+
106
+        assertNotNull(results);
107
+        assertEquals(3, results.size());
108
+
109
+        // 南线100%完成率排第一
110
+        assertEquals("南线", results.get(0).getName());
111
+        assertEquals(1, results.get(0).getRank());
112
+
113
+        // 西线50%完成率排最后
114
+        assertEquals("西线", results.get(2).getName());
115
+        assertEquals(3, results.get(2).getRank());
116
+    }
117
+
118
+    @Test
119
+    @DisplayName("巡检员排名 - 空数据返回空列表")
120
+    void inspectorRank_emptyData() {
121
+        when(statsMapper.executionRateByInspector(any(), any())).thenReturn(new ArrayList<>());
122
+        when(statsMapper.mileageByInspector(any(), any())).thenReturn(new ArrayList<>());
123
+
124
+        List<RankResult> results = rankService.inspectorRank(defaultRequest);
125
+
126
+        assertNotNull(results);
127
+        assertTrue(results.isEmpty());
128
+    }
129
+
130
+    @Test
131
+    @DisplayName("路线排名 - 排名编号正确")
132
+    void routeRank_rankNumbersCorrect() {
133
+        List<Map<String, Object>> routeStats = new ArrayList<>();
134
+        routeStats.add(buildRouteStat(1L, "A线", 10, 10, "100.00"));
135
+        routeStats.add(buildRouteStat(2L, "B线", 10, 10, "100.00"));
136
+        routeStats.add(buildRouteStat(3L, "C线", 10, 5, "50.00"));
137
+
138
+        when(statsMapper.executionRateByRoute(any(), any())).thenReturn(routeStats);
139
+
140
+        List<RankResult> results = rankService.routeRank(defaultRequest);
141
+
142
+        assertEquals(1, results.get(0).getRank());
143
+        assertEquals(2, results.get(1).getRank());
144
+        assertEquals(3, results.get(2).getRank());
145
+    }
146
+
147
+    // ==================== Helper Methods ====================
148
+
149
+    private Map<String, Object> buildExecStat(Long id, String name, int total, int completed, String rate) {
150
+        Map<String, Object> map = new HashMap<>();
151
+        map.put("inspector_id", id);
152
+        map.put("inspector_name", name);
153
+        map.put("total_executions", total);
154
+        map.put("completed_count", completed);
155
+        map.put("completion_rate", new BigDecimal(rate));
156
+        return map;
157
+    }
158
+
159
+    private Map<String, Object> buildMileageStat(Long id, String name, String distance) {
160
+        Map<String, Object> map = new HashMap<>();
161
+        map.put("inspector_id", id);
162
+        map.put("inspector_name", name);
163
+        map.put("total_distance", new BigDecimal(distance));
164
+        return map;
165
+    }
166
+
167
+    private Map<String, Object> buildRouteStat(Long id, String name, int total, int completed, String rate) {
168
+        Map<String, Object> map = new HashMap<>();
169
+        map.put("route_id", id);
170
+        map.put("route_name", name);
171
+        map.put("total_tasks", total);
172
+        map.put("completed_count", completed);
173
+        map.put("completion_rate", new BigDecimal(rate));
174
+        return map;
175
+    }
176
+}

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

@@ -0,0 +1,194 @@
1
+package com.water.patrol.service;
2
+
3
+import com.water.patrol.entity.dto.StatsQueryRequest;
4
+import com.water.patrol.entity.dto.StatsSummary;
5
+import com.water.patrol.mapper.StatsMapper;
6
+import org.junit.jupiter.api.BeforeEach;
7
+import org.junit.jupiter.api.DisplayName;
8
+import org.junit.jupiter.api.Test;
9
+import org.junit.jupiter.api.extension.ExtendWith;
10
+import org.mockito.InjectMocks;
11
+import org.mockito.Mock;
12
+import org.mockito.junit.jupiter.MockitoExtension;
13
+
14
+import java.math.BigDecimal;
15
+import java.time.LocalDate;
16
+import java.util.*;
17
+
18
+import static org.junit.jupiter.api.Assertions.*;
19
+import static org.mockito.ArgumentMatchers.any;
20
+import static org.mockito.ArgumentMatchers.eq;
21
+import static org.mockito.Mockito.*;
22
+
23
+@ExtendWith(MockitoExtension.class)
24
+class PatrolStatsServiceTest {
25
+
26
+    @Mock
27
+    private StatsMapper statsMapper;
28
+
29
+    @InjectMocks
30
+    private PatrolStatsService statsService;
31
+
32
+    private StatsQueryRequest defaultRequest;
33
+
34
+    @BeforeEach
35
+    void setUp() {
36
+        defaultRequest = new StatsQueryRequest();
37
+        defaultRequest.setStartDate(LocalDate.of(2026, 1, 1));
38
+        defaultRequest.setEndDate(LocalDate.of(2026, 1, 31));
39
+    }
40
+
41
+    @Test
42
+    @DisplayName("综合看板概览 - 正常返回统计数据")
43
+    void dashboardSummary_success() {
44
+        Map<String, Object> mockData = new HashMap<>();
45
+        mockData.put("total_tasks", 100);
46
+        mockData.put("completed_tasks", 85);
47
+        mockData.put("total_distance", new BigDecimal("250.50"));
48
+        mockData.put("total_issues", 20);
49
+        mockData.put("resolved_issues", 15);
50
+        mockData.put("inspector_count", 8);
51
+        mockData.put("active_routes", 5);
52
+
53
+        when(statsMapper.dashboardSummary(any(LocalDate.class), any(LocalDate.class))).thenReturn(mockData);
54
+
55
+        StatsSummary summary = statsService.dashboardSummary(defaultRequest);
56
+
57
+        assertNotNull(summary);
58
+        assertEquals(100, summary.getTotalTasks());
59
+        assertEquals(85, summary.getCompletedTasks());
60
+        assertEquals(85.0, summary.getExecutionRate().doubleValue(), 0.01);
61
+        assertEquals(new BigDecimal("250.50"), summary.getTotalDistance());
62
+        assertEquals(20, summary.getTotalIssues());
63
+        assertEquals(15, summary.getResolvedIssues());
64
+        assertEquals(75.0, summary.getResolutionRate().doubleValue(), 0.01);
65
+        assertEquals(8, summary.getInspectorCount());
66
+        assertEquals(5, summary.getActiveRoutes());
67
+
68
+        verify(statsMapper).dashboardSummary(any(LocalDate.class), any(LocalDate.class));
69
+    }
70
+
71
+    @Test
72
+    @DisplayName("综合看板概览 - 空数据返回零值")
73
+    void dashboardSummary_emptyData() {
74
+        when(statsMapper.dashboardSummary(any(LocalDate.class), any(LocalDate.class))).thenReturn(null);
75
+
76
+        StatsSummary summary = statsService.dashboardSummary(defaultRequest);
77
+
78
+        assertNotNull(summary);
79
+        assertEquals(0, summary.getTotalTasks());
80
+        assertEquals(0, summary.getCompletedTasks());
81
+        assertEquals(BigDecimal.ZERO, summary.getExecutionRate());
82
+    }
83
+
84
+    @Test
85
+    @DisplayName("执行率按巡检员统计 - 正确返回")
86
+    void executionRateByInspector_success() {
87
+        List<Map<String, Object>> mockData = new ArrayList<>();
88
+        Map<String, Object> row = new HashMap<>();
89
+        row.put("inspector_id", 1L);
90
+        row.put("inspector_name", "张三");
91
+        row.put("total_executions", 20);
92
+        row.put("completed_count", 18);
93
+        row.put("completion_rate", new BigDecimal("90.00"));
94
+        mockData.add(row);
95
+
96
+        when(statsMapper.executionRateByInspector(any(LocalDate.class), any(LocalDate.class))).thenReturn(mockData);
97
+
98
+        List<Map<String, Object>> result = statsService.executionRateByInspector(defaultRequest);
99
+
100
+        assertNotNull(result);
101
+        assertEquals(1, result.size());
102
+        assertEquals("张三", result.get(0).get("inspector_name"));
103
+        assertEquals(20, result.get(0).get("total_executions"));
104
+
105
+        verify(statsMapper).executionRateByInspector(any(LocalDate.class), any(LocalDate.class));
106
+    }
107
+
108
+    @Test
109
+    @DisplayName("里程统计概览 - 计算日均里程")
110
+    void mileageSummary_calculatesDailyAverage() {
111
+        Map<String, Object> mockData = new HashMap<>();
112
+        mockData.put("total_distance", new BigDecimal("310.00"));
113
+        mockData.put("avg_distance", new BigDecimal("10.00"));
114
+        mockData.put("execution_count", 31);
115
+        mockData.put("max_distance", new BigDecimal("15.5"));
116
+        mockData.put("min_distance", new BigDecimal("5.0"));
117
+
118
+        when(statsMapper.mileageSummary(any(LocalDate.class), any(LocalDate.class))).thenReturn(mockData);
119
+
120
+        Map<String, Object> result = statsService.mileageSummary(defaultRequest);
121
+
122
+        assertNotNull(result);
123
+        assertEquals(new BigDecimal("310.00"), new BigDecimal(result.get("total_distance").toString()));
124
+        assertNotNull(result.get("avg_daily_distance"));
125
+        assertEquals(31, result.get("days"));
126
+
127
+        // 31天,310公里,日均应为10.00
128
+        BigDecimal avgDaily = (BigDecimal) result.get("avg_daily_distance");
129
+        assertEquals(10.0, avgDaily.doubleValue(), 0.01);
130
+
131
+        verify(statsMapper).mileageSummary(any(LocalDate.class), any(LocalDate.class));
132
+    }
133
+
134
+    @Test
135
+    @DisplayName("问题类型分布 - 正确返回分布数据")
136
+    void issueTypeDistribution_success() {
137
+        List<Map<String, Object>> mockData = new ArrayList<>();
138
+
139
+        Map<String, Object> leak = new HashMap<>();
140
+        leak.put("issue_type", "leak");
141
+        leak.put("count", 15);
142
+        mockData.add(leak);
143
+
144
+        Map<String, Object> damage = new HashMap<>();
145
+        damage.put("issue_type", "damage");
146
+        damage.put("count", 8);
147
+        mockData.add(damage);
148
+
149
+        when(statsMapper.issueTypeDistribution(any(LocalDate.class), any(LocalDate.class))).thenReturn(mockData);
150
+
151
+        List<Map<String, Object>> result = statsService.issueTypeDistribution(defaultRequest);
152
+
153
+        assertNotNull(result);
154
+        assertEquals(2, result.size());
155
+        assertEquals("leak", result.get(0).get("issue_type"));
156
+        assertEquals(15, result.get(0).get("count"));
157
+
158
+        verify(statsMapper).issueTypeDistribution(any(LocalDate.class), any(LocalDate.class));
159
+    }
160
+
161
+    @Test
162
+    @DisplayName("问题解决率统计 - 正确计算解决率")
163
+    void issueResolutionStats_success() {
164
+        Map<String, Object> mockData = new HashMap<>();
165
+        mockData.put("total_issues", 50);
166
+        mockData.put("resolved_count", 40);
167
+        mockData.put("pending_count", 5);
168
+        mockData.put("processing_count", 5);
169
+        mockData.put("resolution_rate", new BigDecimal("80.00"));
170
+
171
+        when(statsMapper.issueResolutionStats(any(LocalDate.class), any(LocalDate.class))).thenReturn(mockData);
172
+
173
+        Map<String, Object> result = statsService.issueResolutionStats(defaultRequest);
174
+
175
+        assertNotNull(result);
176
+        assertEquals(50, result.get("total_issues"));
177
+        assertEquals(40, result.get("resolved_count"));
178
+        assertEquals(new BigDecimal("80.00"), new BigDecimal(result.get("resolution_rate").toString()));
179
+
180
+        verify(statsMapper).issueResolutionStats(any(LocalDate.class), any(LocalDate.class));
181
+    }
182
+
183
+    @Test
184
+    @DisplayName("默认日期范围 - 当未传入日期时使用近30天")
185
+    void defaultDateRange_lastThirtyDays() {
186
+        StatsQueryRequest emptyRequest = new StatsQueryRequest();
187
+
188
+        when(statsMapper.dashboardSummary(any(LocalDate.class), any(LocalDate.class))).thenReturn(new HashMap<>());
189
+
190
+        statsService.dashboardSummary(emptyRequest);
191
+
192
+        verify(statsMapper).dashboardSummary(any(LocalDate.class), any(LocalDate.class));
193
+    }
194
+}