ソースを参照

feat(wm-production): #62 在线监测列表与多维筛选

bot_dev2 5 日 前
コミット
21cf0e97af

+ 90
- 0
db/postgresql/V3__monitor_list.sql ファイルの表示

@@ -0,0 +1,90 @@
1
+-- =============================================
2
+-- 智慧水务管理系统 - 在线监测列表 DDL
3
+-- 版本: V3
4
+-- 功能: 在线监测设备 + 实时数据 + 多维筛选
5
+-- =============================================
6
+
7
+-- ==================== 在线监测设备 ====================
8
+
9
+CREATE TABLE IF NOT EXISTS prod_monitor_device (
10
+    id              BIGSERIAL PRIMARY KEY,
11
+    device_code     VARCHAR(50)  NOT NULL UNIQUE,         -- 设备编号
12
+    device_name     VARCHAR(200) NOT NULL,                 -- 设备名称
13
+    device_type     VARCHAR(30)  NOT NULL,                 -- 设备类型: flow/pressure/level/quality
14
+    area            VARCHAR(50)  NOT NULL,                 -- 所属区域
15
+    location        VARCHAR(300),                          -- 安装位置描述
16
+    lng             DECIMAL(12, 8),                        -- 经度
17
+    lat             DECIMAL(12, 8),                        -- 纬度
18
+    status          VARCHAR(20)  NOT NULL DEFAULT 'offline', -- 设备状态: online/offline/fault/abnormal
19
+    last_report_time TIMESTAMP,                            -- 最后上报时间
20
+    brand           VARCHAR(100),                          -- 品牌/型号
21
+    install_time    TIMESTAMP,                             -- 安装时间
22
+    remark          VARCHAR(500),                          -- 备注
23
+    created_time    TIMESTAMP    DEFAULT NOW(),
24
+    updated_time    TIMESTAMP    DEFAULT NOW()
25
+);
26
+
27
+COMMENT ON TABLE  prod_monitor_device IS '在线监测设备表';
28
+COMMENT ON COLUMN prod_monitor_device.device_type IS '设备类型: flow(流量计)/pressure(压力计)/level(液位计)/quality(水质仪)';
29
+COMMENT ON COLUMN prod_monitor_device.status IS '设备状态: online(在线)/offline(离线)/fault(故障)/abnormal(数据异常)';
30
+
31
+CREATE INDEX IF NOT EXISTS idx_monitor_device_area       ON prod_monitor_device(area);
32
+CREATE INDEX IF NOT EXISTS idx_monitor_device_type       ON prod_monitor_device(device_type);
33
+CREATE INDEX IF NOT EXISTS idx_monitor_device_status     ON prod_monitor_device(status);
34
+CREATE INDEX IF NOT EXISTS idx_monitor_device_report     ON prod_monitor_device(last_report_time DESC);
35
+CREATE INDEX IF NOT EXISTS idx_monitor_device_code_name  ON prod_monitor_device(device_code, device_name);
36
+
37
+-- ==================== 监测实时数据 ====================
38
+
39
+CREATE TABLE IF NOT EXISTS prod_monitor_realtime_data (
40
+    id               BIGSERIAL PRIMARY KEY,
41
+    device_id        BIGINT         NOT NULL REFERENCES prod_monitor_device(id), -- 关联设备
42
+    device_code      VARCHAR(50)    NOT NULL,                                     -- 设备编号(冗余)
43
+    metric_key       VARCHAR(50)    NOT NULL,                                     -- 参数类型: flow/pressure/level/turbidity/ph/residual_chlorine/temperature
44
+    metric_value     DECIMAL(14, 4) NOT NULL,                                     -- 实时值
45
+    unit             VARCHAR(20),                                                 -- 单位
46
+    threshold_high   DECIMAL(14, 4),                                              -- 阈值上限
47
+    threshold_low    DECIMAL(14, 4),                                              -- 阈值下限
48
+    is_abnormal      SMALLINT       DEFAULT 0,                                    -- 是否异常: 0正常 1异常
49
+    collect_time     TIMESTAMP      NOT NULL,                                     -- 采集时间
50
+    created_time     TIMESTAMP      DEFAULT NOW()
51
+);
52
+
53
+COMMENT ON TABLE  prod_monitor_realtime_data IS '监测实时数据表';
54
+COMMENT ON COLUMN prod_monitor_realtime_data.metric_key IS '参数类型: flow(流量)/pressure(压力)/level(液位)/turbidity(浊度)/ph(pH值)/residual_chlorine(余氯)/temperature(温度)';
55
+
56
+CREATE INDEX IF NOT EXISTS idx_realtime_device_id     ON prod_monitor_realtime_data(device_id);
57
+CREATE INDEX IF NOT EXISTS idx_realtime_device_code   ON prod_monitor_realtime_data(device_code);
58
+CREATE INDEX IF NOT EXISTS idx_realtime_metric_key    ON prod_monitor_realtime_data(metric_key);
59
+CREATE INDEX IF NOT EXISTS idx_realtime_collect_time  ON prod_monitor_realtime_data(collect_time DESC);
60
+CREATE INDEX IF NOT EXISTS idx_realtime_device_metric ON prod_monitor_realtime_data(device_id, metric_key, collect_time DESC);
61
+
62
+-- ==================== 初始化数据(示例) ====================
63
+
64
+INSERT INTO prod_monitor_device (device_code, device_name, device_type, area, location, lng, lat, status, last_report_time, brand)
65
+VALUES
66
+    ('MON-FLOW-001', '一号泵站出口流量计', 'flow',     '一体化水厂', '一号泵站出口',    82.07123456, 44.84567890, 'online',  NOW(), 'E+H Promag 50'),
67
+    ('MON-FLOW-002', '二号泵站出口流量计', 'flow',     '一体化水厂', '二号泵站出口',    82.07234567, 44.84678901, 'online',  NOW(), 'E+H Promag 50'),
68
+    ('MON-PRES-001', '管网压力监测点A',    'pressure', '管网一区',   '人民路DN300',    82.06890123, 44.84234567, 'online',  NOW(), 'WIKA S-20'),
69
+    ('MON-PRES-002', '管网压力监测点B',    'pressure', '管网一区',   '建设路DN200',    82.06901234, 44.84345678, 'offline', NOW() - INTERVAL '2 hours', 'WIKA S-20'),
70
+    ('MON-LEV-001',  '清水池液位计',       'level',    '一体化水厂', '清水池',         82.07156789, 44.84501234, 'online',  NOW(), 'VEGA VEGAPULS 64'),
71
+    ('MON-LEV-002',  '沉淀池液位计',       'level',    '一体化水厂', '沉淀池',         82.07167890, 44.84512345, 'fault',   NOW() - INTERVAL '30 minutes', 'VEGA VEGAPULS 64'),
72
+    ('MON-QUAL-001', '出厂水质监测仪',     'quality',  '一体化水厂', '出厂水口',       82.07178901, 44.84523456, 'online',  NOW(), 'HACH sc200'),
73
+    ('MON-QUAL-002', '管网末梢水质仪',     'quality',  '管网二区',   '末梢检测点',     82.06543210, 44.83987654, 'abnormal',NOW(), 'HACH sc200'),
74
+    ('MON-FLOW-003', '三号泵站流量计',     'flow',     '管网二区',   '三号泵站',       82.06654321, 44.84098765, 'online',  NOW(), 'E+H Promag 10'),
75
+    ('MON-PRES-003', '高位水池压力计',     'pressure', '管网三区',   '高位水池出口',   82.07345678, 44.84789012, 'online',  NOW(), 'WIKA S-20')
76
+ON CONFLICT (device_code) DO NOTHING;
77
+
78
+INSERT INTO prod_monitor_realtime_data (device_id, device_code, metric_key, metric_value, unit, threshold_high, threshold_low, is_abnormal, collect_time)
79
+SELECT d.id, d.device_code, m.metric_key, m.metric_value, m.unit, m.threshold_high, m.threshold_low, m.is_abnormal, NOW()
80
+FROM prod_monitor_device d
81
+CROSS JOIN (VALUES
82
+    ('flow',              125.50,  'm³/h', 200.0,   10.0,   0),
83
+    ('pressure',          0.35,    'MPa',  0.6,     0.15,   0),
84
+    ('level',             3.80,    'm',    5.0,     0.5,    0),
85
+    ('turbidity',         0.45,    'NTU',  1.0,     NULL,   0),
86
+    ('ph',                7.20,    '',     8.5,     6.5,    0),
87
+    ('residual_chlorine', 0.35,    'mg/L', 0.8,     0.05,   0)
88
+) AS m(metric_key, metric_value, unit, threshold_high, threshold_low, is_abnormal)
89
+WHERE d.status = 'online'
90
+ON CONFLICT DO NOTHING;

