diff --git a/fastbee-iot-data/src/main/java/com/fastbee/iot/data/service/impl/DeviceJobServiceImpl.java b/fastbee-iot-data/src/main/java/com/fastbee/iot/data/service/impl/DeviceJobServiceImpl.java index b5123ff1..11fb35a9 100644 --- a/fastbee-iot-data/src/main/java/com/fastbee/iot/data/service/impl/DeviceJobServiceImpl.java +++ b/fastbee-iot-data/src/main/java/com/fastbee/iot/data/service/impl/DeviceJobServiceImpl.java @@ -44,7 +44,7 @@ public class DeviceJobServiceImpl implements IDeviceJobService { /** * 项目启动时,初始化定时器 主要是防止手动修改数据库导致未同步到定时任务处理(注:不能手动修改数据库ID和任务组名,否则会导致脏数据) */ - @PostConstruct + //@PostConstruct public void init() throws SchedulerException, TaskException { scheduler.clear(); // 设备定时任务 diff --git a/fastbee-open-api/src/main/java/com/fastbee/controller/datacenter/DataCenterController.java b/fastbee-open-api/src/main/java/com/fastbee/controller/datacenter/DataCenterController.java index 74da13dc..997debdf 100644 --- a/fastbee-open-api/src/main/java/com/fastbee/controller/datacenter/DataCenterController.java +++ b/fastbee-open-api/src/main/java/com/fastbee/controller/datacenter/DataCenterController.java @@ -9,7 +9,10 @@ import com.fastbee.common.utils.DateUtils; import com.fastbee.common.utils.StringUtils; import com.fastbee.iot.domain.ThingsModel; import com.fastbee.iot.model.AlertCountVO; +import com.fastbee.iot.model.DataQualityVO; import com.fastbee.iot.model.DeviceHistoryParam; +import com.fastbee.iot.model.DeviceStatsVO; +import com.fastbee.iot.model.HistogramVO; import com.fastbee.iot.model.SubDeviceCompareParam; import com.fastbee.iot.model.ThingsModelLogCountVO; import com.fastbee.iot.model.dto.ThingsModelDTO; @@ -68,6 +71,48 @@ public class DataCenterController { return AjaxResult.success(jsonObject); } + @ApiOperation("查询设备物模型统计指标(最大/最小/均值/累计/数据量/最新值),支持网关设备") + @PreAuthorize("@ss.hasPermi('dataCenter:analysis:list')") + @PostMapping("/deviceStats") + public AjaxResult deviceStats(@RequestBody DeviceHistoryParam deviceHistoryParam) { + if (deviceHistoryParam.getDeviceId() == null) { + return AjaxResult.error("请选择设备"); + } + if (StringUtils.isNotEmpty(deviceHistoryParam.getSerialNumber())) { + deviceHistoryParam.setSerialNumber(deviceHistoryParam.getSerialNumber().toUpperCase()); + } + List list = dataCenterService.deviceStats(deviceHistoryParam); + return AjaxResult.success(list); + } + + @ApiOperation("数据质量分析:上报间隔估算、缺数时间段、连续重复值检测、质量评分、7×24热力图") + @PreAuthorize("@ss.hasPermi('dataCenter:analysis:list')") + @PostMapping("/deviceDataQuality") + public AjaxResult deviceDataQuality(@RequestBody DeviceHistoryParam deviceHistoryParam) { + if (deviceHistoryParam.getDeviceId() == null) { + return AjaxResult.error("请选择设备"); + } + if (StringUtils.isNotEmpty(deviceHistoryParam.getSerialNumber())) { + deviceHistoryParam.setSerialNumber(deviceHistoryParam.getSerialNumber().toUpperCase()); + } + List list = dataCenterService.deviceDataQuality(deviceHistoryParam); + return AjaxResult.success(list); + } + + @ApiOperation("值分布直方图:统计每个物模型标识符在查询时段内的值区间分布频次") + @PreAuthorize("@ss.hasPermi('dataCenter:analysis:list')") + @PostMapping("/deviceHistogram") + public AjaxResult deviceHistogram(@RequestBody DeviceHistoryParam deviceHistoryParam) { + if (deviceHistoryParam.getDeviceId() == null) { + return AjaxResult.error("请选择设备"); + } + if (StringUtils.isNotEmpty(deviceHistoryParam.getSerialNumber())) { + deviceHistoryParam.setSerialNumber(deviceHistoryParam.getSerialNumber().toUpperCase()); + } + List list = dataCenterService.deviceHistogram(deviceHistoryParam); + return AjaxResult.success(list); + } + @ApiOperation("网关产品-可导入的子产品物模型列表(按identifier去重)") @PreAuthorize("@ss.hasPermi('iot:model:list')") @GetMapping("/subProductThingsModel") diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/domain/DeviceDeferredCommand.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/domain/DeviceDeferredCommand.java index 69676e7b..c63dec40 100644 --- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/domain/DeviceDeferredCommand.java +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/domain/DeviceDeferredCommand.java @@ -22,22 +22,66 @@ public class DeviceDeferredCommand { // 执行失败 public static final int STATUS_FAILED = 3; + /** + * 主键 + */ private Long id; + + /** + * 设备序列号 + */ private String serialNumber; + + /** + * 主题名称 + */ private String topicName; + + /** + * 消息体hex + */ private String messageHex; + + /** + * 数据类型 + */ private String dataType; + + /** + * 任务类型 + */ private Integer taskType; + + /** + * 状态(0:待执行,1:执行中,2:执行成功,3:执行失败) + */ private Integer status; + + /** + * 消息体hash值 + */ private String payloadHash; + + /** + * 错误信息 + */ private String errMsg; + /** + * 创建时间 + */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date createTime; + /** + * 过期时间 + */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date expireTime; + /** + * 调度时间 + */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date dispatchTime; } diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/DataQualityVO.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/DataQualityVO.java new file mode 100644 index 00000000..234769d4 --- /dev/null +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/DataQualityVO.java @@ -0,0 +1,62 @@ +package com.fastbee.iot.model; + +import lombok.Data; + +import java.util.List; + +/** + * 设备物模型数据质量 VO + * 包含:上报间隔估算、缺数时间段列表、重复值统计 + */ +@Data +public class DataQualityVO { + + /** 标识符 */ + private String identifier; + + /** 物模型名称 */ + private String modelName; + + /** 所属设备序列号 */ + private String serialNumber; + + /** 时间范围内实际数据量 */ + private Long totalCount; + + /** 估算平均上报间隔(秒) */ + private Long avgIntervalSeconds; + + /** 缺数时间段列表(间隔超过 5× 平均间隔视为缺数) */ + private List missingSegments; + + /** 缺数段数量 */ + private Integer missingCount; + + /** 缺数总时长(分钟) */ + private Long totalMissingMinutes; + + /** 连续重复值数量 */ + private Long repeatCount; + + /** 连续重复率(0~100) */ + private Double repeatRate; + + /** 数据质量评分(0~100,综合缺数率和重复率计算) */ + private Integer qualityScore; + + /** + * 7×24 上报热力图数据,每项为 [星期几(0=周日~6=周六), 小时(0~23), 数据量] + * 用于 ECharts heatmap 的 data 字段 + */ + private List> heatmapData; + + @Data + public static class MissingSegment { + /** 缺数段开始时间(上一条记录时间) */ + private String gapStart; + /** 缺数段结束时间(下一条记录时间) */ + private String gapEnd; + /** 缺数时长(分钟) */ + private Long durationMinutes; + } +} diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/DeviceStatsVO.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/DeviceStatsVO.java new file mode 100644 index 00000000..3cf3a9b9 --- /dev/null +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/DeviceStatsVO.java @@ -0,0 +1,41 @@ +package com.fastbee.iot.model; + +import lombok.Data; + +/** + * 设备物模型统计指标 VO + * 包含查询时间范围内某一标识符的最大/最小/均值/累计值/数据量/最新值 + */ +@Data +public class DeviceStatsVO { + + /** 标识符 */ + private String identifier; + + /** 物模型名称 */ + private String modelName; + + /** 所属设备序列号(网关子设备与网关本身 SN 不同) */ + private String serialNumber; + + /** 最大值(仅数值型属性有效) */ + private String maxValue; + + /** 最小值(仅数值型属性有效) */ + private String minValue; + + /** 平均值(仅数值型属性有效) */ + private String avgValue; + + /** 累计值(仅数值型属性有效) */ + private String sumValue; + + /** 数据量(时间范围内上报次数) */ + private Long dataCount; + + /** 最新值(时间范围内最后一条记录的值) */ + private String lastValue; + + /** 最新值对应的时间 */ + private String lastTime; +} diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/HistogramVO.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/HistogramVO.java new file mode 100644 index 00000000..ac1a1525 --- /dev/null +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/HistogramVO.java @@ -0,0 +1,42 @@ +package com.fastbee.iot.model; + +import lombok.Data; + +import java.util.List; + +/** + * 设备物模型值分布直方图 VO + */ +@Data +public class HistogramVO { + + /** 标识符 */ + private String identifier; + + /** 物模型名称 */ + private String modelName; + + /** 所属设备序列号 */ + private String serialNumber; + + /** 分桶列表 */ + private List buckets; + + /** 非数值条数(布尔/枚举/字符串类型) */ + private Long nonNumericCount; + + /** 非数值枚举分布(key=值, value=次数),用于布尔/枚举类型 */ + private java.util.Map enumDistribution; + + @Data + public static class Bucket { + /** 区间标签,如 "18.00 ~ 20.00" */ + private String label; + /** 区间下界(含) */ + private Double rangeMin; + /** 区间上界(不含,最后一个桶含) */ + private Double rangeMax; + /** 落在该区间的数据量 */ + private Long count; + } +} diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/DataCenterService.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/DataCenterService.java index b0bea964..cfa8636a 100644 --- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/DataCenterService.java +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/DataCenterService.java @@ -3,7 +3,10 @@ package com.fastbee.iot.service; import com.alibaba.fastjson2.JSONObject; import com.fastbee.iot.domain.ThingsModel; import com.fastbee.iot.model.AlertCountVO; +import com.fastbee.iot.model.DataQualityVO; import com.fastbee.iot.model.DeviceHistoryParam; +import com.fastbee.iot.model.DeviceStatsVO; +import com.fastbee.iot.model.HistogramVO; import com.fastbee.iot.model.SubDeviceCompareParam; import com.fastbee.iot.model.dto.ThingsModelDTO; import com.fastbee.iot.model.ThingsModelLogCountVO; @@ -100,4 +103,31 @@ public interface DataCenterService { * @return 仅计算物模型的 DTO 列表 */ List listGatewayCalculatedModels(Long gwDeviceId); + + /** + * 查询设备物模型在时间范围内的统计指标(最大/最小/均值/累计/数据量/最新值) + * 支持网关设备(自动按子设备 SN 分组查询) + * + * @param param deviceId、serialNumber、identifierList(可选)、beginTime、endTime + * @return 每个标识符对应的统计指标列表 + */ + List deviceStats(DeviceHistoryParam param); + + /** + * 数据质量分析:上报间隔估算、缺数时间段、连续重复值检测、质量评分 + * 支持网关设备(自动按子设备 SN 分组查询) + * + * @param param deviceId、serialNumber、identifierList(可选)、beginTime、endTime + * @return 每个标识符对应的数据质量报告 + */ + List deviceDataQuality(DeviceHistoryParam param); + + /** + * 值分布直方图:按标识符统计值在各区间的频次分布 + * 支持网关设备(自动按子设备 SN 分组查询) + * + * @param param deviceId、serialNumber、identifierList(可选)、beginTime、endTime + * @return 每个标识符对应的直方图数据 + */ + List deviceHistogram(DeviceHistoryParam param); } diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DataCenterServiceImpl.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DataCenterServiceImpl.java index dcbcae60..e2496f72 100644 --- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DataCenterServiceImpl.java +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DataCenterServiceImpl.java @@ -14,6 +14,10 @@ import com.fastbee.iot.model.gateWay.SubDeviceListVO; import com.fastbee.iot.model.param.DataCenterParam; import com.fastbee.iot.model.scenemodel.SceneHistoryParam; import com.fastbee.iot.service.*; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Calendar; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.springframework.stereotype.Service; @@ -59,60 +63,82 @@ public class DataCenterServiceImpl implements DataCenterService { @Override public List deviceHistory(DeviceHistoryParam deviceHistoryParam) { - List resultList = new ArrayList<>(); - List identifierVOList = new ArrayList<>(); + // snToIdentifiers: 每个 serialNumber 对应该 SN 下需要查询的标识符列表 + // 对于普通设备只有一个 SN;对于网关设备,自身与各子设备 SN 不同,需分别查询 + Map> snToIdentifiers = new LinkedHashMap<>(); + List allIdentifiers = new ArrayList<>(); + if (CollectionUtils.isEmpty(deviceHistoryParam.getIdentifierList())) { + // 未指定标识符时,从 listThingsModel 获取(已携带每个物模型真实的 serialNumber) List list = deviceService.listThingsModel(deviceHistoryParam.getDeviceId()); - for (ThingsModelDTO thingsModelDTO : list) { - DeviceHistoryParam.IdentifierVO identifierVO = new DeviceHistoryParam.IdentifierVO(); - identifierVO.setIdentifier(thingsModelDTO.getIdentifier()); - identifierVO.setType(thingsModelDTO.getType()); - identifierVOList.add(identifierVO); + for (ThingsModelDTO dto : list) { + DeviceHistoryParam.IdentifierVO vo = new DeviceHistoryParam.IdentifierVO(); + vo.setIdentifier(dto.getIdentifier()); + vo.setType(dto.getType()); + String sn = StringUtils.isNotEmpty(dto.getSerialNumber()) + ? dto.getSerialNumber() + : deviceHistoryParam.getSerialNumber(); + snToIdentifiers.computeIfAbsent(sn, k -> new ArrayList<>()).add(vo); + if (!allIdentifiers.contains(dto.getIdentifier())) { + allIdentifiers.add(dto.getIdentifier()); + } } } else { - identifierVOList = deviceHistoryParam.getIdentifierList(); + // 显式传入标识符列表时统一使用请求中的 serialNumber + snToIdentifiers.put(deviceHistoryParam.getSerialNumber(), deviceHistoryParam.getIdentifierList()); + deviceHistoryParam.getIdentifierList() + .forEach(vo -> allIdentifiers.add(vo.getIdentifier())); } - if (CollectionUtils.isEmpty(identifierVOList)) { - return resultList; + + if (snToIdentifiers.isEmpty()) { + return new ArrayList<>(); } - List identifierList = identifierVOList.stream().map(DeviceHistoryParam.IdentifierVO::getIdentifier) - .collect(Collectors.toList()); + List historyModelList = new ArrayList<>(); - List propertyIdentifierList = identifierVOList.stream().filter(t -> 1 == t.getType()) - .map(DeviceHistoryParam.IdentifierVO::getIdentifier).collect(Collectors.toList()); - if (CollectionUtils.isNotEmpty(propertyIdentifierList)) { - DeviceLog deviceLog = new DeviceLog(); - deviceLog.setIdentityList(propertyIdentifierList); - deviceLog.setSerialNumber(deviceHistoryParam.getSerialNumber()); - deviceLog.setBeginTime(deviceHistoryParam.getBeginTime()); - deviceLog.setEndTime(deviceHistoryParam.getEndTime()); - historyModelList.addAll(deviceLogService.listHistory(deviceLog)); - } - List functionIdentifierList = identifierVOList.stream().filter(t -> 2 == t.getType()) - .map(DeviceHistoryParam.IdentifierVO::getIdentifier).collect(Collectors.toList()); - if (CollectionUtils.isNotEmpty(functionIdentifierList)) { - FunctionLog functionLog = new FunctionLog(); - functionLog.setIdentifyList(functionIdentifierList); - functionLog.setSerialNumber(deviceHistoryParam.getSerialNumber()); - functionLog - .setBeginTime(DateUtils.dateTime(DateUtils.YY_MM_DD_HH_MM_SS, deviceHistoryParam.getBeginTime())); - functionLog.setEndTime(DateUtils.dateTime(DateUtils.YY_MM_DD_HH_MM_SS, deviceHistoryParam.getEndTime())); - historyModelList.addAll(functionLogService.listHistory(functionLog)); - } - List eventIdentifierList = identifierVOList.stream().filter(t -> 3 == t.getType()) - .map(DeviceHistoryParam.IdentifierVO::getIdentifier).collect(Collectors.toList()); - if (CollectionUtils.isNotEmpty(eventIdentifierList)) { - EventLog eventLog = new EventLog(); - eventLog.setIdentityList(eventIdentifierList); - eventLog.setSerialNumber(deviceHistoryParam.getSerialNumber()); - Map params = new HashMap<>(2); - params.put("beginTime", deviceHistoryParam.getBeginTime()); - params.put("endTime", deviceHistoryParam.getEndTime()); - eventLog.setParams(params); - historyModelList.addAll(eventLogService.listHistory(eventLog)); + for (Map.Entry> entry : snToIdentifiers.entrySet()) { + String sn = entry.getKey(); + List identifierVOList = entry.getValue(); + + List propertyIdentifierList = identifierVOList.stream().filter(t -> 1 == t.getType()) + .map(DeviceHistoryParam.IdentifierVO::getIdentifier).collect(Collectors.toList()); + if (CollectionUtils.isNotEmpty(propertyIdentifierList)) { + DeviceLog deviceLog = new DeviceLog(); + deviceLog.setIdentityList(propertyIdentifierList); + deviceLog.setSerialNumber(sn); + deviceLog.setBeginTime(deviceHistoryParam.getBeginTime()); + deviceLog.setEndTime(deviceHistoryParam.getEndTime()); + historyModelList.addAll(deviceLogService.listHistory(deviceLog)); + } + + List functionIdentifierList = identifierVOList.stream().filter(t -> 2 == t.getType()) + .map(DeviceHistoryParam.IdentifierVO::getIdentifier).collect(Collectors.toList()); + if (CollectionUtils.isNotEmpty(functionIdentifierList)) { + FunctionLog functionLog = new FunctionLog(); + functionLog.setIdentifyList(functionIdentifierList); + functionLog.setSerialNumber(sn); + functionLog.setBeginTime( + DateUtils.dateTime(DateUtils.YY_MM_DD_HH_MM_SS, deviceHistoryParam.getBeginTime())); + functionLog.setEndTime( + DateUtils.dateTime(DateUtils.YY_MM_DD_HH_MM_SS, deviceHistoryParam.getEndTime())); + historyModelList.addAll(functionLogService.listHistory(functionLog)); + } + + List eventIdentifierList = identifierVOList.stream().filter(t -> 3 == t.getType()) + .map(DeviceHistoryParam.IdentifierVO::getIdentifier).collect(Collectors.toList()); + if (CollectionUtils.isNotEmpty(eventIdentifierList)) { + EventLog eventLog = new EventLog(); + eventLog.setIdentityList(eventIdentifierList); + eventLog.setSerialNumber(sn); + Map params = new HashMap<>(2); + params.put("beginTime", deviceHistoryParam.getBeginTime()); + params.put("endTime", deviceHistoryParam.getEndTime()); + eventLog.setParams(params); + historyModelList.addAll(eventLogService.listHistory(eventLog)); + } } + historyModelList.sort(Comparator.comparing(HistoryModel::getTime)); - return this.handleData(identifierList, historyModelList); + return this.handleData(allIdentifiers, historyModelList); } private List handleData(List identifierList, List historyModelList) { @@ -510,10 +536,353 @@ public class DataCenterServiceImpl implements DataCenterService { return result; } + @Override + public List deviceDataQuality(DeviceHistoryParam param) { + Map> snToIdentifiers = new LinkedHashMap<>(); + Map identifierToModelName = new LinkedHashMap<>(); + Map identifierToSn = new LinkedHashMap<>(); + buildSnGrouping(param, snToIdentifiers, identifierToModelName, identifierToSn); + if (snToIdentifiers.isEmpty()) { + return new ArrayList<>(); + } + + // 按 identifier 聚合原始历史(仅属性类型) + Map> rawByIdentifier = fetchRawByIdentifier(param, snToIdentifiers, true, false); + + List result = new ArrayList<>(); + for (Map.Entry entry : identifierToSn.entrySet()) { + String identifier = entry.getKey(); + List hList = rawByIdentifier.getOrDefault(identifier, new ArrayList<>()); + // 按时间升序排列 + hList.sort(Comparator.comparing(HistoryModel::getTime, Comparator.nullsLast(Comparator.naturalOrder()))); + + DataQualityVO vo = new DataQualityVO(); + vo.setIdentifier(identifier); + vo.setModelName(identifierToModelName.getOrDefault(identifier, identifier)); + vo.setSerialNumber(entry.getValue()); + vo.setTotalCount((long) hList.size()); + + if (hList.size() >= 2) { + // 计算所有相邻记录的时间差(秒) + List gaps = new ArrayList<>(); + for (int i = 1; i < hList.size(); i++) { + Date t1 = hList.get(i - 1).getTime(); + Date t2 = hList.get(i).getTime(); + if (t1 != null && t2 != null) { + gaps.add((t2.getTime() - t1.getTime()) / 1000L); + } + } + if (!gaps.isEmpty()) { + // 平均上报间隔(秒) + long avgGap = (long) gaps.stream().mapToLong(Long::longValue).average().orElse(0); + vo.setAvgIntervalSeconds(avgGap); + + // 缺数检测阈值:最大取平均间隔的5倍或5分钟,最小不超过1小时 + long threshold = Math.max(avgGap * 5, 300L); + threshold = Math.min(threshold, 3600L); + + List segments = new ArrayList<>(); + long totalMissingSeconds = 0; + for (int i = 0; i < gaps.size(); i++) { + if (gaps.get(i) > threshold) { + DataQualityVO.MissingSegment seg = new DataQualityVO.MissingSegment(); + seg.setGapStart(DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, hList.get(i).getTime())); + seg.setGapEnd(DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, hList.get(i + 1).getTime())); + seg.setDurationMinutes(gaps.get(i) / 60); + segments.add(seg); + totalMissingSeconds += gaps.get(i); + } + } + vo.setMissingSegments(segments); + vo.setMissingCount(segments.size()); + vo.setTotalMissingMinutes(totalMissingSeconds / 60); + } + } else { + vo.setMissingSegments(new ArrayList<>()); + vo.setMissingCount(0); + vo.setTotalMissingMinutes(0L); + } + + // 连续重复值检测 + long repeatCount = 0; + for (int i = 1; i < hList.size(); i++) { + String prev = hList.get(i - 1).getValue(); + String curr = hList.get(i).getValue(); + if (prev != null && prev.equals(curr)) { + repeatCount++; + } + } + vo.setRepeatCount(repeatCount); + if (hList.size() > 1) { + vo.setRepeatRate(Math.round(repeatCount * 10000.0 / (hList.size() - 1)) / 100.0); + } else { + vo.setRepeatRate(0.0); + } + + // 质量评分(0~100):缺数越多扣分,重复率越高扣分 + int score = 100; + if (vo.getMissingCount() != null && vo.getMissingCount() > 0) { + // 每段缺数扣5分,最多扣50分 + score -= Math.min(vo.getMissingCount() * 5, 50); + } + if (vo.getRepeatRate() != null && vo.getRepeatRate() > 5) { + score -= (int) Math.min((vo.getRepeatRate() - 5) / 2, 30); + } + vo.setQualityScore(Math.max(score, 0)); + + // 7×24 热力图:[dayOfWeek(0-6), hour(0-23), count] + int[][] heatmap = new int[7][24]; + Calendar cal = Calendar.getInstance(); + for (HistoryModel h : hList) { + if (h.getTime() != null) { + cal.setTime(h.getTime()); + int dow = cal.get(Calendar.DAY_OF_WEEK) - 1; // 0=周日 + int hour = cal.get(Calendar.HOUR_OF_DAY); + heatmap[dow][hour]++; + } + } + List> heatmapData = new ArrayList<>(); + for (int d = 0; d < 7; d++) { + for (int h = 0; h < 24; h++) { + if (heatmap[d][h] > 0) { + List point = new ArrayList<>(); + point.add(h); // ECharts x = 小时 + point.add(d); // ECharts y = 星期 + point.add(heatmap[d][h]); + heatmapData.add(point); + } + } + } + vo.setHeatmapData(heatmapData); + result.add(vo); + } + return result; + } + + @Override + public List deviceHistogram(DeviceHistoryParam param) { + Map> snToIdentifiers = new LinkedHashMap<>(); + Map identifierToModelName = new LinkedHashMap<>(); + Map identifierToSn = new LinkedHashMap<>(); + buildSnGrouping(param, snToIdentifiers, identifierToModelName, identifierToSn); + if (snToIdentifiers.isEmpty()) { + return new ArrayList<>(); + } + + Map> rawByIdentifier = fetchRawByIdentifier(param, snToIdentifiers, true, false); + + List result = new ArrayList<>(); + for (Map.Entry entry : identifierToSn.entrySet()) { + String identifier = entry.getKey(); + List hList = rawByIdentifier.getOrDefault(identifier, new ArrayList<>()); + + HistogramVO vo = new HistogramVO(); + vo.setIdentifier(identifier); + vo.setModelName(identifierToModelName.getOrDefault(identifier, identifier)); + vo.setSerialNumber(entry.getValue()); + + // 尝试数值解析 + List numericValues = new ArrayList<>(); + Map enumDist = new LinkedHashMap<>(); + long nonNumericCount = 0; + for (HistoryModel h : hList) { + if (h.getValue() == null || h.getValue().trim().isEmpty()) { + continue; + } + try { + numericValues.add(Double.parseDouble(h.getValue().trim())); + } catch (NumberFormatException e) { + nonNumericCount++; + enumDist.merge(h.getValue().trim(), 1L, (a, b) -> a + b); + } + } + vo.setNonNumericCount(nonNumericCount); + + if (!numericValues.isEmpty()) { + double min = numericValues.stream().mapToDouble(Double::doubleValue).min().orElse(0); + double max = numericValues.stream().mapToDouble(Double::doubleValue).max().orElse(0); + int bucketCount = 10; + double range = max - min; + // 若所有值相同,设一个区间宽度 + double bucketWidth = (range == 0) ? 1.0 : range / bucketCount; + long[] counts = new long[bucketCount]; + for (double v : numericValues) { + int idx = (range == 0) ? 0 : (int) Math.min((v - min) / bucketWidth, bucketCount - 1); + counts[idx]++; + } + List buckets = new ArrayList<>(); + for (int i = 0; i < bucketCount; i++) { + HistogramVO.Bucket bucket = new HistogramVO.Bucket(); + double lo = min + i * bucketWidth; + double hi = min + (i + 1) * bucketWidth; + bucket.setRangeMin(Math.round(lo * 100.0) / 100.0); + bucket.setRangeMax(Math.round(hi * 100.0) / 100.0); + bucket.setLabel(String.format("%.2f~%.2f", lo, hi)); + bucket.setCount(counts[i]); + buckets.add(bucket); + } + vo.setBuckets(buckets); + } else if (!enumDist.isEmpty()) { + // 全部是枚举/布尔值,返回枚举分布 + vo.setEnumDistribution(enumDist); + vo.setBuckets(new ArrayList<>()); + } else { + vo.setBuckets(new ArrayList<>()); + } + result.add(vo); + } + return result; + } + + /** + * 公共辅助:按 SN 分组,构建 snToIdentifiers / identifierToModelName / identifierToSn + */ + private void buildSnGrouping(DeviceHistoryParam param, + Map> snToIdentifiers, + Map identifierToModelName, + Map identifierToSn) { + if (CollectionUtils.isEmpty(param.getIdentifierList())) { + List list = deviceService.listThingsModel(param.getDeviceId()); + for (ThingsModelDTO dto : list) { + String sn = StringUtils.isNotEmpty(dto.getSerialNumber()) + ? dto.getSerialNumber() : param.getSerialNumber(); + DeviceHistoryParam.IdentifierVO vo = new DeviceHistoryParam.IdentifierVO(); + vo.setIdentifier(dto.getIdentifier()); + vo.setType(dto.getType()); + snToIdentifiers.computeIfAbsent(sn, k -> new ArrayList<>()).add(vo); + identifierToModelName.putIfAbsent(dto.getIdentifier(), dto.getModelName()); + identifierToSn.putIfAbsent(dto.getIdentifier(), sn); + } + } else { + snToIdentifiers.put(param.getSerialNumber(), param.getIdentifierList()); + param.getIdentifierList().forEach(vo -> { + identifierToSn.put(vo.getIdentifier(), param.getSerialNumber()); + }); + } + } + + /** + * 公共辅助:按 snToIdentifiers 分组查询原始历史,返回 identifier -> List + * + * @param includeProperty 是否查属性日志(type=1) + * @param includeFunction 是否查功能日志(type=2) + */ + private Map> fetchRawByIdentifier(DeviceHistoryParam param, + Map> snToIdentifiers, + boolean includeProperty, boolean includeFunction) { + Map> rawByIdentifier = new LinkedHashMap<>(); + for (Map.Entry> entry : snToIdentifiers.entrySet()) { + String sn = entry.getKey(); + List vos = entry.getValue(); + if (includeProperty) { + List propIds = vos.stream().filter(v -> Integer.valueOf(1).equals(v.getType())) + .map(DeviceHistoryParam.IdentifierVO::getIdentifier).collect(Collectors.toList()); + if (CollectionUtils.isNotEmpty(propIds)) { + DeviceLog dl = new DeviceLog(); + dl.setSerialNumber(sn); + dl.setIdentityList(propIds); + dl.setBeginTime(param.getBeginTime()); + dl.setEndTime(param.getEndTime()); + for (HistoryModel h : deviceLogService.listHistory(dl)) { + rawByIdentifier.computeIfAbsent(h.getIdentity(), k -> new ArrayList<>()).add(h); + } + } + } + if (includeFunction) { + List funcIds = vos.stream().filter(v -> Integer.valueOf(2).equals(v.getType())) + .map(DeviceHistoryParam.IdentifierVO::getIdentifier).collect(Collectors.toList()); + if (CollectionUtils.isNotEmpty(funcIds)) { + FunctionLog fl = new FunctionLog(); + fl.setSerialNumber(sn); + fl.setIdentifyList(funcIds); + fl.setBeginTime(DateUtils.dateTime(DateUtils.YY_MM_DD_HH_MM_SS, param.getBeginTime())); + fl.setEndTime(DateUtils.dateTime(DateUtils.YY_MM_DD_HH_MM_SS, param.getEndTime())); + for (HistoryModel h : functionLogService.listHistory(fl)) { + rawByIdentifier.computeIfAbsent(h.getIdentity(), k -> new ArrayList<>()).add(h); + } + } + } + } + return rawByIdentifier; + } + private Map parseCrossDeviceRefs(String formula) { return CalculateUtils.parseCrossDeviceRefs(formula); } + @Override + public List deviceStats(DeviceHistoryParam param) { + Map> snToIdentifiers = new LinkedHashMap<>(); + Map identifierToModelName = new LinkedHashMap<>(); + Map identifierToSn = new LinkedHashMap<>(); + buildSnGrouping(param, snToIdentifiers, identifierToModelName, identifierToSn); + + if (snToIdentifiers.isEmpty()) { + return new ArrayList<>(); + } + + Map> rawByIdentifier = fetchRawByIdentifier(param, snToIdentifiers, true, true); + + // 计算每个 identifier 的统计指标 + List result = new ArrayList<>(); + for (Map.Entry entry : identifierToSn.entrySet()) { + String identifier = entry.getKey(); + String sn = entry.getValue(); + List hList = rawByIdentifier.getOrDefault(identifier, new ArrayList<>()); + + DeviceStatsVO vo = new DeviceStatsVO(); + vo.setIdentifier(identifier); + vo.setModelName(identifierToModelName.getOrDefault(identifier, identifier)); + vo.setSerialNumber(sn); + vo.setDataCount((long) hList.size()); + + if (!hList.isEmpty()) { + // listHistory 返回 desc 排序,第一条即最新 + HistoryModel latest = hList.get(0); + vo.setLastValue(latest.getValue()); + if (latest.getTime() != null) { + vo.setLastTime(DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, latest.getTime())); + } + // 数值统计 + BigDecimal max = null, min = null, sum = BigDecimal.ZERO; + int numCount = 0; + for (HistoryModel h : hList) { + if (h.getValue() == null || h.getValue().isEmpty()) { + continue; + } + try { + BigDecimal v = new BigDecimal(h.getValue().trim()); + if (max == null || v.compareTo(max) > 0) { + max = v; + } + if (min == null || v.compareTo(min) < 0) { + min = v; + } + sum = sum.add(v); + numCount++; + } catch (NumberFormatException ignored) { + // 非数值型(布尔、枚举、字符串等)跳过数值计算 + } + } + if (numCount > 0) { + vo.setMaxValue(stripTrailingZeros(max)); + vo.setMinValue(stripTrailingZeros(min)); + vo.setAvgValue(stripTrailingZeros(sum.divide(BigDecimal.valueOf(numCount), 4, RoundingMode.HALF_UP))); + vo.setSumValue(stripTrailingZeros(sum)); + } + } + result.add(vo); + } + return result; + } + + private String stripTrailingZeros(BigDecimal value) { + if (value == null) { + return null; + } + return value.stripTrailingZeros().toPlainString(); + } + private List buildMinuteSlots(String beginTime, String endTime) { Date start = DateUtils.parseDate(beginTime); Date end = DateUtils.parseDate(endTime); diff --git a/yack-web/src/api/data/center.js b/yack-web/src/api/data/center.js index 1e80e3b7..f89336b6 100644 --- a/yack-web/src/api/data/center.js +++ b/yack-web/src/api/data/center.js @@ -45,6 +45,33 @@ export function getDataCenterCountThingsModelInvoke(query) { }); } +// 查询设备物模型统计指标(最大/最小/均值/累计/数据量/最新值) +export function getDataCenterDeviceStats(data) { + return request({ + url: '/data/center/deviceStats', + method: 'post', + data: data, + }); +} + +// 数据质量分析(上报间隔、缺数时间段、重复值检测、质量评分、7×24热力图) +export function getDataCenterDeviceDataQuality(data) { + return request({ + url: '/data/center/deviceDataQuality', + method: 'post', + data: data, + }); +} + +// 值分布直方图 +export function getDataCenterDeviceHistogram(data) { + return request({ + url: '/data/center/deviceHistogram', + method: 'post', + data: data, + }); +} + // 导出设备历史数据Excel export function exportDeviceHistory(data) { return request({ diff --git a/yack-web/src/views/dataCenter/analysis.vue b/yack-web/src/views/dataCenter/analysis.vue index 47c1941d..119e19d7 100644 --- a/yack-web/src/views/dataCenter/analysis.vue +++ b/yack-web/src/views/dataCenter/analysis.vue @@ -1,592 +1,397 @@