소스 검색

feat(wm-production): #61 总览大屏后端服务

bot_dev2 5 일 전
부모
커밋
4a82e90fcb

+ 135
- 0
wm-production/src/main/java/com/water/production/controller/DashboardController.java 파일 보기

@@ -0,0 +1,135 @@
1
+package com.water.production.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.production.entity.DashboardSummary;
5
+import com.water.production.entity.EnergyConsumption;
6
+import com.water.production.service.DashboardService;
7
+import com.water.production.service.EnergyService;
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.web.bind.annotation.*;
12
+
13
+import java.util.List;
14
+import java.util.Map;
15
+
16
+/**
17
+ * 总览大屏控制器
18
+ * 提供进出水量、水质、设备、报警、能耗等大屏数据接口
19
+ */
20
+@Tag(name = "总览大屏")
21
+@RestController
22
+@RequestMapping("/api/production/dashboard")
23
+@RequiredArgsConstructor
24
+public class DashboardController {
25
+
26
+    private final DashboardService dashboardService;
27
+    private final EnergyService energyService;
28
+
29
+    // ==================== 综合大屏 ====================
30
+
31
+    @Operation(summary = "获取完整大屏数据(聚合所有维度)")
32
+    @GetMapping("/full")
33
+    public R<Map<String, Object>> fullDashboard(
34
+            @RequestParam(defaultValue = "一体化水厂") String area) {
35
+        return R.ok(dashboardService.getFullDashboard(area));
36
+    }
37
+
38
+    // ==================== 进出水量 ====================
39
+
40
+    @Operation(summary = "进出水量概览(今日/昨日/本月 + 趋势对比)")
41
+    @GetMapping("/flow/summary")
42
+    public R<Map<String, Object>> flowSummary(
43
+            @RequestParam(defaultValue = "一体化水厂") String area) {
44
+        return R.ok(dashboardService.getFlowSummary(area));
45
+    }
46
+
47
+    @Operation(summary = "月度进出水量趋势")
48
+    @GetMapping("/flow/monthly-trend")
49
+    public R<List<Map<String, Object>>> monthlyFlowTrend(
50
+            @RequestParam(defaultValue = "一体化水厂") String area,
51
+            @RequestParam(defaultValue = "6") int months) {
52
+        return R.ok(dashboardService.getMonthlyFlowTrend(area, months));
53
+    }
54
+
55
+    // ==================== 水质概览 ====================
56
+
57
+    @Operation(summary = "水质概览(原水/出厂水/末梢水 + 合格率)")
58
+    @GetMapping("/water-quality/summary")
59
+    public R<Map<String, Object>> waterQualitySummary(
60
+            @RequestParam(defaultValue = "一体化水厂") String area) {
61
+        return R.ok(dashboardService.getWaterQualitySummary(area));
62
+    }
63
+
64
+    // ==================== 设备概况 ====================
65
+
66
+    @Operation(summary = "设备概况(在线/离线/故障 + 在线率)")
67
+    @GetMapping("/device/summary")
68
+    public R<Map<String, Object>> deviceSummary(
69
+            @RequestParam(defaultValue = "一体化水厂") String area) {
70
+        return R.ok(dashboardService.getDeviceSummary(area));
71
+    }
72
+
73
+    // ==================== 报警概览 ====================
74
+
75
+    @Operation(summary = "报警概览(今日报警/活跃报警/级别分布)")
76
+    @GetMapping("/alert/summary")
77
+    public R<Map<String, Object>> alertSummary(
78
+            @RequestParam(defaultValue = "一体化水厂") String area) {
79
+        return R.ok(dashboardService.getAlertSummary(area));
80
+    }
81
+
82
+    // ==================== 能耗数据 ====================
83
+
84
+    @Operation(summary = "今日能耗汇总(电耗/药耗)")
85
+    @GetMapping("/energy/today")
86
+    public R<Map<String, Object>> todayEnergy(
87
+            @RequestParam(defaultValue = "一体化水厂") String area) {
88
+        return R.ok(energyService.getTodaySummary(area));
89
+    }
90
+
91
+    @Operation(summary = "能耗趋势(按日,最近N天)")
92
+    @GetMapping("/energy/trend")
93
+    public R<List<Map<String, Object>>> energyTrend(
94
+            @RequestParam(defaultValue = "一体化水厂") String area,
95
+            @RequestParam(defaultValue = "7") int days) {
96
+        return R.ok(energyService.getEnergyTrend(area, days));
97
+    }
98
+
99
+    @Operation(summary = "月度能耗趋势")
100
+    @GetMapping("/energy/monthly-trend")
101
+    public R<List<Map<String, Object>>> monthlyEnergyTrend(
102
+            @RequestParam(defaultValue = "一体化水厂") String area,
103
+            @RequestParam(defaultValue = "6") int months) {
104
+        return R.ok(energyService.getMonthlyTrend(area, months));
105
+    }
106
+
107
+    @Operation(summary = "单位产水能耗")
108
+    @GetMapping("/energy/unit-consumption")
109
+    public R<Map<String, Object>> unitEnergyConsumption(
110
+            @RequestParam(defaultValue = "一体化水厂") String area,
111
+            @RequestParam String startDate,
112
+            @RequestParam String endDate) {
113
+        return R.ok(energyService.getUnitEnergyConsumption(area, startDate, endDate));
114
+    }
115
+
116
+    @Operation(summary = "能耗记录查询(按类型)")
117
+    @GetMapping("/energy/records")
118
+    public R<List<EnergyConsumption>> energyRecords(
119
+            @RequestParam(defaultValue = "一体化水厂") String area,
120
+            @RequestParam String energyType,
121
+            @RequestParam String startDate,
122
+            @RequestParam String endDate) {
123
+        return R.ok(energyService.getRecordsByType(area, energyType, startDate, endDate));
124
+    }
125
+
126
+    // ==================== 历史快照 ====================
127
+
128
+    @Operation(summary = "历史总览快照(最近N天)")
129
+    @GetMapping("/history/snapshots")
130
+    public R<List<DashboardSummary>> historySnapshots(
131
+            @RequestParam(defaultValue = "一体化水厂") String area,
132
+            @RequestParam(defaultValue = "7") int days) {
133
+        return R.ok(dashboardService.getHistorySnapshots(area, days));
134
+    }
135
+}

+ 108
- 0
wm-production/src/main/java/com/water/production/entity/DashboardSummary.java 파일 보기