+ 130
- 0
wm-production/src/main/java/com/water/production/controller/MonitorController.java ファイルの表示

@@ -0,0 +1,130 @@
1
+package com.water.production.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.production.dto.MonitorExportRequest;
5
+import com.water.production.dto.MonitorQueryRequest;
6
+import com.water.production.service.MonitorExportService;
7
+import com.water.production.service.MonitorListService;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.http.HttpHeaders;
12
+import org.springframework.http.MediaType;
13
+import org.springframework.http.ResponseEntity;
14
+import org.springframework.web.bind.annotation.*;
15
+
16
+import java.net.URLEncoder;
17
+import java.nio.charset.StandardCharsets;
18
+import java.util.List;
19
+import java.util.Map;
20
+
21
+/**
22
+ * 在线监测列表 Controller
23
+ * 提供设备列表、实时数据、统计、导出等接口
24
+ */
25
+@Tag(name = "在线监测管理")
26
+@RestController
27
+@RequestMapping("/api/production/monitor")
28
+@RequiredArgsConstructor
29
+public class MonitorController {
30
+
31
+    private final MonitorListService monitorListService;
32
+    private final MonitorExportService monitorExportService;
33
+
34
+    // ========== 1. 设备列表(分页 + 多维筛选) ==========
35
+    @Operation(summary = "在线监测设备列表(分页+多维筛选)")
36
+    @GetMapping("/list")
37
+    public R<Map<String, Object>> list(MonitorQueryRequest request) {
38
+        return R.ok(monitorListService.queryDeviceList(request));
39
+    }
40
+
41
+    // ========== 2. 设备详情 ==========
42
+    @Operation(summary = "获取设备详情")
43
+    @GetMapping("/device/{deviceId}")
44
+    public R<Map<String, Object>> deviceDetail(@PathVariable Long deviceId) {
45
+        Map<String, Object> detail = monitorListService.getDeviceDetail(deviceId);
46
+        if (detail.isEmpty()) {
47
+            return R.fail(404, "设备不存在");
48
+        }
49
+        return R.ok(detail);
50
+    }
51
+
52
+    // ========== 3. 设备实时数据 ==========
53
+    @Operation(summary = "获取设备实时监测数据")
54
+    @GetMapping("/device/{deviceId}/realtime")
55
+    public R<List<Map<String, Object>>> deviceRealtime(@PathVariable Long deviceId) {
56
+        return R.ok(monitorListService.getDeviceRealtimeData(deviceId));
57
+    }
58
+
59
+    // ========== 4. 设备统计概览 ==========
60
+    @Operation(summary = "监测设备统计概览(按状态/类型/区域分组)")
61
+    @GetMapping("/statistics")
62
+    public R<Map<String, Object>> statistics() {
63
+        return R.ok(monitorListService.getDeviceStatistics());
64
+    }
65
+
66
+    // ========== 5. 更新设备状态 ==========
67
+    @Operation(summary = "更新设备状态(online/offline/fault/abnormal)")
68
+    @PutMapping("/device/{deviceId}/status")
69
+    public R<String> updateStatus(@PathVariable Long deviceId, @RequestParam String status) {
70
+        monitorListService.updateDeviceStatus(deviceId, status);
71
+        return R.ok("状态已更新");
72
+    }
73
+
74
+    // ========== 6. 获取区域列表 ==========
75
+    @Operation(summary = "获取所有监测区域列表")
76
+    @GetMapping("/areas")
77
+    public R<List<String>> areas() {
78
+        return R.ok(monitorListService.getAreaList());
79
+    }
80
+
81
+    // ========== 7. 获取设备类型列表 ==========
82
+    @Operation(summary = "获取所有设备类型列表")
83
+    @GetMapping("/device-types")
84
+    public R<List<String>> deviceTypes() {
85
+        return R.ok(monitorListService.getDeviceTypeList());
86
+    }
87
+
88
+    // ========== 8. 导出 Excel ==========
89
+    @Operation(summary = "导出在线监测数据(Excel)")
90
+    @PostMapping("/export/excel")
91
+    public ResponseEntity<byte[]> exportExcel(@RequestBody MonitorExportRequest request) {
92
+        request.setFormat("excel");
93
+        byte[] data = monitorExportService.exportExcel(request);
94
+        String filename = URLEncoder.encode("在线监测数据.xlsx", StandardCharsets.UTF_8);
95
+        return ResponseEntity.ok()
96
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
97
+                .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
98
+                .body(data);
99
+    }
100
+
101
+    // ========== 9. 导出 CSV ==========
102
+    @Operation(summary = "导出在线监测数据(CSV)")
103
+    @PostMapping("/export/csv")
104
+    public ResponseEntity<byte[]> exportCsv(@RequestBody MonitorExportRequest request) {
105
+        request.setFormat("csv");
106
+        byte[] data = monitorExportService.exportCsv(request);
107
+        String filename = URLEncoder.encode("在线监测数据.csv", StandardCharsets.UTF_8);
108
+        return ResponseEntity.ok()
109
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
110
+                .contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
111
+                .body(data);
112
+    }
113
+
114
+    // ========== 10. 通用导出(按 format 参数自动选择) ==========
115
+    @Operation(summary = "通用导出(format=excel|csv)")
116
+    @PostMapping("/export")
117
+    public ResponseEntity<byte[]> export(@RequestBody MonitorExportRequest request) {
118
+        if ("csv".equalsIgnoreCase(request.getFormat())) {
119
+            return exportCsv(request);
120
+        }
121
+        return exportExcel(request);
122
+    }
123
+
124
+    // ========== 11. 批量获取实时数据 ==========
125
+    @Operation(summary = "批量获取多设备实时数据")
126
+    @PostMapping("/realtime/batch")
127
+    public R<List<Map<String, Object>>> batchRealtime(@RequestBody List<Long> deviceIds) {
128
+        return R.ok(monitorListService.getBatchRealtimeData(deviceIds));
129
+    }
130
+}

+ 39
- 0
wm-production/src/main/java/com/water/production/dto/MonitorExportRequest.java ファイルの表示

@@ -0,0 +1,39 @@
1
+package com.water.production.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.util.List;
6
+
7
+/**
8
+ * 在线监测数据导出请求
9
+ */
10
+@Data
11
+public class MonitorExportRequest {
12
+
13
+    /** 导出格式: excel/csv */
14
+    private String format = "excel";
15
+
16
+    /** 区域 */
17
+    private String area;
18
+
19
+    /** 设备类型 */
20
+    private String deviceType;
21
+
22
+    /** 设备状态 */
23
+    private String status;
24
+
25
+    /** 关键词 */
26
+    private String keyword;
27
+
28
+    /** 开始时间 */
29
+    private String startTime;
30
+
31
+    /** 结束时间 */
32
+    private String endTime;
33
+
34
+    /** 指定导出的设备ID列表(可选,为空则按筛选条件导出) */
35
+    private List<Long> deviceIds;
36
+
37
+    /** 是否包含实时数据 */
38
+    private Boolean includeRealtime = true;
39
+}

+ 40
- 0
wm-production/src/main/java/com/water/production/dto/MonitorQueryRequest.java ファイルの表示

@@ -0,0 +1,40 @@
1
+package com.water.production.dto;
2
+
3
+import lombok.Data;
4
+
5
+/**
6
+ * 在线监测列表查询请求
7
+ */
8
+@Data
9
+public class MonitorQueryRequest {
10
+
11
+    /** 区域 */
12
+    private String area;
13
+
14
+    /** 设备类型: flow/pressure/level/quality */
15
+    private String deviceType;
16
+
17
+    /** 设备状态: online/offline/fault/abnormal */
18
+    private String status;
19
+
20
+    /** 关键词(设备编号/名称模糊搜索) */
21
+    private String keyword;
22
+
23
+    /** 开始时间(最后上报时间范围) */
24
+    private String startTime;
25
+
26
+    /** 结束时间 */
27
+    private String endTime;
28
+
29
+    /** 排序字段: deviceCode/deviceName/status/lastReportTime */
30
+    private String sortField;
31
+
32
+    /** 排序方向: asc/desc */
33
+    private String sortOrder;
34
+
35
+    /** 页码(默认1) */
36
+    private Integer pageNum = 1;
37
+
38
+    /** 每页条数(默认20) */
39
+    private Integer pageSize = 20;
40
+}

