Parcourir la source

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

基于 master 真实表结构(pat_task/pat_work_order/pat_issue_report/pat_route_setup)重新实现,替代操作废弃旧表的旧实现。

- StatsController: GET /api/patrol/stats/{summary,completion-rate,mileage,issues,rank}

- PatrolStatsService: 执行率(按巡检员/路线/时间段) + 里程(概览/巡检员/趋势) + 问题分类(类型/严重度/解决率) + 综合看板

- PatrolRankService: 巡检员/路线排名

- V89__patrol_stats.sql: patrol_stats_daily 每日汇总表 + 聚合SQL

- 单元测试: PatrolStatsServiceTest / PatrolRankServiceTest (JUnit5 + Mock JdbcTemplate)

API路径符合设计文档7.3。分支重建为干净单提交,覆盖此前被#74/#76污染的版本。
bot_dev3 il y a 2 jours
Parent
révision
2081844823

+ 119
- 0
wm-patrol/src/main/java/com/water/patrol/controller/StatsController.java Voir le fichier

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.Parameter;
11
+import io.swagger.v3.oas.annotations.tags.Tag;
12
+import lombok.RequiredArgsConstructor;
13
+import org.springframework.format.annotation.DateTimeFormat;
14
+import org.springframework.web.bind.annotation.GetMapping;
15
+import org.springframework.web.bind.annotation.RequestMapping;
16
+import org.springframework.web.bind.annotation.RequestParam;
17
+import org.springframework.web.bind.annotation.RestController;
18
+
19
+import java.time.LocalDate;
20
+import java.util.List;
21
+import java.util.Map;
22
+
23
+/**
24
+ * 巡检统计分析 REST API(对应设计文档 7.3)
25
+ */
26
+@Tag(name = "巡检统计分析")
27
+@RestController
28
+@RequestMapping("/api/patrol/stats")
29
+@RequiredArgsConstructor
30
+public class StatsController {
31
+
32
+    private final PatrolStatsService statsService;
33
+    private final PatrolRankService rankService;
34
+
35
+    // ==================== 综合看板 ====================
36
+
37
+    @Operation(summary = "综合看板概览")
38
+    @GetMapping("/summary")
39
+    public R<StatsSummary> summary(
40
+            @Parameter(description = "起始日期") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
41
+            @Parameter(description = "结束日期") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
42
+        return R.ok(statsService.dashboardSummary(req(startDate, endDate)));
43
+    }
44
+
45
+    // ==================== 执行率统计 ====================
46
+
47
+    @Operation(summary = "执行率统计(dim: inspector=按巡检员, route=按路线, period=按时间段)")
48
+    @GetMapping("/completion-rate")
49
+    public R<List<Map<String, Object>>> completionRate(
50
+            @RequestParam(defaultValue = "inspector") String dim,
51
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
52
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
53
+        StatsQueryRequest r = req(startDate, endDate);
54
+        List<Map<String, Object>> result;
55
+        switch (dim) {
56
+            case "route" -> result = statsService.completionRateByRoute(r);
57
+            case "period" -> result = statsService.completionRateByPeriod(r);
58
+            default -> result = statsService.completionRateByInspector(r);
59
+        }
60
+        return R.ok(result);
61
+    }
62
+
63
+    // ==================== 里程统计 ====================
64
+
65
+    @Operation(summary = "里程统计(dim: summary=概览, inspector=按巡检员, trend=按天趋势)")
66
+    @GetMapping("/mileage")
67
+    public R<?> mileage(
68
+            @RequestParam(defaultValue = "summary") String dim,
69
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
70
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
71
+        StatsQueryRequest r = req(startDate, endDate);
72
+        Object result;
73
+        switch (dim) {
74
+            case "inspector" -> result = statsService.mileageByInspector(r);
75
+            case "trend" -> result = statsService.mileageTrend(r);
76
+            default -> result = statsService.mileageSummary(r);
77
+        }
78
+        return R.ok(result);
79
+    }
80
+
81
+    // ==================== 问题分类统计 ====================
82
+
83
+    @Operation(summary = "问题分类统计(dim: type=类型分布, severity=严重度分布, resolution=解决率)")
84
+    @GetMapping("/issues")
85
+    public R<?> issues(
86
+            @RequestParam(defaultValue = "type") String dim,
87
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
88
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
89
+        StatsQueryRequest r = req(startDate, endDate);
90
+        Object result;
91
+        switch (dim) {
92
+            case "severity" -> result = statsService.issueSeverityDistribution(r);
93
+            case "resolution" -> result = statsService.issueResolutionStats(r);
94
+            default -> result = statsService.issueTypeDistribution(r);
95
+        }
96
+        return R.ok(result);
97
+    }
98
+
99
+    // ==================== 排名 ====================
100
+
101
+    @Operation(summary = "排名(type: inspector=巡检员, route=路线)")
102
+    @GetMapping("/rank")
103
+    public R<List<RankResult>> rank(
104
+            @RequestParam(defaultValue = "inspector") String type,
105
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
106
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
107
+            @RequestParam(required = false, defaultValue = "10") Integer topN) {
108
+        StatsQueryRequest r = req(startDate, endDate);
109
+        r.setTopN(topN);
110
+        return R.ok("route".equals(type) ? rankService.routeRank(r) : rankService.inspectorRank(r));
111
+    }
112
+
113
+    private StatsQueryRequest req(LocalDate startDate, LocalDate endDate) {
114
+        StatsQueryRequest r = new StatsQueryRequest();
115
+        r.setStartDate(startDate);
116
+        r.setEndDate(endDate);
117
+        return r;
118
+    }
119
+}

