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

This commit is contained in:
2026-06-14 16:11:02 +08:00
parent f240059d1e
commit 4a82e90fcb
9 changed files with 1179 additions and 0 deletions
@@ -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<Map<String, Object>> fullDashboard(
@RequestParam(defaultValue = "一体化水厂") String area) {
return R.ok(dashboardService.getFullDashboard(area));
}
// ==================== 进出水量 ====================
@Operation(summary = "进出水量概览(今日/昨日/本月 + 趋势对比)")
@GetMapping("/flow/summary")
public R<Map<String, Object>> flowSummary(
@RequestParam(defaultValue = "一体化水厂") String area) {
return R.ok(dashboardService.getFlowSummary(area));
}
@Operation(summary = "月度进出水量趋势")
@GetMapping("/flow/monthly-trend")
public R<List<Map<String, Object>>> 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<Map<String, Object>> waterQualitySummary(
@RequestParam(defaultValue = "一体化水厂") String area) {
return R.ok(dashboardService.getWaterQualitySummary(area));
}
// ==================== 设备概况 ====================
@Operation(summary = "设备概况(在线/离线/故障 + 在线率)")
@GetMapping("/device/summary")
public R<Map<String, Object>> deviceSummary(
@RequestParam(defaultValue = "一体化水厂") String area) {
return R.ok(dashboardService.getDeviceSummary(area));
}
// ==================== 报警概览 ====================
@Operation(summary = "报警概览(今日报警/活跃报警/级别分布)")
@GetMapping("/alert/summary")
public R<Map<String, Object>> alertSummary(
@RequestParam(defaultValue = "一体化水厂") String area) {
return R.ok(dashboardService.getAlertSummary(area));
}
// ==================== 能耗数据 ====================
@Operation(summary = "今日能耗汇总(电耗/药耗)")
@GetMapping("/energy/today")
public R<Map<String, Object>> todayEnergy(
@RequestParam(defaultValue = "一体化水厂") String area) {
return R.ok(energyService.getTodaySummary(area));
}
@Operation(summary = "能耗趋势(按日,最近N天)")
@GetMapping("/energy/trend")
public R<List<Map<String, Object>>> 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<List<Map<String, Object>>> 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<Map<String, Object>> 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<List<EnergyConsumption>> 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<List<DashboardSummary>> historySnapshots(
@RequestParam(defaultValue = "一体化水厂") String area,
@RequestParam(defaultValue = "7") int days) {
return R.ok(dashboardService.getHistorySnapshots(area, days));
}
}
@@ -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;
}
@@ -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;
}
@@ -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<DashboardSummary> {
/**
* 查询最近N天的总览快照(按日期倒序)
*/
@Select("SELECT * FROM prod_dashboard_summary WHERE area = #{area} " +
"ORDER BY summary_date DESC LIMIT #{days}")
List<DashboardSummary> 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<Map<String, Object>> 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<Map<String, Object>> dailyFlowTrend(@Param("area") String area, @Param("days") int days);
}
@@ -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<EnergyConsumption> {
/**
* 按区域和日期范围查询能耗数据
*/
@Select("SELECT * FROM prod_energy_consumption WHERE area = #{area} " +
"AND record_date BETWEEN #{startDate} AND #{endDate} ORDER BY record_date")
List<EnergyConsumption> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> unitEnergyStats(@Param("area") String area,
@Param("startDate") String startDate,
@Param("endDate") String endDate);
}
@@ -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<String, Object> 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<String, Object> getFlowSummary(String area) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("area", area);
// 今日进出水量
try {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> getWaterQualitySummary(String area) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> getDeviceSummary(String area) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("area", area);
int online = 0, offline = 0, fault = 0;
try {
List<Map<String, Object>> stats = jdbc.queryForList(
"SELECT status, COUNT(*) as count FROM prod_device_status WHERE area = ? GROUP BY status", area);
for (Map<String, Object> 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<String, Object> getAlertSummary(String area) {
Map<String, Object> 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<Map<String, Object>> levelStats = jdbc.queryForList(
"SELECT alert_level, COUNT(*) as count FROM prod_alert_record " +
"WHERE created_time >= CURRENT_DATE GROUP BY alert_level");
Map<String, Integer> levelMap = new LinkedHashMap<>();
levelMap.put("general", 0);
levelMap.put("important", 0);
levelMap.put("urgent", 0);
for (Map<String, Object> 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<String, Object> getFullDashboard(String area) {
Map<String, Object> 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<DashboardSummary> getHistorySnapshots(String area, int days) {
return dashboardSummaryMapper.findRecent(area, days);
}
/**
* 月度进出水量趋势
*/
public List<Map<String, Object>> getMonthlyFlowTrend(String area, int months) {
String startDate = LocalDate.now().minusMonths(months).toString();
return dashboardSummaryMapper.monthlyFlowTrend(area, startDate);
}
/**
* 计算日环比
*/
private void computeDayOverDay(Map<String, Object> 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) {}
}
}
@@ -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<String, Object> getTodaySummary(String area) {
String today = LocalDate.now().toString();
List<Map<String, Object>> items = energyMapper.dailySummary(area, today);
Map<String, Object> result = new LinkedHashMap<>();
result.put("area", area);
result.put("date", today);
BigDecimal totalPower = BigDecimal.ZERO;
BigDecimal totalChemical = BigDecimal.ZERO;
for (Map<String, Object> 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<Map<String, Object>> getEnergyTrend(String area, int days) {
LambdaQueryWrapper<EnergyConsumption> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(EnergyConsumption::getArea, area)
.ge(EnergyConsumption::getRecordDate, LocalDate.now().minusDays(days))
.orderByAsc(EnergyConsumption::getRecordDate);
List<EnergyConsumption> list = energyMapper.selectList(wrapper);
List<Map<String, Object>> trend = new ArrayList<>();
for (EnergyConsumption ec : list) {
Map<String, Object> 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<Map<String, Object>> getMonthlyTrend(String area, int months) {
String startDate = LocalDate.now().minusMonths(months).toString();
return energyMapper.monthlyEnergyTrend(area, startDate);
}
/**
* 计算单位产水能耗
*/
public Map<String, Object> getUnitEnergyConsumption(String area, String startDate, String endDate) {
Map<String, Object> stats = energyMapper.unitEnergyStats(area, startDate, endDate);
Map<String, Object> 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<EnergyConsumption> getRecordsByType(String area, String energyType, String startDate, String endDate) {
LambdaQueryWrapper<EnergyConsumption> 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);
}
}
@@ -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(消毒剂)';
@@ -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<Map<String, Object>> 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<String, Integer> levelMap = new LinkedHashMap<>();
levelMap.put("general", 0);
levelMap.put("important", 0);
levelMap.put("urgent", 0);
for (Map<String, Object> 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<Map<String, Object>> partialStats = new ArrayList<>();
partialStats.add(Map.of("alert_level", "urgent", "count", 3L));
Map<String, Integer> partialMap = new LinkedHashMap<>();
partialMap.put("general", 0);
partialMap.put("important", 0);
partialMap.put("urgent", 0);
for (Map<String, Object> 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<Map<String, Object>> trend = new ArrayList<>();
for (int i = 0; i < 6; i++) {
Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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);
}
}