|
|
@@ -0,0 +1,265 @@
|
|
|
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 lombok.RequiredArgsConstructor;
|
|
|
6
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
7
|
+import org.springframework.jdbc.core.JdbcTemplate;
|
|
|
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
|
+ * <p>
|
|
|
21
|
+ * 基于 master 真实表结构(pat_task / pat_work_order / pat_issue_report / pat_route_setup)。
|
|
|
22
|
+ * 对应设计文档 7.3 统计分析章节。
|
|
|
23
|
+ */
|
|
|
24
|
+@Slf4j
|
|
|
25
|
+@Service
|
|
|
26
|
+@RequiredArgsConstructor
|
|
|
27
|
+public class PatrolStatsService {
|
|
|
28
|
+
|
|
|
29
|
+ private final JdbcTemplate jdbc;
|
|
|
30
|
+
|
|
|
31
|
+ /** 默认统计区间天数 */
|
|
|
32
|
+ private static final int DEFAULT_DAYS = 30;
|
|
|
33
|
+
|
|
|
34
|
+ // ==================== 区间解析 ====================
|
|
|
35
|
+
|
|
|
36
|
+ /**
|
|
|
37
|
+ * 解析请求日期区间,缺省取最近 30 天。返回 [start, end, days]。
|
|
|
38
|
+ */
|
|
|
39
|
+ private LocalDate[] resolveRange(StatsQueryRequest req) {
|
|
|
40
|
+ LocalDate end = (req == null || req.getEndDate() == null) ? LocalDate.now() : req.getEndDate();
|
|
|
41
|
+ LocalDate start = (req == null || req.getStartDate() == null)
|
|
|
42
|
+ ? end.minusDays(DEFAULT_DAYS - 1L) : req.getStartDate();
|
|
|
43
|
+ return new LocalDate[]{start, end};
|
|
|
44
|
+ }
|
|
|
45
|
+
|
|
|
46
|
+ // ==================== 综合看板 ====================
|
|
|
47
|
+
|
|
|
48
|
+ /**
|
|
|
49
|
+ * 综合看板概览(对应 GET /api/patrol/stats/summary)
|
|
|
50
|
+ */
|
|
|
51
|
+ public StatsSummary dashboardSummary(StatsQueryRequest req) {
|
|
|
52
|
+ LocalDate[] range = resolveRange(req);
|
|
|
53
|
+ LocalDate start = range[0], end = range[1];
|
|
|
54
|
+ long days = Math.max(ChronoUnit.DAYS.between(start, end) + 1, 1);
|
|
|
55
|
+ log.info("巡检综合看板: {} ~ {} ({}天)", start, end, days);
|
|
|
56
|
+
|
|
|
57
|
+ StatsSummary s = new StatsSummary();
|
|
|
58
|
+ s.setDays(days);
|
|
|
59
|
+
|
|
|
60
|
+ Integer totalTasks = jdbc.queryForObject(
|
|
|
61
|
+ "SELECT COUNT(*) FROM pat_task WHERE created_at >= ?::date AND created_at < (?::date + interval '1 day')",
|
|
|
62
|
+ Integer.class, start, end);
|
|
|
63
|
+ Integer completedTasks = jdbc.queryForObject(
|
|
|
64
|
+ "SELECT COUNT(*) FROM pat_task WHERE status = 'completed' AND created_at >= ?::date AND created_at < (?::date + interval '1 day')",
|
|
|
65
|
+ Integer.class, start, end);
|
|
|
66
|
+ int total = totalTasks == null ? 0 : totalTasks;
|
|
|
67
|
+ int completed = completedTasks == null ? 0 : completedTasks;
|
|
|
68
|
+ s.setTotalTasks(total);
|
|
|
69
|
+ s.setCompletedTasks(completed);
|
|
|
70
|
+ s.setExecutionRate(rate(completed, total));
|
|
|
71
|
+
|
|
|
72
|
+ Double totalDistance = jdbc.queryForObject(
|
|
|
73
|
+ "SELECT COALESCE(SUM(distance), 0) FROM pat_task WHERE created_at >= ?::date AND created_at < (?::date + interval '1 day')",
|
|
|
74
|
+ Double.class, start, end);
|
|
|
75
|
+ BigDecimal dist = totalDistance == null ? BigDecimal.ZERO : BigDecimal.valueOf(totalDistance);
|
|
|
76
|
+ s.setTotalDistance(dist);
|
|
|
77
|
+ s.setAvgDailyDistance(dist.divide(BigDecimal.valueOf(days), 2, RoundingMode.HALF_UP));
|
|
|
78
|
+
|
|
|
79
|
+ Integer totalIssues = jdbc.queryForObject(
|
|
|
80
|
+ "SELECT COUNT(*) FROM pat_issue_report WHERE created_at >= ?::date AND created_at < (?::date + interval '1 day')",
|
|
|
81
|
+ Integer.class, start, end);
|
|
|
82
|
+ Integer resolvedIssues = jdbc.queryForObject(
|
|
|
83
|
+ "SELECT COUNT(*) FROM pat_issue_report WHERE status IN ('resolved','closed') AND created_at >= ?::date AND created_at < (?::date + interval '1 day')",
|
|
|
84
|
+ Integer.class, start, end);
|
|
|
85
|
+ int issues = totalIssues == null ? 0 : totalIssues;
|
|
|
86
|
+ int resolved = resolvedIssues == null ? 0 : resolvedIssues;
|
|
|
87
|
+ s.setTotalIssues(issues);
|
|
|
88
|
+ s.setResolvedIssues(resolved);
|
|
|
89
|
+ s.setResolutionRate(rate(resolved, issues));
|
|
|
90
|
+
|
|
|
91
|
+ Integer inspectorCount = jdbc.queryForObject(
|
|
|
92
|
+ "SELECT COUNT(DISTINCT worker_id) FROM pat_task WHERE worker_id IS NOT NULL AND created_at >= ?::date AND created_at < (?::date + interval '1 day')",
|
|
|
93
|
+ Integer.class, start, end);
|
|
|
94
|
+ Integer activeRoutes = jdbc.queryForObject(
|
|
|
95
|
+ "SELECT COUNT(DISTINCT route_id) FROM pat_task WHERE route_id IS NOT NULL AND created_at >= ?::date AND created_at < (?::date + interval '1 day')",
|
|
|
96
|
+ Integer.class, start, end);
|
|
|
97
|
+ s.setInspectorCount(inspectorCount == null ? 0 : inspectorCount);
|
|
|
98
|
+ s.setActiveRoutes(activeRoutes == null ? 0 : activeRoutes);
|
|
|
99
|
+ return s;
|
|
|
100
|
+ }
|
|
|
101
|
+
|
|
|
102
|
+ // ==================== 执行率统计 ====================
|
|
|
103
|
+
|
|
|
104
|
+ /**
|
|
|
105
|
+ * 按巡检员统计执行率(对应 GET /api/patrol/stats/completion-rate?dim=inspector)
|
|
|
106
|
+ */
|
|
|
107
|
+ public List<Map<String, Object>> completionRateByInspector(StatsQueryRequest req) {
|
|
|
108
|
+ LocalDate[] range = resolveRange(req);
|
|
|
109
|
+ log.info("执行率(按巡检员): {} ~ {}", range[0], range[1]);
|
|
|
110
|
+ return jdbc.queryForList(
|
|
|
111
|
+ "SELECT worker_id AS inspectorId, worker_name AS inspectorName, " +
|
|
|
112
|
+ "COUNT(*) AS total, " +
|
|
|
113
|
+ "COUNT(*) FILTER (WHERE status = 'completed') AS completed, " +
|
|
|
114
|
+ "ROUND(COUNT(*) FILTER (WHERE status = 'completed') * 100.0 / NULLIF(COUNT(*), 0), 2) AS completionRate " +
|
|
|
115
|
+ "FROM pat_task WHERE worker_id IS NOT NULL " +
|
|
|
116
|
+ "AND created_at >= ?::date AND created_at < (?::date + interval '1 day') " +
|
|
|
117
|
+ "GROUP BY worker_id, worker_name ORDER BY completionRate DESC NULLS LAST",
|
|
|
118
|
+ range[0], range[1]);
|
|
|
119
|
+ }
|
|
|
120
|
+
|
|
|
121
|
+ /**
|
|
|
122
|
+ * 按路线统计执行率(对应 GET /api/patrol/stats/completion-rate?dim=route)
|
|
|
123
|
+ */
|
|
|
124
|
+ public List<Map<String, Object>> completionRateByRoute(StatsQueryRequest req) {
|
|
|
125
|
+ LocalDate[] range = resolveRange(req);
|
|
|
126
|
+ log.info("执行率(按路线): {} ~ {}", range[0], range[1]);
|
|
|
127
|
+ return jdbc.queryForList(
|
|
|
128
|
+ "SELECT t.route_id AS routeId, COALESCE(r.route_name, ('路线#' || t.route_id)) AS routeName, " +
|
|
|
129
|
+ "COUNT(*) AS total, " +
|
|
|
130
|
+ "COUNT(*) FILTER (WHERE t.status = 'completed') AS completed, " +
|
|
|
131
|
+ "ROUND(COUNT(*) FILTER (WHERE t.status = 'completed') * 100.0 / NULLIF(COUNT(*), 0), 2) AS completionRate " +
|
|
|
132
|
+ "FROM pat_task t LEFT JOIN pat_route_setup r ON t.route_id = r.id " +
|
|
|
133
|
+ "WHERE t.route_id IS NOT NULL " +
|
|
|
134
|
+ "AND t.created_at >= ?::date AND t.created_at < (?::date + interval '1 day') " +
|
|
|
135
|
+ "GROUP BY t.route_id, r.route_name ORDER BY completionRate DESC NULLS LAST",
|
|
|
136
|
+ range[0], range[1]);
|
|
|
137
|
+ }
|
|
|
138
|
+
|
|
|
139
|
+ /**
|
|
|
140
|
+ * 按时间段统计执行率(按天,对应 GET /api/patrol/stats/completion-rate?dim=period)
|
|
|
141
|
+ */
|
|
|
142
|
+ public List<Map<String, Object>> completionRateByPeriod(StatsQueryRequest req) {
|
|
|
143
|
+ LocalDate[] range = resolveRange(req);
|
|
|
144
|
+ log.info("执行率(按时间段): {} ~ {}", range[0], range[1]);
|
|
|
145
|
+ return jdbc.queryForList(
|
|
|
146
|
+ "SELECT DATE(created_at) AS period, COUNT(*) AS total, " +
|
|
|
147
|
+ "COUNT(*) FILTER (WHERE status = 'completed') AS completed, " +
|
|
|
148
|
+ "ROUND(COUNT(*) FILTER (WHERE status = 'completed') * 100.0 / NULLIF(COUNT(*), 0), 2) AS completionRate " +
|
|
|
149
|
+ "FROM pat_task " +
|
|
|
150
|
+ "WHERE created_at >= ?::date AND created_at < (?::date + interval '1 day') " +
|
|
|
151
|
+ "GROUP BY DATE(created_at) ORDER BY period",
|
|
|
152
|
+ range[0], range[1]);
|
|
|
153
|
+ }
|
|
|
154
|
+
|
|
|
155
|
+ // ==================== 里程统计 ====================
|
|
|
156
|
+
|
|
|
157
|
+ /**
|
|
|
158
|
+ * 里程概览(对应 GET /api/patrol/stats/mileage)
|
|
|
159
|
+ */
|
|
|
160
|
+ public Map<String, Object> mileageSummary(StatsQueryRequest req) {
|
|
|
161
|
+ LocalDate[] range = resolveRange(req);
|
|
|
162
|
+ long days = Math.max(ChronoUnit.DAYS.between(range[0], range[1]) + 1, 1);
|
|
|
163
|
+ log.info("里程概览: {} ~ {}", range[0], range[1]);
|
|
|
164
|
+ Map<String, Object> r = new HashMap<>();
|
|
|
165
|
+ Double total = jdbc.queryForObject(
|
|
|
166
|
+ "SELECT COALESCE(SUM(distance), 0) FROM pat_task WHERE created_at >= ?::date AND created_at < (?::date + interval '1 day')",
|
|
|
167
|
+ Double.class, range[0], range[1]);
|
|
|
168
|
+ BigDecimal t = total == null ? BigDecimal.ZERO : BigDecimal.valueOf(total);
|
|
|
169
|
+ r.put("totalDistance", t);
|
|
|
170
|
+ r.put("avgDailyDistance", t.divide(BigDecimal.valueOf(days), 2, RoundingMode.HALF_UP));
|
|
|
171
|
+ r.put("days", days);
|
|
|
172
|
+ return r;
|
|
|
173
|
+ }
|
|
|
174
|
+
|
|
|
175
|
+ /**
|
|
|
176
|
+ * 按巡检员统计里程
|
|
|
177
|
+ */
|
|
|
178
|
+ public List<Map<String, Object>> mileageByInspector(StatsQueryRequest req) {
|
|
|
179
|
+ LocalDate[] range = resolveRange(req);
|
|
|
180
|
+ log.info("里程(按巡检员): {} ~ {}", range[0], range[1]);
|
|
|
181
|
+ return jdbc.queryForList(
|
|
|
182
|
+ "SELECT worker_id AS inspectorId, worker_name AS inspectorName, " +
|
|
|
183
|
+ "COALESCE(SUM(distance), 0) AS totalDistance, COUNT(*) AS taskCount " +
|
|
|
184
|
+ "FROM pat_task WHERE worker_id IS NOT NULL " +
|
|
|
185
|
+ "AND created_at >= ?::date AND created_at < (?::date + interval '1 day') " +
|
|
|
186
|
+ "GROUP BY worker_id, worker_name ORDER BY totalDistance DESC",
|
|
|
187
|
+ range[0], range[1]);
|
|
|
188
|
+ }
|
|
|
189
|
+
|
|
|
190
|
+ /**
|
|
|
191
|
+ * 里程趋势(按天)
|
|
|
192
|
+ */
|
|
|
193
|
+ public List<Map<String, Object>> mileageTrend(StatsQueryRequest req) {
|
|
|
194
|
+ LocalDate[] range = resolveRange(req);
|
|
|
195
|
+ log.info("里程趋势: {} ~ {}", range[0], range[1]);
|
|
|
196
|
+ return jdbc.queryForList(
|
|
|
197
|
+ "SELECT DATE(created_at) AS date, COALESCE(SUM(distance), 0) AS distance, COUNT(*) AS taskCount " +
|
|
|
198
|
+ "FROM pat_task " +
|
|
|
199
|
+ "WHERE created_at >= ?::date AND created_at < (?::date + interval '1 day') " +
|
|
|
200
|
+ "GROUP BY DATE(created_at) ORDER BY date",
|
|
|
201
|
+ range[0], range[1]);
|
|
|
202
|
+ }
|
|
|
203
|
+
|
|
|
204
|
+ // ==================== 问题分类统计 ====================
|
|
|
205
|
+
|
|
|
206
|
+ /**
|
|
|
207
|
+ * 问题类型分布(对应 GET /api/patrol/stats/issues?dim=type)
|
|
|
208
|
+ */
|
|
|
209
|
+ public List<Map<String, Object>> issueTypeDistribution(StatsQueryRequest req) {
|
|
|
210
|
+ LocalDate[] range = resolveRange(req);
|
|
|
211
|
+ log.info("问题类型分布: {} ~ {}", range[0], range[1]);
|
|
|
212
|
+ return jdbc.queryForList(
|
|
|
213
|
+ "SELECT COALESCE(NULLIF(issue_type, ''), '未分类') AS issueType, COUNT(*) AS count " +
|
|
|
214
|
+ "FROM pat_issue_report " +
|
|
|
215
|
+ "WHERE created_at >= ?::date AND created_at < (?::date + interval '1 day') " +
|
|
|
216
|
+ "GROUP BY COALESCE(NULLIF(issue_type, ''), '未分类') ORDER BY count DESC",
|
|
|
217
|
+ range[0], range[1]);
|
|
|
218
|
+ }
|
|
|
219
|
+
|
|
|
220
|
+ /**
|
|
|
221
|
+ * 问题严重度分布
|
|
|
222
|
+ */
|
|
|
223
|
+ public List<Map<String, Object>> issueSeverityDistribution(StatsQueryRequest req) {
|
|
|
224
|
+ LocalDate[] range = resolveRange(req);
|
|
|
225
|
+ log.info("问题严重度分布: {} ~ {}", range[0], range[1]);
|
|
|
226
|
+ return jdbc.queryForList(
|
|
|
227
|
+ "SELECT COALESCE(NULLIF(severity, ''), 'unknown') AS severity, COUNT(*) AS count " +
|
|
|
228
|
+ "FROM pat_issue_report " +
|
|
|
229
|
+ "WHERE created_at >= ?::date AND created_at < (?::date + interval '1 day') " +
|
|
|
230
|
+ "GROUP BY COALESCE(NULLIF(severity, ''), 'unknown') ORDER BY count DESC",
|
|
|
231
|
+ range[0], range[1]);
|
|
|
232
|
+ }
|
|
|
233
|
+
|
|
|
234
|
+ /**
|
|
|
235
|
+ * 问题解决率(对应 GET /api/patrol/stats/issues?dim=resolution)
|
|
|
236
|
+ */
|
|
|
237
|
+ public Map<String, Object> issueResolutionStats(StatsQueryRequest req) {
|
|
|
238
|
+ LocalDate[] range = resolveRange(req);
|
|
|
239
|
+ log.info("问题解决率: {} ~ {}", range[0], range[1]);
|
|
|
240
|
+ Integer total = jdbc.queryForObject(
|
|
|
241
|
+ "SELECT COUNT(*) FROM pat_issue_report WHERE created_at >= ?::date AND created_at < (?::date + interval '1 day')",
|
|
|
242
|
+ Integer.class, range[0], range[1]);
|
|
|
243
|
+ Integer resolved = jdbc.queryForObject(
|
|
|
244
|
+ "SELECT COUNT(*) FROM pat_issue_report WHERE status IN ('resolved','closed') " +
|
|
|
245
|
+ "AND created_at >= ?::date AND created_at < (?::date + interval '1 day')",
|
|
|
246
|
+ Integer.class, range[0], range[1]);
|
|
|
247
|
+ int t = total == null ? 0 : total;
|
|
|
248
|
+ int r = resolved == null ? 0 : resolved;
|
|
|
249
|
+ Map<String, Object> m = new HashMap<>();
|
|
|
250
|
+ m.put("total", t);
|
|
|
251
|
+ m.put("resolved", r);
|
|
|
252
|
+ m.put("resolutionRate", rate(r, t));
|
|
|
253
|
+ return m;
|
|
|
254
|
+ }
|
|
|
255
|
+
|
|
|
256
|
+ // ==================== 工具方法 ====================
|
|
|
257
|
+
|
|
|
258
|
+ /** 计算百分比,分母为 0 返回 0 */
|
|
|
259
|
+ private BigDecimal rate(int part, int whole) {
|
|
|
260
|
+ if (whole <= 0) {
|
|
|
261
|
+ return BigDecimal.ZERO;
|
|
|
262
|
+ }
|
|
|
263
|
+ return BigDecimal.valueOf(part * 100.0 / whole).setScale(2, RoundingMode.HALF_UP);
|
|
|
264
|
+ }
|
|
|
265
|
+}
|