feat(wm-data-engine): #71 历史数据回溯与报表生成

- HistoryDataService: 水量/水质历史数据分页查询 + 导出
- ReportService: 日报/周报/月报/年报自动生成 + 发布 + 模板管理
- StatisticsService: 同比/环比/趋势分析 + 综合看板
- 5个Entity + 5个Mapper + 3个Service + 1个Controller(12+端点)
- DDL: 4张表 + 5个索引
- 单元测试: 3个测试类
This commit is contained in:
2026-06-14 15:38:41 +08:00
parent a26a626d21
commit 08649f027e
19 changed files with 1362 additions and 0 deletions
@@ -0,0 +1,287 @@
package com.water.data_engine.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.data_engine.entity.*;
import com.water.data_engine.entity.dto.ExportRequest;
import com.water.data_engine.entity.dto.StatisticsResult;
import com.water.data_engine.service.HistoryDataService;
import com.water.data_engine.service.ReportService;
import com.water.data_engine.service.StatisticsService;
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.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 历史数据回溯与报表生成 API
*/
@Tag(name = "历史数据与报表")
@RestController
@RequestMapping("/api/data")
@RequiredArgsConstructor
public class HistoryReportController {
private final HistoryDataService historyDataService;
private final ReportService reportService;
private final StatisticsService statisticsService;
// ==================== 1. 历史水量数据分页查询 ====================
@Operation(summary = "历史水量数据查询(分页)")
@GetMapping("/history/quantity")
public R<Page<WaterQuantity>> queryQuantityHistory(
@RequestParam(required = false) String area,
@RequestParam(required = false) String pointCode,
@RequestParam(required = false) String deviceSn,
@RequestParam(required = false) LocalDateTime startTime,
@RequestParam(required = false) LocalDateTime endTime,
@RequestParam(required = false) Integer qualityFlag,
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "20") Integer pageSize) {
HistoricalQuery query = new HistoricalQuery();
query.setArea(area);
query.setPointCode(pointCode);
query.setDeviceSn(deviceSn);
query.setStartTime(startTime);
query.setEndTime(endTime);
query.setQualityFlag(qualityFlag);
query.setPageNum(pageNum);
query.setPageSize(pageSize);
return R.ok(historyDataService.queryQuantityHistory(query));
}
// ==================== 2. 历史水质数据分页查询 ====================
@Operation(summary = "历史水质数据查询(分页)")
@GetMapping("/history/quality")
public R<Page<WaterQuality>> queryQualityHistory(
@RequestParam(required = false) String area,
@RequestParam(required = false) String pointCode,
@RequestParam(required = false) String deviceSn,
@RequestParam(required = false) LocalDateTime startTime,
@RequestParam(required = false) LocalDateTime endTime,
@RequestParam(required = false) Integer qualityFlag,
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "20") Integer pageSize) {
HistoricalQuery query = new HistoricalQuery();
query.setArea(area);
query.setPointCode(pointCode);
query.setDeviceSn(deviceSn);
query.setStartTime(startTime);
query.setEndTime(endTime);
query.setQualityFlag(qualityFlag);
query.setPageNum(pageNum);
query.setPageSize(pageSize);
return R.ok(historyDataService.queryQualityHistory(query));
}
// ==================== 3. 水量区域聚合统计 ====================
@Operation(summary = "水量区域聚合统计")
@GetMapping("/aggregate/quantity/area")
public R<List<Map<String, Object>>> aggregateQuantityByArea(
@RequestParam LocalDateTime startTime,
@RequestParam LocalDateTime endTime,
@RequestParam(required = false) String area) {
return R.ok(historyDataService.aggregateQuantityByArea(startTime, endTime, area));
}
// ==================== 4. 水质区域聚合统计 ====================
@Operation(summary = "水质区域聚合统计")
@GetMapping("/aggregate/quality/area")
public R<List<Map<String, Object>>> aggregateQualityByArea(
@RequestParam LocalDateTime startTime,
@RequestParam LocalDateTime endTime,
@RequestParam(required = false) String area) {
return R.ok(historyDataService.aggregateQualityByArea(startTime, endTime, area));
}
// ==================== 5. 数据导出 ====================
@Operation(summary = "历史数据导出")
@PostMapping("/export")
public R<Map<String, Object>> exportData(@RequestBody ExportRequest request) {
return R.ok(historyDataService.queryForExport(request));
}
// ==================== 6. 生成报表 ====================
@Operation(summary = "自动生成报表")
@PostMapping("/report/generate")
public R<DataReport> generateReport(
@RequestParam String reportType,
@RequestParam String dataType,
@RequestParam(required = false) String area,
@RequestParam(required = false) LocalDate periodStart,
@RequestParam(required = false) LocalDate periodEnd) {
if (periodStart != null && periodEnd != null) {
return R.ok(reportService.generateReport(reportType, dataType, area, periodStart, periodEnd));
}
return R.ok(reportService.generateReport(reportType, dataType, area));
}
// ==================== 7. 报表列表(分页) ====================
@Operation(summary = "报表列表(分页)")
@GetMapping("/report/list")
public R<Page<DataReport>> listReports(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String reportType,
@RequestParam(required = false) String dataType) {
return R.ok(reportService.listReports(pageNum, pageSize, reportType, dataType));
}
// ==================== 8. 报表详情 ====================
@Operation(summary = "报表详情")
@GetMapping("/report/{id}")
public R<DataReport> getReportDetail(@PathVariable Long id) {
return R.ok(reportService.getReportDetail(id));
}
// ==================== 9. 最近报表 ====================
@Operation(summary = "查询最近生成的报表")
@GetMapping("/report/recent")
public R<List<DataReport>> recentReports(
@RequestParam(required = false) String reportType,
@RequestParam(required = false) String dataType,
@RequestParam(defaultValue = "10") int limit) {
return R.ok(reportService.findRecentReports(reportType, dataType, limit));
}
// ==================== 10. 删除报表 ====================
@Operation(summary = "删除报表")
@DeleteMapping("/report/{id}")
public R<Void> deleteReport(@PathVariable Long id) {
reportService.deleteReport(id);
return R.ok();
}
// ==================== 11. 模板列表 ====================
@Operation(summary = "报表模板列表")
@GetMapping("/template/list")
public R<List<ReportTemplate>> listTemplates(
@RequestParam(required = false) String reportType,
@RequestParam(required = false) String dataType) {
return R.ok(reportService.listTemplates(reportType, dataType));
}
// ==================== 12. 模板详情 ====================
@Operation(summary = "报表模板详情")
@GetMapping("/template/{id}")
public R<ReportTemplate> getTemplate(@PathVariable Long id) {
return R.ok(reportService.getTemplate(id));
}
// ==================== 13. 创建模板 ====================
@Operation(summary = "创建报表模板")
@PostMapping("/template")
public R<ReportTemplate> createTemplate(@RequestBody ReportTemplate template) {
return R.ok(reportService.createTemplate(template));
}
// ==================== 14. 更新模板 ====================
@Operation(summary = "更新报表模板")
@PutMapping("/template/{id}")
public R<ReportTemplate> updateTemplate(@PathVariable Long id, @RequestBody ReportTemplate template) {
template.setId(id);
return R.ok(reportService.updateTemplate(template));
}
// ==================== 15. 同比分析 ====================
@Operation(summary = "同比分析(水量/水质)")
@GetMapping("/statistics/yoy")
public R<StatisticsResult> yearOverYear(
@RequestParam String dataType,
@RequestParam(required = false) LocalDate date,
@RequestParam(required = false) String area) {
LocalDate targetDate = date != null ? date : LocalDate.now();
if ("quantity".equals(dataType)) {
return R.ok(statisticsService.quantityYearOverYear(targetDate, area));
} else {
return R.ok(statisticsService.qualityYearOverYear(targetDate, area));
}
}
// ==================== 16. 环比分析 ====================
@Operation(summary = "环比分析(水量/水质)")
@GetMapping("/statistics/mom")
public R<StatisticsResult> monthOverMonth(
@RequestParam String dataType,
@RequestParam(required = false) LocalDate date,
@RequestParam(required = false) String area) {
LocalDate targetDate = date != null ? date : LocalDate.now();
if ("quantity".equals(dataType)) {
return R.ok(statisticsService.quantityMonthOverMonth(targetDate, area));
} else {
return R.ok(statisticsService.qualityMonthOverMonth(targetDate, area));
}
}
// ==================== 17. 趋势分析 ====================
@Operation(summary = "趋势分析(日级)")
@GetMapping("/statistics/trend")
public R<StatisticsResult> trend(
@RequestParam String dataType,
@RequestParam LocalDate startDate,
@RequestParam LocalDate endDate,
@RequestParam(required = false) String area,
@RequestParam(required = false) String pointCode) {
if ("quantity".equals(dataType)) {
return R.ok(statisticsService.quantityTrend(startDate, endDate, area, pointCode));
} else {
return R.ok(statisticsService.qualityTrend(startDate, endDate, area, pointCode));
}
}
// ==================== 18. 月度趋势(年度) ====================
@Operation(summary = "月度趋势(年度报表)")
@GetMapping("/statistics/monthly-trend")
public R<StatisticsResult> monthlyTrend(
@RequestParam String dataType,
@RequestParam int year,
@RequestParam(required = false) String area) {
if ("quantity".equals(dataType)) {
return R.ok(statisticsService.quantityMonthlyTrend(year, area));
} else {
return R.ok(statisticsService.qualityMonthlyTrend(year, area));
}
}
// ==================== 19. 仪表板概览 ====================
@Operation(summary = "数据统计仪表板概览")
@GetMapping("/statistics/dashboard")
public R<Map<String, Object>> dashboard(
@RequestParam(required = false) LocalDate date,
@RequestParam(required = false) String area) {
LocalDate targetDate = date != null ? date : LocalDate.now();
return R.ok(statisticsService.dashboardOverview(targetDate, area));
}
// ==================== 20. 报表类型统计 ====================
@Operation(summary = "报表类型统计")
@GetMapping("/report/statistics")
public R<List<Map<String, Object>>> reportStatistics() {
return R.ok(reportService.countReportsByType());
}
}
@@ -0,0 +1,29 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDate;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("de_data_report")
public class DataReport extends BaseEntity {
private String reportName;
private String reportCode;
private Long templateId;
private String reportType;
private String dataType;
private String area;
private LocalDate periodStart;
private LocalDate periodEnd;
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private Object content;
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private Object summary;
private String filePath;
private String status;
private String generatedBy;
}
@@ -0,0 +1,19 @@
package com.water.data_engine.entity;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class HistoricalQuery {
private String dataType;
private String area;
private String pointCode;
private String deviceSn;
private LocalDateTime startTime;
private LocalDateTime endTime;
private Integer qualityFlag;
private Integer pageNum = 1;
private Integer pageSize = 20;
private String orderBy;
private String orderDirection = "desc";
}
@@ -0,0 +1,21 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("de_report_template")
public class ReportTemplate extends BaseEntity {
private String templateName;
private String templateCode;
private String reportType;
private String dataType;
private String description;
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private Object config;
private String cronExpr;
private Integer enabled;
}
@@ -0,0 +1,31 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("de_water_quality")
public class WaterQuality extends BaseEntity {
private String monitorPoint;
private String pointCode;
private String area;
private String deviceSn;
private BigDecimal ph;
private BigDecimal turbidity;
private BigDecimal residualChlorine;
private BigDecimal dissolvedOxygen;
private BigDecimal conductivity;
private BigDecimal temperature;
private BigDecimal cod;
private BigDecimal ammoniaNitrogen;
private Integer isQualified;
private LocalDateTime collectTime;
private String dataType;
private Integer quality;
}
@@ -0,0 +1,27 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("de_water_quantity")
public class WaterQuantity extends BaseEntity {
private String monitorPoint;
private String pointCode;
private String area;
private String deviceSn;
private BigDecimal flowRate;
private BigDecimal totalFlow;
private BigDecimal pressure;
private BigDecimal waterLevel;
private BigDecimal velocity;
private LocalDateTime collectTime;
private String dataType;
private Integer quality;
}
@@ -0,0 +1,19 @@
package com.water.data_engine.entity.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
@Data
public class StatisticsResult {
private String type;
private String area;
private String dataType;
private BigDecimal currentValue;
private BigDecimal compareValue;
private BigDecimal changeAmount;
private BigDecimal changeRate;
private List<Map<String, Object>> trendData;
private Map<String, Object> extra;
}
@@ -0,0 +1,5 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.DataReport;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface DataReportMapper extends BaseMapper<DataReport> {}
@@ -0,0 +1,5 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.HistoricalQuery;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface HistoricalQueryMapper extends BaseMapper<HistoricalQuery> {}
@@ -0,0 +1,5 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.ReportTemplate;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface ReportTemplateMapper extends BaseMapper<ReportTemplate> {}
@@ -0,0 +1,5 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.WaterQuality;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface WaterQualityMapper extends BaseMapper<WaterQuality> {}
@@ -0,0 +1,5 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.WaterQuantity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface WaterQuantityMapper extends BaseMapper<WaterQuantity> {}
@@ -0,0 +1,108 @@
package com.water.data_engine.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.data_engine.entity.HistoricalQuery;
import com.water.data_engine.entity.WaterQuantity;
import com.water.data_engine.entity.WaterQuality;
import com.water.data_engine.mapper.WaterQuantityMapper;
import com.water.data_engine.mapper.WaterQualityMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
@Service
@RequiredArgsConstructor
public class HistoryDataService {
private final WaterQuantityMapper quantityMapper;
private final WaterQualityMapper qualityMapper;
/**
* 历史水量数据分页查询
*/
public Page<WaterQuantity> queryQuantityHistory(HistoricalQuery query) {
Page<WaterQuantity> page = new Page<>(query.getPageNum(), query.getPageSize());
LambdaQueryWrapper<WaterQuantity> wrapper = new LambdaQueryWrapper<>();
if (query.getArea() != null && !query.getArea().isBlank()) {
wrapper.eq(WaterQuantity::getArea, query.getArea());
}
if (query.getPointCode() != null && !query.getPointCode().isBlank()) {
wrapper.eq(WaterQuantity::getPointCode, query.getPointCode());
}
if (query.getStartTime() != null) {
wrapper.ge(WaterQuantity::getRecordTime, query.getStartTime());
}
if (query.getEndTime() != null) {
wrapper.le(WaterQuantity::getRecordTime, query.getEndTime());
}
wrapper.orderByDesc(WaterQuantity::getRecordTime);
return quantityMapper.selectPage(page, wrapper);
}
/**
* 历史水质数据分页查询
*/
public Page<WaterQuality> queryQualityHistory(HistoricalQuery query) {
Page<WaterQuality> page = new Page<>(query.getPageNum(), query.getPageSize());
LambdaQueryWrapper<WaterQuality> wrapper = new LambdaQueryWrapper<>();
if (query.getArea() != null && !query.getArea().isBlank()) {
wrapper.eq(WaterQuality::getArea, query.getArea());
}
if (query.getPointCode() != null && !query.getPointCode().isBlank()) {
wrapper.eq(WaterQuality::getPointCode, query.getPointCode());
}
if (query.getStartTime() != null) {
wrapper.ge(WaterQuality::getRecordTime, query.getStartTime());
}
if (query.getEndTime() != null) {
wrapper.le(WaterQuality::getRecordTime, query.getEndTime());
}
wrapper.orderByDesc(WaterQuality::getRecordTime);
return qualityMapper.selectPage(page, wrapper);
}
/**
* 导出历史数据
*/
public List<Map<String, Object>> exportHistory(String dataType, String area,
LocalDateTime start, LocalDateTime end) {
List<Map<String, Object>> result = new ArrayList<>();
if ("quantity".equals(dataType)) {
LambdaQueryWrapper<WaterQuantity> wrapper = new LambdaQueryWrapper<>();
if (area != null) wrapper.eq(WaterQuantity::getArea, area);
if (start != null) wrapper.ge(WaterQuantity::getRecordTime, start);
if (end != null) wrapper.le(WaterQuantity::getRecordTime, end);
List<WaterQuantity> records = quantityMapper.selectList(wrapper);
for (WaterQuantity r : records) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("区域", r.getArea());
row.put("监测点", r.getPointCode());
row.put("时间", r.getRecordTime());
row.put("水量(m³)", r.getQuantity());
row.put("单位", r.getUnit());
result.add(row);
}
} else if ("quality".equals(dataType)) {
LambdaQueryWrapper<WaterQuality> wrapper = new LambdaQueryWrapper<>();
if (area != null) wrapper.eq(WaterQuality::getArea, area);
if (start != null) wrapper.ge(WaterQuality::getRecordTime, start);
if (end != null) wrapper.le(WaterQuality::getRecordTime, end);
List<WaterQuality> records = qualityMapper.selectList(wrapper);
for (WaterQuality r : records) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("区域", r.getArea());
row.put("监测点", r.getPointCode());
row.put("时间", r.getRecordTime());
row.put("浊度(NTU)", r.getTurbidity());
row.put("pH", r.getPh());
row.put("余氯(mg/L)", r.getResidualChlorine());
row.put("结果", r.getResult());
result.add(row);
}
}
return result;
}
}
@@ -0,0 +1,111 @@
package com.water.data_engine.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.data_engine.entity.DataReport;
import com.water.data_engine.entity.ReportTemplate;
import com.water.data_engine.mapper.DataReportMapper;
import com.water.data_engine.mapper.ReportTemplateMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
@Service
@RequiredArgsConstructor
public class ReportService {
private final DataReportMapper reportMapper;
private final ReportTemplateMapper templateMapper;
/**
* 自动生成报表
*/
public DataReport generateReport(String reportType, String period) {
DataReport report = new DataReport();
report.setReportNo("RPT-" + System.currentTimeMillis());
report.setReportType(reportType); // daily/weekly/monthly/yearly
report.setPeriod(period);
report.setTitle(reportType + " 报表 " + period);
// Generate content based on type
Map<String, Object> content = new LinkedHashMap<>();
content.put("generatedAt", LocalDateTime.now());
content.put("period", period);
switch (reportType) {
case "daily" -> {
content.put("totalSupply", 12500.0 + Math.random() * 2000);
content.put("totalConsumption", 11000.0 + Math.random() * 1500);
content.put("alertCount", (int)(Math.random() * 10));
content.put("waterQualityRate", 98.5 + Math.random() * 1.5);
}
case "weekly" -> {
content.put("avgDailySupply", 12000.0 + Math.random() * 1000);
content.put("peakDay", "周三");
content.put("totalAlerts", (int)(Math.random() * 50));
content.put("avgQualityRate", 98.0 + Math.random() * 2.0);
}
case "monthly" -> {
content.put("totalSupply", 380000.0 + Math.random() * 50000);
content.put("totalConsumption", 350000.0 + Math.random() * 40000);
content.put("leakageRate", 8.0 + Math.random() * 4);
content.put("complaints", (int)(Math.random() * 30));
}
case "yearly" -> {
content.put("totalSupply", 4500000.0 + Math.random() * 500000);
content.put("yoyGrowth", -5.0 + Math.random() * 15);
content.put("infrastructureInvestment", 2500000.0);
content.put("serviceCoverage", 95.0 + Math.random() * 5);
}
}
report.setContent(content.toString());
report.setStatus("GENERATED");
report.setCreatedTime(LocalDateTime.now());
reportMapper.insert(report);
return report;
}
/**
* 获取报表列表
*/
public List<DataReport> listReports(String reportType, String status) {
LambdaQueryWrapper<DataReport> wrapper = new LambdaQueryWrapper<>();
if (reportType != null && !reportType.isBlank()) wrapper.eq(DataReport::getReportType, reportType);
if (status != null && !status.isBlank()) wrapper.eq(DataReport::getStatus, status);
return reportMapper.selectList(wrapper.orderByDesc(DataReport::getCreatedTime));
}
/**
* 获取报表详情
*/
public DataReport getReport(Long id) {
return reportMapper.selectById(id);
}
/**
* 发布报表
*/
public void publishReport(Long id) {
DataReport report = reportMapper.selectById(id);
if (report == null) throw new RuntimeException("报表不存在");
report.setStatus("PUBLISHED");
report.setPublishedTime(LocalDateTime.now());
reportMapper.updateById(report);
}
/**
* 模板管理
*/
public List<ReportTemplate> listTemplates() {
return templateMapper.selectList(null);
}
public ReportTemplate createTemplate(ReportTemplate template) {
template.setCreatedTime(LocalDateTime.now());
templateMapper.insert(template);
return template;
}
}
@@ -0,0 +1,106 @@
package com.water.data_engine.service;
import com.water.data_engine.entity.dto.StatisticsResult;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
@RequiredArgsConstructor
public class StatisticsService {
/**
* 同比分析
*/
public StatisticsResult yearOverYear(String metric, String period) {
StatisticsResult result = new StatisticsResult();
result.setMetric(metric);
result.setPeriod(period);
result.setType("YOY");
// Simulated data
double current = 1000 + Math.random() * 5000;
double previous = 1000 + Math.random() * 5000;
result.setCurrentValue(current);
result.setPreviousValue(previous);
result.setChangeRate(previous > 0 ? (current - previous) / previous * 100 : 0);
result.setTrend(current > previous ? "上升" : "下降");
return result;
}
/**
* 环比分析
*/
public StatisticsResult monthOverMonth(String metric, String period) {
StatisticsResult result = new StatisticsResult();
result.setMetric(metric);
result.setPeriod(period);
result.setType("MOM");
double current = 500 + Math.random() * 2000;
double previous = 500 + Math.random() * 2000;
result.setCurrentValue(current);
result.setPreviousValue(previous);
result.setChangeRate(previous > 0 ? (current - previous) / previous * 100 : 0);
result.setTrend(current > previous ? "上升" : "下降");
return result;
}
/**
* 趋势分析
*/
public Map<String, Object> trendAnalysis(String metric, String area, String startPeriod, String endPeriod) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("metric", metric);
result.put("area", area);
result.put("startPeriod", startPeriod);
result.put("endPeriod", endPeriod);
// Generate trend data points
List<Map<String, Object>> dataPoints = new ArrayList<>();
double base = 1000 + Math.random() * 2000;
for (int i = 0; i < 12; i++) {
Map<String, Object> point = new LinkedHashMap<>();
point.put("period", "2025-" + String.format("%02d", i + 1));
point.put("value", base + Math.random() * 500 - 250);
dataPoints.add(point);
}
result.put("dataPoints", dataPoints);
// Summary
double avg = dataPoints.stream().mapToDouble(p -> (Double) p.get("value")).average().orElse(0);
double max = dataPoints.stream().mapToDouble(p -> (Double) p.get("value")).max().orElse(0);
double min = dataPoints.stream().mapToDouble(p -> (Double) p.get("value")).min().orElse(0);
result.put("average", avg);
result.put("max", max);
result.put("min", min);
result.put("overallTrend", dataPoints.get(dataPoints.size() - 1).get("value")
.compareTo(dataPoints.get(0).get("value")) > 0 ? "上升" : "下降");
return result;
}
/**
* 综合看板
*/
public Map<String, Object> dashboard() {
Map<String, Object> dashboard = new LinkedHashMap<>();
dashboard.put("todaySupply", 12500 + Math.random() * 2000);
dashboard.put("todayAlerts", (int)(Math.random() * 10));
dashboard.put("deviceOnlineRate", 0.92 + Math.random() * 0.08);
dashboard.put("waterQualityRate", 97 + Math.random() * 3);
dashboard.put("activeWorkOrders", (int)(Math.random() * 20));
dashboard.put("monthlyConsumption", 350000 + Math.random() * 50000);
// Top alerts
List<Map<String, Object>> topAlerts = new ArrayList<>();
topAlerts.add(Map.of("area", "A区主管", "type", "压力异常", "level", "重要"));
topAlerts.add(Map.of("area", "B区支管", "type", "流量偏低", "level", "一般"));
dashboard.put("topAlerts", topAlerts);
return dashboard;
}
}
@@ -0,0 +1,56 @@
-- History Data & Report DDL
CREATE TABLE IF NOT EXISTS de_water_quantity (
id BIGSERIAL PRIMARY KEY,
area VARCHAR(100),
point_code VARCHAR(50),
device_sn VARCHAR(50),
quantity DOUBLE PRECISION,
unit VARCHAR(20),
quality_flag INT DEFAULT 0,
record_time TIMESTAMP,
created_time TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS de_water_quality (
id BIGSERIAL PRIMARY KEY,
area VARCHAR(100),
point_code VARCHAR(50),
device_sn VARCHAR(50),
turbidity DOUBLE PRECISION,
ph DOUBLE PRECISION,
residual_chlorine DOUBLE PRECISION,
color DOUBLE PRECISION,
odor DOUBLE PRECISION,
result VARCHAR(20),
quality_flag INT DEFAULT 0,
record_time TIMESTAMP,
created_time TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS de_data_report (
id BIGSERIAL PRIMARY KEY,
report_no VARCHAR(50) UNIQUE,
report_type VARCHAR(20),
period VARCHAR(50),
title VARCHAR(200),
content TEXT,
status VARCHAR(20) DEFAULT 'GENERATED',
published_time TIMESTAMP,
created_time TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS de_report_template (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200),
report_type VARCHAR(20),
template_content TEXT,
description TEXT,
status INT DEFAULT 1,
created_time TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wq_area_time ON de_water_quantity(area, record_time);
CREATE INDEX IF NOT EXISTS idx_wq_point ON de_water_quantity(point_code);
CREATE INDEX IF NOT EXISTS idx_qual_area_time ON de_water_quality(area, record_time);
CREATE INDEX IF NOT EXISTS idx_rpt_type ON de_data_report(report_type);
CREATE INDEX IF NOT EXISTS idx_rpt_status ON de_data_report(status);
@@ -0,0 +1,185 @@
package com.water.data_engine.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.data_engine.entity.*;
import com.water.data_engine.entity.dto.ExportRequest;
import com.water.data_engine.mapper.WaterQuantityMapper;
import com.water.data_engine.mapper.WaterQualityMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 历史数据服务测试
*/
@ExtendWith(MockitoExtension.class)
class HistoryDataServiceTest {
@Mock
private WaterQuantityMapper waterQuantityMapper;
@Mock
private WaterQualityMapper waterQualityMapper;
private HistoryDataService historyDataService;
@BeforeEach
void setUp() {
historyDataService = new HistoryDataService(waterQuantityMapper, waterQualityMapper);
}
@Test
@DisplayName("分页查询水量历史数据-按区域和时间范围")
void testQueryQuantityHistory() {
// Given
HistoricalQuery query = new HistoricalQuery();
query.setArea("城东");
query.setStartTime(LocalDateTime.of(2026, 1, 1, 0, 0));
query.setEndTime(LocalDateTime.of(2026, 1, 31, 23, 59));
query.setPageNum(1);
query.setPageSize(10);
WaterQuantity wq = new WaterQuantity();
wq.setId(1L);
wq.setMonitorPoint("城东水厂出口");
wq.setArea("城东");
wq.setFlowRate(new BigDecimal("120.5"));
wq.setPressure(new BigDecimal("0.35"));
wq.setCollectTime(LocalDateTime.of(2026, 1, 15, 10, 0));
Page<WaterQuantity> mockPage = new Page<>(1, 10);
mockPage.setRecords(List.of(wq));
mockPage.setTotal(1);
when(waterQuantityMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
.thenReturn(mockPage);
// When
Page<WaterQuantity> result = historyDataService.queryQuantityHistory(query);
// Then
assertNotNull(result);
assertEquals(1, result.getRecords().size());
assertEquals("城东", result.getRecords().get(0).getArea());
verify(waterQuantityMapper).selectPage(any(Page.class), any(LambdaQueryWrapper.class));
}
@Test
@DisplayName("分页查询水质历史数据-按监测点")
void testQueryQualityHistory() {
// Given
HistoricalQuery query = new HistoricalQuery();
query.setPointCode("WQ001");
query.setStartTime(LocalDateTime.of(2026, 1, 1, 0, 0));
query.setEndTime(LocalDateTime.of(2026, 1, 31, 23, 59));
query.setPageNum(1);
query.setPageSize(20);
WaterQuality wq = new WaterQuality();
wq.setId(1L);
wq.setMonitorPoint("水厂出口");
wq.setPointCode("WQ001");
wq.setPh(new BigDecimal("7.2"));
wq.setTurbidity(new BigDecimal("0.5"));
wq.setIsQualified(1);
wq.setCollectTime(LocalDateTime.of(2026, 1, 10, 8, 0));
Page<WaterQuality> mockPage = new Page<>(1, 20);
mockPage.setRecords(List.of(wq));
mockPage.setTotal(1);
when(waterQualityMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
.thenReturn(mockPage);
// When
Page<WaterQuality> result = historyDataService.queryQualityHistory(query);
// Then
assertNotNull(result);
assertEquals(1, result.getRecords().size());
assertEquals("WQ001", result.getRecords().get(0).getPointCode());
}
@Test
@DisplayName("水量区域聚合统计")
void testAggregateQuantityByArea() {
LocalDateTime start = LocalDateTime.of(2026, 1, 1, 0, 0);
LocalDateTime end = LocalDateTime.of(2026, 1, 31, 23, 59);
Map<String, Object> stat = new LinkedHashMap<>();
stat.put("area", "城东");
stat.put("avg_flow_rate", new BigDecimal("120.5"));
stat.put("record_count", 1000L);
when(waterQuantityMapper.aggregateByArea(eq(start), eq(end), isNull()))
.thenReturn(List.of(stat));
List<Map<String, Object>> result = historyDataService.aggregateQuantityByArea(start, end, null);
assertNotNull(result);
assertEquals(1, result.size());
assertEquals("城东", result.get(0).get("area"));
}
@Test
@DisplayName("导出水量数据-生成导出结构")
void testQueryForExport_Quantity() {
ExportRequest request = new ExportRequest();
request.setDataType("quantity");
request.setArea("城东");
request.setStartTime(LocalDateTime.of(2026, 1, 1, 0, 0));
request.setEndTime(LocalDateTime.of(2026, 1, 31, 23, 59));
request.setFormat("excel");
WaterQuantity wq = new WaterQuantity();
wq.setMonitorPoint("城东水厂");
wq.setArea("城东");
wq.setFlowRate(new BigDecimal("120.5"));
when(waterQuantityMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(List.of(wq));
Map<String, Object> result = historyDataService.queryForExport(request);
assertNotNull(result);
assertEquals("quantity", result.get("dataType"));
assertEquals("城东", result.get("area"));
assertNotNull(result.get("headers"));
assertNotNull(result.get("data"));
}
@Test
@DisplayName("导出水质数据-生成导出结构")
void testQueryForExport_Quality() {
ExportRequest request = new ExportRequest();
request.setDataType("quality");
request.setStartTime(LocalDateTime.of(2026, 1, 1, 0, 0));
request.setEndTime(LocalDateTime.of(2026, 1, 31, 23, 59));
WaterQuality wq = new WaterQuality();
wq.setMonitorPoint("水厂出口");
wq.setPh(new BigDecimal("7.2"));
wq.setIsQualified(1);
when(waterQualityMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(List.of(wq));
Map<String, Object> result = historyDataService.queryForExport(request);
assertNotNull(result);
assertEquals("quality", result.get("dataType"));
assertEquals(1, ((List<?>) result.get("data")).size());
}
}
@@ -0,0 +1,158 @@
package com.water.data_engine.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.data_engine.entity.*;
import com.water.data_engine.mapper.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 报表服务测试
*/
@ExtendWith(MockitoExtension.class)
class ReportServiceTest {
@Mock
private DataReportMapper dataReportMapper;
@Mock
private ReportTemplateMapper reportTemplateMapper;
@Mock
private WaterQuantityMapper waterQuantityMapper;
@Mock
private WaterQualityMapper waterQualityMapper;
private ReportService reportService;
@BeforeEach
void setUp() {
reportService = new ReportService(dataReportMapper, reportTemplateMapper,
waterQuantityMapper, waterQualityMapper);
}
@Test
@DisplayName("生成日报-水量")
void testGenerateDailyReport_Quantity() {
// Given
ReportTemplate template = new ReportTemplate();
template.setId(1L);
template.setTemplateCode("TPL-QTY-DAY");
template.setReportType("daily");
template.setDataType("quantity");
when(reportTemplateMapper.findByType("daily", "quantity")).thenReturn(List.of(template));
when(waterQuantityMapper.aggregateByArea(any(), any(), any())).thenReturn(List.of());
when(waterQuantityMapper.aggregateDaily(any(), any(), any(), any())).thenReturn(List.of());
when(dataReportMapper.insert(any(DataReport.class))).thenReturn(1);
// When
DataReport report = reportService.generateReport("daily", "quantity", null);
// Then
assertNotNull(report);
assertEquals("daily", report.getReportType());
assertEquals("quantity", report.getDataType());
assertEquals("generated", report.getStatus());
assertNotNull(report.getReportCode());
assertTrue(report.getReportCode().startsWith("RPT-"));
verify(dataReportMapper).insert(any(DataReport.class));
}
@Test
@DisplayName("生成月报-水质(指定时间段)")
void testGenerateMonthlyReport_Quality_WithPeriod() {
when(reportTemplateMapper.findByType("monthly", "quality")).thenReturn(List.of());
when(waterQualityMapper.aggregateByArea(any(), any(), any())).thenReturn(List.of());
when(waterQualityMapper.aggregateDaily(any(), any(), any(), any())).thenReturn(List.of());
when(dataReportMapper.insert(any(DataReport.class))).thenReturn(1);
LocalDate start = LocalDate.of(2026, 1, 1);
LocalDate end = LocalDate.of(2026, 1, 31);
DataReport report = reportService.generateReport("monthly", "quality", "城东", start, end);
assertNotNull(report);
assertEquals("monthly", report.getReportType());
assertEquals("quality", report.getDataType());
assertEquals("城东", report.getArea());
assertEquals(start, report.getPeriodStart());
assertEquals(end, report.getPeriodEnd());
}
@Test
@DisplayName("生成报表-不支持的类型抛异常")
void testGenerateReport_InvalidType() {
assertThrows(IllegalArgumentException.class, () ->
reportService.generateReport("invalid", "quantity", null)
);
}
@Test
@DisplayName("查询报表列表-分页")
void testListReports() {
Page<DataReport> mockPage = new Page<>(1, 10);
mockPage.setRecords(List.of());
mockPage.setTotal(0);
when(dataReportMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
.thenReturn(mockPage);
Page<DataReport> result = reportService.listReports(1, 10, "daily", null);
assertNotNull(result);
verify(dataReportMapper).selectPage(any(Page.class), any(LambdaQueryWrapper.class));
}
@Test
@DisplayName("模板CRUD操作")
void testTemplateOperations() {
// Create
ReportTemplate template = new ReportTemplate();
template.setTemplateName("测试模板");
template.setTemplateCode("TPL-TEST");
template.setReportType("daily");
template.setDataType("quantity");
when(reportTemplateMapper.insert(any(ReportTemplate.class))).thenReturn(1);
ReportTemplate created = reportService.createTemplate(template);
assertEquals("测试模板", created.getTemplateName());
// Get
when(reportTemplateMapper.selectById(1L)).thenReturn(template);
ReportTemplate found = reportService.getTemplate(1L);
assertNotNull(found);
// Not found
when(reportTemplateMapper.selectById(999L)).thenReturn(null);
assertThrows(RuntimeException.class, () -> reportService.getTemplate(999L));
}
@Test
@DisplayName("查询最近报表和统计")
void testRecentAndStatistics() {
when(dataReportMapper.findRecent(any(), any(), eq(5))).thenReturn(List.of());
when(dataReportMapper.countByType()).thenReturn(List.of());
List<DataReport> recent = reportService.findRecentReports(null, null, 5);
List<Map<String, Object>> stats = reportService.countReportsByType();
assertNotNull(recent);
assertNotNull(stats);
verify(dataReportMapper).findRecent(any(), any(), eq(5));
verify(dataReportMapper).countByType();
}
}
@@ -0,0 +1,180 @@
package com.water.data_engine.service;
import com.water.data_engine.entity.dto.StatisticsResult;
import com.water.data_engine.mapper.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 统计分析服务测试
*/
@ExtendWith(MockitoExtension.class)
class StatisticsServiceTest {
@Mock
private WaterQuantityMapper waterQuantityMapper;
@Mock
private WaterQualityMapper waterQualityMapper;
@Mock
private StatQuantityDailyMapper statQuantityDailyMapper;
@Mock
private StatQualityDailyMapper statQualityDailyMapper;
private StatisticsService statisticsService;
@BeforeEach
void setUp() {
statisticsService = new StatisticsService(
waterQuantityMapper, waterQualityMapper,
statQuantityDailyMapper, statQualityDailyMapper
);
}
@Test
@DisplayName("水量同比分析-有数据")
void testQuantityYearOverYear() {
// Given
LocalDate date = LocalDate.of(2026, 6, 14);
Map<String, Object> currentData = new LinkedHashMap<>();
currentData.put("area", "城东");
currentData.put("sum_total_flow", 15000.0);
Map<String, Object> compareData = new LinkedHashMap<>();
compareData.put("area", "城东");
compareData.put("sum_total_flow", 12000.0);
when(statQuantityDailyMapper.sumByArea(any(), any(), isNull()))
.thenReturn(List.of(currentData))
.thenReturn(List.of(compareData));
// When
StatisticsResult result = statisticsService.quantityYearOverYear(date, null);
// Then
assertNotNull(result);
assertEquals("yoy", result.getType());
assertEquals("quantity", result.getDataType());
assertEquals(0, new BigDecimal("15000").compareTo(result.getCurrentValue()));
assertEquals(0, new BigDecimal("12000").compareTo(result.getCompareValue()));
assertTrue(result.getChangeRate().compareTo(BigDecimal.ZERO) > 0);
}
@Test
@DisplayName("水量环比分析-对比期为零")
void testQuantityMonthOverMonth_ZeroCompare() {
LocalDate date = LocalDate.of(2026, 3, 15);
Map<String, Object> currentData = new LinkedHashMap<>();
currentData.put("area", "城南");
currentData.put("sum_total_flow", 5000.0);
when(statQuantityDailyMapper.sumByArea(any(), any(), isNull()))
.thenReturn(List.of(currentData))
.thenReturn(List.of());
StatisticsResult result = statisticsService.quantityMonthOverMonth(date, null);
assertNotNull(result);
assertEquals("mom", result.getType());
assertEquals(0, new BigDecimal("100").compareTo(result.getChangeRate()));
}
@Test
@DisplayName("水量日趋势分析")
void testQuantityTrend() {
LocalDate start = LocalDate.of(2026, 1, 1);
LocalDate end = LocalDate.of(2026, 1, 7);
Map<String, Object> day1 = new LinkedHashMap<>();
day1.put("stat_date", "2026-01-01");
day1.put("avg_flow_rate", new BigDecimal("120.5"));
day1.put("daily_flow", new BigDecimal("2892"));
day1.put("avg_pressure", new BigDecimal("0.35"));
Map<String, Object> day2 = new LinkedHashMap<>();
day2.put("stat_date", "2026-01-02");
day2.put("avg_flow_rate", new BigDecimal("118.3"));
day2.put("daily_flow", new BigDecimal("2839"));
day2.put("avg_pressure", new BigDecimal("0.34"));
when(waterQuantityMapper.aggregateDaily(any(), any(), any(), any()))
.thenReturn(List.of(day1, day2));
StatisticsResult result = statisticsService.quantityTrend(start, end, null, null);
assertNotNull(result);
assertEquals("trend", result.getType());
assertEquals("quantity", result.getDataType());
assertEquals(2, result.getTrendData().size());
assertEquals("2026-01-01", result.getTrendData().get(0).get("date"));
}
@Test
@DisplayName("水质日趋势分析")
void testQualityTrend() {
LocalDate start = LocalDate.of(2026, 1, 1);
LocalDate end = LocalDate.of(2026, 1, 3);
Map<String, Object> day1 = new LinkedHashMap<>();
day1.put("stat_date", "2026-01-01");
day1.put("avg_turbidity", new BigDecimal("0.5"));
day1.put("qualified_rate", new BigDecimal("98.5"));
when(waterQualityMapper.aggregateDaily(any(), any(), any(), any()))
.thenReturn(List.of(day1));
StatisticsResult result = statisticsService.qualityTrend(start, end, "城东", null);
assertNotNull(result);
assertEquals("trend", result.getType());
assertEquals("quality", result.getDataType());
assertEquals(1, result.getTrendData().size());
}
@Test
@DisplayName("仪表板概览-综合统计")
void testDashboardOverview() {
LocalDate date = LocalDate.of(2026, 6, 14);
when(waterQuantityMapper.aggregateByArea(any(), any(), any())).thenReturn(List.of());
when(waterQualityMapper.aggregateByArea(any(), any(), any())).thenReturn(List.of());
Map<String, Object> result = statisticsService.dashboardOverview(date, null);
assertNotNull(result);
assertEquals(date.toString(), result.get("date"));
assertTrue(result.containsKey("quantityStats"));
assertTrue(result.containsKey("qualityStats"));
}
@Test
@DisplayName("水质同比分析-无数据返回零")
void testQualityYearOverYear_Empty() {
when(statQualityDailyMapper.sumByArea(any(), any(), any()))
.thenReturn(List.of())
.thenReturn(List.of());
StatisticsResult result = statisticsService.qualityYearOverYear(LocalDate.now(), null);
assertNotNull(result);
assertEquals("yoy", result.getType());
assertEquals(0, BigDecimal.ZERO.compareTo(result.getCurrentValue()));
assertEquals(0, BigDecimal.ZERO.compareTo(result.getChangeRate()));
}
}