+ 60
- 0
wm-production/src/main/java/com/water/production/entity/MonitorDevice.java ファイルの表示

@@ -0,0 +1,60 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.math.BigDecimal;
7
+import java.time.LocalDateTime;
8
+
9
+/**
10
+ * 在线监测设备实体
11
+ */
12
+@Data
13
+@TableName("prod_monitor_device")
14
+public class MonitorDevice {
15
+
16
+    @TableId(type = IdType.AUTO)
17
+    private Long id;
18
+
19
+    /** 设备编号 */
20
+    private String deviceCode;
21
+
22
+    /** 设备名称 */
23
+    private String deviceName;
24
+
25
+    /** 设备类型: flow/pressure/level/quality */
26
+    private String deviceType;
27
+
28
+    /** 所属区域 */
29
+    private String area;
30
+
31
+    /** 安装位置 */
32
+    private String location;
33
+
34
+    /** 经度 */
35
+    private BigDecimal lng;
36
+
37
+    /** 纬度 */
38
+    private BigDecimal lat;
39
+
40
+    /** 设备状态: online/offline/fault/abnormal */
41
+    private String status;
42
+
43
+    /** 最后上报时间 */
44
+    private LocalDateTime lastReportTime;
45
+
46
+    /** 设备品牌/型号 */
47
+    private String brand;
48
+
49
+    /** 安装日期 */
50
+    private LocalDateTime installTime;
51
+
52
+    /** 备注 */
53
+    private String remark;
54
+
55
+    @TableField(fill = FieldFill.INSERT)
56
+    private LocalDateTime createdTime;
57
+
58
+    @TableField(fill = FieldFill.INSERT_UPDATE)
59
+    private LocalDateTime updatedTime;
60
+}

+ 48
- 0
wm-production/src/main/java/com/water/production/entity/MonitorRealtimeData.java ファイルの表示

@@ -0,0 +1,48 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.math.BigDecimal;
7
+import java.time.LocalDateTime;
8
+
9
+/**
10
+ * 监测实时数据实体
11
+ */
12
+@Data
13
+@TableName("prod_monitor_realtime_data")
14
+public class MonitorRealtimeData {
15
+
16
+    @TableId(type = IdType.AUTO)
17
+    private Long id;
18
+
19
+    /** 关联设备ID */
20
+    private Long deviceId;
21
+
22
+    /** 设备编号 */
23
+    private String deviceCode;
24
+
25
+    /** 监测参数类型: flow/pressure/level/turbidity/ph/residual_chlorine/temperature */
26
+    private String metricKey;
27
+
28
+    /** 实时值 */
29
+    private BigDecimal metricValue;
30
+
31
+    /** 单位 */
32
+    private String unit;
33
+
34
+    /** 阈值上限 */
35
+    private BigDecimal thresholdHigh;
36
+
37
+    /** 阈值下限 */
38
+    private BigDecimal thresholdLow;
39
+
40
+    /** 是否异常: 0正常 1异常 */
41
+    private Integer isAbnormal;
42
+
43
+    /** 采集时间 */
44
+    private LocalDateTime collectTime;
45
+
46
+    @TableField(fill = FieldFill.INSERT)
47
+    private LocalDateTime createdTime;
48
+}

+ 47
- 0
wm-production/src/main/java/com/water/production/mapper/MonitorDeviceMapper.java ファイルの表示

@@ -0,0 +1,47 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.MonitorDevice;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+import java.util.Map;
11
+
12
+/**
13
+ * 在线监测设备 Mapper
14
+ */
15
+@Mapper
16
+public interface MonitorDeviceMapper extends BaseMapper<MonitorDevice> {
17
+
18
+    /**
19
+     * 查询设备列表(含最新实时数据)
20
+     */
21
+    @Select("<script>" +
22
+            "SELECT d.*, " +
23
+            "  (SELECT COUNT(*) FROM prod_monitor_realtime_data r WHERE r.device_id = d.id) AS metric_count " +
24
+            "FROM prod_monitor_device d " +
25
+            "WHERE 1=1 " +
26
+            "<if test='area != null and area != \"\"'> AND d.area = #{area}</if> " +
27
+            "<if test='deviceType != null and deviceType != \"\"'> AND d.device_type = #{deviceType}</if> " +
28
+            "<if test='status != null and status != \"\"'> AND d.status = #{status}</if> " +
29
+            "<if test='keyword != null and keyword != \"\"'> AND (d.device_code ILIKE CONCAT('%',#{keyword},'%') OR d.device_name ILIKE CONCAT('%',#{keyword},'%'))</if> " +
30
+            "<if test='startTime != null and startTime != \"\"'> AND d.last_report_time &gt;= #{startTime}::timestamp</if> " +
31
+            "<if test='endTime != null and endTime != \"\"'> AND d.last_report_time &lt;= #{endTime}::timestamp</if> " +
32
+            "<if test='sortField == \"deviceCode\"'> ORDER BY d.device_code ${sortOrder}</if> " +
33
+            "<if test='sortField == \"deviceName\"'> ORDER BY d.device_name ${sortOrder}</if> " +
34
+            "<if test='sortField == \"status\"'> ORDER BY d.status ${sortOrder}</if> " +
35
+            "<if test='sortField == \"lastReportTime\"'> ORDER BY d.last_report_time ${sortOrder}</if> " +
36
+            "<if test='sortField == null or sortField == \"\"'> ORDER BY d.last_report_time DESC</if> " +
37
+            "</script>")
38
+    List<Map<String, Object>> selectDeviceListWithMetrics(
39
+            @Param("area") String area,
40
+            @Param("deviceType") String deviceType,
41
+            @Param("status") String status,
42
+            @Param("keyword") String keyword,
43
+            @Param("startTime") String startTime,
44
+            @Param("endTime") String endTime,
45
+            @Param("sortField") String sortField,
46
+            @Param("sortOrder") String sortOrder);
47
+}

+ 50
- 0
wm-production/src/main/java/com/water/production/mapper/MonitorRealtimeDataMapper.java ファイルの表示

@@ -0,0 +1,50 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.MonitorRealtimeData;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+import java.util.Map;
11
+
12
+/**
13
+ * 监测实时数据 Mapper
14
+ */
15
+@Mapper
16
+public interface MonitorRealtimeDataMapper extends BaseMapper<MonitorRealtimeData> {
17
+
18
+    /**
19
+     * 获取指定设备的最新实时数据(所有指标)
20
+     */
21
+    @Select("SELECT r.* FROM prod_monitor_realtime_data r " +
22
+            "INNER JOIN (SELECT device_id, metric_key, MAX(collect_time) AS max_time " +
23
+            "  FROM prod_monitor_realtime_data WHERE device_id = #{deviceId} GROUP BY device_id, metric_key) latest " +
24
+            "ON r.device_id = latest.device_id AND r.metric_key = latest.metric_key AND r.collect_time = latest.max_time")
25
+    List<MonitorRealtimeData> selectLatestByDeviceId(@Param("deviceId") Long deviceId);
26
+
27
+    /**
28
+     * 批量获取多个设备的最新实时数据
29
+     */
30
+    @Select("<script>" +
31
+            "SELECT r.* FROM prod_monitor_realtime_data r " +
32
+            "INNER JOIN (SELECT device_id, metric_key, MAX(collect_time) AS max_time " +
33
+            "  FROM prod_monitor_realtime_data " +
34
+            "  WHERE device_id IN " +
35
+            "  <foreach item='id' collection='deviceIds' open='(' separator=',' close=')'>#{id}</foreach> " +
36
+            "  GROUP BY device_id, metric_key) latest " +
37
+            "ON r.device_id = latest.device_id AND r.metric_key = latest.metric_key AND r.collect_time = latest.max_time " +
38
+            "ORDER BY r.device_id, r.metric_key" +
39
+            "</script>")
40
+    List<Map<String, Object>> selectLatestBatch(@Param("deviceIds") List<Long> deviceIds);
41
+
42
+    /**
43
+     * 按设备ID统计异常数据数量
44
+     */
45
+    @Select("SELECT device_id, COUNT(*) AS cnt FROM prod_monitor_realtime_data " +
46
+            "WHERE is_abnormal = 1 AND device_id IN " +
47
+            "<script><foreach item='id' collection='deviceIds' open='(' separator=',' close=')'>#{id}</foreach></script> " +
48
+            "GROUP BY device_id")
49
+    List<Map<String, Object>> countAbnormalByDevices(@Param("deviceIds") List<Long> deviceIds);
50
+}