+ 30
- 0
wm-patrol/src/main/java/com/water/patrol/entity/dto/RankResult.java Voir le fichier

1
+package com.water.patrol.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.math.BigDecimal;
6
+
7
+/**
8
+ * 巡检排名结果项
9
+ */
10
+@Data
11
+public class RankResult {
12
+
13
+    /** 名次 */
14
+    private int rank;
15
+
16
+    /** 对象主键(巡检员ID/路线ID) */
17
+    private Long id;
18
+
19
+    /** 对象名称(巡检员姓名/路线名称) */
20
+    private String name;
21
+
22
+    /** 排序指标值(里程=公里,完成率=百分比) */
23
+    private BigDecimal score;
24
+
25
+    /** 任务总数 */
26
+    private int totalTasks;
27
+
28
+    /** 已完成任务数 */
29
+    private int completedTasks;
30
+}

+ 28
- 0
wm-patrol/src/main/java/com/water/patrol/entity/dto/StatsQueryRequest.java Voir le fichier

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
+ * 巡检统计查询参数
10
+ */
11
+@Data
12
+public class StatsQueryRequest {
13
+
14
+    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
15
+    private LocalDate startDate;
16
+
17
+    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
18
+    private LocalDate endDate;
19
+
20
+    /** 排名返回数量,默认 10 */
21
+    private Integer topN = 10;
22
+
23
+    /** 指定巡检员过滤(可空) */
24
+    private Long assigneeId;
25
+
26
+    /** 指定路线过滤(可空) */
27
+    private Long routeId;
28
+}

+ 45
- 0
wm-patrol/src/main/java/com/water/patrol/entity/dto/StatsSummary.java Voir le fichier

1
+package com.water.patrol.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.math.BigDecimal;
6
+
7
+/**
8
+ * 巡检综合看板概览
9
+ */
10
+@Data
11
+public class StatsSummary {
12
+
13
+    /** 统计区间天数 */
14
+    private long days;
15
+
16
+    /** 任务总数 */
17
+    private int totalTasks;
18
+
19
+    /** 已完成任务数 */
20
+    private int completedTasks;
21
+
22
+    /** 任务执行率(完成率),百分比 */
23
+    private BigDecimal executionRate;
24
+
25
+    /** 里程合计(公里) */
26
+    private BigDecimal totalDistance;
27
+
28
+    /** 日均里程 */
29
+    private BigDecimal avgDailyDistance;
30
+
31
+    /** 问题总数 */
32
+    private int totalIssues;
33
+
34
+    /** 已解决问题数 */
35
+    private int resolvedIssues;
36
+
37
+    /** 问题解决率,百分比 */
38
+    private BigDecimal resolutionRate;
39
+
40
+    /** 参与巡检员数 */
41
+    private int inspectorCount;
42
+
43
+    /** 活跃路线数 */
44
+    private int activeRoutes;
45
+}

