9 天以前 06ed168bbe9bd100c460ae21a499dbaee04e5abb
feat(lims): 新增数据分析与展示功能

- 修改应用配置文件,将激活环境从lims改为dev
- 新增开发环境配置文件application-dev.yml,包含数据库、redis、minio等完整配置
- 新增LIMS数据分析前端联调文档,详细说明接口规范和返回结构
- 创建多个DTO类用于数据分析传输对象定义
- 实现LIMS数据分析控制器,提供dashboard、overview、trend等5个核心接口
- 开发LIMS数据分析服务接口及实现类,包含完整的数据统计和分析逻辑
- 实现数据趋势、比较、质量分布等多维度分析功能
- 添加查询参数标准化和数据校验功能
- 支持按时间、设备、数据类型等多维度筛选分析
- 提供完整的数据概览指标统计功能
已添加11个文件
1016 ■■■■■ 文件已修改
doc/lims-data-analysis-front-integration.md 150 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/lims/controller/LimsDataAnalysisController.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/lims/dto/LimsAnalysisOverviewDto.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/lims/dto/LimsComparisonItemDto.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/lims/dto/LimsDataAnalysisDashboardDto.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/lims/dto/LimsDataAnalysisQueryDto.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/lims/dto/LimsQualityDistributionItemDto.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/lims/dto/LimsTrendPointDto.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/lims/service/LimsDataAnalysisService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/lims/service/impl/LimsDataAnalysisServiceImpl.java 387 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application-dev.yml 222 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/lims-data-analysis-front-integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,150 @@
# LIMS æ•°æ®åˆ†æžä¸Žå±•示 - å‰ç«¯è”调文档
## 1. æ¨¡å—说明
本次在 `lims` æ¨¡å—新增了“数据分析与展示”能力,覆盖:
- æ•°æ®è¶‹åŠ¿åˆ†æžï¼ˆæŒ‰å¤©/按小时)
- æ•°æ®æ¯”较分析(按数据类型/设备)
- æ•°æ®è´¨é‡åˆ†å¸ƒç»Ÿè®¡
- æ¦‚览指标看板(总量、异常、合格率、预警等)
接口统一前缀:`/lims/dataAnalysis`
---
## 2. é‰´æƒä¸Žé€šç”¨çº¦å®š
1. é‰´æƒæ–¹å¼ï¼šæ²¿ç”¨ç³»ç»ŸçŽ°æœ‰ Token(`Authorization` è¯·æ±‚头)。
2. è¿”回格式:`AjaxResult`
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {}
}
```
3. æ—¶é—´å‚数格式:`yyyy-MM-dd HH:mm:ss`
4. é»˜è®¤æ—¶é—´èŒƒå›´ï¼šè‹¥ `startTime/endTime` éƒ½ä¸ä¼ ï¼Œåˆ™é»˜è®¤æœ€è¿‘ 7 å¤©ã€‚
---
## 3. æŸ¥è¯¢å‚数(通用)
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| startTime | string | å¦ | å¼€å§‹æ—¶é—´ï¼Œæ ¼å¼ï¼š`yyyy-MM-dd HH:mm:ss` |
| endTime | string | å¦ | ç»“束时间,格式:`yyyy-MM-dd HH:mm:ss` |
| dataType | string | å¦ | æ•°æ®ç±»åž‹ï¼š`temperature/humidity/pressure/flow/concentration` |
| deviceCode | string | å¦ | è®¾å¤‡ç¼–号 |
| granularity | string | å¦ | è¶‹åŠ¿ç²’åº¦ï¼š`day/hour`,默认 `day` |
| dimension | string | å¦ | æ¯”较维度:`dataType/deviceName/deviceCode`,默认 `dataType` |
| topN | number | å¦ | æ¯”较分析返回条数,默认 `10`,最大 `50` |
---
## 4. æŽ¥å£æ¸…单
| æŽ¥å£ | æ–¹æ³• | è¯´æ˜Ž |
|---|---|---|
| `/lims/dataAnalysis/dashboard` | GET | ä¸€æ¬¡è¿”回看板全部数据(推荐) |
| `/lims/dataAnalysis/overview` | GET | ä»…返回概览指标 |
| `/lims/dataAnalysis/trend` | GET | ä»…返回趋势数据 |
| `/lims/dataAnalysis/comparison` | GET | ä»…返回比较分析数据 |
| `/lims/dataAnalysis/qualityDistribution` | GET | ä»…返回质量分布数据 |
---
## 5. è¯¦ç»†è¿”回结构
### 5.1 `GET /lims/dataAnalysis/dashboard`
`data` ç»“构:
```json
{
  "startTime": "2026-05-16 00:00:00",
  "endTime": "2026-05-22 23:59:59",
  "granularity": "day",
  "dimension": "dataType",
  "overview": {
    "totalCollections": 1200,
    "todayCollections": 210,
    "abnormalCollections": 38,
    "qualifiedRate": 94.17,
    "inProgressExperiments": 6,
    "warningMonitors": 3,
    "inStockSamples": 168
  },
  "trend": [
    {
      "time": "2026-05-16",
      "pointCount": 145,
      "avgValue": 23.65,
      "maxValue": 29.40,
      "minValue": 20.10
    }
  ],
  "comparison": [
    {
      "dimensionValue": "temperature",
      "pointCount": 560,
      "avgValue": 24.31,
      "maxValue": 33.20,
      "minValue": 18.90
    }
  ],
  "qualityDistribution": [
    {
      "category": "qualified",
      "pointCount": 1130,
      "ratio": 94.17
    },
    {
      "category": "abnormal",
      "pointCount": 38,
      "ratio": 3.17
    },
    {
      "category": "pending",
      "pointCount": 32,
      "ratio": 2.67
    }
  ]
}
```
字段解释:
- `overview.totalCollections`:时间范围内采集总条数
- `overview.todayCollections`:最近 24 å°æ—¶é‡‡é›†æ¡æ•°
- `overview.qualifiedRate`:合格率(百分比)
- `trend`:折线图数据源
- `comparison`:柱状图/横向条形图数据源
- `qualityDistribution`:饼图数据源
---
## 6. å‰ç«¯è°ƒç”¨å»ºè®®
1. é¡µé¢åˆå§‹åŒ–优先调用 `dashboard`,减少请求次数。
2. ç”¨æˆ·åˆ‡æ¢ç­›é€‰æ¡ä»¶ï¼ˆæ—¶é—´ã€è®¾å¤‡ã€æ•°æ®ç±»åž‹ï¼‰åŽï¼Œé‡æ–°è¯·æ±‚ `dashboard`。
3. è‹¥é¡µé¢åˆ†åŒºåŠ è½½ï¼Œå¯åˆ†åˆ«è°ƒ `overview/trend/comparison/qualityDistribution`。
4. `trend` å·²è¡¥é½æ—¶é—´è½´ç©ºæ¡£ä½ï¼ˆæ— æ•°æ®è¿”回 0),可直接绘制连续折线。
---
## 7. è°ƒç”¨ç¤ºä¾‹
```bash
curl -G 'http://localhost:8080/lims/dataAnalysis/dashboard' \
  --data-urlencode 'startTime=2026-05-16 00:00:00' \
  --data-urlencode 'endTime=2026-05-22 23:59:59' \
  --data-urlencode 'granularity=day' \
  --data-urlencode 'dimension=dataType' \
  --data-urlencode 'topN=10' \
  -H 'Authorization: Bearer <token>'