@@ -0,0 +1,108 @@
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.LocalDate;
8
+import java.time.LocalDateTime;
9
+
10
+/**
11
+ * 总览大屏汇总实体
12
+ * 存储每日聚合的进出水量、水质、设备、报警、能耗快照数据
13
+ */
14
+@Data
15
+@TableName("prod_dashboard_summary")
16
+public class DashboardSummary {
17
+
18
+    @TableId(type = IdType.AUTO)
19
+    private Long id;
20
+
21
+    /** 统计日期 */
22
+    private LocalDate summaryDate;
23
+
24
+    /** 所属区域 */
25
+    private String area;
26
+
27
+    // ---- 进出水量 ----
28
+    /** 今日进水量(m³) */
29
+    private BigDecimal todayInflow;
30
+
31
+    /** 今日出水量(m³) */
32
+    private BigDecimal todayOutflow;
33
+
34
+    /** 昨日进水量(m³) */
35
+    private BigDecimal yesterdayInflow;
36
+
37
+    /** 昨日出水量(m³) */
38
+    private BigDecimal yesterdayOutflow;
39
+
40
+    /** 本月累计进水量(m³) */
41
+    private BigDecimal monthInflow;
42
+
43
+    /** 本月累计出水量(m³) */
44
+    private BigDecimal monthOutflow;
45
+
46
+    // ---- 水质概览 ----
47
+    /** 原水合格率(%) */
48
+    private BigDecimal rawWaterPassRate;
49
+
50
+    /** 出厂水合格率(%) */
51
+    private BigDecimal factoryWaterPassRate;
52
+
53
+    /** 末梢水合格率(%) */
54
+    private BigDecimal terminalWaterPassRate;
55
+
56
+    /** 综合合格率(%) */
57
+    private BigDecimal overallPassRate;
58
+
59
+    // ---- 设备概况 ----
60
+    /** 设备总数 */
61
+    private Integer deviceTotal;
62
+
63
+    /** 在线设备数 */
64
+    private Integer deviceOnline;
65
+
66
+    /** 离线设备数 */
67
+    private Integer deviceOffline;
68
+
69
+    /** 故障设备数 */
70
+    private Integer deviceFault;
71
+
72
+    /** 设备在线率(%) */
73
+    private BigDecimal deviceOnlineRate;
74
+
75
+    // ---- 报警概览 ----
76
+    /** 今日报警总数 */
77
+    private Integer todayAlertTotal;
78
+
79
+    /** 活跃报警数 */
80
+    private Integer activeAlertCount;
81
+
82
+    /** 一般报警数 */
83
+    private Integer generalAlertCount;
84
+
85
+    /** 重要报警数 */
86
+    private Integer importantAlertCount;
87
+
88
+    /** 紧急报警数 */
89
+    private Integer urgentAlertCount;
90
+
91
+    // ---- 能耗数据 ----
92
+    /** 今日电耗(kWh) */
93
+    private BigDecimal todayPowerKwh;
94
+
95
+    /** 今日药耗(kg) */
96
+    private BigDecimal todayChemicalKg;
97
+
98
+    /** 单位产水能耗(kWh/m³) */
99
+    private BigDecimal unitEnergyConsumption;
100
+
101
+    /** 创建时间 */
102
+    @TableField(fill = FieldFill.INSERT)
103
+    private LocalDateTime createdTime;
104
+
105
+    /** 更新时间 */
106
+    @TableField(fill = FieldFill.INSERT_UPDATE)
107
+    private LocalDateTime updatedTime;
108
+}

+ 52
- 0
wm-production/src/main/java/com/water/production/entity/EnergyConsumption.java 파일 보기

@@ -0,0 +1,52 @@
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.LocalDate;
8
+import java.time.LocalDateTime;
9
+
10
+/**
11
+ * 能耗数据实体
12
+ * 记录每日电耗、药耗及产水量,用于能耗分析和成本核算
13
+ */
14
+@Data
15
+@TableName("prod_energy_consumption")
16
+public class EnergyConsumption {
17
+
18
+    @TableId(type = IdType.AUTO)
19
+    private Long id;
20
+
21
+    /** 统计日期 */
22
+    private LocalDate recordDate;
23
+
24
+    /** 所属区域/水厂 */
25
+    private String area;
26
+
27
+    /** 能耗类型: power(电耗)/coagulant(混凝剂)/disinfectant(消毒剂) */
28
+    private String energyType;
29
+
30
+    /** 能耗数值 */
31
+    private BigDecimal consumption;
32
+
33
+    /** 计量单位: kWh/kg */
34
+    private String unit;
35
+
36
+    /** 当日产水量(m³),用于计算单位能耗 */
37
+    private BigDecimal productionVolume;
38
+
39
+    /** 单位产水能耗(kWh/m³ 或 kg/m³) */
40
+    private BigDecimal unitConsumption;
41
+
42
+    /** 备注 */
43
+    private String remark;
44
+
45
+    /** 创建时间 */
46
+    @TableField(fill = FieldFill.INSERT)
47
+    private LocalDateTime createdTime;
48
+
49
+    /** 更新时间 */
50
+    @TableField(fill = FieldFill.INSERT_UPDATE)
51
+    private LocalDateTime updatedTime;
52
+}

+ 38
- 0
wm-production/src/main/java/com/water/production/mapper/DashboardSummaryMapper.java 파일 보기

@@ -0,0 +1,38 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.DashboardSummary;
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
+@Mapper
13
+public interface DashboardSummaryMapper extends BaseMapper<DashboardSummary> {
14
+
15
+    /**
16
+     * 查询最近N天的总览快照(按日期倒序)
17
+     */
18
+    @Select("SELECT * FROM prod_dashboard_summary WHERE area = #{area} " +
19
+            "ORDER BY summary_date DESC LIMIT #{days}")
20
+    List<DashboardSummary> findRecent(@Param("area") String area, @Param("days") int days);
21
+
22
+    /**
23
+     * 按月份汇总进出水量趋势
24
+     */
25
+    @Select("SELECT DATE_TRUNC('month', summary_date) as month, " +
26
+            "SUM(today_inflow) as total_inflow, SUM(today_outflow) as total_outflow " +
27
+            "FROM prod_dashboard_summary WHERE area = #{area} AND summary_date >= #{startDate} " +
28
+            "GROUP BY DATE_TRUNC('month', summary_date) ORDER BY month")
29
+    List<Map<String, Object>> monthlyFlowTrend(@Param("area") String area, @Param("startDate") String startDate);
30
+
31
+    /**
32
+     * 按日期查询进出水量趋势(最近N天)
33
+     */
34
+    @Select("SELECT summary_date, today_inflow, today_outflow " +
35
+            "FROM prod_dashboard_summary WHERE area = #{area} " +
36
+            "ORDER BY summary_date DESC LIMIT #{days}")
37
+    List<Map<String, Object>> dailyFlowTrend(@Param("area") String area, @Param("days") int days);
38
+}

+ 52
- 0
wm-production/src/main/java/com/water/production/mapper/EnergyConsumptionMapper.java 파일 보기