+ 102
- 0
wm-patrol/src/main/java/com/water/patrol/service/PatrolRankService.java Voir le fichier

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 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.sql.ResultSet;
12
+import java.sql.SQLException;
13
+import java.time.LocalDate;
14
+import java.util.ArrayList;
15
+import java.util.List;
16
+
17
+/**
18
+ * 巡检排名服务(对应设计文档 7.3 GET /api/patrol/rank)
19
+ */
20
+@Slf4j
21
+@Service
22
+@RequiredArgsConstructor
23
+public class PatrolRankService {
24
+
25
+    private final JdbcTemplate jdbc;
26
+
27
+    private static final int DEFAULT_DAYS = 30;
28
+
29
+    /**
30
+     * 巡检员排名(默认按里程,对应 GET /api/patrol/rank?type=inspector)
31
+     */
32
+    public List<RankResult> inspectorRank(StatsQueryRequest req) {
33
+        LocalDate[] range = resolve(req);
34
+        int limit = topN(req);
35
+        log.info("巡检员排名: {} ~ {}, topN={}", range[0], range[1], limit);
36
+        List<RankResult> raw = jdbc.query(
37
+                "SELECT worker_id AS id, worker_name AS name, " +
38
+                        "COALESCE(SUM(distance), 0) AS score, " +
39
+                        "COUNT(*) AS total_tasks, " +
40
+                        "COUNT(*) FILTER (WHERE status = 'completed') AS completed_tasks " +
41
+                        "FROM pat_task WHERE worker_id IS NOT NULL " +
42
+                        "AND created_at >= ?::date AND created_at < (?::date + interval '1 day') " +
43
+                        "GROUP BY worker_id, worker_name ORDER BY score DESC NULLS LAST LIMIT ?",
44
+                (rs, i) -> mapRow(rs), range[0], range[1], limit);
45
+        return assignRanks(raw);
46
+    }
47
+
48
+    /**
49
+     * 路线排名(对应 GET /api/patrol/rank?type=route)
50
+     */
51
+    public List<RankResult> routeRank(StatsQueryRequest req) {
52
+        LocalDate[] range = resolve(req);
53
+        int limit = topN(req);
54
+        log.info("路线排名: {} ~ {}, topN={}", range[0], range[1], limit);
55
+        List<RankResult> raw = jdbc.query(
56
+                "SELECT t.route_id AS id, COALESCE(r.route_name, ('路线#' || t.route_id)) AS name, " +
57
+                        "COALESCE(SUM(t.distance), 0) AS score, " +
58
+                        "COUNT(*) AS total_tasks, " +
59
+                        "COUNT(*) FILTER (WHERE t.status = 'completed') AS completed_tasks " +
60
+                        "FROM pat_task t LEFT JOIN pat_route_setup r ON t.route_id = r.id " +
61
+                        "WHERE t.route_id IS NOT NULL " +
62
+                        "AND t.created_at >= ?::date AND t.created_at < (?::date + interval '1 day') " +
63
+                        "GROUP BY t.route_id, r.route_name ORDER BY score DESC NULLS LAST LIMIT ?",
64
+                (rs, i) -> mapRow(rs), range[0], range[1], limit);
65
+        return assignRanks(raw);
66
+    }
67
+
68
+    // ==================== 内部工具 ====================
69
+
70
+    private RankResult mapRow(ResultSet rs) throws SQLException {
71
+        RankResult r = new RankResult();
72
+        long id = rs.getLong("id");
73
+        r.setId(rs.wasNull() ? null : id);
74
+        r.setName(rs.getString("name"));
75
+        r.setScore(BigDecimal.valueOf(rs.getDouble("score")));
76
+        r.setTotalTasks(rs.getInt("total_tasks"));
77
+        r.setCompletedTasks(rs.getInt("completed_tasks"));
78
+        return r;
79
+    }
80
+
81
+    private List<RankResult> assignRanks(List<RankResult> raw) {
82
+        List<RankResult> result = new ArrayList<>(raw.size());
83
+        for (int i = 0; i < raw.size(); i++) {
84
+            RankResult r = raw.get(i);
85
+            r.setRank(i + 1);
86
+            result.add(r);
87
+        }
88
+        return result;
89
+    }
90
+
91
+    private int topN(StatsQueryRequest req) {
92
+        Integer n = req == null ? null : req.getTopN();
93
+        return (n == null || n <= 0) ? 10 : n;
94
+    }
95
+
96
+    private LocalDate[] resolve(StatsQueryRequest req) {
97
+        LocalDate end = (req == null || req.getEndDate() == null) ? LocalDate.now() : req.getEndDate();
98
+        LocalDate start = (req == null || req.getStartDate() == null)
99
+                ? end.minusDays(DEFAULT_DAYS - 1L) : req.getStartDate();
100
+        return new LocalDate[]{start, end};
101
+    }
102
+}