+ 206
- 0
wm-production/src/main/java/com/water/production/service/MonitorExportService.java ファイルの表示

@@ -0,0 +1,206 @@
1
+package com.water.production.service;
2
+
3
+import com.alibaba.excel.EasyExcel;
4
+import com.water.production.dto.MonitorExportRequest;
5
+import com.water.production.dto.MonitorQueryRequest;
6
+import com.water.production.entity.MonitorDevice;
7
+import com.water.production.entity.MonitorRealtimeData;
8
+import com.water.production.mapper.MonitorRealtimeDataMapper;
9
+import lombok.RequiredArgsConstructor;
10
+import lombok.extern.slf4j.Slf4j;
11
+import org.springframework.stereotype.Service;
12
+
13
+import java.io.ByteArrayOutputStream;
14
+import java.io.IOException;
15
+import java.io.OutputStreamWriter;
16
+import java.io.PrintWriter;
17
+import java.nio.charset.StandardCharsets;
18
+import java.util.*;
19
+import java.util.stream.Collectors;
20
+
21
+/**
22
+ * 在线监测数据导出服务
23
+ * 支持 Excel / CSV 格式导出
24
+ */
25
+@Slf4j
26
+@Service
27
+@RequiredArgsConstructor
28
+public class MonitorExportService {
29
+
30
+    private final MonitorListService monitorListService;
31
+    private final MonitorRealtimeDataMapper realtimeDataMapper;
32
+
33
+    /**
34
+     * 导出 Excel 格式
35
+     */
36
+    public byte[] exportExcel(MonitorExportRequest req) {
37
+        List<MonitorDevice> devices = getDevicesForExport(req);
38
+        if (devices.isEmpty()) {
39
+            return new byte[0];
40
+        }
41
+
42
+        // 构建导出数据
43
+        List<List<String>> head = buildExcelHead();
44
+        List<List<Object>> data = buildExcelData(devices, req.getIncludeRealtime());
45
+
46
+        try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
47
+            EasyExcel.write(bos)
48
+                    .sheet("在线监测数据")
49
+                    .head(head.stream()
50
+                            .map(row -> row.stream()
51
+                                    .map(c -> (String) c)
52
+                                    .collect(Collectors.toList()))
53
+                            .collect(Collectors.toList()))
54
+                    .doWrite(data);
55
+            return bos.toByteArray();
56
+        } catch (IOException e) {
57
+            log.error("Excel 导出失败", e);
58
+            throw new RuntimeException("Excel 导出失败: " + e.getMessage());
59
+        }
60
+    }
61
+
62
+    /**
63
+     * 导出 CSV 格式
64
+     */
65
+    public byte[] exportCsv(MonitorExportRequest req) {
66
+        List<MonitorDevice> devices = getDevicesForExport(req);
67
+        if (devices.isEmpty()) {
68
+            return new byte[0];
69
+        }
70
+
71
+        try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
72
+             PrintWriter writer = new PrintWriter(new OutputStreamWriter(bos, StandardCharsets.UTF_8))) {
73
+            // BOM for Excel UTF-8
74
+            bos.write(0xEF);
75
+            bos.write(0xBB);
76
+            bos.write(0xBF);
77
+
78
+            // CSV header
79
+            writer.println("设备编号,设备名称,设备类型,区域,安装位置,状态,最后上报时间,流量(m³/h),压力(MPa),液位(m),浊度(NTU),pH,余氯(mg/L)");
80
+
81
+            for (MonitorDevice device : devices) {
82
+                StringBuilder line = new StringBuilder();
83
+                line.append(csvEsc(device.getDeviceCode())).append(",");
84
+                line.append(csvEsc(device.getDeviceName())).append(",");
85
+                line.append(csvEsc(device.getDeviceType())).append(",");
86
+                line.append(csvEsc(device.getArea())).append(",");
87
+                line.append(csvEsc(device.getLocation())).append(",");
88
+                line.append(csvEsc(device.getStatus())).append(",");
89
+                line.append(device.getLastReportTime() != null ? device.getLastReportTime().toString() : "");
90
+
91
+                if (Boolean.TRUE.equals(req.getIncludeRealtime())) {
92
+                    List<MonitorRealtimeData> realtimeList = realtimeDataMapper.selectLatestByDeviceId(device.getId());
93
+                    Map<String, String> metricMap = new LinkedHashMap<>();
94
+                    for (MonitorRealtimeData data : realtimeList) {
95
+                        metricMap.put(data.getMetricKey(),
96
+                                data.getMetricValue() != null ? data.getMetricValue().toPlainString() : "");
97
+                    }
98
+                    line.append(",").append(metricMap.getOrDefault("flow", ""));
99
+                    line.append(",").append(metricMap.getOrDefault("pressure", ""));
100
+                    line.append(",").append(metricMap.getOrDefault("level", ""));
101
+                    line.append(",").append(metricMap.getOrDefault("turbidity", ""));
102
+                    line.append(",").append(metricMap.getOrDefault("ph", ""));
103
+                    line.append(",").append(metricMap.getOrDefault("residual_chlorine", ""));
104
+                }
105
+
106
+                writer.println(line);
107
+            }
108
+
109
+            writer.flush();
110
+            return bos.toByteArray();
111
+        } catch (IOException e) {
112
+            log.error("CSV 导出失败", e);
113
+            throw new RuntimeException("CSV 导出失败: " + e.getMessage());
114
+        }
115
+    }
116
+
117
+    private List<MonitorDevice> getDevicesForExport(MonitorExportRequest req) {
118
+        // 如果指定了设备ID列表,直接按ID查询
119
+        if (req.getDeviceIds() != null && !req.getDeviceIds().isEmpty()) {
120
+            MonitorQueryRequest queryReq = new MonitorQueryRequest();
121
+            queryReq.setPageSize(10000);
122
+            return monitorListService.queryDevicesForExport(queryReq).stream()
123
+                    .filter(d -> req.getDeviceIds().contains(d.getId()))
124
+                    .collect(Collectors.toList());
125
+        }
126
+
127
+        // 按筛选条件查询
128
+        MonitorQueryRequest queryReq = new MonitorQueryRequest();
129
+        queryReq.setArea(req.getArea());
130
+        queryReq.setDeviceType(req.getDeviceType());
131
+        queryReq.setStatus(req.getStatus());
132
+        queryReq.setKeyword(req.getKeyword());
133
+        queryReq.setStartTime(req.getStartTime());
134
+        queryReq.setEndTime(req.getEndTime());
135
+        queryReq.setPageSize(10000);
136
+        return monitorListService.queryDevicesForExport(queryReq);
137
+    }
138
+
139
+    private List<List<String>> buildExcelHead() {
140
+        List<List<String>> head = new ArrayList<>();
141
+        head.add(List.of("设备编号"));
142
+        head.add(List.of("设备名称"));
143
+        head.add(List.of("设备类型"));
144
+        head.add(List.of("区域"));
145
+        head.add(List.of("安装位置"));
146
+        head.add(List.of("状态"));
147
+        head.add(List.of("最后上报时间"));
148
+        head.add(List.of("流量(m³/h)"));
149
+        head.add(List.of("压力(MPa)"));
150
+        head.add(List.of("液位(m)"));
151
+        head.add(List.of("浊度(NTU)"));
152
+        head.add(List.of("pH"));
153
+        head.add(List.of("余氯(mg/L)"));
154
+        return head;
155
+    }
156
+
157
+    private List<List<Object>> buildExcelData(List<MonitorDevice> devices, Boolean includeRealtime) {
158
+        List<List<Object>> data = new ArrayList<>();
159
+        for (MonitorDevice device : devices) {
160
+            List<Object> row = new ArrayList<>();
161
+            row.add(device.getDeviceCode());
162
+            row.add(device.getDeviceName());
163
+            row.add(device.getDeviceType());
164
+            row.add(device.getArea());
165
+            row.add(device.getLocation());
166
+            row.add(formatStatus(device.getStatus()));
167
+            row.add(device.getLastReportTime() != null ? device.getLastReportTime().toString() : "");
168
+
169
+            if (Boolean.TRUE.equals(includeRealtime)) {
170
+                List<MonitorRealtimeData> realtimeList = realtimeDataMapper.selectLatestByDeviceId(device.getId());
171
+                Map<String, Object> metricMap = new LinkedHashMap<>();
172
+                for (MonitorRealtimeData d : realtimeList) {
173
+                    metricMap.put(d.getMetricKey(), d.getMetricValue());
174
+                }
175
+                row.add(metricMap.getOrDefault("flow", ""));
176
+                row.add(metricMap.getOrDefault("pressure", ""));
177
+                row.add(metricMap.getOrDefault("level", ""));
178
+                row.add(metricMap.getOrDefault("turbidity", ""));
179
+                row.add(metricMap.getOrDefault("ph", ""));
180
+                row.add(metricMap.getOrDefault("residual_chlorine", ""));
181
+            }
182
+
183
+            data.add(row);
184
+        }
185
+        return data;
186
+    }
187
+
188
+    private String formatStatus(String status) {
189
+        if (status == null) return "未知";
190
+        return switch (status) {
191
+            case "online" -> "在线";
192
+            case "offline" -> "离线";
193
+            case "fault" -> "故障";
194
+            case "abnormal" -> "数据异常";
195
+            default -> status;
196
+        };
197
+    }
198
+
199
+    private String csvEsc(String value) {
200
+        if (value == null) return "";
201
+        if (value.contains(",") || value.contains("\"") || value.contains("\n")) {
202
+            return "\"" + value.replace("\"", "\"\"") + "\"";
203
+        }
204
+        return value;
205
+    }
206
+}