@@ -0,0 +1,52 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.EnergyConsumption;
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
+@Mapper
13
+public interface EnergyConsumptionMapper extends BaseMapper<EnergyConsumption> {
14
+
15
+    /**
16
+     * 按区域和日期范围查询能耗数据
17
+     */
18
+    @Select("SELECT * FROM prod_energy_consumption WHERE area = #{area} " +
19
+            "AND record_date BETWEEN #{startDate} AND #{endDate} ORDER BY record_date")
20
+    List<EnergyConsumption> findByDateRange(@Param("area") String area,
21
+                                             @Param("startDate") String startDate,
22
+                                             @Param("endDate") String endDate);
23
+
24
+    /**
25
+     * 按月份汇总能耗趋势
26
+     */
27
+    @Select("SELECT DATE_TRUNC('month', record_date) as month, energy_type, " +
28
+            "SUM(consumption) as total_consumption, SUM(production_volume) as total_production " +
29
+            "FROM prod_energy_consumption WHERE area = #{area} AND record_date >= #{startDate} " +
30
+            "GROUP BY DATE_TRUNC('month', record_date), energy_type ORDER BY month")
31
+    List<Map<String, Object>> monthlyEnergyTrend(@Param("area") String area,
32
+                                                   @Param("startDate") String startDate);
33
+
34
+    /**
35
+     * 统计指定日期的能耗汇总
36
+     */
37
+    @Select("SELECT energy_type, SUM(consumption) as total, unit " +
38
+            "FROM prod_energy_consumption WHERE area = #{area} AND record_date = #{date} " +
39
+            "GROUP BY energy_type, unit")
40
+    List<Map<String, Object>> dailySummary(@Param("area") String area, @Param("date") String date);
41
+
42
+    /**
43
+     * 计算单位产水能耗(指定日期范围)
44
+     */
45
+    @Select("SELECT SUM(CASE WHEN energy_type='power' THEN consumption ELSE 0 END) as total_power, " +
46
+            "SUM(production_volume) as total_production " +
47
+            "FROM prod_energy_consumption WHERE area = #{area} " +
48
+            "AND record_date BETWEEN #{startDate} AND #{endDate}")
49
+    Map<String, Object> unitEnergyStats(@Param("area") String area,
50
+                                         @Param("startDate") String startDate,
51
+                                         @Param("endDate") String endDate);
52
+}

+ 269
- 0
wm-production/src/main/java/com/water/production/service/DashboardService.java 파일 보기

@@ -1,9 +1,15 @@
1 1
 package com.water.production.service;
2 2
 
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.production.entity.DashboardSummary;
5
+import com.water.production.mapper.DashboardSummaryMapper;
3 6
 import lombok.RequiredArgsConstructor;
4 7
 import org.springframework.jdbc.core.JdbcTemplate;
5 8
 import org.springframework.stereotype.Service;
6 9
 
10
+import java.math.BigDecimal;
11
+import java.math.RoundingMode;
12
+import java.time.LocalDate;
7 13
 import java.util.*;
8 14
 
9 15
 @Service