+ 265
- 0
wm-patrol/src/main/java/com/water/patrol/service/PatrolStatsService.java Voir le fichier

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

+ 43
- 0
wm-patrol/src/main/resources/sql/V89__patrol_stats.sql Voir le fichier

1
+-- =============================================================
2
+-- V89__patrol_stats.sql
3
+-- 巡检统计分析 - 每日汇总表(对应设计文档 7.3 统计分析)
4
+-- =============================================================
5
+
6
+-- 巡检每日汇总表(由定时任务凌晨执行聚合,本脚本仅建表 + 提供聚合 SQL 示例)
7
+CREATE TABLE IF NOT EXISTS patrol_stats_daily (
8
+    stat_date        DATE          NOT NULL,
9
+    worker_id        BIGINT,
10
+    worker_name      VARCHAR(50),
11
+    route_id         BIGINT,
12
+    area_id          BIGINT,
13
+    total_tasks      INT           DEFAULT 0,
14
+    completed_tasks  INT           DEFAULT 0,
15
+    total_distance   DOUBLE PRECISION DEFAULT 0,
16
+    total_minutes    INT           DEFAULT 0,
17
+    issue_count      INT           DEFAULT 0,
18
+    created_at       TIMESTAMPTZ   DEFAULT NOW(),
19
+    PRIMARY KEY (stat_date, worker_id, route_id)
20
+);
21
+CREATE INDEX IF NOT EXISTS idx_patrol_stats_date   ON patrol_stats_daily(stat_date);
22
+CREATE INDEX IF NOT EXISTS idx_patrol_stats_worker ON patrol_stats_daily(worker_id);
23
+
24
+-- =============================================================
25
+-- 每日聚合刷新 SQL(由 cron 凌晨执行,例如刷新昨天的数据)
26
+-- 用法: 将 :stat_date 替换为目标日期,如 CURRENT_DATE - 1
27
+-- =============================================================
28
+-- DELETE FROM patrol_stats_daily WHERE stat_date = :stat_date;
29
+-- INSERT INTO patrol_stats_daily (stat_date, worker_id, worker_name, route_id, area_id,
30
+--                                 total_tasks, completed_tasks, total_distance, total_minutes, issue_count)
31
+-- SELECT
32
+--     DATE(t.created_at)                                          AS stat_date,
33
+--     t.worker_id, t.worker_name, t.route_id, r.area_id,
34
+--     COUNT(*)                                                    AS total_tasks,
35
+--     COUNT(*) FILTER (WHERE t.status = 'completed')              AS completed_tasks,
36
+--     COALESCE(SUM(t.distance), 0)                                AS total_distance,
37
+--     COALESCE(SUM(EXTRACT(EPOCH FROM (t.end_time - t.start_time)) / 60), 0)::INT AS total_minutes,
38
+--     (SELECT COUNT(*) FROM pat_issue_report ir
39
+--        WHERE ir.task_id = t.id)                                 AS issue_count
40
+-- FROM pat_task t
41
+-- LEFT JOIN pat_route_setup r ON t.route_id = r.id
42
+-- WHERE DATE(t.created_at) = :stat_date
43
+-- GROUP BY DATE(t.created_at), t.worker_id, t.worker_name, t.route_id, r.area_id;