```
src/main/java/com/ruoyi/lims/controller/LimsDataAnalysisController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package com.ruoyi.lims.controller;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.lims.dto.LimsDataAnalysisQueryDto;
import com.ruoyi.lims.service.LimsDataAnalysisService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@AllArgsConstructor
@RequestMapping("/lims/dataAnalysis")
@Api(value = "LimsDataAnalysis", tags = "数据分析与展示")
public class LimsDataAnalysisController {
    private final LimsDataAnalysisService limsDataAnalysisService;
    @GetMapping("/dashboard")
    @ApiOperation("数据分析看板")
    public AjaxResult dashboard(LimsDataAnalysisQueryDto queryDto) {
        return AjaxResult.success(limsDataAnalysisService.dashboard(queryDto));
    }
    @GetMapping("/overview")
    @ApiOperation("数据分析概览")
    public AjaxResult overview(LimsDataAnalysisQueryDto queryDto) {
        return AjaxResult.success(limsDataAnalysisService.overview(queryDto));
    }
    @GetMapping("/trend")
    @ApiOperation("数据趋势分析")
    public AjaxResult trend(LimsDataAnalysisQueryDto queryDto) {
        return AjaxResult.success(limsDataAnalysisService.trend(queryDto));
    }
    @GetMapping("/comparison")
    @ApiOperation("数据比较分析")
    public AjaxResult comparison(LimsDataAnalysisQueryDto queryDto) {
        return AjaxResult.success(limsDataAnalysisService.comparison(queryDto));
    }
    @GetMapping("/qualityDistribution")
    @ApiOperation("数据质量分布")
    public AjaxResult qualityDistribution(LimsDataAnalysisQueryDto queryDto) {
        return AjaxResult.success(limsDataAnalysisService.qualityDistribution(queryDto));
    }
}
src/main/java/com/ruoyi/lims/dto/LimsAnalysisOverviewDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package com.ruoyi.lims.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
@Data
@ApiModel(description = "LIMS数据分析概览")
public class LimsAnalysisOverviewDto {
    @ApiModelProperty(value = "采集总条数")
    private Long totalCollections;
    @ApiModelProperty(value = "最近24小时采集条数")
    private Long todayCollections;
    @ApiModelProperty(value = "异常数据条数")
    private Long abnormalCollections;
    @ApiModelProperty(value = "数据合格率(%)")
    private BigDecimal qualifiedRate;
    @ApiModelProperty(value = "进行中实验数")
    private Long inProgressExperiments;
    @ApiModelProperty(value = "预警监控数")
    private Long warningMonitors;
    @ApiModelProperty(value = "在库样品数")
    private Long inStockSamples;
}
src/main/java/com/ruoyi/lims/dto/LimsComparisonItemDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package com.ruoyi.lims.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
@Data
@ApiModel(description = "LIMS比较分析项")
public class LimsComparisonItemDto {
    @ApiModelProperty(value = "维度值")
    private String dimensionValue;
    @ApiModelProperty(value = "数据点数量")
    private Long pointCount;
    @ApiModelProperty(value = "平均值")
    private BigDecimal avgValue;
    @ApiModelProperty(value = "最大值")
    private BigDecimal maxValue;
    @ApiModelProperty(value = "最小值")
    private BigDecimal minValue;
}
src/main/java/com/ruoyi/lims/dto/LimsDataAnalysisDashboardDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package com.ruoyi.lims.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
@ApiModel(description = "LIMS数据分析看板")
public class LimsDataAnalysisDashboardDto {
    @ApiModelProperty(value = "分析开始时间")
    private LocalDateTime startTime;
    @ApiModelProperty(value = "分析结束时间")
    private LocalDateTime endTime;
    @ApiModelProperty(value = "趋势粒度")
    private String granularity;
    @ApiModelProperty(value = "比较维度")
    private String dimension;
    @ApiModelProperty(value = "概览数据")
    private LimsAnalysisOverviewDto overview;
    @ApiModelProperty(value = "趋势数据")
    private List<LimsTrendPointDto> trend;
    @ApiModelProperty(value = "比较数据")
    private List<LimsComparisonItemDto> comparison;
    @ApiModelProperty(value = "质量分布数据")
    private List<LimsQualityDistributionItemDto> qualityDistribution;
}
src/main/java/com/ruoyi/lims/dto/LimsDataAnalysisQueryDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package com.ruoyi.lims.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
@Data
@ApiModel(description = "LIMS数据分析查询参数")
public class LimsDataAnalysisQueryDto {
    @ApiModelProperty(value = "开始时间,格式:yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime startTime;
    @ApiModelProperty(value = "结束时间,格式:yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime endTime;
    @ApiModelProperty(value = "数据类型:temperature/humidity/pressure/flow/concentration")
    private String dataType;
    @ApiModelProperty(value = "设备编号")
    private String deviceCode;
    @ApiModelProperty(value = "趋势粒度:day/hour,默认day")
    private String granularity;
    @ApiModelProperty(value = "比较维度:dataType/deviceName/deviceCode,默认dataType")
    private String dimension;
    @ApiModelProperty(value = "比较分析返回条数,默认10,最大50")
    private Integer topN;
}
src/main/java/com/ruoyi/lims/dto/LimsQualityDistributionItemDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package com.ruoyi.lims.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
@Data
@ApiModel(description = "LIMS质量分布项")
public class LimsQualityDistributionItemDto {
    @ApiModelProperty(value = "数据质量分类")
    private String category;
    @ApiModelProperty(value = "数量")
    private Long pointCount;
    @ApiModelProperty(value = "占比(%)")
    private BigDecimal ratio;
}
src/main/java/com/ruoyi/lims/dto/LimsTrendPointDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package com.ruoyi.lims.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
@Data
@ApiModel(description = "LIMS趋势分析点")
public class LimsTrendPointDto {
    @ApiModelProperty(value = "时间维度值")
    private String time;
    @ApiModelProperty(value = "数据点数量")
    private Long pointCount;
    @ApiModelProperty(value = "平均值")
    private BigDecimal avgValue;
    @ApiModelProperty(value = "最大值")
    private BigDecimal maxValue;
    @ApiModelProperty(value = "最小值")
    private BigDecimal minValue;
}
src/main/java/com/ruoyi/lims/service/LimsDataAnalysisService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package com.ruoyi.lims.service;
import com.ruoyi.lims.dto.*;
import java.util.List;
public interface LimsDataAnalysisService {
    LimsDataAnalysisDashboardDto dashboard(LimsDataAnalysisQueryDto queryDto);
    LimsAnalysisOverviewDto overview(LimsDataAnalysisQueryDto queryDto);
    List<LimsTrendPointDto> trend(LimsDataAnalysisQueryDto queryDto);
    List<LimsComparisonItemDto> comparison(LimsDataAnalysisQueryDto queryDto);
    List<LimsQualityDistributionItemDto> qualityDistribution(LimsDataAnalysisQueryDto queryDto);
}
src/main/java/com/ruoyi/lims/service/impl/LimsDataAnalysisServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,387 @@
package com.ruoyi.lims.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.ruoyi.lims.dto.*;
import com.ruoyi.lims.mapper.DataCollectionMapper;
import com.ruoyi.lims.mapper.ExperimentMapper;
import com.ruoyi.lims.mapper.RealtimeMonitorMapper;
import com.ruoyi.lims.mapper.SampleMapper;
import com.ruoyi.lims.pojo.DataCollection;
import com.ruoyi.lims.pojo.Experiment;
import com.ruoyi.lims.pojo.RealtimeMonitor;
import com.ruoyi.lims.pojo.Sample;
import com.ruoyi.lims.service.LimsDataAnalysisService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(rollbackFor = Exception.class)
public class LimsDataAnalysisServiceImpl implements LimsDataAnalysisService {
    private static final DateTimeFormatter DAY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    private static final DateTimeFormatter HOUR_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00");
    private final DataCollectionMapper dataCollectionMapper;
    private final ExperimentMapper experimentMapper;
    private final RealtimeMonitorMapper realtimeMonitorMapper;
    private final SampleMapper sampleMapper;
    @Override
    public LimsDataAnalysisDashboardDto dashboard(LimsDataAnalysisQueryDto queryDto) {
        LimsDataAnalysisQueryDto query = normalizeQuery(queryDto);
        LimsDataAnalysisDashboardDto dashboardDto = new LimsDataAnalysisDashboardDto();
        dashboardDto.setStartTime(query.getStartTime());
        dashboardDto.setEndTime(query.getEndTime());
        dashboardDto.setGranularity(query.getGranularity());
        dashboardDto.setDimension(query.getDimension());
        dashboardDto.setOverview(buildOverview(query));
        dashboardDto.setTrend(buildTrend(query));
        dashboardDto.setComparison(buildComparison(query));
        dashboardDto.setQualityDistribution(buildQualityDistribution(query));
        return dashboardDto;
    }
    @Override
    public LimsAnalysisOverviewDto overview(LimsDataAnalysisQueryDto queryDto) {
        return buildOverview(normalizeQuery(queryDto));
    }
    @Override
    public List<LimsTrendPointDto> trend(LimsDataAnalysisQueryDto queryDto) {
        return buildTrend(normalizeQuery(queryDto));
    }
    @Override
    public List<LimsComparisonItemDto> comparison(LimsDataAnalysisQueryDto queryDto) {
        return buildComparison(normalizeQuery(queryDto));
    }
    @Override
    public List<LimsQualityDistributionItemDto> qualityDistribution(LimsDataAnalysisQueryDto queryDto) {
        return buildQualityDistribution(normalizeQuery(queryDto));
    }
    private LimsAnalysisOverviewDto buildOverview(LimsDataAnalysisQueryDto query) {
        QueryWrapper<DataCollection> totalWrapper = createDataCollectionFilter(query);
        Long totalCollections = dataCollectionMapper.selectCount(totalWrapper);
        QueryWrapper<DataCollection> abnormalWrapper = createDataCollectionFilter(query);
        abnormalWrapper.eq("data_quality", "abnormal");
        Long abnormalCollections = dataCollectionMapper.selectCount(abnormalWrapper);
        QueryWrapper<DataCollection> qualifiedWrapper = createDataCollectionFilter(query);
        qualifiedWrapper.eq("data_quality", "qualified");
        Long qualifiedCollections = dataCollectionMapper.selectCount(qualifiedWrapper);
        QueryWrapper<DataCollection> todayWrapper = new QueryWrapper<>();
        todayWrapper.eq("del_flag", "0")
                .ge("collection_time", LocalDateTime.now().minusHours(24))
                .le("collection_time", LocalDateTime.now());
        Long todayCollections = dataCollectionMapper.selectCount(todayWrapper);
        Long inProgressExperiments = experimentMapper.selectCount(new QueryWrapper<Experiment>()
                .eq("del_flag", "0")
                .eq("experiment_status", "inProgress"));
        Long warningMonitors = realtimeMonitorMapper.selectCount(new QueryWrapper<RealtimeMonitor>()
                .eq("del_flag", "0")
                .in("alert_status", Arrays.asList("warning", "alert")));
        Long inStockSamples = sampleMapper.selectCount(new QueryWrapper<Sample>()
                .eq("del_flag", "0")
                .eq("sample_status", "inStock"));
        LimsAnalysisOverviewDto dto = new LimsAnalysisOverviewDto();
        dto.setTotalCollections(defaultLong(totalCollections));
        dto.setTodayCollections(defaultLong(todayCollections));
        dto.setAbnormalCollections(defaultLong(abnormalCollections));
        dto.setInProgressExperiments(defaultLong(inProgressExperiments));
        dto.setWarningMonitors(defaultLong(warningMonitors));
        dto.setInStockSamples(defaultLong(inStockSamples));
        dto.setQualifiedRate(calculateRate(qualifiedCollections, totalCollections));
        return dto;
    }
    private List<LimsTrendPointDto> buildTrend(LimsDataAnalysisQueryDto query) {
        String groupExpression = resolveTimeGroupExpression(query.getGranularity());
        QueryWrapper<DataCollection> wrapper = createDataCollectionFilter(query);
        wrapper.select(
                        groupExpression + " AS time_key",
                        "COUNT(1) AS point_count",
                        "AVG(collection_value) AS avg_value",
                        "MAX(collection_value) AS max_value",
                        "MIN(collection_value) AS min_value"
                )
                .groupBy(groupExpression)
                .orderByAsc("time_key");
        List<Map<String, Object>> rawRows = dataCollectionMapper.selectMaps(wrapper);
        Map<String, LimsTrendPointDto> trendMap = rawRows.stream().map(this::mapToTrendPoint)
                .collect(Collectors.toMap(LimsTrendPointDto::getTime, item -> item, (a, b) -> b));
        List<String> axis = buildTimeAxis(query);
        List<LimsTrendPointDto> result = new ArrayList<>();
        for (String key : axis) {
            LimsTrendPointDto point = trendMap.get(key);
            if (point == null) {
                point = new LimsTrendPointDto();
                point.setTime(key);
                point.setPointCount(0L);
                point.setAvgValue(BigDecimal.ZERO);
                point.setMaxValue(BigDecimal.ZERO);
                point.setMinValue(BigDecimal.ZERO);
            }
            result.add(point);
        }
        return result;
    }
    private List<LimsComparisonItemDto> buildComparison(LimsDataAnalysisQueryDto query) {
        String dimensionColumn = resolveDimensionColumn(query.getDimension());
        QueryWrapper<DataCollection> wrapper = createDataCollectionFilter(query);
        wrapper.isNotNull(dimensionColumn)
                .ne(dimensionColumn, "")
                .select(
                        dimensionColumn + " AS dimension_value",
                        "COUNT(1) AS point_count",
                        "AVG(collection_value) AS avg_value",
                        "MAX(collection_value) AS max_value",
                        "MIN(collection_value) AS min_value"
                )
                .groupBy(dimensionColumn);
        List<LimsComparisonItemDto> result = dataCollectionMapper.selectMaps(wrapper).stream()
                .map(this::mapToComparisonItem)
                .sorted(Comparator.comparing(LimsComparisonItemDto::getPointCount, Comparator.nullsLast(Long::compareTo)).reversed())
                .collect(Collectors.toList());
        int limit = query.getTopN() == null ? 10 : query.getTopN();
        if (result.size() > limit) {
            return result.subList(0, limit);
        }
        return result;
    }
    private List<LimsQualityDistributionItemDto> buildQualityDistribution(LimsDataAnalysisQueryDto query) {
        QueryWrapper<DataCollection> wrapper = createDataCollectionFilter(query);
        wrapper.isNotNull("data_quality")
                .ne("data_quality", "")
                .select("data_quality AS category", "COUNT(1) AS point_count")
                .groupBy("data_quality");
        List<LimsQualityDistributionItemDto> result = dataCollectionMapper.selectMaps(wrapper).stream()
                .map(this::mapToQualityDistributionItem)
                .sorted(Comparator.comparing(LimsQualityDistributionItemDto::getPointCount, Comparator.nullsLast(Long::compareTo)).reversed())
                .collect(Collectors.toList());
        long total = result.stream().map(LimsQualityDistributionItemDto::getPointCount).filter(Objects::nonNull).mapToLong(Long::longValue).sum();
        for (LimsQualityDistributionItemDto item : result) {
            item.setRatio(calculateRate(item.getPointCount(), total));
        }
        return result;
    }
    private QueryWrapper<DataCollection> createDataCollectionFilter(LimsDataAnalysisQueryDto query) {
        QueryWrapper<DataCollection> wrapper = new QueryWrapper<>();
        wrapper.eq("del_flag", "0")
                .ge("collection_time", query.getStartTime())
                .le("collection_time", query.getEndTime());
        if (StringUtils.hasText(query.getDataType())) {
            wrapper.eq("data_type", query.getDataType().trim());
        }
        if (StringUtils.hasText(query.getDeviceCode())) {
            wrapper.eq("device_code", query.getDeviceCode().trim());
        }
        return wrapper;
    }
    private String resolveTimeGroupExpression(String granularity) {
        if ("hour".equalsIgnoreCase(granularity)) {
            return "DATE_FORMAT(collection_time, '%Y-%m-%d %H:00:00')";
        }
        return "DATE_FORMAT(collection_time, '%Y-%m-%d')";
    }
    private String resolveDimensionColumn(String dimension) {
        if ("deviceName".equalsIgnoreCase(dimension)) {
            return "device_name";
        }
        if ("deviceCode".equalsIgnoreCase(dimension)) {
            return "device_code";
        }
        return "data_type";
    }
    private List<String> buildTimeAxis(LimsDataAnalysisQueryDto query) {
        List<String> axis = new ArrayList<>();
        if ("hour".equalsIgnoreCase(query.getGranularity())) {
            LocalDateTime cursor = query.getStartTime().withMinute(0).withSecond(0).withNano(0);
            LocalDateTime end = query.getEndTime().withMinute(0).withSecond(0).withNano(0);
            while (!cursor.isAfter(end)) {
                axis.add(cursor.format(HOUR_FORMATTER));
                cursor = cursor.plusHours(1);
            }
            return axis;
        }
        LocalDate cursor = query.getStartTime().toLocalDate();
        LocalDate end = query.getEndTime().toLocalDate();
        while (!cursor.isAfter(end)) {
            axis.add(cursor.format(DAY_FORMATTER));
            cursor = cursor.plusDays(1);
        }
        return axis;
    }
    private LimsTrendPointDto mapToTrendPoint(Map<String, Object> row) {
        LimsTrendPointDto point = new LimsTrendPointDto();
        point.setTime(asString(readIgnoreCase(row, "time_key")));
        point.setPointCount(defaultLong(asLong(readIgnoreCase(row, "point_count"))));
        point.setAvgValue(scale(asBigDecimal(readIgnoreCase(row, "avg_value"))));
        point.setMaxValue(scale(asBigDecimal(readIgnoreCase(row, "max_value"))));
        point.setMinValue(scale(asBigDecimal(readIgnoreCase(row, "min_value"))));
        return point;
    }
    private LimsComparisonItemDto mapToComparisonItem(Map<String, Object> row) {
        LimsComparisonItemDto item = new LimsComparisonItemDto();
        item.setDimensionValue(asString(readIgnoreCase(row, "dimension_value")));
        item.setPointCount(defaultLong(asLong(readIgnoreCase(row, "point_count"))));
        item.setAvgValue(scale(asBigDecimal(readIgnoreCase(row, "avg_value"))));
        item.setMaxValue(scale(asBigDecimal(readIgnoreCase(row, "max_value"))));
        item.setMinValue(scale(asBigDecimal(readIgnoreCase(row, "min_value"))));
        return item;
    }
    private LimsQualityDistributionItemDto mapToQualityDistributionItem(Map<String, Object> row) {
        LimsQualityDistributionItemDto item = new LimsQualityDistributionItemDto();
        item.setCategory(asString(readIgnoreCase(row, "category")));
        item.setPointCount(defaultLong(asLong(readIgnoreCase(row, "point_count"))));
        item.setRatio(BigDecimal.ZERO);
        return item;
    }
    private Object readIgnoreCase(Map<String, Object> row, String key) {
        if (row.containsKey(key)) {
            return row.get(key);
        }
        for (Map.Entry<String, Object> entry : row.entrySet()) {
            if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(key)) {
                return entry.getValue();
            }
        }
        return null;
    }
    private BigDecimal calculateRate(Number part, Number total) {
        if (part == null || total == null || total.longValue() == 0L) {
            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
        }
        return BigDecimal.valueOf(part.longValue())
                .multiply(BigDecimal.valueOf(100))
                .divide(BigDecimal.valueOf(total.longValue()), 2, RoundingMode.HALF_UP);
    }
    private BigDecimal scale(BigDecimal value) {
        if (value == null) {
            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
        }
        return value.setScale(2, RoundingMode.HALF_UP);
    }
    private String asString(Object value) {
        return value == null ? "" : String.valueOf(value);
    }
    private Long asLong(Object value) {
        if (value == null) {
            return 0L;
        }
        if (value instanceof Number) {
            return ((Number) value).longValue();
        }
        return Long.parseLong(String.valueOf(value));
    }
    private BigDecimal asBigDecimal(Object value) {
        if (value == null) {
            return BigDecimal.ZERO;
        }
        if (value instanceof BigDecimal) {
            return (BigDecimal) value;
        }
        if (value instanceof Number) {
            return BigDecimal.valueOf(((Number) value).doubleValue());
        }
        return new BigDecimal(String.valueOf(value));
    }
    private Long defaultLong(Long value) {
        return value == null ? 0L : value;
    }
    private LimsDataAnalysisQueryDto normalizeQuery(LimsDataAnalysisQueryDto source) {
        LimsDataAnalysisQueryDto query = new LimsDataAnalysisQueryDto();
        if (source != null) {
            query.setStartTime(source.getStartTime());
            query.setEndTime(source.getEndTime());
            query.setDataType(source.getDataType());
            query.setDeviceCode(source.getDeviceCode());
            query.setGranularity(source.getGranularity());
            query.setDimension(source.getDimension());
            query.setTopN(source.getTopN());
        }
        LocalDateTime now = LocalDateTime.now().withSecond(0).withNano(0);
        if (query.getStartTime() == null && query.getEndTime() == null) {
            query.setEndTime(now);
            query.setStartTime(now.minusDays(6).withHour(0).withMinute(0));
        } else if (query.getStartTime() == null) {
            query.setStartTime(query.getEndTime().minusDays(6).withHour(0).withMinute(0));
        } else if (query.getEndTime() == null) {
            query.setEndTime(query.getStartTime().plusDays(6).withHour(23).withMinute(59));
        }
        if (query.getStartTime().isAfter(query.getEndTime())) {
            LocalDateTime temp = query.getStartTime();
            query.setStartTime(query.getEndTime());
            query.setEndTime(temp);
        }
        if (!"hour".equalsIgnoreCase(query.getGranularity()) && !"day".equalsIgnoreCase(query.getGranularity())) {
            query.setGranularity("day");
        } else {
            query.setGranularity(query.getGranularity().toLowerCase(Locale.ROOT));
        }
        if (!"deviceName".equalsIgnoreCase(query.getDimension())
                && !"deviceCode".equalsIgnoreCase(query.getDimension())
                && !"dataType".equalsIgnoreCase(query.getDimension())) {
            query.setDimension("dataType");
        } else if ("devicename".equalsIgnoreCase(query.getDimension())) {
            query.setDimension("deviceName");
        } else if ("devicecode".equalsIgnoreCase(query.getDimension())) {
            query.setDimension("deviceCode");
        } else {
            query.setDimension("dataType");
        }
        if (query.getTopN() == null || query.getTopN() <= 0) {
            query.setTopN(10);
        } else if (query.getTopN() > 50) {
            query.setTopN(50);
        }
        return query;
    }
}
src/main/resources/application-dev.yml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,222 @@
# èН坼-仓储物流系统项目相关配置
inspur:
  appId: a3b0e3f1-7210-4ed0-8eed-c6c443e04e36
  appSecret: 7aab6b10061962e861ab69596eec6c037daffd2b56cdf518df8554ff56daa9c8
ruoyi:
  # åç§°
  name: RuoYi
  # ç‰ˆæœ¬
  version: 3.8.9
  # ç‰ˆæƒå¹´ä»½
  copyrightYear: 2025
  # æ–‡ä»¶è·¯å¾„ ç¤ºä¾‹ï¼ˆ Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
  profile: /center-lims/mis/file
  # èŽ·å–ip地址开关
  addressEnabled: false
  # éªŒè¯ç ç±»åž‹ math æ•°å­—计算 char å­—符验证
  captchaType: math
# å¼€å‘环境配置
server:
  # æœåŠ¡å™¨çš„HTTP端口,默认为8080
  port: 8080
  servlet:
    # åº”用的访问路径
    context-path: /
  tomcat:
    # tomcat的URI编码
    uri-encoding: UTF-8
    # è¿žæŽ¥æ•°æ»¡åŽçš„æŽ’队数,默认为100
    accept-count: 1000
    threads:
      # tomcat最大线程数,默认为200
      max: 800
      # Tomcat启动初始化的线程数,默认值10
      min-spare: 100
# æ—¥å¿—配置
logging:
  level:
    com.ruoyi: warn
    org.springframework: warn
minio:
  endpoint: http://114.132.189.42/
  port: 7019
  secure: false
  accessKey: admin
  secretKey: 12345678
  preview-expiry: 24 # é¢„览地址默认24小时
  default-bucket: uploadPath
# ç”¨æˆ·é…ç½®
user:
  password:
    # å¯†ç æœ€å¤§é”™è¯¯æ¬¡æ•°
    maxRetryCount: 5
    # å¯†ç é”å®šæ—¶é—´ï¼ˆé»˜è®¤10分钟)
    lockTime: 10
# Spring配置
spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driverClassName: com.mysql.cj.jdbc.Driver
    druid:
      # ä¸»åº“数据源
      master:
        url: jdbc:mysql://127.0.0.1:3306/lims-ruoyi?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
        username: root
        password: 123456
      # ä»Žåº“数据源
      slave:
        # ä»Žæ•°æ®æºå¼€å…³/默认关闭
        enabled: false
        url:
        username:
        password:
      # åˆå§‹è¿žæŽ¥æ•°
      initialSize: 5
      # æœ€å°è¿žæŽ¥æ± æ•°é‡
      minIdle: 10
      # æœ€å¤§è¿žæŽ¥æ± æ•°é‡
      maxActive: 20
      # é…ç½®èŽ·å–è¿žæŽ¥ç­‰å¾…è¶…æ—¶çš„æ—¶é—´
      maxWait: 60000
      # é…ç½®è¿žæŽ¥è¶…æ—¶æ—¶é—´
      connectTimeout: 30000
      # é…ç½®ç½‘络超时时间
      socketTimeout: 60000
      # é…ç½®é—´éš”多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
      timeBetweenEvictionRunsMillis: 60000
      # é…ç½®ä¸€ä¸ªè¿žæŽ¥åœ¨æ± ä¸­æœ€å°ç”Ÿå­˜çš„æ—¶é—´ï¼Œå•位是毫秒
      minEvictableIdleTimeMillis: 300000
      # é…ç½®ä¸€ä¸ªè¿žæŽ¥åœ¨æ± ä¸­æœ€å¤§ç”Ÿå­˜çš„æ—¶é—´ï¼Œå•位是毫秒
      maxEvictableIdleTimeMillis: 900000
      # é…ç½®æ£€æµ‹è¿žæŽ¥æ˜¯å¦æœ‰æ•ˆ
      validationQuery: SELECT 1 FROM DUAL
      testWhileIdle: true
      testOnBorrow: false
      testOnReturn: false
      webStatFilter:
        enabled: true
      statViewServlet:
        enabled: true
        # è®¾ç½®ç™½åå•,不填则允许所有访问
        allow:
        url-pattern: /druid/*
        # æŽ§åˆ¶å°ç®¡ç†ç”¨æˆ·åå’Œå¯†ç 
        login-username: ruoyi
        login-password: 123456
      filter:
        stat:
          enabled: true
          # æ…¢SQL记录
          log-slow-sql: true
          slow-sql-millis: 1000
          merge-sql: true
        wall:
          config:
            multi-statement-allow: true
  # èµ„源信息
  messages:
    # å›½é™…化资源文件路径
    basename: i18n/messages
  # æ–‡ä»¶ä¸Šä¼ 
  servlet:
    multipart:
      # å•个文件大小
      max-file-size: 1GB
      # è®¾ç½®æ€»ä¸Šä¼ çš„æ–‡ä»¶å¤§å°
      max-request-size: 2GB
  # æœåŠ¡æ¨¡å—
  devtools:
    restart:
      # çƒ­éƒ¨ç½²å¼€å…³
      enabled: false
  # redis é…ç½®
  redis:
    # åœ°å€
    host: 127.0.0.1
#    host: 172.17.0.1
    # ç«¯å£ï¼Œé»˜è®¤ä¸º6379
    port: 6379
    # æ•°æ®åº“索引
    database: 0
    # å¯†ç 
    password:
#    password: 123456
    # è¿žæŽ¥è¶…æ—¶æ—¶é—´
    timeout: 10s
    lettuce:
      pool:
        # è¿žæŽ¥æ± ä¸­çš„æœ€å°ç©ºé—²è¿žæŽ¥
        min-idle: 0
        # è¿žæŽ¥æ± ä¸­çš„æœ€å¤§ç©ºé—²è¿žæŽ¥
        max-idle: 8
        # è¿žæŽ¥æ± çš„æœ€å¤§æ•°æ®åº“连接数
        max-active: 8
        # #连接池最大阻塞等待时间(使用负值表示没有限制)
        max-wait: -1ms
# token配置
token:
  # ä»¤ç‰Œè‡ªå®šä¹‰æ ‡è¯†
  header: Authorization
  # ä»¤ç‰Œå¯†é’¥
  secret: abcdefghijklmnopqrstuvwxyz
  # ä»¤ç‰Œæœ‰æ•ˆæœŸï¼ˆé»˜è®¤30分钟)
  expireTime: 450
# MyBatis Plus配置
mybatis-plus:
  # æœç´¢æŒ‡å®šåŒ…别名   æ ¹æ®è‡ªå·±çš„项目来
  typeAliasesPackage: com.ruoyi.**.pojo
  # é…ç½®mapper的扫描,找到所有的mapper.xml映射文件
  mapperLocations: classpath*:mapper/**/*Mapper.xml
  # åŠ è½½å…¨å±€çš„é…ç½®æ–‡ä»¶
  configLocation: classpath:mybatis/mybatis-config.xml
  global-config:
    enable-sql-runner: true
    db-config:
      id-type: auto
# PageHelper分页插件
pagehelper:
  helperDialect: mysql
  supportMethodsArguments: true
  params: count=countSql
# Swagger配置
swagger:
  # æ˜¯å¦å¼€å¯swagger
  enabled: false
  # è¯·æ±‚前缀
  pathMapping: /dev-api
# é˜²æ­¢XSS攻击
xss:
  # è¿‡æ»¤å¼€å…³
  enabled: true
  # æŽ’除链接(多个用逗号分隔)
  excludes: /system/notice
  # åŒ¹é…é“¾æŽ¥
  urlPatterns: /system/*,/monitor/*,/tool/*
# ä»£ç ç”Ÿæˆ
gen:
  # ä½œè€…
  author: ruoyi
  # é»˜è®¤ç”ŸæˆåŒ…路径 system éœ€æ”¹æˆè‡ªå·±çš„æ¨¡å—名称 å¦‚ system monitor tool
  packageName: com.ruoyi.project.system
  # è‡ªåŠ¨åŽ»é™¤è¡¨å‰ç¼€ï¼Œé»˜è®¤æ˜¯true
  autoRemovePre: false
  # è¡¨å‰ç¼€ï¼ˆç”Ÿæˆç±»åä¸ä¼šåŒ…含表前缀,多个用逗号分隔)
  tablePrefix: sys_
  # æ˜¯å¦å…è®¸ç”Ÿæˆæ–‡ä»¶è¦†ç›–到本地(自定义路径),默认不允许
  allowOverwrite: false
file:
  temp-dir: /center-lims/mis/file/temp/uploads
  upload-dir: /center-lims/mis/file/prod/uploads