+ 271
- 0
wm-production/src/main/java/com/water/production/service/MonitorListService.java ファイルの表示

@@ -0,0 +1,271 @@
1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.production.dto.MonitorQueryRequest;
6
+import com.water.production.entity.MonitorDevice;
7
+import com.water.production.entity.MonitorRealtimeData;
8
+import com.water.production.mapper.MonitorDeviceMapper;
9
+import com.water.production.mapper.MonitorRealtimeDataMapper;
10
+import lombok.RequiredArgsConstructor;
11
+import lombok.extern.slf4j.Slf4j;
12
+import org.springframework.stereotype.Service;
13
+
14
+import java.math.BigDecimal;
15
+import java.time.LocalDateTime;
16
+import java.time.format.DateTimeFormatter;
17
+import java.util.*;
18
+import java.util.stream.Collectors;
19
+
20
+/**
21
+ * 在线监测列表服务
22
+ * 提供设备列表查询、实时数据获取、设备详情、统计等功能
23
+ */
24
+@Slf4j
25
+@Service
26
+@RequiredArgsConstructor
27
+public class MonitorListService {
28
+
29
+    private final MonitorDeviceMapper deviceMapper;
30
+    private final MonitorRealtimeDataMapper realtimeDataMapper;
31
+
32
+    private static final DateTimeFormatter DTF = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
33
+
34
+    /**
35
+     * 分页查询在线监测设备列表(含最新实时数据)
36
+     */
37
+    public Map<String, Object> queryDeviceList(MonitorQueryRequest req) {
38
+        // 使用 MyBatis-Plus 分页
39
+        Page<MonitorDevice> page = new Page<>(req.getPageNum(), req.getPageSize());
40
+
41
+        LambdaQueryWrapper<MonitorDevice> wrapper = new LambdaQueryWrapper<>();
42
+        if (req.getArea() != null && !req.getArea().isEmpty()) {
43
+            wrapper.eq(MonitorDevice::getArea, req.getArea());
44
+        }
45
+        if (req.getDeviceType() != null && !req.getDeviceType().isEmpty()) {
46
+            wrapper.eq(MonitorDevice::getDeviceType, req.getDeviceType());
47
+        }
48
+        if (req.getStatus() != null && !req.getStatus().isEmpty()) {
49
+            wrapper.eq(MonitorDevice::getStatus, req.getStatus());
50
+        }
51
+        if (req.getKeyword() != null && !req.getKeyword().isEmpty()) {
52
+            wrapper.and(w -> w.like(MonitorDevice::getDeviceCode, req.getKeyword())
53
+                    .or().like(MonitorDevice::getDeviceName, req.getKeyword()));
54
+        }
55
+        if (req.getStartTime() != null && !req.getStartTime().isEmpty()) {
56
+            wrapper.ge(MonitorDevice::getLastReportTime, LocalDateTime.parse(req.getStartTime(), DTF));
57
+        }
58
+        if (req.getEndTime() != null && !req.getEndTime().isEmpty()) {
59
+            wrapper.le(MonitorDevice::getLastReportTime, LocalDateTime.parse(req.getEndTime(), DTF));
60
+        }
61
+
62
+        // 排序
63
+        String sortField = req.getSortField();
64
+        boolean isAsc = "asc".equalsIgnoreCase(req.getSortOrder());
65
+        if ("deviceCode".equals(sortField)) {
66
+            wrapper.orderBy(true, isAsc, MonitorDevice::getDeviceCode);
67
+        } else if ("deviceName".equals(sortField)) {
68
+            wrapper.orderBy(true, isAsc, MonitorDevice::getDeviceName);
69
+        } else if ("status".equals(sortField)) {
70
+            wrapper.orderBy(true, isAsc, MonitorDevice::getStatus);
71
+        } else {
72
+            wrapper.orderByDesc(MonitorDevice::getLastReportTime);
73
+        }
74
+
75
+        Page<MonitorDevice> result = deviceMapper.selectPage(page, wrapper);
76
+
77
+        // 填充实时数据
78
+        List<Map<String, Object>> deviceList = new ArrayList<>();
79
+        for (MonitorDevice device : result.getRecords()) {
80
+            Map<String, Object> deviceMap = new LinkedHashMap<>();
81
+            deviceMap.put("id", device.getId());
82
+            deviceMap.put("deviceCode", device.getDeviceCode());
83
+            deviceMap.put("deviceName", device.getDeviceName());
84
+            deviceMap.put("deviceType", device.getDeviceType());
85
+            deviceMap.put("area", device.getArea());
86
+            deviceMap.put("location", device.getLocation());
87
+            deviceMap.put("lng", device.getLng());
88
+            deviceMap.put("lat", device.getLat());
89
+            deviceMap.put("status", device.getStatus());
90
+            deviceMap.put("lastReportTime", device.getLastReportTime());
91
+            deviceMap.put("brand", device.getBrand());
92
+
93
+            // 获取最新实时数据
94
+            List<MonitorRealtimeData> realtimeList = realtimeDataMapper.selectLatestByDeviceId(device.getId());
95
+            Map<String, Object> realtimeMap = new LinkedHashMap<>();
96
+            for (MonitorRealtimeData data : realtimeList) {
97
+                Map<String, Object> item = new LinkedHashMap<>();
98
+                item.put("metricKey", data.getMetricKey());
99
+                item.put("value", data.getMetricValue());
100
+                item.put("unit", data.getUnit());
101
+                item.put("thresholdHigh", data.getThresholdHigh());
102
+                item.put("thresholdLow", data.getThresholdLow());
103
+                item.put("isAbnormal", data.getIsAbnormal());
104
+                item.put("collectTime", data.getCollectTime());
105
+                realtimeMap.put(data.getMetricKey(), item);
106
+            }
107
+            deviceMap.put("realtimeData", realtimeMap);
108
+            deviceList.add(deviceMap);
109
+        }
110
+
111
+        Map<String, Object> pageResult = new LinkedHashMap<>();
112
+        pageResult.put("records", deviceList);
113
+        pageResult.put("total", result.getTotal());
114
+        pageResult.put("pageNum", result.getCurrent());
115
+        pageResult.put("pageSize", result.getSize());
116
+        pageResult.put("pages", result.getPages());
117
+        return pageResult;
118
+    }
119
+
120
+    /**
121
+     * 获取单个设备详情(含实时数据)
122
+     */
123
+    public Map<String, Object> getDeviceDetail(Long deviceId) {
124
+        MonitorDevice device = deviceMapper.selectById(deviceId);
125
+        if (device == null) {
126
+            return Collections.emptyMap();
127
+        }
128
+
129
+        Map<String, Object> detail = new LinkedHashMap<>();
130
+        detail.put("id", device.getId());
131
+        detail.put("deviceCode", device.getDeviceCode());
132
+        detail.put("deviceName", device.getDeviceName());
133
+        detail.put("deviceType", device.getDeviceType());
134
+        detail.put("area", device.getArea());
135
+        detail.put("location", device.getLocation());
136
+        detail.put("lng", device.getLng());
137
+        detail.put("lat", device.getLat());
138
+        detail.put("status", device.getStatus());
139
+        detail.put("lastReportTime", device.getLastReportTime());
140
+        detail.put("brand", device.getBrand());
141
+        detail.put("installTime", device.getInstallTime());
142
+        detail.put("remark", device.getRemark());
143
+
144
+        List<MonitorRealtimeData> realtimeList = realtimeDataMapper.selectLatestByDeviceId(deviceId);
145
+        detail.put("realtimeData", realtimeList);
146
+
147
+        return detail;
148
+    }
149
+
150
+    /**
151
+     * 获取设备实时数据(多参数)
152
+     */
153
+    public List<Map<String, Object>> getDeviceRealtimeData(Long deviceId) {
154
+        List<MonitorRealtimeData> list = realtimeDataMapper.selectLatestByDeviceId(deviceId);
155
+        List<Map<String, Object>> result = new ArrayList<>();
156
+        for (MonitorRealtimeData data : list) {
157
+            Map<String, Object> map = new LinkedHashMap<>();
158
+            map.put("metricKey", data.getMetricKey());
159
+            map.put("value", data.getMetricValue());
160
+            map.put("unit", data.getUnit());
161
+            map.put("thresholdHigh", data.getThresholdHigh());
162
+            map.put("thresholdLow", data.getThresholdLow());
163
+            map.put("isAbnormal", data.getIsAbnormal());
164
+            map.put("collectTime", data.getCollectTime());
165
+            result.add(map);
166
+        }
167
+        return result;
168
+    }
169
+
170
+    /**
171
+     * 获取设备统计概览(按状态、类型、区域分组)
172
+     */
173
+    public Map<String, Object> getDeviceStatistics() {
174
+        Map<String, Object> stats = new LinkedHashMap<>();
175
+
176
+        // 按状态统计
177
+        List<MonitorDevice> allDevices = deviceMapper.selectList(null);
178
+        Map<String, Long> statusCount = allDevices.stream()
179
+                .collect(Collectors.groupingBy(d -> d.getStatus() != null ? d.getStatus() : "unknown", Collectors.counting()));
180
+        stats.put("statusDistribution", statusCount);
181
+        stats.put("totalDevices", allDevices.size());
182
+        stats.put("onlineCount", statusCount.getOrDefault("online", 0L));
183
+        stats.put("offlineCount", statusCount.getOrDefault("offline", 0L));
184
+        stats.put("faultCount", statusCount.getOrDefault("fault", 0L));
185
+        stats.put("abnormalCount", statusCount.getOrDefault("abnormal", 0L));
186
+
187
+        // 按类型统计
188
+        Map<String, Long> typeCount = allDevices.stream()
189
+                .collect(Collectors.groupingBy(d -> d.getDeviceType() != null ? d.getDeviceType() : "unknown", Collectors.counting()));
190
+        stats.put("typeDistribution", typeCount);
191
+
192
+        // 按区域统计
193
+        Map<String, Long> areaCount = allDevices.stream()
194
+                .collect(Collectors.groupingBy(d -> d.getArea() != null ? d.getArea() : "unknown", Collectors.counting()));
195
+        stats.put("areaDistribution", areaCount);
196
+
197
+        return stats;
198
+    }
199
+
200
+    /**
201
+     * 更新设备状态
202
+     */
203
+    public void updateDeviceStatus(Long deviceId, String status) {
204
+        MonitorDevice device = new MonitorDevice();
205
+        device.setId(deviceId);
206
+        device.setStatus(status);
207
+        device.setUpdatedTime(LocalDateTime.now());
208
+        deviceMapper.updateById(device);
209
+        log.info("设备 {} 状态更新为 {}", deviceId, status);
210
+    }
211
+
212
+    /**
213
+     * 获取所有支持的区域列表
214
+     */
215
+    public List<String> getAreaList() {
216
+        LambdaQueryWrapper<MonitorDevice> wrapper = new LambdaQueryWrapper<>();
217
+        wrapper.select(MonitorDevice::getArea).groupBy(MonitorDevice::getArea);
218
+        return deviceMapper.selectList(wrapper).stream()
219
+                .map(MonitorDevice::getArea)
220
+                .filter(Objects::nonNull)
221
+                .distinct()
222
+                .sorted()
223
+                .collect(Collectors.toList());
224
+    }
225
+
226
+    /**
227
+     * 获取所有支持的设备类型列表
228
+     */
229
+    public List<String> getDeviceTypeList() {
230
+        LambdaQueryWrapper<MonitorDevice> wrapper = new LambdaQueryWrapper<>();
231
+        wrapper.select(MonitorDevice::getDeviceType).groupBy(MonitorDevice::getDeviceType);
232
+        return deviceMapper.selectList(wrapper).stream()
233
+                .map(MonitorDevice::getDeviceType)
234
+                .filter(Objects::nonNull)
235
+                .distinct()
236
+                .sorted()
237
+                .collect(Collectors.toList());
238
+    }
239
+
240
+    /**
241
+     * 批量获取设备实时数据(用于导出)
242
+     */
243
+    public List<Map<String, Object>> getBatchRealtimeData(List<Long> deviceIds) {
244
+        if (deviceIds == null || deviceIds.isEmpty()) {
245
+            return Collections.emptyList();
246
+        }
247
+        return realtimeDataMapper.selectLatestBatch(deviceIds);
248
+    }
249
+
250
+    /**
251
+     * 根据筛选条件查询设备列表(不分页,用于导出)
252
+     */
253
+    public List<MonitorDevice> queryDevicesForExport(MonitorQueryRequest req) {
254
+        LambdaQueryWrapper<MonitorDevice> wrapper = new LambdaQueryWrapper<>();
255
+        if (req.getArea() != null && !req.getArea().isEmpty()) {
256
+            wrapper.eq(MonitorDevice::getArea, req.getArea());
257
+        }
258
+        if (req.getDeviceType() != null && !req.getDeviceType().isEmpty()) {
259
+            wrapper.eq(MonitorDevice::getDeviceType, req.getDeviceType());
260
+        }
261
+        if (req.getStatus() != null && !req.getStatus().isEmpty()) {
262
+            wrapper.eq(MonitorDevice::getStatus, req.getStatus());
263
+        }
264
+        if (req.getKeyword() != null && !req.getKeyword().isEmpty()) {
265
+            wrapper.and(w -> w.like(MonitorDevice::getDeviceCode, req.getKeyword())
266
+                    .or().like(MonitorDevice::getDeviceName, req.getKeyword()));
267
+        }
268
+        wrapper.orderByDesc(MonitorDevice::getLastReportTime);
269
+        return deviceMapper.selectList(wrapper);
270
+    }
271
+}