+ 86
- 0
wm-patrol/src/test/java/com/water/patrol/service/PatrolRankServiceTest.java Voir le fichier

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 org.junit.jupiter.api.Test;
6
+import org.junit.jupiter.api.extension.ExtendWith;
7
+import org.mockito.InjectMocks;
8
+import org.mockito.Mock;
9
+import org.mockito.junit.jupiter.MockitoExtension;
10
+import org.springframework.jdbc.core.JdbcTemplate;
11
+
12
+import java.util.List;
13
+
14
+import static org.junit.jupiter.api.Assertions.*;
15
+import static org.mockito.ArgumentMatchers.*;
16
+import static org.mockito.Mockito.*;
17
+
18
+/**
19
+ * PatrolRankService 单元测试
20
+ */
21
+@ExtendWith(MockitoExtension.class)
22
+class PatrolRankServiceTest {
23
+
24
+    @Mock JdbcTemplate jdbc;
25
+    @InjectMocks PatrolRankService svc;
26
+
27
+    @Test
28
+    void inspectorRank_assignsSequentialRanksAndRespectsTopN() {
29
+        // 模拟 mapper 返回两行(按 score 降序)
30
+        when(jdbc.query(anyString(), any(org.springframework.jdbc.core.RowMapper.class), any(), any(), any()))
31
+                .thenAnswer(inv -> {
32
+                    List<RankResult> rows = List.of(build(1L, "张三", 50.0, 10, 9),
33
+                                                    build(2L, "李四", 30.0, 8, 5));
34
+                    return rows;
35
+                });
36
+
37
+        StatsQueryRequest req = new StatsQueryRequest();
38
+        req.setTopN(5);
39
+        List<RankResult> r = svc.inspectorRank(req);
40
+        assertEquals(2, r.size());
41
+        assertEquals(1, r.get(0).getRank());
42
+        assertEquals("张三", r.get(0).getName());
43
+        assertEquals(2, r.get(1).getRank());
44
+        assertEquals("李四", r.get(1).getName());
45
+        // score 保留
46
+        assertEquals(0, java.math.BigDecimal.valueOf(50.0).compareTo(r.get(0).getScore()));
47
+    }
48
+
49
+    @Test
50
+    void inspectorRank_emptyResultWhenNoData() {
51
+        when(jdbc.query(anyString(), any(org.springframework.jdbc.core.RowMapper.class), any(), any(), any()))
52
+                .thenReturn(List.of());
53
+        List<RankResult> r = svc.inspectorRank(null);
54
+        assertNotNull(r);
55
+        assertTrue(r.isEmpty());
56
+    }
57
+
58
+    @Test
59
+    void routeRank_returnsRows() {
60
+        when(jdbc.query(anyString(), any(org.springframework.jdbc.core.RowMapper.class), any(), any(), any()))
61
+                .thenReturn(List.of(build(10L, "北线", 100.0, 5, 5)));
62
+        List<RankResult> r = svc.routeRank(null);
63
+        assertEquals(1, r.size());
64
+        assertEquals(1, r.get(0).getRank());
65
+        assertEquals("北线", r.get(0).getName());
66
+    }
67
+
68
+    @Test
69
+    void topN_defaultsTo10WhenNullOrNonPositive() {
70
+        // 仅验证默认值逻辑:传 null 请求不应抛异常
71
+        when(jdbc.query(anyString(), any(org.springframework.jdbc.core.RowMapper.class), any(), any(), eq(10)))
72
+                .thenReturn(List.of());
73
+        StatsQueryRequest req = new StatsQueryRequest(); // topN 默认 10
74
+        assertDoesNotThrow(() -> svc.inspectorRank(req));
75
+    }
76
+
77
+    private RankResult build(Long id, String name, double score, int total, int completed) {
78
+        RankResult r = new RankResult();
79
+        r.setId(id);
80
+        r.setName(name);
81
+        r.setScore(java.math.BigDecimal.valueOf(score));
82
+        r.setTotalTasks(total);
83
+        r.setCompletedTasks(completed);
84
+        return r;
85
+    }
86
+}