@@ -11,6 +17,7 @@ import java.util.*;
11 17
 public class DashboardService {
12 18
 
13 19
     private final JdbcTemplate jdbc;
20
+    private final DashboardSummaryMapper dashboardSummaryMapper;
14 21
 
15 22
     /** 获取供水总览数据(按角色自动定位区域) */
16 23
     public Map<String, Object> getOverview(String area, String roleType) {
@@ -58,4 +65,266 @@ public class DashboardService {
58 65
         sql.append(" ORDER BY last_report_time DESC LIMIT 100");
59 66
         return jdbc.queryForList(sql.toString());
60 67
     }
68
+
69
+    // ==================== 总览大屏增强接口 ====================
70
+
71
+    /**
72
+     * 进出水量概览(今日/昨日/本月 + 趋势对比)
73
+     */
74
+    public Map<String, Object> getFlowSummary(String area) {
75
+        Map<String, Object> result = new LinkedHashMap<>();
76
+        result.put("area", area);
77
+
78
+        // 今日进出水量
79
+        try {
80
+            Map<String, Object> todayFlow = jdbc.queryForMap(
81
+                "SELECT COALESCE(SUM(CASE WHEN metric_key='inflow' THEN metric_value ELSE 0 END),0) AS inflow, " +
82
+                "COALESCE(SUM(CASE WHEN metric_key='outflow' THEN metric_value ELSE 0 END),0) AS outflow " +
83
+                "FROM iot_telemetry WHERE ts >= CURRENT_DATE AND area = ?", area);
84
+            result.put("todayInflow", todayFlow.get("inflow"));
85
+            result.put("todayOutflow", todayFlow.get("outflow"));
86
+        } catch (Exception e) {
87
+            result.put("todayInflow", 0);
88
+            result.put("todayOutflow", 0);
89
+        }
90
+
91
+        // 昨日进出水量
92
+        try {
93
+            Map<String, Object> yesterdayFlow = jdbc.queryForMap(
94
+                "SELECT COALESCE(SUM(CASE WHEN metric_key='inflow' THEN metric_value ELSE 0 END),0) AS inflow, " +
95
+                "COALESCE(SUM(CASE WHEN metric_key='outflow' THEN metric_value ELSE 0 END),0) AS outflow " +
96
+                "FROM iot_telemetry WHERE ts >= CURRENT_DATE - 1 AND ts < CURRENT_DATE AND area = ?", area);
97
+            result.put("yesterdayInflow", yesterdayFlow.get("inflow"));
98
+            result.put("yesterdayOutflow", yesterdayFlow.get("outflow"));
99
+        } catch (Exception e) {
100
+            result.put("yesterdayInflow", 0);
101
+            result.put("yesterdayOutflow", 0);
102
+        }
103
+
104
+        // 本月累计
105
+        try {
106
+            Map<String, Object> monthFlow = jdbc.queryForMap(
107
+                "SELECT COALESCE(SUM(CASE WHEN metric_key='inflow' THEN metric_value ELSE 0 END),0) AS inflow, " +
108
+                "COALESCE(SUM(CASE WHEN metric_key='outflow' THEN metric_value ELSE 0 END),0) AS outflow " +
109
+                "FROM iot_telemetry WHERE ts >= DATE_TRUNC('month', CURRENT_DATE) AND area = ?", area);
110
+            result.put("monthInflow", monthFlow.get("inflow"));
111
+            result.put("monthOutflow", monthFlow.get("outflow"));
112
+        } catch (Exception e) {
113
+            result.put("monthInflow", 0);
114
+            result.put("monthOutflow", 0);
115
+        }
116
+
117
+        // 日环比
118
+        computeDayOverDay(result);
119
+
120
+        // 最近7天趋势
121
+        result.put("trend", dashboardSummaryMapper.dailyFlowTrend(area, 7));
122
+
123
+        return result;
124
+    }
125
+
126
+    /**
127
+     * 水质概览(原水/出厂水/末梢水关键指标 + 合格率)
128
+     */
129
+    public Map<String, Object> getWaterQualitySummary(String area) {
130
+        Map<String, Object> result = new LinkedHashMap<>();
131
+        result.put("area", area);
132
+
133
+        // 各类型水质合格率
134
+        String[] types = {"raw", "factory", "terminal"};
135
+        String[] labels = {"原水", "出厂水", "末梢水"};
136
+        int totalTests = 0;
137
+        int totalPassed = 0;
138
+
139
+        for (int i = 0; i < types.length; i++) {
140
+            try {
141
+                Map<String, Object> stats = jdbc.queryForMap(
142
+                    "SELECT COUNT(*) as total, " +
143
+                    "COALESCE(SUM(CASE WHEN result='合格' THEN 1 ELSE 0 END),0) as passed " +
144
+                    "FROM prod_water_quality WHERE position_type = ?", types[i]);
145
+                int total = ((Number) stats.get("total")).intValue();
146
+                int passed = ((Number) stats.get("passed")).intValue();
147
+                double rate = total > 0 ? (passed * 100.0 / total) : 100.0;
148
+
149
+                Map<String, Object> typeResult = new LinkedHashMap<>();
150
+                typeResult.put("label", labels[i]);
151
+                typeResult.put("totalTests", total);
152
+                typeResult.put("passedTests", passed);
153
+                typeResult.put("passRate", BigDecimal.valueOf(rate).setScale(1, RoundingMode.HALF_UP));
154
+                result.put(types[i] + "Water", typeResult);
155
+
156
+                totalTests += total;
157
+                totalPassed += passed;
158
+            } catch (Exception e) {
159
+                result.put(types[i] + "Water", Map.of("label", labels[i], "totalTests", 0, "passedTests", 0, "passRate", 100.0));
160
+            }
161
+        }
162
+
163
+        // 综合合格率
164
+        double overallRate = totalTests > 0 ? (totalPassed * 100.0 / totalTests) : 100.0;
165
+        result.put("overallPassRate", BigDecimal.valueOf(overallRate).setScale(1, RoundingMode.HALF_UP));
166
+
167
+        // 最新水质记录
168
+        try {
169
+            result.put("latestRecords", jdbc.queryForList(
170
+                "SELECT * FROM prod_water_quality ORDER BY created_time DESC LIMIT 5"));
171
+        } catch (Exception e) {
172
+            result.put("latestRecords", Collections.emptyList());
173
+        }
174
+
175
+        return result;
176
+    }
177
+
178
+    /**
179
+     * 设备概况(在线/离线/故障数量 + 在线率)
180
+     */
181
+    public Map<String, Object> getDeviceSummary(String area) {
182
+        Map<String, Object> result = new LinkedHashMap<>();
183
+        result.put("area", area);
184
+
185
+        int online = 0, offline = 0, fault = 0;
186
+        try {
187
+            List<Map<String, Object>> stats = jdbc.queryForList(
188
+                "SELECT status, COUNT(*) as count FROM prod_device_status WHERE area = ? GROUP BY status", area);
189
+            for (Map<String, Object> row : stats) {
190
+                int status = ((Number) row.get("status")).intValue();
191
+                int count = ((Number) row.get("count")).intValue();
192
+                switch (status) {
193
+                    case 1 -> online = count;
194
+                    case 0 -> offline = count;
195
+                    case 2 -> fault = count;
196
+                }
197
+            }
198
+        } catch (Exception ignored) {}
199
+
200
+        int total = online + offline + fault;
201
+        double onlineRate = total > 0 ? (online * 100.0 / total) : 0.0;
202
+
203
+        result.put("total", total);
204
+        result.put("online", online);
205
+        result.put("offline", offline);
206
+        result.put("fault", fault);
207
+        result.put("onlineRate", BigDecimal.valueOf(onlineRate).setScale(1, RoundingMode.HALF_UP));
208
+
209
+        return result;
210
+    }
211
+
212
+    /**
213
+     * 报警概览(今日报警数/活跃报警/各级别分布)
214
+     */
215
+    public Map<String, Object> getAlertSummary(String area) {
216
+        Map<String, Object> result = new LinkedHashMap<>();
217
+        result.put("area", area);
218
+
219
+        // 今日报警数
220
+        try {
221
+            Integer todayTotal = jdbc.queryForObject(
222
+                "SELECT COUNT(*) FROM prod_alert_record WHERE created_time >= CURRENT_DATE", Integer.class);
223
+            result.put("todayTotal", todayTotal != null ? todayTotal : 0);
224
+        } catch (Exception e) {
225
+            result.put("todayTotal", 0);
226
+        }
227
+
228
+        // 活跃报警数
229
+        try {
230
+            Integer activeCount = jdbc.queryForObject(
231
+                "SELECT COUNT(*) FROM prod_alert_record WHERE status < 4", Integer.class);
232
+            result.put("activeCount", activeCount != null ? activeCount : 0);
233
+        } catch (Exception e) {
234
+            result.put("activeCount", 0);
235
+        }
236
+
237
+        // 各级别分布
238
+        try {
239
+            List<Map<String, Object>> levelStats = jdbc.queryForList(
240
+                "SELECT alert_level, COUNT(*) as count FROM prod_alert_record " +
241
+                "WHERE created_time >= CURRENT_DATE GROUP BY alert_level");
242
+            Map<String, Integer> levelMap = new LinkedHashMap<>();
243
+            levelMap.put("general", 0);
244
+            levelMap.put("important", 0);
245
+            levelMap.put("urgent", 0);
246
+            for (Map<String, Object> row : levelStats) {
247
+                String level = (String) row.get("alert_level");
248
+                int count = ((Number) row.get("count")).intValue();
249
+                levelMap.put(level, count);
250
+            }
251
+            result.put("levelDistribution", levelMap);
252
+        } catch (Exception e) {
253
+            result.put("levelDistribution", Map.of("general", 0, "important", 0, "urgent", 0));
254
+        }
255
+
256
+        // 最近7天趋势
257
+        try {
258
+            result.put("trend", jdbc.queryForList(
259
+                "SELECT DATE(created_time) as date, COUNT(*) as count " +
260
+                "FROM prod_alert_record WHERE created_time >= CURRENT_DATE - 7 " +
261
+                "GROUP BY DATE(created_time) ORDER BY date"));
262
+        } catch (Exception e) {
263
+            result.put("trend", Collections.emptyList());
264
+        }
265
+
266
+        return result;
267
+    }
268
+
269
+    /**
270
+     * 获取总览大屏完整快照(聚合所有维度)
271
+     */
272
+    public Map<String, Object> getFullDashboard(String area) {
273
+        Map<String, Object> dashboard = new LinkedHashMap<>();
274
+        dashboard.put("flow", getFlowSummary(area));
275
+        dashboard.put("waterQuality", getWaterQualitySummary(area));
276
+        dashboard.put("device", getDeviceSummary(area));
277
+        dashboard.put("alert", getAlertSummary(area));
278
+        dashboard.put("area", area);
279
+        dashboard.put("timestamp", System.currentTimeMillis());
280
+        return dashboard;
281
+    }
282
+
283
+    /**
284
+     * 查询历史总览快照
285
+     */
286
+    public List<DashboardSummary> getHistorySnapshots(String area, int days) {
287
+        return dashboardSummaryMapper.findRecent(area, days);
288
+    }
289
+
290
+    /**
291
+     * 月度进出水量趋势
292
+     */
293
+    public List<Map<String, Object>> getMonthlyFlowTrend(String area, int months) {
294
+        String startDate = LocalDate.now().minusMonths(months).toString();
295
+        return dashboardSummaryMapper.monthlyFlowTrend(area, startDate);
296
+    }
297
+
298
+    /**
299
+     * 计算日环比
300
+     */
301
+    private void computeDayOverDay(Map<String, Object> result) {
302
+        try {
303
+            Object todayIn = result.get("todayInflow");
304
+            Object yesterdayIn = result.get("yesterdayInflow");
305
+            if (todayIn != null && yesterdayIn != null) {
306
+                double ti = ((Number) todayIn).doubleValue();
307
+                double yi = ((Number) yesterdayIn).doubleValue();
308
+                if (yi > 0) {
309
+                    double rate = ((ti - yi) / yi) * 100;
310
+                    result.put("inflowDayOverDay", BigDecimal.valueOf(rate).setScale(1, RoundingMode.HALF_UP));
311
+                } else {
312
+                    result.put("inflowDayOverDay", null);
313
+                }
314
+            }
315
+
316
+            Object todayOut = result.get("todayOutflow");
317
+            Object yesterdayOut = result.get("yesterdayOutflow");
318
+            if (todayOut != null && yesterdayOut != null) {
319
+                double to = ((Number) todayOut).doubleValue();
320
+                double yo = ((Number) yesterdayOut).doubleValue();
321
+                if (yo > 0) {
322
+                    double rate = ((to - yo) / yo) * 100;
323
+                    result.put("outflowDayOverDay", BigDecimal.valueOf(rate).setScale(1, RoundingMode.HALF_UP));
324
+                } else {
325
+                    result.put("outflowDayOverDay", null);
326
+                }
327
+            }
328
+        } catch (Exception ignored) {}
329
+    }
61 330
 }

+ 135
- 0
wm-production/src/main/java/com/water/production/service/EnergyService.java 파일 보기

@@ -0,0 +1,135 @@
1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.production.entity.EnergyConsumption;
5
+import com.water.production.mapper.EnergyConsumptionMapper;
6
+import lombok.RequiredArgsConstructor;
7
+import org.springframework.stereotype.Service;
8
+
9
+import java.math.BigDecimal;
10
+import java.math.RoundingMode;
11
+import java.time.LocalDate;
12
+import java.util.*;
13
+
14
+/**
15
+ * 能耗数据服务
16
+ * 提供电耗、药耗统计及单位产水能耗分析
17
+ */
18
+@Service
19
+@RequiredArgsConstructor
20
+public class EnergyService {
21
+
22
+    private final EnergyConsumptionMapper energyMapper;
23
+
24
+    /**
25
+     * 获取今日能耗汇总
26
+     */
27
+    public Map<String, Object> getTodaySummary(String area) {
28
+        String today = LocalDate.now().toString();
29
+        List<Map<String, Object>> items = energyMapper.dailySummary(area, today);
30
+
31
+        Map<String, Object> result = new LinkedHashMap<>();
32
+        result.put("area", area);
33
+        result.put("date", today);
34
+
35
+        BigDecimal totalPower = BigDecimal.ZERO;
36
+        BigDecimal totalChemical = BigDecimal.ZERO;
37
+
38
+        for (Map<String, Object> item : items) {
39
+            String type = (String) item.get("energy_type");
40
+            BigDecimal total = item.get("total") != null ? new BigDecimal(item.get("total").toString()) : BigDecimal.ZERO;
41
+            if ("power".equals(type)) {
42
+                totalPower = total;
43
+                result.put("powerKwh", total);
44
+                result.put("powerUnit", item.get("unit"));
45
+            } else {
46
+                totalChemical = totalChemical.add(total);
47
+                result.put("chemical_" + type + "_kg", total);
48
+            }
49
+        }
50
+
51
+        result.put("totalPowerKwh", totalPower);
52
+        result.put("totalChemicalKg", totalChemical);
53
+        result.put("items", items);
54
+        return result;
55
+    }
56
+
57
+    /**
58
+     * 获取能耗趋势(按日)
59
+     */
60
+    public List<Map<String, Object>> getEnergyTrend(String area, int days) {
61
+        LambdaQueryWrapper<EnergyConsumption> wrapper = new LambdaQueryWrapper<>();
62
+        wrapper.eq(EnergyConsumption::getArea, area)
63
+                .ge(EnergyConsumption::getRecordDate, LocalDate.now().minusDays(days))
64
+                .orderByAsc(EnergyConsumption::getRecordDate);
65
+        List<EnergyConsumption> list = energyMapper.selectList(wrapper);
66
+
67
+        List<Map<String, Object>> trend = new ArrayList<>();
68
+        for (EnergyConsumption ec : list) {
69
+            Map<String, Object> item = new LinkedHashMap<>();
70
+            item.put("date", ec.getRecordDate());
71
+            item.put("energyType", ec.getEnergyType());
72
+            item.put("consumption", ec.getConsumption());
73
+            item.put("unit", ec.getUnit());
74
+            item.put("productionVolume", ec.getProductionVolume());
75
+            item.put("unitConsumption", ec.getUnitConsumption());
76
+            trend.add(item);
77
+        }
78
+        return trend;
79
+    }
80
+
81
+    /**
82
+     * 获取月度能耗趋势
83
+     */
84
+    public List<Map<String, Object>> getMonthlyTrend(String area, int months) {
85
+        String startDate = LocalDate.now().minusMonths(months).toString();
86
+        return energyMapper.monthlyEnergyTrend(area, startDate);
87
+    }
88
+
89
+    /**
90
+     * 计算单位产水能耗
91
+     */
92
+    public Map<String, Object> getUnitEnergyConsumption(String area, String startDate, String endDate) {
93
+        Map<String, Object> stats = energyMapper.unitEnergyStats(area, startDate, endDate);
94
+
95
+        Map<String, Object> result = new LinkedHashMap<>();
96
+        result.put("area", area);
97
+        result.put("startDate", startDate);
98
+        result.put("endDate", endDate);
99
+
100
+        if (stats != null) {
101
+            BigDecimal totalPower = stats.get("total_power") != null
102
+                    ? new BigDecimal(stats.get("total_power").toString()) : BigDecimal.ZERO;
103
+            BigDecimal totalProduction = stats.get("total_production") != null
104
+                    ? new BigDecimal(stats.get("total_production").toString()) : BigDecimal.ZERO;
105
+
106
+            result.put("totalPowerKwh", totalPower);
107
+            result.put("totalProductionM3", totalProduction);
108
+
109
+            if (totalProduction.compareTo(BigDecimal.ZERO) > 0) {
110
+                result.put("unitEnergyKwhPerM3",
111
+                        totalPower.divide(totalProduction, 4, RoundingMode.HALF_UP));
112
+            } else {
113
+                result.put("unitEnergyKwhPerM3", BigDecimal.ZERO);
114
+            }
115
+        } else {
116
+            result.put("totalPowerKwh", BigDecimal.ZERO);
117
+            result.put("totalProductionM3", BigDecimal.ZERO);
118
+            result.put("unitEnergyKwhPerM3", BigDecimal.ZERO);
119
+        }
120
+        return result;
121
+    }
122
+
123
+    /**
124
+     * 按类型查询能耗记录
125
+     */
126
+    public List<EnergyConsumption> getRecordsByType(String area, String energyType, String startDate, String endDate) {
127
+        LambdaQueryWrapper<EnergyConsumption> wrapper = new LambdaQueryWrapper<>();
128
+        wrapper.eq(EnergyConsumption::getArea, area)
129
+                .eq(EnergyConsumption::getEnergyType, energyType)
130
+                .ge(EnergyConsumption::getRecordDate, startDate)
131
+                .le(EnergyConsumption::getRecordDate, endDate)
132
+                .orderByAsc(EnergyConsumption::getRecordDate);
133
+        return energyMapper.selectList(wrapper);
134
+    }
135
+}

+ 86
- 0
wm-production/src/main/resources/db/V2__dashboard_energy.sql 파일 보기

@@ -0,0 +1,86 @@
1
+-- ============================================================
2
+-- V2: 总览大屏 + 能耗数据表
3
+-- Issue #61: 总览大屏(进出水量/水质/设备/报警/能耗)
4
+-- ============================================================
5
+
6
+-- 总览大屏每日快照表
7
+CREATE TABLE IF NOT EXISTS prod_dashboard_summary (
8
+    id              BIGSERIAL PRIMARY KEY,
9
+    summary_date    DATE NOT NULL,
10
+    area            VARCHAR(100) NOT NULL DEFAULT '一体化水厂',
11
+
12
+    -- 进出水量
13
+    today_inflow        NUMERIC(14,2) DEFAULT 0,
14
+    today_outflow       NUMERIC(14,2) DEFAULT 0,
15
+    yesterday_inflow    NUMERIC(14,2) DEFAULT 0,
16
+    yesterday_outflow   NUMERIC(14,2) DEFAULT 0,
17
+    month_inflow        NUMERIC(16,2) DEFAULT 0,
18
+    month_outflow       NUMERIC(16,2) DEFAULT 0,
19
+
20
+    -- 水质概览
21
+    raw_water_pass_rate     NUMERIC(5,2) DEFAULT 100,
22
+    factory_water_pass_rate NUMERIC(5,2) DEFAULT 100,
23
+    terminal_water_pass_rate NUMERIC(5,2) DEFAULT 100,
24
+    overall_pass_rate       NUMERIC(5,2) DEFAULT 100,
25
+
26
+    -- 设备概况
27
+    device_total    INT DEFAULT 0,
28
+    device_online   INT DEFAULT 0,
29
+    device_offline  INT DEFAULT 0,
30
+    device_fault    INT DEFAULT 0,
31
+    device_online_rate NUMERIC(5,2) DEFAULT 0,
32
+
33
+    -- 报警概览
34
+    today_alert_total   INT DEFAULT 0,
35
+    active_alert_count  INT DEFAULT 0,
36
+    general_alert_count INT DEFAULT 0,
37
+    important_alert_count INT DEFAULT 0,
38
+    urgent_alert_count  INT DEFAULT 0,
39
+
40
+    -- 能耗数据
41
+    today_power_kwh         NUMERIC(12,2) DEFAULT 0,
42
+    today_chemical_kg       NUMERIC(12,2) DEFAULT 0,
43
+    unit_energy_consumption NUMERIC(8,4) DEFAULT 0,
44
+
45
+    created_time    TIMESTAMP DEFAULT NOW(),
46
+    updated_time    TIMESTAMP DEFAULT NOW()
47
+);
48
+
49
+-- 唯一约束:每个区域每天一条快照
50
+CREATE UNIQUE INDEX IF NOT EXISTS uk_dashboard_date_area
51
+    ON prod_dashboard_summary (summary_date, area);
52
+
53
+-- 索引:按区域查询
54
+CREATE INDEX IF NOT EXISTS idx_dashboard_area
55
+    ON prod_dashboard_summary (area);
56
+
57
+-- 能耗数据明细表
58
+CREATE TABLE IF NOT EXISTS prod_energy_consumption (
59
+    id                  BIGSERIAL PRIMARY KEY,
60
+    record_date         DATE NOT NULL,
61
+    area                VARCHAR(100) NOT NULL DEFAULT '一体化水厂',
62
+    energy_type         VARCHAR(30) NOT NULL,       -- power/coagulant/disinfectant
63
+    consumption         NUMERIC(14,2) NOT NULL DEFAULT 0,
64
+    unit                VARCHAR(20) NOT NULL DEFAULT 'kWh',
65
+    production_volume   NUMERIC(14,2) DEFAULT 0,
66
+    unit_consumption    NUMERIC(8,4) DEFAULT 0,
67
+    remark              VARCHAR(500),
68
+    created_time        TIMESTAMP DEFAULT NOW(),
69
+    updated_time        TIMESTAMP DEFAULT NOW()
70
+);
71
+
72
+-- 索引:按区域+日期
73
+CREATE INDEX IF NOT EXISTS idx_energy_area_date
74
+    ON prod_energy_consumption (area, record_date);
75
+
76
+-- 索引:按能耗类型
77
+CREATE INDEX IF NOT EXISTS idx_energy_type
78
+    ON prod_energy_consumption (energy_type);
79
+
80
+-- 唯一约束:每个区域每天每种能耗类型一条记录
81
+CREATE UNIQUE INDEX IF NOT EXISTS uk_energy_date_area_type
82
+    ON prod_energy_consumption (record_date, area, energy_type);
83
+
84
+COMMENT ON TABLE prod_dashboard_summary IS '总览大屏每日快照表';
85
+COMMENT ON TABLE prod_energy_consumption IS '能耗数据明细表';
86
+COMMENT ON COLUMN prod_energy_consumption.energy_type IS '能耗类型: power(电耗)/coagulant(混凝剂)/disinfectant(消毒剂)';

+ 304
- 0
wm-production/src/test/java/com/water/production/service/DashboardServiceTest.java 파일 보기

@@ -0,0 +1,304 @@
1
+package com.water.production.service;
2
+
3
+import com.water.production.entity.DashboardSummary;
4
+import com.water.production.entity.EnergyConsumption;
5
+import org.junit.jupiter.api.DisplayName;
6
+import org.junit.jupiter.api.Test;
7
+
8
+import java.math.BigDecimal;
9
+import java.math.RoundingMode;
10
+import java.time.LocalDate;
11
+import java.util.*;
12
+
13
+import static org.junit.jupiter.api.Assertions.*;
14
+
15
+/**
16
+ * DashboardService + EnergyService 单元测试
17
+ * 测试实体构建、数据聚合逻辑、环比计算等核心逻辑
18
+ */
19
+class DashboardServiceTest {
20
+
21
+    @Test
22
+    @DisplayName("测试 DashboardSummary 实体字段完整性")
23
+    void testDashboardSummaryEntityFields() {
24
+        DashboardSummary summary = new DashboardSummary();
25
+        summary.setId(1L);
26
+        summary.setSummaryDate(LocalDate.of(2026, 6, 14));
27
+        summary.setArea("一体化水厂");
28
+
29
+        // 进出水量
30
+        summary.setTodayInflow(new BigDecimal("15000.50"));
31
+        summary.setTodayOutflow(new BigDecimal("14200.30"));
32
+        summary.setYesterdayInflow(new BigDecimal("14500.00"));
33
+        summary.setYesterdayOutflow(new BigDecimal("13800.00"));
34
+        summary.setMonthInflow(new BigDecimal("420000.00"));
35
+        summary.setMonthOutflow(new BigDecimal("398000.00"));
36
+
37
+        // 水质
38
+        summary.setRawWaterPassRate(new BigDecimal("98.5"));
39
+        summary.setFactoryWaterPassRate(new BigDecimal("99.2"));
40
+        summary.setTerminalWaterPassRate(new BigDecimal("97.8"));
41
+        summary.setOverallPassRate(new BigDecimal("98.5"));
42
+
43
+        // 设备
44
+        summary.setDeviceTotal(120);
45
+        summary.setDeviceOnline(105);
46
+        summary.setDeviceOffline(10);
47
+        summary.setDeviceFault(5);
48
+        summary.setDeviceOnlineRate(new BigDecimal("87.5"));
49
+
50
+        // 报警
51
+        summary.setTodayAlertTotal(8);
52
+        summary.setActiveAlertCount(3);
53
+        summary.setGeneralAlertCount(5);
54
+        summary.setImportantAlertCount(2);
55
+        summary.setUrgentAlertCount(1);
56
+
57
+        // 能耗
58
+        summary.setTodayPowerKwh(new BigDecimal("1250.50"));
59
+        summary.setTodayChemicalKg(new BigDecimal("57.50"));
60
+        summary.setUnitEnergyConsumption(new BigDecimal("0.0834"));
61
+
62
+        // 验证所有字段
63
+        assertEquals(1L, summary.getId());
64
+        assertEquals(LocalDate.of(2026, 6, 14), summary.getSummaryDate());
65
+        assertEquals("一体化水厂", summary.getArea());
66
+        assertEquals(new BigDecimal("15000.50"), summary.getTodayInflow());
67
+        assertEquals(new BigDecimal("14200.30"), summary.getTodayOutflow());
68
+        assertEquals(120, summary.getDeviceTotal());
69
+        assertEquals(105, summary.getDeviceOnline());
70
+        assertEquals(8, summary.getTodayAlertTotal());
71
+        assertEquals(new BigDecimal("1250.50"), summary.getTodayPowerKwh());
72
+        assertEquals(new BigDecimal("0.0834"), summary.getUnitEnergyConsumption());
73
+    }
74
+
75
+    @Test
76
+    @DisplayName("测试 EnergyConsumption 实体字段及单位能耗计算")
77
+    void testEnergyConsumptionEntity() {
78
+        EnergyConsumption ec = new EnergyConsumption();
79
+        ec.setId(1L);
80
+        ec.setRecordDate(LocalDate.of(2026, 6, 14));
81
+        ec.setArea("一体化水厂");
82
+        ec.setEnergyType("power");
83
+        ec.setConsumption(new BigDecimal("1250.50"));
84
+        ec.setUnit("kWh");
85
+        ec.setProductionVolume(new BigDecimal("15000.00"));
86
+
87
+        // 计算单位能耗
88
+        BigDecimal unitConsumption = ec.getConsumption()
89
+                .divide(ec.getProductionVolume(), 4, RoundingMode.HALF_UP);
90
+        ec.setUnitConsumption(unitConsumption);
91
+
92
+        assertEquals("power", ec.getEnergyType());
93
+        assertEquals(new BigDecimal("1250.50"), ec.getConsumption());
94
+        assertEquals("kWh", ec.getUnit());
95
+        assertEquals(new BigDecimal("15000.00"), ec.getProductionVolume());
96
+        assertEquals(new BigDecimal("0.0834"), ec.getUnitConsumption());
97
+
98
+        // 测试药剂类型
99
+        EnergyConsumption chemical = new EnergyConsumption();
100
+        chemical.setEnergyType("coagulant");
101
+        chemical.setConsumption(new BigDecimal("45.00"));
102
+        chemical.setUnit("kg");
103
+        chemical.setProductionVolume(new BigDecimal("15000.00"));
104
+        BigDecimal chemUnit = chemical.getConsumption()
105
+                .divide(chemical.getProductionVolume(), 4, RoundingMode.HALF_UP);
106
+        chemical.setUnitConsumption(chemUnit);
107
+
108
+        assertEquals("coagulant", chemical.getEnergyType());
109
+        assertEquals(new BigDecimal("0.0030"), chemical.getUnitConsumption());
110
+    }
111
+
112
+    @Test
113
+    @DisplayName("测试日环比计算逻辑")
114
+    void testDayOverDayCalculation() {
115
+        // 模拟进出水量日环比计算
116
+        double todayInflow = 15000.0;
117
+        double yesterdayInflow = 14000.0;
118
+        double expectedRate = ((todayInflow - yesterdayInflow) / yesterdayInflow) * 100;
119
+        BigDecimal rate = BigDecimal.valueOf(expectedRate).setScale(1, RoundingMode.HALF_UP);
120
+        assertEquals(new BigDecimal("7.1"), rate);
121
+
122
+        // 测试下降情况
123
+        todayInflow = 13000.0;
124
+        yesterdayInflow = 14000.0;
125
+        expectedRate = ((todayInflow - yesterdayInflow) / yesterdayInflow) * 100;
126
+        rate = BigDecimal.valueOf(expectedRate).setScale(1, RoundingMode.HALF_UP);
127
+        assertEquals(new BigDecimal("-7.1"), rate);
128
+
129
+        // 测试昨日为零的情况(除以零保护)
130
+        yesterdayInflow = 0;
131
+        if (yesterdayInflow > 0) {
132
+            fail("Should not reach here");
133
+        }
134
+        // 应该返回null,不计算
135
+        BigDecimal nullRate = null;
136
+        assertNull(nullRate);
137
+    }
138
+
139
+    @Test
140
+    @DisplayName("测试水质合格率计算逻辑")
141
+    void testWaterQualityPassRateCalculation() {
142
+        // 模拟原水检测数据
143
+        int totalTests = 200;
144
+        int passedTests = 195;
145
+        double passRate = totalTests > 0 ? (passedTests * 100.0 / totalTests) : 100.0;
146
+        BigDecimal rate = BigDecimal.valueOf(passRate).setScale(1, RoundingMode.HALF_UP);
147
+        assertEquals(new BigDecimal("97.5"), rate);
148
+
149
+        // 模拟全部合格
150
+        totalTests = 150;
151
+        passedTests = 150;
152
+        passRate = totalTests > 0 ? (passedTests * 100.0 / totalTests) : 100.0;
153
+        rate = BigDecimal.valueOf(passRate).setScale(1, RoundingMode.HALF_UP);
154
+        assertEquals(new BigDecimal("100.0"), rate);
155
+
156
+        // 模拟无检测数据
157
+        totalTests = 0;
158
+        passedTests = 0;
159
+        passRate = totalTests > 0 ? (passedTests * 100.0 / totalTests) : 100.0;
160
+        rate = BigDecimal.valueOf(passRate).setScale(1, RoundingMode.HALF_UP);
161
+        assertEquals(new BigDecimal("100.0"), rate);
162
+
163
+        // 模拟综合合格率
164
+        int rawTotal = 200, rawPassed = 195;
165
+        int factoryTotal = 180, factoryPassed = 178;
166
+        int terminalTotal = 160, terminalPassed = 155;
167
+        int allTotal = rawTotal + factoryTotal + terminalTotal;
168
+        int allPassed = rawPassed + factoryPassed + terminalPassed;
169
+        double overallRate = allTotal > 0 ? (allPassed * 100.0 / allTotal) : 100.0;
170
+        BigDecimal overallBd = BigDecimal.valueOf(overallRate).setScale(1, RoundingMode.HALF_UP);
171
+        assertEquals(new BigDecimal("97.8"), overallBd);
172
+    }
173
+
174
+    @Test
175
+    @DisplayName("测试设备在线率计算逻辑")
176
+    void testDeviceOnlineRateCalculation() {
177
+        int online = 105, offline = 10, fault = 5;
178
+        int total = online + offline + fault;
179
+        assertEquals(120, total);
180
+
181
+        double onlineRate = total > 0 ? (online * 100.0 / total) : 0.0;
182
+        BigDecimal rate = BigDecimal.valueOf(onlineRate).setScale(1, RoundingMode.HALF_UP);
183
+        assertEquals(new BigDecimal("87.5"), rate);
184
+
185
+        // 测试全部离线
186
+        online = 0;
187
+        offline = 50;
188
+        fault = 10;
189
+        total = online + offline + fault;
190
+        onlineRate = total > 0 ? (online * 100.0 / total) : 0.0;
191
+        rate = BigDecimal.valueOf(onlineRate).setScale(1, RoundingMode.HALF_UP);
192
+        assertEquals(new BigDecimal("0.0"), rate);
193
+
194
+        // 测试无设备
195
+        online = 0;
196
+        offline = 0;
197
+        fault = 0;
198
+        total = 0;
199
+        onlineRate = total > 0 ? (online * 100.0 / total) : 0.0;
200
+        rate = BigDecimal.valueOf(onlineRate).setScale(1, RoundingMode.HALF_UP);
201
+        assertEquals(new BigDecimal("0.0"), rate);
202
+    }
203
+
204
+    @Test
205
+    @DisplayName("测试报警级别分布统计逻辑")
206
+    void testAlertLevelDistribution() {
207
+        // 模拟数据库返回的级别统计
208
+        List<Map<String, Object>> levelStats = new ArrayList<>();
209
+        levelStats.add(Map.of("alert_level", "general", "count", 5L));
210
+        levelStats.add(Map.of("alert_level", "important", "count", 2L));
211
+        levelStats.add(Map.of("alert_level", "urgent", "count", 1L));
212
+
213
+        Map<String, Integer> levelMap = new LinkedHashMap<>();
214
+        levelMap.put("general", 0);
215
+        levelMap.put("important", 0);
216
+        levelMap.put("urgent", 0);
217
+
218
+        for (Map<String, Object> row : levelStats) {
219
+            String level = (String) row.get("alert_level");
220
+            int count = ((Number) row.get("count")).intValue();
221
+            levelMap.put(level, count);
222
+        }
223
+
224
+        assertEquals(5, levelMap.get("general"));
225
+        assertEquals(2, levelMap.get("important"));
226
+        assertEquals(1, levelMap.get("urgent"));
227
+        assertEquals(8, levelMap.values().stream().mapToInt(Integer::intValue).sum());
228
+
229
+        // 测试缺少某个级别时的默认值
230
+        List<Map<String, Object>> partialStats = new ArrayList<>();
231
+        partialStats.add(Map.of("alert_level", "urgent", "count", 3L));
232
+
233
+        Map<String, Integer> partialMap = new LinkedHashMap<>();
234
+        partialMap.put("general", 0);
235
+        partialMap.put("important", 0);
236
+        partialMap.put("urgent", 0);
237
+
238
+        for (Map<String, Object> row : partialStats) {
239
+            String level = (String) row.get("alert_level");
240
+            int count = ((Number) row.get("count")).intValue();
241
+            partialMap.put(level, count);
242
+        }
243
+
244
+        assertEquals(0, partialMap.get("general"));
245
+        assertEquals(0, partialMap.get("important"));
246
+        assertEquals(3, partialMap.get("urgent"));
247
+    }
248
+
249
+    @Test
250
+    @DisplayName("测试月度进出水量趋势数据结构")
251
+    void testMonthlyFlowTrendStructure() {
252
+        // 模拟月度趋势数据
253
+        List<Map<String, Object>> trend = new ArrayList<>();
254
+        for (int i = 0; i < 6; i++) {
255
+            Map<String, Object> month = new LinkedHashMap<>();
256
+            month.put("month", LocalDate.of(2026, i + 1, 1));
257
+            month.put("total_inflow", new BigDecimal(String.valueOf(400000 + i * 10000)));
258
+            month.put("total_outflow", new BigDecimal(String.valueOf(380000 + i * 9500)));
259
+            trend.add(month);
260
+        }
261
+
262
+        assertEquals(6, trend.size());
263
+        assertEquals(LocalDate.of(2026, 1, 1), trend.get(0).get("month"));
264
+        assertEquals(new BigDecimal("400000"), trend.get(0).get("total_inflow"));
265
+        assertEquals(new BigDecimal("450000"), trend.get(5).get("total_inflow"));
266
+
267
+        // 验证趋势递增
268
+        for (int i = 1; i < trend.size(); i++) {
269
+            BigDecimal prev = (BigDecimal) trend.get(i - 1).get("total_inflow");
270
+            BigDecimal curr = (BigDecimal) trend.get(i).get("total_inflow");
271
+            assertTrue(curr.compareTo(prev) > 0);
272
+        }
273
+    }
274
+
275
+    @Test
276
+    @DisplayName("测试能耗汇总按类型聚合逻辑")
277
+    void testEnergySummaryAggregation() {
278
+        // 模拟能耗数据
279
+        List<Map<String, Object>> items = new ArrayList<>();
280
+        items.add(Map.of("energy_type", "power", "total", new BigDecimal("1250.50"), "unit", "kWh"));
281
+        items.add(Map.of("energy_type", "coagulant", "total", new BigDecimal("45.00"), "unit", "kg"));
282
+        items.add(Map.of("energy_type", "disinfectant", "total", new BigDecimal("12.50"), "unit", "kg"));
283
+
284
+        BigDecimal totalPower = BigDecimal.ZERO;
285
+        BigDecimal totalChemical = BigDecimal.ZERO;
286
+
287
+        for (Map<String, Object> item : items) {
288
+            String type = (String) item.get("energy_type");
289
+            BigDecimal total = (BigDecimal) item.get("total");
290
+            if ("power".equals(type)) {
291
+                totalPower = total;
292
+            } else {
293
+                totalChemical = totalChemical.add(total);
294
+            }
295
+        }
296
+
297
+        assertEquals(new BigDecimal("1250.50"), totalPower);
298
+        assertEquals(new BigDecimal("57.50"), totalChemical);
299
+
300
+        // 验证总能耗
301
+        BigDecimal grandTotal = totalPower.add(totalChemical);
302
+        assertEquals(new BigDecimal("1308.00"), grandTotal);
303
+    }
304
+}