From 4a82e90fcb4fe19d4bb8fe436fee50bf2f3b8b74 Mon Sep 17 00:00:00 2001 From: bot_dev2 Date: Sun, 14 Jun 2026 16:11:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(wm-production):=20#61=20=E6=80=BB=E8=A7=88?= =?UTF-8?q?=E5=A4=A7=E5=B1=8F=E5=90=8E=E7=AB=AF=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/DashboardController.java | 135 ++++++++ .../production/entity/DashboardSummary.java | 108 +++++++ .../production/entity/EnergyConsumption.java | 52 +++ .../mapper/DashboardSummaryMapper.java | 38 +++ .../mapper/EnergyConsumptionMapper.java | 52 +++ .../production/service/DashboardService.java | 269 ++++++++++++++++ .../production/service/EnergyService.java | 135 ++++++++ .../resources/db/V2__dashboard_energy.sql | 86 +++++ .../service/DashboardServiceTest.java | 304 ++++++++++++++++++ 9 files changed, 1179 insertions(+) create mode 100644 wm-production/src/main/java/com/water/production/controller/DashboardController.java create mode 100644 wm-production/src/main/java/com/water/production/entity/DashboardSummary.java create mode 100644 wm-production/src/main/java/com/water/production/entity/EnergyConsumption.java create mode 100644 wm-production/src/main/java/com/water/production/mapper/DashboardSummaryMapper.java create mode 100644 wm-production/src/main/java/com/water/production/mapper/EnergyConsumptionMapper.java create mode 100644 wm-production/src/main/java/com/water/production/service/EnergyService.java create mode 100644 wm-production/src/main/resources/db/V2__dashboard_energy.sql create mode 100644 wm-production/src/test/java/com/water/production/service/DashboardServiceTest.java diff --git a/wm-production/src/main/java/com/water/production/controller/DashboardController.java b/wm-production/src/main/java/com/water/production/controller/DashboardController.java new file mode 100644 index 00000000..7355b6f1 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/controller/DashboardController.java @@ -0,0 +1,135 @@ +package com.water.production.controller; + +import com.water.common.core.result.R; +import com.water.production.entity.DashboardSummary; +import com.water.production.entity.EnergyConsumption; +import com.water.production.service.DashboardService; +import com.water.production.service.EnergyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 总览大屏控制器 + * 提供进出水量、水质、设备、报警、能耗等大屏数据接口 + */ +@Tag(name = "总览大屏") +@RestController +@RequestMapping("/api/production/dashboard") +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardService dashboardService; + private final EnergyService energyService; + + // ==================== 综合大屏 ==================== + + @Operation(summary = "获取完整大屏数据(聚合所有维度)") + @GetMapping("/full") + public R> fullDashboard( + @RequestParam(defaultValue = "一体化水厂") String area) { + return R.ok(dashboardService.getFullDashboard(area)); + } + + // ==================== 进出水量 ==================== + + @Operation(summary = "进出水量概览(今日/昨日/本月 + 趋势对比)") + @GetMapping("/flow/summary") + public R> flowSummary( + @RequestParam(defaultValue = "一体化水厂") String area) { + return R.ok(dashboardService.getFlowSummary(area)); + } + + @Operation(summary = "月度进出水量趋势") + @GetMapping("/flow/monthly-trend") + public R>> monthlyFlowTrend( + @RequestParam(defaultValue = "一体化水厂") String area, + @RequestParam(defaultValue = "6") int months) { + return R.ok(dashboardService.getMonthlyFlowTrend(area, months)); + } + + // ==================== 水质概览 ==================== + + @Operation(summary = "水质概览(原水/出厂水/末梢水 + 合格率)") + @GetMapping("/water-quality/summary") + public R> waterQualitySummary( + @RequestParam(defaultValue = "一体化水厂") String area) { + return R.ok(dashboardService.getWaterQualitySummary(area)); + } + + // ==================== 设备概况 ==================== + + @Operation(summary = "设备概况(在线/离线/故障 + 在线率)") + @GetMapping("/device/summary") + public R> deviceSummary( + @RequestParam(defaultValue = "一体化水厂") String area) { + return R.ok(dashboardService.getDeviceSummary(area)); + } + + // ==================== 报警概览 ==================== + + @Operation(summary = "报警概览(今日报警/活跃报警/级别分布)") + @GetMapping("/alert/summary") + public R> alertSummary( + @RequestParam(defaultValue = "一体化水厂") String area) { + return R.ok(dashboardService.getAlertSummary(area)); + } + + // ==================== 能耗数据 ==================== + + @Operation(summary = "今日能耗汇总(电耗/药耗)") + @GetMapping("/energy/today") + public R> todayEnergy( + @RequestParam(defaultValue = "一体化水厂") String area) { + return R.ok(energyService.getTodaySummary(area)); + } + + @Operation(summary = "能耗趋势(按日,最近N天)") + @GetMapping("/energy/trend") + public R>> energyTrend( + @RequestParam(defaultValue = "一体化水厂") String area, + @RequestParam(defaultValue = "7") int days) { + return R.ok(energyService.getEnergyTrend(area, days)); + } + + @Operation(summary = "月度能耗趋势") + @GetMapping("/energy/monthly-trend") + public R>> monthlyEnergyTrend( + @RequestParam(defaultValue = "一体化水厂") String area, + @RequestParam(defaultValue = "6") int months) { + return R.ok(energyService.getMonthlyTrend(area, months)); + } + + @Operation(summary = "单位产水能耗") + @GetMapping("/energy/unit-consumption") + public R> unitEnergyConsumption( + @RequestParam(defaultValue = "一体化水厂") String area, + @RequestParam String startDate, + @RequestParam String endDate) { + return R.ok(energyService.getUnitEnergyConsumption(area, startDate, endDate)); + } + + @Operation(summary = "能耗记录查询(按类型)") + @GetMapping("/energy/records") + public R> energyRecords( + @RequestParam(defaultValue = "一体化水厂") String area, + @RequestParam String energyType, + @RequestParam String startDate, + @RequestParam String endDate) { + return R.ok(energyService.getRecordsByType(area, energyType, startDate, endDate)); + } + + // ==================== 历史快照 ==================== + + @Operation(summary = "历史总览快照(最近N天)") + @GetMapping("/history/snapshots") + public R> historySnapshots( + @RequestParam(defaultValue = "一体化水厂") String area, + @RequestParam(defaultValue = "7") int days) { + return R.ok(dashboardService.getHistorySnapshots(area, days)); + } +} diff --git a/wm-production/src/main/java/com/water/production/entity/DashboardSummary.java b/wm-production/src/main/java/com/water/production/entity/DashboardSummary.java new file mode 100644 index 00000000..4e2367e6 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/DashboardSummary.java @@ -0,0 +1,108 @@ +package com.water.production.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 总览大屏汇总实体 + * 存储每日聚合的进出水量、水质、设备、报警、能耗快照数据 + */ +@Data +@TableName("prod_dashboard_summary") +public class DashboardSummary { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 统计日期 */ + private LocalDate summaryDate; + + /** 所属区域 */ + private String area; + + // ---- 进出水量 ---- + /** 今日进水量(m³) */ + private BigDecimal todayInflow; + + /** 今日出水量(m³) */ + private BigDecimal todayOutflow; + + /** 昨日进水量(m³) */ + private BigDecimal yesterdayInflow; + + /** 昨日出水量(m³) */ + private BigDecimal yesterdayOutflow; + + /** 本月累计进水量(m³) */ + private BigDecimal monthInflow; + + /** 本月累计出水量(m³) */ + private BigDecimal monthOutflow; + + // ---- 水质概览 ---- + /** 原水合格率(%) */ + private BigDecimal rawWaterPassRate; + + /** 出厂水合格率(%) */ + private BigDecimal factoryWaterPassRate; + + /** 末梢水合格率(%) */ + private BigDecimal terminalWaterPassRate; + + /** 综合合格率(%) */ + private BigDecimal overallPassRate; + + // ---- 设备概况 ---- + /** 设备总数 */ + private Integer deviceTotal; + + /** 在线设备数 */ + private Integer deviceOnline; + + /** 离线设备数 */ + private Integer deviceOffline; + + /** 故障设备数 */ + private Integer deviceFault; + + /** 设备在线率(%) */ + private BigDecimal deviceOnlineRate; + + // ---- 报警概览 ---- + /** 今日报警总数 */ + private Integer todayAlertTotal; + + /** 活跃报警数 */ + private Integer activeAlertCount; + + /** 一般报警数 */ + private Integer generalAlertCount; + + /** 重要报警数 */ + private Integer importantAlertCount; + + /** 紧急报警数 */ + private Integer urgentAlertCount; + + // ---- 能耗数据 ---- + /** 今日电耗(kWh) */ + private BigDecimal todayPowerKwh; + + /** 今日药耗(kg) */ + private BigDecimal todayChemicalKg; + + /** 单位产水能耗(kWh/m³) */ + private BigDecimal unitEnergyConsumption; + + /** 创建时间 */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdTime; + + /** 更新时间 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; +} diff --git a/wm-production/src/main/java/com/water/production/entity/EnergyConsumption.java b/wm-production/src/main/java/com/water/production/entity/EnergyConsumption.java new file mode 100644 index 00000000..35ae9f5d --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/EnergyConsumption.java @@ -0,0 +1,52 @@ +package com.water.production.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 能耗数据实体 + * 记录每日电耗、药耗及产水量,用于能耗分析和成本核算 + */ +@Data +@TableName("prod_energy_consumption") +public class EnergyConsumption { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 统计日期 */ + private LocalDate recordDate; + + /** 所属区域/水厂 */ + private String area; + + /** 能耗类型: power(电耗)/coagulant(混凝剂)/disinfectant(消毒剂) */ + private String energyType; + + /** 能耗数值 */ + private BigDecimal consumption; + + /** 计量单位: kWh/kg */ + private String unit; + + /** 当日产水量(m³),用于计算单位能耗 */ + private BigDecimal productionVolume; + + /** 单位产水能耗(kWh/m³ 或 kg/m³) */ + private BigDecimal unitConsumption; + + /** 备注 */ + private String remark; + + /** 创建时间 */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdTime; + + /** 更新时间 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; +} diff --git a/wm-production/src/main/java/com/water/production/mapper/DashboardSummaryMapper.java b/wm-production/src/main/java/com/water/production/mapper/DashboardSummaryMapper.java new file mode 100644 index 00000000..a90543c0 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/DashboardSummaryMapper.java @@ -0,0 +1,38 @@ +package com.water.production.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.water.production.entity.DashboardSummary; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface DashboardSummaryMapper extends BaseMapper { + + /** + * 查询最近N天的总览快照(按日期倒序) + */ + @Select("SELECT * FROM prod_dashboard_summary WHERE area = #{area} " + + "ORDER BY summary_date DESC LIMIT #{days}") + List findRecent(@Param("area") String area, @Param("days") int days); + + /** + * 按月份汇总进出水量趋势 + */ + @Select("SELECT DATE_TRUNC('month', summary_date) as month, " + + "SUM(today_inflow) as total_inflow, SUM(today_outflow) as total_outflow " + + "FROM prod_dashboard_summary WHERE area = #{area} AND summary_date >= #{startDate} " + + "GROUP BY DATE_TRUNC('month', summary_date) ORDER BY month") + List> monthlyFlowTrend(@Param("area") String area, @Param("startDate") String startDate); + + /** + * 按日期查询进出水量趋势(最近N天) + */ + @Select("SELECT summary_date, today_inflow, today_outflow " + + "FROM prod_dashboard_summary WHERE area = #{area} " + + "ORDER BY summary_date DESC LIMIT #{days}") + List> dailyFlowTrend(@Param("area") String area, @Param("days") int days); +} diff --git a/wm-production/src/main/java/com/water/production/mapper/EnergyConsumptionMapper.java b/wm-production/src/main/java/com/water/production/mapper/EnergyConsumptionMapper.java new file mode 100644 index 00000000..8fd50194 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/EnergyConsumptionMapper.java @@ -0,0 +1,52 @@ +package com.water.production.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.water.production.entity.EnergyConsumption; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface EnergyConsumptionMapper extends BaseMapper { + + /** + * 按区域和日期范围查询能耗数据 + */ + @Select("SELECT * FROM prod_energy_consumption WHERE area = #{area} " + + "AND record_date BETWEEN #{startDate} AND #{endDate} ORDER BY record_date") + List findByDateRange(@Param("area") String area, + @Param("startDate") String startDate, + @Param("endDate") String endDate); + + /** + * 按月份汇总能耗趋势 + */ + @Select("SELECT DATE_TRUNC('month', record_date) as month, energy_type, " + + "SUM(consumption) as total_consumption, SUM(production_volume) as total_production " + + "FROM prod_energy_consumption WHERE area = #{area} AND record_date >= #{startDate} " + + "GROUP BY DATE_TRUNC('month', record_date), energy_type ORDER BY month") + List> monthlyEnergyTrend(@Param("area") String area, + @Param("startDate") String startDate); + + /** + * 统计指定日期的能耗汇总 + */ + @Select("SELECT energy_type, SUM(consumption) as total, unit " + + "FROM prod_energy_consumption WHERE area = #{area} AND record_date = #{date} " + + "GROUP BY energy_type, unit") + List> dailySummary(@Param("area") String area, @Param("date") String date); + + /** + * 计算单位产水能耗(指定日期范围) + */ + @Select("SELECT SUM(CASE WHEN energy_type='power' THEN consumption ELSE 0 END) as total_power, " + + "SUM(production_volume) as total_production " + + "FROM prod_energy_consumption WHERE area = #{area} " + + "AND record_date BETWEEN #{startDate} AND #{endDate}") + Map unitEnergyStats(@Param("area") String area, + @Param("startDate") String startDate, + @Param("endDate") String endDate); +} diff --git a/wm-production/src/main/java/com/water/production/service/DashboardService.java b/wm-production/src/main/java/com/water/production/service/DashboardService.java index 051a6fb8..641b47e4 100644 --- a/wm-production/src/main/java/com/water/production/service/DashboardService.java +++ b/wm-production/src/main/java/com/water/production/service/DashboardService.java @@ -1,9 +1,15 @@ package com.water.production.service; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.water.production.entity.DashboardSummary; +import com.water.production.mapper.DashboardSummaryMapper; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; import java.util.*; @Service @@ -11,6 +17,7 @@ import java.util.*; public class DashboardService { private final JdbcTemplate jdbc; + private final DashboardSummaryMapper dashboardSummaryMapper; /** 获取供水总览数据(按角色自动定位区域) */ public Map getOverview(String area, String roleType) { @@ -58,4 +65,266 @@ public class DashboardService { sql.append(" ORDER BY last_report_time DESC LIMIT 100"); return jdbc.queryForList(sql.toString()); } + + // ==================== 总览大屏增强接口 ==================== + + /** + * 进出水量概览(今日/昨日/本月 + 趋势对比) + */ + public Map getFlowSummary(String area) { + Map result = new LinkedHashMap<>(); + result.put("area", area); + + // 今日进出水量 + try { + Map todayFlow = jdbc.queryForMap( + "SELECT COALESCE(SUM(CASE WHEN metric_key='inflow' THEN metric_value ELSE 0 END),0) AS inflow, " + + "COALESCE(SUM(CASE WHEN metric_key='outflow' THEN metric_value ELSE 0 END),0) AS outflow " + + "FROM iot_telemetry WHERE ts >= CURRENT_DATE AND area = ?", area); + result.put("todayInflow", todayFlow.get("inflow")); + result.put("todayOutflow", todayFlow.get("outflow")); + } catch (Exception e) { + result.put("todayInflow", 0); + result.put("todayOutflow", 0); + } + + // 昨日进出水量 + try { + Map yesterdayFlow = jdbc.queryForMap( + "SELECT COALESCE(SUM(CASE WHEN metric_key='inflow' THEN metric_value ELSE 0 END),0) AS inflow, " + + "COALESCE(SUM(CASE WHEN metric_key='outflow' THEN metric_value ELSE 0 END),0) AS outflow " + + "FROM iot_telemetry WHERE ts >= CURRENT_DATE - 1 AND ts < CURRENT_DATE AND area = ?", area); + result.put("yesterdayInflow", yesterdayFlow.get("inflow")); + result.put("yesterdayOutflow", yesterdayFlow.get("outflow")); + } catch (Exception e) { + result.put("yesterdayInflow", 0); + result.put("yesterdayOutflow", 0); + } + + // 本月累计 + try { + Map monthFlow = jdbc.queryForMap( + "SELECT COALESCE(SUM(CASE WHEN metric_key='inflow' THEN metric_value ELSE 0 END),0) AS inflow, " + + "COALESCE(SUM(CASE WHEN metric_key='outflow' THEN metric_value ELSE 0 END),0) AS outflow " + + "FROM iot_telemetry WHERE ts >= DATE_TRUNC('month', CURRENT_DATE) AND area = ?", area); + result.put("monthInflow", monthFlow.get("inflow")); + result.put("monthOutflow", monthFlow.get("outflow")); + } catch (Exception e) { + result.put("monthInflow", 0); + result.put("monthOutflow", 0); + } + + // 日环比 + computeDayOverDay(result); + + // 最近7天趋势 + result.put("trend", dashboardSummaryMapper.dailyFlowTrend(area, 7)); + + return result; + } + + /** + * 水质概览(原水/出厂水/末梢水关键指标 + 合格率) + */ + public Map getWaterQualitySummary(String area) { + Map result = new LinkedHashMap<>(); + result.put("area", area); + + // 各类型水质合格率 + String[] types = {"raw", "factory", "terminal"}; + String[] labels = {"原水", "出厂水", "末梢水"}; + int totalTests = 0; + int totalPassed = 0; + + for (int i = 0; i < types.length; i++) { + try { + Map stats = jdbc.queryForMap( + "SELECT COUNT(*) as total, " + + "COALESCE(SUM(CASE WHEN result='合格' THEN 1 ELSE 0 END),0) as passed " + + "FROM prod_water_quality WHERE position_type = ?", types[i]); + int total = ((Number) stats.get("total")).intValue(); + int passed = ((Number) stats.get("passed")).intValue(); + double rate = total > 0 ? (passed * 100.0 / total) : 100.0; + + Map typeResult = new LinkedHashMap<>(); + typeResult.put("label", labels[i]); + typeResult.put("totalTests", total); + typeResult.put("passedTests", passed); + typeResult.put("passRate", BigDecimal.valueOf(rate).setScale(1, RoundingMode.HALF_UP)); + result.put(types[i] + "Water", typeResult); + + totalTests += total; + totalPassed += passed; + } catch (Exception e) { + result.put(types[i] + "Water", Map.of("label", labels[i], "totalTests", 0, "passedTests", 0, "passRate", 100.0)); + } + } + + // 综合合格率 + double overallRate = totalTests > 0 ? (totalPassed * 100.0 / totalTests) : 100.0; + result.put("overallPassRate", BigDecimal.valueOf(overallRate).setScale(1, RoundingMode.HALF_UP)); + + // 最新水质记录 + try { + result.put("latestRecords", jdbc.queryForList( + "SELECT * FROM prod_water_quality ORDER BY created_time DESC LIMIT 5")); + } catch (Exception e) { + result.put("latestRecords", Collections.emptyList()); + } + + return result; + } + + /** + * 设备概况(在线/离线/故障数量 + 在线率) + */ + public Map getDeviceSummary(String area) { + Map result = new LinkedHashMap<>(); + result.put("area", area); + + int online = 0, offline = 0, fault = 0; + try { + List> stats = jdbc.queryForList( + "SELECT status, COUNT(*) as count FROM prod_device_status WHERE area = ? GROUP BY status", area); + for (Map row : stats) { + int status = ((Number) row.get("status")).intValue(); + int count = ((Number) row.get("count")).intValue(); + switch (status) { + case 1 -> online = count; + case 0 -> offline = count; + case 2 -> fault = count; + } + } + } catch (Exception ignored) {} + + int total = online + offline + fault; + double onlineRate = total > 0 ? (online * 100.0 / total) : 0.0; + + result.put("total", total); + result.put("online", online); + result.put("offline", offline); + result.put("fault", fault); + result.put("onlineRate", BigDecimal.valueOf(onlineRate).setScale(1, RoundingMode.HALF_UP)); + + return result; + } + + /** + * 报警概览(今日报警数/活跃报警/各级别分布) + */ + public Map getAlertSummary(String area) { + Map result = new LinkedHashMap<>(); + result.put("area", area); + + // 今日报警数 + try { + Integer todayTotal = jdbc.queryForObject( + "SELECT COUNT(*) FROM prod_alert_record WHERE created_time >= CURRENT_DATE", Integer.class); + result.put("todayTotal", todayTotal != null ? todayTotal : 0); + } catch (Exception e) { + result.put("todayTotal", 0); + } + + // 活跃报警数 + try { + Integer activeCount = jdbc.queryForObject( + "SELECT COUNT(*) FROM prod_alert_record WHERE status < 4", Integer.class); + result.put("activeCount", activeCount != null ? activeCount : 0); + } catch (Exception e) { + result.put("activeCount", 0); + } + + // 各级别分布 + try { + List> levelStats = jdbc.queryForList( + "SELECT alert_level, COUNT(*) as count FROM prod_alert_record " + + "WHERE created_time >= CURRENT_DATE GROUP BY alert_level"); + Map levelMap = new LinkedHashMap<>(); + levelMap.put("general", 0); + levelMap.put("important", 0); + levelMap.put("urgent", 0); + for (Map row : levelStats) { + String level = (String) row.get("alert_level"); + int count = ((Number) row.get("count")).intValue(); + levelMap.put(level, count); + } + result.put("levelDistribution", levelMap); + } catch (Exception e) { + result.put("levelDistribution", Map.of("general", 0, "important", 0, "urgent", 0)); + } + + // 最近7天趋势 + try { + result.put("trend", jdbc.queryForList( + "SELECT DATE(created_time) as date, COUNT(*) as count " + + "FROM prod_alert_record WHERE created_time >= CURRENT_DATE - 7 " + + "GROUP BY DATE(created_time) ORDER BY date")); + } catch (Exception e) { + result.put("trend", Collections.emptyList()); + } + + return result; + } + + /** + * 获取总览大屏完整快照(聚合所有维度) + */ + public Map getFullDashboard(String area) { + Map dashboard = new LinkedHashMap<>(); + dashboard.put("flow", getFlowSummary(area)); + dashboard.put("waterQuality", getWaterQualitySummary(area)); + dashboard.put("device", getDeviceSummary(area)); + dashboard.put("alert", getAlertSummary(area)); + dashboard.put("area", area); + dashboard.put("timestamp", System.currentTimeMillis()); + return dashboard; + } + + /** + * 查询历史总览快照 + */ + public List getHistorySnapshots(String area, int days) { + return dashboardSummaryMapper.findRecent(area, days); + } + + /** + * 月度进出水量趋势 + */ + public List> getMonthlyFlowTrend(String area, int months) { + String startDate = LocalDate.now().minusMonths(months).toString(); + return dashboardSummaryMapper.monthlyFlowTrend(area, startDate); + } + + /** + * 计算日环比 + */ + private void computeDayOverDay(Map result) { + try { + Object todayIn = result.get("todayInflow"); + Object yesterdayIn = result.get("yesterdayInflow"); + if (todayIn != null && yesterdayIn != null) { + double ti = ((Number) todayIn).doubleValue(); + double yi = ((Number) yesterdayIn).doubleValue(); + if (yi > 0) { + double rate = ((ti - yi) / yi) * 100; + result.put("inflowDayOverDay", BigDecimal.valueOf(rate).setScale(1, RoundingMode.HALF_UP)); + } else { + result.put("inflowDayOverDay", null); + } + } + + Object todayOut = result.get("todayOutflow"); + Object yesterdayOut = result.get("yesterdayOutflow"); + if (todayOut != null && yesterdayOut != null) { + double to = ((Number) todayOut).doubleValue(); + double yo = ((Number) yesterdayOut).doubleValue(); + if (yo > 0) { + double rate = ((to - yo) / yo) * 100; + result.put("outflowDayOverDay", BigDecimal.valueOf(rate).setScale(1, RoundingMode.HALF_UP)); + } else { + result.put("outflowDayOverDay", null); + } + } + } catch (Exception ignored) {} + } } diff --git a/wm-production/src/main/java/com/water/production/service/EnergyService.java b/wm-production/src/main/java/com/water/production/service/EnergyService.java new file mode 100644 index 00000000..98412507 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/service/EnergyService.java @@ -0,0 +1,135 @@ +package com.water.production.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.water.production.entity.EnergyConsumption; +import com.water.production.mapper.EnergyConsumptionMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.*; + +/** + * 能耗数据服务 + * 提供电耗、药耗统计及单位产水能耗分析 + */ +@Service +@RequiredArgsConstructor +public class EnergyService { + + private final EnergyConsumptionMapper energyMapper; + + /** + * 获取今日能耗汇总 + */ + public Map getTodaySummary(String area) { + String today = LocalDate.now().toString(); + List> items = energyMapper.dailySummary(area, today); + + Map result = new LinkedHashMap<>(); + result.put("area", area); + result.put("date", today); + + BigDecimal totalPower = BigDecimal.ZERO; + BigDecimal totalChemical = BigDecimal.ZERO; + + for (Map item : items) { + String type = (String) item.get("energy_type"); + BigDecimal total = item.get("total") != null ? new BigDecimal(item.get("total").toString()) : BigDecimal.ZERO; + if ("power".equals(type)) { + totalPower = total; + result.put("powerKwh", total); + result.put("powerUnit", item.get("unit")); + } else { + totalChemical = totalChemical.add(total); + result.put("chemical_" + type + "_kg", total); + } + } + + result.put("totalPowerKwh", totalPower); + result.put("totalChemicalKg", totalChemical); + result.put("items", items); + return result; + } + + /** + * 获取能耗趋势(按日) + */ + public List> getEnergyTrend(String area, int days) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(EnergyConsumption::getArea, area) + .ge(EnergyConsumption::getRecordDate, LocalDate.now().minusDays(days)) + .orderByAsc(EnergyConsumption::getRecordDate); + List list = energyMapper.selectList(wrapper); + + List> trend = new ArrayList<>(); + for (EnergyConsumption ec : list) { + Map item = new LinkedHashMap<>(); + item.put("date", ec.getRecordDate()); + item.put("energyType", ec.getEnergyType()); + item.put("consumption", ec.getConsumption()); + item.put("unit", ec.getUnit()); + item.put("productionVolume", ec.getProductionVolume()); + item.put("unitConsumption", ec.getUnitConsumption()); + trend.add(item); + } + return trend; + } + + /** + * 获取月度能耗趋势 + */ + public List> getMonthlyTrend(String area, int months) { + String startDate = LocalDate.now().minusMonths(months).toString(); + return energyMapper.monthlyEnergyTrend(area, startDate); + } + + /** + * 计算单位产水能耗 + */ + public Map getUnitEnergyConsumption(String area, String startDate, String endDate) { + Map stats = energyMapper.unitEnergyStats(area, startDate, endDate); + + Map result = new LinkedHashMap<>(); + result.put("area", area); + result.put("startDate", startDate); + result.put("endDate", endDate); + + if (stats != null) { + BigDecimal totalPower = stats.get("total_power") != null + ? new BigDecimal(stats.get("total_power").toString()) : BigDecimal.ZERO; + BigDecimal totalProduction = stats.get("total_production") != null + ? new BigDecimal(stats.get("total_production").toString()) : BigDecimal.ZERO; + + result.put("totalPowerKwh", totalPower); + result.put("totalProductionM3", totalProduction); + + if (totalProduction.compareTo(BigDecimal.ZERO) > 0) { + result.put("unitEnergyKwhPerM3", + totalPower.divide(totalProduction, 4, RoundingMode.HALF_UP)); + } else { + result.put("unitEnergyKwhPerM3", BigDecimal.ZERO); + } + } else { + result.put("totalPowerKwh", BigDecimal.ZERO); + result.put("totalProductionM3", BigDecimal.ZERO); + result.put("unitEnergyKwhPerM3", BigDecimal.ZERO); + } + return result; + } + + /** + * 按类型查询能耗记录 + */ + public List getRecordsByType(String area, String energyType, String startDate, String endDate) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(EnergyConsumption::getArea, area) + .eq(EnergyConsumption::getEnergyType, energyType) + .ge(EnergyConsumption::getRecordDate, startDate) + .le(EnergyConsumption::getRecordDate, endDate) + .orderByAsc(EnergyConsumption::getRecordDate); + return energyMapper.selectList(wrapper); + } +} diff --git a/wm-production/src/main/resources/db/V2__dashboard_energy.sql b/wm-production/src/main/resources/db/V2__dashboard_energy.sql new file mode 100644 index 00000000..ec193f8e --- /dev/null +++ b/wm-production/src/main/resources/db/V2__dashboard_energy.sql @@ -0,0 +1,86 @@ +-- ============================================================ +-- V2: 总览大屏 + 能耗数据表 +-- Issue #61: 总览大屏(进出水量/水质/设备/报警/能耗) +-- ============================================================ + +-- 总览大屏每日快照表 +CREATE TABLE IF NOT EXISTS prod_dashboard_summary ( + id BIGSERIAL PRIMARY KEY, + summary_date DATE NOT NULL, + area VARCHAR(100) NOT NULL DEFAULT '一体化水厂', + + -- 进出水量 + today_inflow NUMERIC(14,2) DEFAULT 0, + today_outflow NUMERIC(14,2) DEFAULT 0, + yesterday_inflow NUMERIC(14,2) DEFAULT 0, + yesterday_outflow NUMERIC(14,2) DEFAULT 0, + month_inflow NUMERIC(16,2) DEFAULT 0, + month_outflow NUMERIC(16,2) DEFAULT 0, + + -- 水质概览 + raw_water_pass_rate NUMERIC(5,2) DEFAULT 100, + factory_water_pass_rate NUMERIC(5,2) DEFAULT 100, + terminal_water_pass_rate NUMERIC(5,2) DEFAULT 100, + overall_pass_rate NUMERIC(5,2) DEFAULT 100, + + -- 设备概况 + device_total INT DEFAULT 0, + device_online INT DEFAULT 0, + device_offline INT DEFAULT 0, + device_fault INT DEFAULT 0, + device_online_rate NUMERIC(5,2) DEFAULT 0, + + -- 报警概览 + today_alert_total INT DEFAULT 0, + active_alert_count INT DEFAULT 0, + general_alert_count INT DEFAULT 0, + important_alert_count INT DEFAULT 0, + urgent_alert_count INT DEFAULT 0, + + -- 能耗数据 + today_power_kwh NUMERIC(12,2) DEFAULT 0, + today_chemical_kg NUMERIC(12,2) DEFAULT 0, + unit_energy_consumption NUMERIC(8,4) DEFAULT 0, + + created_time TIMESTAMP DEFAULT NOW(), + updated_time TIMESTAMP DEFAULT NOW() +); + +-- 唯一约束:每个区域每天一条快照 +CREATE UNIQUE INDEX IF NOT EXISTS uk_dashboard_date_area + ON prod_dashboard_summary (summary_date, area); + +-- 索引:按区域查询 +CREATE INDEX IF NOT EXISTS idx_dashboard_area + ON prod_dashboard_summary (area); + +-- 能耗数据明细表 +CREATE TABLE IF NOT EXISTS prod_energy_consumption ( + id BIGSERIAL PRIMARY KEY, + record_date DATE NOT NULL, + area VARCHAR(100) NOT NULL DEFAULT '一体化水厂', + energy_type VARCHAR(30) NOT NULL, -- power/coagulant/disinfectant + consumption NUMERIC(14,2) NOT NULL DEFAULT 0, + unit VARCHAR(20) NOT NULL DEFAULT 'kWh', + production_volume NUMERIC(14,2) DEFAULT 0, + unit_consumption NUMERIC(8,4) DEFAULT 0, + remark VARCHAR(500), + created_time TIMESTAMP DEFAULT NOW(), + updated_time TIMESTAMP DEFAULT NOW() +); + +-- 索引:按区域+日期 +CREATE INDEX IF NOT EXISTS idx_energy_area_date + ON prod_energy_consumption (area, record_date); + +-- 索引:按能耗类型 +CREATE INDEX IF NOT EXISTS idx_energy_type + ON prod_energy_consumption (energy_type); + +-- 唯一约束:每个区域每天每种能耗类型一条记录 +CREATE UNIQUE INDEX IF NOT EXISTS uk_energy_date_area_type + ON prod_energy_consumption (record_date, area, energy_type); + +COMMENT ON TABLE prod_dashboard_summary IS '总览大屏每日快照表'; +COMMENT ON TABLE prod_energy_consumption IS '能耗数据明细表'; +COMMENT ON COLUMN prod_energy_consumption.energy_type IS '能耗类型: power(电耗)/coagulant(混凝剂)/disinfectant(消毒剂)'; diff --git a/wm-production/src/test/java/com/water/production/service/DashboardServiceTest.java b/wm-production/src/test/java/com/water/production/service/DashboardServiceTest.java new file mode 100644 index 00000000..f14cf2b1 --- /dev/null +++ b/wm-production/src/test/java/com/water/production/service/DashboardServiceTest.java @@ -0,0 +1,304 @@ +package com.water.production.service; + +import com.water.production.entity.DashboardSummary; +import com.water.production.entity.EnergyConsumption; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * DashboardService + EnergyService 单元测试 + * 测试实体构建、数据聚合逻辑、环比计算等核心逻辑 + */ +class DashboardServiceTest { + + @Test + @DisplayName("测试 DashboardSummary 实体字段完整性") + void testDashboardSummaryEntityFields() { + DashboardSummary summary = new DashboardSummary(); + summary.setId(1L); + summary.setSummaryDate(LocalDate.of(2026, 6, 14)); + summary.setArea("一体化水厂"); + + // 进出水量 + summary.setTodayInflow(new BigDecimal("15000.50")); + summary.setTodayOutflow(new BigDecimal("14200.30")); + summary.setYesterdayInflow(new BigDecimal("14500.00")); + summary.setYesterdayOutflow(new BigDecimal("13800.00")); + summary.setMonthInflow(new BigDecimal("420000.00")); + summary.setMonthOutflow(new BigDecimal("398000.00")); + + // 水质 + summary.setRawWaterPassRate(new BigDecimal("98.5")); + summary.setFactoryWaterPassRate(new BigDecimal("99.2")); + summary.setTerminalWaterPassRate(new BigDecimal("97.8")); + summary.setOverallPassRate(new BigDecimal("98.5")); + + // 设备 + summary.setDeviceTotal(120); + summary.setDeviceOnline(105); + summary.setDeviceOffline(10); + summary.setDeviceFault(5); + summary.setDeviceOnlineRate(new BigDecimal("87.5")); + + // 报警 + summary.setTodayAlertTotal(8); + summary.setActiveAlertCount(3); + summary.setGeneralAlertCount(5); + summary.setImportantAlertCount(2); + summary.setUrgentAlertCount(1); + + // 能耗 + summary.setTodayPowerKwh(new BigDecimal("1250.50")); + summary.setTodayChemicalKg(new BigDecimal("57.50")); + summary.setUnitEnergyConsumption(new BigDecimal("0.0834")); + + // 验证所有字段 + assertEquals(1L, summary.getId()); + assertEquals(LocalDate.of(2026, 6, 14), summary.getSummaryDate()); + assertEquals("一体化水厂", summary.getArea()); + assertEquals(new BigDecimal("15000.50"), summary.getTodayInflow()); + assertEquals(new BigDecimal("14200.30"), summary.getTodayOutflow()); + assertEquals(120, summary.getDeviceTotal()); + assertEquals(105, summary.getDeviceOnline()); + assertEquals(8, summary.getTodayAlertTotal()); + assertEquals(new BigDecimal("1250.50"), summary.getTodayPowerKwh()); + assertEquals(new BigDecimal("0.0834"), summary.getUnitEnergyConsumption()); + } + + @Test + @DisplayName("测试 EnergyConsumption 实体字段及单位能耗计算") + void testEnergyConsumptionEntity() { + EnergyConsumption ec = new EnergyConsumption(); + ec.setId(1L); + ec.setRecordDate(LocalDate.of(2026, 6, 14)); + ec.setArea("一体化水厂"); + ec.setEnergyType("power"); + ec.setConsumption(new BigDecimal("1250.50")); + ec.setUnit("kWh"); + ec.setProductionVolume(new BigDecimal("15000.00")); + + // 计算单位能耗 + BigDecimal unitConsumption = ec.getConsumption() + .divide(ec.getProductionVolume(), 4, RoundingMode.HALF_UP); + ec.setUnitConsumption(unitConsumption); + + assertEquals("power", ec.getEnergyType()); + assertEquals(new BigDecimal("1250.50"), ec.getConsumption()); + assertEquals("kWh", ec.getUnit()); + assertEquals(new BigDecimal("15000.00"), ec.getProductionVolume()); + assertEquals(new BigDecimal("0.0834"), ec.getUnitConsumption()); + + // 测试药剂类型 + EnergyConsumption chemical = new EnergyConsumption(); + chemical.setEnergyType("coagulant"); + chemical.setConsumption(new BigDecimal("45.00")); + chemical.setUnit("kg"); + chemical.setProductionVolume(new BigDecimal("15000.00")); + BigDecimal chemUnit = chemical.getConsumption() + .divide(chemical.getProductionVolume(), 4, RoundingMode.HALF_UP); + chemical.setUnitConsumption(chemUnit); + + assertEquals("coagulant", chemical.getEnergyType()); + assertEquals(new BigDecimal("0.0030"), chemical.getUnitConsumption()); + } + + @Test + @DisplayName("测试日环比计算逻辑") + void testDayOverDayCalculation() { + // 模拟进出水量日环比计算 + double todayInflow = 15000.0; + double yesterdayInflow = 14000.0; + double expectedRate = ((todayInflow - yesterdayInflow) / yesterdayInflow) * 100; + BigDecimal rate = BigDecimal.valueOf(expectedRate).setScale(1, RoundingMode.HALF_UP); + assertEquals(new BigDecimal("7.1"), rate); + + // 测试下降情况 + todayInflow = 13000.0; + yesterdayInflow = 14000.0; + expectedRate = ((todayInflow - yesterdayInflow) / yesterdayInflow) * 100; + rate = BigDecimal.valueOf(expectedRate).setScale(1, RoundingMode.HALF_UP); + assertEquals(new BigDecimal("-7.1"), rate); + + // 测试昨日为零的情况(除以零保护) + yesterdayInflow = 0; + if (yesterdayInflow > 0) { + fail("Should not reach here"); + } + // 应该返回null,不计算 + BigDecimal nullRate = null; + assertNull(nullRate); + } + + @Test + @DisplayName("测试水质合格率计算逻辑") + void testWaterQualityPassRateCalculation() { + // 模拟原水检测数据 + int totalTests = 200; + int passedTests = 195; + double passRate = totalTests > 0 ? (passedTests * 100.0 / totalTests) : 100.0; + BigDecimal rate = BigDecimal.valueOf(passRate).setScale(1, RoundingMode.HALF_UP); + assertEquals(new BigDecimal("97.5"), rate); + + // 模拟全部合格 + totalTests = 150; + passedTests = 150; + passRate = totalTests > 0 ? (passedTests * 100.0 / totalTests) : 100.0; + rate = BigDecimal.valueOf(passRate).setScale(1, RoundingMode.HALF_UP); + assertEquals(new BigDecimal("100.0"), rate); + + // 模拟无检测数据 + totalTests = 0; + passedTests = 0; + passRate = totalTests > 0 ? (passedTests * 100.0 / totalTests) : 100.0; + rate = BigDecimal.valueOf(passRate).setScale(1, RoundingMode.HALF_UP); + assertEquals(new BigDecimal("100.0"), rate); + + // 模拟综合合格率 + int rawTotal = 200, rawPassed = 195; + int factoryTotal = 180, factoryPassed = 178; + int terminalTotal = 160, terminalPassed = 155; + int allTotal = rawTotal + factoryTotal + terminalTotal; + int allPassed = rawPassed + factoryPassed + terminalPassed; + double overallRate = allTotal > 0 ? (allPassed * 100.0 / allTotal) : 100.0; + BigDecimal overallBd = BigDecimal.valueOf(overallRate).setScale(1, RoundingMode.HALF_UP); + assertEquals(new BigDecimal("97.8"), overallBd); + } + + @Test + @DisplayName("测试设备在线率计算逻辑") + void testDeviceOnlineRateCalculation() { + int online = 105, offline = 10, fault = 5; + int total = online + offline + fault; + assertEquals(120, total); + + double onlineRate = total > 0 ? (online * 100.0 / total) : 0.0; + BigDecimal rate = BigDecimal.valueOf(onlineRate).setScale(1, RoundingMode.HALF_UP); + assertEquals(new BigDecimal("87.5"), rate); + + // 测试全部离线 + online = 0; + offline = 50; + fault = 10; + total = online + offline + fault; + onlineRate = total > 0 ? (online * 100.0 / total) : 0.0; + rate = BigDecimal.valueOf(onlineRate).setScale(1, RoundingMode.HALF_UP); + assertEquals(new BigDecimal("0.0"), rate); + + // 测试无设备 + online = 0; + offline = 0; + fault = 0; + total = 0; + onlineRate = total > 0 ? (online * 100.0 / total) : 0.0; + rate = BigDecimal.valueOf(onlineRate).setScale(1, RoundingMode.HALF_UP); + assertEquals(new BigDecimal("0.0"), rate); + } + + @Test + @DisplayName("测试报警级别分布统计逻辑") + void testAlertLevelDistribution() { + // 模拟数据库返回的级别统计 + List> levelStats = new ArrayList<>(); + levelStats.add(Map.of("alert_level", "general", "count", 5L)); + levelStats.add(Map.of("alert_level", "important", "count", 2L)); + levelStats.add(Map.of("alert_level", "urgent", "count", 1L)); + + Map levelMap = new LinkedHashMap<>(); + levelMap.put("general", 0); + levelMap.put("important", 0); + levelMap.put("urgent", 0); + + for (Map row : levelStats) { + String level = (String) row.get("alert_level"); + int count = ((Number) row.get("count")).intValue(); + levelMap.put(level, count); + } + + assertEquals(5, levelMap.get("general")); + assertEquals(2, levelMap.get("important")); + assertEquals(1, levelMap.get("urgent")); + assertEquals(8, levelMap.values().stream().mapToInt(Integer::intValue).sum()); + + // 测试缺少某个级别时的默认值 + List> partialStats = new ArrayList<>(); + partialStats.add(Map.of("alert_level", "urgent", "count", 3L)); + + Map partialMap = new LinkedHashMap<>(); + partialMap.put("general", 0); + partialMap.put("important", 0); + partialMap.put("urgent", 0); + + for (Map row : partialStats) { + String level = (String) row.get("alert_level"); + int count = ((Number) row.get("count")).intValue(); + partialMap.put(level, count); + } + + assertEquals(0, partialMap.get("general")); + assertEquals(0, partialMap.get("important")); + assertEquals(3, partialMap.get("urgent")); + } + + @Test + @DisplayName("测试月度进出水量趋势数据结构") + void testMonthlyFlowTrendStructure() { + // 模拟月度趋势数据 + List> trend = new ArrayList<>(); + for (int i = 0; i < 6; i++) { + Map month = new LinkedHashMap<>(); + month.put("month", LocalDate.of(2026, i + 1, 1)); + month.put("total_inflow", new BigDecimal(String.valueOf(400000 + i * 10000))); + month.put("total_outflow", new BigDecimal(String.valueOf(380000 + i * 9500))); + trend.add(month); + } + + assertEquals(6, trend.size()); + assertEquals(LocalDate.of(2026, 1, 1), trend.get(0).get("month")); + assertEquals(new BigDecimal("400000"), trend.get(0).get("total_inflow")); + assertEquals(new BigDecimal("450000"), trend.get(5).get("total_inflow")); + + // 验证趋势递增 + for (int i = 1; i < trend.size(); i++) { + BigDecimal prev = (BigDecimal) trend.get(i - 1).get("total_inflow"); + BigDecimal curr = (BigDecimal) trend.get(i).get("total_inflow"); + assertTrue(curr.compareTo(prev) > 0); + } + } + + @Test + @DisplayName("测试能耗汇总按类型聚合逻辑") + void testEnergySummaryAggregation() { + // 模拟能耗数据 + List> items = new ArrayList<>(); + items.add(Map.of("energy_type", "power", "total", new BigDecimal("1250.50"), "unit", "kWh")); + items.add(Map.of("energy_type", "coagulant", "total", new BigDecimal("45.00"), "unit", "kg")); + items.add(Map.of("energy_type", "disinfectant", "total", new BigDecimal("12.50"), "unit", "kg")); + + BigDecimal totalPower = BigDecimal.ZERO; + BigDecimal totalChemical = BigDecimal.ZERO; + + for (Map item : items) { + String type = (String) item.get("energy_type"); + BigDecimal total = (BigDecimal) item.get("total"); + if ("power".equals(type)) { + totalPower = total; + } else { + totalChemical = totalChemical.add(total); + } + } + + assertEquals(new BigDecimal("1250.50"), totalPower); + assertEquals(new BigDecimal("57.50"), totalChemical); + + // 验证总能耗 + BigDecimal grandTotal = totalPower.add(totalChemical); + assertEquals(new BigDecimal("1308.00"), grandTotal); + } +}