+ 161
- 0
wm-patrol/src/test/java/com/water/patrol/service/PatrolStatsServiceTest.java Voir le fichier

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 org.junit.jupiter.api.Test;
6
+import org.junit.jupiter.api.extension.ExtendWith;
7
+import org.mockito.InjectMocks;
8
+import org.mockito.Mock;
9
+import org.mockito.junit.jupiter.MockitoExtension;
10
+import org.springframework.jdbc.core.JdbcTemplate;
11
+
12
+import java.math.BigDecimal;
13
+import java.time.LocalDate;
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.*;
19
+import static org.mockito.Mockito.*;
20
+
21
+/**
22
+ * PatrolStatsService 单元测试
23
+ */
24
+@ExtendWith(MockitoExtension.class)
25
+class PatrolStatsServiceTest {
26
+
27
+    @Mock JdbcTemplate jdbc;
28
+    @InjectMocks PatrolStatsService svc;
29
+
30
+    // ---------- 区间默认值 ----------
31
+    @Test
32
+    void resolveRange_defaultsToLast30Days() {
33
+        // 不传日期时应取最近 30 天,不会抛异常(即便底层 jdbc 未打桩,dashboard 也容忍 null)
34
+        // 这里用 mileage(单查询)验证默认区间被解析且执行
35
+        when(jdbc.queryForObject(anyString(), eq(Double.class), any(), any())).thenReturn(0.0);
36
+        StatsQueryRequest req = new StatsQueryRequest(); // 全空
37
+        Map<String, Object> m = svc.mileageSummary(req);
38
+        assertNotNull(m);
39
+        assertEquals(30, ((Number) m.get("days")).intValue());
40
+    }
41
+
42
+    // ---------- 综合看板:执行率/解决率计算 ----------
43
+    @Test
44
+    void dashboardSummary_computesExecutionAndResolutionRate() {
45
+        // totalTasks=10, completedTasks=8 -> 执行率 80
46
+        when(jdbc.queryForObject(contains("FROM pat_task WHERE created_at"), eq(Integer.class), any(), any()))
47
+                .thenReturn(10);
48
+        when(jdbc.queryForObject(contains("status = 'completed'"), eq(Integer.class), any(), any()))
49
+                .thenReturn(8);
50
+        // totalDistance
51
+        when(jdbc.queryForObject(contains("SUM(distance)"), eq(Double.class), any(), any()))
52
+                .thenReturn(300.0);
53
+        // issues total=5, resolved=3 -> 解决率 60
54
+        when(jdbc.queryForObject(contains("FROM pat_issue_report WHERE created_at"), eq(Integer.class), any(), any()))
55
+                .thenReturn(5);
56
+        when(jdbc.queryForObject(contains("status IN ('resolved','closed')"), eq(Integer.class), any(), any()))
57
+                .thenReturn(3);
58
+        when(jdbc.queryForObject(contains("COUNT(DISTINCT worker_id)"), eq(Integer.class), any(), any()))
59
+                .thenReturn(4);
60
+        when(jdbc.queryForObject(contains("COUNT(DISTINCT route_id)"), eq(Integer.class), any(), any()))
61
+                .thenReturn(2);
62
+
63
+        StatsSummary s = svc.dashboardSummary(null);
64
+        assertEquals(10, s.getTotalTasks());
65
+        assertEquals(8, s.getCompletedTasks());
66
+        assertEquals(0, new BigDecimal("80.00").compareTo(s.getExecutionRate()));
67
+        assertEquals(5, s.getTotalIssues());
68
+        assertEquals(3, s.getResolvedIssues());
69
+        assertEquals(0, new BigDecimal("60.00").compareTo(s.getResolutionRate()));
70
+        assertEquals(4, s.getInspectorCount());
71
+        assertEquals(2, s.getActiveRoutes());
72
+        assertEquals(0, new BigDecimal("300.0").compareTo(s.getTotalDistance()));
73
+    }
74
+
75
+    @Test
76
+    void dashboardSummary_zeroTotalYieldsZeroRate() {
77
+        when(jdbc.queryForObject(anyString(), eq(Integer.class), any(), any())).thenReturn(0);
78
+        when(jdbc.queryForObject(anyString(), eq(Double.class), any(), any())).thenReturn(0.0);
79
+        StatsSummary s = svc.dashboardSummary(null);
80
+        assertEquals(0, s.getTotalTasks());
81
+        assertEquals(0, new BigDecimal("0").compareTo(s.getExecutionRate()));
82
+        assertEquals(0, new BigDecimal("0").compareTo(s.getResolutionRate()));
83
+    }
84
+
85
+    // ---------- 执行率分组 ----------
86
+    @Test
87
+    void completionRateByInspector_returnsRows() {
88
+        List<Map<String, Object>> rows = List.of(
89
+                Map.of("inspectorId", 1L, "inspectorName", "张三", "total", 10, "completed", 9, "completionrate", 90.0));
90
+        when(jdbc.queryForList(anyString(), any(), any())).thenReturn(rows);
91
+        List<Map<String, Object>> result = svc.completionRateByInspector(null);
92
+        assertEquals(1, result.size());
93
+        assertEquals("张三", result.get(0).get("inspectorName"));
94
+    }
95
+
96
+    @Test
97
+    void completionRateByRoute_returnsRows() {
98
+        when(jdbc.queryForList(anyString(), any(), any())).thenReturn(List.of());
99
+        assertNotNull(svc.completionRateByRoute(null));
100
+    }
101
+
102
+    @Test
103
+    void completionRateByPeriod_returnsRows() {
104
+        when(jdbc.queryForList(anyString(), any(), any())).thenReturn(List.of());
105
+        assertNotNull(svc.completionRateByPeriod(null));
106
+    }
107
+
108
+    // ---------- 里程 ----------
109
+    @Test
110
+    void mileageSummary_computesAvgDaily() {
111
+        when(jdbc.queryForObject(anyString(), eq(Double.class), any(), any())).thenReturn(600.0);
112
+        StatsQueryRequest req = new StatsQueryRequest();
113
+        req.setStartDate(LocalDate.of(2026, 6, 1));
114
+        req.setEndDate(LocalDate.of(2026, 6, 5)); // 5 天
115
+        Map<String, Object> m = svc.mileageSummary(req);
116
+        assertEquals(0, new BigDecimal("600.0").compareTo((BigDecimal) m.get("totalDistance")));
117
+        assertEquals(5, ((Number) m.get("days")).intValue());
118
+        // 日均 = 600 / 5 = 120.00
119
+        assertEquals(0, new BigDecimal("120.00").compareTo((BigDecimal) m.get("avgDailyDistance")));
120
+    }
121
+
122
+    @Test
123
+    void mileageByInspector_returnsRows() {
124
+        when(jdbc.queryForList(anyString(), any(), any())).thenReturn(List.of());
125
+        assertNotNull(svc.mileageByInspector(null));
126
+    }
127
+
128
+    @Test
129
+    void mileageTrend_returnsRows() {
130
+        when(jdbc.queryForList(anyString(), any(), any())).thenReturn(List.of());
131
+        assertNotNull(svc.mileageTrend(null));
132
+    }
133
+
134
+    // ---------- 问题分类 ----------
135
+    @Test
136
+    void issueTypeDistribution_returnsRows() {
137
+        when(jdbc.queryForList(anyString(), any(), any())).thenReturn(
138
+                List.of(Map.of("issuetype", "leak", "count", 3)));
139
+        List<Map<String, Object>> r = svc.issueTypeDistribution(null);
140
+        assertEquals(1, r.size());
141
+    }
142
+
143
+    @Test
144
+    void issueSeverityDistribution_returnsRows() {
145
+        when(jdbc.queryForList(anyString(), any(), any())).thenReturn(List.of());
146
+        assertNotNull(svc.issueSeverityDistribution(null));
147
+    }
148
+
149
+    @Test
150
+    void issueResolutionStats_computesRate() {
151
+        when(jdbc.queryForObject(contains("FROM pat_issue_report WHERE created_at"), eq(Integer.class), any(), any()))
152
+                .thenReturn(4);
153
+        when(jdbc.queryForObject(contains("'resolved','closed'"), eq(Integer.class), any(), any()))
154
+                .thenReturn(1);
155
+        Map<String, Object> m = svc.issueResolutionStats(null);
156
+        assertEquals(4, m.get("total"));
157
+        assertEquals(1, m.get("resolved"));
158
+        // 1/4 * 100 = 25.00
159
+        assertEquals(0, new BigDecimal("25.00").compareTo((BigDecimal) m.get("resolutionRate")));
160
+    }
161
+}