+ 423
- 0
wm-production/src/test/java/com/water/production/service/MonitorListServiceTest.java ファイルの表示

@@ -0,0 +1,423 @@
1
+package com.water.production.service;
2
+
3
+import com.water.production.dto.MonitorExportRequest;
4
+import com.water.production.dto.MonitorQueryRequest;
5
+import com.water.production.entity.MonitorDevice;
6
+import com.water.production.entity.MonitorRealtimeData;
7
+import org.junit.jupiter.api.DisplayName;
8
+import org.junit.jupiter.api.Test;
9
+
10
+import java.math.BigDecimal;
11
+import java.math.RoundingMode;
12
+import java.time.LocalDateTime;
13
+import java.util.*;
14
+import java.util.stream.Collectors;
15
+
16
+import static org.junit.jupiter.api.Assertions.*;
17
+
18
+/**
19
+ * 在线监测列表单元测试
20
+ * 覆盖实体、DTO、查询筛选逻辑、导出格式、状态判断等
21
+ */
22
+class MonitorListServiceTest {
23
+
24
+    // ========== 1. 实体构建测试 ==========
25
+
26
+    @Test
27
+    @DisplayName("MonitorDevice 实体字段完整性")
28
+    void testMonitorDeviceEntity() {
29
+        MonitorDevice device = new MonitorDevice();
30
+        device.setId(1L);
31
+        device.setDeviceCode("MON-FLOW-001");
32
+        device.setDeviceName("一号泵站出口流量计");
33
+        device.setDeviceType("flow");
34
+        device.setArea("一体化水厂");
35
+        device.setLocation("一号泵站出口");
36
+        device.setLng(new BigDecimal("82.07123456"));
37
+        device.setLat(new BigDecimal("44.84567890"));
38
+        device.setStatus("online");
39
+        device.setLastReportTime(LocalDateTime.of(2026, 6, 14, 16, 0, 0));
40
+        device.setBrand("E+H Promag 50");
41
+        device.setInstallTime(LocalDateTime.of(2025, 1, 15, 10, 0, 0));
42
+        device.setRemark("正常运行");
43
+
44
+        assertEquals(1L, device.getId());
45
+        assertEquals("MON-FLOW-001", device.getDeviceCode());
46
+        assertEquals("flow", device.getDeviceType());
47
+        assertEquals("online", device.getStatus());
48
+        assertEquals(new BigDecimal("82.07123456"), device.getLng());
49
+        assertNotNull(device.getLastReportTime());
50
+        assertEquals("E+H Promag 50", device.getBrand());
51
+    }
52
+
53
+    @Test
54
+    @DisplayName("MonitorRealtimeData 实体字段完整性")
55
+    void testMonitorRealtimeDataEntity() {
56
+        MonitorRealtimeData data = new MonitorRealtimeData();
57
+        data.setId(1L);
58
+        data.setDeviceId(1L);
59
+        data.setDeviceCode("MON-FLOW-001");
60
+        data.setMetricKey("flow");
61
+        data.setMetricValue(new BigDecimal("125.5000"));
62
+        data.setUnit("m³/h");
63
+        data.setThresholdHigh(new BigDecimal("200.0000"));
64
+        data.setThresholdLow(new BigDecimal("10.0000"));
65
+        data.setIsAbnormal(0);
66
+        data.setCollectTime(LocalDateTime.of(2026, 6, 14, 15, 55, 0));
67
+
68
+        assertEquals("flow", data.getMetricKey());
69
+        assertEquals(new BigDecimal("125.5000"), data.getMetricValue());
70
+        assertEquals("m³/h", data.getUnit());
71
+        assertEquals(0, data.getIsAbnormal());
72
+        assertNotNull(data.getCollectTime());
73
+    }
74
+
75
+    // ========== 2. DTO 默认值测试 ==========
76
+
77
+    @Test
78
+    @DisplayName("MonitorQueryRequest 默认分页参数")
79
+    void testQueryRequestDefaults() {
80
+        MonitorQueryRequest req = new MonitorQueryRequest();
81
+        assertEquals(1, req.getPageNum());
82
+        assertEquals(20, req.getPageSize());
83
+        assertNull(req.getArea());
84
+        assertNull(req.getDeviceType());
85
+        assertNull(req.getStatus());
86
+        assertNull(req.getSortField());
87
+    }
88
+
89
+    @Test
90
+    @DisplayName("MonitorExportRequest 默认格式为 excel")
91
+    void testExportRequestDefaults() {
92
+        MonitorExportRequest req = new MonitorExportRequest();
93
+        assertEquals("excel", req.getFormat());
94
+        assertTrue(req.getIncludeRealtime());
95
+        assertNull(req.getDeviceIds());
96
+    }
97
+
98
+    // ========== 3. 状态判断逻辑测试 ==========
99
+
100
+    @Test
101
+    @DisplayName("设备状态格式化逻辑")
102
+    void testStatusFormatting() {
103
+        // 模拟 Controller 中的状态格式化
104
+        Map<String, String> statusMap = Map.of(
105
+                "online", "在线",
106
+                "offline", "离线",
107
+                "fault", "故障",
108
+                "abnormal", "数据异常"
109
+        );
110
+
111
+        assertEquals("在线", statusMap.get("online"));
112
+        assertEquals("离线", statusMap.get("offline"));
113
+        assertEquals("故障", statusMap.get("fault"));
114
+        assertEquals("数据异常", statusMap.get("abnormal"));
115
+        assertNull(statusMap.get("unknown"));
116
+    }
117
+
118
+    @Test
119
+    @DisplayName("设备异常判定逻辑")
120
+    void testAbnormalDetection() {
121
+        // 模拟异常判定:值超出阈值范围
122
+        BigDecimal value = new BigDecimal("250.00");
123
+        BigDecimal high = new BigDecimal("200.00");
124
+        BigDecimal low = new BigDecimal("10.00");
125
+
126
+        boolean isAbnormal = (high != null && value.compareTo(high) > 0)
127
+                || (low != null && value.compareTo(low) < 0);
128
+        assertTrue(isAbnormal, "超出上限应判定为异常");
129
+
130
+        // 正常范围
131
+        value = new BigDecimal("125.50");
132
+        isAbnormal = (high != null && value.compareTo(high) > 0)
133
+                || (low != null && value.compareTo(low) < 0);
134
+        assertFalse(isAbnormal, "正常范围不应判定为异常");
135
+
136
+        // 低于下限
137
+        value = new BigDecimal("5.00");
138
+        isAbnormal = (high != null && value.compareTo(high) > 0)
139
+                || (low != null && value.compareTo(low) < 0);
140
+        assertTrue(isAbnormal, "低于下限应判定为异常");
141
+    }
142
+
143
+    // ========== 4. 筛选逻辑测试 ==========
144
+
145
+    @Test
146
+    @DisplayName("多维度筛选条件构建")
147
+    void testMultiDimensionFilter() {
148
+        // 模拟设备列表
149
+        List<MonitorDevice> devices = buildMockDevices();
150
+
151
+        // 按区域筛选
152
+        List<MonitorDevice> filtered = devices.stream()
153
+                .filter(d -> "一体化水厂".equals(d.getArea()))
154
+                .collect(Collectors.toList());
155
+        assertEquals(3, filtered.size());
156
+
157
+        // 按设备类型筛选
158
+        filtered = devices.stream()
159
+                .filter(d -> "pressure".equals(d.getDeviceType()))
160
+                .collect(Collectors.toList());
161
+        assertEquals(2, filtered.size());
162
+
163
+        // 按状态筛选
164
+        filtered = devices.stream()
165
+                .filter(d -> "online".equals(d.getStatus()))
166
+                .collect(Collectors.toList());
167
+        assertEquals(3, filtered.size());
168
+
169
+        // 组合筛选:区域 + 类型
170
+        filtered = devices.stream()
171
+                .filter(d -> "一体化水厂".equals(d.getArea()) && "flow".equals(d.getDeviceType()))
172
+                .collect(Collectors.toList());
173
+        assertEquals(2, filtered.size());
174
+
175
+        // 关键词搜索
176
+        String keyword = "流量计";
177
+        filtered = devices.stream()
178
+                .filter(d -> (d.getDeviceName() != null && d.getDeviceName().contains(keyword))
179
+                        || (d.getDeviceCode() != null && d.getDeviceCode().contains(keyword)))
180
+                .collect(Collectors.toList());
181
+        assertEquals(2, filtered.size());
182
+    }
183
+
184
+    @Test
185
+    @DisplayName("排序逻辑测试")
186
+    void testSortLogic() {
187
+        List<MonitorDevice> devices = buildMockDevices();
188
+
189
+        // 按设备编号升序
190
+        devices.sort(Comparator.comparing(MonitorDevice::getDeviceCode));
191
+        assertEquals("MON-FLOW-001", devices.get(0).getDeviceCode());
192
+
193
+        // 按设备编号降序
194
+        devices.sort(Comparator.comparing(MonitorDevice::getDeviceCode).reversed());
195
+        assertEquals("MON-QUAL-001", devices.get(0).getDeviceCode());
196
+
197
+        // 按最后上报时间降序(最新在前)
198
+        devices.sort(Comparator.comparing(MonitorDevice::getLastReportTime, Comparator.nullsLast(Comparator.reverseOrder())));
199
+        assertNotNull(devices.get(0).getLastReportTime());
200
+    }
201
+
202
+    // ========== 5. 统计逻辑测试 ==========
203
+
204
+    @Test
205
+    @DisplayName("设备统计聚合逻辑")
206
+    void testDeviceStatistics() {
207
+        List<MonitorDevice> devices = buildMockDevices();
208
+
209
+        // 按状态统计
210
+        Map<String, Long> statusCount = devices.stream()
211
+                .collect(Collectors.groupingBy(MonitorDevice::getStatus, Collectors.counting()));
212
+        assertEquals(3L, statusCount.get("online"));
213
+        assertEquals(1L, statusCount.get("offline"));
214
+        assertEquals(1L, statusCount.get("fault"));
215
+
216
+        // 按类型统计
217
+        Map<String, Long> typeCount = devices.stream()
218
+                .collect(Collectors.groupingBy(MonitorDevice::getDeviceType, Collectors.counting()));
219
+        assertEquals(2L, typeCount.get("flow"));
220
+        assertEquals(2L, typeCount.get("pressure"));
221
+        assertEquals(1L, typeCount.get("quality"));
222
+
223
+        // 按区域统计
224
+        Map<String, Long> areaCount = devices.stream()
225
+                .collect(Collectors.groupingBy(MonitorDevice::getArea, Collectors.counting()));
226
+        assertEquals(3L, areaCount.get("一体化水厂"));
227
+        assertEquals(2L, areaCount.get("管网一区"));
228
+    }
229
+
230
+    // ========== 6. 分页计算测试 ==========
231
+
232
+    @Test
233
+    @DisplayName("分页参数计算")
234
+    void testPagination() {
235
+        int total = 50;
236
+        int pageSize = 20;
237
+
238
+        int pages = (int) Math.ceil((double) total / pageSize);
239
+        assertEquals(3, pages);
240
+
241
+        // 第1页偏移量
242
+        int offset = (1 - 1) * pageSize;
243
+        assertEquals(0, offset);
244
+
245
+        // 第3页偏移量
246
+        offset = (3 - 1) * pageSize;
247
+        assertEquals(40, offset);
248
+
249
+        // 整除情况
250
+        total = 40;
251
+        pages = (int) Math.ceil((double) total / pageSize);
252
+        assertEquals(2, pages);
253
+    }
254
+
255
+    // ========== 7. CSV 转义测试 ==========
256
+
257
+    @Test
258
+    @DisplayName("CSV 字段转义逻辑")
259
+    void testCsvEscaping() {
260
+        // 包含逗号
261
+        String value = "一号泵站,出口";
262
+        String escaped = csvEsc(value);
263
+        assertEquals("\"一号泵站,出口\"", escaped);
264
+
265
+        // 包含引号
266
+        value = "含\"引号\"的文本";
267
+        escaped = csvEsc(value);
268
+        assertEquals("\"含\"\"引号\"\"的文本\"", escaped);
269
+
270
+        // 普通文本
271
+        value = "正常文本";
272
+        escaped = csvEsc(value);
273
+        assertEquals("正常文本", escaped);
274
+
275
+        // null
276
+        escaped = csvEsc(null);
277
+        assertEquals("", escaped);
278
+    }
279
+
280
+    // ========== 8. 实时数据结构测试 ==========
281
+
282
+    @Test
283
+    @DisplayName("多参数实时数据结构完整性")
284
+    void testRealtimeDataStructure() {
285
+        // 模拟多参数实时数据
286
+        List<String> expectedMetrics = List.of("flow", "pressure", "level", "turbidity", "ph", "residual_chlorine");
287
+        Map<String, MonitorRealtimeData> metricMap = new LinkedHashMap<>();
288
+
289
+        for (String metric : expectedMetrics) {
290
+            MonitorRealtimeData data = new MonitorRealtimeData();
291
+            data.setDeviceId(1L);
292
+            data.setMetricKey(metric);
293
+            data.setMetricValue(new BigDecimal("100.00"));
294
+            data.setUnit("unit_" + metric);
295
+            data.setCollectTime(LocalDateTime.now());
296
+            metricMap.put(metric, data);
297
+        }
298
+
299
+        assertEquals(6, metricMap.size());
300
+        assertTrue(metricMap.containsKey("flow"));
301
+        assertTrue(metricMap.containsKey("pressure"));
302
+        assertTrue(metricMap.containsKey("level"));
303
+        assertTrue(metricMap.containsKey("turbidity"));
304
+        assertTrue(metricMap.containsKey("ph"));
305
+        assertTrue(metricMap.containsKey("residual_chlorine"));
306
+
307
+        // 验证各参数值
308
+        for (String metric : expectedMetrics) {
309
+            assertNotNull(metricMap.get(metric).getMetricValue());
310
+            assertEquals(metric, metricMap.get(metric).getMetricKey());
311
+        }
312
+    }
313
+
314
+    // ========== 9. 设备在线率计算 ==========
315
+
316
+    @Test
317
+    @DisplayName("设备在线率计算")
318
+    void testOnlineRateCalculation() {
319
+        List<MonitorDevice> devices = buildMockDevices();
320
+        int total = devices.size();
321
+        long onlineCount = devices.stream().filter(d -> "online".equals(d.getStatus())).count();
322
+
323
+        BigDecimal onlineRate = total > 0
324
+                ? BigDecimal.valueOf(onlineCount * 100.0 / total).setScale(1, RoundingMode.HALF_UP)
325
+                : BigDecimal.ZERO;
326
+
327
+        assertEquals(5, total);
328
+        assertEquals(3, onlineCount);
329
+        assertEquals(new BigDecimal("60.0"), onlineRate);
330
+    }
331
+
332
+    // ========== 10. 导出数据量限制测试 ==========
333
+
334
+    @Test
335
+    @DisplayName("导出最大数据量限制")
336
+    void testExportMaxLimit() {
337
+        MonitorExportRequest req = new MonitorExportRequest();
338
+        int maxExportLimit = 10000;
339
+
340
+        // 模拟大量设备
341
+        List<MonitorDevice> devices = new ArrayList<>();
342
+        for (int i = 0; i < 15000; i++) {
343
+            MonitorDevice d = new MonitorDevice();
344
+            d.setId((long) (i + 1));
345
+            d.setDeviceCode("MON-" + String.format("%05d", i));
346
+            devices.add(d);
347
+        }
348
+
349
+        // 导出应限制在 maxExportLimit 内
350
+        List<MonitorDevice> exportList = devices.size() > maxExportLimit
351
+                ? devices.subList(0, maxExportLimit)
352
+                : devices;
353
+
354
+        assertEquals(maxExportLimit, exportList.size());
355
+        assertTrue(devices.size() > maxExportLimit);
356
+    }
357
+
358
+    // ========== Helper Methods ==========
359
+
360
+    private List<MonitorDevice> buildMockDevices() {
361
+        List<MonitorDevice> devices = new ArrayList<>();
362
+
363
+        MonitorDevice d1 = new MonitorDevice();
364
+        d1.setId(1L);
365
+        d1.setDeviceCode("MON-FLOW-001");
366
+        d1.setDeviceName("一号泵站出口流量计");
367
+        d1.setDeviceType("flow");
368
+        d1.setArea("一体化水厂");
369
+        d1.setStatus("online");
370
+        d1.setLastReportTime(LocalDateTime.of(2026, 6, 14, 16, 0, 0));
371
+        devices.add(d1);
372
+
373
+        MonitorDevice d2 = new MonitorDevice();
374
+        d2.setId(2L);
375
+        d2.setDeviceCode("MON-FLOW-002");
376
+        d2.setDeviceName("二号泵站流量计");
377
+        d2.setDeviceType("flow");
378
+        d2.setArea("一体化水厂");
379
+        d2.setStatus("online");
380
+        d2.setLastReportTime(LocalDateTime.of(2026, 6, 14, 15, 50, 0));
381
+        devices.add(d2);
382
+
383
+        MonitorDevice d3 = new MonitorDevice();
384
+        d3.setId(3L);
385
+        d3.setDeviceCode("MON-PRES-001");
386
+        d3.setDeviceName("管网压力监测点A");
387
+        d3.setDeviceType("pressure");
388
+        d3.setArea("管网一区");
389
+        d3.setStatus("online");
390
+        d3.setLastReportTime(LocalDateTime.of(2026, 6, 14, 15, 55, 0));
391
+        devices.add(d3);
392
+
393
+        MonitorDevice d4 = new MonitorDevice();
394
+        d4.setId(4L);
395
+        d4.setDeviceCode("MON-PRES-002");
396
+        d4.setDeviceName("管网压力监测点B");
397
+        d4.setDeviceType("pressure");
398
+        d4.setArea("管网一区");
399
+        d4.setStatus("offline");
400
+        d4.setLastReportTime(LocalDateTime.of(2026, 6, 14, 14, 0, 0));
401
+        devices.add(d4);
402
+
403
+        MonitorDevice d5 = new MonitorDevice();
404
+        d5.setId(5L);
405
+        d5.setDeviceCode("MON-QUAL-001");
406
+        d5.setDeviceName("出厂水质监测仪");
407
+        d5.setDeviceType("quality");
408
+        d5.setArea("一体化水厂");
409
+        d5.setStatus("fault");
410
+        d5.setLastReportTime(LocalDateTime.of(2026, 6, 14, 15, 30, 0));
411
+        devices.add(d5);
412
+
413
+        return devices;
414
+    }
415
+
416
+    private String csvEsc(String value) {
417
+        if (value == null) return "";
418
+        if (value.contains(",") || value.contains("\"") || value.contains("\n")) {
419
+            return "\"" + value.replace("\"", "\"\"") + "\"";
420
+        }
421
+        return value;
422
+    }
423
+}