diff --git a/fastbee-iot-data/src/main/java/com/fastbee/iot/data/ruleEngine/SceneContext.java b/fastbee-iot-data/src/main/java/com/fastbee/iot/data/ruleEngine/SceneContext.java index 9f2d2170..591fcc52 100644 --- a/fastbee-iot-data/src/main/java/com/fastbee/iot/data/ruleEngine/SceneContext.java +++ b/fastbee-iot-data/src/main/java/com/fastbee/iot/data/ruleEngine/SceneContext.java @@ -10,6 +10,7 @@ import com.fastbee.common.core.thingsModel.SceneThingsModelItem; import com.fastbee.common.core.thingsModel.ThingsModelSimpleItem; import com.fastbee.common.exception.ServiceException; import com.fastbee.common.utils.DateUtils; +import com.fastbee.common.utils.SecurityUtils; import com.fastbee.common.utils.StringUtils; import com.fastbee.common.utils.spring.SpringUtils; import com.fastbee.iot.data.service.IFunctionInvoke; @@ -76,7 +77,8 @@ public class SceneContext { */ private List sceneThingsModelItems; - public SceneContext(String deviceNum, Long productId, int type, List thingsModelSimpleItems) { + public SceneContext(String deviceNum, Long productId, int type, + List thingsModelSimpleItems) { this.deviceNum = deviceNum; this.productId = productId; this.type = type; @@ -94,7 +96,7 @@ public class SceneContext { String requestId = "scene/" + scriptTemplate.getChainName(); scene_logger.info("[{}]=====+>场景联动规则开始执行!", requestId); scene_logger.info("[{}]=====+>规则引擎脚本配置:{},规则引擎上下文:{}", requestId, scriptTemplate, this); - //MDC.put("id", scriptTemplate.getChainName()); + // MDC.put("id", scriptTemplate.getChainName()); // 触发器 if (scriptTemplate.getPurpose() == 2) { // 触发器,检查静默时间 @@ -142,6 +144,7 @@ public class SceneContext { /*** * 设备触发脚本处理 + * * @param scriptTemplate 解析后的Json脚本数据 * @return */ @@ -169,18 +172,21 @@ public class SceneContext { if (sceneThingsModelItems == null) { sceneThingsModelItems = new ArrayList<>(); } - SceneThingsModelItem sceneItem = new SceneThingsModelItem(scriptTemplate.getId(), matchItem.getValue(), type, - scriptTemplate.getScriptId(), scriptTemplate.getSceneId(), scriptTemplate.getProductId(), deviceNum, matchItem.getRemark()); + SceneThingsModelItem sceneItem = new SceneThingsModelItem(scriptTemplate.getId(), + matchItem.getValue(), type, + scriptTemplate.getScriptId(), scriptTemplate.getSceneId(), scriptTemplate.getProductId(), + deviceNum, matchItem.getRemark()); sceneThingsModelItems.add(sceneItem); // 3.设备上报值匹配 - return matchValue(requestId, scriptTemplate.getOperator(), scriptTemplate.getValue(), matchItem.getValue()); + return matchValue(requestId, scriptTemplate.getOperator(), scriptTemplate.getValue(), + matchItem.getValue()); } } else { - //设备上线清理 离线延时匹配任务 + // 设备上线清理 离线延时匹配任务 if (type == 5 && scriptTemplate.getCheckdelay() != 0) { String key = "CHECK_" + scriptTemplate.getSceneId() + deviceNum; - //设备上线删除告警任务 + // 设备上线删除告警任务 ruleService.removeCheckTask(key); scene_logger.info("[{}]=====+>设备上线清理 离线延时匹配任务:{}", requestId, key); } @@ -190,8 +196,10 @@ public class SceneContext { if (sceneThingsModelItems == null) { sceneThingsModelItems = new ArrayList<>(); } - SceneThingsModelItem sceneItem = new SceneThingsModelItem(type == 5 ? "online" : "offline", type == 5 ? "1" : "0", type, - scriptTemplate.getScriptId(), scriptTemplate.getSceneId(), scriptTemplate.getProductId(), deviceNum, ""); + SceneThingsModelItem sceneItem = new SceneThingsModelItem(type == 5 ? "online" : "offline", + type == 5 ? "1" : "0", type, + scriptTemplate.getScriptId(), scriptTemplate.getSceneId(), scriptTemplate.getProductId(), + deviceNum, ""); sceneThingsModelItems.add(sceneItem); // 记录结果 return true; @@ -236,7 +244,8 @@ public class SceneContext { sceneThingsModelItems = new ArrayList<>(); } SceneThingsModelItem sceneItem = new SceneThingsModelItem(scriptTemplate.getId(), value, type, - scriptTemplate.getScriptId(), scriptTemplate.getSceneId(), scriptTemplate.getProductId(), num, ""); + scriptTemplate.getScriptId(), scriptTemplate.getSceneId(), scriptTemplate.getProductId(), + num, ""); sceneThingsModelItems.add(sceneItem); resultCount++; } @@ -249,6 +258,7 @@ public class SceneContext { /*** * 产品触发脚本处理 + * * @param scriptTemplate * @return */ @@ -276,18 +286,21 @@ public class SceneContext { if (sceneThingsModelItems == null) { sceneThingsModelItems = new ArrayList<>(); } - SceneThingsModelItem sceneItem = new SceneThingsModelItem(scriptTemplate.getId(), matchItem.getValue(), type, - scriptTemplate.getScriptId(), scriptTemplate.getSceneId(), scriptTemplate.getProductId(), deviceNum, matchItem.getRemark()); + SceneThingsModelItem sceneItem = new SceneThingsModelItem(scriptTemplate.getId(), + matchItem.getValue(), type, + scriptTemplate.getScriptId(), scriptTemplate.getSceneId(), scriptTemplate.getProductId(), + deviceNum, matchItem.getRemark()); sceneThingsModelItems.add(sceneItem); // 设备上报值匹配 - return matchValue(requestId, scriptTemplate.getOperator(), scriptTemplate.getValue(), matchItem.getValue()); + return matchValue(requestId, scriptTemplate.getOperator(), scriptTemplate.getValue(), + matchItem.getValue()); } } else { - //设备上线清理 离线延时匹配任务 + // 设备上线清理 离线延时匹配任务 if (type == 5 && scriptTemplate.getCheckdelay() != 0) { String key = "CHECK_" + scriptTemplate.getSceneId() + deviceNum; - //设备上线删除告警任务 + // 设备上线删除告警任务 ruleService.removeCheckTask(key); scene_logger.info("[{}]=====+>设备上线清理 离线延时匹配任务:{}", requestId, key); } @@ -297,8 +310,10 @@ public class SceneContext { if (sceneThingsModelItems == null) { sceneThingsModelItems = new ArrayList<>(); } - SceneThingsModelItem sceneItem = new SceneThingsModelItem(type == 5 ? "online" : "offline", type == 5 ? "1" : "0", type, - scriptTemplate.getScriptId(), scriptTemplate.getSceneId(), scriptTemplate.getProductId(), deviceNum, ""); + SceneThingsModelItem sceneItem = new SceneThingsModelItem(type == 5 ? "online" : "offline", + type == 5 ? "1" : "0", type, + scriptTemplate.getScriptId(), scriptTemplate.getSceneId(), scriptTemplate.getProductId(), + deviceNum, ""); sceneThingsModelItems.add(sceneItem); // 记录结果 return true; @@ -344,7 +359,8 @@ public class SceneContext { sceneThingsModelItems = new ArrayList<>(); } SceneThingsModelItem sceneItem = new SceneThingsModelItem(scriptTemplate.getId(), value, type, - scriptTemplate.getScriptId(), scriptTemplate.getSceneId(), scriptTemplate.getProductId(), num, ""); + scriptTemplate.getScriptId(), scriptTemplate.getSceneId(), scriptTemplate.getProductId(), + num, ""); sceneThingsModelItems.add(sceneItem); resultCount++; } @@ -427,11 +443,11 @@ public class SceneContext { alertLog.setCreateBy(scriptTemplate.getId()); alertLog.setSerialNumber(deviceNum); alertLog.setStatus(3); - //自动设置告警处理状态 + // 自动设置告警处理状态 alertLogService.updateAlertLogStatus(alertLog); - //如果存在延时确认任务,则删除 + // 如果存在延时确认任务,则删除 String key = "CHECK_" + scriptTemplate.getId() + deviceNum; - //设备上线删除告警任务 + // 设备上线删除告警任务 ruleService.removeCheckTask(key); scene_logger.info("[{}]=====+>告警恢复!", requestId); } @@ -442,14 +458,17 @@ public class SceneContext { * @param sceneId 场景ID */ private void alert(Long sceneId, boolean isNotify) { - Set sceneIdSet = sceneThingsModelItems.stream().map(SceneThingsModelItem::getSceneId).collect(Collectors.toSet()); + Set sceneIdSet = sceneThingsModelItems.stream().map(SceneThingsModelItem::getSceneId) + .collect(Collectors.toSet()); List sceneTerminalUserVOList = sceneService.selectTerminalUserBySceneIds(sceneIdSet); - Map sceneTerminalUserMap = sceneTerminalUserVOList.stream().collect(Collectors.toMap(SceneTerminalUserVO::getSceneId, Function.identity())); + Map sceneTerminalUserMap = sceneTerminalUserVOList.stream() + .collect(Collectors.toMap(SceneTerminalUserVO::getSceneId, Function.identity())); List alertLogList = new ArrayList<>(); for (SceneThingsModelItem sceneThingsModelItem : sceneThingsModelItems) { // 查询设备信息 Device device = deviceService.selectDeviceBySerialNumber(sceneThingsModelItem.getDeviceNumber()); - Optional.ofNullable(device).orElseThrow(() -> new ServiceException("告警推送,设备不存在" + "[{" + sceneThingsModelItem.getDeviceNumber() + "}]")); + Optional.ofNullable(device).orElseThrow( + () -> new ServiceException("告警推送,设备不存在" + "[{" + sceneThingsModelItem.getDeviceNumber() + "}]")); // 判断是否是终端用户的场景 SceneTerminalUserVO sceneTerminalUserVO = sceneTerminalUserMap.get(sceneId); if (1 == sceneTerminalUserVO.getTerminalUser()) { @@ -469,7 +488,8 @@ public class SceneContext { // 开启通知发送 if (isNotify) { AlertPushParams alertPushParams = buildAlertPushParams(device, sceneThingsModelItem); - List alertNotifyTemplateList = alertService.listAlertNotifyTemplate(alertSceneSendVO.getAlertId()); + List alertNotifyTemplateList = alertService + .listAlertNotifyTemplate(alertSceneSendVO.getAlertId()); alertPushParams.setAlertName(alertSceneSendVO.getAlertName()); for (AlertNotifyTemplate alertNotifyTemplate : alertNotifyTemplateList) { alertPushParams.setNotifyTemplateId(alertNotifyTemplate.getNotifyTemplateId()); @@ -492,15 +512,23 @@ public class SceneContext { alertPushParams.setDeviceId(device.getDeviceId()); alertPushParams.setDeviceName(device.getDeviceName()); alertPushParams.setSerialNumber(sceneThingsModelItem.getDeviceNumber()); -// List deviceUserList = deviceUserService.getDeviceUserAndShare(device.getDeviceId()); - // 多租户改版查询自己配置的告警用户 + // List deviceUserList = deviceUserService.getDeviceUserAndShare(device.getDeviceId()); + // 多租户改版查询自己配置的告警用户 默认优先推送本账号超级管理员 + Set userIdSet = new HashSet<>(); + try { + userIdSet.add(SecurityUtils.getUserId()); + } catch (Exception e) { + userIdSet.add(device.getUserId() == null ? device.getTenantId() : device.getUserId()); + } DeviceAlertUser deviceAlertUser = new DeviceAlertUser(); deviceAlertUser.setDeviceId(device.getDeviceId()); List deviceUserList = deviceAlertUserService.selectDeviceAlertUserList(deviceAlertUser); if (CollectionUtils.isNotEmpty(deviceUserList)) { - alertPushParams.setUserPhoneSet(deviceUserList.stream().map(DeviceAlertUser::getPhoneNumber).filter(StringUtils::isNotEmpty).collect(Collectors.toSet())); - alertPushParams.setUserIdSet(deviceUserList.stream().map(DeviceAlertUser::getUserId).collect(Collectors.toSet())); + alertPushParams.setUserPhoneSet(deviceUserList.stream().map(DeviceAlertUser::getPhoneNumber) + .filter(StringUtils::isNotEmpty).collect(Collectors.toSet())); + userIdSet.addAll(deviceUserList.stream().map(DeviceAlertUser::getUserId).collect(Collectors.toSet())); } + alertPushParams.setUserIdSet(userIdSet); String address; if (StringUtils.isNotEmpty(device.getNetworkAddress())) { address = device.getNetworkAddress(); @@ -546,7 +574,8 @@ public class SceneContext { * @return com.fastbee.iot.domain.AlertLog * @param: device */ - private AlertLog getAlertLog(AlertSceneSendVO alertSceneSendVO, Device device, SceneThingsModelItem sceneThingsModelItem) { + private AlertLog getAlertLog(AlertSceneSendVO alertSceneSendVO, Device device, + SceneThingsModelItem sceneThingsModelItem) { AlertLog alertLog = new AlertLog(); alertLog.setAlertName(alertSceneSendVO.getAlertName()); alertLog.setAlertLevel(alertSceneSendVO.getAlertLevel()); @@ -609,14 +638,16 @@ public class SceneContext { // 比较值用英文中划线分割 - String[] triggerValues = triggerValue.split("-"); if (isNumeric(value) && isNumeric(triggerValues[0]) && isNumeric(triggerValues[1])) { - result = Double.parseDouble(value) >= Double.parseDouble(triggerValues[0]) && Double.parseDouble(value) <= Double.parseDouble(triggerValues[1]); + result = Double.parseDouble(value) >= Double.parseDouble(triggerValues[0]) + && Double.parseDouble(value) <= Double.parseDouble(triggerValues[1]); } break; case "notBetween": // 比较值用英文中划线分割 - String[] trigValues = triggerValue.split("-"); if (isNumeric(value) && isNumeric(trigValues[0]) && isNumeric(trigValues[1])) { - result = Double.parseDouble(value) <= Double.parseDouble(trigValues[0]) || Double.parseDouble(value) >= Double.parseDouble(trigValues[1]); + result = Double.parseDouble(value) <= Double.parseDouble(trigValues[0]) + || Double.parseDouble(value) >= Double.parseDouble(trigValues[1]); } break; case "contain": 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 e668d0e3..74da13dc 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 @@ -7,8 +7,10 @@ import com.alibaba.fastjson2.JSONObject; import com.fastbee.common.core.domain.AjaxResult; 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.DeviceHistoryParam; +import com.fastbee.iot.model.SubDeviceCompareParam; import com.fastbee.iot.model.ThingsModelLogCountVO; import com.fastbee.iot.model.dto.ThingsModelDTO; import com.fastbee.iot.model.param.DataCenterParam; @@ -66,6 +68,184 @@ public class DataCenterController { return AjaxResult.success(jsonObject); } + @ApiOperation("网关产品-可导入的子产品物模型列表(按identifier去重)") + @PreAuthorize("@ss.hasPermi('iot:model:list')") + @GetMapping("/subProductThingsModel") + public AjaxResult listSubProductThingsModel(@RequestParam Long gwProductId) { + List list = dataCenterService.listSubProductThingsModelForImport(gwProductId); + return AjaxResult.success(list); + } + + @ApiOperation("网关产品-一键导入子产品物模型") + @PreAuthorize("@ss.hasPermi('iot:model:add')") + @PostMapping("/importSubProductThingsToGateway") + public AjaxResult importSubProductThingsToGateway(@RequestBody Map body) { + Long gwProductId = body != null ? body.get("gwProductId") : null; + if (gwProductId == null) { + return AjaxResult.error("请传入网关产品ID"); + } + int count = dataCenterService.importSubProductThingsToGateway(gwProductId); + return AjaxResult.success("导入成功,共 " + count + " 个物模型", count); + } + + @ApiOperation("网关计算物模型历史(查询时按公式计算,按分钟对齐,缺数沿用上一分钟)") + @PreAuthorize("@ss.hasPermi('dataCenter:history:list')") + @PostMapping("/deviceHistoryCalculated") + public AjaxResult deviceHistoryCalculated(@RequestBody DeviceHistoryParam deviceHistoryParam) { + if (deviceHistoryParam.getDeviceId() == null || StringUtils.isEmpty(deviceHistoryParam.getSerialNumber())) { + return AjaxResult.error("请选择网关设备"); + } + if (StringUtils.isNotEmpty(deviceHistoryParam.getSerialNumber())) { + deviceHistoryParam.setSerialNumber(deviceHistoryParam.getSerialNumber().toUpperCase()); + } + List list = dataCenterService.deviceHistoryCalculated(deviceHistoryParam); + return AjaxResult.success(list); + } + + @ApiOperation("导出网关计算物模型历史Excel") + @PreAuthorize("@ss.hasPermi('dataCenter:history:list')") + @PostMapping("/deviceHistoryCalculated/export") + public void exportDeviceHistoryCalculated(@RequestBody DeviceHistoryParam deviceHistoryParam, + HttpServletResponse response) throws IOException { + if (deviceHistoryParam.getDeviceId() == null || StringUtils.isEmpty(deviceHistoryParam.getSerialNumber())) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("请选择网关设备"); + return; + } + if (StringUtils.isNotEmpty(deviceHistoryParam.getSerialNumber())) { + deviceHistoryParam.setSerialNumber(deviceHistoryParam.getSerialNumber().toUpperCase()); + } + List dataList = dataCenterService.deviceHistoryCalculated(deviceHistoryParam); + if (dataList == null || dataList.isEmpty()) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + response.getWriter().write("没有数据可导出"); + return; + } + List modelList = dataCenterService + .listGatewayCalculatedModels(deviceHistoryParam.getDeviceId()); + Map identifierNameMap = new LinkedHashMap<>(); + for (com.fastbee.iot.model.dto.ThingsModelDTO m : modelList) { + identifierNameMap.put(m.getIdentifier(), m.getModelName() != null ? m.getModelName() : m.getIdentifier()); + } + List> headList = new ArrayList<>(); + headList.add(Collections.singletonList("时间")); + List identifierList = new ArrayList<>(); + if (deviceHistoryParam.getIdentifierList() != null) { + for (DeviceHistoryParam.IdentifierVO vo : deviceHistoryParam.getIdentifierList()) { + identifierList.add(vo.getIdentifier()); + headList.add(Collections + .singletonList(identifierNameMap.getOrDefault(vo.getIdentifier(), vo.getIdentifier()))); + } + } + List> rows = new ArrayList<>(); + for (JSONObject jsonObject : dataList) { + String time = jsonObject.keySet().iterator().next(); + Object dataValue = jsonObject.get(time); + List rowData = new ArrayList<>(); + rowData.add(time); + if (dataValue instanceof List) { + @SuppressWarnings("unchecked") + List identifierDataList = (List) dataValue; + Map valueMap = new HashMap<>(); + for (JSONObject identifierObj : identifierDataList) { + for (Map.Entry entry : identifierObj.entrySet()) { + valueMap.put(entry.getKey(), entry.getValue()); + } + } + for (String id : identifierList) { + rowData.add(valueMap.get(id)); + } + } + rows.add(rowData); + } + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + String fileName = URLEncoder + .encode(deviceHistoryParam.getSerialNumber() + "_calculated_" + + DateUtils.parseDateToStr(DateUtils.YYYYMMDDHHMMSS, new Date()), "UTF-8") + .replaceAll("\\+", "%20"); + response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); + ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).head(headList).build(); + WriteSheet writeSheet = EasyExcel.writerSheet(0, "网关计算量历史").build(); + excelWriter.write(rows, writeSheet); + excelWriter.finish(); + } + + @ApiOperation("网关设备仅计算物模型列表(历史记录-网关计算量 变量下拉用)") + @PreAuthorize("@ss.hasPermi('dataCenter:history:list')") + @GetMapping("/gatewayCalculatedModels") + public AjaxResult listGatewayCalculatedModels(@RequestParam Long deviceId) { + return AjaxResult.success(dataCenterService.listGatewayCalculatedModels(deviceId)); + } + + @ApiOperation("多设备对比历史(支持多变量,按分钟对齐,缺数沿用上一分钟)") + @PreAuthorize("@ss.hasPermi('dataCenter:history:list')") + @PostMapping("/subDeviceComparativeHistory") + public AjaxResult subDeviceComparativeHistory(@RequestBody SubDeviceCompareParam param) { + boolean hasIdentifier = (param.getIdentifierList() != null && !param.getIdentifierList().isEmpty()) + || !StringUtils.isEmpty(param.getIdentifier()); + if (param.getGwDeviceId() == null || !hasIdentifier) { + return AjaxResult.error("请选择网关设备和变量"); + } + if (param.getType() == null) { + param.setType(1); + } + List list = dataCenterService.subDeviceComparativeHistory(param); + return AjaxResult.success(list); + } + + @ApiOperation("导出多设备对比历史Excel") + @PreAuthorize("@ss.hasPermi('dataCenter:history:list')") + @PostMapping("/subDeviceComparativeHistory/export") + public void exportSubDeviceComparativeHistory(@RequestBody SubDeviceCompareParam param, + HttpServletResponse response) throws IOException { + boolean hasIdentifier = (param.getIdentifierList() != null && !param.getIdentifierList().isEmpty()) + || !StringUtils.isEmpty(param.getIdentifier()); + if (param.getGwDeviceId() == null || !hasIdentifier) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("请选择网关设备和变量"); + return; + } + if (param.getType() == null) { + param.setType(1); + } + List dataList = dataCenterService.subDeviceComparativeHistory(param); + if (dataList == null || dataList.isEmpty()) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + response.getWriter().write("没有数据可导出"); + return; + } + List> headList = new ArrayList<>(); + headList.add(Collections.singletonList("时间")); + Set keys = new LinkedHashSet<>(dataList.get(0).keySet()); + keys.remove("time"); + for (String k : keys) { + headList.add(Collections.singletonList(k)); + } + List> rows = new ArrayList<>(); + for (JSONObject row : dataList) { + List r = new ArrayList<>(); + r.add(row.getString("time")); + for (String k : keys) { + r.add(row.get(k)); + } + rows.add(r); + } + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + String idPart = (param.getIdentifierList() != null && !param.getIdentifierList().isEmpty()) + ? String.join("-", param.getIdentifierList()) : param.getIdentifier(); + String fileName = URLEncoder + .encode("sub_compare_" + idPart + "_" + + DateUtils.parseDateToStr(DateUtils.YYYYMMDDHHMMSS, new Date()), "UTF-8") + .replaceAll("\\+", "%20"); + response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); + ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).head(headList).build(); + WriteSheet writeSheet = EasyExcel.writerSheet(0, "多设备对比").build(); + excelWriter.write(rows, writeSheet); + excelWriter.finish(); + } + @ApiOperation("查询场景变量历史数据") @PreAuthorize("@ss.hasPermi('dataCenter:history:list')") @GetMapping("/sceneHistory") @@ -138,7 +318,8 @@ public class DataCenterController { @ApiOperation("导出设备历史数据Excel") @PreAuthorize("@ss.hasPermi('dataCenter:history:list')") @PostMapping("/deviceHistory/export") - public void exportDeviceHistory(@RequestBody DeviceHistoryParam deviceHistoryParam, HttpServletResponse response) throws IOException { + public void exportDeviceHistory(@RequestBody DeviceHistoryParam deviceHistoryParam, HttpServletResponse response) + throws IOException { final String serialNumber = deviceHistoryParam.getSerialNumber(); if (StringUtils.isEmpty(serialNumber)) { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); @@ -178,11 +359,13 @@ public class DataCenterController { for (String identifier : identifierList) { ThingsModelDTO thingsModel = thingsModelMap.get(identifier); String headerName = thingsModel != null && StringUtils.isNotEmpty(thingsModel.getModelName()) - ? thingsModel.getModelName() : identifier; + ? thingsModel.getModelName() + : identifier; identifierNameMap.put(identifier, headerName); // 获取数据类型 String dataType = "string"; // 默认字符串类型 - if (thingsModel != null && thingsModel.getDatatype() != null && StringUtils.isNotEmpty(thingsModel.getDatatype().getType())) { + if (thingsModel != null && thingsModel.getDatatype() != null + && StringUtils.isNotEmpty(thingsModel.getDatatype().getType())) { dataType = thingsModel.getDatatype().getType(); } identifierTypeMap.put(identifier, dataType); @@ -220,11 +403,13 @@ public class DataCenterController { for (String identifier : identifierSet) { ThingsModelDTO thingsModel = thingsModelMap.get(identifier); String headerName = thingsModel != null && StringUtils.isNotEmpty(thingsModel.getModelName()) - ? thingsModel.getModelName() : identifier; + ? thingsModel.getModelName() + : identifier; identifierNameMap.put(identifier, headerName); // 获取数据类型 String dataType = "string"; // 默认字符串类型 - if (thingsModel != null && thingsModel.getDatatype() != null && StringUtils.isNotEmpty(thingsModel.getDatatype().getType())) { + if (thingsModel != null && thingsModel.getDatatype() != null + && StringUtils.isNotEmpty(thingsModel.getDatatype().getType())) { dataType = thingsModel.getDatatype().getType(); } identifierTypeMap.put(identifier, dataType); @@ -290,7 +475,9 @@ public class DataCenterController { // 设置响应头 response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); - String fileName = URLEncoder.encode(serialNumber + "_" + DateUtils.parseDateToStr(DateUtils.YYYYMMDDHHMMSS, new Date()), "UTF-8").replaceAll("\\+", "%20"); + String fileName = URLEncoder + .encode(serialNumber + "_" + DateUtils.parseDateToStr(DateUtils.YYYYMMDDHHMMSS, new Date()), "UTF-8") + .replaceAll("\\+", "%20"); response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); // 使用EasyExcel写入数据 diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/SubDeviceCompareParam.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/SubDeviceCompareParam.java new file mode 100644 index 00000000..a946c608 --- /dev/null +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/SubDeviceCompareParam.java @@ -0,0 +1,30 @@ +package com.fastbee.iot.model; + +import lombok.Data; + +import java.util.List; + +/** + * 多设备对比查询参数(支持多变量,历史记录按分钟对齐,缺数沿用上一分钟最后值) + *

+ * 传 identifierList 时支持多变量对比;仅传 identifier 时按单变量兼容。 + *

+ * + * @author fastbee + */ +@Data +public class SubDeviceCompareParam { + + /** 网关设备ID */ + private Long gwDeviceId; + /** 物模型标识符(单变量时使用,与 identifierList 二选一) */ + private String identifier; + /** 物模型标识符列表(多变量对比时使用,优先于 identifier) */ + private List identifierList; + /** 指定参与对比的子设备ID列表;为空或 null 时表示全部子设备 */ + private List subDeviceIds; + /** 类型 1-属性 2-功能 3-事件,对比仅支持属性 */ + private Integer type; + private String beginTime; + private String endTime; +} 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 be9d79e8..b0bea964 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 @@ -1,17 +1,16 @@ 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.DeviceHistoryParam; -import com.fastbee.iot.model.HistoryModel; +import com.fastbee.iot.model.SubDeviceCompareParam; +import com.fastbee.iot.model.dto.ThingsModelDTO; import com.fastbee.iot.model.ThingsModelLogCountVO; import com.fastbee.iot.model.param.DataCenterParam; import com.fastbee.iot.model.scenemodel.SceneHistoryParam; -import java.util.Date; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; /** * 数据中心服务类 @@ -30,6 +29,38 @@ public interface DataCenterService { */ List deviceHistory(DeviceHistoryParam deviceHistoryParam); + /** + * 网关产品:查询绑定子产品的物模型列表(用于一键导入,按 identifier 去重) + * + * @param gwProductId 网关产品ID + * @return 去重后的物模型列表 + */ + List listSubProductThingsModelForImport(Long gwProductId); + + /** + * 网关产品:一键将绑定子产品的物模型导入到网关产品(按 identifier 去重,已存在则跳过) + * + * @param gwProductId 网关产品ID + * @return 本次导入数量 + */ + int importSubProductThingsToGateway(Long gwProductId); + + /** + * 网关计算物模型历史:仅查询时计算,按分钟对齐,缺数沿用上一分钟最后值;公式支持 {slaveId:N.identifier} + * + * @param deviceHistoryParam 网关设备ID、计算物模型 identifier 列表、时间范围 + * @return 与 deviceHistory 相同格式 + */ + List deviceHistoryCalculated(DeviceHistoryParam deviceHistoryParam); + + /** + * 多设备同一物理量对比历史:按分钟对齐,缺数沿用上一分钟最后值 + * + * @param param 网关设备ID、identifier、时间范围 + * @return 每行格式:{ "time": "yyyy-MM-dd HH:mm:ss", "设备标签1": v1, "设备标签2": v2, ... } + */ + List subDeviceComparativeHistory(SubDeviceCompareParam param); + /** * 查询场景变量的历史数据 * @@ -61,4 +92,12 @@ public interface DataCenterService { * @return com.fastbee.common.core.domain.AjaxResult */ List countThingsModelInvoke(DataCenterParam dataCenterParam); + + /** + * 网关设备仅计算物模型列表(用于历史记录-网关计算量 变量选择) + * + * @param gwDeviceId 网关设备ID + * @return 仅计算物模型的 DTO 列表 + */ + List listGatewayCalculatedModels(Long gwDeviceId); } 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 10a4afd5..d41f5787 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 @@ -3,14 +3,19 @@ package com.fastbee.iot.service.impl; import com.alibaba.fastjson2.JSONObject; import com.fastbee.common.enums.scenemodel.SceneModelVariableTypeEnum; import com.fastbee.common.utils.DateUtils; +import com.fastbee.common.utils.SecurityUtils; import com.fastbee.common.utils.StringUtils; +import com.fastbee.common.utils.CaculateUtils; import com.fastbee.iot.cache.SceneModelTagCache; import com.fastbee.iot.domain.*; import com.fastbee.iot.model.AlertCountVO; import com.fastbee.iot.model.DeviceHistoryParam; import com.fastbee.iot.model.HistoryModel; +import com.fastbee.iot.model.SubDeviceCompareParam; import com.fastbee.iot.model.ThingsModelLogCountVO; import com.fastbee.iot.model.dto.ThingsModelDTO; +import com.fastbee.iot.model.gateWay.ProductSubGatewayVO; +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.*; @@ -20,6 +25,8 @@ import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.*; import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -47,6 +54,17 @@ public class DataCenterServiceImpl implements DataCenterService { private SceneModelTagCache sceneModelTagCache; @Resource private IAlertLogService alertLogService; + @Resource + private IProductSubGatewayService productSubGatewayService; + @Resource + private ISubGatewayService subGatewayService; + @Resource + private IProductService productService; + @Resource + private com.fastbee.iot.cache.ITSLCache itslCache; + + /** 公式中跨设备引用:{slaveId:N.identifier} */ + private static final Pattern CROSS_DEVICE_REF = Pattern.compile("\\{slaveId:(\\d+)\\.([^}]+)\\}"); @Override public List deviceHistory(DeviceHistoryParam deviceHistoryParam) { @@ -66,9 +84,11 @@ public class DataCenterServiceImpl implements DataCenterService { if (CollectionUtils.isEmpty(identifierVOList)) { return resultList; } - List identifierList = identifierVOList.stream().map(DeviceHistoryParam.IdentifierVO::getIdentifier).collect(Collectors.toList()); + 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()); + 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); @@ -77,16 +97,19 @@ public class DataCenterServiceImpl implements DataCenterService { 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()); + 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 + .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()); + 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); @@ -109,7 +132,8 @@ public class DataCenterServiceImpl implements DataCenterService { for (Map.Entry> entry : map.entrySet()) { JSONObject jsonObject = new JSONObject(); List value = entry.getValue(); - Map historyModelMap = value.stream().collect(Collectors.toMap(HistoryModel::getIdentity, Function.identity(), (o, n) -> n)); + Map historyModelMap = value.stream() + .collect(Collectors.toMap(HistoryModel::getIdentity, Function.identity(), (o, n) -> n)); List jsonObjectList = new ArrayList<>(); for (String identifier : identifierList) { JSONObject jsonObject1 = new JSONObject(); @@ -140,11 +164,14 @@ public class DataCenterServiceImpl implements DataCenterService { return new ArrayList<>(); } List sceneModelDataIdList = StringUtils.str2List(sceneHistoryParam.getIds(), ",", true, true); - List sceneModelDataList = sceneModelDataService.selectSceneModelDataListByIds(sceneModelDataIdList.stream().map(Long::parseLong).collect(Collectors.toList())); - List datasourceIdList = sceneModelDataList.stream().map(SceneModelData::getDatasourceId).distinct().collect(Collectors.toList()); + List sceneModelDataList = sceneModelDataService.selectSceneModelDataListByIds( + sceneModelDataIdList.stream().map(Long::parseLong).collect(Collectors.toList())); + List datasourceIdList = sceneModelDataList.stream().map(SceneModelData::getDatasourceId).distinct() + .collect(Collectors.toList()); if (SceneModelVariableTypeEnum.THINGS_MODEL.getType().equals(sceneHistoryParam.getVariableType())) { List thingsModelList = thingsModelService.selectThingsModelListByModelIds(datasourceIdList); - Map thingsModelMap = thingsModelList.stream().collect(Collectors.toMap(ThingsModel::getModelId, ThingsModel::getType)); + Map thingsModelMap = thingsModelList.stream() + .collect(Collectors.toMap(ThingsModel::getModelId, ThingsModel::getType)); DeviceHistoryParam deviceHistoryParam = new DeviceHistoryParam(); List identifierVOList = new ArrayList<>(); for (SceneModelData sceneModelData : sceneModelDataList) { @@ -189,4 +216,354 @@ public class DataCenterServiceImpl implements DataCenterService { resultList.addAll(functionLogService.countThingsModelInvoke(dataCenterParam)); return resultList; } + + @Override + public List listSubProductThingsModelForImport(Long gwProductId) { + ProductSubGateway query = new ProductSubGateway(); + query.setGwProductId(gwProductId); + List list = productSubGatewayService.selectProductSubGatewayList(query); + if (CollectionUtils.isEmpty(list)) { + return new ArrayList<>(); + } + List subProductIds = list.stream().map(ProductSubGatewayVO::getSubProductId).distinct() + .collect(Collectors.toList()); + Map dedup = new LinkedHashMap<>(); + ThingsModel q = new ThingsModel(); + q.setLanguage(SecurityUtils.getLanguage()); + q.setType(1); + for (Long subId : subProductIds) { + q.setProductId(subId); + List models = thingsModelService.selectThingsModelList(q); + for (ThingsModel m : models) { + dedup.putIfAbsent(m.getIdentifier(), m); + } + } + return new ArrayList<>(dedup.values()); + } + + @Override + public int importSubProductThingsToGateway(Long gwProductId) { + Product gwProduct = productService.selectProductByProductId(gwProductId); + if (gwProduct == null) { + return 0; + } + List toImport = listSubProductThingsModelForImport(gwProductId); + ThingsModel existQ = new ThingsModel(); + existQ.setProductId(gwProductId); + existQ.setLanguage(SecurityUtils.getLanguage()); + Set existingIds = thingsModelService.selectThingsModelList(existQ).stream() + .map(ThingsModel::getIdentifier).collect(Collectors.toSet()); + int count = 0; + for (ThingsModel m : toImport) { + if (existingIds.contains(m.getIdentifier())) { + continue; + } + ThingsModel add = new ThingsModel(); + add.setProductId(gwProductId); + add.setProductName(gwProduct.getProductName()); + add.setModelName(m.getModelName()); + add.setIdentifier(m.getIdentifier()); + add.setType(m.getType()); + add.setDatatype(m.getDatatype()); + add.setSpecs(m.getSpecs()); + add.setFormula(m.getFormula()); + add.setIsCalculated(m.getIsCalculated() != null ? m.getIsCalculated() : 0); + add.setIsChart(m.getIsChart() != null ? m.getIsChart() : 0); + add.setIsHistory(m.getIsHistory() != null ? m.getIsHistory() : 1); + add.setIsMonitor(m.getIsMonitor() != null ? m.getIsMonitor() : 0); + add.setIsReadonly(m.getIsReadonly() != null ? m.getIsReadonly() : 1); + add.setDeviceId(0L); + add.setDelFlag("0"); + if (SecurityUtils.getLoginUser() != null && SecurityUtils.getLoginUser().getUser() != null) { + if (SecurityUtils.getLoginUser().getUser().getDept() != null) { + add.setTenantId(SecurityUtils.getLoginUser().getUser().getDept().getDeptUserId()); + add.setTenantName(SecurityUtils.getLoginUser().getUser().getDept().getDeptUserName()); + } + } + thingsModelService.insertThingsModel(add); + existingIds.add(add.getIdentifier()); + count++; + } + if (count > 0) { + itslCache.setCacheThingsModelByProductId(gwProductId); + } + return count; + } + + @Override + public List deviceHistoryCalculated(DeviceHistoryParam deviceHistoryParam) { + if (deviceHistoryParam.getDeviceId() == null || StringUtils.isEmpty(deviceHistoryParam.getSerialNumber())) { + return Collections.emptyList(); + } + Device device = deviceService.selectDeviceByDeviceId(deviceHistoryParam.getDeviceId()); + if (device == null) { + return Collections.emptyList(); + } + List identifierVOList = deviceHistoryParam.getIdentifierList(); + if (CollectionUtils.isEmpty(identifierVOList)) { + return Collections.emptyList(); + } + ThingsModel queryModel = new ThingsModel(); + queryModel.setProductId(device.getProductId()); + queryModel.setIsCalculated(1); + List allCalculated = thingsModelService.selectThingsModelList(queryModel); + long gwDeviceId = device.getDeviceId(); + allCalculated = allCalculated.stream() + .filter(m -> m.getDeviceId() == null || m.getDeviceId() == 0 + || m.getDeviceId().longValue() == gwDeviceId) + .collect(Collectors.toList()); + Set calculatedIdentifiers = allCalculated.stream().map(ThingsModel::getIdentifier) + .collect(Collectors.toSet()); + List requestedIds = identifierVOList.stream().map(DeviceHistoryParam.IdentifierVO::getIdentifier) + .collect(Collectors.toList()); + requestedIds = requestedIds.stream().filter(calculatedIdentifiers::contains).collect(Collectors.toList()); + if (requestedIds.isEmpty()) { + return Collections.emptyList(); + } + SubGateway subQ = new SubGateway(); + subQ.setGwDeviceId(device.getDeviceId()); + List subDevices = subGatewayService.selectGatewayList(subQ); + // String gwSn = device.getSerialNumber(); + List computedHistory = new ArrayList<>(); + for (String calcIdentifier : requestedIds) { + ThingsModel model = allCalculated.stream().filter(m -> m.getIdentifier().equals(calcIdentifier)).findFirst() + .orElse(null); + if (model == null || StringUtils.isEmpty(model.getFormula())) { + continue; + } + Map refs = parseCrossDeviceRefs(model.getFormula()); + if (refs.isEmpty()) { + continue; + } + Map> rawByRef = new LinkedHashMap<>(); + Set minutesWithData = new TreeSet<>(); + for (Map.Entry e : refs.entrySet()) { + String key = e.getKey(); + String ident = e.getValue(); + int slaveId = Integer.parseInt(key.split("\\.")[0].replace("slaveId:", "")); + String subSn = subDevices.stream().filter(s -> s.getSlaveId() != null && s.getSlaveId() == slaveId) + .findFirst().map(SubDeviceListVO::getSubDeviceNo).orElse(null); + if (subSn == null) { + continue; + } + DeviceLog dl = new DeviceLog(); + dl.setSerialNumber(subSn); + dl.setIdentityList(Collections.singletonList(ident)); + dl.setBeginTime(deviceHistoryParam.getBeginTime()); + dl.setEndTime(deviceHistoryParam.getEndTime()); + List raw = deviceLogService.listHistory(dl); + rawByRef.put(key, raw); + for (HistoryModel h : raw) { + if (h.getTime() != null) { + long minuteMs = (h.getTime().getTime() / 60_000L) * 60_000L; + minutesWithData.add(minuteMs); + } + } + } + if (minutesWithData.isEmpty()) { + continue; + } + List minuteSlotsOnlyWithData = new ArrayList<>(); + for (Long ms : minutesWithData) { + minuteSlotsOnlyWithData.add(new Date(ms)); + } + Map> alignedByRef = new LinkedHashMap<>(); + for (Map.Entry> entry : rawByRef.entrySet()) { + LinkedHashMap aligned = alignByMinuteWithCarryForward(entry.getValue(), + minuteSlotsOnlyWithData); + alignedByRef.put(entry.getKey(), aligned); + } + for (Date minute : minuteSlotsOnlyWithData) { + Map replaceMap = new HashMap<>(); + for (Map.Entry> entry : alignedByRef.entrySet()) { + String val = entry.getValue().get(minute); + replaceMap.put("{" + entry.getKey() + "}", val != null ? val : "0"); + } + try { + String computed = CaculateUtils.execute(model.getFormula(), replaceMap).toString(); + HistoryModel hm = new HistoryModel(); + hm.setTime(minute); + hm.setIdentity(calcIdentifier); + hm.setValue(computed); + computedHistory.add(hm); + } catch (Exception ignored) { + } + } + } + computedHistory.sort(Comparator.comparing(HistoryModel::getTime)); + return handleData(requestedIds, computedHistory); + } + + /** 多设备对比列名分隔符:设备标签:::变量标识,用于多变量时前端解析展示名 */ + private static final String SUB_COMPARE_COL_SEP = ":::"; + + @Override + public List subDeviceComparativeHistory(SubDeviceCompareParam param) { + List identifiers = resolveCompareIdentifiers(param); + if (param.getGwDeviceId() == null || identifiers.isEmpty() || param.getType() == null + || param.getType() != 1) { + return Collections.emptyList(); + } + Device gw = deviceService.selectDeviceByDeviceId(param.getGwDeviceId()); + if (gw == null) { + return Collections.emptyList(); + } + SubGateway subQ = new SubGateway(); + subQ.setGwDeviceId(param.getGwDeviceId()); + List subList = subGatewayService.selectGatewayList(subQ); + if (CollectionUtils.isEmpty(subList)) { + return Collections.emptyList(); + } + if (param.getSubDeviceIds() != null && !param.getSubDeviceIds().isEmpty()) { + subList = subList.stream() + .filter(s -> param.getSubDeviceIds().contains(s.getSubDeviceId())) + .collect(Collectors.toList()); + if (subList.isEmpty()) { + return Collections.emptyList(); + } + } + String gwSn = gw.getSerialNumber(); + Set minutesWithData = new TreeSet<>(); + Map> rawByDevice = new LinkedHashMap<>(); + for (SubDeviceListVO sub : subList) { + String subSn = sub.getSubDeviceNo() != null ? sub.getSubDeviceNo() : (gwSn + "_" + sub.getSlaveId()); + String label = (sub.getSubDeviceName() != null ? sub.getSubDeviceName() : ("从机" + sub.getSlaveId())) + "(从机" + + sub.getSlaveId() + ")"; + DeviceLog dl = new DeviceLog(); + dl.setSerialNumber(subSn); + dl.setIdentityList(identifiers); + dl.setBeginTime(param.getBeginTime()); + dl.setEndTime(param.getEndTime()); + List raw = deviceLogService.listHistory(dl); + rawByDevice.put(label, raw); + for (HistoryModel h : raw) { + if (h.getTime() != null) { + long minuteMs = (h.getTime().getTime() / 60_000L) * 60_000L; + minutesWithData.add(minuteMs); + } + } + } + List minuteSlotsOnlyWithData = new ArrayList<>(); + for (Long ms : minutesWithData) { + minuteSlotsOnlyWithData.add(new Date(ms)); + } + boolean singleVar = (identifiers.size() == 1); + Map> deviceAligned = new LinkedHashMap<>(); + for (Map.Entry> e : rawByDevice.entrySet()) { + String label = e.getKey(); + List raw = e.getValue(); + if (singleVar) { + LinkedHashMap aligned = alignByMinuteWithCarryForward(raw, minuteSlotsOnlyWithData); + deviceAligned.put(label, aligned); + } else { + Map> byIdentity = raw.stream() + .filter(h -> h.getIdentity() != null) + .collect(Collectors.groupingBy(HistoryModel::getIdentity)); + for (Map.Entry> entry : byIdentity.entrySet()) { + LinkedHashMap aligned = alignByMinuteWithCarryForward(entry.getValue(), + minuteSlotsOnlyWithData); + deviceAligned.put(label + SUB_COMPARE_COL_SEP + entry.getKey(), aligned); + } + } + } + List result = new ArrayList<>(); + for (Date minute : minuteSlotsOnlyWithData) { + JSONObject row = new JSONObject(); + row.put("time", DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, minute)); + for (Map.Entry> e : deviceAligned.entrySet()) { + row.put(e.getKey(), e.getValue().get(minute)); + } + result.add(row); + } + return result; + } + + private List resolveCompareIdentifiers(SubDeviceCompareParam param) { + if (param.getIdentifierList() != null && !param.getIdentifierList().isEmpty()) { + return param.getIdentifierList(); + } + if (param.getIdentifier() != null && !param.getIdentifier().isEmpty()) { + return Collections.singletonList(param.getIdentifier()); + } + return Collections.emptyList(); + } + + @Override + public List listGatewayCalculatedModels(Long gwDeviceId) { + Device device = deviceService.selectDeviceByDeviceId(gwDeviceId); + if (device == null) { + return new ArrayList<>(); + } + ThingsModel queryModel = new ThingsModel(); + queryModel.setProductId(device.getProductId()); + queryModel.setLanguage(SecurityUtils.getLanguage()); + queryModel.setIsCalculated(1); + List list = thingsModelService.selectThingsModelList(queryModel); + long gwId = device.getDeviceId(); + list = list.stream() + .filter(m -> m.getDeviceId() == null || m.getDeviceId() == 0 || m.getDeviceId().longValue() == gwId) + .collect(Collectors.toList()); + List result = new ArrayList<>(); + for (ThingsModel m : list) { + ThingsModelDTO dto = new ThingsModelDTO(); + dto.setModelId(m.getModelId()); + dto.setModelName(m.getModelName()); + dto.setIdentifier(m.getIdentifier()); + dto.setType(m.getType()); + dto.setProductId(m.getProductId()); + dto.setSerialNumber(device.getSerialNumber()); + result.add(dto); + } + return result; + } + + private Map parseCrossDeviceRefs(String formula) { + Map refs = new LinkedHashMap<>(); + Matcher m = CROSS_DEVICE_REF.matcher(formula); + while (m.find()) { + String key = "slaveId:" + m.group(1) + "." + m.group(2); + refs.put(key, m.group(2)); + } + return refs; + } + + private List buildMinuteSlots(String beginTime, String endTime) { + Date start = DateUtils.parseDate(beginTime); + Date end = DateUtils.parseDate(endTime); + if (start == null || end == null || !start.before(end)) { + return new ArrayList<>(); + } + long step = 60_000L; + long startMs = (start.getTime() / step) * step; + long endMs = (end.getTime() / step) * step; + List slots = new ArrayList<>(); + for (long t = startMs; t <= endMs; t += step) { + slots.add(new Date(t)); + } + return slots; + } + + private LinkedHashMap alignByMinuteWithCarryForward(List raw, List minuteSlots) { + if (minuteSlots.isEmpty()) { + return new LinkedHashMap<>(); + } + TreeMap byMinute = new TreeMap<>(); + for (HistoryModel h : raw) { + if (h.getTime() == null || h.getValue() == null) + continue; + long minuteMs = (h.getTime().getTime() / 60_000L) * 60_000L; + byMinute.put(minuteMs, h.getValue()); + } + LinkedHashMap result = new LinkedHashMap<>(); + String last = null; + for (Date slot : minuteSlots) { + long slotMs = (slot.getTime() / 60_000L) * 60_000L; + Map.Entry floor = byMinute.floorEntry(slotMs); + if (floor != null && floor.getKey() == slotMs) { + last = floor.getValue(); + } + result.put(slot, last); + } + return result; + } } diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java index 1614b353..a7cf5c15 100644 --- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java @@ -1089,6 +1089,7 @@ public class DeviceServiceImpl implements IDeviceService { SysUser sysUser = getLoginUser().getUser(); // 添加设备 device.setCreateTime(DateUtils.getNowDate()); + device.setUserId(sysUser.getUserId()); device.setTenantId(sysUser.getDept().getDeptUserId()); device.setTenantName(sysUser.getDept().getDeptUserName()); device.setRssi(0); @@ -1096,9 +1097,9 @@ public class DeviceServiceImpl implements IDeviceService { Product product = productService.selectProductByProductId(device.getProductId()); device.setImgUrl(product.getImgUrl()); // 随机经纬度和地址 - SysUser user = getLoginUser().getUser(); - device.setNetworkIp(user.getLoginIp()); - setLocation(user.getLoginIp(), device); + //SysUser user = getLoginUser().getUser(); + device.setNetworkIp(sysUser.getLoginIp()); + setLocation(sysUser.getLoginIp(), device); deviceMapper.insertDevice(device); // 处理modbus产品 if (FastBeeConstant.PROTOCOL.ModbusRtu.equals(product.getProtocolCode()) diff --git a/yack-web/src/api/data/center.js b/yack-web/src/api/data/center.js index b9b3fd7c..1e80e3b7 100644 --- a/yack-web/src/api/data/center.js +++ b/yack-web/src/api/data/center.js @@ -54,3 +54,68 @@ export function exportDeviceHistory(data) { responseType: 'blob', }); } + +// 网关产品-可导入的子产品物模型列表 +export function getSubProductThingsModel(gwProductId) { + return request({ + url: '/data/center/subProductThingsModel', + method: 'get', + params: { gwProductId }, + }); +} + +// 网关产品-一键导入子产品物模型 +export function importSubProductThingsToGateway(gwProductId) { + return request({ + url: '/data/center/importSubProductThingsToGateway', + method: 'post', + data: { gwProductId }, + }); +} + +// 网关设备仅计算物模型列表 +export function getGatewayCalculatedModels(deviceId) { + return request({ + url: '/data/center/gatewayCalculatedModels', + method: 'get', + params: { deviceId }, + }); +} + +// 网关计算物模型历史(查询时计算,按分钟对齐) +export function getDeviceHistoryCalculated(data) { + return request({ + url: '/data/center/deviceHistoryCalculated', + method: 'post', + data, + }); +} + +// 导出网关计算物模型历史Excel +export function exportDeviceHistoryCalculated(data) { + return request({ + url: '/data/center/deviceHistoryCalculated/export', + method: 'post', + data, + responseType: 'blob', + }); +} + +// 多设备同一物理量对比历史 +export function getSubDeviceComparativeHistory(data) { + return request({ + url: '/data/center/subDeviceComparativeHistory', + method: 'post', + data, + }); +} + +// 导出多设备对比历史Excel +export function exportSubDeviceComparativeHistory(data) { + return request({ + url: '/data/center/subDeviceComparativeHistory/export', + method: 'post', + data, + responseType: 'blob', + }); +} diff --git a/yack-web/src/lang/en-US/dataCenter.json b/yack-web/src/lang/en-US/dataCenter.json index d5b35dd2..1ce86348 100644 --- a/yack-web/src/lang/en-US/dataCenter.json +++ b/yack-web/src/lang/en-US/dataCenter.json @@ -45,5 +45,8 @@ "dataCenter.history.384934-21": "In the past seven days", "dataCenter.history.384934-22": "Last 30 days", "dataCenter.history.384934-23": "total", - "dataCenter.history.384934-24": "Export Excel" + "dataCenter.history.384934-24": "Export Excel", + "dataCenter.history.gwCalculated": "Gateway Calculated", + "dataCenter.history.subCompare": "Sub-Device Compare", + "dataCenter.history.gateway": "Gateway Device" } diff --git a/yack-web/src/lang/zh-CN/dataCenter.json b/yack-web/src/lang/zh-CN/dataCenter.json index 40091ca4..b11be498 100644 --- a/yack-web/src/lang/zh-CN/dataCenter.json +++ b/yack-web/src/lang/zh-CN/dataCenter.json @@ -45,5 +45,8 @@ "dataCenter.history.384934-21": "最近七天", "dataCenter.history.384934-22": "最近三十天", "dataCenter.history.384934-23": "总量", - "dataCenter.history.384934-24": "导出Excel" + "dataCenter.history.384934-24": "导出Excel", + "dataCenter.history.gwCalculated": "网关计算量", + "dataCenter.history.subCompare": "多设备对比", + "dataCenter.history.gateway": "网关设备" } diff --git a/yack-web/src/views/dataCenter/history.vue b/yack-web/src/views/dataCenter/history.vue index 55808aeb..814babb2 100644 --- a/yack-web/src/views/dataCenter/history.vue +++ b/yack-web/src/views/dataCenter/history.vue @@ -7,7 +7,7 @@ - + @@ -70,6 +70,95 @@ + +
+ + + + + + + + + + + {{ gwGwIsAllSelected ? '全部反选' : '全部勾选' }} + + + + + + + + + + + + + + + + + + + + 查询 + 重置 + 导出Excel + + + +
{{ gwResultTitle }}
+ + + + + + + +
+
+
@@ -155,7 +244,16 @@ import moment from 'moment'; import { listDeviceShort, listThingsModel } from '@/api/iot/device.js'; import { listGateway } from '@/api/iot/gateway.js'; import { getSceneModelList, getSceneModelDetail, getSceneModelDataList } from '@/api/scene/list.js'; -import { getDataCenterDeviceHistory, getDataCenterSceneHistory, exportDeviceHistory } from '@/api/data/center.js'; +import { + getDataCenterDeviceHistory, + getDataCenterSceneHistory, + exportDeviceHistory, + getGatewayCalculatedModels, + getDeviceHistoryCalculated, + exportDeviceHistoryCalculated, + getSubDeviceComparativeHistory, + exportSubDeviceComparativeHistory, +} from '@/api/data/center.js'; import { saveAs } from 'file-saver'; import { blobValidate } from '@/utils/ruoyi'; @@ -246,8 +344,40 @@ export default { scenePageNum: 1, scenePageSize: 10, sceneTotal: 0, - // 导出loading exportLoading: false, + // 网关设备 tab 统一 4 步(物模型支持多选) + gwQueryParams: { + gwDeviceId: null, + identifiers: [], + /** 子设备:null=全部,[id]=单设备,[id1,id2,...]=多设备对比指定 */ + subDeviceIds: null, + dayDaterange: [new Date(Date.now() - 3600 * 1000 * 2), new Date()], + }, + gwSubDeviceList: [], + gwCalcIdentifierList: [], + subCompareIdentifierList: [], + gwResultMode: '', // 'calc' | 'compare' | 'single' + // 计算量结果(复用原结构) + gwCalcDatas: [], + gwCalcTableHeaderTemp: [], + gwCalcTableComTemp: [], + gwCalcTotal: 0, + gwCalcPageNum: 1, + gwCalcPageSize: 10, + // 多设备对比结果 + subCompareDatas: [], + subCompareTableColumns: [], + subCompareTableComTemp: [], + subCompareTotal: 0, + subComparePageNum: 1, + subComparePageSize: 10, + // 单子设备历史结果 + gwSingleDatas: [], + gwSingleTableHeaderTemp: [], + gwSingleTableComTemp: [], + gwSingleTotal: 0, + gwSinglePageNum: 1, + gwSinglePageSize: 10, }; }, mounted() { @@ -301,6 +431,107 @@ export default { return allIdentifiers.length > 0 && allIdentifiers.every(id => this.sceneQueryParams.identifiers.includes(id)); }, + /** 设备 tab 仅展示非网关设备 */ + devDeviceListNonGw() { + return (this.devDeviceList || []).filter((d) => d.deviceType !== 2 && d.deviceType !== 4); + }, + /** 网关设备 tab 仅展示网关设备 */ + gwDeviceList() { + return (this.devDeviceList || []).filter((d) => d.deviceType === 2); + }, + gwCalcTableList() { + const start = (this.gwCalcPageNum - 1) * this.gwCalcPageSize; + return (this.gwCalcTableComTemp || []).slice(start, start + this.gwCalcPageSize); + }, + subCompareTableList() { + const start = (this.subComparePageNum - 1) * this.subComparePageSize; + return (this.subCompareTableComTemp || []).slice(start, start + this.subComparePageSize); + }, + // 网关统一:计算量变量列表(用于下拉分组) + gwVariableListCalc() { + return this.gwCalcIdentifierList || []; + }, + // 计算量按 identifier 去重,避免重复 key + gwVariableListCalcUnique() { + const list = this.gwCalcIdentifierList || []; + const seen = new Set(); + return list.filter((i) => { + if (seen.has(i.identifier)) return false; + seen.add(i.identifier); + return true; + }); + }, + // 网关统一:子设备变量列表 + gwVariableListSub() { + return this.subCompareIdentifierList || []; + }, + // 子设备变量按 identifier 去重,且排除计算量(跨设备计算量不展示在子设备变量中) + gwVariableListSubUnique() { + const list = this.subCompareIdentifierList || []; + const calcSet = new Set((this.gwCalcIdentifierList || []).map((i) => i.identifier)); + const seen = new Set(); + return list.filter((i) => { + if (seen.has(i.identifier) || calcSet.has(i.identifier)) return false; + seen.add(i.identifier); + return true; + }); + }, + // 当前选中的物模型是否全部为计算量(计算量时不展示子设备选择) + gwSelectedIsCalculated() { + const ids = this.gwQueryParams.identifiers || []; + if (ids.length === 0) return false; + const calcSet = new Set((this.gwCalcIdentifierList || []).map((i) => i.identifier)); + return ids.every((id) => calcSet.has(id)); + }, + // 选中的计算量 identifier 列表 + gwSelectedCalcIdentifiers() { + const ids = this.gwQueryParams.identifiers || []; + const calcSet = new Set((this.gwCalcIdentifierList || []).map((i) => i.identifier)); + return ids.filter((id) => calcSet.has(id)); + }, + // 选中的子设备变量 identifier 列表(排除同时属于计算量的,避免仅选计算量时报混合选择) + gwSelectedSubIdentifiers() { + const ids = this.gwQueryParams.identifiers || []; + const subSet = new Set((this.subCompareIdentifierList || []).map((i) => i.identifier)); + const calcSet = new Set((this.gwCalcIdentifierList || []).map((i) => i.identifier)); + return ids.filter((id) => subSet.has(id) && !calcSet.has(id)); + }, + // 网关物模型“全部勾选”仅针对子设备变量(不包含计算量) + gwGwIsAllSelected() { + const ids = this.gwQueryParams.identifiers || []; + const allSub = this.gwVariableListSubUnique.map((i) => i.identifier); + return allSub.length > 0 && allSub.every((id) => ids.includes(id)); + }, + gwShowSubDeviceSelect() { + return (this.gwQueryParams.identifiers || []).length > 0 && !this.gwSelectedIsCalculated; + }, + /** 子设备多选绑定:null 显示为“全部”,选具体设备时去掉“全部” */ + gwSubDeviceSelectValue: { + get() { + const ids = this.gwQueryParams.subDeviceIds; + return ids === null || (Array.isArray(ids) && ids.length === 0) ? ['__all__'] : ids; + }, + set(val) { + const ids = (val || []).filter((v) => v !== '__all__'); + this.gwQueryParams.subDeviceIds = ids.length === 0 ? null : ids; + }, + }, + gwResultTitle() { + if (this.gwResultMode === 'calc') return '曲线趋势图(计算量)'; + if (this.gwResultMode === 'compare') return '多设备同一变量对比(仅显示有数据的时间点)'; + if (this.gwResultMode === 'single') return '单子设备历史'; + return '曲线趋势图'; + }, + gwHasResult() { + if (this.gwResultMode === 'calc') return this.gwCalcDatas.length > 0; + if (this.gwResultMode === 'compare') return this.subCompareDatas.length > 0; + if (this.gwResultMode === 'single') return this.gwSingleDatas.length > 0; + return false; + }, + gwSingleTableList() { + const start = (this.gwSinglePageNum - 1) * this.gwSinglePageSize; + return (this.gwSingleTableComTemp || []).slice(start, start + this.gwSinglePageSize); + }, }, methods: { // 获取设备列表 @@ -317,18 +548,13 @@ export default { } }); }, - // 选择设备后 + // 选择设备后(设备 tab 仅非网关,直接拉取物模型) handleDevDeviceChange(val) { this.devQueryParams.identifiers = []; this.devQueryParams.subDeviceId = null; this.devSubDeviceList = []; this.devIdentifierList = []; - const device = this.devDeviceList.find((item) => item.deviceId === val); - if (device && device.deviceType === 2) { - this.getDevSubDeviceList(val); - } else { - this.getDevIdentifierList(val); - } + if (val) this.getDevIdentifierList(val); }, // 选择子设备后 handleDevSubDeviceChange(val) { @@ -647,6 +873,434 @@ export default { }; }); }, + // 网关统一:选网关后拉取计算量变量、子设备变量、子设备列表 + handleGwDeviceChange(val) { + this.gwQueryParams.identifiers = []; + this.gwQueryParams.subDeviceIds = null; + this.gwCalcIdentifierList = []; + this.subCompareIdentifierList = []; + this.gwSubDeviceList = []; + this.gwResultMode = ''; + if (!val) return; + Promise.all([ + getGatewayCalculatedModels(val).then((res) => (res.code === 200 ? res.data || [] : [])), + listThingsModel({ deviceId: val, pageNum: 1, pageSize: 9999 }).then((res) => { + if (res.code === 200) { + const rows = res.rows || []; + return rows.filter((r) => r.type === 1); + } + return []; + }), + listGateway({ gwDeviceId: val, pageNum: 1, pageSize: 9999 }).then((res) => (res.code === 200 ? res.rows || [] : [])), + ]).then(([calcList, subList, subDevices]) => { + this.gwCalcIdentifierList = calcList; + this.subCompareIdentifierList = subList; + this.gwSubDeviceList = subDevices; + }); + }, + handleGwIdentifierChange(val) { + const raw = (val || []).filter((v) => v !== '__selectAll__'); + const index = (val || []).indexOf('__selectAll__'); + if (index > -1) { + // 全部勾选/反选:仅作用于子设备变量,不勾选计算量 + const allSub = this.gwVariableListSubUnique.map((i) => i.identifier); + this.$nextTick(() => { + this.gwQueryParams.identifiers = this.gwGwIsAllSelected ? [] : [...allSub]; + }); + return; + } + // 计算量与子设备变量均支持多选,保留用户选择的全部项(混合选择在查询时校验) + this.gwQueryParams.identifiers = raw; + if (this.gwSelectedIsCalculated) this.gwQueryParams.subDeviceIds = null; + }, + handleGwQuery() { + const { gwDeviceId, identifiers, subDeviceIds, dayDaterange } = this.gwQueryParams; + if (!gwDeviceId || !identifiers || identifiers.length === 0 || !dayDaterange || !dayDaterange.length) { + this.$message.warning('请选择网关设备、物模型和时间范围'); + return; + } + const calcIds = this.gwSelectedCalcIdentifiers; + const subIds = this.gwSelectedSubIdentifiers; + if (calcIds.length > 0 && subIds.length > 0) { + this.$message.warning('不能混合选择计算量与子设备变量,请只选其中一类'); + return; + } + const device = this.devDeviceList.find((d) => d.deviceId === gwDeviceId); + if (!device) return; + const isCalc = this.gwSelectedIsCalculated; + const singleDeviceId = Array.isArray(subDeviceIds) && subDeviceIds.length === 1 ? subDeviceIds[0] : null; + if (!isCalc && this.gwSubDeviceList.length > 0 && singleDeviceId) { + // 单子设备历史(多变量) + const sub = this.gwSubDeviceList.find((s) => s.subDeviceId === singleDeviceId); + if (!sub) { + this.$message.warning('请选择子设备'); + return; + } + this.loading = true; + this.gwResultMode = 'single'; + const identifierList = subIds.map((id) => { + const item = this.subCompareIdentifierList.find((i) => i.identifier === id) || {}; + return { identifier: id, type: item.type || 1 }; + }); + const params = { + deviceId: sub.subDeviceId, + serialNumber: sub.subDeviceNo, + identifierList, + beginTime: moment(dayDaterange[0]).format('YYYY-MM-DD HH:mm:ss'), + endTime: moment(dayDaterange[1]).format('YYYY-MM-DD HH:mm:ss'), + }; + getDataCenterDeviceHistory(params).then((res) => { + if (res.code === 200) { + this.gwSingleDatas = res.data || []; + this.formatGwSingleTableDatas(); + this.$nextTick(() => { + if (this.gwSingleDatas.length) this.drawGwSingleLine(); + }); + } + this.loading = false; + }); + return; + } + if (!isCalc) { + // 多设备对比(支持多变量、指定子设备或全部) + this.loading = true; + this.gwResultMode = 'compare'; + const params = { + gwDeviceId, + identifierList: subIds, + type: 1, + beginTime: moment(dayDaterange[0]).format('YYYY-MM-DD HH:mm:ss'), + endTime: moment(dayDaterange[1]).format('YYYY-MM-DD HH:mm:ss'), + }; + if (Array.isArray(subDeviceIds) && subDeviceIds.length > 0) { + params.subDeviceIds = subDeviceIds; + } + getSubDeviceComparativeHistory(params).then((res) => { + if (res.code === 200) { + this.subCompareDatas = res.data || []; + this.formatSubCompareTableDatas(); + this.$nextTick(() => { + if (this.subCompareDatas.length) this.drawSubCompareLine(); + }); + } + this.loading = false; + }); + return; + } + // 计算量(多变量) + this.loading = true; + this.gwResultMode = 'calc'; + const identifierList = calcIds.map((id) => { + const item = this.gwCalcIdentifierList.find((i) => i.identifier === id) || {}; + return { identifier: id, type: item.type || 1 }; + }); + const calcParams = { + deviceId: device.deviceId, + serialNumber: device.serialNumber, + identifierList, + beginTime: moment(dayDaterange[0]).format('YYYY-MM-DD HH:mm:ss'), + endTime: moment(dayDaterange[1]).format('YYYY-MM-DD HH:mm:ss'), + }; + getDeviceHistoryCalculated(calcParams).then((res) => { + if (res.code === 200) { + this.gwCalcDatas = res.data || []; + this.gwCalcTableHeaderTemp = calcIds.map((id) => { + const m = this.gwCalcIdentifierList.find((i) => i.identifier === id); + return { name: (m && m.modelName) || id, value: id }; + }); + this.formatGwCalcTableDatas(); + this.$nextTick(() => { + if (this.gwCalcDatas.length) this.drawGwCalcLine(); + }); + } + this.loading = false; + }); + }, + handleGwReset() { + this.gwQueryParams.gwDeviceId = null; + this.gwQueryParams.identifiers = []; + this.gwQueryParams.subDeviceIds = null; + this.gwQueryParams.dayDaterange = [new Date(Date.now() - 3600 * 1000 * 2), new Date()]; + this.gwCalcIdentifierList = []; + this.subCompareIdentifierList = []; + this.gwSubDeviceList = []; + this.gwResultMode = ''; + this.gwCalcDatas = []; + this.gwCalcTableComTemp = []; + this.gwCalcTotal = 0; + this.subCompareDatas = []; + this.subCompareTableComTemp = []; + this.subCompareTotal = 0; + this.gwSingleDatas = []; + this.gwSingleTableComTemp = []; + this.gwSingleTotal = 0; + }, + async handleGwExport() { + const { gwDeviceId, identifiers, subDeviceIds, dayDaterange } = this.gwQueryParams; + if (!gwDeviceId || !identifiers || identifiers.length === 0 || !dayDaterange?.length) { + this.$message.warning('请先选择网关、物模型和时间范围'); + return; + } + const device = this.devDeviceList.find((d) => d.deviceId === gwDeviceId); + if (!device) return; + const calcIds = this.gwSelectedCalcIdentifiers; + const subIds = this.gwSelectedSubIdentifiers; + if (calcIds.length > 0 && subIds.length > 0) { + this.$message.warning('不能混合选择计算量与子设备变量'); + return; + } + try { + this.exportLoading = true; + const beginTime = moment(dayDaterange[0]).format('YYYY-MM-DD HH:mm:ss'); + const endTime = moment(dayDaterange[1]).format('YYYY-MM-DD HH:mm:ss'); + const singleId = Array.isArray(subDeviceIds) && subDeviceIds.length === 1 ? subDeviceIds[0] : null; + if (this.gwResultMode === 'single' && singleId) { + const sub = this.gwSubDeviceList.find((s) => s.subDeviceId === singleId); + if (!sub || this.gwSingleDatas.length === 0) { + this.$message.warning('没有数据可导出'); + return; + } + const identifierList = subIds.map((id) => { + const item = this.subCompareIdentifierList.find((i) => i.identifier === id) || {}; + return { identifier: id, type: item.type || 1 }; + }); + const params = { deviceId: sub.subDeviceId, serialNumber: sub.subDeviceNo, identifierList, beginTime, endTime }; + const res = await exportDeviceHistory(params); + if (res instanceof Blob) { + const clonedBlob = res.slice(); + const isValidBlob = await blobValidate(clonedBlob); + if (!isValidBlob) { + try { + const text = await res.text(); + const err = JSON.parse(text); + this.$message.error(err.msg || '导出失败'); + return; + } catch (e) { + this.$message.error('导出失败'); + return; + } + } + saveAs(res, `${sub.subDeviceNo}_${subIds.join('-')}_${moment().format('YYYYMMDDHHmmss')}.xlsx`); + this.$message.success('导出成功'); + } + return; + } + if (this.gwResultMode === 'compare' && this.subCompareDatas.length > 0) { + const params = { gwDeviceId, identifierList: subIds, type: 1, beginTime, endTime }; + if (Array.isArray(subDeviceIds) && subDeviceIds.length > 0) params.subDeviceIds = subDeviceIds; + const res = await exportSubDeviceComparativeHistory(params); + if (res instanceof Blob) { + const clonedBlob = res.slice(); + const isValidBlob = await blobValidate(clonedBlob); + if (!isValidBlob) { + try { + const text = await res.text(); + const err = JSON.parse(text); + this.$message.error(err.msg || '导出失败'); + return; + } catch (e) { + this.$message.error('导出失败'); + return; + } + } + const idPart = subIds.length ? subIds.join('-') : 'compare'; + saveAs(res, `sub_compare_${idPart}_${moment().format('YYYYMMDDHHmmss')}.xlsx`); + this.$message.success('导出成功'); + } + return; + } + if (this.gwResultMode === 'calc' && this.gwCalcDatas.length > 0) { + const identifierList = calcIds.map((id) => { + const item = this.gwCalcIdentifierList.find((i) => i.identifier === id) || {}; + return { identifier: id, type: item.type || 1 }; + }); + const params = { deviceId: device.deviceId, serialNumber: device.serialNumber, identifierList, beginTime, endTime }; + const res = await exportDeviceHistoryCalculated(params); + if (res instanceof Blob) { + const clonedBlob = res.slice(); + const isValidBlob = await blobValidate(clonedBlob); + if (!isValidBlob) { + try { + const text = await res.text(); + const err = JSON.parse(text); + this.$message.error(err.msg || '导出失败'); + return; + } catch (e) { + this.$message.error('导出失败'); + return; + } + } + saveAs(res, `${device.serialNumber}_calculated_${moment().format('YYYYMMDDHHmmss')}.xlsx`); + this.$message.success('导出成功'); + } + return; + } + this.$message.warning('没有数据可导出'); + } catch (e) { + this.$message.error(e.message || '导出失败'); + } finally { + this.exportLoading = false; + } + }, + getGwCalcTableList(e) { + if (e) { + this.gwCalcPageNum = e.page; + this.gwCalcPageSize = e.limit; + } + }, + formatGwCalcTableDatas() { + this.gwCalcTableComTemp = (this.gwCalcDatas || []).map((item) => { + const time = Object.keys(item)[0]; + const vals = Object.values(item)[0] || []; + const obj = { time }; + vals.forEach((v) => { + const k = Object.keys(v)[0]; + obj[k] = Object.values(v)[0]; + }); + return obj; + }); + this.gwCalcTotal = this.gwCalcTableComTemp.length; + const ids = this.gwSelectedCalcIdentifiers; + this.gwCalcTableHeaderTemp = ids.map((id) => { + const m = this.gwCalcIdentifierList.find((i) => i.identifier === id); + return { name: (m && m.modelName) || id, value: id }; + }); + }, + drawGwCalcLine() { + if (!this.$refs.gwCalcLineChart) return; + const chart = this.$echarts.init(this.$refs.gwCalcLineChart); + chart.clear(); + const ids = this.gwSelectedCalcIdentifiers; + const times = this.gwCalcDatas.map((d) => Object.keys(d)[0]); + const series = ids.map((id) => { + const m = this.gwCalcIdentifierList.find((i) => i.identifier === id); + return { + name: (m && m.modelName) || id, + type: 'line', + smooth: true, + data: this.gwCalcDatas.map((d) => { + const arr = Object.values(d)[0] || []; + const o = arr.find((x) => Object.keys(x)[0] === id); + return o ? Object.values(o)[0] : null; + }), + }; + }); + chart.setOption({ + tooltip: { trigger: 'axis' }, + legend: { data: series.map((s) => s.name) }, + grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, + xAxis: { type: 'category', data: times }, + yAxis: { type: 'value', scale: true }, + series, + }); + }, + getGwSingleTableList(e) { + if (e) { + this.gwSinglePageNum = e.page; + this.gwSinglePageSize = e.limit; + } + }, + formatGwSingleTableDatas() { + const ids = this.gwSelectedSubIdentifiers; + this.gwSingleTableComTemp = (this.gwSingleDatas || []).map((item) => { + const time = Object.keys(item)[0]; + const vals = Object.values(item)[0] || []; + const obj = { time }; + vals.forEach((v) => { + const k = Object.keys(v)[0]; + obj[k] = Object.values(v)[0]; + }); + return obj; + }); + this.gwSingleTotal = this.gwSingleTableComTemp.length; + this.gwSingleTableHeaderTemp = ids.map((id) => { + const m = this.subCompareIdentifierList.find((i) => i.identifier === id); + return { name: (m && m.modelName) || id, value: id }; + }); + }, + drawGwSingleLine() { + if (!this.$refs.gwSingleLineChart || !this.gwSingleDatas.length) return; + const chart = this.$echarts.init(this.$refs.gwSingleLineChart); + chart.clear(); + const ids = this.gwSelectedSubIdentifiers; + const times = this.gwSingleDatas.map((d) => Object.keys(d)[0]); + const series = ids.map((id) => { + const m = this.subCompareIdentifierList.find((i) => i.identifier === id); + return { + name: (m && m.modelName) || id, + type: 'line', + smooth: true, + data: this.gwSingleDatas.map((d) => { + const arr = Object.values(d)[0] || []; + const o = arr.find((x) => Object.keys(x)[0] === id); + return o ? Object.values(o)[0] : null; + }), + }; + }); + chart.setOption({ + tooltip: { trigger: 'axis' }, + legend: { data: series.map((s) => s.name) }, + grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, + xAxis: { type: 'category', data: times }, + yAxis: { type: 'value', scale: true }, + series, + }); + }, + getSubCompareTableList(e) { + if (e) { + this.subComparePageNum = e.page; + this.subComparePageSize = e.limit; + } + }, + /** 多设备对比列名分隔符,与后端一致 */ + subCompareColSep() { + return ':::'; + }, + /** 多设备对比表格/图例列展示名:后端返回 "设备标签:::identifier" 时解析为 "设备标签 - 变量名" */ + getSubCompareColumnLabel(key) { + if (key === 'time') return '更新时间'; + const sep = this.subCompareColSep(); + if (key && key.includes(sep)) { + const idx = key.indexOf(sep); + const deviceLabel = key.substring(0, idx); + const identifier = key.substring(idx + sep.length); + const model = (this.subCompareIdentifierList || []).find((i) => i.identifier === identifier); + return `${deviceLabel} - ${(model && model.modelName) || identifier}`; + } + return key; + }, + formatSubCompareTableDatas() { + this.subCompareTableComTemp = this.subCompareDatas || []; + this.subCompareTotal = this.subCompareTableComTemp.length; + const cols = []; + if (this.subCompareDatas.length) { + Object.keys(this.subCompareDatas[0]).forEach((k) => { + cols.push({ prop: k, label: this.getSubCompareColumnLabel(k) }); + }); + } + this.subCompareTableColumns = cols; + }, + drawSubCompareLine() { + if (!this.$refs.subCompareLineChart || !this.subCompareDatas.length) return; + const chart = this.$echarts.init(this.$refs.subCompareLineChart); + chart.clear(); + const times = this.subCompareDatas.map((d) => d.time); + const keys = Object.keys(this.subCompareDatas[0]).filter((k) => k !== 'time'); + const series = keys.map((k) => ({ + name: this.getSubCompareColumnLabel(k), + type: 'line', + smooth: true, + data: this.subCompareDatas.map((d) => d[k]), + })); + chart.setOption({ + tooltip: { trigger: 'axis' }, + legend: { data: series.map((s) => s.name) }, + grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, + xAxis: { type: 'category', data: times }, + yAxis: { type: 'value', scale: true }, + series, + }); + }, // 格式化设备列表数据 formatDevTableDatas() { this.devTableComTemp = this.devDatas.map((item) => { @@ -826,5 +1480,8 @@ export default { .scene-wrap { margin-top: 5px; } + .gateway-wrap { + margin-top: 5px; + } } diff --git a/yack-web/src/views/iot/device/device-things-model.vue b/yack-web/src/views/iot/device/device-things-model.vue index b5387356..663753e6 100644 --- a/yack-web/src/views/iot/device/device-things-model.vue +++ b/yack-web/src/views/iot/device/device-things-model.vue @@ -156,10 +156,27 @@ )
- +
- -
+ + +
import { listModel, getModel, addModel, updateModel, delModel } from '@/api/iot/model'; +import { listGateway } from '@/api/iot/gateway'; +import { listThingsModel } from '@/api/iot/device'; export default { name: 'DeviceThingsModel', @@ -439,8 +473,14 @@ export default { { validator: this.validateFormula, trigger: 'none' }, ], }, + gatewayVariableGroups: [], // 网关设备时按从机分组的变量,用于公式占位 { slaveId, items: [{ modelName, placeholder }] } }; }, + computed: { + isGatewayDevice() { + return this.device && this.device.deviceType === 2; + }, + }, watch: { device: { handler(val) { @@ -464,26 +504,27 @@ export default { } // 去除空格后校验 const formula = value.replace(/\s+/g, ''); - - // 1. 校验是否包含非法字符(只允许:数字、小数点、运算符、括号、大括号变量、sqrt) - // 合法字符:0-9 . + - * / % ^ ( ) { } 字母(用于变量和sqrt) - const validCharsPattern = /^[0-9a-zA-Z_+\-*/%^(){}.\s]+$/; + + // 1. 校验是否包含非法字符(含大括号变量、跨设备 slaveId:N.xxx 中的冒号) + const validCharsPattern = /^[0-9a-zA-Z_+\-*/%^(){}.:\s]+$/; if (!validCharsPattern.test(formula)) { callback(new Error('公式包含非法字符')); return; } - - // 2. 校验变量格式:{标识符},标识符以字母开头,只含字母、数字、下划线 + + // 2. 校验变量格式:{标识符} 或 {slaveId:数字.标识符}(跨设备) const variablePattern = /\{([^}]*)\}/g; let match; + const simpleVar = /^[a-zA-Z][a-zA-Z0-9_]*$/; + const crossVar = /^slaveId:\d+\.[a-zA-Z][a-zA-Z0-9_]*$/; while ((match = variablePattern.exec(formula)) !== null) { const varName = match[1]; - if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(varName)) { - callback(new Error(`变量格式错误: {${varName}},标识符需以字母开头,只含字母、数字、下划线`)); + if (!simpleVar.test(varName) && !crossVar.test(varName)) { + callback(new Error(`变量格式错误: {${varName}}。支持:{标识符} 或 {slaveId:从机号.标识符}(如 {slaveId:1.WaterH})`)); return; } } - + // 3. 校验括号是否匹配 let parenthesesCount = 0; let braceCount = 0; @@ -509,40 +550,40 @@ export default { callback(new Error('变量大括号不匹配:左右大括号数量不一致')); return; } - + // 4. 校验不能以运算符开头(除了负号和左括号) if (/^[*/%^)]/.test(formula)) { callback(new Error('公式不能以 * / % ^ ) 开头')); return; } - + // 5. 校验不能以运算符结尾(除了右括号和右大括号) if (/[+\-*/%^(]$/.test(formula)) { callback(new Error('公式不能以运算符结尾')); return; } - + // 6. 校验不能有连续的运算符(除了负号可以跟在其他运算符后面) if (/[+\-*/%^]{2,}/.test(formula.replace(/([+*/%^(])-/g, '$1NEG'))) { callback(new Error('公式中不能有连续的运算符')); return; } - + // 7. 校验sqrt后面必须跟左括号或数字或变量 if (/sqrt(?![({0-9])/.test(formula)) { callback(new Error('sqrt后面必须跟括号、数字或变量')); return; } - + // 8. 校验公式必须包含至少一个变量 - if (!/\{[a-zA-Z][a-zA-Z0-9_]*\}/.test(formula)) { - callback(new Error('计算公式必须包含至少一个物模型变量,如:{temperature}')); - return; + if (!/\{([a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)*|[a-zA-Z]+:\d+\.[a-zA-Z][a-zA-Z0-9_]*)}/.test(formula)) { + callback(new Error('计算公式必须包含至少一个物模型变量,如:{temperature} 或 {slaveId:1.temperature}')); + return; } - + callback(); }, - + /** 查询设备计算物模型列表 */ getList() { if (!this.queryParams.deviceId) return; @@ -556,10 +597,45 @@ export default { this.loading = false; }); }, - /** 加载产品物模型(用于公式变量选择) */ + /** 加载公式变量:网关设备按从机加载(用于跨设备插值),否则加载产品物模型 */ loadProductModels() { - if (!this.device.productId) return; + if (!this.device || !this.device.deviceId) return; this.loadingModels = true; + this.gatewayVariableGroups = []; + if (this.isGatewayDevice) { + listGateway({ gwDeviceId: this.device.deviceId, pageNum: 1, pageSize: 999 }) + .then((res) => { + const subs = res.rows || res.data || []; + const promises = subs.map((sub) => + listThingsModel({ deviceId: sub.subDeviceId, pageNum: 1, pageSize: 200 }).then((r) => ({ + slaveId: sub.slaveId, + subName: sub.subDeviceName || '从机' + sub.slaveId, + rows: (r.rows || []).filter((m) => m.isCalculated !== 1), + })) + ); + return Promise.all(promises); + }) + .then((groups) => { + this.gatewayVariableGroups = groups + .filter((g) => g.rows && g.rows.length) + .map((g) => ({ + slaveId: g.slaveId, + items: g.rows.map((m) => ({ + modelName: m.modelName || m.identifier, + placeholder: `{slaveId:${g.slaveId}.${m.identifier}}`, + })), + })); + this.productModels = []; + }) + .finally(() => { + this.loadingModels = false; + }); + return; + } + if (!this.device.productId) { + this.loadingModels = false; + return; + } listModel({ productId: this.device.productId, deviceId: 0, @@ -567,7 +643,6 @@ export default { pageSize: 200, }) .then((response) => { - // 过滤掉纯计算物模型(只保留设备上报的物模型) this.productModels = (response.rows || []).filter((m) => m.isCalculated !== 1); }) .finally(() => { @@ -648,9 +723,13 @@ export default { insertOperator(op) { this.insertAtCursor(op); }, - /** 插入变量(标识符使用大括号包裹) */ - insertVariable(identifier) { - this.insertAtCursor(`{${identifier}}`); + /** 插入变量:普通变量传 identifier 自动加大括号;网关跨设备变量传完整占位符如 {slaveId:1.WaterH} */ + insertVariable(identifierOrPlaceholder) { + const text = + identifierOrPlaceholder && (identifierOrPlaceholder.startsWith('{') || identifierOrPlaceholder.includes('slaveId:')) + ? identifierOrPlaceholder + : `{${identifierOrPlaceholder}}`; + this.insertAtCursor(text); }, /** 在光标位置插入内容(支持替换选中文本) */ insertAtCursor(text) { @@ -665,15 +744,15 @@ export default { this.form.formula = (this.form.formula || '') + text; return; } - + const formula = this.form.formula || ''; const start = textarea.selectionStart; const end = textarea.selectionEnd; - + // 在光标位置插入或替换选中的文本 const newFormula = formula.substring(0, start) + text + formula.substring(end); this.form.formula = newFormula; - + // 设置光标位置到插入内容之后 this.$nextTick(() => { const newCursorPos = start + text.length; diff --git a/yack-web/src/views/iot/product/product-things-model.vue b/yack-web/src/views/iot/product/product-things-model.vue index 4bd8e79f..8c909516 100644 --- a/yack-web/src/views/iot/product/product-things-model.vue +++ b/yack-web/src/views/iot/product/product-things-model.vue @@ -39,6 +39,11 @@ {{ $t('product.product-things-model.142341-1') }} + + + 从子产品导入 + + {{ $t('product.product-things-model.142341-2') }} @@ -361,6 +366,21 @@
+ + +

将已绑定子产品的物模型按 identifier 去重后导入到当前网关产品,已存在的 identifier 会跳过。

+ + + + + +
当前产品未绑定子产品或子产品下暂无物模型
+ +
+
@@ -390,6 +410,7 @@ import JsonViewer from 'vue-json-viewer'; import 'vue-json-viewer/style.css'; import thingsParameter from '../template/parameter'; import { listModel, getModel, delModel, addModel, updateModel, importModel } from '@/api/iot/model'; +import { getSubProductThingsModel, importSubProductThingsToGateway } from '@/api/data/center'; import importBatch from './components/batchImportThingsModel'; export default { @@ -454,6 +475,9 @@ export default { open: false, openSelect: false, openThingsModel: false, + openSubProductImport: false, + subProductModelList: [], + subProductImportLoading: false, // 查询参数 queryParams: { productId: 0, @@ -709,6 +733,36 @@ export default { }); } }, + /** 从子产品导入:打开弹窗并拉取可导入列表 */ + handleImportFromSubProduct() { + if (!this.productInfo || !this.productInfo.productId) { + this.$modal.msgWarning('请先选择产品'); + return; + } + this.openSubProductImport = true; + this.subProductModelList = []; + getSubProductThingsModel(this.productInfo.productId).then((res) => { + if (res.code === 200 && res.data) { + this.subProductModelList = res.data; + } + }); + }, + /** 从子产品导入:确认导入 */ + confirmSubProductImport() { + if (!this.productInfo || !this.productInfo.productId) return; + this.subProductImportLoading = true; + importSubProductThingsToGateway(this.productInfo.productId) + .then((res) => { + this.subProductImportLoading = false; + this.openSubProductImport = false; + this.$modal.msgSuccess(res.msg || '导入成功'); + this.getList(); + this.$emit('updateModel'); + }) + .catch(() => { + this.subProductImportLoading = false; + }); + }, containsUnderscore(value) { // 使用正则表达式检查值中是否包含下划线 return /_/.test(value);